pyd逆向基础

业务上遇到一个pyinstaller打包的样本需要逆向分析,分析的过程中遇到了很多问题,开篇文章记录一下

pyinstaller

pyinstaller可以理解为把pyc,pyd,相关的lib依赖,python运行时全部打包进一个exe,然后运行时释放在内存中,分析的第一步是解包,推荐使用pyinstxtractor-ng,对多版本python兼容性较好

main

解包完后应该会出现一个项目文件夹,这个时候一般在根目录找pyc文件,比如说app.pyc这样的命名看着就很像程序入口,使用pylingual将其还原成python代码,一般都能成功还原,如果出错了就手撕还原字节码,pyc的字节码才用的是基于栈运行的虚拟机,并不难手撕,这里不展开赘述,只要去查一下相关对应的字节码对应的指令即可
比如我遇到的app.pyc反编译结果就如下

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# Decompiled with PyLingual (https://pylingual.io)
# Internal filename: v2\app.py
# Bytecode version: 3.8.0rc1+ (3413)
# Source timestamp: 1970-01-01 00:00:00 UTC (0)

import datetime
import json
import os
from decimal import Decimal
from PySide2.QtCore import Qt, QUrl
from PySide2.QtGui import QPixmap, QIcon
from PySide2.QtWidgets import QWidget, QTableWidgetItem, QApplication, QMessageBox, QPushButton, QVBoxLayout, QDialog, QLabel
from PySide2.QtMultimedia import QMediaPlayer, QMediaContent
from openpyxl.workbook import Workbook
from v2.qtmain import start_qt
from v2.spider import CrawlerThread
from utils import common
from ui.xhs_v2 import Ui_Form
import datetime
import json
import logging
import os
import queue
import re
import threading
import time
import hashlib
from random import randint, choice
import execjs
import http.cookies
import requests
from PySide2.QtCore import QThread, Signal
from utils import common
from dateutil import parser
from fake_useragent import UserAgent
import base64
import datetime
import math
import os
import random
import time
import uuid
import requests
import qrcode
from lxml import etree
from PySide2 import QtCore, QtGui, QtWidgets
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import utils.xhs_xs
if __name__ == '__main__':
start_qt()

可以看到主函数里啥都没有,python逆向对抗的主要区域是在pyd,稍微注重一点加固的软件都不会把重要代码放在pyc里
这个阶段还有一个重要的工作是收集信息,根据目录下的dll确定python版本(非常重要,后续的运行和调试基本要基于相同的版本),如果可以的话也可以尝试确认依赖库的具体版本,不过这个相对没有那么严格
然后就是尝试运行程序python app.pyc,因为如果运行打包好的程序所有东西都是混在一起的,没法调试,一般是运行python主文件,然后用调试器attach到python进程上,这样每个pyd文件都会作为独立的dll存在
如果是比较复杂的程序这个时候会缺各种依赖,我们最好使用一个虚拟环境python -m venv .venv,然后根据报错去下载对应的依赖,同时可能会出现只有特定版本区间的依赖库才能正常运行的情况,这种情况就要根据报错去查资料然后试着换不同版本号的依赖库
如果程序能正常运行了,那么前期的准备工作基本就完毕了,可以进入pyd的分析了

pyd

恢复符号

pyd里的符号是全抹去的,因为pyd只导出pyInit_module这一个符号用来将c实现的python方法导出给python进程,我们首先要做的是尽可能恢复一些和python数据结构操作相关的符号以及python特有的结构体,这里一般采取的方法是编译一份带符号和调试信息的pyd文件,然后在ida中导出头文件来提供相关类型定义,然后再用bindiff和样本pyd比对恢复一部分符号
bindiff在这个repo下载,解压了直接拖进ida的插件目录即可,好像9.1的有个bug,他会去找ida64.exe这个文件执行导出任务,但是ida9.0以及把ida64和ida合并成了一个文件,解决办法就是复制一份ida.exe并重命名成ida64.exe放在ida根目录下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# setup.py
from distutils.core import setup
from Cython.Build import cythonize
from distutils.extension import Extension

module = Extension(
"common",
sources=["MyPyd.py"],
extra_compile_args=["/Zi"], # 编译器选项:生成调试信息
extra_link_args=["/DEBUG"], # 链接器选项:生成 PDB 文件

)
setup(
ext_modules=cythonize(module)
)

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
31
32
33
# MyPyd.py
class common:
def get_expire_time():
return ""


testString = "testSSString"
print(testString)
a = 1
b = 2
c = 3
print(a+b)
print(a-b)
print(a*b)
print(a/b)
print(a//b)
print(a % b)
print(a & b)
print(a | b)
print(a & b)
print(a ^ b)
print(~a)
print(not a)
print(a and b)
print(a or b)
print(a**b)
d = [1, 2, 3]
print(d[0])
e = {1, 2, 3}
print(e[0])
f = (1, 2, 3)
print(f[0])

原则上样本文件越复杂越好,包括越多的操作生成的符号就越多,能恢复的也越多
运行python setup.py build_ext --inplace即可编译,这里最好用相同版本的python进行编译

然后运行Parse C header file导出头文件即可
然后退出ida并把分析结果保存为i64/idb数据库
打开要分析的样本,并plugins中选择bindiff,再选择diff database

然后选中刚刚的保存的数据库,就会自动开始匹配符号,一般看着选一部分,然后import symbols即可导入符号,把绿色的差不多都导入了就行,反正置信度这种就看个乐

定位方法

正常情况下方法名都会直接直接以裸字符串储存在rdata里(用于在报错时添加traceback),通过字符串可以迅速定位到指定方法的具体实现

动调分析

PyObject默认都是未初始化的,具体来说就是静态的时候看到的一大堆空的qword

网上有说法是有一个Pyinit函数统一处理这些所有对象的初始化,可惜我并没有定位到对应的实现,不过像这种PyASCIIObject跳过去基本能看到是什么,对应的初始值就和PyObject指针放在一起

这里再介绍一种动调的方法,attach到python进程后在想看的PyObject附近下断点,然后跳到对应内存,需要跳两次,因为是结构体指针,然后可以看到明显的结构体特征

这里前两个值直接转化成qword,所有PyObject这两个都是固定的,分别是引用次数和PyType

知道了type后就好办了,我们之前准备的头文件已经搞定了这些PyObject类型的定义,像这里其实这就是一个PyASCIIObject

紧跟在PyASCIIObject结构体后的就是这个python对象的具体值

另外在分析的过程中可能会看到有结构体没还原的情况(ida识别为qword,然后相关操作都带偏移),这种情况可以先试试重定义为PyObject*,大部分情况下其实都是PyObject*

另外如果目标程序里有复杂的加密计算流程,可以考虑trace相关的数值操作,这里使用frida或条件断点都是不错的选择

不过位操作(左右移,and,or,xor)似乎不使用导入的函数,所以还要去具体的加密部分找函数hook

替换返回值

如果要替换数值对象或字符串返回值最好用frida,主动调用PyXXX_fromXXX构造PyObject对象后返回,如果是替换true/false倒是可以直接patch,注意到python中ture和false分别被储存到两个全局单例中,所以如果想让某个函数永远返回true或false,只要将该单例的指针储存到返回寄存器(rax),然后增加引用数并return即可

Author

SGSG

Posted on

2025-07-08

Updated on

2025-07-11

Licensed under