正向角度看Go逆向

Go语言具有开发效率高,运行速度快,跨平台等优点,因此正越来越多的被攻击者所使用,其生成的是可直接运行的二进制文件,因此对它的分析类似于普通C语言可执行文件分析,但是又有所不同,本文将会使用正向与逆向结合的方式描述这些区别与特征。

一、语言特性

Ⅰ. Compile与Runtime

Go语言类似于C语言,目标是一个二进制文件,逆向的也是native代码,它有如下特性:

  1. 强类型检查的编译型语言,接近C但拥有原生的包管理,内建的网络包,协程等使其成为一款开发效率更高的工程级语言。
  2. 作为编译型语言它有运行速度快的优点,但是它又能通过内置的运行时符号信息实现反射这种动态特性。
  3. 作为一种内存安全的语言,它不仅有内建的垃圾回收,还在编译与运行时提供了大量的安全检查。

可见尽管它像C编译的可执行文件但是拥有更复杂的运行时库,Go通常也是直接将这些库统一打包成一个文件的,即使用静态链接,因此其程序体积较大,且三方库、标准库与用户代码混在一起,需要区分,这可以用类似flirt方法做区分(特别是对于做了混淆的程序)。在分析Go语言编写的二进制程序前,需要弄清楚某一操作是发生在编译期间还是运行期间,能在编译时做的事就在编译时做,这能实现错误前移并提高运行效率等,而为了语言的灵活性引入的某些功能又必须在运行时才能确定,在这时就需要想到运行时它应该怎么做,又需要为它提供哪些数据,例如:

func main() {
    s := [...]string{"hello", "world"}
    fmt.Printf("%s %s\n", s[0], s[1])  // func Printf(format string, a ...interface{}) (n int, err error)
}

在第二行定义了一个字符串数组,第三行将其输出,编译阶段就能确定元素访问的指令以及下标访问是否越界,于是就可以去除s的类型信息。但是由于Printf的输入是interface{}类型,因此在编译时它无法得知传入的数据实际为什么类型,但是作为一个输出函数,希望传入数字时直接输出,传入数组时遍历输出每个元素,那么在传入参数时,就需要在编译时把实际参数的类型与参数绑定后再传入Printf,在运行时它就能根据参数绑定的信息确定是什么类型了。其实在编译时,编译器做的事还很多,从逆向看只需要注意它会将很多操作转换为runtime的内建函数调用,这些函数定义在cmd/compile/internal/gc/builtin/runtime.go,并且在src/runtime目录下对应文件中实现,例如:

a := "123" + b + "321"

将被转换为concatstring3函数调用:

0x0038 00056 (str.go:4) LEAQ    go.string."123"(SB), AX
0x003f 00063 (str.go:4) MOVQ    AX, 8(SP)
0x0044 00068 (str.go:4) MOVQ    $3, 16(SP)
0x004d 00077 (str.go:4) MOVQ    "".b+104(SP), AX
0x0052 00082 (str.go:4) MOVQ    "".b+112(SP), CX
0x0057 00087 (str.go:4) MOVQ    AX, 24(SP)
0x005c 00092 (str.go:4) MOVQ    CX, 32(SP)
0x0061 00097 (str.go:4) LEAQ    go.string."321"(SB), AX
0x0068 00104 (str.go:4) MOVQ    AX, 40(SP)
0x006d 00109 (str.go:4) MOVQ    $3, 48(SP)
0x0076 00118 (str.go:4) PCDATA  $1, $1
0x0076 00118 (str.go:4) CALL    runtime.concatstring3(SB)

我们将在汇编中看到大量这类函数调用,本文将在对应章节介绍最常见的一些函数。若需要观察某语法最终编译后的汇编代码,除了使用ida等也可以直接使用如下三种方式:

go tool compile -N -l -S once.go
go tool compile -N -l once.go ; go tool objdump -gnu -s Do once.o
go build -gcflags -S once.go

Ⅱ. 动态与类型系统

尽管是编译型语言,Go仍然提供了一定的动态能力,这主要表现在接口与反射上,而这些能力离不开类型系统,它需要保留必要的类型定义以及对象和类型之间的关联,这部分内容无法在二进制文件中被去除,否则会影响程序运行,因此在Go逆向时能获取到大量的符号信息,大大简化了逆向的难度,对此类信息已有大量文章介绍并有许多优秀的的工具可供使用,例如go_parserredress,因此本文不再赘述此内容,此处推荐《Go二进制文件逆向分析从基础到进阶——综述》。

