首页
论坛
课程
招聘
[原创]Golang版本简易fuzzer及debugger实践
2022-1-13 17:24 6413

[原创]Golang版本简易fuzzer及debugger实践

2022-1-13 17:24
6413

代码

 

本文为Fuzzing like a caveman学习笔记,学习这个系列文章主要出于两个目的:一是为了学习fuzzing相关知识,二是为了学习go语言。因此文章中的代码使用go进行了改写。

1. 基础的go版本mutation fuzzer

1.1.fuzz目标

https://github.com/mkttanabe/exif

1.2 fuzz方法

首先选取一个jpg格式的sample文件,从这里下载的Canon_40D.jpg

 

然后分别采用bitflip和magic number替换的方式生成变形的用于测试的图片文件。其中为了保证文件的格式不变,需要排除掉文件开头的0xFFD8以及结尾的0xFFD9者四个字节

 

由于go语言以及个人编程习惯的原因,采用的方法与原文有所差别:

  1. bitflip

    该方法会随机选取所有字节中1%的字节,再随机选取其中的一位进行反转。

    运用一点数学的知识,不使用原本文章中将字符串转换为数组再转回字符串的方式。

    该函数完整代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // randomly change flipPercent of bytes in data
    // randomly flip one bit in every byte
    func bitFlip(data []byte) []byte {
        result := make([]byte, len(data))
        if nbytes := copy(result, data); nbytes == 0 {
            panic("bitFlip: Error when copying")
        }
     
        nflips := int((float64(len(data)) - 4) * flipPercent)
        var chosenIdx []int
     
        for i := 0; i < nflips; i++ {
            chosenIdx = append(chosenIdx, rand.Intn(len(data)-4)+2)
        }
     
        for _, x := range chosenIdx {
            flipIdx := rand.Intn(8)
            result[x] ^= byte(math.Pow(2, float64(flipIdx)))
        }
     
        return result
    }
  2. magic number

    该方法源自GynvaelColdwind的‘Basics of fuzzing’ stream,将字节设置为特殊的,容易引发溢出的数值。这样如果程序中正好存在针对这几个字节的数值操作,就可能会发生溢出。

    原文章的方法过于冗长,我直接把magic number做成一个二维数组,在通过随机的方式确定替换位置之后,搜索二维数组进行替换。

    该函数完整代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    // randomly overwrite 1~4 bytes in data with magic number
    func magic(data []byte) []byte {
        magicNum := [][]byte{
            {0xFF}, {0x7F}, {0x00},
            {0xFF, 0xFF}, {0x00, 0x00},
            {0xFF, 0xFF, 0xFF, 0xFF},
            {0x00, 0x00, 0x00, 0x00},
            {0x80, 0x00, 0x00, 0x00},
            {0x40, 0x00, 0x00, 0x00},
            {0x7F, 0xFF, 0xFF, 0xFF},
        }
        result := make([]byte, len(data))
        if nbytes := copy(result, data); nbytes == 0 {
            panic("magic: Error when copying")
        }
     
        chosenIdx := rand.Intn(len(data)-8) + 2
        chosenMagic := rand.Intn(len(magicNum))
     
        for _, i := range magicNum[chosenMagic] {
            result[chosenIdx] = byte(i)
            chosenIdx++
        }
     
        return result
    }

1.3 怎样抓取crash

编译生成可执行文件gcc -o exif sample_main.c exif.c,使用exec.Command执行命令并获得输出,利用panic, defer机制处理出现的错误,并将导致崩溃的图片保存在crashes目录下。

 

结果发现不管能不能成功,函数都会返回错误信息,然后所有测试图片都被保存到了crashes目录下

 

查看源码发现,在sample_main.c文件中,main函数的返回值是由createIfdTableArray函数返回的result数值决定的,大于0表示程序处理的图片正常,小于0则是发生了错误,但无论result是何值,都表示程序正常识别出了图片中可能存在的错误并执行完成。

 

可是如果返回值不为0,系统会认为程序发生错误,因此我写的代码会认为所有图片都可以导致崩溃

 

于是修改main函数,将返回值固定为0,然后再抓取出现的错误,进行1000次迭代后,找到了24个crash的图片。

 

该函数代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func exif(counter int, data []byte) {
    defer func() {
        handler := recover()
        if handler != nil {
            result := fmt.Sprintf("%v", handler)
            fmt.Println(handler)
            if strings.Contains(result, "Run cmd") {
                filename := fmt.Sprintf("crashes/crash_%d.jpg", counter)
                err := os.WriteFile(filename, data, 0644)
                checkError("Write crash file", err)
            }
        }
    }()
 
    cmd := exec.Command("./exif.exe", "./mutated.jpg", "-verbose")
 
    if _, err := cmd.CombinedOutput(); err != nil {
        panic("Run cmd" + ": " + err.Error())
    }
}

