Go逆向总结
一、介绍
Go 语言是一个强类型静态语言,实现了 CSP 并发模型,并在 2012 年才发布首个稳定版。由于Go语言方便跨平台交叉编译,所以吸引了恶意软件开发者使用Go来开发恶意软件。
Go编译器会静态链接构建二进制文件,把标准库函数和第三方 package 进行静态编译,还将runtime 和 GC(Garbage Collection,垃圾回收) 模块打包进去 。
Go逆向的难点:
- 函数和代码量较传统C或C++程序要大得多;
- Go 语言的独特的函数调用约定、栈结构以及多返回值机制和常见的C/C++的机制不同;
- 用户代码中带有大量运行时库的API调用,剥离符号后会带来很大程度上的干扰。
Go支持continuous stack机制,每次执行goruntine时都会检查stack空间是否够用,否则会调用Go运行时库的morestack_noctx函数,申请一块更大的栈空间,将旧栈拷贝到新栈中。
二、修复符号
pclntab(Program Counter Line Table,可直译为程序计数器行数映射表)是一种将程序计数器映射到行号的数据结构。pclntab是为了服务于程序报错时调用栈回溯而存在的。
pclntab中存储了函数名等符号信息,可以通过该表获取函数地址和其对应函数名字符串的关系,进而使用idc脚本重命名函数名称,帮助我们更容易逆向Go程序。
在源码中,pclntab如此定义:
type LineTable struct { Data []byte PC uint64 Line int // This mutex is used to keep parsing of pclntab synchronous. mu sync.Mutex // Contains the version of the pclntab section. version version // Go 1.2/1.16/1.18 state binary binary.ByteOrder quantum uint32 ptrsize uint32 // size of a ptr in bytes textStart uint64 // address of runtime.text symbol (1.18+) funcnametab []byte cutab []byte // offset to the cutab variable from pcHeader funcdata []byte functab []byte nfunctab uint32 // number of functions in the module filetab []byte // number of entries in the file tab. pctab []byte // points to the pctables. nfiletab uint32 // number of entries in the file tab. funcNames map[uint32]string // cache the function names strings map[uint32]string // interned substrings of Data, keyed by offset // fileMap varies depending on the version of the object file. // For ver12, it maps the name to the index in the file table. // For ver116, it maps the name to the offset in filetab. fileMap map[string]uint32 }
其中,我们通过funcnametab这个成员可以找到函数名称表,进而得到函数地址和函数名称的关系。
对于 PE 文件,.symtab符号表由FileHeader.PointerToSymbolTable字段指向。此表中名为runtime.pclntab的符号包含pclntab的地址。ELF 和 Mach-O 文件也有一个.symtab符号表,其中包含一个runtime.pclntab符号,但不依赖它来定位pclntab。要在 ELF 文件中找到 .symtab,请查找类型为SH_SYMTAB的名为 .symtab 的部分。在 Mach-O 文件中, .symtab由LC_SYMTAB引用加载命令。PS:带有e前缀的符号(例如,epclntab)表示对应表的结尾。
当 Go 二进制文件被剥离时,.symtab符号表被清零或不存在。这消除了查找符号(例如runtime.pclntab )的能力。但是,这些符号指向的数据(例如pclntab本身)仍然存在于二进制文件中。对于 ELF 和 Mach-O 文件,命名部分(例如.gopclntab)也仍然存在,可用于定位pclntab。
对未剥离二进制文件的pclntab进行定位:
- 找到.symtab符号并解析runtime.pclntab;
- 对于 ELF 和 Mach-O 文件,找到.gopclntab或__gopclntab部分;
对已剥离的二进制文件的pclntab进行定位:
线性扫描二进制文件。pclntab始终以特定于版本号的magic数为开头。
magic根据版本不同会不一样,如下:
const ( go12magic = 0xfffffffb go116magic = 0xfffffffa go118magic = 0xfffffff0 )
这里推荐一个新的符号修复工具GoReSym(https://github.com/mandiant/GoReSym)。
三、初始化过程
Go程序从系统初始化直到退出必经的流程:
_rt0_amd64_darwin main _rt0_amd64 runtime.check:检测像int8,int16,float等是否是预期的大小,检测cas操作是否正常 runtime.args:将argc,argv设置到static全局变量中了 runtime.osinit:设置runtime.ncpu,不同平台实现方式不一样 runtime.hashinit:通过读取/dev/urandom文件的方式从内核获得随机数种子 runtime.schedinit:内存管理初始化,根据GOMAXPROCS设置使用的procs等等 runtime.newproc:把runtime.main放到就绪线程队列里面 runtime.mstart:调用调度函数schedule main.main:用户的main函数,用户代码 runtime.exit
go关键字的调用协议:先将参数进栈,再被调函数指针和参数字节数进栈,接着调用runtime.newproc函数。newproc函数新开个goroutine执行runtime.main。schedule函数绝不返回,它会根据当前线程队列中线程状态挑选一个goroutine来运行。由于当前只有这一个goroutine,它会被调度,然后就到了runtime.main函数中来,runtime.main会调用用户的main函数,即main.main从此进入用户代码。
当然,在main.main执行之前(runtime.main在进入用户main函数之前做的一些事情),Go语言的runtime库还会初始化一些后台任务,暂时略过。
四、函数调用约定
Golang的调用约定与C不同,它支持多值返回,返回值并不保存在eax寄存器中返回,而是作为参数添加到参数列表中,如:func f(arg1, arg2 int) (ret1, ret2 int):返回值有两个,则增加两个参数(最后两个参数)。
Go打包的x86_x64平台程序,参数中int类型在内存为一个字(2个字节)大小,不是4个字节。如*int这样的指针类型也是只使用一个字(8个字节), 而interface接口类型的变量占2个字(16个字节)。
String和Slice类型的变量分别占两个字和三个字。Go中的String类型是一个由address和len组成的结构体,而Slice类型是一个由array(byte*)、len以及capacity组成的结构体。
PS:32 位 cpu的1word = 4 bytes,64位cpu的1 word = 8 bytes。
Go中的接口实现和普通的函数不同,其没有堆栈平衡,不是函数只是一个代码片段。
比如
func InterfacePass ( Foo x ) Foo { return x }
其对应二进制内容为:
InterfacePass: 0 x4805b0 488 b442408 MOVQ 0x8 ( SP ), AX 0 x4805b5 4889442418 MOVQ AX , 0x18 ( SP ) 0 x4805ba 488 b442410 MOVQ 0x10 ( SP ), AX 0 x4805bf 4889442420 MOVQ AX , 0x20 ( SP ) 0 x4805c4 c3 RET
又或者:
var x int func RetPtr () * int { return & x } func NilPtr () * int { return nil }
其对应二进制内容为:
RetPtr: 0 x480650 488 d0581010c00 LEAQ src.x ( SB ), AX ; 计算 &x 0 x480657 4889442408 MOVQ AX , 0x8 ( SP ) ; return &x 0 x48065c c3 RET NilPtr: 0 x480660 48 c744240800000000 MOVQ $0x0 , 0x8 ( SP ) ; 返回 0 0 x480669 c3 RET
五、关于空值
按照Go语言规范,任何类型在未初始化时都对应一个零值:布尔类型是false,整型是0,字符串是"",而指针,函数,interface,slice,channel和map的零值都是nil。
一个interface在没有进行初始化时,对应的值是nil。也就是说var v interface{}
,此时v就是一个nil。在底层存储上,它是一个空指针。
var v *T var i interface{} // i = nil i = v
string的空值是"",它是不能跟nil比较的。即使是空的string,它的大小也是两个机器字长的。slice也类似,它的空值并不是一个空指针,而是结构体中的指针域为空,空的slice的大小也是三个机器字长的。
channel跟string或slice有些不同,它在栈上只是一个指针,实际的数据都是由指针所指向的堆上面。跟channel相关的操作有:初始化/读/写/关闭。channel未初始化值就是nil,未初始化的channel是不能使用的。
六、go关键字的实现
在Go语言中,表达式go f(x, y, z)会启动一个新的goroutine运行函数f(x, y, z)。函数f,变量x、y、z的值是在原goroutine计算的,只有函数f的执行是在新的goroutine中的。
新的goroutine不能和当前go线程用同一个栈,否则会相互覆盖。所以对go关键字的调用协议与普通函数调用是不同的。
正常的函数调用,下面是调用f(1, 2, 3)时的汇编代码:
f(1,2,3): MOVL $1, 0(SP) MOVL $2, 4(SP) MOVL $3, 8(SP) CALL f(SB)
go f(1, 2, 3)生成的代码:
MOVL $1, 0(SP) MOVL $2, 4(SP) MOVL $3, 8(SP) PUSHQ $f(SB) PUSHQ $12 CALL runtime.newproc(SB) POPQ AX POPQ AX
将f和12作为参数进栈而不直接调用f,然后调用函数runtime.newproc
。12是参数占用的大小(12个字节,int类型是4个字节)。
runtime.newproc(size, func, args)
七、defer关键字的实现
defer用于资源的释放。defer关键字的实现跟go关键字很类似,不同的是它调用的是runtime.deferproc而不是runtime.newproc。
在defer出现的地方,插入了指令call runtime.deferproc,然后在函数返回之前的地方,插入指令call runtime.deferreturn。
goroutine的控制结构中,有一张表记录defer,调用runtime.deferproc时会将需要defer的表达式记录在表中,而在调用runtime.deferreturn的时候,则会依次从defer表中出栈并执行。
普通的函数返回时,汇编代码类似:
add xx SP return
包含了defer语句,则汇编代码是:
call runtime.deferreturn, add xx SP return
相关参考
J!4Yu大佬关于Go逆向的文章:https://www.anquanke.com/post/id/214940
knz大佬的文章非常底层详细:https://dr-knz.net/go-calling-convention-x86-64.html
IDA帮助文档:https://www.hex-rays.com/products/ida/support/idadoc/162.shtml
其他:
https://tiancaiamao.gitbooks.io/go-internals/content/zh/04.1.html