授权算法 字符解析 文件边界与线程行为
研究与验证日期 2026 年 10 月 12 日
这个 CrackMe 的标准卡密能够还原,但更值得研究的是程序实际接受哪些输入。深入验证后可以看到:一个字符就能满足卡密比较;某些标点可以替代数字;对于余数为 −1 的机器字节,多达 186 种非零字节都能作为匹配字符。与此同时,机器名比较、文件字节和、代码指纹和线程状态仍然是独立的通过条件。
本文围绕用户提供的练习样本,使用 REA 调用 Ghidra,检查原始 x86 指令,再通过 Unicorn 的受控实验和 Wine 中的原始进程运行交叉验证。文章将解释完整的授权流程、被忽略的输入边界,以及“算法正确”与“程序成功运行”之间的距离。
一 研究来源与验证范围
先说明研究来源:最初的分析已经阅读了吾爱破解的解答帖,核心函数定位和标准卡密思路受到该帖启发。因此,这不是一次未知答案条件下的独立破解。本文在这一基础上重新检查样本,新增字符解析穷举、授权文件边界、安装输入溢出、线程先后顺序,以及原始 EXE 在 Wine 中的运行实验。解答帖提供线索,本文自己的脚本和日志承担验证结论的依据。
压缩包以 52pojie 为解压密码,得到 CrackMe.exe。整个研究使用同一份未修改的 EXE。下面的摘要可以用于检查读者手里的文件是否属于本文样本。
| 项目 | 本次实际使用的对象 |
|---|---|
| 文件 | CrackMe.exe 19,470 字节 |
| 格式 | PE32 i386 控制台程序 |
| 装载与入口 | ImageBase 0x400000 EntryPoint 0x4014A0 |
| 静态分析 | REA npm 包 6.4.0 Ghidra 12.1.4 JDK 21 |
| 指令执行 | Unicorn 2.1.4 pefile 2024.8.26 |
| 进程运行 | Ubuntu 24.04 容器 Wine 9.0 Xvfb |
| Wine 测试身份 | GetUserNameA → lab GetComputerNameA → CRACKME-LAB |
SHA-256
195c955c7e21a7222f92bfdc3e5224606
c9a8309bb222a942c6eb3fd29820248
上一框为了排版把摘要拆为两行,校验时应拼接成一个 64 位十六进制字符串。PE 入口先经过运行库启动代码,业务主函数位于 0x4015C0;两者不能混为同一个地址。
| 证据来源 | 它直接证明什么 | 它没有覆盖什么 |
|---|---|---|
| REA 与 Ghidra | 样本中的代码、引用、反编译结果 | 实际线程调度与运行成功率 |
| Unicorn | 给定导入模型与初始状态下的原始指令行为 | 完整 Windows API、CRT 与调度器 |
| Wine 原进程 | 兼容层中真实文件读写、线程和成功或错误弹窗 | 原生 Windows 上的全部行为 |
Unicorn 装载原 PE 的代码,重定向导入地址到显式的 CRT 和 Win32 模型。卡密、签名、指纹和计时函数本体仍执行原始指令。大多数授权测试记录计时线程的创建,却不调度它;计时函数另用受控 tick 值单独执行。因此,字符穷举的通过结果只适用于所声明的初始化状态,不能冒充整机运行结果。
二 REA 在这次研究中承担什么工作
REA 为智能体提供 MCP 和 CLI 接口,把本地分析引擎的结果组织成可追踪的证据。对于这个 PE,真正执行反汇编和反编译的是 Ghidra。此次研究实际打开样本,并通过 analyze_function 获取了 14 个业务相关函数的分析记录。Unicorn 验证器和 Wine 运行器由本次研究另行编写。
这里值得检查的是结果的来路。REA 源码中的 executeFunctionAnalysisEvidence 先校验 analyze_function 参数,再调用 provider,解析函数 dossier,最后保留 provider、rawResult、limitations、locations 和 analysisProfile,形成 Evidence。它让一段伪代码能追溯到特定样本、分析引擎和分析配置。
本次返回记录包含样本 SHA-256,provider 为 Ghidra 12.1.4,authority 为 shipped-artifact,confidence 为 observed。这里的 observed 描述分析工具获得了该静态观察,不能解读为“程序运行已经成功”。environment 为 null,也不包含一份 Windows 运行环境证明。
open_binary({
path: "/absolute/path/CrackMe.exe",
provider_id: "ghidra"
})
analyze_function({ procedure: "0x401d0c" })
analyze_function({ procedure: "0x402190" })
close_binary({})
上面是本次 MCP 请求的关键参数。复现时需要配置 GHIDRA_INSTALL_DIR 和 JAVA_HOME,并启动 REA 的 stdio MCP 服务。证据包保留了调用脚本;文章中的描述性函数名由分析者添加,Ghidra 原始名称通常仍是 FUN_0040xxxx。
三 从程序启动追到最终成功条件
| 地址 | 本文使用的名称 | 职责 |
|---|---|---|
| 0x4015C0 | main | 设置目录、创建线程、处理参数 |
| 0x40174F | remove_data | 删除授权文件与 data 目录 |
| 0x401781 | decode_message | 将提示字符串逐字节减一 |
| 0x4017BC | init_check | 查询 data 并设置就绪标志 |
| 0x401841 | copy_signature | 复制指定长度的字节 |
| 0x401874 | install | 输入卡密并写授权文件 |
| 0x401B69 | fetch_machine | 取得用户名与计算机名 |
| 0x401BD4 | unlock | 串联授权校验并显示成功 |
| 0x401CB0 | check_signatures | 比较文件首尾标记 |
| 0x401D0C | verify_key | 机器绑定与卡密字符匹配 |
| 0x401F18 | check_authdata | 读取记录、指纹与最终字节检查 |
| 0x402190 | decode_key_char | 把卡密字符转为比较数值 |
| 0x4021C9 | fingerprint_or_sum | 代码指纹或文件字节和校验 |
| 0x40239F | timing | 记录和比较 GetTickCount |
main 将当前目录切换到 %USERPROFILE%,所以授权文件的位置是用户配置文件目录下的 data,而不一定是 EXE 所在目录。程序创建一个计时线程和一个解锁线程,然后计算主函数指纹。-i 进入安装流程,-u 删除文件,无参数调用 init_check 并等待解锁线程。目录展开、目录切换和线程创建的返回结果并未完整检查。

