Python 打包后 DUMP 的那些事

前言

随着CTF的发展,Python逆向题目在出题人手下越来越不走常规路,常常搞一些魔改或是加密,对于打包完后的结果我们并不一定每次都可以成功的对其进行解包。我们常见的 exe 或是 elf 的 DUMP 目的是提取对应的字节码,而Python打包后的东西与前两者不太一样,对此我们考虑使用 Python 内置的虚拟机来进行完成我们的DUMP。

要想完成相关代码的提取我们首先需要知道要提取什么,在此过程我们主要的目标是f_code

主要工作

帧对象

帧对象是 Python 中用于表示函数调用栈帧的数据结构。

每当一个函数被调用时,Python 会为该函数创建一个帧对象。帧对象包含了函数调用过程中的所有上下文信息,如局部变量、全局变量、调用者的信息以及代码对象等。

帧对象在函数调用期间会被分配到栈上,从而形成一个函数调用栈。

帧对象(frame objects)具有以下一些属性:

f_back:指向调用当前函数的帧对象的引用。

f_code:当前帧对象所执行的代码对象。

f_globals:全局命名空间的字典。

f_locals:局部命名空间的字典。

f_lineno:当前正在执行的行号。

f_code(code object):代码对象是 Python 中用于表示编译后的字节码的数据结构。

当 Python 解释器解析并编译源代码时,它会生成代码对象。代码对象包含了编译后的字节码以及与源代码相关的其他元数据,如变量名、行号、常量等。

代码对象(code objects)具有以下一些属性:

co_argcount:普通参数的数量。

co_code:包含编译后字节码的字符串。

co_consts:常量元组,包含了代码对象中所有的常量。

co_filename:源代码文件的名称。

co_firstlineno:源代码中第一个行号。

co_flags:解释器内部使用的标志。

co_lnotab:编码字节码偏移量到源代码行号的映射。

co_name:代码对象的名称。

co_names:包含了代码对象中所有变量名的元组。

co_varnames:包含了局部变量名的元组。

代码抽取

所以对于我们来说当我们能拿到f_code时我们便可以写出对应的代码逻辑,那么重点便落到了我们如何进行拿到相应的代码,目前有两种方式来进行获取:

  1. 调试对应虚拟机,从中找核心函数
  2. 调用内置的Python虚拟机

一般来说我们并不想弄的那么复杂,我们采用第二种方式进行实现

一般我们打包的Python程序中会带有虚拟机,我们是如何知道的呢?此处采用一个简单打包文件进行运行,使用Process Hacker观察对应程序

我们可以看到其创建了一个进程,通过分析PyInstaller源码可以知道我们打包的程序会将真正的PYC文件进行释放(通过CreateProcess),之后再运行,我们查看对应子进程所加载的动态链接库:

我们可以看到其加载了Python 3.10的一个dll文件,这个其实就是我们PyInstaller打包进去的虚拟机,其便是我们执行相关的代码所需要的文件,我们可以看到其中存在许多导出函数

这些函数便是我们之后想要利用的代码,通过调用其中的代码来进行调用对应的代码。

那么我们如何进行调用对应导出空间的函数呢?我们可以使用dll进行注入,这样我们就可以使用对应空间的代码,我们采用以下项目的代码来进行生成我们需要的dll文件

Stanislav-Povolotsky/PyInjector: PyInjector - Inject python-code into any python process or spawn interactive python-shell inside the target process. (github.com)

其创建了一个线程,当我们将生成的dll注入到对应的打包程序中后,其会执行对应目录下的code.py代码中的内容,对此我们可以在该文件中写入我们想要获取的信息,便可以从中拿到对应的代码

当我们注入dll后,便是编写相关的code.py文件来达到我们提取代码的目的,代码如下:

1
2
3
4
5
6
7
8
import sys
import marshal
i = 0
for frame in sys._current_frames().values():
code = frame.f_code
open("dumped_" + str(i) + ".marshal", "wb").write(marshal.dumps(code)) # Loop all the threads running in the process
i+=1
print("Dump finished!")

我们通过遍历当前的frames将其中的f_code进行提取并转换为marshal,但是marshal与我们通常的pyc不太一样,我们还需要将其进行转换操作

1
2
3
4
5
6
7
8
9
10
import marshal
import importlib

with open('dumped_0.marshal', 'rb') as f:
code = marshal.load(f)

pyc_data = importlib._bootstrap_external._code_to_timestamp_pyc(code) # 将 marshal 转换为 pyc
# print(pyc_data)
with open('file.pyc', 'wb') as f:
f.write(pyc_data)

之后我们便可以拿到对应的pyc文件,之后就可以丢进uncompyle6或是pycdc中对pyc进行反编译

一些问题

上述代码中我们采用的是_current_frames()来获取当前的frame,意味着我们如果中间采用一个加密是在其他的文件中的,那么我们可能只能够提取出对应文件中的加密信息,而调用者的相关代码则无法进行提取,但是我们可以使用_getframe来获取对应的调用,但是其功能也比较有限,需要我们手动输入对应的层来进行dump相应的信息

1
2
3
4
5
code = marshal.dumps(sys._getframe(1).f_code) # 获取调用该函数的信息
code = importlib._bootstrap_external._code_to_timestamp_pyc(marshal.loads(code))
f = open("dumps-Test.pyc","wb")
f.write(code)
f.close()

函数原型为:sys._getframe([depth]) depth为对应的调用深度,当前函数深度为 0,调用该函数则深度为 1,并以此类推

与此同时我们也可能会遇到多个marshal文件,当我们dump某些包含代码库的打包程序时便会产生上述情况,此时因为marshal文件过多,我们比较难以定位到相应的核心代码,因此该方法也不一定十分的有效

总结

整体来说上述方法有一定的可行性,但是对于一些大型的软件逆向可能仍然存在有一些不足之处,其主要的核心依然是获取CPython中的相关函数来为我们创建一个线程进而来获取对应的代码信息,进而达到我们的目的

参考

inspect — Inspect live objects — Python 3.7.17 documentation

What is a code object in Python? - Quora

Python3 获取调用栈信息 sys._getframe_118路司机的博客-CSDN博客

Python 函数自定义属性 —— 牛客博客 (nowcoder.net)

Python函数属性、__code__属性的解释、PyCodeObject_lamehd的博客-CSDN博客


Python 打包后 DUMP 的那些事
https://equinox-shame.github.io/2023/08/31/Python 打包后的Dump/
作者
梓曰
发布于
2023年8月31日
许可协议