上面代码在执行的时候可以看到遇到的错误返回值都是0xc0000005

1.4 crash分析

接下来使用Address Sanitizer分析crash,需要安装llvm,然后执行

1
clang .\exif.c .\sample_main.c -o exifsan.exe -g -fsanitize=address

如果出现Macro definition of vsnprintf conflicts with Standard Library function declaration的报错,需要删除exif.c中的#define vsnprintf _vsnprintf

 

得到exifsan.exe之后,用它测试一下之前得到的crash图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PS D:\Myfiles\Code\go\src\fuzzer> .\exifsan.exe .\crashes\crash_103.jpg -verbose
system: little-endian
  data: little-endian
=================================================================
==9344==ERROR: AddressSanitizer: access-violation on unknown address 0x000000000000 (pc 0x7ff6313c6a19 bp 0x00678fbbed20 sp 0x00678fbbcb00 T0)
==9344==The signal is caused by a READ memory access.
==9344==Hint: address points to the zero page.
    #0 0x7ff6313c6a18 in parseIFD D:\Myfiles\Code\exif\exif.c:2448
    #1 0x7ff6313c2434 in createIfdTableArray D:\Myfiles\Code\exif\exif.c:333
    #2 0x7ff6313dd78e in main D:\Myfiles\Code\exif\sample_main.c:63
    #3 0x7ff631424433 in invoke_main d:\a01\_work\6\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl:78
    #4 0x7ff631424433 in __scrt_common_main_seh d:\a01\_work\6\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl:288
    #5 0x7ffd898b7033  (C:\WINDOWS\System32\KERNEL32.DLL+0x180017033)
    #6 0x7ffd8afe2650  (C:\WINDOWS\SYSTEM32\ntdll.dll+0x180052650)
 
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: access-violation D:\Myfiles\Code\exif\exif.c:2448 in parseIFD
==9344==ABORTING

确实可以得到具体的错误信息,之后我又多测试了几个文件,发现我遇到的大多都是access-violation on unknown address 0x000000000000,而不像文章中的那样有很多heap-buffer-overflow

 

因为crash信息并不多,而且考虑到之后进一步学习可能还会用到这些信息,所以我在统计的时候没有像原文中那样对信息进行提取,而是全部输出到了一个文件中,方便后续使用。如果要提取的话,可能要用到正则表达式,这部分内容我不太确定,因为我的go语言学习还没有看到interface的部分,所以对于输出的处理可能会有想不到的地方。

 

至此,第一篇文章学习结束

2. 性能优化

2.1 简述

这里我尝试把bitflip中原本存在的nflips计算放到了循环外部,最终result计算中的乘方操作使用一个mask数组代替,然后像文章中一样全部使用bitflip执行1000次循环,性能优化了2秒左右。但是即使是这样运行时间也在15s~,我看了一下原文章中的性能,100,000迭代才执行了256s!!!

 

 

—>因为要fuzz新的目标,转战到kali上之后,效果提升了10倍,变成了1.5s,执行100,000次迭代,花费时间为86s,

2.2 新的fuzz目标——exiv2

在进行fuzz之前,我在此对代码进行了一些修改:

  1. 尝试fuzz更多命令选项,使用map保存要fuzz的命令var cmds = []string{"rm", "pr", "fi", "fc", "ex"}
  2. 统计输出的错误信息,只在该错误信息第一次出现的时候打印出来

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    func exif(counter int, data []byte, ccmd string) {
        defer func() {
            handler := recover()
            if handler != nil {
                result := fmt.Sprintf("%v", handler)
                errorMessage[result]++
                if errorMessage[result] == 1 {
                    fmt.Println(ccmd, result)
                }
                if !strings.Contains(result, "255") {
                    filename := fmt.Sprintf("crashes/crash_%d.jpg", counter)
                    err := os.WriteFile(filename, data, 0644)
                    checkError("Write crash file", err)
                }
            }
        }()
     
        cmd := exec.Command("exiv2", ccmd, "-v", "./mutated.jpg")
     
        if err := cmd.Run(); err != nil {
            panic("Run cmd" + ": " + err.Error())
        }
    }

但是最终没发现什么bug

3. code-coverage的重要性

3.1 修改bitflip函数

在这个系列文章中的第四篇,作者提到了bitflip和byte overwriting的区别:

 

