Skip to content

标题: Python模块注入技术简析

创建: 2023-03-27 22:12 更新: 2023-03-30 15:47 链接: https://scz.617.cn/python/202303272212.txt

有人在推特上提供了一段神奇的Python代码

https://twitter.com/David3141593/status/1640115094255198208


unused=b'\x50K\3\4'+b'\0'26+b'+(\xca\xcc+\ \xd1P\xcfHL\xceNMQ\xc8\xc9\xcfQ\xd7\4\0PK\1\2'+\ b'\0'6+b'\1'+b'\0'9+b'\x15'+b'\0'7+b'\13'+b'\0'17+\ b'\x6da\x69n.\x70y\x50K\5\6'+b'\0'8+b'9\0\0\0003\0\0\0' i=import i("runpy").run_path(i("py_compile").compile(file))


假设将上述代码置入test.py,以Python 3.8及以上版本执行之,输出"hacked lol"。

$ python3 test.py hacked lol

PoC出场后有两位第一时间揭密

https://twitter.com/AstraKernel/status/1640255265382735873 https://twitter.com/c3rb3ru5d3d53c/status/1640191261435985920

下面是我的分析

PoC执行时,将自身编译成pyc再加载,在此过程中用到了zipimport模块。zipimport 模块"意外地"将pyc co_consts中的unused值识别成zip文件,用_read_directory() 对其中的__main__.py进行解析,后来又用_get_data()返回解压后的__main__.py字 节码。最后由runpy.run_code()执行__main__.py字节码。

David3141593在twitter上提供PoC时,刻意做了些混淆;unused最前面4字节是zip文 件的magic number,他故意不以可打印字符显示;unused中部有"main.py",他 故意不以可打印字符显示。PoC中unused是个畸形zip文件,作者故意的,实际此处可 以是任意有效zip文件。最简方案是,将想执行的代码放入__main__.py,再压缩成 zip文件,将zip文件的bytes表示赋给unused变量即可。不过作者后来提了issue,讲 了不少细节。

https://github.com/python/cpython/issues/103051

zip文件格式参看

https://en.wikipedia.org/wiki/Zip_%28file_format%29

Python 3.10.6的调用栈简介


runpy.run_path(py_compile.compile(file)) importer = pkgutil.get_importer(path_name) // runpy.py:279 // 返回zipimporter importer = path_hook(path_item) // pkgutil.py:421 // path_hook等于zipimport.zipimporter zipimport.zipimporter.init(path_item) // 处理test.cpython-310.pyc files = zipimport._read_directory(path) // zipimport.py:95 // zipimport._read_directory()从pyc尾部倒着搜索"PK\5\6" // 幺蛾子出在zipimport._read_directory()中 code = runpy._get_main_module_details() // runpy.py:302 runpy._get_module_details("main") // runpy.py:238 code = loader.get_code("main") // runpy.py:157 // 调用zipimport.zipimporter.get_code() code = zipimport._get_module_code(self, fullname) // zipimport.py:196 data = zipimport._get_data(self.archive, "main") // zipimport.py:752 // zipimport._get_data()返回解压后的__main__.py runpy.run_code(code,...) // runpy.py:306 exec(code,...) // runpy.py:86 main.py // test.cpython-310.pyc


我这是用gdb调试Python解释器得到的调用栈回溯,实际情况有些微妙,后面是小侯 的研究结论。

cpython-3.10.6\Lib\zipimport.py cpython-3.10.6\Python\importlib_zipimport.h cpython-3.10.6\Programs_freeze_importlib.c

_freeze_importlib.c将zipimport.py编译成pyc,再序列化到importlib_zipimport.h, 后者内容形如

const unsigned char _Py_M__zipimport[] = {99,0,0,0,...,19,12,15,}

Python源码提供zipimport.py,同时也提供预编译好的importlib_zipimport.h。编 译Python源码时,缺省用importlib_zipimport.h,之后修改zipimport.py并不影响 解释器。

参看

cpython-3.10.6\Makefile.pre.in

编译源码时可用如下命令强制重新生成importlib_zipimport.h

make regen-importlib

这是老版定义frozen module的位置

https://github.com/python/cpython/blob/3.8/Python/frozen.c


static const struct _frozen _PyImport_FrozenModules[] = { / importlib / {"_frozen_importlib", _Py_M__importlib_bootstrap, (int)sizeof(_Py_M__importlib_bootstrap)}, {"_frozen_importlib_external", _Py_M__importlib_bootstrap_external, (int)sizeof(_Py_M__importlib_bootstrap_external)}, {"zipimport", _Py_M__zipimport, (int)sizeof(_Py_M__zipimport)}, / Test module / {"hello", Mhello_, SIZE}, / Test package (negative size indicates package-ness) / {"phello__", Mhello_, -SIZE}, {"phello__.spam", M___hello__, SIZE}, {0, 0, 0} / sentinel / };


这是新版(3.12.0)定义frozen module的位置

https://github.com/python/cpython/blob/60bdc16b459cf8f7b359c7f87d8ae6c5928147a4/Programs/_bootstrap_python.c#L36


static const struct _frozen bootstrap_modules[] = { {"_frozen_importlib", _Py_M__importlib__bootstrap, (int)sizeof(_Py_M__importlib__bootstrap)}, {"_frozen_importlib_external", _Py_M__importlib__bootstrap_external, (int)sizeof(_Py_M__importlib__bootstrap_external)}, {"zipimport", _Py_M__zipimport, (int)sizeof(_Py_M__zipimport)}, {0, 0, 0} / bootstrap sentinel / };


搞清楚原理后,可以自制这样的PoC,放一个正常zip到unused变量即可,也可以放畸 型的,比如hello.py。


unused=b'PK\3\4'+b'\0'26+\ b'+(\xca\xcc+\xd1P\xf7H\xcd\xc9\xc9W\x08\xcf/\xcaIQT\xd7\4\0PK\1\2'+\ b'\0'6+b'\1'+b'\0'9+b'\x17'+b'\0'7+b'\v'+b'\0'17+b'main.py'+\ b'PK\5\6'+b'\0'8+b'9'+b'\0'3+b'5'+b'\0'3 i=import i("runpy").run_path(i("py_compile").compile(file))


$ python3 hello.py Hello World!