图 1 主线程与两个线程的功能关系 三列不表示实际执行先后
解锁线程先计算 init_check 和 install 的指纹,再获取机器信息。它只有在 data 就绪、未进入参数分支且调试标志仍为 2 时,才继续检查文件字节和与授权记录。最终还要求第二组代码指纹通过、首尾标记有效、机器绑定匹配,以及卡密失败标志为零。
if (dir_ready && !args_present && debugger_flag == 2) {
fingerprint_or_sum(NULL);
check_authdata();
if (fp_group2 == 0 && signature_ok == 1 &&
machine_ok == 1 && key_failure == 0) {
show_success();
exit(0);
}
}
这是便于阅读的逻辑重写,不是从原作者取得的源码。成功判据采用原程序的 Success 弹窗文本;exit(0) 本身不能作为解锁证据,因为错误输入、计时失败和若干提前退出路径也使用退出码 0。
四 授权文件的实际结构
安装函数写出 220 字节 authdata.dat,并把这 220 个无符号字节的和写入另一个文件 checksum。checksum 是一个四字节小端整数。记录的字段布局可以从两个签名写入点、strcpy 目标和 scanf 目标反推出。
| 十进制偏移 | 长度 | 字段 | 含义与实际检查 |
|---|---|---|---|
| 0 | 6 | 首标记 | cv2pr 加 NUL 使用 strcmp |
| 6 | 2 | 填充 | 没有独立语义检查 |
| 8 | 4 | 时间值 | time 返回值 没有有效期比较 |
| 12 | 50 | 用户名 | ANSI C 字符串 与当前用户名 strcmp |
| 62 | 50 | 计算机名 | ANSI C 字符串 与当前计算机名 strcmp |
| 112 | 100 | 卡密 | C 字符串 验证时会被改写 |
| 212 | 6 | 尾标记 | cv2pr 加 NUL 使用 strcmp |
| 218 | 2 | 填充 | 没有独立语义检查 |
checksum = sum(authdata[0:220])
checksum_file = little_endian_uint32(checksum)
这里没有时间签名、口令哈希或加密校验。时间值和填充字节只参与加法和。我将时间四字节改为另一组值,再重算 checksum,原进程在 Wine 中三次都成功。又在受控指令测试中把一个未检查字节加一、另一个减一,保留原 checksum,同样通过。这个文件和只能发现部分修改,不能证明记录的真实性。
字段长度是存储空间大小,不是已经执行的输入长度限制。程序用 strcpy 写两个 50 字节字段,不能自动阻止超长名字。本文生成器把每个名字限制为 1 至 49 个 ANSI 字节,并把记录预先清零;这是为了稳定构造符合正常布局的文件,不代表原程序拥有同样的边界检查。
五 代码指纹与反调试状态
0x4021C9 有两种任务。参数是函数地址时,它逐字节读取代码,遇到连续七个 0x90 才停止;单个 0x90 不计入和,其余字节相加。五个预设值分别对应 main、init_check、install、verify_key 和 check_authdata。匹配后清零相应槽位,再据前三个和后两个槽位更新两组全局状态。
| 被检查函数 | 预设加法和 | 所属条件 |
|---|---|---|
| main | 0x89D9 | 第一组 |
| init_check | 0x302C | 第一组 |
| install | 0x11B35 | 第一组 |
| verify_key | 0xD21B | 第二组 |
| check_authdata | 0xE5B5 | 第二组 |
这解释了为什么随意改写关键函数可能连带破坏授权流程。它也不是密码学指纹:排除 0x90 的加法和会产生碰撞,函数扫描的停止位置又依赖一段字节模式。本文没有改动这些函数,也没有把补丁绕过作为成功路径。
参数为 NULL 时,同一个函数改为读取 authdata.dat 的前 220 字节,计算字节和,再读 checksum 的前四字节。两者不同会先删除两个文件并尝试删除 data,之后授权读取才产生 Key not found。因而“Key not found”不一定说明文件最初不存在。
IsDebuggerPresent 为真会把一个初始值为 2 的全局标志改为 1,解锁线程要求该值仍为 2。另一个全局计数器累计非 NULL 的指纹调用,正常序列得到 5;卡密函数只有在计数恰为 5 时才反转输入卡密。计数和指纹槽位是两个不同条件,下一节的算法以它们处于正常状态为前提。
六 标准卡密算法与 ANSI 字节语义
机器绑定比较的是 GetUserNameA 与 GetComputerNameA 返回的字节字符串。卡密则逐字节把这些数据当作有符号 char,再计算除以 16 的余数。对 ASCII,结果与取低四位相同;对大于等于 0x80 的字节,这个简化常常是错的。
signed_byte = b if b < 128 else b - 256
# 在 Python 中实现 C 的向零截断余数
r = signed_byte % 16 if signed_byte >= 0 else -(
(-signed_byte) % 16
)
标准生成方式先处理用户名,再处理计算机名。余数非负时使用 0123456789ABCDEF;余数为负时使用 ASCII 值 0x30 加余数的字符。生成出的完整字符串保持用户名和计算机名原有的字节顺序。正常校验会先反转卡密,然后依次比较倒序计算机名、倒序用户名,两个反转正好对应起来。
def canonical_key(username, computername):
out = []
for b in username + computername:
v = b if b < 128 else b - 256
r = v % 16 if v >= 0 else -((-v) % 16)
out.append("0123456789ABCDEF"[r] if r >= 0
else chr(0x30 + r))
return "".join(out)
| 例子 | 机器信息 | 标准完整卡密 |
|---|---|---|
| 参考帖例子 | 13120 / ANDY_DESKTOP | 131201E49F453B4F0 |
| ASCII 实验 | alice / PC01 | 1C9350301 |
| Wine 实验 | lab / CRACKME-LAB | C123213BD5DC12 |
| cp936 字节实验 | 测试 / ANDY_DESKTOP | ""*$1E49F453B4F0 |
“测试”在 cp936 中是 B2 E2 CA D4。作为有符号字节,它们的余数是 −14、−14、−6、−12,对应两个双引号、星号和美元符号。直接对 Unicode 码点计算,或者把中文编码为 UTF-8,都会得到另一种输入。这个例子在原指令模型中通过,本文没有声称在 Wine 中构造了相同的中文系统账号。
早期验证还对除 NUL 和保留值 x 以外的 254 种单字节用户名做过测试。该结果支持有符号字节规则,但这些 API 返回值是合成输入,并不等于 Windows 允许创建 254 种这样的账号。用户名或计算机名恰为字符串 x 时,会触发程序自身的无效机器标记判断。
七 实际接受的卡密比标准生成规则更宽
1 为什么一个字符就能通过
verify_key 在匹配成功后将卡密对应字节改为 0xFF,失败则改为 0x00。check_authdata 保存修改前的卡密长度,随后仅检查这段长度内是否全部为 0xFF。程序没有要求卡密长度等于用户名与计算机名的总长度。
正常状态下,任意非空后缀都会被反转为比较序列的前缀。对于 alice / PC01,完整卡密 1C9350301 的九种非空后缀全部通过;其中最短的 1 只需要匹配计算机名最后一个字节 1。Wine 身份的完整卡密以 2 结束,单字符 2 也三次显示成功,错误单字符 3 则三次显示 Illegal copy。
这个长度缺口削弱了卡密比较,却没有删除机器绑定:文件里的完整用户名和计算机名仍要与当前系统信息相等,首尾标记与文件和也仍须有效。复制另一台机器的文件后,仅把卡密缩短,不能自动解决名字比较失败。
2 解码器不是严格的十六进制解析器
int decode_key_char(unsigned char b) {
if (b > 0x46) return -1;
int n = b - 0x30;
if (n > 9) n -= 7;
return n;
}
我直接执行了 0x402190 的原指令,穷举全部 256 个输入字节。结果与上面的重写一致。它没有先检查字符是否落在 0–9 或 A–F 两个区间,而是允许两个区间之间的标点进入第二段算式。
| 字符 | 返回值 | 与标准字符的关系 |
|---|---|---|
| : ; < | 3 4 5 | 分别可替代 3 4 5 |
| = > ? @ | 6 7 8 9 | 分别可替代 6 7 8 9 |
| / | −1 | 可匹配有符号余数 −1 |
| G 至 0xFF | −1 | 全部被转为 −1 并非独立拒绝 |
| A 至 F | 10 至 15 | 标准大写十六进制字母 |
例如 alice / PC01 的 1C9350301 可写成 1C9:50:01;两个 3 都用冒号替代。Wine 例子中的 C12:21:BD5DC12 也三次成功。按完整卡密的每个字符解析值列出所有等价输入,再对全部非空后缀做笛卡尔积,alice / PC01 一共得到 66 个不同的有效卡密,全部通过原指令验证。
后缀长度 1 2 3 4 5 6 7 8 9
等价卡密数量 1 1 2 2 4 8 16 16 16
总计 66
进一步穷举这组机器信息的 255 个非零单字节卡密,仅 ASCII 1 通过。随后把计算机名 API 的最后一个字节设为合成值 0xFF,其有符号余数是 −1;此时 / 以及 0x47 至 0xFF 的 185 种字节都能匹配,总计 186 种。这是在合成机器字节条件下的原指令结果,不能推导出普通 ASCII 机器上的任意小写字母都有效。
3 用一个条件描述整个接受集合
令 U、C 为机器名的原始 ANSI 字节,T 为 U 连接 C 后逐字节计算的有符号余数序列,N 为 T 的长度,L 为输入卡密的长度。若指纹计数为 5、机器绑定与其他门槛均有效,那么字符比较的条件是:
1 <= L <= N
对每个 j = 0 ... L-1
decode_key_char(key[j]) == T[N-L+j]
标准生成器只选择每个数值的一种常规表示,并取 L 等于 N。程序实际接受的是每个非空余数后缀上,各字符解码前像的组合。这比“算法是用户名和计算机名的低四位十六进制”更准确,也解释了短卡密和标点替代为何同时存在。
八 文件长度与读取失败不能靠格式图推断
程序两次读取授权记录都使用 fread(buffer, 220, 1, file),却不检查返回值;checksum 也只读取一个四字节元素。因此,220 字节是正常写出的长度,不是读取端强制要求的文件总长度。
| 实验输入 | 原指令受控结果 | Wine 原进程结果 |
|---|---|---|
| 有效 220 字节记录 | 成功 | 3 次成功 |
| 220 字节后追加数据 | 成功 | 3 次成功 |
| checksum 四字节后追加数据 | 成功 | 3 次成功 |
| 改变时间并重算字节和 | 成功 | 3 次成功 |
| 截断为 217、218 或 219 字节 | 零栈可成功 A5 栈失败 | 每组 3 次 Key not found |
| 错误 checksum | 删除文件后失败 | 3 次 Key not found |
截断实验特意使用同一记录、同一 checksum,改变读取前的栈填充值。模型只复制可读到的文件前缀,未写入部分保留原值:初始为零时,缺少的末尾零字节恰好得到补全;初始为 0xA5 时,额外字节参与文件和,引发删除。这证明该模型下的结果依赖未初始化状态。它不能证明截断文件在任意 CRT 上可稳定通过。
本次 Wine 的九次截断实验全部失败,与零栈模型的结果不同。这种差异是研究结果的一部分:模拟器提供了一个受控条件,兼容层进程使用另一份真实栈和真实 CRT。微软 fread 文档也说明,部分读取元素的值无法确定,读取返回的是完整元素数量;程序应检查返回值再使用数据。
checksum 文件不存在时还有一个独立问题:函数跳过 fread,却仍调用 fclose(NULL)。早期验证器把所有 fclose 简化为成功,掩盖了这条路径。本次扩展模型会明确记录 CRT 契约错误并停止,不把它归为正常的授权拒绝。现代微软 CRT 文档规定空指针触发无效参数处理;样本导入的是 msvcrt.dll,本文没有据此断言所有历史 CRT 都会显示同一种错误。
九 安装流程的三个边界问题
1 输入宽度 100 会覆盖尾签名
卡密字段只有 100 字节,但 scanf 使用 %100[^\n]。宽度限制最多读入 100 个字符,随后还会写入一个 NUL。读满时,NUL 正好写到偏移 112 + 100 = 212,覆盖尾标记的首字符 c。
正常尾标记 63 76 32 70 72 00 cv2pr\0
100 字符之后 00 76 32 70 72 00 \0v2pr\0
受控安装函数和 Wine 中实际执行 -i 都复现了这个覆盖。99 字符没有覆盖尾签名,但实验所用的 99 个 A 本来就不满足机器卡密算法,仍显示 Illegal copy。这里检查的是内存边界,不能把“99 不覆盖”误写为“99 字符卡密有效”。
2 授权记录包含未初始化栈字节
安装函数没有在正常路径预先清零整条记录,而是只写签名、时间、名字和输入字符串等字段。以 alice / PC01 和九字符完整卡密为例,先将栈填充 0xA5,再执行原安装指令,输出的 220 字节中仍有 183 个 0xA5;这些剩余字节同样进入 checksum。重新读取这个记录仍可通过。
183 是这组指定名字、指定输入和指定栈模型的观察值,不是所有机器的固定数量。原进程中的剩余数据会随栈布局和运行历史变化。这也解释了为什么自行构造时应固定未使用字节,而不能照着字段表假定原安装文件天然全零。
3 名字复制与空输入检查都缺少可靠边界
把合成用户名设为 50 个字节时,strcpy 写出的 NUL 落入计算机名字段起点,随后被计算机名覆盖。后续 strcmp 看到的用户名跨过字段边界,机器绑定失败。这个实验验证了复制函数的边界缺口,没有把合成名字当作一个实际创建的系统账号。
空行输入也值得注意。扫描集没有匹配到字符时,scanf 可返回 0,不写目标缓冲区;程序却只检查 key[0] 是否为零,没有检查 scanf 返回值。0xA5 栈实验中,空输入仍写出了文件。这是对某种初始内存状态的复现,不能预测每一次控制台空输入都会产生相同文件。
十 计时判断实际比较的是 256 毫秒区间
0x40239F 的参数为 1 时记录 GetTickCount,否则对当前值与记录值异或,然后用 mov al, 0 清掉最低八位。剩余位非零就 exit(0)。它等价于要求两个 tick 的高 24 位相同。
passed = (baseline & 0xFFFFFF00) == (current & 0xFFFFFF00)
| 基准 tick | 当前 tick | 差值 | 原指令结果 |
|---|---|---|---|
| 256 | 511 | 255 | 通过 |
| 511 | 512 | 1 | 退出 0 |
| 0 | 4096 | 4096 | 退出 0 |
| 0x123400 | 0x1234FF | 255 | 通过 |
| 0xFFFFFFFF | 0 | 回绕 | 退出 0 |
因此,“运行耗时小于 256 毫秒就通过”并不成立。跨过区间边界,即使 tick 只差 1,也会退出。基准记录线程若尚未运行,零初值也可能导致比较失败。这里给定的 tick 是为了精确检查分支的模型输入;GetTickCount 在真实 Windows 上的分辨率通常为 10 至 16 毫秒,不能把差 1 的案例描述为本次实际测得的墙钟时间。
Microsoft GetTickCount 分辨率与回绕说明
十一 线程先后顺序会改变授权流程
1 解锁线程错过初始化后不会重试
main 在调用 init_check 之前就创建了解锁线程。解锁线程只判断一次 dir_ready,没有等待循环。受控实验先执行 unlock:它计算两项指纹、取得名字,由于第一组指纹尚未齐全又将名字替换为 x,随后发现 dir_ready 为零直接返回,整个过程中没有读取授权文件。之后再执行 main 的指纹和 init_check,状态虽已就绪,解锁线程却已结束。
反过来,在主函数指纹完成且目录就绪后执行解锁线程,同一份有效记录会成功。这是函数级原指令的两种受控执行顺序,说明源码中存在漏过就绪状态的路径;本文没有模拟完整的 Windows 调度器,也没有给出这个路径在原生 Windows 上的发生概率。

