-
-
[原创]Nuitka逆向-战**具 1.5 逆向分析与授权绕过全记录
-
发表于: 2天前 892
-
、# 战舰工具 1.47 逆向分析与授权绕过全记录
声明:本文仅用于技术研究与学习,记录逆向分析的完整思路与过程。逆向他人软件可能涉及法律风险,请在合法授权范围内操作,尊重开发者劳动成果。
一、目标概述
1.1 程序基本信息
本次分析对象为一款基于 Python 开发、使用 Nuitka 编译为原生机器码的 Windows 桌面工具程序,版本号 1.47。
| 属性 | 值 |
|---|---|
| 主程序 | main.exe(279 MB,PE32+ x64) |
| 架构 | x86_64 |
| 运行时 | Python 3.8.x + PyQt5 |
| 打包方式 | Nuitka(Python → C → 机器码) |
| 加密体系 | AES-128-CBC(通信加密)+ RSA-2048(机器码加密)+ RSA-1024(响应签名) |
| 通信协议 | HTTP 明文 + 数据层加密(JSON-in-Encrypted) |
| 完整性保护 | 二进制自校验(修改后无法启动) |
程序的设计思路体现了较高的防护意识:通信内容加密,防止直接抓包;数字签名验证,防止伪造响应;机器绑定,防止授权码跨设备使用;Nuitka 编译,防止源码还原。
1.2 逆向目标
理解并绕过授权验证机制,实现在不联系服务器的情况下让任意激活码通过本地验证。
二、环境搭建与工具链
2.1 硬件环境
- 宿主机:macOS(Apple M 系列,ARM 架构)
- 虚拟机:Parallels Desktop 运行 Windows 11 ARM(关键限制:非 x86_64 原生)
- 游戏模拟器:MuMu 模拟器(Android x86,运行游戏本体,产生分析目标使用的数据)
2.2 分析工具与选型理由
| 工具 | 版本 | 用途 | 选型理由 |
|---|---|---|---|
| IDA Pro | 9.3 | 静态反编译,PE 结构分析 | 业界最强反编译器,对 x64 机器码支持完善 |
| ida-pro-mcp | latest | AI 辅助分析(接入 Claude) | 批量理解 Nuitka 生成的复杂 C 代码,提升效率 |
| Frida | 17.9.1 | 动态注入、Python API Hook、进程内代码执行 | 支持 Windows ARM,且可直接操作 Python 解释器 |
| Python 3.14 | latest | 编写分析脚本、AES/RSA 验证脚本 | 宿主机分析用 |
| Python 3.8 | 3.8.20 | 与目标程序 ABI 兼容 | 程序内嵌 Python 3.8,ctypes 模块需对齐版本 |
| pycryptodome | 3.x | AES/RSA 加解密 | 提供 CBC 模式和 PKCS7 padding 支持 |
| mitmproxy | 10.x | HTTP 中间人抓包 | 支持脚本化修改请求/响应 |
| Detect-It-Easy | 3.x | PE 文件类型识别,区段分析 | 快速确认打包方式 |
| CFF Explorer | - | PE 结构可视化 | 手动查看节区、导入表 |
| HxD | - | 十六进制编辑器 | 二进制字符串搜索 |
2.3 关键环境限制(影响整个分析策略)
ARM Windows 的根本性问题:
程序虽为 x64 机器码,但 Windows ARM 内核通过 SXS(Software Translation for x64)进行透明翻译执行。这导致:
- x64dbg 无法启动:x64dbg 本身是 x64 PE,在 ARM Windows 上无法运行(不是 UWP/ARM64)
- WinDbg 兼容性有限:调试翻译层上运行的 x64 进程时,断点行为不稳定,单步执行会 crash
- Frida
Interceptor.attach失效:Frida 通过在目标函数头部写入跳转指令(JMP hook)实现拦截。但 Python C API 函数(PyRun_SimpleString、PyObject_CallObject等)的代码页被 SXS 标记为只读,写入跳转指令导致访问违例
这三个限制使常规动态调试路径全部封闭,被迫发展出一套替代方案。
三、逆向分析过程
第一阶段:PE 结构分析与打包方式识别
3.1.1 初步识别
第一步不是打开 IDA,而是用 Detect-It-Easy 快速扫描:
$ die main.exe
PE64
Linker: Microsoft Linker(14.29)[EXE64]
.NET: -
Packer: -
Library: Python(3.8.0)[Python]
Detect-It-Easy 识别出 Python 3.8,但无法确定打包工具。进一步检查节区:
节区分布(CFF Explorer):
Name VSize VOffset RawSize Flags
.text 38,412,288 0x1000 38,412,800 CODE|EXECUTE|READ
.rdata 2,654,208 0x24A8000 2,654,208 INITIALIZED|READ
.data 2,654,208 0x2730000 2,654,208 INITIALIZED|READ|WRITE
.rsrc 240,123,904 0x29B8000 240,123,904 INITIALIZED|READ
.reloc 163,840 0x13A9B000 163,840 INITIALIZED|READ|DISCARDABLE
.text 段 38MB(原生机器码),.rsrc 段惊人的 229MB(嵌入资源)。
正常 Nuitka 程序会将 Python 标准库的字节码(.pyc)打包进资源段,229MB 的资源段完全符合预期。对比:PyInstaller 程序会有 MEIPASS 字符串和自解压存根,PyInstaller 特征完全缺失。
在二进制中搜索 Nuitka 特征字符串(HxD 搜索 4E75 itka):
Offset Hex ASCII
0x17A3B420 4E 75 69 74 6B 61 20 63 6F 6D Nuitka com
0x17A3B42A 70 69 6C 65 64 20 66 6F 72 20 piled for
确认:Nuitka 编译无疑。
3.1.2 Nuitka 编译对逆向的影响
Nuitka 不同于 PyInstaller 的关键点:
- PyInstaller:将 Python 解释器 +
.pyc字节码打包,运行时解压,.pyc可以被 uncompyle6/decompile3 还原为源码 - Nuitka:将 Python 代码翻译为等价的 C 代码,再用 MSVC 编译为原生机器码,
.text段中没有任何 Python 字节码
Nuitka 翻译后的 C 代码特征:
- 每个 Python 函数对应一个 C 函数(如
impl_get_aes_key_$1) - 大量
Py_INCREF/Py_DECREF调用(Python 引用计数) - 变量名被混淆为
tmp_1,tmp_2, ...,outline_0 PyObject*到处传递
一个简单的 Python a = b + c 在 Nuitka 编译后:
// Python: a = b + c
tmp_1 = BINARY_OPERATION_ADD_OBJECT_OBJECT(b, c);
if (tmp_1 == NULL) goto error_exit;
Py_XDECREF(a);
a = tmp_1;
一个包含 5 行业务逻辑的 Python 函数,Nuitka 翻译后可能有 200+ 行 C 代码,全是引用计数和异常处理。
结论:必须放弃"还原源码"的思路,转向运行时动态分析。
第二阶段:静态字符串与模块结构分析
3.2.1 提取字符串
Nuitka 程序在 .rdata(只读数据段)中保存常量,包括字符串字面量。IDA 的 Strings 窗口(Shift+F12)提取出关键信息:
服务器地址(裸 IP,无域名,hosts 重定向失效):
http://111.229.156.130
API 接口路径:
/api/new/GetValidateZJ?key=%s&mm=%s
/api/new/ValidateLoginZJ?key=%s
/api/new/GetNoticeInfo?type=zj
自定义模块名(Nuitka 编译的业务模块,仍以 Python 模块形式存在于内存中):
keyHelper
aeshelper
httpOperator
mainForm
关键字段名(JSON Key,来自响应解析逻辑):
canuse
es
msg
rs
rd
t
end
start
type
key
混淆常量(后续密钥分析的关键):
22
)02j45_&*&1+
012
210
3.2.2 模块职责推断
根据字符串分布(每个字符串引用了哪个函数段),推断各模块职责:
keyHelper:引用了 RSA 公钥常量(9 行 PEM 格式分段存储)和 AES 相关常量"22"、")02j45_&*&1+"。负责密钥管理,提供get_aes_key()aeshelper:引用了base64、AES、pad、unpad。负责 AES 加解密httpOperator:引用了所有 API URL、requests、hashlib、hmac。负责 HTTP 通信和签名验证mainForm:引用了 PyQt5 控件名。负责 UI 逻辑
第三阶段:网络协议逆向
3.3.1 流量拦截方案
目标服务器是裸 IP(111.229.156.130),常规方案:
- hosts 文件:无效(指定 IP 不经过 DNS 解析)
- Fiddler/Charles 全局代理:需要程序支持系统代理,Python
requests库默认读取HTTP_PROXY环境变量
第一次尝试——本地 IP + 端口转发:
:: 给本机以太网接口添加一个额外 IP
netsh interface ip add address "以太网" 111.229.156.130 255.255.255.0
:: 将本机 80 端口流量转发到 fake_server.py 的 8080
netsh interface portproxy add v4tov4 listenaddress=111.229.156.130 listenport=80 connectaddress=127.0.0.1 connectport=8080
这个方案有缺陷:netsh portproxy 只支持 TCP,且对程序自己建立的连接不一定生效(取决于路由表顺序)。测试发现程序仍然连到真实服务器。
第二次尝试——HTTP_PROXY 环境变量(成功):
set HTTP_PROXY=2d1K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0p5J5y4#2)9J5k6e0m8Q4x3X3f1H3i4K6u0W2x3g2)9K6b7e0R3^5z5o6R3`.
set HTTPS_PROXY=454K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0p5J5y4#2)9J5k6e0m8Q4x3X3f1H3i4K6u0W2x3g2)9K6b7e0R3^5z5o6R3`.
main.exe
Python requests 库会自动读取 HTTP_PROXY 环境变量。mitmproxy 启动在 8888 端口,成功拦截到流量。
3.3.2 请求格式分析
拦截到的授权验证请求:
GET /api/new/GetValidateZJ?key=XXXXXX-XXXXXX-XXXXXX&mm=BASE64DATA HTTP/1.1
Host: 111.229.156.130
User-Agent: python-requests/2.27.1
mm 参数解码后是一段 Base64,原始数据长度为 256 字节——恰好是 RSA-2048 加密的输出大小(2048 bit = 256 bytes)。
import base64
mm_raw = base64.b64decode(mm_param)
print(len(mm_raw)) # 256
这 256 字节是什么?根据 keyHelper 的职责推断:RSA-2048 加密了 机器码 + AES IV,用服务器公钥加密。服务器收到后,用私钥解密得到机器码(用于绑定校验)和 IV(用于加密响应)。
3.3.3 响应格式分析
服务器响应(HTTP 200,Content-Type: text/plain):
Gh3k9...(约 100 字符 Base64A).rT5mP...(约 344 字符 Base64B)
格式为 {加密数据}.{签名},以 . 分隔。
验证猜想:
Base64B长度解码后 = 256 字节 → RSA-2048 签名(SHA256withRSA,256 字节输出)确认Base64A长度解码后 = 64 字节 → AES-128-CBC 加密(16 字节 IV + 48 字节密文,PKCS7 padding)确认
流程梳理:
客户端:RSA加密(机器码 + IV) → mm 参数
服务端:RSA解密(mm) → 得到机器码和IV → 校验机器码 → 构造结果JSON → AES加密(JSON, key, IV) → RSA签名(密文) → 返回 密文.签名
客户端:验签(密文, 签名, 公钥) → 解密(密文, key, IV) → 解析JSON → validateHttp(json)
第四阶段:内存取证——从进程中搜索解密明文
3.4.1 问题与思路
此时已知响应格式,但不知道 AES Key,无法自己解密。但程序自身会解密,解密后的 JSON 必然短暂存在于进程内存中。
思路:在程序完成解密后、读取 JSON 字段前,扫描进程内存,搜索 JSON 特征字符串。
3.4.2 内存扫描实现
Frida 的 Memory.scan 在 ARM Windows 上不可靠,改用 Python ctypes 直接调用 Windows API:
import ctypes
from ctypes import wintypes
import re
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
PROCESS_ALL_ACCESS = 0x1F0FFF
def open_process(pid):
return kernel32.OpenProcess(PROCESS_ALL_ACCESS, False, pid)
def read_memory(handle, address, size):
buf = ctypes.create_string_buffer(size)
bytes_read = ctypes.c_size_t(0)
kernel32.ReadProcessMemory(handle, ctypes.c_void_p(address), buf, size, ctypes.byref(bytes_read))
return buf.raw[:bytes_read.value]
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
_fields_ = [
("BaseAddress", ctypes.c_void_p),
("AllocationBase", ctypes.c_void_p),
("AllocationProtect", wintypes.DWORD),
("RegionSize", ctypes.c_size_t),
("State", wintypes.DWORD),
("Protect", wintypes.DWORD),
("Type", wintypes.DWORD),
]
def scan_process_memory(pid, pattern: bytes):
"""扫描进程所有可读内存页,搜索指定字节模式"""
handle = open_process(pid)
mbi = MEMORY_BASIC_INFORMATION()
addr = 0
results = []
MEM_COMMIT = 0x1000
PAGE_READABLE = {0x02, 0x04, 0x20, 0x40} # READONLY, READWRITE, EXECUTE_READ, EXECUTE_READWRITE
while addr < 0x7FFFFFFFFFFF:
ret = kernel32.VirtualQueryEx(handle, ctypes.c_void_p(addr), ctypes.byref(mbi), ctypes.sizeof(mbi))
if ret == 0:
break
if mbi.State == MEM_COMMIT and mbi.Protect in PAGE_READABLE:
chunk = read_memory(handle, addr, mbi.RegionSize)
for m in re.finditer(re.escape(pattern), chunk):
results.append(hex(addr + m.start()))
addr += mbi.RegionSize
return results
# 在程序登录操作期间扫描
hits = scan_process_memory(target_pid, b'{"msg":')
print(hits)
3.4.3 时机控制
内存扫描有时机问题:扫描太早,程序还没解密;扫描太晚,Python 垃圾回收已经释放对象。
解决方法:写一个循环扫描脚本,每 100ms 扫描一次,在点击"激活"按钮后立即触发:
import threading, time
stop_event = threading.Event()
def scan_loop(pid):
while not stop_event.is_set():
hits = scan_process_memory(pid, b'"msg"')
if hits:
for addr in hits:
data = read_memory(open_process(pid), int(addr, 16) - 2, 200)
print(addr, data)
time.sleep(0.1)
t = threading.Thread(target=scan_loop, args=(target_pid,))
t.start()
# 此时手动在程序界面点击激活按钮
input("Press Enter to stop...")
stop_event.set()
3.4.4 找到失败响应的明文
第一次成功捕获(使用随机激活码):
{"msg":"不一致,请检查","rs":-1637,"t":1775072039,"rd":5018565}
字段解析:
msg:错误信息,存在msg字段 → 验证失败rs:响应状态码,负数 → 失败t:Unix 时间戳(服务端时间,防重放)rd:随机数(防重放)
但成功响应的格式还不知道,需要用正版激活码触发一次。
第五阶段:AES 密钥提取(最艰难的攻关)
这是整个逆向过程历时最长、失败次数最多的环节,前后经历了四种方案。
3.5.1 方案一:IDA 静态分析密钥推导算法(失败)
在 IDA 中定位 keyHelper 模块相关函数。Nuitka 生成的函数命名有规律,搜索字符串 "keyHelper" 找到模块初始化代码,再顺着调用链找到 KeyVault.__init__ 和 get_aes_key。
KeyVault.__init__ 的 IDA 伪代码(节选,约 300 行中的关键部分):
// 对应 Python: self.part1 = "22"
v12 = PyUnicode_FromStringAndSize("22", 2);
Py_INCREF(v12);
PyObject_SetAttrString(self, "part1", v12);
Py_DECREF(v12);
// 对应 Python: self.part2 = ")02j45_&*&1+"
v13 = PyUnicode_FromStringAndSize(")02j45_&*&1+", 12);
Py_INCREF(v13);
PyObject_SetAttrString(self, "part2", v13);
Py_DECREF(v13);
// 对应 Python: self.trans = str.maketrans("012", "210")
v14 = PyUnicode_FromStringAndSize("012", 3);
v15 = PyUnicode_FromStringAndSize("210", 3);
v16 = _PyObject_CallMethodIdObjArgs(
&PyUnicode_Type, &PyId_maketrans, v14, v15, NULL); // str.maketrans("012", "210")
Py_DECREF(v14); Py_DECREF(v15);
PyObject_SetAttrString(self, "trans", v16);
Py_DECREF(v16);
get_aes_key 伪代码(约 150 行 C,对应约 8 行 Python):
// 从 self 获取 part1, part2, trans
v2 = PyObject_GetAttrString(self, "part1"); // "22"
v3 = PyObject_GetAttrString(self, "part2"); // ")02j45_&*&1+"
v4 = PyObject_GetAttrString(self, "trans"); // maketrans表
// step1: part1.translate(trans)
v5 = _PyObject_CallMethodIdObjArgs(v2, &PyId_translate, v4, NULL);
// "22".translate(maketrans("012","210")) = "22"(不含012中字符,无变化)
// step2: PyUnicode_Concat(v5, v3)
v6 = PyUnicode_Concat(v5, v3); // "22" + ")02j45_&*&1+" = "22)02j45_&*&1+"
Py_DECREF(v5);
// step3: part2.translate(trans)
v7 = _PyObject_CallMethodIdObjArgs(v3, &PyId_translate, v4, NULL);
// ")02j45_&*&1+".translate(maketrans("012","210"))
// 含有 "0" → "2", "1" → "1", "2" → "0" 的替换
// ")02j45_&*&1+" → ")20j45_&*&1+"
// 等等...实际结果需要运行才能确认
// step4: 另一个拼接操作(IDA 反编译不清晰,可能有 3 个 part)
// ... 更多混淆操作
尝试手动还原算法,对常量 "22" 和 ")02j45_&*&1+" 进行各种组合:
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import base64, itertools, hashlib
part1 = "22"
part2 = ")02j45_&*&1+"
trans = str.maketrans("012", "210")
known_ct_b64 = "..." # 从抓包得到的一个已知密文
known_ct = base64.b64decode(known_ct_b64)
candidates = []
# 尝试各种组合
for ops in itertools.permutations(["translate", "concat_before", "concat_after", "md5", "sha1"]):
# ... 约 50 种组合
pass
# 全部失败,无法复现正确 Key
失败原因:IDA 对 Nuitka 代码的反编译不完整,步骤 4 及以后的逻辑被混淆在大量引用计数操作中,无法辨认是否有第三个常量或额外变换。
耗时:约 4 小时,失败。
3.5.2 方案二:内存 Dump 暴力搜索 AES Key(失败)
思路:AES-128 的 Key 是 16 字节。在程序执行解密时,Key 必然存在于内存某处。把整个进程内存 dump 下来,穷举每个 16 字节作为候选 Key,尝试解密已知密文,用 PKCS7 padding 合法性作为验证条件。
内存 dump:
def dump_all_memory(pid, output_file):
handle = open_process(pid)
with open(output_file, "wb") as f:
addr = 0
while addr < 0x7FFFFFFFFFFF:
mbi = MEMORY_BASIC_INFORMATION()
ret = kernel32.VirtualQueryEx(handle, ctypes.c_void_p(addr), ctypes.byref(mbi), ctypes.sizeof(mbi))
if ret == 0:
break
if mbi.State == 0x1000: # MEM_COMMIT
chunk = read_memory(handle, addr, min(mbi.RegionSize, 0x10000000))
f.write(chunk)
addr += mbi.RegionSize
Dump 大小:1.1 GB。
暴力搜索用 C 实现(Python 太慢):
#include <CommonCrypto/CommonCryptor.h>
#include <stdio.h>
#include <string.h>
// 已知的一段密文(去掉前16字节IV后的部分)
static const uint8_t known_ct[16] = { /* 密文最后一个AES块 */ };
// PKCS7: 合法padding是末尾N个字节均为N,1≤N≤16
int is_valid_pkcs7(const uint8_t *data, size_t len) {
uint8_t pad = data[len - 1];
if (pad == 0 || pad > 16) return 0;
for (int i = len - pad; i < len; i++) {
if (data[i] != pad) return 0;
}
return 1;
}
int main() {
FILE *f = fopen("memdump.bin", "rb");
fseek(f, 0, SEEK_END);
long fsz = ftell(f);
rewind(f);
uint8_t *mem = malloc(fsz);
fread(mem, 1, fsz, f);
fclose(f);
uint8_t dec[16];
size_t outlen;
long hits = 0;
for (long i = 0; i < fsz - 16; i++) {
CCCryptorStatus status = CCCrypt(
kCCDecrypt, kCCAlgorithmAES128, kCCOptionECBMode,
mem + i, 16, // 候选 Key
NULL, // ECB 无 IV(单块验证)
known_ct, 16,
dec, 16, &outlen
);
if (status == kCCSuccess && is_valid_pkcs7(dec, 16)) {
printf("HIT at offset %ld: ", i);
for (int j = 0; j < 16; j++) printf("%02x", mem[i+j]);
printf("\n");
hits++;
}
if (i % 10000000 == 0)
printf("Progress: %.1f%%\n", 100.0 * i / fsz);
}
printf("Total hits: %ld (false positives expected)\n", hits);
return 0;
}
结果:遍历 8.3 亿个候选 Key,找到约 1200 个 PKCS7 合法的解密结果,但验证其他密文块时全部排除,没有一个是真正的 Key。
失败原因分析:
- AES Key 是 Python
str对象,get_aes_key()执行完后返回值,调用方用完即释放引用 - Python 的
str内部是 Unicode 对象(PyCompactUnicodeObject),在内存中的布局是对象头 + 紧随其后的字符数据 - 内存 dump 是在程序启动后、激活请求完成后采集的,此时 Python 垃圾回收器已经回收了 Key 字符串
- 即使 dump 时机对,Key 在内存中的形态是 UTF-8/Latin-1 编码的 Python str 内部格式,不是 raw bytes,暴力搜索用的是 raw 16 字节,无法匹配
耗时:约 6 小时(含脚本编写、dump 采集、搜索),失败。
3.5.3 方案三:Frida Hook Python C API(失败)
思路:Hook PyObject_CallObject 或 PyUnicode_AsEncodedString,在 get_aes_key 返回时截获返回值。
// Frida 脚本
var base = Module.findBaseAddress("main.exe");
// 尝试 Hook PyObject_CallObject
var pco = Module.findExportByName(null, "PyObject_CallObject");
if (pco) {
Interceptor.attach(pco, {
onEnter: function(args) {
this.callable = args[0];
},
onLeave: function(retval) {
// 检查返回值是否是字符串
if (retval.toInt32() != 0) {
try {
var pyStr = new NativeFunction(
Module.findExportByName(null, "PyUnicode_AsUTF8"),
'pointer', ['pointer']
);
var s = pyStr(retval);
if (s.isNull() == false) {
console.log("str return:", s.readUtf8String());
}
} catch(e) {}
}
}
});
}
结果:脚本注入成功,但完全没有输出。
排查原因:在 Windows ARM + SXS 翻译层下,Interceptor.attach 写入的 JMP 指令会触发访问违例(写保护内存页),Frida 内部捕获了异常并静默失败。frida-server 日志:
[!] Interceptor attach failed at 0x7FF812340A00: Error writing to memory
耗时:约 2 小时,失败。
3.5.4 方案四:Frida 进程内注入执行 Python(成功)
关键洞察:既然 Interceptor.attach 写内存失败,但 Frida 的 Memory.alloc 可以申请新内存(不受保护),而且目标进程内嵌了 Python 解释器,可以调用 PyGILState_Ensure + PyRun_SimpleString 在目标进程中执行任意 Python 代码,这套 API 不需要修改任何已有代码页。
Frida 脚本:
// 找到 Python 解释器导出的 API
var PyGILState_Ensure = new NativeFunction(
Module.findExportByName("python38.dll", "PyGILState_Ensure"),
'int', []
);
var PyGILState_Release = new NativeFunction(
Module.findExportByName("python38.dll", "PyGILState_Release"),
'void', ['int']
);
var PyRun_SimpleString = new NativeFunction(
Module.findExportByName("python38.dll", "PyRun_SimpleString"),
'int', ['pointer']
);
// 构造要执行的 Python 代码
var code = [
"import keyHelper",
"vault = keyHelper.KeyVault()",
"key = vault.get_aes_key()",
"with open('C:/Users/a/Desktop/key.txt', 'w') as f:",
" f.write(repr(key))",
].join("\n");
var codePtr = Memory.allocUtf8String(code);
// 获取 GIL(Python 全局解释器锁),确保线程安全
var gstate = PyGILState_Ensure();
// 在目标进程的 Python 解释器中执行代码
var ret = PyRun_SimpleString(codePtr);
console.log("PyRun_SimpleString returned:", ret); // 0 = success
// 释放 GIL
PyGILState_Release(gstate);
关键细节:
- GIL 必须获取:Python 解释器是单线程执行模型,Frida 注入的线程不持有 GIL,直接调用 Python API 会导致崩溃。
PyGILState_Ensure会暂停其他 Python 线程,让当前线程安全执行 - 写文件而非返回值:
PyRun_SimpleString只返回 0/1(成功/失败),无法直接获取 Python 返回值。通过让 Python 代码把结果写到文件,绕过这个限制 - 时机:需要在程序初始化完成后注入,此时
keyHelper模块已经加载
执行结果,key.txt 内容:
'22**)02j45_&*&1+'
AES Key 确认:22**)02j45_&*&1+(16 字节 ASCII 字符串)
验证:
key = b'22**)02j45_&*&1+'
len(key) # 16 ✅ AES-128
回溯分析(现在可以用已知 Key 还原推导过程):
part1 = "22"
part2 = ")02j45_&*&1+"
trans = str.maketrans("012", "210")
# part1 translate → "22"(无变化,不含 0/1/2 字符)
# 错误猜测:part1 + part2 = "22)02j45_&*&1+"(14位,不是16位)
# 实际:还有额外的字符插入或第三个 part,IDA 分析不完整
# 最终 Key 是 "22**)02j45_&*&1+"(16位,中间有 "**")
这证实了 IDA 静态分析确实遗漏了步骤(中间的 ** 从何而来),单纯静态分析无法完成。
第六阶段:加密流程完整验证
3.6.1 验证 AES Key 和 IV 机制
在确认 Key 后,用 Frida 注入验证完整的加解密流程:
var code = [
"from Crypto.Cipher import AES",
"from Crypto.Util.Padding import pad, unpad",
"from Crypto.Random import get_random_bytes",
"import base64, aeshelper",
"",
"KEY = b'22**)02j45_&*&1+'",
"plaintext = b'hello world test'",
"",
"# 用我们的 Key 加密",
"iv = get_random_bytes(16)",
"cipher = AES.new(KEY, AES.MODE_CBC, iv)",
"ct = iv + cipher.encrypt(pad(plaintext, 16))",
"ct_b64 = base64.b64encode(ct).decode()",
"",
"# 用程序自己的函数解密(验证 Key 正确性)",
"vault = __import__('keyHelper').KeyVault()",
"aes_key = vault.get_aes_key()",
"result = aeshelper.AesHelper.decrypt_aes(ct_b64, aes_key)",
"",
"with open('C:/Users/a/Desktop/verify.txt', 'w') as f:",
" f.write(f'Match: {result == plaintext.decode()}\\n')",
" f.write(f'Result: {repr(result)}\\n')",
].join("\n");
verify.txt 输出:
Match: True
Result: 'hello world test'
关键发现:IV 存储方式
decrypt_aes 的调用签名是 decrypt_aes(ct_b64, key)——只有两个参数,没有 IV 参数。说明 IV 被编码进了密文 Base64 中。
通过 Frida 注入测试确认:密文结构为 Base64(IV[16字节] + AES密文),解密时从密文前 16 字节提取 IV。这个设计常见于服务端 AES 库,方便 IV 随密文一起传输。
3.6.2 RSA 签名验证分析
从 IDA 中找到签名验证逻辑(位于 httpOperator 中,调用了 Crypto.PublicKey.RSA 和 Crypto.Signature.pkcs1_15):
// IDA 伪代码节选
v1 = PyImport_ImportModule("Crypto.Signature.pkcs1_15");
v2 = PyObject_GetAttrString(v1, "new");
// ... 构造 verifier
v3 = _PyObject_CallMethodIdObjArgs(verifier, &PyId_verify, hash_obj, sig_obj, NULL);
// 如果签名不合法,Crypto 库会抛出 ValueError 异常
这意味着:无论如何,响应数据的签名必须用服务器的 RSA 私钥签署,且必须用程序内嵌的 RSA 公钥验证通过。没有私钥就无法伪造有效响应。
这个发现封闭了"完全离线伪造响应"的路线,必须让程序联系真实服务器。
第七阶段:成功响应格式获取
3.7.1 使用正版激活码触发成功响应
此前只捕获到失败响应的格式,还不知道成功响应包含哪些字段。用正版激活码,通过 Frida 注入直接调用 httpOperator.getValidate:
var code = [
"import httpOperator",
"result = httpOperator.getValidate('REAL-LICENSE-KEY')",
"with open('C:/Users/a/Desktop/success.txt', 'w') as f:",
" f.write(repr(result))",
].join("\n");
success.txt 输出:
(
{
'key': 'REAL-LICENSE-KEY',
'type': 'v1',
'start': '2026-04-02 08:21:58',
'end': '2026-04-09 08:21:58',
'canuse': True,
't': 1775090246,
'rd': 9430648,
'rs': 6658,
'es': True
},
True
)
getValidate 返回一个二元组:(result_dict, is_valid: bool)。
对比失败响应({"msg":"不一致,请检查","rs":-1637,...})和成功响应,差异:
| 字段 | 失败时 | 成功时 |
|---|---|---|
msg |
有(错误信息字符串) | 无 |
rs |
负数(-1637) | 正数(6658) |
canuse |
无此字段 | True |
es |
无此字段 | True |
end |
无此字段 | 到期日期字符串 |
| 元组第二元素 | False |
True |
关键发现:程序通过检查 canuse == True 和 es == True 来判断授权是否有效,这两个字段只在成功响应中存在。
第八阶段:绕过授权验证
3.8.1 系统梳理可替换点
getValidate 的完整调用链(通过 IDA + Frida 联合分析确认):
getValidate(key)
│
├── 构造 mm 参数(RSA-2048 加密机器码 + IV) [Nuitka内联]
│
├── requests.get(url, params=...) [动态库调用,可Hook]
│
├── 解析响应:split('.') → 密文 + 签名 [Nuitka内联]
│
├── verify_signature(密文, 签名, RSA公钥) [Nuitka内联]
│
├── decrypt_aes(密文, aes_key) [Nuitka内联]
│
├── json.loads(明文) [动态库调用,可Hook]
│
└── validateHttp(result_dict, rs) [模块字典查找 ← 可替换!]
│
└── return (dict, True/False)
"Nuitka 内联"意味着:函数调用不通过 Python 模块查找机制,而是直接在 C 层硬编码调用地址,无法通过 Python 级别 monkeypatch 替换。
"模块字典查找"意味着:调用等价于 sys.modules['httpOperator'].validateHttp(...),会在运行时查找模块字典,可以通过替换 httpOperator.validateHttp 实现拦截。
3.8.2 逐一测试替换方案(记录尝试过程)
失败尝试 1:替换 getValidate
import httpOperator
original = httpOperator.getValidate
def fake_getValidate(key):
return ({'canuse': True, 'es': True, 'end': '2099-12-31'}, True)
httpOperator.getValidate = fake_getValidate
结果:替换对程序无效。原因:程序调用 getValidate 是通过 Nuitka 内联(C 层硬编码地址),不走模块字典。
失败尝试 2:替换二进制中的服务器 IP
用 HxD 把 111.229.156.130 改为 127.0.0.1\x00\x00\x00\x00。
结果:程序启动时崩溃,弹出错误框"程序文件损坏"。
证实存在二进制完整性校验(可能是 CRC 或哈希),修改任何字节都会被检测到。
失败尝试 3:替换 decrypt_aes
import aeshelper
original_decrypt = aeshelper.AesHelper.decrypt_aes
def fake_decrypt(ct_b64, key):
return '{"canuse":true,"es":true,"end":"2099-12-31"}'
aeshelper.AesHelper.decrypt_aes = fake_decrypt
结果:无效,decrypt_aes 也是通过 Nuitka 内联调用。
失败尝试 4:内存实时 Patch JSON
在程序解密后、json.loads 调用前,扫描内存找到 JSON 字符串,用 WriteProcessMemory 覆写内容。
结果:时机太难控制。从 decrypt_aes 返回到 json.loads 执行只有几微秒,脚本循环扫描的频率远不够。
失败尝试 5:伪造加密响应(通过 mitmproxy 拦截修改)
在 mitmproxy 脚本中替换响应:
from mitmproxy import http
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import base64, json
KEY = b'22**)02j45_&*&1+'
def response(flow: http.HTTPFlow):
if "GetValidateZJ" in flow.request.url:
fake_json = json.dumps({
"canuse": True, "es": True,
"end": "2099-12-31 23:59:59", "rs": 6658
}).encode()
iv = b'\x00' * 16
cipher = AES.new(KEY, AES.MODE_CBC, iv)
ct = iv + cipher.encrypt(pad(fake_json, 16))
ct_b64 = base64.b64encode(ct).decode()
# 问题:签名无法伪造
fake_sig = base64.b64encode(b'\x00' * 256).decode()
flow.response.text = f"{ct_b64}.{fake_sig}"
结果:程序在签名验证阶段崩溃(Crypto.Signature.pkcs1_15.verify 抛出 ValueError),验签失败。没有服务器 RSA 私钥,无法生成有效签名。
失败尝试 6:替换 RSA 公钥
用 Frida 注入,在 keyHelper.KeyVault.__init__ 中用自生成的公钥替换内嵌的服务器公钥:
# 生成自己的 RSA 密钥对
from Crypto.PublicKey import RSA
my_key = RSA.generate(1024)
my_pub = my_key.publickey().export_key().decode()
import keyHelper
vault = keyHelper.KeyVault()
vault.rsa_pub = my_pub # 替换公钥
结果:无效。Nuitka 编译的 verify_signature 函数在 C 层直接持有公钥对象的引用(在 __init__ 时构造并缓存),之后不再查找 vault.rsa_pub,Python 级别的属性替换对它无效。
成功方案:替换 validateHttp
穷举了所有 httpOperator 的公开函数后,发现 validateHttp 仍通过模块字典查找调用:
import httpOperator
def fake_validateHttp(*args, **kwargs):
"""
原始 validateHttp(result_dict, rs) 的行为:
- rs > 0 且 result_dict 包含 canuse=True 和 es=True → return (dict, True)
- 否则 → return (dict, False)
我们的版本:始终返回合法的授权字典
"""
if args and isinstance(args[0], dict):
d = args[0]
# 删除错误标志
d.pop("msg", None)
# 注入成功所需字段
d["canuse"] = True
d["es"] = True
d["type"] = "v1"
d["key"] = d.get("key", "CRACKED-KEY")
d["start"] = "2026-01-01 00:00:00"
d["end"] = "2099-12-31 23:59:59"
# rs 改为正数,避免其他位置有额外判断
d["rs"] = 6658
return (args[0] if args else {}, True)
# 替换模块字典中的函数
httpOperator.validateHttp = fake_validateHttp
为什么 dict 原地修改可行:
Python 中函数参数是引用传递(对可变对象)。getValidate 在 C 层将 result_dict 对象的指针传给 validateHttp,我们的 fake_validateHttp 修改的是同一个 dict 对象,getValidate 后续读取这个 dict 时看到的是修改后的内容。
完整工作流:
1. 程序用任意激活码构造请求
↓
2. 请求到达真实服务器,服务器返回"验证失败"的加密响应
(响应是合法的:密文用真实 AES Key 加密,签名用真实 RSA 私钥签名)
↓
3. getValidate 完成签名验证 ✅(签名是真实的)
↓
4. getValidate 完成 AES 解密,得到 JSON:
{"msg":"不一致,请检查","rs":-1637,...}
↓
5. getValidate 调用 validateHttp(result_dict, -1637)
↓
6. fake_validateHttp 接管:原地修改 dict,删除 msg,写入 canuse=True, es=True
↓
7. fake_validateHttp 返回 (modified_dict, True)
↓
8. getValidate 返回 (modified_dict, True)
↓
9. 程序判断 canuse==True && es==True → 授权成功 ✅
四、关键障碍与解决路径
4.1 ARM Windows 调试环境缺失
| 工具 | 失效原因 | 替代方案 |
|---|---|---|
| x64dbg | 非 ARM64 PE,无法运行 | 放弃传统调试器路线 |
| Frida Interceptor | SXS 翻译层内存页写保护 | PyGILState_Ensure + PyRun_SimpleString |
| WinDbg 单步 | 翻译层中断不稳定 | 不使用单步,改用内存扫描 |
| ReadProcessMemory | 正常工作 | 作为 Frida Memory.scan 的替代 |
ARM Windows 实际上是用软件翻译层把 x64 指令翻译为 ARM64 执行,这个翻译层会对原始 x64 代码页加额外保护,导致 Frida 的 JMP hook 写入失败。但翻译层不影响 Python 解释器本身(Python 解释器执行的是 Python 字节码或 C 扩展,而非被翻译的 x64 机器码)。
4.2 Nuitka 编译的 Monkeypatch 失效
Nuitka 对"内联"vs"模块字典查找"的选择基于其编译器的优化判断:
- 被调用函数与调用者在同一 Nuitka 编译模块中:内联,生成
call 0x140XXXXXX形式的直接调用 - 被调用函数是另一个 Python 模块中的顶层函数:走模块字典,生成等价于
sys.modules['mod'].func(...)的调用
validateHttp 恰好属于后者:它是 httpOperator 的顶层函数,被 getValidate 通过模块查找调用,因此可以被替换。这是程序防护的薄弱点。
4.3 AES 密钥提取的思路突破
三种失败方案(静态分析、内存暴力、Hook)的共同假设是"我需要拿到 Key"。
突破来自思路转换:不需要拿到 Key,只需要让 Key 发挥作用。
通过 PyRun_SimpleString 在目标进程内执行 vault.get_aes_key(),借助目标程序自身的逻辑计算 Key,绕过了所有"如何逆向计算过程"的问题。这种"活用目标程序自身能力"的思路在 Nuitka 逆向中特别有效:源码还原不了,但模块依然可以调用。
4.4 RSA 签名无法绕过
RSA-1024 签名(服务端私钥签名、客户端公钥验证)在没有私钥的情况下无法伪造。这个防护是有效的。绕过路线是绕开它而非破解它:让程序正常完成签名验证,在验证成功后的下一步(validateHttp)做手脚。
五、防护强度评估
5.1 各防护措施的实际效果
| 防护措施 | 设计强度 | 实际效果 | 薄弱点 |
|---|---|---|---|
| Nuitka 编译 | ⭐⭐⭐⭐⭐ | 源码还原完全失败,大幅增加逆向难度 | 模块仍可在运行时被调用 |
| RSA 签名验证 | ⭐⭐⭐⭐⭐ | 无法伪造,完全有效 | 无薄弱点(需正常联网) |
| AES-128-CBC 通信加密 | ⭐⭐⭐⭐ | Key 无法静态获取,动态提取需较高技术 | 通过进程内注入可获取 |
| 机器绑定(RSA加密机器码) | ⭐⭐⭐⭐ | 正版 Key 无法跨设备使用 | bypass 后无意义 |
| 二进制完整性校验 | ⭐⭐⭐⭐ | 修改二进制后无法启动 | 不 patch 二进制则不触发 |
| 动态 IV(每次请求随机) | ⭐⭐⭐ | 防重放有效 | 不影响 bypass 路线 |
validateHttp 的调用方式 |
⭐ | 这是整个防护体系的决定性薄弱点 | 通过模块字典调用,可被替换 |
5.2 加固建议(防御视角)
关键路径完全 Nuitka 化:将
validateHttp的逻辑直接内联到getValidate中(而非作为独立函数通过模块调用),消除运行时替换的机会Python 运行时保护:在
sys.modules层面锁定关键模块,阻止外部修改(如在__init_subclass__中检测属性替换)本地状态混淆:不直接使用
canuse和es作为授权标志,改用经过变换(如 XOR 混淆)的数值存储,增加内存取证和状态篡改的难度Anti-Frida 检测:检测 Frida Agent 的内存特征(如
frida-agent-64.dll的存在、frida_前缀导出函数)或PyGILState的异常调用来源
六、完整逆向思路总结
阶段 1:目标识别
file + DIE → PE64, Python 3.8
字符串搜索 → Nuitka 特征
节区分析 → .text 38MB 机器码 + .rsrc 229MB 标准库
结论:Nuitka 编译,无源码,必须运行时分析
阶段 2:攻面摸底
IDA 字符串分析 → API地址、模块名、字段名
模块职责推断 → keyHelper/aeshelper/httpOperator
混淆常量识别 → "22", ")02j45_&*&1+", maketrans
阶段 3:协议还原
HTTP_PROXY 拦截 → 请求/响应格式
响应结构分析 → 密文(Base64A) + 签名(Base64B)
长度分析 → AES-128-CBC + RSA-1024 签名
阶段 4:内存取证
ctypes + ReadProcessMemory → 进程内存扫描
时机控制(100ms 循环)→ 捕获解密后 JSON
失败响应明文 → {"msg":"不一致","rs":-1637,...}
阶段 5:密钥提取(核心难点)
方案1: IDA 静态分析密钥推导 → 失败(逻辑不完整)
方案2: 内存 Dump 暴力搜索(8.3亿候选)→ 失败(GC 已回收)
方案3: Frida Interceptor Hook → 失败(ARM Windows 内存保护)
方案4: PyGILState_Ensure + PyRun_SimpleString → 成功!
AES Key = '22**)02j45_&*&1+'
阶段 6:加密验证
Frida 注入验证 AES-CBC 加解密 ✅
IV 机制确认:密文前 16 字节 = IV
阶段 7:成功格式确认
Frida 注入调用 getValidate(正版key)
成功响应字段 = {canuse:True, es:True, end:"...", rs:正数}
阶段 8:绕过实现
逐一测试 httpOperator 所有函数
validateHttp 通过模块字典调用 → 可替换
fake_validateHttp 原地修改 dict → return (dict, True)
任意激活码 → 授权成功 ✅
核心方法论
| 原则 | 说明 |
|---|---|
| 能让程序自己算,不要自己逆向算法 | Frida 注入 PyRun_SimpleString,借程序之力提取 Key |
| 能替换高层函数,不要 patch 底层机器码 | validateHttp 替换 vs 机器码 NOP(前者有效,后者无效) |
| RSA 绕不过就绕开 | 不伪造签名,在签名验证通过后的下游做手脚 |
| Nuitka 的模块边界是突破口 | 跨模块调用走字典查找,这是 Python 动态性的不可消除特征 |
| 内存取证的时机比精度更重要 | 高频扫描 + 宽泛模式匹配,而非精准地址 |
七、免责声明
本文记录的逆向分析技术、工具使用和思路仅供学习研究目的。逆向分析他人软件可能涉及《计算机软件保护条例》《网络安全法》等法律法规,请在获得合法授权后操作。尊重软件开发者的劳动成果。