2024羊城杯chal
0x00 前言
Cython逆向,借机学一下:Cython 二进制库逆向分析全面指南
WP来自2024年羊城杯粤港澳大湾区网络安全大赛WP-Reverse篇(全网唯一chal题解的含金量)
本体对环境有要求,一定要是linux+python3.12的环境
0x01 解
根据给出的main.py,可以知道chal里面有个chal
1 | import chal |
开启python,导入一下chal库然后打印一下属性
1 | Python 3.12.5 | packaged by Anaconda, Inc. | (main, Sep 12 2024, 18:27:27) [GCC 11.2.0] on linux |
第一步可以发现chal实际上是chal模块里的一个类
第二步可以发现类里面有_p1
、_p2
、_p3
三个函数
第三步初始化以后多了_tips
、_var1
、_var2
、_var3
四个变量,其中_var1
存储的就是输入,_var2
和_var3
是固定数组
main.py里面实际上是用输入的flag初始化了一个chal.chal类,对flag的校验在类的__init__
函数中。而__init__
函数透露信息不多,因此归根到底得看伪代码()
C级别的全局变量赋值
字符串类型
在Cython中,使用函数_Pyx_CreateStringTabAndInitStrings
进行全局字符串赋值,在函数中,可以看到如下这个样子的函数
1 | v8 = _mm_unpacklo_epi64((__m128i)(unsigned __int64)&qword_28A98, (__m128i)(unsigned __int64)"AttributeError"); |
这个意思就是将"AttributeError"
这个字符串放入&qword_28A98
这个地址进行存储,后面需要用到这个字符串的时候将直接调这个地址
实际上,该函数中使用的字符串定义结构体是这样的(可以参考这个Cython程序分析_1):
1
2
3
4
5
6
7
8
9 typedef struct {
PyObject **p;
const char *s;
const Py_ssize_t n;
const char* encoding;
const char is_unicode;
const char is_str;
const char intern;
} __Pyx_StringTabEntry;而字符串是通过
__Pyx_StringTabEntry
的数组进行初始化的,也就是说当我们在该函数中看到以下伪代码时:
1
2
3
4
5
6
7
8
9
10
11 __m128i v8;
__int64 v9;
__int64 v10;
__int16 v11;
char v12;
v8 = _mm_unpacklo_epi64(&qword_28A98, "AttributeError");
v9 = 15LL; // len
v10 = 0LL;
v11 = 0x100; // 1 and 0 (binary = 0000 0001 0000 0000)
v12 = 1;就代表这是一个
{&qword_28A98, "AttributeError", 15, 0, 1, 0, 1}
的__Pyx_StringTabEntry
,也就是说qword_28A98
中将要初始化一个内容是"AttributeError"
的字符串对象的地址,在后续调用中,调用到AttributeError字符串的地方都会用&qword_28A98
指代。
整数类型
在函数_pyx_pymod_exec_chal
中(最后是库名字,只要找__pyx_pymod_exec_
即可),第205行左右,可以找到形如这样的内容:
1 | qword_28DE8 = PyLong_FromLong(0LL); |
在
__pyx_pymod_exec_test2()
函数的前半段都是检查和初始化。这里可以搜索到初始化的Python int
型变量(PyLong_FromLong
)。
这个地方就是赋值了一个整数类型,qword_28DE8
中将存储一个值为0
的整数类型的Python对象。
而在983行左右的位置可以看到这样的内容
1 | qword_29600 = PyLong_FromString("2654435769", 0LL, 0LL); |
这是大数的存储方式,大数会用PyLong_FromString
函数来初始化,这里qword_29600
中将存储一个值为2654435769
的整数类型的Python对象,后续用到2654435769
的地方将使用qword_29600
。
内建函数/变量
在函数_pyx_pymod_exec_chal
中,第1042行左右,可以找到如下内容(在某些优化下也会直接嵌入 _pyx_pymod_exec_chal
):
1 | if ( (int)_Pyx_InitCachedBuiltins() < 0 ) |
此处是初始化内建函数
事实上,在函数
_Pyx_InitCachedBuiltins()
中可以看到形如以下的内容:
1
2
3 _pyx_builtin_AttributeError = _Pyx_GetBuiltinName(qword_28A98);
if ( !_pyx_builtin_AttributeError )
return 0xFFFFFFFFLL;
qword_28A98
就是前面的"AttributeError"
字符串,这里是通过名字找到AttributeError
对象,并赋值给_pyx_builtin_AttributeError
,后续用到AttributeError
对象的地方将使用_pyx_builtin_AttributeError
。
常量
在函数_pyx_pymod_exec_chal
中,第1051行左右,可以找到如下内容(在某些优化下也会直接嵌入 _pyx_pymod_exec_chal
):
1 | inited = _Pyx_InitCachedConstants(); |
此处是初始化常量
在函数
_Pyx_InitCachedConstants()
中可以找到形如以下的内容:
1
2
3
4 >qword_29630 = PyTuple_Pack(2LL, qword_29600, qword_29618);
>// 我这个地方是只有qword_29600,但是汇编是能看到qword_29618的,怀疑是IDA反编译的锅
>if ( !qword_29630 )
return 0xFFFFFFFFLL;
qword_29600
就是前面的值为2654435769
的整数类型的Python对象,同理qword_29618
就是值为3337565984
的整数类型的Python对象。这里将这两个对象打包成了一个长度为2
的元组(2654435769, 3337565984)
,并赋值给qword_29630
变量,后续用到这个元组的地方将使用qword_29630
。
函数声明及定义
声明在
_Pyx_InitCachedConstants
, called by_pyx_pymod_exec_chal
(在某些优化下也会直接嵌入
_pyx_pymod_exec_chal
)定义在
_pyx_pymod_exec_chal
即函数的声明和常量在同一个函数中进行初始化,常量上文给出了例子,函数的声明则如下:
1 | qword_29660 = PyTuple_Pack(7LL, qword_28C98); |
将变量恢复一下符号(转成能看的样子)如下(来自2024年羊城杯粤港澳大湾区网络安全大赛WP-Reverse篇):
1 | // *** _Pyx_InitCachedConstants *** |
_Pyx_PyCode_New_constprop_0
用于创建一个PyCodeObject
,其参数就是PyCodeObject
的各属性,具体可参考各版本cpython源码中对PyCodeObject
的定义,这里就是以v1
元组为参数+局部变量名(前3
个为参数),原Python函数第一行在文件中的第19
行(qword_28A78
是()
(空的tuple,见_pyx_pymod_exec_chal
的第155行左右),qword_28A80
是""
,无内容不用关注)创建了一个名为_p1
的函数PyCodeObject
,相当于是函数声明(因为co_code
字段是空的,没有指定具体行为)。PyCodeObject结构体的定义如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 typedef struct {
PyObject_HEAD /* 头部信息, 我们看到真的一切皆对象, 字节码也是个对象 */
int co_argcount; /* 可以通过位置参数传递的参数个数 */
int co_posonlyargcount; /* 只能通过位置参数传递的参数个数, Python3.8新增 */
int co_kwonlyargcount; /* 只能通过关键字参数传递的参数个数 */
int co_nlocals; /* 代码块中局部变量的个数,也包括参数 */
int co_stacksize; /* 执行该段代码块需要的栈空间 */
int co_flags; /* 参数类型标识 */
int co_firstlineno; /* 代码块在对应文件的行号 */
PyObject *co_code; /* 指令集, 也就是字节码, 它是一个bytes对象 */
PyObject *co_consts; /* 常量池, 一个元组,保存代码块中的所有常量。 */
PyObject *co_names; /* 一个元组,保存代码块中引用的其它作用域的变量 list of strings (names used) */
PyObject *co_varnames; /* 一个元组,保存当前作用域中的变量 tuple of strings (local variable names)*/
PyObject *co_freevars; /* 内层函数引用的外层函数的作用域中的变量 tuple of strings (free variable names)*/
PyObject *co_cellvars; /* 外层函数中作用域中被内层函数引用的变量,本质上和co_freevars是一样的 tuple of strings (cell variable names)*/
Py_ssize_t *co_cell2arg; /* 无需关注 */
PyObject *co_filename; /* 代码块所在的文件名 */
PyObject *co_name; /* 代码块的名字,通常是函数名或者类名 */
PyObject *co_lnotab; /* 字节码指令与python源代码的行号之间的对应关系,以PyByteObject的形式存在 */
//剩下的无需关注了
void *co_zombieframe; /* for optimization only (see frameobject.c) */
PyObject *co_weakreflist; /* to support weakrefs to code objects */
void *co_extra;
unsigned char *co_opcache_map;
_PyOpcache *co_opcache;
int co_opcache_flag;
unsigned char co_opcache_size;
} PyCodeObject;示例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 PyCode_New(
0, /*int argcount,*/
0, /*int kwonlyargcount,*/
0, /*int nlocals,*/
0, /*int stacksize,*/
0, /*int flags,*/
empty_string, /*PyObject *code,*/
empty_tuple, /*PyObject *consts,*/
empty_tuple, /*PyObject *names,*/
empty_tuple, /*PyObject *varnames,*/
empty_tuple, /*PyObject *freevars,*/
empty_tuple, /*PyObject *cellvars,*/
py_srcfile, /*PyObject *filename,*/
py_funcname, /*PyObject *name,*/
lineno, /*int firstlineno,*/
empty_string /*PyObject *lnotab*/
);
而根据对_Pyx_PyCode_New_constprop_0
函数的结果(qword_29688
)进行索引,可以在_pyx_pymod_exec_chal
中找到
1 | v48 = _Pyx_CyFunction_New_constprop_0(&_pyx_mdef_4chal_4chal_3_p1, qword_28B28, qword_28B18, _pyx_mstate_global_static, qword_29688); |
对应恢复符号后的结果为:
1 | v48 = _Pyx_CyFunction_New_constprop_0(&_pyx_mdef_4chal_4chal_3_p1, chal__p1, chal, _pyx_mstate_global_static, qword_29688); |
而在这下面有
1 | v49 = (unsigned int)PyObject_SetItem(v7, qword_28C28, v48) >> 31; |
同样,对应恢复符号后的结果为
1 | v49 = PyObject_SetItem(v7, p1, v48) >> 31; // self._p1 = v559 |
(不知道为什么我这边解析出的变量名和WP有出入,不过基本上都对的上,这边改成了我的变量名了)
cython中一般使用PyMethodDef
进行指定,PyMethodDef结构体源码(Include/methodobject.h)如下:
1 | typedef PyObject *(*PyCFunction)(PyObject *, PyObject *); |
PyMethodDef 是一个 C结构体,用来完成一个映射,也就是便于方法查找,我们把需要被外面调用的方法都记录在这表内。
PyMethodDef 结构体成员说明:
- 第一个字段:在 Python 里面使用的方法名;
- 第二个字段:C 模块内的函数名(已经实现了的函数名);(IDA中可以由此找到函数体所在的位置)
- 第三个字段:方法参数类型,是无参数(METH_NOARGS) , 还是有位置参数(METH_VARARGS), 还是其他等等;
- 第四个字段:方法描述,就是通过 help() 或者 doc 可以看到的;
正因为存在这样的一份记录表,Python 才能够寻找到相应的函数
伪代码中的_pyx_mdef_4chal_4chal_3_p1
就是一个PyMethodDef
:
1 | .data:00000000000289C0 __pyx_mdef_4chal_4chal_3_p1 dq offset aChalChalP1+0Ah |
_p1
的函数体实际上在__pyx_pw_4chal_4chal_3_p1
中。
import
在函数_pyx_pymod_exec_chal
中
可以找到如下内容:
1 | v22 = _Pyx_ImportDottedModule_constprop_0(qword_28C70); |
通过手动恢复符号,可以发现qword_28C70
对应的字符串为"random"
,说明其对应的就是python中的import random
的操作
Python代码的转译
对象变量赋值
在函数_pyx_pw_4chal_4chal_1__init__
中,第358行左右,可以找到如下内容:
1 | v22 = PyObject_SetAttr(v19, qword_28D50, v20); // qword_28D50 = "_var1" |
直接用了PyObject_SetAttr
函数,其实反编译过来就是self._var1 = v20
。
数组赋值
在函数_pyx_pw_4chal_4chal_1__init__
中,第369行左右,可以找到如下内容:
1 | v23 = (__int64 *)PyList_New(32LL); |
即最后将成型的列表v23
赋给self._var2
,这里还原后的结果就是self._var2 = v23 = [121, ...]
参数解析
在函数_pyx_pw_4chal_4chal_1__init__
可以找到如下内容:
1 | v15 = *(_QWORD *)(a4 + 16); |
这是对__init__
函数传入参数的解析,解析的结果赋值给其他变量
函数调用
在进行函数调用的例子之前,需要
在函数_pyx_pw_4chal_4chal_1__init__
的第940行左右,是一段如果参数为变量对Python函数的完整调用,没有标注的都是框架代码及错误处理。
1 | v159 = *(__int64 (__fastcall **)(__int64, __int64))(*(_QWORD *)(v19 + 8) + 144LL); |
如果参数都为常量则简单得多(在函数_pyx_pf_4chal_4chal_6_p3_isra_0
中的第5169行左右,反编译这个函数要改IDA的允许反编译的最大大小):
1 | v420 = (__m128 *)_Pyx_PyObject_GetAttrStr((__int64)v419, to_bytes); // 重点 |
等同于Python中的:
1 | v422 = v419.to_bytes(2, 'little') |
对比
在函数_pyx_pf_4chal_4chal_6_p3_isra_0
中的第998行左右可以找到如下内容
1 | // v95 = self._var2 |
取数组长度
在函数_pyx_pf_4chal_4chal_6_p3_isra_0
中的第670行左右可以找到如下内容
1 | v94 = *(__int64 (__fastcall **)(__int64, __int64))(*(_QWORD *)(v19 + 8) + 144LL);// (v19 + 8) == 'self' |
这一段实际上等同于Python中的
1 | v93 = len(self._var1) |
运算
以下函数均在_pyx_pf_4chal_4chal_6_p3_isra_0
中可以找到
1 | // v100 = 2654435769 - v98 |
HOOK辅助
使用x86_64机器上的Python 3.12导入chal,即可对其函数进行hook。如我们需要hookself._p1
:
1 | import chal |
hook出中间数据以后可以帮助我们确认或纠正在静态手工反编译过程中的一些细节,如这里hook出self._p1
的中间数据后,如果我们恢复出的源代码中的self._p1
的中间数据与此相同,那么可以保证流程走到self._p1
时其参数和返回值一定是正确的;反之则说明恢复的源代码有误。
在本题中,hookself._p1
可以确认输入数据流的加密是否正确;hookself._p2
可以确认随机数是否相同,从而确认随机数种子及使用random模块中函数的次数是否一致;hookself._p3
可以拿到被self._p3
改变但未被self.__init__
改变的self.tips
,确认比对的细节。
解题
用上面的这些方法就可以将so文件里面的内容恢复成python源码了
这边建议先根据_Pyx_CreateStringTabAndInitStrings
函数将符号变量什么的恢复出来,方便后面分析
同时,可以搜索一下特别长的数组,说不定能搜出来用了什么加密
重点要用于参考的函数有:_pyx_pw_4chal_4chal_1__init__
(int以及杂项内容的初始化)、_Pyx_CreateStringTabAndInitStrings
(各种字符串的索引)、_Pyx_InitCachedConstants
(函数与一些元组的初始化)、_pyx_pf_4chal_4chal_6_p3_isra_0
(p3函数的函数体)
其中在_pyx_pf_4chal_4chal_6_p3_isra_0
中,需要交叉索引_Pyx_GetItemInt_List_Fast_constprop_0
,这个函数代表着取数组内容,对分析很有帮助(特别是代码已经到了一万行的情况下)
还原的代码如下:
1 | import os |
其中关键点在于,其对比不是用
self._var2
和self._var3
进行对比,而是用当前轮的random.getrandbits(8)
进行对比,xor为0表示相同,只要最后self._tips == 0
就算成功。所以,比对数据实际上是一组伪随机数!
最后写解密脚本
1 | import random |