导读: 第 5 篇用 ReadProcessMemory 从运行中的进程里偷出了干净的代码段——73,728 字节、96 个函数。脱壳只是第一步,真正的问题现在才出现:面对这 73KB 的汇编代码,从哪看起?怎么知道里面哪些函数是 MD5、哪些是 RC4、哪些在做 RSA?本文展示一套系统性的方法——算法指纹识别:先用字符串交叉引用定位核心校验函数,再用每种密码学算法独有的"指纹"(常数、结构、调用序列)把它从茫茫汇编中认出来。


一、定位 Verify():字符串交叉引用

面对 73KB 代码的第一个动作,不是逐行读,而是搜索

我们知道程序成功时弹 "Great"、失败时弹 "Failed"。这两个字符串的地址(第 1 篇已确认在数据段 0x41E1000x41E160)一定会被某条指令引用——通常是 push 压栈作为 MessageBoxA 的参数。

搜索引用这两个地址的所有 push imm32 指令:

import struct

with open('text_code.bin', 'rb') as f:
    code = f.read()

base = 0x401000
targets = {
    'Great':    0x41E100,
    'Failed':   0x41E160,
}
for name, va in targets.items():
    needle = b'\x68' + struct.pack('<I', va)  # 68 = push imm32
    hits = []
    start = 0
    while True:
        off = code.find(needle, start)
        if off < 0:
            break
        hits.append(hex(base + off))
        start = off + 1
    print(f'{name}: {hits}')

实测输出:

Great:  ['0x4013bf']
Failed: ['0x4013d7']

两个引用紧紧挨着(相差 0x18 字节)——这通常意味着它们属于同一个函数里的成功/失败分支。把 0x4013A0~0x401400 这段反汇编出来:

0x4013A2: mov [esp], 0x417180      ; arg0 = &GLOBAL_NAME_BUF
0x4013A9: mov [esp+4], 0x417100    ; arg1 = &GLOBAL_SERIAL_BUF
0x4013B1: call 0x401610            ; ⭐ 调用核心校验函数
0x4013B6: movzx edx, al            ; 取返回值低字节
0x4013B9: test edx, edx
0x4013BB: je 失败路径
0x4013BD: push 0x40                ; MB_OK | MB_ICONASTERISK
0x4013BF: push 0x41E100            ; "Great"
0x4013C4: push 0x41E0E0            ; "Successfully registered!"
0x4013CA: call [0x413124]          ; MessageBoxA
0x4013D0: jmp 完成
0x4013D5: push 0x10                ; MB_OK | MB_ICONERROR
0x4013D7: push 0x41E160            ; "Failed"
0x4013DC: push 0x41E120            ; "Incorrect serial! You are refused."
0x4013E2: call [0x413124]          ; MessageBoxA

这段代码揭示了三个关键事实:

  1. 0x401610 就是核心校验函数——call 它之后根据返回的 al 决定弹成功还是失败
  2. 调用方式:调用者在 [esp] 写入 0x417180(Name 全局缓冲区地址)、在 [esp+4] 写入 0x417100(Serial 全局缓冲区地址),然后调用。参数本身就是全局缓冲区的地址
  3. 弹窗逻辑在调用点函数里,不在 0x401610 内部——0x401610 只负责计算并返回布尔值

在这里插入图片描述

这个"调用点函数"在对话框过程(DlgProc)里,通常在 WM_COMMAND 处理"Register"按钮点击的分支中。


二、MD5 指纹:四个 IV 常数

定位到 0x401610(Verify())后,反汇编它。函数开头是一段标准序言,然后调用了几个子函数。第一个引起注意的是 0x4016ED: call 0x408310

0x408310 处反汇编,看到这样的代码:

0x408310: mov edx, dword ptr [esp + 4]
0x408314: xor eax, eax
0x408316: mov dword ptr [edx + 4], eax
0x408319: mov dword ptr [edx], eax
0x40831B: mov dword ptr [edx + 8], 0x67452301
0x408322: mov dword ptr [edx + 0xc], 0xefcdab89
0x408329: mov dword ptr [edx + 0x10], 0x98badcfe
0x408330: mov dword ptr [edx + 0x14], 0x10325476

0x674523010xEFCDAB890x98BADCFE0x10325476 这四个常数,是 MD5 独一无二的指纹。 全世界没有任何其他标准算法把这四个值作为初始化向量。看到它们,基本可以断定这是 MD5_Init

