-
-
[原创]Golang版本简易fuzzer及debugger实践
-
发表于: 2022-1-13 17:24 23035
-
本文为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语言以及个人编程习惯的原因,采用的方法与原文有所差别:
bitflip
该方法会随机选取所有字节中1%的字节,再随机选取其中的一位进行反转。
运用一点数学的知识,不使用原本文章中将字符串转换为数组再转回字符串的方式。
该函数完整代码如下:
12345678910111213141516171819202122/
/
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
}
magic number
该方法源自GynvaelColdwind的‘Basics of fuzzing’ stream,将字节设置为特殊的,容易引发溢出的数值。这样如果程序中正好存在针对这几个字节的数值操作,就可能会发生溢出。
原文章的方法过于冗长,我直接把magic number做成一个二维数组,在通过随机的方式确定替换位置之后,搜索二维数组进行替换。
该函数完整代码如下:
1234567891011121314151617181920212223242526/
/
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之前,我在此对代码进行了一些修改:
- 尝试fuzz更多命令选项,使用map保存要fuzz的命令
var cmds = []string{"rm", "pr", "fi", "fc", "ex"}
统计输出的错误信息,只在该错误信息第一次出现的时候打印出来
1234567891011121314151617181920212223func 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包中,保留了SetConfig
和Run
接口(和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 流程 & 需要实现的内容
按照文章中的方法,需要实现一个调试器,使用调试器:
- 在目标程序加载完图片以及结束之前设置断点(start, end)
- 在程序到达start断点后:
- 缓存此时可写内存段数据以及寄存器数据
- 通过寄存器内容获得被加载图片所在内存地址
- 将变形图片数据写入该地址
- 继续执行程序,到达end断点
- 恢复②中缓存的内存段数据以及寄存器数据
- 返回②→c继续执行
因此这个调试器需要实现的功能有:暂停程序执行、设置和取消断点、读写内存、读写寄存器、继续执行程序
4.3.2 暂停程序执行
这个功能的实现很简单,只需要将exec.Command
的SysProcAttr
属性中的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, ®s); 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>
注意:后续测试时,发现这个方法进行大段内存读写很费时间。因此只在设置/恢复断点时采用如上方法修改内存。而在拍摄/恢复快照时选择使用ProcessVMReadv
和ProcessVMWritev
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, ®s); err ! = nil { return err } regs.Rip = uint64(addr) if err : = sys.PtraceSetRegs(pid, ®s); 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, ®s); 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函数,因此我选择设置两个断点,分别在这两个函数调用之前。断点位置为:0x48da0c
和0x48da1b
得到断点位置距离程序起始位置的偏移分别为0x30d28
、0x30eac
和0x30ebb
。
在到达第一个断点后,检查一下寄存器eax指向的地址中保存的数据是不是测试图像:
1 2 3 4 5 6 7 | if err : = sys.PtraceGetRegs(pid, ®s); 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, ®s); 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
变量ws
的StopSignal()
方法的返回值,我发现在循环迭代过程中,每次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()) } } |
至此,第四篇文章学习结束
参考资料
- Fuzzing like a caveman
- Windows下使用AddressSanitizer检测内存访问越界
- 实现一个 Golang 调试器(第二部分)
- How debuggers work: Part 1 - Basics
- ptrace(2) — Linux manual page