本文将从语言特性上介绍Go语言编写的二进制文件在汇编下的各种结构,为了表述方便此处定义一些约定:

  1. 尽管Go并非面向对象语言,但是本文将Go的类型描述为类,将类型对应的变量描述为类型的实例对象。
  2. 本文分析的样例是x64上的样本,通篇会对应该平台叙述,一个机器字认为是64bit。
  3. 本文会涉及到Go的参数和汇编层面的参数描述,比如一个复数在Go层面是一个参数,但是它占16字节,在汇编上将会分成两部分传递(不使用xmm时),就认为汇编层面是两个参数。
  4. 一个复杂的实例对象可以分为索引头和数据部分,它们在内存中分散存储,下文提到一种数据所占内存大小是指索引头的大小,因为这部分是逆向关注的点,详见下文字符串结构。

二、数据结构

Ⅰ. 数值类型

数值类型很简单只需要注意其大小即可:

类型 32位平台 64位平台
bool、int8、uint8 8bit 8bit
int16、uint16 16bit 16bit
int32、uint32、float32 32bit 32bit
int64、uint64、float64、complex64 64bit 64bit
int、uint、uintptr 32bit 64bit
complex128 128bit 1238bit

Ⅱ. 字符串string

Go语言中字符串是二进制安全的,它不以\0作为终止符,一个字符串对象在内存中分为两部分,一部分为如下结构,占两个机器字用于索引数据:

type StringHeader struct {
    Data uintptr            // 字符串首地址
    Len  int                // 字符串长度
}

而它的另一部分才存放真正的数据,它的大小由字符串长度决定,在逆向中重点关注的是如上结构,因此说一个string占两个机器字,后文其他结构也按这种约定。例如下图使用printf输出一个字符串"hello world",它会将上述结构入栈,由于没有终止符ida无法正常识别字符串结束因此输出了很多信息,我们需要依靠它的第二个域(此处的长度0x0b)决定它的结束位置:

字符串常见的操作是字符串拼接,若拼接的个数不超过5个会调用concatstringN,否则会直接调用concatstrings,它们声明如下,可见在多个字符串拼接时参数形式不同:

func concatstring2(*[32]byte, string, string) string
func concatstring3(*[32]byte, string, string, string) string
func concatstring4(*[32]byte, string, string, string, string) string
func concatstring5(*[32]byte, string, string, string, string, string) string
func concatstrings(*[32]byte, []string) string

因此在遇到concatstringN时可以跳过第一个参数,随后入栈的参数即为字符串,而遇到concatstrings时,跳过第一个参数后汇编层面还剩三个参数,其中后两个一般相同且指明字符串个数,第一个参数则指明字符串数组的首地址,另外经常出现的是string与[]byte之间的转换,详见下文slice部分。提醒一下,可能是优化导致一般来说在栈内一个纯字符串的两部分在物理上并没有连续存放,例如下图调用macaroncontext.Query("username")获取到的应该是一个代表username的字符串,但是它们并没有被连续存放:

因此ida中通过定义本地结构体去解析string会遇到困难,其他结构也存在这类情况,气。

Ⅲ. 数组array

类似C把字符串看作char数组,Go类比知array和string的结构类似,其真实数据也是在内存里连续存放,而使用如下结构索引数据,对数组里的元素访问其地址偏移在编译时就能确定,总之逆向角度看它也是占两个机器字:

type arrayHeader struct {
    Data uintptr        
    Len int
}

数组有三种存储位置,当数组内元素较少时可以直接存于栈上,较多时存于数据区,而当数据会被返回时会存于堆上。如下定义了三个局部变量,但是它们将在底层表现出不同的形态:

func ArrDemo() *[3]int {
    a := [...]int{1, 2, 3}
    b := [...]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7}
    c := [...]int{1, 2, 3}
    if len(a) < len(b) {return &c}
    return nil
}

变量a的汇编如下,它直接在栈上定义并初始化:

变量b的汇编如下,它的初始值被定义在了数据段并进行拷贝初始化:

事实上更常见的拷贝操作会被定义为如下这类函数,因此若符号信息完整遇到无法识别出的函数一般也就是数据拷贝函数:

变量c的汇编如下,尽管它和a的值一样,但是它的地址会被返回,如果在C语言中这种写法会造成严重的后果,不过Go作为内存安全的语言在编译时就识别出了该问题(指针逃逸)并将其放在了堆上,此处引出了runtime.newobject函数,该函数传入的是数据的类型指针,它将在堆上申请空间存放对象实例,返回的是新的对象指针:

经常会遇到的情况是返回一个结构体变量,然后将其赋值给newobject申请的新变量上

Ⅳ. 切片slice

类似数组,切片的实例对象数据结构如下,可知它占用了三个机器字,与它相关的函数是growslice表示扩容,逆向时可忽略:

go

点击收藏 | 2 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