图 2 相同授权数据在两种受控执行顺序下的路径差异
2 非原子的指纹计数会丢失更新
0x4022D3 mov eax, [0x407040]
0x4022D8 add eax, 1
0x4022DB mov [0x407040], eax
这三条指令不是原子自增。本次在同一个 Unicorn 内存中使用两份 CPU 上下文和两段栈,安排 A、B 都先读取计数 0,再让 A 完成加一和存回,最后让 B 完成加一和存回。两个指纹调用都执行完成,全局计数却只有 1。这里复现的是原始机器指令的丢失更新,不只是纸面推测。
计数的后果可单独检查:固定其他条件有效,计数 5 时标准完整卡密通过,计数 4 时它失败;反转完整卡密则恰好相反。单字符 1 在两个计数状态下都匹配,因为反转单字符没有变化。这些是卡密函数级实验;本文没有宣称已经在 Wine 中触发了计数竞争。
3 线程句柄等待也值得谨慎解读
程序保存 _beginthread 的返回值并传给 WaitForSingleObject,却不检查等待结果。微软文档提醒,_beginthread 的线程结束会自动关闭句柄,过早结束的线程可能使返回句柄失效。这个 API 契约支持对等待逻辑的质疑,但具体 msvcrt.dll 和 Wine 的实现仍须独立观察。本文没有将此项列为已经测得的原生 Windows 故障。
Microsoft _beginthread 与 _beginthreadex 句柄语义
十二 两处容易误判的辅助逻辑
初始化检查忽略搜索句柄
init_check 调用 FindFirstFileA(".\data", …),丢弃返回值,随后只判断 GetLastError 是否等于 2。受控导入模型给出成功句柄和遗留错误码 2 时,原指令报告 Not Initialized;给出 INVALID_HANDLE_VALUE 和错误码 5 时,它反而设置 dir_ready 为 1。
正确的判断入口应是 FindFirstFileA 返回的搜索句柄,失败后才读取相关错误码。GetLastError 是每线程保存的状态,成功 API 未必清零;它不是其他线程共同写入的全局错误值。此外,此路径没有关闭成功的搜索句柄。上述两种导入返回组合用于验证分支缺陷,本文未声称在 Wine 实测中碰到了它们。
Microsoft FindFirstFileA 返回值与 FindClose
Microsoft GetLastError 的线程局部状态
提示字符串解码不补 NUL
提示文本用每个字节减一的方式隐藏。0x401781 写入共享缓冲区,却没有在新文本末尾写 NUL。缓冲区初始为零时往往看不出问题;较长文本之后写较短文本,旧后缀就可能残留。
第一次输入密文 BCDEFG\0 → 缓冲区 ABCDEF
第二次输入密文 YZ\0 → 缓冲区 XYCDEF
期望的独立短文本应为 XY
这组结果由同一模拟实例连续执行原解码函数获得,说明它依赖旧缓冲区内容。多线程调用又会共享这个缓冲区,但本文没有测定真实运行中的文本竞争概率。描述弹窗时应优先记录实际观察文本,不宜只根据解码后的常量推断显示效果。
十三 原始 EXE 的运行结果
为检验模拟结果,我在独立的 Ubuntu 24.04 容器中安装 Wine 9.0,使用一个专用 32 位前缀和 Xvfb。容器运行阶段关闭网络,以普通用户执行,样本只读挂载。辅助 PE 通过与样本相同的 ANSI API 获取名字,复制测试记录到 C:\users\lab\data,启动原始 CrackMe.exe,读取属于该子进程的弹窗文本,然后关闭弹窗并记录退出状态。
这种方法没有替换原程序的导入函数,也没有跳过计时或改写代码。每个测试重新写入各自的授权记录,测试顺序串行,防止错误 checksum 删除文件后污染下一组。辅助程序只控制实验目录和自己启动的子进程。
| 输入组 | 重复次数 | 捕获到的原始弹窗 |
|---|---|---|
| 标准完整卡密 | 3 | Success |
| 单字符后缀 2 | 3 | Success |
| 错误单字符 3 | 3 | Illegal copy |
| 两个 3 改写为 : | 3 | Success |
| 授权记录追加尾部数据 | 3 | Success |
| 时间字段修改并重算和 | 3 | Success |
| 截断 217 字节 | 3 | Key not found |
| 截断 218 字节 | 3 | Key not found |
| 截断 219 字节 | 3 | Key not found |
| 错误 checksum | 3 | Key not found |
| checksum 追加尾部数据 | 3 | Success |
共 33 次无参数原进程校验,其中 18 次捕获 Success,15 次捕获上述预期错误文本。随后另执行三次 -i 安装,并分别重新启动验证:标准完整卡密成功,99 个 A 显示 Illegal copy,100 个 A 无成功弹窗而退出;读取安装产物确认,100 字符案例的尾标记变成 00 76 32 70 72 00。这些数量描述这批固定用例,不是对随机线程调度成功率的统计。

