无符号Golang程序逆向方法解析

在去年的inctf2018中,出现了一道Go语言编写的进程通信逆向题,无论是从题目整体设计还是解题思路上来说都独树一帜,自己在解题过程中遇到了很多问题,但我这不打算做过多探讨,网上也有大佬的解题过程,本文仅针对该题涉及到的无符号Go语言恢复信息问题进行详细讨论。

前言

在整个后期整理过程中,自己参考了很多资料,现放出所有链接,下文中也会有对应的说明。

 

初步分析

首先用file命令简单查看下文件类型,发现是64位的,由题目名也能猜出是和go语言有关的逆向题,但是发现虽然是动态链接的,但是无符号,这是比较坑的,心中有点小怕。

$ file ../ultimateGOal
../ultimateGOal: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, stripped

那么用ida打开之后,由于没有符号的原因,我们并不能找到主函数的位置,不熟悉go语言逆向的同学可能会不清楚go语言逆向的入口,那么简单说明一下。

简单demo测试

对下面这个简单的go语言例子而言,我们在进行编译go程序的时候,会生成可执行文件,而go程序需要满足2个条件:

  1. go程序中需要包含main包
  2. 在main包中还必须包含main函数
package main
import  "fmt"  
func main() {
  fmt.Println("Hello World!")
}

也就是说go语言的入口点是main.main(真正的入口点),即是main包下的main函数。对这个demo,我们编译之后用ida打开查看,搜索主函数,当确定了主函数入口时,尝试反编译代码,缺发现失败,如下图所示:

主函数

给出的提示是Size of 'int' is 8 (4 expected),对于这种错误,通过选项中的编译设置,修改整型的字长大小即可解决问题,如下图所示。

反编译设置

得到如下的代码:

void __cdecl main_main()
{
  __int64 v0; // rdx
  error_0 v1; // di
  __interface_{} ST00_24_2; // ST00_24
  __interface_{} v3; // [rsp+30h] [rbp-18h]
  void *retaddr; // [rsp+48h] [rbp+0h]

  while ( (unsigned __int64)&retaddr <= *(_QWORD *)(__readfsqword(0xFFFFFFF8) + 16) )
    runtime_morestack_noctxt();
  v3.array = (__DIE_10518_interface_{} *)&r3;
  v3.len = (__int64)&main_statictmp_0;
  ST00_24_2.array = (__DIE_10518_interface_{} *)&v3;
  ST00_24_2.len = 1LL;
  ST00_24_2.cap = 1LL;
  fmt_Println(ST00_24_2, v1, v0);
}

对于ida反编译出的go语言代码,可读性较差,类型转换较多,结构体较复杂,异常处理比较冗长,对于main_statictmp_0这个结构体,在go语言中包括了2部分,分别是字符串偏移量和字符串长度,这个结构体所指向的是string <offset unk_4B317B, 0Dh>,指出了字符串的偏移量和长度,长度是0xd(即”Hello World!”的长度),另外go语言中所有字符串都是放在一起的,所以偏移量和长度是很关键的,如下图所示:

字符串

 

解决无符号的问题

对于上文那个demo而言,是有符号的程序,逆向起来有信息可以参考,大大加快了逆向的速度,但是对这道题而言,是无符号的,无符号的程序逆向起来由于不知道函数的功能,会特别饶,很容易把人绕进去。如下图所示:

所有函数

当然也有人能纯静态分析出来,比如奈沙夜影师傅,参考最上面贴出来的链接,使用动态调试结合黑盒测试的方法Orz。师傅也自嘲道:Go语言的逆向感觉目前没啥方便的工具,只能硬怼汇编,缺少符号的情况下还是有点麻烦的,等一个师傅们的指教,这个题目我是纯黑盒调试出来的。针对这个问题,自己参考了几篇博文,收集了一些方法,供参考。

从签名角度出发

众所周知,签名是对函数、第三方库很好的检测方式,通过签名,ida很容易能分析出函数的名称,从而大概知道函数的作用。由于没有官方的签名库,所以这需要自己制作,参考了diffway@兰云科技银河实验室的方法,论述如下:

idb2pat.py+sigmake制作签名

idb2pat.py是火眼公司FireEye Labs Advanced Reverse Engineering团队编写的脚本,代码在GitHub上开源,该脚本主要通过CRC16的方式来计算每个函数块的特性,从而来识别不同的函数。这点也和IDA官方对签名文件的说明相符合,参见IDA F.L.I.R.T. Technology: In-Depth

