Golang逆向分析 & 第五届XMan选拔赛 EzCM Writeup
ezCM
直至比赛结束,这道题目都是 0 解题,一方面是因为比赛时间较短,另一方面还是因为这道题目较难,考察了不常见的椭圆曲线算法(ECC),大大增加了对做题者的要求。
我也是第一次做题遇到 ECC,对此了解也不多,在查阅大量资料和请教了队内的密码学师傅(Dawn_whisper yyds),最终解出了此题,对于此题的思考过程做了一个整理,方便大家学习。但由于我对于 ECC 只是一个粗浅的理解,所以本篇文章在一些专业知识上有些可能会有所出错,请各位师傅指教!题目附件
题目信息
题目是使用 Golang 来编写的一个 CrackMe 程序,程序内符号没有被去除,所以这篇文章就不会讲解如何恢复 Golang 程序符号,另外 IDA Pro 7.6 已经支持 Golang 程序分析,打开就可以直接恢复被去除的符号信息。
题目要求打开一个 KeyFile ,并且通过读取其文件的内容来注册程序,我们要做的就是通过分析程序验证方式来编写一个 KeyFile,使其可以通过程序注册验证,最终拿到 flag 数据。
前置知识
由于是 Golang 的题目,在一些数据结构和调用约定上和大多数语言都不一样,所以一定不能过于的依赖伪代码,在调试过程中最好能够多关注汇编代码,这样在逆向过程中会快速掌握到核心。这部分内容参考学习了 panda0s - Golang underlying data representaion ,本来是不想把这部分内容放在这篇文章中的,但是由于关联性过大,所以不得不拿来饱满文章内容。
函数调用
在函数调用的过程中,无论是调用参数还是返回值都是通过栈来传递。
其传参的特征是
- 参数传递顺序是从右往左传递,而且不使用像是 push pop 这样的操作栈的指令,而是直接对栈上的内容进行修改。
- 参数传递一般都是借助一个寄存器中转,例如 rax、rcx,先把数据原来的储存位置的数据赋值到这个寄存器上,然后再把这个寄存器的内容赋值给栈上数据,并且如果数据是 0x10 size 的结构体,就会借助 xmm 寄存器中转来加速。
其返回值的特征是
- 返回值的位置紧贴着在最后一个参数的地址之后。以上图为例,最后一个参数的地址是 rsp + 0x250 - 0x248 + 0x8 = rsp + 0x10,所以这里的返回值的地址就是在 rsp + 0x250 - 0x238 = rsp + 0x18,有多个返回值的情况也是类似。
方法调用
在上图中,严格意义上并不是一次函数调用,而是一次方法调用。他是对 MyMainWindow 这个对象下的 Academy 方法进行了调用,这个传入的参数就是这个对象的指针,像是 this 一样。这个对象的指针就相对于函数调用的第一个参数。
String 字符串
String 结构
|
|
所以 Golang 程序在传递字符串的时候,同时也会在后续跟一个参数,这个参数指的就是字符串的长度。同时由于这样的机制,使得字符串的内容在内存中分布不需要截止符’\x00'
Slice 切片
在其他语言中(例如 python),Slice 是一种切片的操作,切片之后可以返回一个新的数据对象,但是 Golang 中的 Slice 不仅仅是一种切片的操作,更像是一种灵活的数据结构。
了解 Slice 结构后,在 IDA 中修改对应的变量类型,可以大大加快分析速度。
Slice 结构
|
|
Pointer:指向 Slice 底层数组的元素开始位置的指针
Length:Slice 的当前长度
Capacity:Slice 底层数组的最大长度,超过此长度会自动扩展
初始化 Slice
|
|
这表示先声明一个长度为 5、数据类型为 int 的底层数组,然后从这个底层数组中从前向后取 3 个元素作为 slice 的结构(length = 3,cap = 5)
make 最底层调用 runtime_makeslice 分配空间,这个函数返回的是指向内部数组的指针
访问 Slice
|
|
在访问 Slice 中元素时,会检测是否越界如果越界则调用 runtime_panicIndex
append / copy
当 Length 已经等于 Capacity 的时候,再使用 append 给 slice 追加元素,会调用 runtime_growslice 进行扩容。
在代码中的表现是在 append / copy 的时候会检测,slice.len + 1 与 slice1.cap 的大小关系
如图在把 slice 转换为字符串的过程前,由于将要 copy slice,所以会对传参 len 进行检测。
切片截取
|
|
myvar 是新一个新的切片结构 dataPtr = &slice1.dataPtr[1],相当于给了一个底层数组的指针 len = b - a cap = slice1.cap - a
寻找关键函数
对于这种有界面的程序,和常规的只有一个控制台的题目不同的是,题目的关键信息不是直接存在于 main 函数中,所以我们首先要做的就是定位到题目的关键位置。
而在这道题里,我们的突破口就是在没有选择文件的情况下,点击“Register”就会弹出的信息框“Cannot open target file.”
我们可以利用 IDA 的 Shift + F12 热键调出 Strings 窗口来查找“Cannot open target file.”字符串
搜索字符串后,我们再双击进入,在前面自动生成的名称处按下 X 热键查找交叉引用
对于每一处引用我们都前去查看,最终找到了关键的代码位置(截图仅截取部分代码)
我们接下来对函数内容进行分步骤的解析
KeyFile 格式解析
特征格式
这部分内容虽然伪代码看起来混乱,但是大概可以猜测是开头和结尾的特征字符
|
|
通过这两个特征读取出关键的秘钥信息 xxx,然后传入到后续函数,这样的标记格式在其他地方也很常见,所以这里不着重分析。
解密核心数据
在后续函数中的对秘钥核心数据做了一个解码
这里上述代码中可以看出,程序先通过一个 base64 解密对中间部分内容进行解码,然后以两个字节为一个单位进行解密,对第一字节异或 0xAA 得到数据,并且对第二字节异或 0x55,与第一字节的内容进行比对,如果不同则直接退出。
数据结构格式
解密后的数据是如何在存放的,分别又代表着那些信息?想要知道这些就要分析接下来所做的代码。
代码中由于对切片做了很多索引操作,所以有各种各样的越界检测,我们抛开这部分代码来看,就可以看出 Username 和 Organization 的储存结构 —— 第一个字节存放字符串长度,后续跟字符数据。
根据前置知识中切片的相关知识,这里调用 math_big_nat_setBytes 的切片内容我们可以大致还原,主要就是根据切片的 len 和 cap 来确定,
切片左边的值:由来源切片的 cap 减去的内容
切片右边的值:由来源切片左边的值 + 新切片的 len
所以 main_a 和 main_b 的内容来源于新的切片内容分别是
|
|
这部分内容可以结合上面的伪代码结合得出,也就是 Username 和 Organization 后的 8 字节是 main_a 的内容,再后 8 字节是 main_b 的内容,最后 4 字节是 main_expire 的内容。
这里需要注意的是,main_a 和 main_b 的内容都是以字节的形式直接转换为大数类型,而 main_expire 是以 WORD 的形式读取(使用 2 字节),这两种读取方式的字节序不同。
数据表
综合上面所说的,可以得出以下表格来记录 KeyFile 文件内加密数据格式
偏移 | 内容 | 变量名 | 长度 |
---|---|---|---|
0 | Username 长度 | name_len | 1 |
1 | Username 内容 | str_name | name_len |
1 + name_len | Organization 长度 | org_len | 1 |
2 + name_len | Organization 内容 | str_org | org_len |
2 + name_len + org_len | 内容 A | main_a | 8 |
10 + name_len + org_len | 内容 B | main_b | 8 |
18 + name_len + org_len | 过期时间 | main_expire | 2 |
验证逻辑
约束条件
了解了程序如何解析 KeyFile 后,接下来才是本文最关键的地方,也就是程序的验证方法。
首先会把 main_a 和 main_b 的内容相加,然后与 13417336609348053335(0xba33f48ee008e957)进行比对,如果相同则进入后续的判定,这就是对 a 和 b 之间关系的一个约束。
初始化大数
接下来又对三个大数进行了初始化,我把这几个常量去 Google 搜索了一下,发现 main_p 的值是在 GF(p) 上的椭圆曲线中常用的一种取值,这对于我们了解接下来的代码的大致内容有所帮助。
这样根据常量来猜测程序意义的方法也是常用的,这里就是借助了椭圆曲线中常见的 p。
验证代码
最终的检测代码就是判断题目 public_key 是否在椭圆曲线(y^2 = x^3 + ax + b)上,其中 a 和 b 是用户可控的值,我们现在有一个在椭圆曲线上的点 生成元 G ,那么我们就可以根据这个 G 点的值和 a + b 的约束来反推 a 和 b 的值。
|
|
推导过程只是我粗浅的理解,所以可能不是很规范,但是表明了如何推出 a 的值,有了 a 的值后,我们直接相减就可以计算得到 b 的值。
注册机编写
通过上述的逻辑和整理,我们可以快速的编写出一个 Keygen,我的代码如下
|
|
接下来把 KeyFile 拖入程序,点击 Register,即可成功通过验证得到 Flag
可以发现 flag 的值其实就是 main_a 和 main_b 的 hex 编码后的值,这样可以保证 flag 的唯一性。
总结
在这之前其实也遇到过一些 Golang 的题目,但是因为 Golang 难以分析,所以这些题目的核心算法相对来说都比较简单,都是一些比较简单的逻辑问题。这道题虽然分析过程看似简单轻松,但是实际上我对其内涵的原理和程序的用法进行了深入的研究,消耗了大量的时间和精力。虽然本题最终展现的并不是一个 ECC 难题,但是在逆向分析的过程中,我也学习到了一些 ECC 的内涵和代码实现。希望各位师傅可以借此题来开篇学习 Golang 逆向和 ECC 算法。