【逆向工程-Nuitka逆向-战**具 1.5 逆向分析与授权绕过全记录】此文章归类为:逆向工程。
、# 战舰工具 1.47 逆向分析与授权绕过全记录
声明:本文仅用于技术研究与学习,记录逆向分析的完整思路与过程。逆向他人软件可能涉及法律风险,请在合法授权范围内操作,尊重开发者劳动成果。
本次分析对象为一款基于 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 编译,防止源码还原。
理解并绕过授权验证机制,实现在不联系服务器的情况下让任意激活码通过本地验证。
| 工具 | 版本 | 用途 | 选型理由 |
|---|---|---|---|
| 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 | - | 十六进制编辑器 | 二进制字符串搜索 |
ARM Windows 的根本性问题:
程序虽为 x64 机器码,但 Windows ARM 内核通过 SXS(Software Translation for x64)进行透明翻译执行。这导致:
Interceptor.attach 失效:Frida 通过在目标函数头部写入跳转指令(JMP hook)实现拦截。但 Python C API 函数(PyRun_SimpleString、PyObject_CallObject 等)的代码页被 SXS 标记为只读,写入跳转指令导致访问违例这三个限制使常规动态调试路径全部封闭,被迫发展出一套替代方案。
第一步不是打开 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 编译无疑。
Nuitka 不同于 PyInstaller 的关键点:
.pyc 字节码打包,运行时解压,.pyc 可以被 uncompyle6/decompile3 还原为源码.text 段中没有任何 Python 字节码Nuitka 翻译后的 C 代码特征:
impl_get_aes_key_$1)Py_INCREF/Py_DECREF 调用(Python 引用计数)tmp_1, tmp_2, ..., outline_0PyObject* 到处传递一个简单的 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 代码,全是引用计数和异常处理。
结论:必须放弃"还原源码"的思路,转向运行时动态分析。
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
根据字符串分布(每个字符串引用了哪个函数段),推断各模块职责:
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 逻辑目标服务器是裸 IP(111.229.156.130),常规方案:
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=c06K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0p5J5y4#2)9J5k6e0m8Q4x3X3f1H3i4K6u0W2x3g2)9K6b7e0R3^5z5o6R3`.
set HTTPS_PROXY=a48K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0p5J5y4#2)9J5k6e0m8Q4x3X3f1H3i4K6u0W2x3g2)9K6b7e0R3^5z5o6R3`.
main.exe
Python requests 库会自动读取 HTTP_PROXY 环境变量。mitmproxy 启动在 8888 端口,成功拦截到流量。
拦截到的授权验证请求:
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(用于加密响应)。
服务器响应(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)
此时已知响应格式,但不知道 AES Key,无法自己解密。但程序自身会解密,解密后的 JSON 必然短暂存在于进程内存中。
思路:在程序完成解密后、读取 JSON 字段前,扫描进程内存,搜索 JSON 特征字符串。
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)
内存扫描有时机问题:扫描太早,程序还没解密;扫描太晚,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()
第一次成功捕获(使用随机激活码):
{"msg":"不一致,请检查","rs":-1637,"t":1775072039,"rd":5018565}
字段解析:
msg:错误信息,存在 msg 字段 → 验证失败rs:响应状态码,负数 → 失败t:Unix 时间戳(服务端时间,防重放)rd:随机数(防重放)但成功响应的格式还不知道,需要用正版激活码触发一次。
这是整个逆向过程历时最长、失败次数最多的环节,前后经历了四种方案。
在 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 小时,失败。
思路: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。
失败原因分析:
str 对象,get_aes_key() 执行完后返回值,调用方用完即释放引用str 内部是 Unicode 对象(PyCompactUnicodeObject),在内存中的布局是对象头 + 紧随其后的字符数据耗时:约 6 小时(含脚本编写、dump 采集、搜索),失败。
思路: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 小时,失败。
关键洞察:既然 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);
关键细节:
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 静态分析确实遗漏了步骤(中间的 ** 从何而来),单纯静态分析无法完成。
在确认 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 随密文一起传输。
从 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 公钥验证通过。没有私钥就无法伪造有效响应。
这个发现封闭了"完全离线伪造响应"的路线,必须让程序联系真实服务器。
此前只捕获到失败响应的格式,还不知道成功响应包含哪些字段。用正版激活码,通过 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 来判断授权是否有效,这两个字段只在成功响应中存在。
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 实现拦截。
失败尝试 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 → 授权成功 ✅
| 工具 | 失效原因 | 替代方案 |
|---|---|---|
| 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 机器码)。
Nuitka 对"内联"vs"模块字典查找"的选择基于其编译器的优化判断:
call 0x140XXXXXX 形式的直接调用sys.modules['mod'].func(...) 的调用validateHttp 恰好属于后者:它是 httpOperator 的顶层函数,被 getValidate 通过模块查找调用,因此可以被替换。这是程序防护的薄弱点。
三种失败方案(静态分析、内存暴力、Hook)的共同假设是"我需要拿到 Key"。
突破来自思路转换:不需要拿到 Key,只需要让 Key 发挥作用。
通过 PyRun_SimpleString 在目标进程内执行 vault.get_aes_key(),借助目标程序自身的逻辑计算 Key,绕过了所有"如何逆向计算过程"的问题。这种"活用目标程序自身能力"的思路在 Nuitka 逆向中特别有效:源码还原不了,但模块依然可以调用。
RSA-1024 签名(服务端私钥签名、客户端公钥验证)在没有私钥的情况下无法伪造。这个防护是有效的。绕过路线是绕开它而非破解它:让程序正常完成签名验证,在验证成功后的下一步(validateHttp)做手脚。
| 防护措施 | 设计强度 | 实际效果 | 薄弱点 |
|---|---|---|---|
| Nuitka 编译 | ⭐⭐⭐⭐⭐ | 源码还原完全失败,大幅增加逆向难度 | 模块仍可在运行时被调用 |
| RSA 签名验证 | ⭐⭐⭐⭐⭐ | 无法伪造,完全有效 | 无薄弱点(需正常联网) |
| AES-128-CBC 通信加密 | ⭐⭐⭐⭐ | Key 无法静态获取,动态提取需较高技术 | 通过进程内注入可获取 |
| 机器绑定(RSA加密机器码) | ⭐⭐⭐⭐ | 正版 Key 无法跨设备使用 | bypass 后无意义 |
| 二进制完整性校验 | ⭐⭐⭐⭐ | 修改二进制后无法启动 | 不 patch 二进制则不触发 |
| 动态 IV(每次请求随机) | ⭐⭐⭐ | 防重放有效 | 不影响 bypass 路线 |
validateHttp 的调用方式 |
⭐ | 这是整个防护体系的决定性薄弱点 | 通过模块字典调用,可被替换 |
关键路径完全 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 动态性的不可消除特征 |
| 内存取证的时机比精度更重要 | 高频扫描 + 宽泛模式匹配,而非精准地址 |
本文记录的逆向分析技术、工具使用和思路仅供学习研究目的。逆向分析他人软件可能涉及《计算机软件保护条例》《网络安全法》等法律法规,请在获得合法授权后操作。尊重软件开发者的劳动成果。
更多【逆向工程-Nuitka逆向-战**具 1.5 逆向分析与授权绕过全记录】相关视频教程:www.yxfzedu.com