CRC16

通过生成pat文件后,再使用ida SDK中提供的sigmake工具来生成相应的sig签名文件,将其复制到ida安装目录下的sigpc目录,然后我们在ida中就可以载入签名进行识别。在2088个函数中,识别出1271个函数,但是不是每个识别出的函数都能有效的命名,所以实际识别出的函数个数也就600左右,识别率较低,而且只能识别出被制作签名的程序所带的包,如果另一个程序使用了其他的包,那将无法识别,拓展性较低。

方法一

bindiff或者diaphora对比

通过bindiff或者diaphora来对比不同是ida数据库,以获取函数的特征也是种很好的方法,这种方法在平时分析静态链接的程序也很有用。但是存在的问题也很明显,由于diaphora是python编写的,所以运行速度是肉眼可见的慢,对于1000数量级的函数保存到数据库中竟然也要3分钟左右,保存完之后,再分析对比的时候,需要更多时间,效率很低。当数据完成对比之后,我们得到情况如下图所示:

方法二

主要分为五栏:完全匹配、部分匹配、只在第一个数据库出现的、只在第二个数据库出现的以及不可靠匹配的。对我们来说,我们只需要关注匹配率高的即可,所以我们首选对完全匹配中的函数进行重命名,方法很简单,就是选中所有的完全匹配的函数,然后右键导入即可。
该方法的主要问题是速度太慢,不管是在前期初始化数据库的时候,还是后面重命名函数的时候,非常卡顿。其次是对函数类别没有很好的区分度,如下图所示,同样都是运行时函数,对函数类别处理不好,也不能对主函数进行区别,和第一种方式一样,识别率不高,只能识别出被制作签名的程序所带的部分库函数。

方法二函数

Rizzo插件生成数据库识别

关于rizzo,可以参看GitHub上的介绍Rizzo,同样也是一种对ida数据库进行保存然后提取信息进行对比的工具,收录于devttys0的ida脚本目录中。自己也进行了测试,速度还可以。

Building Rizzo signatures, this may take a few minutes...
Generated 1314 formal signatures and 844 fuzzy signatures for 1784 functions in 10.64 seconds.
Saving signatures to C:UsersxxxxxxDesktop11111.riz... done.
Built signatures in 12.30 seconds

那么在保存完之后,载入riz文件进行测试,如下图所示。基本识别情况和上面2种方式差不多,也存在对原始文件的局部依赖性,所不同的是,这种方式不会误识别,而前面2种方式会产生很多未知的函数命名情况,歧义性较低。猜测是前面2种方法对模糊测试效果更好,第三种方式属于保守型的测试方法,会将极大概率的函数进行重命名,而较低的大概率函数则不会通过。

方法三

从Golang特性出发

上面的3种方法虽然能对部分函数进行识别,但是效果一半,前2种方法识别率大概有50%左右,第3种只有20%左右。且三者均不能对签名生成程序中没有的函数进行识别,也就是说连每个程序中的主函数都无法定位,因为每个程序中的主函数均不一致。下面将从Go语言的特性来解决这个问题。

GolangAssist脚本

在网上有一篇著名的go语言逆向解析博文,安全客中也有人提供了翻译,链接参见前言部分。该作者对golang的编译原理有着较深的理解,同时其提供了golang_loader_assist.py脚本用于还原符号信息,这对逆向而言真是再好也不为过了。但是这个脚本无法恢复Windows下编译的go程序,因为这个脚本中最重要的部分,是找到go语言程序中一个非常重要的段,叫做.gopclntab,在这个段中保存了函数的实际名称,而在Windows下编译的程序中则不存在这个段。
在这个脚本中最终的部分如下图所示:

关键脚本

首先通过get_segm_by_name('.gopclntab')来定位到.gopclntab段的首地址。然后寻找偏移量是8的地方,根据程序字长来创建指针,再接下来一个字长则给出了.gopclntab段的大小,这样我们就能开始逐个处理每条数据,对每条数据而言,其中都包含了一个函数指针和一个函数名字符串偏移量,循环处理这些数据就能对所有的函数名进行还原,得到带符号信息的函数名称。

ida-bug-fix

在实际使用这个脚本进行测试的过程中,需要注意的是,由于ida7.0对sdk和api的大量更新和重新,在idapython中的创建字符串函数MakeStr出错,主要原因是函数的重复定义,参看前言中看雪论坛的相关讨论,修改方式如上图所示。