一个重要的技术细节:这四个常数不是连续存储的。它们分散在四条 mov dword ptr [mem], imm32 指令里,每条指令的编码是 C7 42 XX <4字节立即数>——opcode 3 字节 + 立即数 4 字节,所以相邻两个常数的字节间隔正好是 7。

实测逐个搜索这四个常数:

import struct

ivs = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476]
for c in ivs:
    needle = struct.pack('<I', c)
    off = code.find(needle)
    print(f'0x{c:08X} -> VA 0x{base + off:06X}')

实测输出:

0x67452301 -> VA 0x40831E
0xEFCDAB89 -> VA 0x408325
0x98BADCFE -> VA 0x40832C
0x10325476 -> VA 0x408333

四个命中点的间隔恰好都是 7 字节(0x31E0x3250x32C0x333),与指令编码完全吻合。

踩坑提示:如果你试图把四个常数拼成 16 字节连续序列去 find(),会搜不到——因为它们在指令流里被 opcode 分隔开了。理解指令编码格式,才能正确搜索。

在这里插入图片描述


三、RC4 指纹:KSA + PRGA 双循环结构

MD5 有常数可以搜,RC4 没有——RC4 的结构里不存在任何魔数。但它有另一个更独特的指纹:算法结构

RC4 由两个阶段组成:

KSA(Key Scheduling Algorithm),用密钥打乱 256 字节的 S-box:

; RC4_KSA @ 0x408FF0(结构还原)
; 第一步:S[i] = i,填充 0~255
;   (循环 256 次,每次 mov [S+i], i)
; 第二步:用密钥做 256 轮交换
;   j = (j + S[i] + key[i % keylen]) mod 256
;   swap(S[i], S[j])

PRGA(Pseudo-Random Generation Algorithm),生成密钥流:

; RC4_PRGA @ 0x4090F0(结构还原)
; i = (i + 1) mod 256
; j = (j + S[i]) mod 256
; swap(S[i], S[j])
; output = S[(S[i] + S[j]) mod 256]

识别 RC4 靠三个结构特征:

  1. 256 轮循环——反汇编里能看到 cmp reg, 0x100; jl 循环 这样的边界检查
  2. 两个计数器 i、j 持续递增取模——RC4 独有
  3. 每次循环交换两个 S-box 元素——KSA 和 PRGA 里都有

在这里插入图片描述

在 Verify() 里,RC4 的调用序列是:

0x40176F: call 0x408FF0   ; RC4_KSA(key, 8)
0x401782: call 0x4090F0   ; RC4_PRGA(decoded_buf, 8)

密钥在调用前由 8 条 mov byte 指令逐字节写入栈帧:

0x401726: mov byte [ebp-0x160], 0x29
0x40172D: mov byte [ebp-0x15F], 0x47
0x401734: mov byte [ebp-0x15E], 0x07
0x40173B: mov byte [ebp-0x15D], 0x85
0x401742: mov byte [ebp-0x15C], 0x87
0x401749: mov byte [ebp-0x15B], 0x33
0x401750: mov byte [ebp-0x15A], 0x25
0x401757: mov byte [ebp-0x159], 0x44

8 字节密钥 = 29 47 07 85 87 33 25 44。这是硬编码在代码里的常量,稍后 Keygen 会用到。


四、RSA 指纹:zexpmod 大数模幂

继续往下看 Verify(),在 RC4 比较通过之后,出现了一组更奇怪的调用:

0x4017F4: call 0x4071D0   ; zsread(decoded[8:])  十进制字符串 → 大数 D
0x40180F: call 0x4071D0   ; zsread("65537")      十进制字符串 → 大数 E
0x401820: call 0x406ED0   ; zfread("B80A90BF53C6C979")  十六进制字符串 → 大数 N
0x401838: call 0x405E40   ; zexpmod(D, E, N)     大数模幂 D^E mod N

注意第 2、3 个调用的参数——压栈的是两个字符串的地址 0x41E2200x41E240(第 5 篇 dump 数据段时已经确认过内容分别是 "65537""B80A90BF53C6C979")。

在 2001 年的 CrackMe 语境里,"十进制/十六进制字符串转大数 + 大数模幂"这个组合几乎只有一个用途:RSA(ElGamal 也做模幂,但远不如 RSA 常见)。

三个函数各司其职:

函数 地址 作用
zsread 0x4071D0 十进制 ASCII 字符串 → 大数
zfread 0x406ED0 十六进制 ASCII 字符串 → 大数
zexpmod 0x405E40 计算 base^exp mod modulus

这段代码的实际数学含义是:

D = 序列号 Base64 解码后的数字部分(大数)
E = 65537                    (RSA 公钥指数)
N = 0xB80A90BF53C6C979       (RSA 模数)
校验: D^E mod N == MD5(Name) 的后 8 字节

这是 RSA 签名验证——用公钥 (E, N) 验证序列号里携带的数字 D 是"正确签名"。

在这里插入图片描述


五、Verify() 的完整逻辑

把以上识别结果拼起来,Verify()(0x401610)的完整逻辑是:

输入: 参数1 = 0x417180 的地址(Name 全局缓冲区)
     参数2 = 0x417100 的地址(Serial 全局缓冲区)

① Base64Decode(Serial)            → decoded(要求解码后 ≥ 8 字节,且第 8 字节起全是数字字符)
② MD5(Name)                       → 16 字节摘要 M
③ RC4(CONST_KEY, decoded[0:8])    → 与 M[0:8] 比较,不等则失败
④ D = zsread(decoded[8:])         → 数字部分解析为大数
   E = zsread("65537")
   N = zfread("B80A90BF53C6C979")
   zexpmod(D, E, N)                → 与 M[8:16] 比较,不等则失败
⑤ 全部通过 → eax=1;任一失败 → eax=0

在这里插入图片描述


六、方法论总结

回顾本文的识别方法,可以抽象为三条通用原则:

  1. 字符串引用优先于指令分析"Great"/"Failed" 这样的 UI 字符串是最可靠的定位锚点——它们必须被某个函数引用,顺着 push 就能找到决策点,再回溯到校验函数。

  2. 常数识别先理解编码。MD5 的 IV 常数藏在 mov dword [mem], imm32 指令里,整体连续搜索会失败,必须理解指令编码(C7 42 XX imm32)后逐个搜索。这条经验适用于所有"在代码里找常数"的场景。

  3. 无常数的算法靠结构识别。RC4 没有魔数,靠 256 轮循环 + 双计数器 + 交换操作的组合特征识别;RSA 靠"字符串转大数 + 模幂"的函数组合识别。结构指纹比常数指纹更难伪造,也更有说服力。

小结

  • 定位"Great" push 在 0x4013BF,回溯到 call 0x401610(Verify()),弹窗逻辑在调用点函数而非 Verify() 内部
  • MD50x408310 处的四个 IV 常数 0x67452301… 是唯一指纹,需按指令编码逐个搜索(命中点间隔 7 字节)
  • RC40x408FF0(KSA)+ 0x4090F0(PRGA),256 轮循环 + 双计数器结构识别;硬编码密钥 29 47 07 85 87 33 25 44
  • RSAzsread/zfread/zexpmod 三函数组合,公钥 E=65537, N=0xB80A90BF53C6C979

算法识别完成,但"识别出来"只是第一步——这些理解对不对,还需要让程序自己证明。Verify() 是否真的按这个逻辑执行?RC4 实现和标准实现是否一致?下一篇,我们搭建预言机,直接调用程序内部这些函数来验证每个判断。

下一篇预告

《预言机调试(上):从进程崩溃到线程劫持》——识别出算法后,怎么验证理解正确?直接调用目标进程里的 Verify(),把我们的猜测喂给它,读它的真实返回值。这条路先踩了 CreateRemoteThread 的坑(进程直接崩溃),再改进为线程劫持,还撞上了 32/64 位的 WOW64 兼容问题。

参考文献与引用

  • Ronald Rivest, RFC 1321 - The MD5 Message-Digest Algorithmtools.ietf.org/html/rfc1321——MD5 初始化向量的原始定义(0x67452301 等四个常数)
  • Ron Rivest, RSA Laboratories - “The RC4 Encryption Algorithm”(1992 年私人备忘录)——RC4 KSA/PRGA 算法结构定义
  • Rivest, Shamir, Adleman, “A Method for Obtaining Digital Signatures and Public-Key Cryptosystems”(1978)——RSA 公钥加密与签名验证的原始论文
  • Microsoft PE/COFF 规范learn.microsoft.com/windows/win32/debug/pe-format

觉得有用?点个关注,持续获取优质内容。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