如果只是随机反转字节中的一位,那个一个字节可以修改的数值只有8中可能,而修改字节则可以将其修改成其他任意的255个值

 

因此重新写一个新的函数,不再采用随机反转字节中一位的方法,而是直接随机修改一个字节的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// randomly overwrite flipPercent of bytes in data
// randomly choose from [0, 256)
func byteOverwrite(data []byte) []byte {
    result := make([]byte, len(data))
    if nbytes := copy(result, data); nbytes == 0 {
        panic("byteOverwrite: Error when copying")
    }
 
    var chosenIdx []int
    for i := 0; i < nflips; i++ {
        chosenIdx = append(chosenIdx, rand.Intn(len(data)-4)+2)
    }
 
    for _, x := range chosenIdx {
        result[x] = byte(rand.Intn(256))
    }
 
    return result
}

3.2 测试用漏洞程序

第四篇文章的其余部分使用一个示例漏洞程序说明了在fuzzer中引入code-coverage的重要性,改写后的go代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package main
 
import (
    "fmt"
    "os"
    "strconv"
)
 
var checkPos = []float64{0.33, 0.5, 0.67}
var checkVal = []byte{0x6c, 0x57, 0x21}
 
func main() {
    if len(os.Args) < 3 {
        panic("usage vulfunc.exe filename ncheck ([0,3])")
    }
 
    data, err := os.ReadFile(os.Args[1])
    if err != nil {
        panic("main: error when reading file")
    }
 
    n := len(data)
    nCheck, err := strconv.Atoi(os.Args[2])
    if err != nil {
        panic("main: error when convert string to number")
    }
 
    for i := 0; i < nCheck; i++ {
        if checkFunc(data, int(checkPos[i]*float64(n)), checkVal[i]) {
            fmt.Printf("[√]Check %d succeed!\n", i+1)
        } else {
            fmt.Printf("[x]Check %d failed!\n", i+1)
            os.Exit(1)
        }
    }
    vuln(data, n)
}
 
func checkFunc(data []byte, pos int, val byte) bool {
    //fmt.Printf("%x\n", pos)
    return data[pos] == val
}
 
func vuln(data []byte, n int) {
    defer func() {
        if handler := recover(); handler != nil {
            fmt.Println("ERROR: index out of range [20] with length 20")
            os.Exit(123)
        }
    }()
 
    fmt.Println("[√]Pass all checks!")
    newBuf := make([]byte, 20)
 
    for i := 0; i < n; i++ {
        newBuf[i] = data[i]
    }
}

3.3 测试结果

使用byteOverwrite方法,设置flipPercent值为0.02,迭代100,000次,漏洞程序只完成第一道检查,测试得到crash的图片8张;对比文章中1,000,000次迭代-88张图片的结果,两者的比例是一致的。

 

只做第一道检查,fuzz找到漏洞的概率≈8/100000=0.008%,如果想要通过两次检查,找到漏洞的概率成指数级下降,只有0.00000064%

 

因此在不检查code-coverage的情况下,完全随机进行fuzz,能够找到漏洞的概率极小

4. 实现快照和代码覆盖率检查

4.1 代码整理

鉴于我最近对于go的熟练度有所提升,因此在学习文章内容之前,我先对之前自己写的代码进行了整理。把和实际的模糊测试工作有关的代码封装在了fuzzer包中,保留了SetConfigRun接口(和go中的interface概念无关)

 

最终外部的调用代码变成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func main() {
    if len(os.Args) < 2 {
        fmt.Println("Usage: fuzzer.exe <valid_jpg>")
        os.Exit(1)
    }
 
    start := time.Now()
 
    filename := os.Args[1]
    data, err := os.ReadFile(filename)
    if err != nil {
        panic("main: " + err.Error())
    }
    // setconfig第二个参数表示测试方法,-1:随机,0:byteOverwrite,1:magic
    fuzzer.SetConfig(100000, 0, []string{"./exif", "./mutated.jpg", "-verbose"})
    fuzzer.Run(data)
 
    duration := time.Since(start).Microseconds()
    fmt.Println("Execution time:", duration, "ms")
}

通过SetConfig设置迭代次数,模糊测试方法以及执行的命令,然后调用Run开始进行测试。

 

其中执行的命令那里封装的还不是特别好,mutated.jpg这个文件名还暴露在外面,因为之后的内容会放弃写入mutated.jpg文件,因此这部分内容还会有很大的变动,所以就没有解决这个问题

4.2 性能分析

整理后的代码可以很清晰的看到整个模糊测试的流程:

1
2
3
4
5
6
7
func Run(data []byte) {
    for i := 0; i < iteration; i++ {
        mutateData := getData(data, method)
        createNew(mutateData)
        runCommand(i, mutateData)
    }
}

每次迭代分为三个步骤:生成变形后数据,将变形数据写入文件,执行模糊测试

 

因为在对目标程序进行模糊测试的时候,采用

1
ccmd := exec.Command(cmd[0], cmd[1:]...)

的方式执行命令,因此每次都需要把变形后的数据写入到文件,然后再由目标程序进行加载。上述流程在每次迭代都会发生,这也是文章中提到的导致性能下降的主要原因之一。

 

除此之外,文章中也提到了使用strace跟踪程序系统调用情况的方法,在使用strace检查我写的vulfunc的时候,在执行openat(AT_FDCWD, "./Canon_40D.jpg", O_RDONLY|O_CLOEXEC) = 3之前,程序执行了大量系统调用(有158行,出于篇幅考虑,不再贴出)。

 

因此文章中提到了利用调试器建立快照的方法。

4.3 调试器的实现

4.3.0 参考资料

原文提供了一篇关于ptrace的参考资料(④),同时我找到了一篇golang调试器的文章(③)。但是参考资料③是2017年写的,对于go这门新语言来说有些过时了,原本syscall包的功能已经被移植到了sys包中,因此我同时参考了delve的源码

4.3.1 流程 & 需要实现的内容

按照文章中的方法,需要实现一个调试器,使用调试器:

  1. 在目标程序加载完图片以及结束之前设置断点(start, end)
  2. 在程序到达start断点后:
    1. 缓存此时可写内存段数据以及寄存器数据
    2. 通过寄存器内容获得被加载图片所在内存地址
    3. 将变形图片数据写入该地址
  3. 继续执行程序,到达end断点
  4. 恢复②中缓存的内存段数据以及寄存器数据
  5. 返回②→c继续执行

因此这个调试器需要实现的功能有:暂停程序执行、设置和取消断点、读写内存、读写寄存器、继续执行程序

4.3.2 暂停程序执行

这个功能的实现很简单,只需要将exec.CommandSysProcAttr属性中的Ptrace设置为true就可以:

1
2
cmd := exec.Command(s)
cmd.SysProcAttr = &sys.SysProcAttr{Ptrace: true}

根据文档,该操作相当于子进程执行了ptrace(PTRACE_TRACEME)

4.3.3 读写寄存器

通过查找文档,找到了函数PtraceGetRegs

1
func PtraceGetRegs(pid int, regsout *PtraceRegs) error

测试读取RIP寄存器的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func Debug(s string, args []string) {
    cmd := exec.Command(s, args...)
    cmd.SysProcAttr = &sys.SysProcAttr{Ptrace: true}
    if err := cmd.Start(); err != nil {
        log.Fatal(err)
    }
 
    var regs sys.PtraceRegs
    if err := sys.PtraceGetRegs(cmd.Process.Pid, &regs); err != nil {
        log.Panic(err)
    }
    log.Printf("Value of rip: %x\n", regs.Rip)
 
    err := cmd.Wait()
    log.Printf("State: %v\n", err)
}
1
2
3
func TestDebug(t *testing.T) {
    Debug("./vulfunc", []string{"./Canon_40D.jpg", "3"})
}

多次执行得到的RIP数值是一样的:

1
2
3
4
5
6
7
└─$ go test -v
=== RUN   TestDebug
2022/01/06 00:15:42 Value of rip: 45cb60
2022/01/06 00:15:42 State: stop signal: trace/breakpoint trap
--- PASS: TestDebug (0.00s)
PASS
ok      fuzz/debugger   0.003s

从IDA中检查vulfunc文件,可以看到地址0x45CB60就在程序开始的位置:

 

图片描述

 

同理,如果需要写入寄存器,就使用函数PtraceSetRegs:

1
func PtraceSetRegs(pid int, regs *PtraceRegs) (err error)

4.3.4 读写内存

通过查找文档,找到函数PtracePeekData

1
func PtracePeekData(pid int, addr uintptr, out []byte) (count int, err error)

在Debug函数中添加如下代码段:

1
2
3
4
5
6
var data = make([]byte, 1)
addr := uintptr(regs.Rip + 0x30d80)   // 不需要关注这个地址
if _, err := sys.PtracePeekData(cmd.Process.Pid, addr, data); err != nil {
    log.Panic(err)
}
log.Printf("data at %x: %x\n", addr, data)

得到结果:

1
2
3
4
5
6
7
8
└─$ go test -v
=== RUN   TestDebug
2022/01/06 01:10:23 Value of rip: 45cb60
2022/01/06 01:10:23 data at 48d8e0: eb
2022/01/06 01:10:23 State: stop signal: trace/breakpoint trap
--- PASS: TestDebug (0.00s)
PASS
ok      fuzz/debugger   0.003s

得到0x48d8e0处的字节值为0xeb,和IDA中显示的一样。

 

同理,写内存可以通过PtracePokeData

 

<aside>
注意:后续测试时,发现这个方法进行大段内存读写很费时间。因此只在设置/恢复断点时采用如上方法修改内存。而在拍摄/恢复快照时选择使用ProcessVMReadvProcessVMWritev

func ProcessVMReadv(pid int, localIov []Iovec, remoteIov []RemoteIovec, flags uint) (n int, err error)
func ProcessVMWritev(pid int, localIov []Iovec, remoteIov []RemoteIovec, flags uint) (n int, err error)

</aside>

4.3.5 设置和取消断点

断点的原理就是将原本的指令修改成0xCC。所以需要1)确定设置断点的位置,2)读取该位置的数值并保存,3)将数值修改为0xCC

 

因此只需要使用上面提到的读写内存的功能,就可以实现设置和取消断点功能。

 

但是这里有一个trick需要注意,假设想要在0x1000处设置断点,将指令修改为0xCC之后,程序继续执行并中断,此时的RIP值应该是0x1001,如果想让程序继续执行,我们必须先取消断点,并把RIP减1,让程序可以继续从0x1000开始执行。这个trick会在CancelBreakPoint中实现。

 

断点位置的确定在之后说明,先给出函数代码。设置断点:

1
2
3
4
5
6
7
8
9
10
func SetBreakPoint(pid int, addr uintptr) ([]byte, error) {
    data := make([]byte, 1)
    if _, err := sys.PtracePeekData(pid, addr, data); err != nil {
        return nil, errors.New("SetBreakPOint: peek data: " + err.Error())
    }
    if _, err := sys.PtracePokeData(pid, addr, []byte{0xcc}); err != nil {
        return nil, errors.New("SetBreakPOint: poke data: " + err.Error())
    }
    return data, nil
}

取消断点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func CancelBreakPoint(pid int, addr uintptr, data []byte) error {
    if _, err := sys.PtracePokeData(pid, addr, data); err != nil {
        return errors.New("CancelBreakPOint: poke data: " + err.Error())
    }
 
    sys.PtracePeekData(pid, addr, data)
    log.Printf("Cancel bp at %x, data is %x\n", addr, data)
 
    var regs sys.PtraceRegs
    if err := sys.PtraceGetRegs(pid, &regs); err != nil {
        return err
    }
 
    regs.Rip = uint64(addr)
 
    if err := sys.PtraceSetRegs(pid, &regs); err != nil {
        return err
    }
 
    return nil
}

4.3.6 程序继续执行

通过查找ptrace文档,找到了PTRACE_CONT信号,对应golang中的:

1
func PtraceCont(pid int, signal int) (err error)

这个函数会重启中断的程序。

 

之后使用Wait4函数接收信号,

1
func Wait4(pid int, wstatus *WaitStatus, options int, rusage *Rusage) (wpid int, err error)

两者结合实现程序的继续执行。

 

至此可以得到Debug函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
func Debug(s string, args []string) {
    //runtime.LockOSThread()
 
    // open process
    cmd := exec.Command(s, args...)
    cmd.Stderr = os.Stderr
    cmd.SysProcAttr = &sys.SysProcAttr{Ptrace: true}
    if err := cmd.Start(); err != nil {
        log.Fatal(err)
    }
 
    pid := cmd.Process.Pid
    // get rip
    var regs sys.PtraceRegs
    if err := sys.PtraceGetRegs(pid, &regs); err != nil {
        log.Panic(err)
    }
    log.Printf("Value of rip: %x\n", regs.Rip)
 
    // set breakpoint1 before loop
    addr := uintptr(regs.Rip + bpOffset[0])
    data, err := SetBreakPoint(pid, addr)
    log.Printf("Set breakpoint at %x\n", addr)
    if err != nil {
        log.Panic(err)
    }
 
    // Continue
    if err := sys.PtraceCont(pid, 0); err != nil {
        log.Panic(err)
    }
    var ws sys.WaitStatus
    if _, err := sys.Wait4(pid, &ws, sys.WALL, nil); err != nil {
        log.Fatal(err)
    }
 
    log.Println("Hit bp1")
 
    // Cancel breakpoint
    if err := CancelBreakPoint(pid, addr, data); err != nil {
        log.Panic(err)
    }
 
    // need to do:
    // make snapshot
 
    // Set breakpoint before end
    bpAddr = uintptr(entrypoint + bpOffset[1])
    _, err = SetBreakPoint(pid, bpAddr)
    if err != nil {
        log.Panic(err)
    }
 
    bpAddr = uintptr(entrypoint + bpOffset[2])
    _, err = SetBreakPoint(pid, bpAddr)
    if err != nil {
        log.Panic(err)
    }
 
    // Continue
    if err = sys.PtraceCont(pid, 0); err != nil {
        log.Panic(err)
    }
    if _, err = sys.Wait4(pid, &ws, sys.WALL, nil); err != nil {
        log.Fatal(err)
    }
 
    log.Println("Hit bp2")
 
    // need to do
    // restore snapshot
}

