、# 战舰工具 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 编译后:
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=a17K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0p5J5y4#2)9J5k6e0m8Q4x3X3f1H3i4K6u0W2x3g2)9K6b7e0R3^5z5o6R3`.
set HTTPS_PROXY=32dK9s2c8@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 字节是什么?根据 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 }
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 行中的关键部分):
v12 = PyUnicode_FromStringAndSize("22" , 2 );
Py_INCREF(v12);
PyObject_SetAttrString(self, "part1" , v12);
Py_DECREF(v12);
v13 = PyUnicode_FromStringAndSize(")02j45_&*&1+" , 12 );
Py_INCREF(v13);
PyObject_SetAttrString(self, "part2" , v13);
Py_DECREF(v13);
v14 = PyUnicode_FromStringAndSize("012" , 3 );
v15 = PyUnicode_FromStringAndSize("210" , 3 );
v16 = _PyObject_CallMethodIdObjArgs(
&PyUnicode_Type, &PyId_maketrans, v14, v15, NULL );
Py_DECREF(v14); Py_DECREF(v15);
PyObject_SetAttrString(self, "trans" , v16);
Py_DECREF(v16);
get_aes_key 伪代码(约 150 行 C,对应约 8 行 Python):
v2 = PyObject_GetAttrString(self, "part1" );
v3 = PyObject_GetAttrString(self, "part2" );
v4 = PyObject_GetAttrString(self, "trans" );
v5 = _PyObject_CallMethodIdObjArgs(v2, &PyId_translate, v4, NULL );
v6 = PyUnicode_Concat(v5, v3);
Py_DECREF(v5);
v7 = _PyObject_CallMethodIdObjArgs(v3, &PyId_translate, v4, NULL );
尝试手动还原算法,对常量 "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" ]):
pass
失败原因: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 :
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>
static const uint8_t known_ct[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 ,
NULL ,
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 返回时截获返回值。
var base = Module .findBaseAddress ("main.exe" );
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 脚本:
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' ]
);
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);
var gstate = PyGILState _Ensure();
var ret = PyRun _SimpleString(codePtr);
console .log ("PyRun_SimpleString returned:" , ret);
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)
回溯分析(现在可以用已知 Key 还原推导过程):
part1 = "22"
part2 = ")02j45_&*&1+"
trans = str .maketrans("012" , "210" )
这证实了 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):
v1 = PyImport_ImportModule("Crypto.Signature.pkcs1_15" );
v2 = PyObject_GetAttrString(v1, "new" );
v3 = _PyObject_CallMethodIdObjArgs(verifier, &PyId_verify, hash_obj, sig_obj, NULL );
这意味着:无论如何,响应数据的签名必须用服务器的 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__ 中用自生成的公钥替换内嵌的服务器公钥:
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"
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 动态性的不可消除特征
内存取证的时机比精度更重要
高频扫描 + 宽泛模式匹配,而非精准地址
七、免责声明
本文记录的逆向分析技术、工具使用和思路仅供学习研究目的。逆向分析他人软件可能涉及《计算机软件保护条例》《网络安全法》等法律法规,请在获得合法授权后操作。尊重软件开发者的劳动成果。
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!