自己将最关键的部分代码进行了分析和抽取,脚本如上,这段代码可直接在ida中运行,用以定位函数名偏移量和修改函数名,但是注意最后这个地方函数名修改会有点问题,原因是函数名中除了下划线不能出现其他字符,但是当我们运行完毕后,很多函数名是存在特殊符号的,需要自己过滤。

gopclntab = get_segm_by_name('.gopclntab')
if gopclntab is not None:
    base = gopclntab.startEA + 8
    # 计算函数名个数
    count = Dword(base)
    # 基地址
    base += 8
    for i in xrange(0, 2 * count, 2):
        # 创建函数指针
        MakeQword(base + 8*i)
        functionAddr = Qword(base + 8*i)
        # 创建函数名字符串偏移量(相对于gopclntab基地址而言)
        MakeQword(base + 8*i + 8)
        offset = Qword(base + 8*i + 8)
        offset = Dword(offset + gopclntab.startEA + 8)
        # 函数名字符串偏移量(文件偏移量FOA)
        functionName = offset + gopclntab.startEA
        name = GetString(functionName)
        # 创建字符串
        MakeStr(functionName, functionName + len(name))
        # 修改函数名(ida禁止函数名出现特殊符号,需过滤后才能达到100%效果,我这里没有过滤)
        MakeName(functionAddr, name)

运行结果如下图所示:

运行结果

通过分析go语言特有的.gopclntab段,我们可以恢复调试符号信息,只有该段中保存的信息均可以进行恢复,恢复率达到98%以上。

IDAGolangHelper脚本

刚刚讨论完了GolangAssist,效果是非常不错的,而作为GolangAssist的升级版本,IDAGolangHelper做的则更加完善,该脚本的作者在2016年底的zeronights会议中展示了他的成果,有兴趣的同学可以参看他的PPT,其实整个脚本的思路和上面一样,同样是通过.gopclntab这个段所保存的符号信息来获取函数信息,如下图所示。

PPT1

除此之外,作者进一步分析了go语言的版本问题,通过2种方式来确定当前程序的go语言版本,一是通过特征字符串的来进行查找,二是通过分析go中特有的结构体类型,由于不同版本之间有结构体会产生变化,作者提出了这种思路来确定版本信息。

PPT2

而通过实际分析证明,第一种方法,即特征字符串的方式来查找版本还是会更高效,更准确些,相比而言,第二种方法由于版本之间的差异不多,则会导致歧义。下图是使用效果,当我们载入该脚本后,第二种方式只能判断是go1.8或1.9或1.10,但是特征字符串则较好的确定是go1.9版本。在重命名函数后,再搜索main字符串,就能定位到main包中的所有函数了。

效果

其他方法

当然无符号问题解决的方法还有很多,比如著名符号执行引擎angr中就使用了基于函数语义识别库函数,也有人对源码进行了分析。函数语义就是分析函数的功能和执行的操作,包括寄存器、内存、堆栈和对其他函数的调用流,作为人去分析函数的时,也是类似的,所以感觉这里也可以用机器学习的方法来进一步提高分析效率。
当然学术界也有对这方面的研究,比如二进制代码函数相似度匹配技术研究这篇论文,通过函数的序言部分的特征,提出了二阶段函数匹配方法TPM,在识别出相似函数后,找到其调用关系和决策规则,然后递归的识别不同版本的函数,据论文所述,识别准确率平均高于diaphora和patchdiff方法,也是值得借鉴的。

 

结语

至此,提出的这么多方法,就能较好的解决Go语言程序逆向中的无符号的问题了。而其实对无符号程序的分析一直是逆向工程中的一个难点,如何有效的分析无符号的程序也一直是我们所关心的问题。那么总结起来无外乎以下几点。

  1. 从各种语言本身特性出发。比如本文详细讨论的Go语言特性,由于特定段保存了符号信息,从而可以进行恢复。
  2. 从代码复用和库函数检测出发。将程序中未知函数和已知功能函数进行某种方式下的对比,比如hash计算、匹配签名、等等。
  3. 从函数语义分析出发。也是最常见的硬核分析手段,就是一个一个函数分析其代码实现和功能,从而推断函数的作用,积少成多,就能对整个程序进行逆向。毫无疑问,也可以将人工智能结合到这种方式中,仿照逆向工程师的思路来进行深度学习的,可能也是学术界未来的一个研究方向吧。
(完)