输出为:

1
2
3
4
5
6
7
8
9
└─$ go test -v
=== RUN   TestDebug
2022/01/10 01:43:54 Value of rip: 45cb60
2022/01/10 01:43:54 Set breakpoint at 48d8e0
2022/01/10 01:43:54 Hit bp1
2022/01/10 01:43:54 Hit bp2
--- PASS: TestDebug (0.00s)
PASS
ok      fuzz/debugger   0.003s

现在只需要替换掉测试的图片数据

4.3.7 相关位置确定

我不清楚原文是怎么确定的断点地址,从源码看断点是硬编码到代码中的,并且在启动子进程之前取消了ASLR。

 

我考虑先通过静态分析确定断点代码距离程序起始位置的偏移,然后在程序起始位置中断,获取RIP的地址,再加上偏移,就可以得到断点地址了。

 

上面已经确定了RIP的值是0x45cb60

 

一开始我打算在循环开始之前设置断点,后来发现这样设置不太方便替换测试图像数据。

 

在IDA中找到函数check的调用位置,发现由于这个函数太短,已经被优化掉了:

 

图片描述

 

注意到在最终的cmp语句中,其中的一个比较对象sil来自于main_checkVal再加上偏移值,它对应的就是程序中的checkVal slice数组。

 

因此bl中保存的就是图像数据,向上溯源,寄存器rdx中保存的就应该是测试图像数据的起始地址。

 

如果继续向上溯源,rdx ← [rsp+88h+var_30] ← rax ← ReadFile

 

因此最终第一个断点选在ReadFile函数调用结束之后,即0x48D888

 

接下来选择第二个断点,因为程序有两个中断点,一个是测试没通过到达的os.Exit()函数,一个是全部测试通过到达的main_vul函数,因此我选择设置两个断点,分别在这两个函数调用之前。断点位置为:0x48da0c0x48da1b

 

得到断点位置距离程序起始位置的偏移分别为0x30d280x30eac0x30ebb

 

在到达第一个断点后,检查一下寄存器eax指向的地址中保存的数据是不是测试图像:

1
2
3
4
5
6
7
if err := sys.PtraceGetRegs(pid, &regs); err != nil {
        log.Panic(err)
    }
dataAddr := regs.Rax
data := make([]byte, 8)
sys.PtracePeekData(pid, uintptr(dataAddr), data)
log.Printf("%x\n", data)

得到输出:

1
2022/01/10 02:52:37 ffd8ffe000104a46

发现确实是测试图像的前八个字节。


 

然后需要确定可写内存的地址。使用文章中提到的方法,每次启动得到的内存块地址不同(不知道是不是ASLR的问题,因为我没有处理ASLR)。

 

我决定直接在程序中获取可读写内存块的地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func getMemSecs(pid int) error {
    defer trace("getMemSecs")()  // 这个函数用于测试程序执行时间,调试用
    var output bytes.Buffer
    cmd := exec.Command("cat", "/proc/"+strconv.Itoa(pid)+"/maps", "|", "grep", "rw")
    cmd.Stdout = &output
    cmd.Run()
 
    reg := regexp.MustCompile(`(\S+)-(\S+) rw`)
    result := reg.FindAllStringSubmatch(output.String(), -1)
    for idx, item := range result {
        begin, _ := strconv.ParseInt(item[1], 16, 0)
        end, _ := strconv.ParseInt(item[2], 16, 0)
 
        len := int(end - begin)
        rIov := sys.RemoteIovec{uintptr(begin), len}
        remoteIOV = append(remoteIOV, rIov)
 
        data := make([]byte, len)
        backupData = append(backupData, data)  // backupData初始化,内容为0
        iov := sys.Iovec{&backupData[idx][0], uint64(len)}
        localIOV = append(localIOV, iov)
    }
 
    return nil
}