图 3 在 Wine 中捕获的原始 CrackMe 成功弹窗内容
Wine 是兼容层。这一实验把结论推进到了真实 PE 进程执行层,但仍不能代替原生 Windows 的环境验证。当前没有完成原生 Windows 测试;线程竞态、旧 CRT 的异常路径和不同 ANSI 代码页,仍应在对应环境中另行检查。
十四 复现方式与随文材料
配套实验包保留 14 份 REA Evidence 及伪代码、原始反汇编、标准生成器、原指令验证器和扩展实验、Wine 辅助程序源码、容器构建文件、测试记录与日志。样本文件由读者使用原附件准备,包内不重复放入 EXE,也不打包 Wine 镜像或运行前缀。
python -m venv verify-env
# 按当前系统激活 verify-env
pip install -r requirements.txt
python crackme_verify.py /path/to/CrackMe.exe
python deep_verify.py /path/to/CrackMe.exe
deep_verify.py 会校验样本摘要,并在脚本所在目录保存 deep-verification.json。其扩展模型明确记录计时线程未调度、部分读取保留旧内存、以及 fclose(NULL) 的处理方式。763 项字符相关输入检查由 256 项解码器输入、66 项等价后缀、255 项单字节卡密和 186 项负一余数匹配组成;它们之外还有文件、安装、计时、初始化和上下文交错实验。
python crackme_keygen.py alice PC01
python crackme_keygen.py 测试 ANDY_DESKTOP --encoding cp936
python crackme_keygen.py alice PC01 --out demo-data
生成器使用标准完整卡密,输出目录中的两个文件可用于所指定的机器字节。Windows 上省略两个名字参数时,它调用 GetUserNameA 和 GetComputerNameA 取得同类数据。–out 显式控制写文件,现有文件不会被覆盖。完整输入检查与文件布局实现均保留在配套脚本中。
REA 部分可以独立重跑。将证据包中的 rea-reproduce.mjs按 README 所述配置为本地 MCP 客户端,指定 REA 6.4.0、Ghidra 安装目录和 JDK,即可重新取得函数 dossier。Wine 部分使用 Dockerfile 构建测试镜像,再按 README 创建独立前缀、编译 probe.c、准备与实际 API 名字一致的测试文件。文章列出的 Wine 名字是本次实验身份,换环境后应重新生成文件。
读者可以从标准完整卡密开始,再按表中用例改变长度、字符和文件结构。每一步保留原始弹窗与状态记录,就能检查自己的环境是否重现了本文的接受条件。
