隔了几个月,新年第一个blog。重新整整最恶俗的cython逆向。
Cython程序分析_1
好久没有系统化的研究一个项目了。这次准备从python入手,先从最麻烦的cython编译文件来进行分析。
cython编译后的pyd,so文件非常复杂,cython编译器向其中添加了很多冗余代码,难度会非常大。
这次准备通过举例的方法,将几种常见的python语法类型都试一遍,看看其特征。
测试版本
1 | Cython version 0.29.24 |
编译方法
第4篇:Cython编译细节详解 - 知乎 (zhihu.com)
setup.py编译(推荐)
setup.py
1 | from setuptools import setup |
命令行
1 | python3 setup.py build_ext --inplace |
这样会得到一个c文件,一个注释过的html文件,以及build文件夹中编译好的pyd或so文件。
print.py——整理cython整体结构
1 | def print_msg(): |
查看html注释后发现
1 | +1: def print_msg(): |
print()
函数调用的是__Pyx_PrintOne()
API。
C文件分析
字符串声明
再看看c文件,发现里面一大堆标准的胶水代码。搜索print_msg
字符串。
在第1000多行左右会遇到第一个相关字符串。
1 | /* Implementation of 'print' */ |
1000行及以上的都是无关胶水代码。
这里声明了模块里的字符串,其中有函数名称,被打印的字符串,还有一些编译器生成的字符串。
紧接着就是PyObject
,这是Cython里面一个比较重要的对象,这是实现面向对象思想的一个重要体现。
python源码剖析之PyObject详解 | w3c笔记 (w3cschool.cn)
Common Object Structures — Python 3.8.0 文档 (hubwiz.com)
根据参考文章,Cython的所有数据元素都是对象,也就是可以通过PyObject*
指针进行引用的。
命名规则
根据已知,说一下命名规则(在Linux上编so的情况下,会保留符号表,Windows上pyd就不一定了)
k
C风格字符串kp
字符串指针,PyObject*
类型n
是对象指针,PyObject*
类型pf
是函数,PyObject*
类型pw
是Wrapper(封装)函数,将pf
函数包裹了一下
有几个后面还有有s
,可能是静态(static)的意思,也有可能是字符串的意思。
kp_s
和n_s
的区别待定,因为最后都被用于初始化Python字符串对象了。
封装函数
下面是第一行代码,也就是函数声明那行。
1 | /* Late includes */ |
由/* Python wrapper */
注释可以得知,首先定义了一个wrapper函数__pyx_pw_5print_1print_msg()
对真正的函数进行了一个简单的封装。
第一行是声明了原型/*proto*/
,
紧接着进行了PyMethodDef
声明了此对象的一些元信息:
- 方法名(函数名)
- wrapper函数指针(名称中含有
pw
的便是Python Wrapper的缩写,也就是封装函数) METH_NOARGS
(疑似是无参声明)- 0(参数数量?)
然后定义了封装函数的一些操作。可以大致了解一下里面进行的内容,这样逆向过程中可以通过这些特征来快速识别函数块。
首先定义了一个PyObject*
,在__Pyx_RefNannySetupContext()
之后,便获得了子函数指针并进行了调用。然后就是默认的退出代码__Pyx_RefNannyFinishContext()
。
__Pyx_RefNannySetupContext()
是一个宏函数:
1 |
这里有有关多线程以及GIL的东西,暂时不管。核心就是
1 | __Pyx_RefNanny->SetupContext((name), __LINE__, __FILE__); |
这里可以作为一个特征码,其字符串大抵可以用于判别函数的名称。(前提是需要#define CYTHON_REFNANNY
print_msg()
核心函数
接下来把完整的子函数代码贴上来。
1 | static PyObject *__pyx_pf_5print_print_msg(CYTHON_UNUSED PyObject *__pyx_self) { |
前半段定义了一些数据对象。
PyObject *__pyx_r
作为返回值返回int __pyx_lineno = 0; const char *__pyx_filename = NULL; int __pyx_clineno = 0;
这三个都是在出现错误的时候被调用的
然后有一个SetupContext()
。
核心代码
if (__Pyx_PrintOne(0, __pyx_kp_s_Hello_Cython) < 0) __PYX_ERR(0, 2, __pyx_L1_error)
是核心代码
__Pyx_PrintOne
是一般的print()
函数,大抵是没有任何传参和格式化操作下的API。
值得注意的是,为了安全性,该函数被一个if
包裹。若返回值小于0就会调用错误异常处理,跳转到AddTraceback()
那里去。
其他
__pyx_string_tab
1 | static __Pyx_StringTabEntry __pyx_string_tab[] = { |
这一块结构体,在逆向上会有帮助,即通过寻找字符串即可找到PyObject*
指针,通过交叉引用找到相应代码。
往上翻可以找到__Pyx_StringTabEntry
结构体的定义
__Pyx_StringTabEntry
1 | typedef struct { |
__Pyx_PyCode_New
继续往下翻,能发现一个与Cython的Small Code
有关的代码。
1 | static CYTHON_SMALL_CODE int __Pyx_InitCachedConstants(void) { |
CYTHON_SMALL_CODE
网上没怎么查到,我觉得这应该是用于代码优化的一个技术。
1 |
在GNU编译下会声明__attribute__((cold))
,也就是此函数是冷门函数,可以降低将其放入高速缓存的优先级。
__Pyx_PyCode_New
1 |
__Pyx_InitGlobals
这个将用于初始化上面见过的__pyx_string_tab
。
1 | static CYTHON_SMALL_CODE int __Pyx_InitGlobals(void) { |
贴一下__Pyx_InitStrings
的代码。
1 | /* InitStrings */ |
逆向分析
下面看看Windows上pyd二进制下的样子。
导出函数
先看一下导出界面,可以发现两个函数。
1 | PyInit_print 0000000180004520 1 |
DllEntryPoint
函数可能是在Cython库函数中定义的,在编译的时候引进。c文件中没有发现其身影。可以暂时不用管它。
看一下PyInit_print
函数。这个便是import
后,调用print_msg
函数后会引用的。
值得一提的是,如果编写的Python文件是像个一般程序一样,也就是直接就可以跑的,即对函数进行了调用,而不是像库一样,仅仅定义了一些对象与函数,但没有进行调用,那么在编译成pyd文件后,只要一import
,就会立刻运行。这个例子没有这样干,不过我猜测应该是这些函数逻辑都会在Dll入口点或者在PyInit_print
出体现。后面会继续研究。
PyInit_Print
IDA中
1 | __int64 PyInit_print() |
看看C源码的声明
1 |
|
PyModuleDef_Init
是个库函数,C文件里面没有定义。
那么再看看__pyx_moduledef
__pyx_moduledef
1 | static struct PyModuleDef __pyx_moduledef = { |
使用c/c++编写python扩展(一):定义模块函数与异常 - 知乎 (zhihu.com)
贴一下别人Cython代码编写的教程。直接写Cython真的狠人。
创建模块
定义好新的函数后,我们需要把方法放入某个模块下,这样才能使用python进行调用。为此我们需要创建一个PyModuleDef类型的静态变量。
1
2
3
4
5
6
7 static struct PyModuleDef fputsmodule = {
PyModuleDef_HEAD_INIT,
"fputs",
"Python interface for the fputs C library function",
-1,
FputsMethods
};PyModuleDef类型的结构体共有五个变量,PyModuleDef_HEAD_INIT是固定写法,是Python.h中定义的宏,第二个参数”fputs“是模块的名称,第三个参数则是模块的docstring。第四个参数-1是跟多解释器有关的参数,负值代表不支持多解释器,正值表示每个子解释器需要申请的内存。这已经超出了本文章的范围,具体内容可以参考https://segmentfault.com/a/1190000019229771。第五个参数便是我们刚才定义的函数结构体静态数组。
有了模块的定义,最后一步我们需要定义一个PyMODINIT_FUNC类型的函数,用来供Python解释器调用初始化我们的模块
1
2
3 PyMODINIT_FUNC PyInit_fputs(void) {
return PyModule_Create(&fputsmodule);
}函数只有一行,将刚才定义的fputsmodule传入PyModule_Create函数并返回即可。
再对着我们的IDA看看这个结构体,可以发现#if
条件成立,于是把#else
的都给删掉。
1 | static struct PyModuleDef __pyx_moduledef = { |
现在在64位平台上都默认整数是64位,于是我也这么处理。发现得到的结构体还挺正确的。
贴一下IDA中的结构体
1 | .data:0000000180007740 qword_180007740 dq 1 ; DATA XREF: PyInit_print↑o |
可以发现第一个元素PyModuleDef_HEAD_INIT
占了5个QWORD。然后三个指针,一个指向模块的字符串,一个指向__pyx_methods
结构体,一个指向__pyx_moduledef_slots
结构体。
__pyx_methods
1 | static PyMethodDef __pyx_methods[] = { |
这个数组声明了本文件中含有的方法。这个例子里面只有一个函数,所以显然只有默认的0。
贴一下上面那个帖子中的:
1 | static PyObject *method_fputs(PyObject *self, PyObject *args) { |
这样一个静态变量数组可以存放多个函数的定义,并且以NULL定义作为结尾。对于数组的每个元素,有四个成员变量。本例中第一个变量”fput”为函数名称,method_fputs为我们刚才定义的函数指针,第四个参数为函数的docstring。
第三个参数使用来指定函数参数的格式。
后记:函数也被当成该模块的方法了。在上面看Wrapper函数的时候
1 | static PyMethodDef __pyx_mdef_5print_1print_msg = {"print_msg", (PyCFunction)__pyx_pw_5print_1print_msg, METH_NOARGS, 0}; |
便是声明了一下。这个模块声明非常有用,在Exec函数中会被用到。
IDA中:
有意思的是,IDA中这里指向了未知的内存区域。大抵是动态运行的时候会调用API再去初始化。
__pyx_moduledef_slots
1 | static PyModuleDef_Slot __pyx_moduledef_slots[] = { |
指向了两个函数,Create
和Exec
。
IDA二进制中的样子:
1 | .data:0000000180007220 qword_180007220 dq 1 ; DATA XREF: .data:0000000180007788↓o |
__pyx_pymod_create
根据名字,可以猜出应该是一开始被import
的时候调用的函数。贴一下相关代码,同时根据版本去除了一些无用的宏定义。
1 | static PyObject *__pyx_m = NULL; |
IDA中的样子(已经被美化过):
1 | QWORD *__fastcall sub_180004150(__int64 spec) |
了解即可,掌握一下这个函数的大概,便于定位。
__pyx_pymod_exec_print
这个函数非常重要,保留了总的函数逻辑。
1 | /* CythonFunction.proto */ |
其中,函数前面很大一部分都是初始化,检查用的。
核心在这里
1 | /* "print.py":1 |
对这个函数的调用使得我们有机会找到大致的逻辑。
定位
通过__Pyx_PyCode_New()
尝试IDA中定位。
IDA中的代码不少内联了,再加上优化,变得很复杂。我们先通过一些特征慢慢摸索到本体。
1 | // C文件中 |
这个函数是在__Pyx_CreateCodeObjectForTraceback()
中的;然后又在__Pyx_AddTraceback()
中被调用;然后又在Exec函数的末尾被调用。总的来说,就是内联了两层代码。
1 | /* AddTraceback */ |
通过一大串初始化和PyObject_GC_Track(op)
这个函数调用在核心代码的旁边。
1 | static PyObject *__Pyx_CyFunction_Init( |
__Pyx_CyFunction_New()
和__Pyx_CyFunction_Init()
函数在IDA中没找着,看样子是被内联掉了。
先找到PyObject_GC_Track(op)
1 | if ( !py_code ) |
这一块和__Pyx_CyFunction_Init()
函数那一串长长的初始化操作很像。可以确定就是在这里了。
找到PyMethodDef*
指针
1 | *(_QWORD *)(v26 + 16) = &off_180007720; // method def指针 |
回顾一下上文所说的这个结构体组成。
1 | static PyMethodDef __pyx_mdef_5print_1print_msg = { |
1 | .data:0000000180007720 off_180007720 dq offset aPrintMsg_0 ; DATA XREF: sub_180002920+5E↑o |
关于METH_NOARGS
枚举,可以在python/include/objectmethod.h
中找到。(建议使用grep -r
命令搜索)
1 | /* Flag passed to newmethodobject */ |
定位到函数
这样我们就终于定位到了print_msg()
函数了。
1 | // write access to const memory has been detected, the output may be wrong! |
比对
对比一下C文件里的函数(封装函数和子函数可能会因为内联合并)
1 | static PyObject *__pyx_pw_5print_1print_msg(PyObject *__pyx_self, CYTHON_UNUSED PyObject *unused) { |
通过和二进制文件的对比,可以发现默认情况下,与RefNanny
有关的代码都不存在。应该是这个并没有被设置。所以可以在C文件中忽视这一块代码。
首先查找PyObject_Call()
在C文件中的位置。
1 | static int __Pyx_Print(PyObject* stream, PyObject *arg_tuple, int newline) { |
只在这个Print()
函数中找到。那么大概也是叠层内联了。
这个函数又在__Pyx_Print_One()
中被调用
1 | static int __Pyx_PrintOne(PyObject* stream, PyObject *o) { |
可以发现,这个函数有Tuple_Pack
等在二进制文件中出现的代码。
大致的进行重命名
1 | // write access to const memory has been detected, the output may be wrong! |
恢复大致逻辑
看上去十分离谱,这个玩意怎么能够跟print()
函数串在一起。
我的想法是,根据Cython的思想,将一切都是为PyObject对象。于是乎我们观察
1 | __pyx_print = PyObject_GetAttr(_pyx_b, _pyx_n_s_print) |
PyObject_Call
在python/include/abstract.h
中可以找到PyObject_Call
的原型
1 | PyAPI_FUNC(PyObject *) PyObject_Call( |
PyObject_GetAttr
1 | PyObject* PyObject_GetAttr(PyObject *o, PyObject *attr_name); |
从对象o
中找到特征名为attr_name
的对象。
__pyx_b
此对象是通过对比C文件得到的。实战情况下不一定能分析出来,但可以了解一下。
C文件中相关操作
1 | // 在 Exec函数中 |
IDA中
1 | v11 = (_QWORD *)PyImport_AddModule(aBuiltins); |
__pyx_b
获得的是__builtin__
模块的指针。这个内置模块拥有若干Python自带的函数等元素,比如str()
,eval()
,print()
等不需要导入即可使用的函数。
了解此对象,可以缩小范围。
_pyx_n_s_print
在IDA中,这又是一个未初始变量。
交叉引用,找到其在data段的引用
1 | .data:00000001800078A0 dq offset __pyx_n_s_print |
联想起上文所述的__pyx_string_tab
中的结构体定义
1 | typedef struct { |
C文件中:
1 | {&__pyx_n_s_print, __pyx_k_print, sizeof(__pyx_k_print), 0, 0, 1, 1}, |
由此得知,这个_pyx_n_s_print
对象就是一个包裹过的print
字符串的指针。
于是乎逻辑就通顺了,那便是从__builtin__
模块中寻找print
函数,然后PyObject_Call
调用它。
打印的字符串
刚刚分析了这么久,研究出来了大致的逻辑结果,但是还没发觉到底打印了啥字符串。
重新分析一下print_msg()
函数
1 | v0 = (_QWORD *)PyTuple_Pack(1i64, obj); |
这个便是传入的参数。
obj
对象交叉引用一下,发现
1 | .data:00000001800077B0 off_1800077B0 dq offset obj ; DATA XREF: sub_180001D30+6↑r |
也就是在C文件中__pyx_string_tab
中的初始化。
所以得到
1 | print("Hello Cython") |
其他
基于错误信息定位
1 | __Pyx_AddTraceback(aPrintPrintMsg, 0x492u, 2, (__int64)aPrintPy); |
能够显示出函数的名称和位置。十分轻松,不需要顺藤摸瓜去苦苦搜索。
基于函数表定位
也就是wrapper函数声明后的PyMethodDef
结构体。
1 | static PyMethodDef __pyx_mdef_5print_1print_msg = { |
上文已经讲过了,但是那是从Exec函数里面慢慢抽出来的。
其实也可以直接找函数名print_msg
,然后交叉引用找到这里。相当于倒过来整理逻辑。
后记/疑问
print_msg()
有多个备份
通过上面obj
对象,也就是C文件中__pyx_kp_s_Hello_Cython
对象的交叉引用,可以发现另外2个和print_msg()
一模一样的函数都引用了它,且都有相同的错误抛出,表明原来都是python中的同一个函数。然而只有一个是在PyModuleDef*
里面注册的。
- 本文作者: Taardis
- 本文链接: https://taardisaa.github.io/2022/01/25/Cython Reverse/
- 版权声明: 本博客所有文章除特别声明外,均采用 Apache License 2.0 许可协议。转载请注明出处!