go语言默认采用静态编译的策略,这意味着各种标准库和第三方库包括runtime和gc都会被全静态链接构建,这导致go二进制文件较大,同时go函数调用约定,数据结构和栈管理策略非常特殊,而且不同go版本之间的细节也存在很多差异,这一系列原因导致go逆向存在诸多难处。不过根据其特殊之处入手可以帮助进行符号恢复,字符串引用恢复等操作来帮助逆向工程师获得更好的体验。
不同go版本之间存在一定差异。
对于go function
来说,它的栈设计本身就非常独特,因为操作系统下的threads managed
对用户来说已经完全被go runtime抽象,这使得用户只需要关注存在于用户空间的新抽象goroutines,这使得go runtime可以自己设置栈的行为准则。
当go函数启动的时候,首先就会为栈分配一个固定大小的空间,但是这个大小在不同版本之间存在差异,在go1.12的时候最小栈空间为2kb,但是go1.2变成了4kb,然后在go1.4再次变回2kb,我查看了目前的go1.20.1版本同样是2kb。然后在接下来的函数调用中会对栈空间是否合适进行检查,如果需要的话通过runtime.morestack_noctxt
函数对栈空间进行扩充,这也对应了汇编中经常看到的call prologue
(this particular prologue is present only in routines with local variables):
在进行栈扩充的时候采用整体Free重新分配的策略,同时一般每次newsize := oldsize * 2
,如下:
根据不同的架构,栈具有不同的最大值,栈的分配超过该值就会引发错误。同时如果需要的话,栈空间可以被gc回收,在回收的时候栈空间变化为newsize := oldsize/2
,并且会复用之前的栈地址。另一个值得注意的点是自动go1.3之后开始,goroutine stacks的实现方式从segmented model转变为contiguous model,contiguous model优化了segmented model的hot split问题,这里就不细说,需要了解可以参考该链接。
在go中参数和返回值都会存储在caller的栈空间里面,存储返回值的空间会被预先分配,然后由被调用函数写入对应的值,通过这种方式go实现了多返回值机制,但是最新版的go语言中参数和返回值也可以在寄存器中传递:
https://tip.golang.org/src/cmd/compile/abi-internal
Function calls pass arguments and results using a combination of the stack and machine registers. Each argument or result is passed either entirely in registers or entirely on the stack. Because access to registers is generally faster than access to the stack, arguments and results are preferentially passed in registers. However, any argument or result that contains a non-trivial array or does not fit entirely in the remaining available registers is passed on the stack.
因此新版go语言中参数和返回值的传递优先采用寄存器,但是寄存器参数传递规则并不是类似x86_64的rdi, rsi等,而是有一套属于自己的算法,具体可以参考上面的链接,比较值得注意的是在新版(当前为1.20.1)go的调用约定中,参数和返回值可以共享寄存器但是不会共享栈空间,同时即使有些参数通过寄存机传参,caller依然会在栈空间中依然会为它们预留一定的空间,同时caller也会为寄存器传参的参数在栈空间中预留spill area
溢出区:
如图rax和rbx用来保存返回值,同时也用到了栈上预留的空间。同时,如上面提到的,假如有struct,array和string类型的参数那么调用约定就会变得更为复杂:
f(a1 uint8, a2 [2]uintptr, a3 uint8) (r1 struct { x uintptr; y [2]uintptr }, r2 string)
上面的官方例子很好的讲述了这一点,假设存在寄存器R0-R9,在函数起始阶段a1会被赋予R0,a3会被赋予R1,a2则是在栈中初始化,栈中为a1和a3预留的空间则不会初始化。在函数结束阶段,r2.base也就是字符串所在的地址被赋予到R0,r2.len也就是字符串长度被赋予R1,r1.x和r1.y则被初始化在栈上。总结就是如果参数或者返回值中包含类似array结构,那么就会被放在栈上操作,其它则通过寄存器操作,字符串则因为go自己独特的存储模式(后面会细说)需要分不同的部分进行传递。在栈空间排布上,参数要比返回值处于更低的地址,同时也比spill area更低。
具体到逆向目标这里给出对于amd64和arm64的相关内容,对于amd64架构来说,下列寄存器会被用于传递整数类型的参数和结果:
RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11
使用X0-X14来传递浮点类型的参数和结果,而对于arm64架构来说,它使用R0-R15来保存整数类型的参数和结果,使用F0-F15来保存浮点类型的参数和结果。
内建基础类型:
• one boolean built-in boolean type: bool.
• 11 built-in integer numeric types (basic integer types): int8, uint8, int16, uint16, int32, uint32, int64, uint64, int, uint, and uintptr.
• two built-in floating-point numeric types: float32 and float64.
• two built-in complex numeric types: complex64 and complex128.
• one built-in string type: string.
在内建基础类型上,go的表现基本和C语言类似,在逆向分析过程中最重要的类型拆解其实就是结构体,在Go语言里面rtype是很多类型的基础实现,它会被嵌入到其它的struct types:
// rtype is the common implementation of most values.
// It is embedded in other struct types.
//
// rtype must be kept in sync with ../runtime/type.go:/^type._type.
type rtype struct {
size uintptr
ptrdata uintptr // number of bytes in the type that can contain pointers
hash uint32 // hash of type; avoids computation in hash tables
tflag tflag // extra type information flags
align uint8 // alignment of variable with this type
fieldAlign uint8 // alignment of struct field with this type
kind uint8 // enumeration for C
// function for comparing objects of this type
// (ptr to object A, ptr to object B) -> ==?
equal func(unsafe.Pointer, unsafe.Pointer) bool
gcdata *byte // garbage collection data
str nameOff // string form
ptrToThis typeOff // type for pointer to this type, may be zero
}
对于逆向工程来说,这些字段非常有帮助,kind:代表了目标结构体的基础类型,nameOff
可以知道目标结构体的名字,ptrToThis
则代表了指向该结构体的指针类型,在go开发过程中很多情况下会针对指针实现结构体函数,这里的ptrToThis
帮助找到指针类型所在的位置从而可以找到对应实现的结构体函数来帮助逆向:
// ptrType represents a pointer type.
type ptrType struct {
rtype
elem *rtype // pointer element (pointed at) type
}
接下来是struct的实现:
// structType represents a struct type.
type structType struct {
rtype
pkgPath name
fields []structField // sorted by offset
}
所以在struct
的实现中包含所在的包路径,然后接下来跟一个structField
数组:
// Struct field
type structField struct {
name name // name is always non-empty
typ *rtype // type of field
offset uintptr // byte offset of field
}
其中name代表的是文件名,还有一个rtype指针用来该Field的类型,通过对这些结构体信息进行组织就可以对目标结构体进行还原。
为了理解go二进制文件的数据分布,必须理解go独特的moduledata,但是随着版本的变化moduledata也一直在改变,这里是针对go1.20.1的解析:
//moduledata records information about the layout of the executable
// image. It is written by the linker. Any changes here must be
// matched changes to the code in cmd/link/internal/ld/symtab.go:symtab.
// moduledata is stored in statically allocated non-pointer memory;
// none of the pointers here are visible to the garbage collector.
type moduledata struct {
pcHeader *pcHeader
funcnametab []byte
cutab []uint32
filetab []byte
pctab []byte
pclntable []byte
ftab []functab
findfunctab uintptr
minpc, maxpc uintptr
text, etext uintptr
noptrdata, enoptrdata uintptr
data, edata uintptr
bss, ebss uintptr
noptrbss, enoptrbss uintptr
covctrs, ecovctrs uintptr
end, gcdata, gcbss uintptr
types, etypes uintptr
rodata uintptr
gofunc uintptr // go.func.*
textsectmap []textsect
typelinks []int32 // offsets from types
itablinks []*itab
ptab []ptabEntry
pluginpath string
pkghashes []modulehash
modulename string
modulehashes []modulehash
hasmain uint8 // 1 if module contains the main function, 0 otherwise
gcdatamask, gcbssmask bitvector
typemap map[typeOff]*_type // offset to *_rtype in previous module
bad bool // module failed to load and should be ignored
next *moduledata
}
详细看该结构体就知道这个对于逆向来说非常重要,可以帮助定位不同段的位置和相关信息,对于类型系统来说最重要的就是types, etypes
和typelinks
,types段中包含type descriptions而typelinks段中包含对于types的偏移:
可以看到虽然没有types段,但是存在typelinks段来很方便的定位到目标。因此通过递归搜索拆解类型信息并交叉引用就可以很好的完成对于类型的恢复。
对于go逆向来说,另一个重要的类型就是接口,在go源码里可以看到其实现为:
type iface struct {
tab *itab
data unsafe.Pointer
}
type itab struct {
inter *interfacetype
_type *_type
hash uint32 // copy of _type.hash. Used for type switches.
_ [4]byte
fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}
type interfacetype struct {
typ _type
pkgpath name
mhdr []imethod
}
上面就是go源码内部的相关数据结构,其中_type其实就是上述的rtype,所以其实对于iface的结构来说就很清晰了,在itab中,fun是一个对象的virtual dispatch table用来索引一些函数。
无论是interface还是struct其实在逆向中都更关心它们包含的一些函数,在go的类型系统中还存在funcType:
type funcType struct {
rtype
inCount uint16
outCount uint16 // top bit is set if last input parameter is ...
}
inCount和outCount代表参数和返回值。
pclntab
全名是Program Counter Line Table
。2013年由Russ Cox
(Go 语言创始团队成员,核心开发者)从Plan9移植到 Go 1.2
上,至今没有太大变化。引入*pcnlntab*
这个结构的最初动机,是为 *Stack Trace*
服务的。当程序运行出错要 *panic*
的时候,runtime 需要知道当前的位置,层级关系如 *pkg*->*src file*->*function or method*->*line number*
,每一层的信息 runtime 都要知道。Go 就把这些信息结构化地打包到了编译出的二进制文件中
。除此之外,pcnlntab
中还包含了栈的动态管理用到的栈帧信息、垃圾回收用到的栈变量的生命周期信息以及二进制文件涉及的所有源码文件路径信息。这个结构体反过来就是我们逆向的重要参考。定义如下(go 1.16版本之后在源码中才存在定义):
其中magic的值在Go 1.16之前是0xFFFFFFFB,Go 1.16到17是0xFFFFFFFA,Go 1.18是0xFFFFFFF0。具体的解析逻辑在源码中pclntab.go。各字段含义如下:
• 开头 4-Bytes 是 Magic Number: 0xFFFFFFFB ;
• 第 5、6个字节为 0x00,暂无实际用途;
• 第 7 个字节代表 instruction size quantum, 1 为 x86, 4 为 ARM;
• 第 8 个字节为地址的大小,32bit 的为 4,64 bit 的为 8,至此的前 8 个字节可以看作是 pclntab 的 Header;
• 第 9 个字节开始是 function table 的起始位置;
• 第一个 uintptr 元素为函数(pc, Program Counter) 的个数;
• 第 2 个 uintptr 元素为第 1 个函数(pc0) 的地址,第 3 个 uintptr 元素为第 1 个函数结构定义相对于 pclntab 的偏移,后面的函数信息就以此类推;
• 直到 function table 结束,下面就是 Source file table的信息。pcN后面的int32表示Source file table的偏移。
• Source file table 以 4 字节(int32)为单位,前 4 个字节代表 Source File 的数量,后面每一个 int32 都代表一个 Source File Path String 相对于 pclntab 的偏移;
里面比较重要的信息有函数表和源码表。其中函数表(func table)的起始地址,为 (pclntab_addr + 8),第一个元素( uintptr N) 代表函数的个数。每两个 uintptr 元素为一组,即 (func_addr, func_struct_offset),每组第一个元素为函数的地址,第二个元素为函数结构体定义(Function Struct)相对于 pclntab 起始地址的偏移。Function Struct定义如下:
struct Func
{
uintptr entry; // start pc
int32 name; // name (offset to C string)
int32 args; // size of arguments passed to function
int32 frame; // size of function frame, including saved caller PC
int32 pcsp; // pcsp table (offset to pcvalue table)
int32 pcfile; // pcfile table (offset to pcvalue table)
int32 pcln; // pcln table (offset to pcvalue table)
int32 nfuncdata; // number of entries in funcdata list
int32 npcdata; // number of entries in pcdata list
};
对于逆向分析来说,这里最有用的信息,就是函数名了。如上图所示,函数名是一个以 0x00 结尾的 C-String。在 Function Struct 中,第二个元素只是 int32 类型的偏移值(仍然相对于pclntab地址)。而Function Struct 中第 3 个元素 args 在 Go 标准库源码 src/debug/gosym/symtab.go 中
解析这个Function Struct的一个类型定义中,有两条注释,说Go 1.3之后就没这种信息了:
另外,还有一些函数用上面的方式无法解析,是编译器做循环展开时自动生成的匿名函数,也叫 Duff’s Device。这样的函数知道它是用来连续操作内存(拷贝、清零等等)的就可以。
对于字符串引用问题解决方法很粗暴沿用了golang_loader_assist.py。核心就是观察汇编中字符串引用格式,golang中const char *
字符串几乎是堆在一起的,然后以str_addr + str_len
的方式引用。
以386架构来说,观察其字符串引用汇编如下:
mov rcx, cs:qword_BC2908 ; str len
mov rdx, cs:off_BC2900 ; str pointer
mov [rsp+0A8h+var_90], rdx
mov [rsp+0A8h+var_88], rcx
call github_com_rs_zerolog_internal_json_Encoder_AppendKey
然后通过指针和长度来识别某一个字符串,而不是所有字符串。对于arm架构有如下两种方式引用:
pattern0: properly for local string variable invoking
LDR R0, =aGodebugUnknown; strptr
STR R0, [SP,#0x50+var_4C]
MOV R1, #0x1E ; len
STR R1, [SP,#0x50+var_48]
BL runtime_printstring
pattern1: properly for global string variable
LDR R3, =off_888AE8 ; ".SH NAME/dev/mem/dev/mtd/gid_map/static" strptr-len_ptr
...
...
BL fmt_Fprintln
.data:0014C3F8 off_14C3F8 DCD aGlobalHelloMbA ; DATA XREF: main_main+2C↑o
.data:0014C3F8 ; main_main+30↑r ...
.data:0014C3F8 ; "Global Hello !MB; allocated Other_ID_St"...
.data:0014C3FC dword_14C3FC DCD 0xE
第一种:LDR,STR,MOV,STR获取字符串地址和长度并压栈。
第二种:获得一个指针该指针指向一个结构体包含字符串地址和长度。上面不管是386架构还是arm的两种情况在引用字符串时,字符串地址和长度都是地址相邻的(这个可能对后续详细识别引用有一定帮助)。
在Go语言的体系中,Module是比Package更高层次的概念,具体表现在一个Module中可以包含多个不同的Package,而每个Package中可以包含多个目录和很多的源码文件。相应地,Moduledata在Go二进制文件中也是一个更高层次的数据结构,它包含很多其他结构的索引信息,可以看作是Go二进制文件中 RTSI(Runtime Symbol Information) 和 RTTI(Runtime Type Information) 的地图:
// moduledata records information about the layout of the executable
// image. It is written by the linker. Any changes here must be
// matched changes to the code in cmd/internal/ld/symtab.go:symtab.
// moduledata is stored in statically allocated non-pointer memory;
// none of the pointers here are visible to the garbage collector.
type moduledata struct {
pclntable []byte
ftab []functab
filetab []uint32
findfunctab uintptr
minpc, maxpc uintptr
text, etext uintptr
noptrdata, enoptrdata uintptr
data, edata uintptr
bss, ebss uintptr
noptrbss, enoptrbss uintptr
end, gcdata, gcbss uintptr
types, etypes uintptr
textsectmap []textsect
typelinks []int32 // offsets from types
itablinks []*itab
ptab []ptabEntry
pluginpath string
pkghashes []modulehash
modulename string
modulehashes []modulehash
hasmain uint8 // 1 if module contains the main function, 0 otherwise
gcdatamask, gcbssmask bitvector
typemap map[typeOff]*_type // offset to *_rtype in previous module
bad bool // module failed to load and should be ignored
next *moduledata
}//https://github.com/golang/go/blob/dev.boringcrypto.go1.13/src/runtime/symtab.go
根据 Moduledata 的定义,Moduledata 是可以串成链表的形式的,而一个完整的可执行 Go 二进制文件中,只有一个 firstmoduledata 包含如上完整的字段。简单介绍一下关键字段:
• 第 1 个字段 pclntable,即为 pclntab 的地址;
• 第 2 个字段 ftab,为 pclntab 中 Function Table 的地址(=pclntab_addr + 8);
• 第 3 个字段 filetab,为 pclntab 中 Source File Table 的地址;
• 第 5 个字段 minpc,为 pclntab 中第一个函数的起始地址;
• 第 7 个字段 text,在普通二进制文件中,对应于 [.text] section 的起始地址;在 PIE 二进制文件中则没有这个要求;
firstmoduledata
其第一个uintptr元素指向的位置,前4字节为pclntab的Magic Number
。所以以uintptr为单位遍历整个二进制文件找到符合这一点的地址作为可能的,firstmoduledata
起始地址。如果是真实的firstmoduledata,它内部是有几个字段可以跟pclntab中的数据进行交叉验证的,比如:
• firstmoduledata.ftab == pclntab_addr + 8
• firstmoduledata.filetab == firstmoduledata.ftab + pclntab.functab_size + sizeof(uintptr)
• firstmoduledata.minpc == firstmoduledata.text_addr == uintptr(pclntbl_addr + 8 + ADDR_SZ) == first function of pclntab.functab
当然,不一定要验证上面所有条件,验证其中一部分甚至一个关键条件,就可以确认当前地址是否为真正的firstmoduledata**。**go_parser中就是通过这种方法定位firstmoduledata然后根据不同版本从pclntable恢复函数名(以及函数地址范围)和源码路径。