4.3.8 建立/恢复快照

最好的主要功能就是内存和寄存器的备份和恢复,这两个函数比较简单,没什么可说的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func Backup(pid int) error {
    defer trace("Backup")()
 
    if _, err := sys.ProcessVMReadv(pid, localIOV, remoteIOV, 0); err != nil {
        return errors.New("Backup: vm read: " + err.Error())
    }
 
    if err := sys.PtraceGetRegs(pid, &backupRegs); err != nil {
        return errors.New("Backup: get regs: " + err.Error())
    }
 
    return nil
}
 
func Restore(pid int) error {
    //defer trace("Restore")()
    if _, err := sys.ProcessVMWritev(pid, localIOV, remoteIOV, 0); err != nil {
        return errors.New("Restore: vm write: " + err.Error())
    }
 
    if err := sys.PtraceSetRegs(pid, &backupRegs); err != nil {
        return errors.New("Restore: set regs: " + err.Error())
    }
    return nil
}

4.3.9 最终代码与运行结果分析

最后对代码的结构又做了一些修改,得到Debug函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
func Debug(s string, args []string, data []byte) {
    runtime.LockOSThread()
    defer trace("Debug")()
    var pid int
    var entrypoint uint64
    var bpAddr = make([]uintptr, 3)
    var bpData = make([][]byte, 3)
    var stdout bytes.Buffer
    var err error
    //var stderr bytes.Buffer
 
    // open process
    cmd := exec.Command(s, args...)
    cmd.Stdout = &stdout
    cmd.SysProcAttr = &sys.SysProcAttr{Ptrace: true}
    if err := cmd.Start(); err != nil {
        log.Fatal(err)
    }
    pid = cmd.Process.Pid
 
    // get rip
    var regs sys.PtraceRegs
    if err := sys.PtraceGetRegs(pid, &regs); err != nil {
        log.Panic(err)
    }
    entrypoint = regs.Rip
    log.Printf("Value of rip: %x\n", regs.Rip)
 
    // set breakpoint1 before loop
    bpAddr[0] = uintptr(entrypoint + bpOffset[0])
    bpData[0], err = SetBreakPoint(pid, bpAddr[0])
    log.Printf("Set breakpoint at %x\n", bpAddr)
    if err != nil {
        log.Panic(err)
    }
 
    // Set breakpoint before end
    bpAddr[1] = uintptr(entrypoint + bpOffset[1])
    _, err = SetBreakPoint(pid, bpAddr[1])
    if err != nil {
        log.Panic(err)
    }
 
    bpAddr[2] = uintptr(entrypoint + bpOffset[2])
    _, err = SetBreakPoint(pid, bpAddr[2])
    if err != nil {
        log.Panic(err)
    }
 
    // Continue
    if err := Continue(pid); err != nil {
        log.Panic(err)
    }
 
    log.Println("Hit bp1")
 
    // Cancel breakpoint
    if err := CancelBreakPoint(pid, bpAddr[0], bpData[0]); err != nil {
        log.Panic(err)
    }
 
    // Backup
    getMemSecs(pid)
    if err := Backup(pid); err != nil {
        log.Panic(err)
    }
    fmt.Printf("My pid is %d, child pid is %d\n", os.Getpid(), pid)
 
    for i := 0; i < 1000; i++ {
        mutateData := getData(data, method)
        // Change data
        dataAddr := uintptr(backupRegs.Rax)
        sys.ProcessVMWritev(pid, []sys.Iovec{{&mutateData[0], uint64(len(mutateData))}},
            []sys.RemoteIovec{{dataAddr, len(mutateData)}}, 0)
 
        // Continue
        if err = Continue(pid); err != nil {
            log.Panic(err)
        }
 
        // Restore
        Restore(pid)
    }
    log.Println(timeRestore)
}

注意我把所有的断点设置都放在了循环外部,循环内部只完成数据替换,继续执行,以及快照恢复功能。

 

代码在逻辑上没有问题,但是最终执行失败了,通过检查Continue函数中WaitStatus变量wsStopSignal()方法的返回值,我发现在循环迭代过程中,每次Continue函数的调用并没有让vulfunc继续执行到达程序结尾处的断点,而是由于urgent I/O condition导致程序中断执行。

 

