长亭百川云 - 文章详情

Go逆向研究

RainSec

79

2024-07-13

基础信息

  go语言默认采用静态编译的策略,这意味着各种标准库和第三方库包括runtime和gc都会被全静态链接构建,这导致go二进制文件较大,同时go函数调用约定,数据结构和栈管理策略非常特殊,而且不同go版本之间的细节也存在很多差异,这一系列原因导致go逆向存在诸多难处。不过根据其特殊之处入手可以帮助进行符号恢复,字符串引用恢复等操作来帮助逆向工程师获得更好的体验。

calling convention

不同go版本之间存在一定差异。

stack

  对于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问题,这里就不细说,需要了解可以参考该链接。

call arguments and return value

  在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来保存浮点类型的参数和结果。

Type System

内建基础类型:

  • • 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:

https://go.dev/src/reflect/type.go

// 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的解析:

https://go.dev/src/runtime/symtab.go

//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, etypestypelinks,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

  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的两种情况在引用字符串时,字符串地址和长度都是地址相邻的(这个可能对后续详细识别引用有一定帮助)。

moduledata

  在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恢复函数名(以及函数地址范围)和源码路径。

相关推荐
关注或联系我们
添加百川云公众号,移动管理云安全产品
咨询热线:
4000-327-707
百川公众号
百川公众号
百川云客服
百川云客服

Copyright ©2024 北京长亭科技有限公司
icon
京ICP备 2024055124号-2