首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
逆向工程
发新帖
10
5
[原创]Nuitka逆向-战**具 1.5 逆向分析与授权绕过全记录
发表于: 2026-4-3 02:15
5188
[原创]Nuitka逆向-战**具 1.5 逆向分析与授权绕过全记录
Zedbully
2026-4-3 02:15
5188
、# 战舰工具 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)进行透明翻译执行。这导致: 1. **x64dbg 无法启动**:x64dbg 本身是 x64 PE,在 ARM Windows 上无法运行(不是 UWP/ARM64) 2. **WinDbg 兼容性有限**:调试翻译层上运行的 x64 进程时,断点行为不稳定,单步执行会 crash 3. **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 编译后: ```c // 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 + 端口转发: ```batch :: 给本机以太网接口添加一个额外 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` 环境变量(成功): ```batch set HTTP_PROXY=http://127.0.0.1:8888 set HTTPS_PROXY=http://127.0.0.1:8888 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)。 ```python 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: ```python 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 扫描一次,在点击"激活"按钮后立即触发: ```python 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 找到失败响应的明文 第一次成功捕获(使用随机激活码): ```json {"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 行中的关键部分): ```c // 对应 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): ```c // 从 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+"` 进行各种组合: ```python 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: ```python 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 太慢): ```c #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` 返回时截获返回值。 ```javascript // 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 脚本: ```javascript // 找到 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); ``` 关键细节: 1. **GIL 必须获取**:Python 解释器是单线程执行模型,Frida 注入的线程不持有 GIL,直接调用 Python API 会导致崩溃。`PyGILState_Ensure` 会暂停其他 Python 线程,让当前线程安全执行 2. **写文件而非返回值**:`PyRun_SimpleString` 只返回 0/1(成功/失败),无法直接获取 Python 返回值。通过让 Python 代码把结果写到文件,绕过这个限制 3. **时机**:需要在程序初始化完成后注入,此时 `keyHelper` 模块已经加载 执行结果,`key.txt` 内容: ``` '22**)02j45_&*&1+' ``` **AES Key 确认:`22**)02j45_&*&1+`(16 字节 ASCII 字符串)** 验证: ```python key = b'22**)02j45_&*&1+' len(key) # 16 ✅ AES-128 ``` 回溯分析(现在可以用已知 Key 还原推导过程): ```python 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 注入验证完整的加解密流程: ```javascript 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`): ```c // 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`: ```javascript 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` 输出: ```python ( { '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`** ```python 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`** ```python 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 脚本中替换响应: ```python 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__` 中用自生成的公钥替换内嵌的服务器公钥: ```python # 生成自己的 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` 仍通过模块字典查找调用: ```python 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 加固建议(防御视角) 1. **关键路径完全 Nuitka 化**:将 `validateHttp` 的逻辑直接内联到 `getValidate` 中(而非作为独立函数通过模块调用),消除运行时替换的机会 2. **Python 运行时保护**:在 `sys.modules` 层面锁定关键模块,阻止外部修改(如在 `__init_subclass__` 中检测属性替换) 3. **本地状态混淆**:不直接使用 `canuse` 和 `es` 作为授权标志,改用经过变换(如 XOR 混淆)的数值存储,增加内存取证和状态篡改的难度 4. **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 动态性的不可消除特征 | | 内存取证的时机比精度更重要 | 高频扫描 + 宽泛模式匹配,而非精准地址 | --- ## 七、免责声明 本文记录的逆向分析技术、工具使用和思路仅供学习研究目的。逆向分析他人软件可能涉及《计算机软件保护条例》《网络安全法》等法律法规,请在获得合法授权后操作。尊重软件开发者的劳动成果。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
#调试逆向
#软件保护
#加密算法
收藏
・
10
点赞
・
5
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
hanluz
+2
谢谢你的细致分析,受益匪浅!
2026-7-21 19:20
wx_晨梦
期待更多优质内容的分享,论坛有你更精彩!
2026-7-16 05:28
AEVE
感谢你的积极参与,期待更多精彩内容!
2026-5-12 18:25
ab012
+1
谢谢你的细致分析,受益匪浅!
2026-5-11 23:25
我的小拇指啊
感谢你的积极参与,期待更多精彩内容!
2026-4-3 18:19
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
3
)
mb_flcmhcvi
雪 币:
511
能力值:
( LV1,RANK:0 )
在线值:
发帖
0
回帖
11
粉丝
0
关注
私信
mb_flcmhcvi
2
楼
感谢大牛的无私分享,现在我遇到一个情况,就是nuitka打包的,不是单文件,带环境的,主文件加了VMP3.X的壳,对于这种请教一下大佬,应该怎么入手分析。尝试了各种方法,最后都失败了,请大佬指点一下
2026-5-9 17:46
0
ab012
雪 币:
24
活跃值:
(71)
能力值:
( LV2,RANK:10 )
在线值:
发帖
0
回帖
2
粉丝
0
关注
私信
ab012
3
楼
大牛太厉害了!请问有没有付费帮忙找漏洞的服务?我的软件和您上面分析的软件授权有点相似,之前也被破解,最近做了加固,想看看破解授权的难度
2026-5-11 23:23
0
口艾口牙女马口牙
雪 币:
113
活跃值:
(1009)
能力值:
( LV2,RANK:10 )
在线值:
发帖
2
回帖
53
粉丝
0
关注
私信
口艾口牙女马口牙
4
楼
很有用,我之前只尝试过内存搜索秘钥,但是没有私钥,无法伪造返回替换,然后打开后内存替换又晚了。尝试Frida进行hook无任何输出。就完成了解密环节
2026-6-4 16:26
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
Zedbully
30
发帖
17
回帖
20
RANK
关注
私信
他的文章
[原创]金融 APP 安全协同方案:御盾加固、反作弊与防羊毛党实战评测
324
[原创]御盾 App 加固效果实测与质量深度剖析
379
Java2C、VMP 还是 SO 保护:Android 核心代码到底该放在哪里
684
从Java Hook到eBPF:Android动态注入防护的威胁模型变化
900
JADX 看不到算法之后,公开脱壳工具还能走多远:一次御盾加固 APK 实测
3353
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部