之后我在循环中调用了两次Continue函数,强行让程序继续执行到达第二个断点,但是迭代数次之后,程序还是崩溃掉了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
┌──(kali㉿kali)-[~/go/src/fuzz/fuzzing]
└─$ go run main.go Canon_40D.jpg                                                                                                                                                                           1
2022/01/13 02:56:26 Value of rip: 45cb60
2022/01/13 02:56:26 SetBreakPoint using 0
2022/01/13 02:56:26 Set breakpoint at [48d8b4 0 0]
2022/01/13 02:56:26 SetBreakPoint using 0
2022/01/13 02:56:26 SetBreakPoint using 0
2022/01/13 02:56:26 Before continue: RIP is: 0x45cb60, data at here is 0xe9
2022/01/13 02:56:26 trace/breakpoint trap
2022/01/13 02:56:26 After continue: RIP is: 0x48d8b5, data at here is 0x89
2022/01/13 02:56:26 Hit bp1
2022/01/13 02:56:26 Cancel bp at 48d8b4, data is 48
2022/01/13 02:56:26 getMemSecs using 6
2022/01/13 02:56:26 Backup using 14
My pid is 1147262, child pid is 1147268
2022/01/13 02:56:26 Before continue: RIP is: 0x48d8b4, data at here is 0x48
2022/01/13 02:56:26 urgent I/O condition
2022/01/13 02:56:26 After continue: RIP is: 0x48d8b4, data at here is 0x48
2022/01/13 02:56:26 Before continue: RIP is: 0x48d8b4, data at here is 0x48
[x]Check 1 failed!
2022/01/13 02:56:26 trace/breakpoint trap
2022/01/13 02:56:26 After continue: RIP is: 0x48da0d, data at here is 0x01
...
2022/01/13 02:56:26 Before continue: RIP is: 0x48d8b4, data at here is 0x48
2022/01/13 02:56:26 urgent I/O condition
2022/01/13 02:56:26 After continue: RIP is: 0x48d8b4, data at here is 0x48
2022/01/13 02:56:26 Before continue: RIP is: 0x48d8b4, data at here is 0x48
2022/01/13 02:56:26 signal -1
2022/01/13 02:56:26 After continue:
2022/01/13 02:56:26 Before continue:
2022/01/13 02:56:26 no such process
2022/01/13 02:56:26 Debug using 131
panic: no such process
 
goroutine 1 [running, locked to thread]:
log.Panic({0xc000069e10, 0xc000069e00, 0x4dff67})
        /usr/local/go/src/log/log.go:354 +0x65
fuzz/fuzzer.Debug({0x4db874, 0x9}, {0xc00009af20, 0x2, 0x2}, {0xc0000c8000, 0x1f16, 0x1f17})
        /home/kali/go/src/fuzz/fuzzer/debugger.go:115 +0x75e
main.main()
        /home/kali/go/src/fuzz/fuzzing/main.go:27 +0x1c6
exit status 2

进行了N次尝试,只有一次程序正常迭代测试,没有出现崩溃的情况。

 

鉴于出现了执行成功的情况,所以我认为代码本身没有问题,但是可能和golang的一些特性有冲突。在github上面找到一个issue,但是不确定是不是有关。

4.4 覆盖率检查

虽然上面的快照方法没有成功,但是覆盖率检查还是可以实现的。

 

在命令执行后的输出检查中,检查输出信息中是否包含strconv.Itoa(succeedCount+1)+" succeed”信息,如果包含,就将用于生成变形后数据的baseData修改为当前的mutateData,然后继续进行迭代,最终程序在迭代36939次之后成功通过三次测试,到达了漏洞位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func runCommand(counter int, data []byte) {
    defer func() {
        handler := recover()
        if handler != nil {
            result := fmt.Sprintf("%v", handler)
            if strings.Contains(result, "123") {
                filename := fmt.Sprintf("crashes/crash_%d.jpg", counter)
                err := os.WriteFile(filename, data, 0644)
                checkError("Write crash file", err)
                return
            }
        }
    }()
 
    ccmd := exec.Command(cmd[0], cmd[1:]...)
 
    if output, err := ccmd.CombinedOutput(); err != nil {
        if strings.Contains(string(output), strconv.Itoa(succeedCount+1)+" succeed") {
            fmt.Println(string(output))
            baseData = data
            succeedCount++
        }
        panic("Run ccmd" + ": " + err.Error())
    }
}

至此,第四篇文章学习结束

参考资料

  1. Fuzzing like a caveman
  2. Windows下使用AddressSanitizer检测内存访问越界
  3. 实现一个 Golang 调试器(第二部分)
  4. How debuggers work: Part 1 - Basics
  5. ptrace(2) — Linux manual page

【公告】欢迎大家踊跃尝试高研班11月试题,挑战自己的极限!

最后于 2022-1-13 21:29 被LarryS编辑 ,原因: 添加源码
收藏
点赞4
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回