首页
社区
课程
招聘
[原创]Nuitka逆向-战**具 1.5 逆向分析与授权绕过全记录
发表于: 2天前 892

[原创]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)进行透明翻译执行。这导致:

  1. x64dbg 无法启动:x64dbg 本身是 x64 PE,在 ARM Windows 上无法运行(不是 UWP/ARM64)
  2. WinDbg 兼容性有限:调试翻译层上运行的 x64 进程时,断点行为不稳定,单步执行会 crash
  3. Frida Interceptor.attach 失效:Frida 通过在目标函数头部写入跳转指令(JMP hook)实现拦截。但 Python C API 函数(PyRun_SimpleStringPyObject_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:引用了 base64AESpadunpad。负责 AES 加解密
  • httpOperator:引用了所有 API URL、requestshashlibhmac。负责 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 &lt;CommonCrypto/CommonCryptor.h&gt;
#include &lt;stdio.h&gt;
#include &lt;string.h&gt;

// 已知的一段密文(去掉前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_CallObjectPyUnicode_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);

关键细节:

  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 字符串)

验证:

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.RSACrypto.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 == Truees == 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=True7. 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. 本地状态混淆:不直接使用 canusees 作为授权标志,改用经过变换(如 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 原地修改 dictreturn (dict, True)
  任意激活码 → 授权成功 ✅

核心方法论

原则 说明
能让程序自己算,不要自己逆向算法 Frida 注入 PyRun_SimpleString,借程序之力提取 Key
能替换高层函数,不要 patch 底层机器码 validateHttp 替换 vs 机器码 NOP(前者有效,后者无效)
RSA 绕不过就绕开 不伪造签名,在签名验证通过后的下游做手脚
Nuitka 的模块边界是突破口 跨模块调用走字典查找,这是 Python 动态性的不可消除特征
内存取证的时机比精度更重要 高频扫描 + 宽泛模式匹配,而非精准地址

七、免责声明

本文记录的逆向分析技术、工具使用和思路仅供学习研究目的。逆向分析他人软件可能涉及《计算机软件保护条例》《网络安全法》等法律法规,请在获得合法授权后操作。尊重软件开发者的劳动成果。


传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

收藏
免费 1
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回