引言
原帖地址:https://www.52pojie.cn/thread-2121853-1-1.html。
CrackMe 说明
模拟实际环境下的卡密认证,适合初中级玩家。
技术要点
程序用法
1、命令行参数 -i 运行后需要输入卡密,此后会在本地生成一个 authdata.dat 文件。
2、命令行参数 -u 运行后,程序会清除 authdata.dat 文件
3、直接运行,程序会校验 authdata.dat 文件,文件合法、非法均会弹窗提示。
玩儿法
1、暴力破解
2、计算得到合法卡密
3、修改认证文件
4、生成注册机
不算难,我觉得是很适合入门 Windows 逆向的一道题,故写下这篇 writeup。本文的目的是帮助新手朋友完整地走一遍静态分析流程,学会绕过反动态调试,动静结合,最终达成原帖中提到的四种玩法。
黑盒观察
首先我们先运行几次程序,初步了解一下它的各项功能。
加 -i 参数会提示我们输入卡密,目前我们完全不清楚怎么填卡密,随便输一个 123456789 进去,程序正常退出:
题目中说程序会校验 authdata.dat 文件,我们可以用 Everything 全局搜一下这个文件在哪。发现这个文件位于 C:\Users\13120\data,也就是 %USERPROFILE%\data。该目录下还有个 checksum 文件。用任意 Hex 编辑器打开 authdata.dat (我用的是 010 Editor):
00000000 63 76 32 70 72 00 60 00 5f 7f 7c 6a 31 33 31 32 |cv2pr.`._.|j1312|
00000010 30 00 00 00 bc 03 0c 01 f7 19 1b 77 14 02 00 00 |0...¼...÷..w....|
00000020 00 00 0c 01 14 02 00 00 44 00 00 00 20 02 00 00 |........D... ...|
00000030 00 00 00 00 10 00 00 00 03 00 00 00 00 00 41 4e |..............AN|
00000040 44 59 5f 44 45 53 4b 54 4f 50 00 00 19 f8 15 77 |DY_DESKTOP...ø.w|
00000050 08 00 00 00 f0 1f d0 11 14 02 00 00 00 00 00 00 |....ð.Ð.........|
00000060 14 02 00 00 f0 fd 60 00 17 00 09 00 fe ff ff ff |....ðý`.....þÿÿÿ|
00000070 31 32 33 34 35 36 37 38 39 00 1b 77 8c c3 03 75 |123456789..w.Ã.u|
00000080 09 34 17 77 01 00 00 00 6d cc 76 cc 44 fb 60 00 |.4.w....mÌvÌDû`.|
00000090 0c 00 00 00 cc ff 60 00 80 ce da 74 25 8f ff b8 |....Ìÿ`..ÎÚt%.ÿ¸|
000000a0 fe ff ff ff 68 fe 60 00 37 c8 fe 75 ff ff ff ff |þÿÿÿhþ`.7Èþuÿÿÿÿ|
000000b0 00 00 00 00 00 00 00 00 60 8b fe 74 90 16 0c 01 |........`.þt....|
000000c0 04 00 00 00 00 00 00 00 94 fe 60 00 ec 9f 19 77 |.........þ`.ì..w|
000000d0 33 96 da 74 63 76 32 70 72 00 60 00 |3.Útcv2pr.`.|
这是一个 220 字节(0xDC)的文件。尽管我们现在对认证文件的生成算法一无所知,也可以推断出部分格式信息:
| 偏移 |
长度 |
内容 |
| 0 |
6 |
固定签名 cv2pr\0 |
| 12 |
- |
当前用户名 13120 |
| 62 |
- |
当前计算机名 ANDY_DESKTOP |
| 112 |
- |
卡密字符串 123456789 |
| 212 |
6 |
固定签名 cv2pr\0 |
checksum 文件的内容很少:04 3E 00 00。猜测这是认证文件的校验和。我们可以用 010 Editor 的 Check Sum 功能把 authdata.dat 的各种 Checksum 都算一下,发现 checksum 文件存的其实就是 authdata.dat 的字节和(Little Endian)。
此时不加任何参数直接运行 CrackMe.exe,校验 authdata.dat 文件,提示非法副本:
加 -u 参数运行,整个 %USERPROFILE%\data 目录会被删除。此时再直接运行一次 CrackMe.exe,提示未初始化
至此我们对程序的三条路径:-i 安装认证文件,直接运行校验认证文件,以及 -u 卸载文件都有了基础的认识。最后用 DIE 检测一下,可知该 CrackMe 由 MinGW GCC 8.1.0 编译,这对我们后续分析程序入口有帮助。
第一道坎:加密字符串
在 IDA 中打开 CrackMe.exe,开始静态分析。我们可以先从刚刚在弹窗里看到的几个字符串入手,但在 Strings 视图中搜索后,发现并不是每个字符串都能搜到。在 Strings 视图里随便看看,很容易找到一些奇怪的字符串:
.rdata:0040504C 0000005D C voefgjofe!bshvnfout///!.j!up!jotubmm-!boe!.v!up!vojotubmm-!up!vtf!opsnbmmz-!svo!xjuipvu!bsht
.rdata:004050AC 0000003D C Uif!tpguxbsf!jt!opu!jojujbmj{fe//!Qmfbtf!jojujbmj{f!vtjoh!.j
.rdata:004050E9 0000000F C Foufs!uif!Lfz;
.rdata:004050F8 00000061 C Jmmfhbm!dpqz///!Qmfbtf!pcubjo!uif!lfz!boe!svo!xjui!.v!boe!uifo!.j!up!sfjotubmm!xjui!dpssfdu!lfz/
.rdata:0040515C 00000029 C Tvddftt\"\"!Uif!qsphsbn!ibt!cffo!vompdlfe\"
.rdata:00405185 00000010 C Lfz!opu!gpvoe//
逐个进去看交叉引用 xref,找到指向字符串的数据引用指针 offset,再继续跟 xref 找到实际使用这些字符串的地方。不难发现这些字符串都有一个共性:先作为参数被传入 sub_401781,然后后面又跟着 MessageBoxA 或 printf。猜测 sub_401781 是解密字符串的函数,所以每次显示加密文本前都要调用该函数。该函数并不复杂:
int __cdecl sub_401781(unsigned __int8 *a1)
{
int result; // eax
CHAR *i; // [esp+Ch] [ebp-4h]
for ( i = Text; ; ++i )
{
result = *a1;
if ( (_BYTE)result == 0 )
break;
*i = *a1++ - 1;
}
return result;
}
该函数是一个凯撒密码解密函数。它把传入的字符串的每个字节减 1,将结果写到 .bss 段的 Text 偏移处。将其重命名为 decrypt_str 方便后续分析。
我们可以写一个 python 脚本把这些加密字符串全解密了:
def decrypt(data: str) -> str:
return ''.join(chr((ord(c) - 1)) for c in data)
if __name__ == "__main__":
encrypted_string_list = [
'voefgjofe!bshvnfout///!.j!up!jotubmm-!boe!.v!up!vojotubmm-!up!vtf!opsnbmmz-!svo!xjuipvu!bsht',
'Uif!tpguxbsf!jt!opu!jojujbmj{fe//!Qmfbtf!jojujbmj{f!vtjoh!.j',
'Foufs!uif!Lfz;',
'Jmmfhbm!dpqz///!Qmfbtf!pcubjo!uif!lfz!boe!svo!xjui!.v!boe!uifo!.j!up!sfjotubmm!xjui!dpssfdu!lfz/',
'Tvddftt\"\"!Uif!qsphsbn!ibt!cffo!vompdlfe\"',
'Lfz!opu!gpvoe//'
]
for encrypted_data in encrypted_string_list:
print(decrypt(encrypted_data))
运行结果:
undefined arguments... -i to install, and -u to uninstall, to use normally, run without args
The software is not initialized.. Please initialize using -i
Enter the Key:
Illegal copy... Please obtain the key and run with -u and then -i to reinstall with correct key.
Success!! The program has been unlocked!
Key not found..
可以在 IDA 里给这些加密字符串加上注释。知道这些加密字符串的含义有助于后续理解程序逻辑,比如我们可以通过 Success!! The program has been unlocked! 直接定位到 CrackMe 被成功解锁的逻辑的位置。
入口与 main
在 Exports 视图中找到 start,这是程序的入口:
int __usercall start@<eax>(int a1@<ebx>, int a2@<ebp>, int a3@<edi>, int a4@<esi>, char a5)
{
dword_4073B8 = 0;
sub_4024C0();
return sub_401150(a1, a2, a3, a4, a5);
}
sub_4024C0 是 CRT 的 __security_init_cookie(),内部初始化了 __security_cookie (dword_404070) 和 __security_cookie_complement (dword_404074)。sub_401150 则是 mainCRTStartup(),其中调用了 main (_main) 函数 —— 本 CrackMe 的核心所在。__security_init_cookie() 和 mainCRTStartup() 是编译器/链接器(MinGW GCC)的锅炉代码,与 CrackMe 作者的意图无关,可以略过。
看下 main 函数:
int __cdecl main(int argc, const char **argv, const char **envp)
{
HANDLE hHandle; // [esp+0h] [ebp-10h]
CHAR *lpDst; // [esp+4h] [ebp-Ch]
sub_402480();
lpDst = (CHAR *)malloc(Size: 0xFFu);
ExpandEnvironmentStringsA(lpSrc: "%USERPROFILE%", lpDst, nSize: 0xFFu);
SetCurrentDirectoryA(lpPathName: lpDst);
free(Block: lpDst);
beginthread(StartAddress: StartAddress, StackSize: 0, ArgList: (void *)1);
if ( IsDebuggerPresent() )
word_404024 = 1;
// 直接运行校验认证文件路径(独立线程,无执行顺序保证,但通常后执行),unlock_check
hHandle = (HANDLE)beginthread(StartAddress: sub_401BD4, StackSize: 0, ArgList: nullptr);
sub_4021C9(a1: main);
if ( argc > 1 )
{
word_407034 = 1; // 传入了至少 1 个参数,word_407034 被置 1
if ( strcmp(Str1: argv[1], Str2: "-i") == 0 )
{
sub_401874(); // -i 安装认证文件路径,install
}
else if ( strcmp(Str1: argv[1], Str2: "-u") == 0 )
{
sub_40174F(); // -u 卸载认证文件路径,remove_data_directory
}
else
{
decrypt_str(a1: (unsigned __int8 *)off_40400C[0]);
MessageBoxA(hWnd: nullptr, lpText: Text, lpCaption: "Error undefined arg", uType: 0x30u);
}
exit(Code: 0);
}
sub_4017BC(); // 直接运行校验认证文件路径(主线程调用,存在时序问题,但通常先执行),init_check
WaitForSingleObject(hHandle, dwMilliseconds: 0xFFFFFFFF);
return 0;
}
刚刚提到的三条路径(-i 安装认证文件,直接运行校验认证文件,-u 卸载文件)在这里都能找到。其中最简单(也最不重要)的是 -u 卸载认证文件路径:
BOOL sub_40174F()
{
remove(FileName: ".\\data\\authdata.dat");
remove(FileName: ".\\data\\checksum");
return RemoveDirectoryA(lpPathName: ".\\data");
}
它的作用是移除 %USERPROFILE%\data 目录和里面的 authdata.dat 和 checksum 文件。可以将该函数重命名为 remove_data_directory。
类似的,根据函数的功能,重命名安装和校验路径上的几个函数:sub_401BD4 → unlock_check,sub_401874 → install,sub_4017BC → init_check。
后续我们会对 -i 安装认证文件和校验认证文件的路径进行分析。
反调试与反篡改
分析复杂逻辑时常用动静结合的方式:先通过静态分析(IDA)了解大致逻辑,遇到难以看懂的部分时再上动态调试(x32dbg)进一步分析。但直接对本 CrackMe 进行动态调试的话,程序会莫名其妙地退出。这是因为本程序包含若干反调试逻辑,下面讲解如何绕过这些反调试,方便我们后续对核心逻辑做动态调试。
ps:本 CrackMe 的逻辑并不复杂,其实不动态调试也能破解。你可以试试用 IDA Pro 的 MCP 服务器 搭配任意模型(deepseek-v4-flash 就足够),看看 AI 是怎么凭借纯静态分析解决这道题的。
调试器检测
在 main 函数中很容易找到这样一段:
if ( IsDebuggerPresent() )
word_404024 = 1;
检测到调试器后,程序把 word_404024 置 1。查一下 word_404024 的定义:
.data:00404024 word_404024 dw 2 ; DATA XREF: _main+8C↑w
.data:00404024 ; unlock_check+43↑r
它的初始值是 2,再根据 xref 找到 unlock_check 中读取它的地方:
void __cdecl unlock_check()
{
// ...
if ( word_40703E != 0 && word_407034 == 0 && word_404024 == 2 ) // 这里
{
// ...
// 成功解锁,弹窗提示 Success!! The program has been unlocked!
}
}
可以看到 word_404024 的值为 2(初始值)是成功解锁程序的必要条件。若该条件不满足,程序后续会静默退出,不报错。
使用 x32dbg 动态调试时,在程序运行到 IsDebuggerPresent() 前的任意位置暂停(如入口断点处),执行 x32dbg 内置的 HideDebugger 命令(别名 dbh / hide),即可通过修改 PEB 的方式规避调试器检测。
你可能也想到了另一种绕过方案:将原本置 1 的指令 mov word_404024, 1 的立即数由 1 改为 2,又或者把这条指令改为 nop 并填充(共 9 字节)。但该 CrackMe 存在代码指纹校验(见后文),若采用这种方式,需要先绕过指纹校验。
时间差检测
回到 main 函数,在启动 unlock_check 的子线程之前,程序还启动了 StartAddress 线程,并传入了参数 1。
beginthread(StartAddress: StartAddress, StackSize: 0, ArgList: (void *)1);
StartAddress 的伪代码:
void __cdecl StartAddress(void *a1)
{
int v1; // eax
DWORD TickCount; // [esp+1Ch] [ebp-Ch]
if ( a1 == (void *)1 )
{
dword_407614 = GetTickCount(); // GetTickCount(): 获取系统启动后经过的毫秒数
}
else
{
TickCount = GetTickCount();
v1 = TickCount ^ dword_407614;
LOBYTE(v1) = 0;
if ( v1 != 0 )
exit(Code: 0);
dword_407614 = TickCount;
}
}
当参数为 1 时,该函数记录系统启动后经过的毫秒数作为基准值 dword_407614 并退出。
当参数为其他值时,取当前 TickCount 与基准值 dword_407614 异或,把结果的低 8 位清零;如果剩余高位非 0 则程序退出。也就是说,实际判断条件是 (TickCount ^ dword_407614) & 0xFFFFFF00 == 0,两次读数必须位于同一个 256 毫秒时间桶内。
通过这个函数的 xref 可以看到其余两处调用传入了参数 0,但三处调用都运行在线程中,xref 地址只表示静态位置,不保证实际执行顺序。正常时序下参数 1 的线程先写入 dword_407614,后续参数 0 的线程再做检测;但如果参数 0 的线程抢先执行,基准值仍可能是初始的 0。此时只要系统运行时间已经超过 256 毫秒,就会直接触发退出。另外,即使初始化线程先执行,只要两次检测跨过 256 毫秒时间桶边界,也会被误判为调试器暂停。正常运行的间隔通常只有几毫秒,所以一般不会触发。
绕过时间差检测的方法:找到 if ( v1 != 0 ) 对应的条件跳转 jz short loc_4023DD,把 jz 改为无条件跳转 jmp,从而规避 exit(0) 分支。
指纹自校验
过了调试器和时间差检测后,就已经能在 x32dbg 里正常单步调试了。但是当你修改了部分指令后可能发现程序又莫名其妙地退出了。来看下面这个函数 sub_4021C9,该函数共有 6 处 xref,其中 5 次传入的参数是一个函数指针,1 次是空指针。
int __cdecl sub_4021C9(_BYTE *a1)
{
int result; // eax
_BYTE Buffer[500]; // [esp+10h] [ebp-218h] BYREF
int v3; // [esp+204h] [ebp-24h] BYREF
FILE *Stream; // [esp+208h] [ebp-20h]
int v5; // [esp+20Ch] [ebp-1Ch]
int v6; // [esp+210h] [ebp-18h]
int v7; // [esp+214h] [ebp-14h]
unsigned int i; // [esp+218h] [ebp-10h]
int v9; // [esp+21Ch] [ebp-Ch]
if ( a1 != nullptr )
{
// 参数是函数指针,校验函数指纹
++dword_407040; // 全局函数指纹检测计数;非原子自增,可能丢失更新
v7 = 0;
v6 = 0; // 函数机器码字节和
v5 = 0; // 连续 nop 计数
while ( v5 <= 6 ) // 遇到 7 个连续 nop 时停止遍历
{
if ( *a1 == 0x90 ) // 0x90 为 NOP 指令的机器码
{
++v5;
}
else
{
v5 = 0; // 遇到非 nop 指令,清零连续 nop 计数
v6 += (unsigned __int8)*a1;
}
++a1; // 逐字节顺序遍历函数体
}
while ( dword_404028[v7] != -1 ) // dword_404028: 函数指纹数组
{
if ( v6 == dword_404028[v7] )
dword_404028[v7] = 0; // 指纹吻合,把数组对应项置 0
++v7;
}
if ( dword_404028[0] == 0 && dword_40402C == 0 && dword_404030 == 0 ) // dword_40402C、dword_404030: 指纹数组的第 2、3 项
dword_404040 = 0; // 如果前 3 个函数的指纹校验成功,被置 0
result = dword_404034;
if ( dword_404034 == 0 ) // dword_404034: 指纹数组的第 4 项
{
result = dword_404038;
if ( dword_404038 == 0 ) // dword_404038: 指纹数组的第 5 项
dword_404044 = 0; // 仅当指纹数组第 4、5 项都为 0 时置 0
}
}
else
{
// 参数是空指针,校验认证文件
Stream = fopen(FileName: ".\\data\\authdata.dat", Mode: "rb");
if ( Stream == nullptr )
{
// 打开认证文件失败,删除 data 目录
remove_data_directory();
exit(Code: -1);
}
fread(Buffer, ElementSize: 0xDCu, ElementCount: 1u, Stream);
v9 = 0; // 认证文件 authdata.dat 的字节和
v3 = 0; // checksum 文件内容,直接解析为 int 值
for ( i = 0; i <= 0xDB; ++i ) // 0xDB = 219
v9 += (unsigned __int8)Buffer[i];
fclose(Stream);
Stream = fopen(FileName: ".\\data\\checksum", Mode: "rb");
if ( Stream != nullptr )
fread(Buffer: &v3, ElementSize: 4u, ElementCount: 1u, Stream);
fclose(Stream);
result = v3;
if ( v9 != v3 )
// 如果 authdata.dat 的字节和不等于 checksum 中存的值,删 data 目录
return remove_data_directory();
}
return result;
}
该函数有校验程序自身函数代码指纹和认证文件的功能,故将其重命名为 verify_fingerprint。当传入的参数是函数指针时,计算该函数的字节和并和函数指纹数组 dword_404028 中的每一项比对,如果有命中则把对应项置 0。下表展示了指纹数组中每一项的值和该指纹对应的函数:
| 槽位 |
初始值 |
对应函数 |
| dword_404028 |
0x89D9 |
_main |
| dword_40402C |
0x302C |
init_check |
| dword_404030 |
0x11B35 |
install |
| dword_404034 |
0xD21B |
sub_401D0C(后续被重命名为 verify_key) |
| dword_404038 |
0xE5B5 |
sub_401F18(后续被重命名为 check_authdata) |
| dword_40403C |
-1 |
哨兵(遍历终止) |
dword_404040 对应指纹数组的前 3 项(dword_404028、dword_40402C、dword_404030),这 3 项都为 0 时它才会被置 0;dword_404044 只对应后 2 项(dword_404034、dword_404038),这 2 项都为 0 时它才会被置 0。两个状态变量的初始值均为 -1,且都存在多处 xref。
dword_407040 是全局函数指纹检测计数。程序共有 5 次非空 verify_fingerprint 调用,因此在正常情况下该计数最终会是 5。不过有个小小的竞态问题,++dword_407040 在汇编中只是普通的“读、加一、写回”,早期两个线程的自增若恰好重叠,可能丢失一次更新,导致计数最终不是 5。这个竞态窗口只有几条指令宽,实际运行很难触发。
认证文件的校验逻辑和函数类似:计算 authdata.dat 文件的字节和是否等于 checksum 文件里存的值(Little Endian)。
因为 CrackMe 最终通过 dword_404040 和 dword_404044 分别判断前 3 个和后 2 个关键函数的代码是否被修改过,只需要把这两个状态变量置 0 即可通过函数指纹校验。保险起见可以把指纹数组 dword_404028 中的 5 个值也置 0。过认证文件的校验只需要确保 checksum 文件值为 authdata.dat 的字节和即可。
栈保护(未启用)
前文提到程序在入口处调用了 __security_init_cookie,其中初始化了种子对 __security_cookie / __security_cookie_complement。除 __security_init_cookie 自身的写入外,种子对唯一的引用在 sub_402570(即 __report_gsfailure,该函数无 xref)。另外,程序中未使用 __security_check_cookie 且没有任何函数 prologue 形如 mov eax, __security_cookie; xor eax, ebp; mov [ebp-4], eax 的金丝雀放置。因此,本 CrackMe 未启用栈保护(未开 MinGW GCC 的 -fstack-protector 选项,其效果类似于 MSVC 的 /GS-),cookie 被初始化了但没有任何栈帧使用它做检测。
-i 安装认证文件路径
根据之前对 main 函数的分析,安装认证文件的逻辑集中在 install 函数中:
int install()
{
int v1; // [esp+18h] [ebp-100h] BYREF
_BYTE Buffer[8]; // [esp+1Ch] [ebp-FCh] BYREF // 疑点:Buffer 作为认证文件数据长度是 220,为什么这里显示只有 8?
__time32_t v3; // [esp+24h] [ebp-F4h]
char v4[50]; // [esp+28h] [ebp-F0h] BYREF
char v5[50]; // [esp+5Ah] [ebp-BEh] BYREF
_BYTE v6[100]; // [esp+8Ch] [ebp-8Ch] BYREF
int v7; // [esp+F0h] [ebp-28h] BYREF
FILE *Stream; // [esp+F8h] [ebp-20h]
__time32_t v9; // [esp+FCh] [ebp-1Ch]
unsigned int j; // [esp+100h] [ebp-18h]
unsigned __int8 *v11; // [esp+104h] [ebp-14h]
unsigned int i; // [esp+108h] [ebp-10h]
_BYTE *v13; // [esp+10Ch] [ebp-Ch]
CreateDirectoryA(lpPathName: ".\\data", lpSecurityAttributes: nullptr); // 创建 data 目录
v9 = time(Time: nullptr); // v9: 系统时间戳
Stream = fopen(FileName: ".\\data\\authdata.dat", Mode: "wb"); // 以二进制写模式打开 authdata.dat 文件
if ( Stream == nullptr )
{
MessageBoxA(hWnd: nullptr, lpText: "File Access Error", lpCaption: "Error: Internal", uType: 0x10u);
exit(Code: -2);
}
beginthread(StartAddress: StartAddress, StackSize: 0, ArgList: nullptr); // 时间差检测
while ( word_407036 == 0 )
;
sub_401841(a1: off_404008, a2: Buffer, a3: 6);
sub_401841(a1: off_404008, a2: &v7, a3: 6); // 疑点:v7 在此处被写入后从未被读取
v3 = v9; // 疑点:v3 之后从未被使用
strcpy(Destination: v4, Source: ::Buffer); // 疑点:v4 被写入了用户名(后文会解释),但之后未被使用
strcpy(Destination: v5, Source: word_4076A0); // 疑点:v5 被写入了计算机名(后文会解释),但之后未被使用
decrypt_str(a1: (unsigned __int8 *)off_404014[0]);
printf(Format: "%s", Text);
scanf(Format: "%100[^\n]", v6);
if ( v6[0] == 0 )
{ // 疑点:v6 看上去是卡密 key,但怎么就做了次非空判断,后续未被使用?
MessageBoxA(hWnd: nullptr, lpText: "Key can't be empty", lpCaption: "Error Input", uType: 0x30u);
fclose(Stream);
remove_data_directory();
exit(Code: 0);
}
if ( dword_404040 != 0 )
{ // 若先前未通过指纹自校验,用 220 个空字节填充 Buffer
v13 = Buffer;
for ( i = 0; i <= 0xDB; ++i )
*v13++ = 0;
}
v1 = 0;
v11 = Buffer;
for ( j = 0; j <= 0xDB; ++j )
v1 += *v11++; // 计算 Buffer 字节和作为校验和
fwrite(Buffer, ElementSize: 0xDCu, ElementCount: 1u, Stream); // Buffer 是被写入 authdata.dat 文件的数据,长度 0xDC (220)
fclose(Stream);
Stream = fopen(FileName: ".\\data\\checksum", Mode: "wb");
if ( Stream == nullptr )
{
remove_data_directory();
MessageBoxA(hWnd: nullptr, lpText: "File Access Error", lpCaption: "Error: Internal", uType: 0x10u);
exit(Code: -2);
}
fwrite(Buffer: &v1, ElementSize: 4u, ElementCount: 1u, Stream); // 把校验和 v1(长度为 4)写入 checksum 文件
return fclose(Stream);
}
该函数计算数据并写 authdata.dat 和 checksum 文件,但存在不少疑点,如果只读伪代码就会让人感到疑惑。比起去读汇编,存在一种更方便且有利于后续分析的办法:给 Buffer 定义类型。不难看出 Buffer 是被写入 authdata.dat 的数据,但由于 Hex-Rays 反编译器无法从汇编中推断其类型,导致出现了一些奇怪的变量(v3 ~ v7)。
先回到 IDA 的汇编视图,按 Ctrl+K 打开栈变量窗口:
-0000000000000118 // Use data definition commands to manipulate stack variables and arguments.
-0000000000000118 // Frame size: 118; Saved regs: 4; Purge: 0
-0000000000000118
-0000000000000118 // padding byte
// --- 省略很多 padding bytes ... ---
-0000000000000101 // padding byte
-0000000000000100 _DWORD var_100;
-00000000000000FC _BYTE Buffer;
-00000000000000FB // padding byte
-00000000000000FA // padding byte
-00000000000000F9 // padding byte
-00000000000000F8 // padding byte
-00000000000000F7 // padding byte
-00000000000000F6 // padding byte
-00000000000000F5 // padding byte
-00000000000000F4 _DWORD var_F4;
-00000000000000F0 char var_F0[50];
-00000000000000BE char var_BE[50];
-000000000000008C _BYTE var_8C[100];
-0000000000000028 // padding byte
-0000000000000027 // padding byte
-0000000000000026 // padding byte
-0000000000000025 // padding byte
-0000000000000024 // padding byte
-0000000000000023 // padding byte
-0000000000000022 // padding byte
-0000000000000021 // padding byte
-0000000000000020 FILE *Stream;
-000000000000001C _DWORD var_1C;
-0000000000000018 _DWORD var_18;
-0000000000000014 _DWORD var_14;
-0000000000000010 _DWORD var_10;
-000000000000000C _DWORD var_C;
-0000000000000008 // padding byte
-0000000000000007 // padding byte
-0000000000000006 // padding byte
-0000000000000005 // padding byte
-0000000000000004 // padding byte
-0000000000000003 // padding byte
-0000000000000002 // padding byte
-0000000000000001 // padding byte
+0000000000000000 _DWORD __saved_registers;
+0000000000000004 _UNKNOWN *__return_address;
+0000000000000008
+0000000000000008 // end of stack variables
Buffer 的长度实际上是 220,在栈上它应该刚好排在 FILE *Stream 的后面,但从栈变量窗口中可以看出这 220 字节里又夹杂着一些其它变量。这是因为程序通过偏移量访问了 Buffer 中的部分数据,但却无法把这些值和 Buffer 关联起来。在黑盒观察阶段我们已经对 Buffer 的结构有了初步了解,因此我们可以给 Buffer 定义一个结构体:
struct authdata_file_t { // 总长 220 (0xDC)
char signature[6]; // 0x00 签名 "cv2pr\0"
char pad0[6]; // 0x06 填充,未知内容
char usernameAndUnknown[50]; // 0x0C 用户名 + 未知内容
char computernameAndUnknown[50]; // 0x3E 计算机名 + 未知内容
char keyAndUnknown[100]; // 0x70 卡密 + 未知内容
char tail_signature[6]; // 0xD4 尾部签名 "cv2pr\0"
char pad1[2]; // 0xDA 填充,未知内容
};
打开 Local Types 视图,使用这个 C 结构体新增类型 authdata_file_t。然后回到伪代码视图,将 _BYTE Buffer[8] 变量的类型设置为 authdata_file_t。IDA 会自动重新反编译,新的伪代码如下:
int install()
{
int v1; // [esp+18h] [ebp-100h] BYREF
authdata_file_t Buffer; // [esp+1Ch] [ebp-FCh] BYREF
FILE *Stream; // [esp+F8h] [ebp-20h]
__time32_t v4; // [esp+FCh] [ebp-1Ch]
unsigned int j; // [esp+100h] [ebp-18h]
authdata_file_t *v6; // [esp+104h] [ebp-14h]
unsigned int i; // [esp+108h] [ebp-10h]
authdata_file_t *p_Buffer; // [esp+10Ch] [ebp-Ch]
CreateDirectoryA(lpPathName: ".\\data", lpSecurityAttributes: nullptr);
v4 = time(Time: nullptr);
Stream = fopen(FileName: ".\\data\\authdata.dat", Mode: "wb");
if ( Stream == nullptr )
{
MessageBoxA(hWnd: nullptr, lpText: "File Access Error", lpCaption: "Error: Internal", uType: 0x10u);
exit(Code: -2);
}
beginthread(StartAddress: StartAddress, StackSize: 0, ArgList: nullptr);
while ( word_407036 == 0 )
;
sub_401841(a1: (int)off_404008, a2: (int)&Buffer, a3: 6); // &Buffer = Buffer.signature
sub_401841(a1: (int)off_404008, a2: (int)Buffer.tail_signature, a3: 6);
*(_DWORD *)&Buffer.pad0[2] = v4; // 时间戳被放置在 pad0[2] 位置
strcpy(Destination: Buffer.usernameAndUnknown, Source: Source); // strcpy 的目标都看懂了
strcpy(Destination: Buffer.computernameAndUnknown, Source: word_4076A0); // 同上
decrypt_str(a1: (unsigned __int8 *)off_404014[0]);
printf(Format: "%s", Text);
scanf(Format: "%100[^\n]", Buffer.keyAndUnknown); // 使用 scanf 读取卡密,写到 Buffer 的卡密区域
if ( Buffer.keyAndUnknown[0] == 0 )
{
MessageBoxA(hWnd: nullptr, lpText: "Key can't be empty", lpCaption: "Error Input", uType: 0x30u);
fclose(Stream);
remove_data_directory();
exit(Code: 0);
}
if ( dword_404040 != 0 )
{
p_Buffer = &Buffer;
for ( i = 0; i <= 0xDB; ++i )
{ // 副作用:空字节填充 Buffer 的部分反而更难看懂了
p_Buffer->signature[0] = 0;
p_Buffer = (authdata_file_t *)((char *)p_Buffer + 1);
}
}
v1 = 0;
v6 = &Buffer;
for ( j = 0; j <= 0xDB; ++j )
{ // 副作用:计算 Buffer 字节和的部分也更难读懂了
v1 += (unsigned __int8)v6->signature[0];
v6 = (authdata_file_t *)((char *)v6 + 1);
}
fwrite(&Buffer, ElementSize: 0xDCu, ElementCount: 1u, Stream);
fclose(Stream);
Stream = fopen(FileName: ".\\data\\checksum", Mode: "wb");
if ( Stream == nullptr )
{
remove_data_directory();
MessageBoxA(hWnd: nullptr, lpText: "File Access Error", lpCaption: "Error: Internal", uType: 0x10u);
exit(Code: -2);
}
fwrite(Buffer: &v1, ElementSize: 4u, ElementCount: 1u, Stream);
return fclose(Stream);
}
部分代码的可读性一下子就上来了(虽然也有起相反效果的),我们在这个基础上继续分析,在读懂认证文件数据 Buffer 究竟是怎么计算的同时完善 authdata_file_t 类型。从启动时间差检测线程之后的地方开始:
while ( word_407036 == 0 )
;
这段代码会一直阻塞后续逻辑直到 word_407036 变为非 0 值。查一下 xref,唯一的写入在 sub_401B69。
int sub_401B69()
{
int result; // eax
DWORD pcbBuffer[3]; // [esp+1Ch] [ebp-Ch] BYREF
pcbBuffer[0] = 100;
GetUserNameA(lpBuffer: Source, pcbBuffer); // 获取用户名,写到 Source
pcbBuffer[0] = 100;
GetComputerNameA(lpBuffer: word_4076A0, nSize: pcbBuffer); // 获取计算机名,写到 word_4076A0
result = dword_404040;
if ( dword_404040 != 0 )
{ // 没有通过函数指纹校验,把用户名和计算机名写成 "x"
strcpy(Source, "x");
strcpy(word_4076A0, "x");
}
word_407036 = 1; // 机器信息已获取
return result;
}
这是一个获取用户名和计算机名的函数(重命名为 fetch_machine_info),两处 xref 分别在 unlock_check 和 sub_401F18(该函数在 unlock_check 内被调用) 。
因此这个 while 循环阻塞无需在意,只是在等 unlock_check 中的第一个 fetch_machine_info 执行完毕。
继续往下看:
sub_401841(a1: (int)off_404008, a2: (int)&Buffer, a3: 6); // &Buffer = Buffer.signature
sub_401841(a1: (int)off_404008, a2: (int)Buffer.tail_signature, a3: 6);
off_404008 最终指向 cv2pr 这个字符串,这是认证文件的签名。看 sub_401841 的实现:
int __cdecl sub_401841(int a1, int a2, int a3) // a1: 签名,a2: 签名写入位置,a3: 签名写入长度
{
int result; // eax
int i; // [esp+Ch] [ebp-4h]
for ( i = 0; ; ++i )
{
result = i;
if ( i >= a3 )
break;
*(_BYTE *)(i + a2) = *(_BYTE *)(i + a1);
}
return result;
}
该函数把 a1 指向的字符串写入到 a2 地址处,写指定长度 a3(长度包含字符串末尾的 \0)。install 中两次调用它是为了给 Buffer 写入两处固定签名信息,故将该函数重命名为 write_signature。
接着往下看:
*(_DWORD *)&Buffer.pad0[2] = v4; // 回顾:__time32_t v4; v4 = time(Time: nullptr);
这里我们找到了时间戳的位置,&Buffer.pad0[2]。之后优化 authdata_file_t 结构体时我们可以将 pad0 填充的长度改为 2,在它的后面加上时间戳字段。
最后再看一段:
strcpy(Destination: Buffer.usernameAndUnknown, Source: Source); // 把 fetch_machine_info 拿到的用户名写入 Buffer
strcpy(Destination: Buffer.computernameAndUnknown, Source: word_4076A0); // 把 fetch_machine_info 拿到的计算机名写入 Buffer
decrypt_str(a1: (unsigned __int8 *)off_404014[0]);
printf(Format: "%s", Text); // Enter the Key:
scanf(Format: "%100[^\n]", Buffer.keyAndUnknown); // 读取用户输入的卡密(去掉末尾的换行符)并写入 Buffer
我们之前定义 authdata_file_t 的时候因为不确定用户名,计算机名和卡密后面还有没有别的东西,起字段名时都加上了 AndUnknown。但通过这段及后续代码不难看出 Buffer 的这三部分数据真的就只有用户名,计算机名和卡密。至此 Buffer 的结构已经清晰了:
| 偏移 |
长度 |
内容 |
| 0 |
6 |
签名 cv2pr\0(off_404008 → .rdata:00405044) |
| 8 |
4 |
time() 时间戳 |
| 12 |
50 |
用户名(GetUserNameA) |
| 62 |
50 |
计算机名(GetComputerNameA) |
| 112 |
100 |
卡密字符串 |
| 212 |
6 |
尾部签名 cv2pr\0 |
重新定义 authdata_file_t 结构体,在 Local Types 视图中编辑类型:
struct authdata_file_t { // 总长 220 (0xDC)
char signature[6]; // 0x00 "cv2pr\0"
char pad0[2]; // 0x06 填充
int timestamp; // 0x08 time() 时间戳(安装认证文件的时间)
char username[50]; // 0x0C 用户名
char computername[50]; // 0x3E 计算机名
char key[100]; // 0x70 卡密区
char tail_signature[6]; // 0xD4 "cv2pr\0"
char pad1[2]; // 0xDA 填充
};
此时再重新反编译,install 函数的代码就非常好懂了,这里贴一小段:
write_signature(a1: off_404008, a2: &Buffer, a3: 6);
write_signature(a1: off_404008, a2: Buffer.tail_signature, a3: 6);
Buffer.timestamp = v4;
strcpy(Destination: Buffer.username, Source: Source);
strcpy(Destination: Buffer.computername, Source: word_4076A0);
decrypt_str(a1: (unsigned __int8 *)off_404014[0]);
printf(Format: "%s", Text);
scanf(Format: "%100[^\n]", Buffer.key);
后续我们分析认证文件的校验逻辑时 authdata_file_t 类型将再次发挥作用。
总结一下,CrackMe 安装认证文件的逻辑是读取用户输入的卡密,构造 authdata_file_t Buffer 并写入认证文件 authdata.dat,同时计算其字节和写入校验和文件 checksum。
关于 authdata.dat 中的“随机”数据
回顾一下“黑盒观察”阶段看到的 hexdump,用户名 13120 后面跟着一堆杂乱的字节,卡密 123456789 后面也是。这些看似随机的数据是从哪来的?
00000000 63 76 32 70 72 00 60 00 5f 7f 7c 6a 31 33 31 32 |cv2pr.`._.|j1312|
00000010 30 00 00 00 bc 03 0c 01 f7 19 1b 77 14 02 00 00 |0...¼...÷..w....|
要回答这个问题,需要关注 authdata_file_t 结构体中的两处填充和三个字符串字段的实际写入方式:
- 两处填充字段从未被写入:
pad0(偏移 6,2 字节)和 pad1(偏移 218,2 字节)在 install 函数中没有任何代码向它们写入数据。
- 字符串字段只写了一部分:用户名、计算机名和卡密三个字段分别有 50、50、100 字节的空间,但
strcpy 只拷贝到源字符串的 \0 就停止,scanf 也只写入用户实际输入的字符加 \0。以用户名 13120 为例,它只有 6 个字节(含 \0),剩余 44 字节程序没碰过。
这些未被写入的字节里装着什么呢?答案是栈上的垃圾数据(uninitialized stack memory)。
在 C 语言中,函数的局部变量分配在栈上。与 calloc 不同,栈内存不会自动清零——它保留着之前函数调用遗留下来的旧数据。Buffer 是一个 220 字节的栈变量,install 只往其中部分位置写入了有意义的值,其余字节是栈上原有的随机内容。每次运行程序时栈的布局可能不同,所以这些“垃圾”每次都不一样。这也是为什么即便输入的卡密相同,每次用 -i 生成的 authdata.dat 文件内容(以及对应的 checksum)也不固定。
校验认证文件路径
回到 main 函数,校验逻辑主要在子线程 unlock_check 中,但主线程里也有一段简单的检测逻辑 init_check:
DWORD init_check()
{
DWORD result; // eax
struct _WIN32_FIND_DATAA FindFileData; // [esp+10h] [ebp-148h] BYREF
FindFirstFileA(lpFileName: ".\\data", lpFindFileData: &FindFileData); // 返回值被忽略
result = GetLastError();
if ( result == 2 )
{
decrypt_str(a1: (unsigned __int8 *)off_404010[0]);
MessageBoxA(hWnd: nullptr, lpText: Text, lpCaption: "Error Not Initialized", uType: 0x30u);
exit(Code: 0);
}
word_40703E = 1; // 程序已经初始化过(data 目录存在)
return result;
}
很普通的初始化检测逻辑,要求存放认证文件的 data 目录必须存在,如果存在把 word_40703E 置 1。
这里的实现并不是可靠的目录存在性检查:代码忽略了 FindFirstFileA 的返回值,只比较随后读取的 GetLastError() 是否为 2。成功时错误码可能保留旧值,其他失败码也会被当作“已初始化”。只有在错误码恰好为 2 时才弹出未初始化提示,否则就把 word_40703E 置 1。此外成功时返回的搜索句柄也没有关闭。
看看 unlock_check:
void __cdecl unlock_check()
{
verify_fingerprint(a1: init_check);
verify_fingerprint(a1: install);
fetch_machine_info();
if ( word_40703E != 0 && word_407034 == 0 && word_404024 == 2 )
{
verify_fingerprint(a1: nullptr);
sub_401F18(); // 核心校验逻辑
if ( dword_404044 == 0 && word_407038 == 1 && word_40703A == 1 && word_40703C == 0 )
{
decrypt_str(a1: (unsigned __int8 *)off_40401C[0]); // Success!! The program has been unlocked!
MessageBoxA(hWnd: nullptr, lpText: Text, lpCaption: &byte_40524E, uType: 0x40u);
exit(Code: 0);
}
}
}
这里存在一个线程时序问题:main 在 0x401673 已经创建 unlock_check,而主线程要到 0x40182E 才在 init_check 中写入 word_40703E = 1(表示 data 目录存在);unlock_check 在 0x401BF7 读取该变量,期间没有事件、锁或等待。因此正常时序下是主线程先完成目录检查,但子线程也可能先读到 0,随后静默返回。不过子线程在读取前要完成 init_check、install 函数的指纹校验以及机器信息的获取,且新线程本身有启动开销;所以主线程通常会先完成目录检查。这些因素解释了为什么实际运行很少触发静默退出。
结构还是很清晰的,只要能通过重重条件判断就能顺利解锁 CrackMe。这涉及很多全局变量,但有相当一部分我们已经见过了:
| 全局变量和解锁条件 |
解锁条件说明 |
word_40703E != 0 |
程序已经初始化过 |
word_407034 == 0 |
没有传命令行参数 |
word_404024 == 2 |
没有检测到调试器 |
dword_404044 == 0 |
指纹数组后 2 项通过 |
word_407038 == 1 |
暂不清楚 |
word_40703A == 1 |
暂不清楚 |
word_40703C == 0 |
暂不清楚 |
有三个全局变量我们还没见过,但它们在 sub_401F18 中都有 xref。大致浏览一下可知这个函数负责校验认证文件 authdata.dat(重命名为 check_authdata),里面有个局部变量 char Buffer[12] 存放认证文件内容。修改其类型为我们之前定义的 authdata_file_t 后重新反编译:
int check_authdata()
{
_BOOL2 v0; // ax
int result; // eax
DWORD pcbBuffer; // [esp+10h] [ebp-F8h] BYREF
authdata_file_t Buffer; // [esp+14h] [ebp-F4h] BYREF
signed int v4; // [esp+F0h] [ebp-18h]
FILE *Stream; // [esp+F4h] [ebp-14h]
signed int i; // [esp+F8h] [ebp-10h]
int v7; // [esp+FCh] [ebp-Ch]
Stream = fopen(FileName: ".\\data\\authdata.dat", Mode: "rb");
if ( Stream == nullptr )
{ // Key not found..
decrypt_str(a1: (unsigned __int8 *)off_404020);
MessageBoxA(hWnd: nullptr, lpText: Text, lpCaption: &byte_40524E, uType: 0);
exit(Code: -1);
}
verify_fingerprint(a1: sub_401D0C);
fread(&Buffer, ElementSize: 0xDCu, ElementCount: 1u, Stream); // 读整个认证文件
sub_401CB0(Str1: Buffer.signature); // 签名校验,稍后分析
Str = Buffer.key;
verify_fingerprint(a1: check_authdata);
beginthread(StartAddress: StartAddress, StackSize: 0, ArgList: nullptr);
v4 = strlen(Str: Str);
if ( v4 == 0 )
word_407038 = 0; // 卡密为空字符串,将 word_407038 置 0
pcbBuffer = 100;
GetUserNameA(lpBuffer: ::Buffer, &pcbBuffer); // 获取当前用户名,写到全局 ::Buffer(即前文 install/fetch_machine_info 里的 Source,只是 IDA 在不同函数里显示成了不同名字)
pcbBuffer = 100;
GetComputerNameA(lpBuffer: word_4076A0, nSize: &pcbBuffer); // 获取计算机名,后续要用
v0 = strcmp(Str1: Buffer.username, Str2: "x") != 0 && strcmp(Str1: Buffer.computername, Str2: "x") != 0;
word_40703A = v0; // 初始只表示认证文件中的用户名/计算机名均不是 "x"
if ( dword_404044 != 0 )
{
// 反篡改陷阱:verify_key/check_authdata 的指纹未全部通过 → 把机器信息覆盖为 "x"
dword_404040 = 2;
fetch_machine_info();
}
result = (unsigned __int16)word_407038;
if ( word_407038 != 0 )
{ // 签名校验通过
sub_401D0C(a1: (int)&Buffer); // 核心校验函数,后续深入分析
v7 = 0;
for ( i = 0; i < v4; ++i )
{
if ( Str[i] != -1 ) // -1 即 0xFF,出现非 0xFF 即校验失败
{
decrypt_str(a1: (unsigned __int8 *)off_404018[0]); // "Illegal copy..." 弹窗
MessageBoxA(hWnd: nullptr, lpText: Text, lpCaption: &byte_40524E, uType: 0x10u);
v7 = 1;
break;
}
}
if ( v7 == 1 )
{
word_407038 = 0; // 卡密校验失败:签名与机器信息标志全部作废
word_40703A = 0;
}
else
{
word_40703C = 0; // 卡密区全 0xFF:设置程序解锁成功标志,把 word_40703C 置 0
}
return fclose(Stream);
}
else
{
word_40703C = 1; // 签名无效:设置程序解锁失败标志,把 word_40703C 置 1
}
return result;
}
大致逻辑已经清晰:读认证文件 authdata.dat,做各种校验,根据校验结果设置全局变量标志。后续 CrackMe 将根据这些标志判断是否可以解锁程序。有两个函数需要进一步分析,先看 sub_401CB0:
int __cdecl sub_401CB0(char *Str1)
{
int result; // eax
result = dword_404040;
if ( dword_404040 != -1 )
{ // 指纹校验通过,校验首尾两处固定签名
result = strcmp(Str1, Str2: off_404008) == 0 && strcmp(Str1: Str1 + 212, Str2: off_404008) == 0;
word_407038 = result; // 签名校验成功,将 word_407038 置 1
}
return result;
}
很简单的签名校验函数(重命名为 check_authdata_signature)。调用时传入参数 &Buffer(认证文件内容字符串指针),判断偏移 0 和 212 处的签名是否为 cv2pr,如果是则将 word_407038 置 1。返回值未被使用。
再来看 sub_401D0C。该函数负责对卡密做校验,可重命名为 verify_key。并且我们知道它的参数是 &Buffer,所以可以将它的参数类型修改为 authdata_file_t * (如果没输入 *,在接下来的弹窗中选择 Convert to pointer 即可)。再次反编译,结果如下:
int __cdecl verify_key(authdata_file_t *authdata) // 为了可读性,已将参数重命名为 authdata
{
int result; // eax
_BOOL2 v2; // ax
int v3; // [esp+10h] [ebp-28h]
signed int v4; // [esp+14h] [ebp-24h]
char *String; // [esp+18h] [ebp-20h]
signed int v6; // [esp+1Ch] [ebp-1Ch]
signed int v7; // [esp+20h] [ebp-18h]
int k; // [esp+24h] [ebp-14h]
signed int j; // [esp+28h] [ebp-10h]
signed int i; // [esp+2Ch] [ebp-Ch]
result = (unsigned __int16)word_40703A;
if ( word_40703A != 0 )
{ // 认证文件里的用户名/计算机名均不是 "x"(此条件不是指纹校验结果)
v2 = strcmp(Str1: authdata->username, Str2: Buffer) == 0
&& strcmp(Str1: authdata->computername, Str2: word_4076A0) == 0;
word_40703A = v2; // 认证文件中的用户名/计算机名与当前机器信息是否一致
v7 = strlen(Str: Buffer); // v7: 用户名长度(Buffer 即前文 Source,IDA 的显示问题)
v6 = strlen(Str: word_4076A0); // v6: 计算机名长度
String = authdata->key;
result = (unsigned __int16)word_40703A;
if ( word_40703A == 1 )
{ // 再次校验机器信息
result = dword_404040;
if ( dword_404040 == 0 )
{ // 指纹校验结果判定
if ( dword_407040 == 5 ) // 只有计数恰好为 5 时才反转;正常路径通常为 5
strrev(String); // 反转卡密字符串
v4 = strlen(Str: String); // v4: 卡密长度
for ( i = 0; i < v7; ++i )
Buffer[i] %= 16; // 用户名每个 ANSI 字节按 signed char 执行 % 16
for ( j = 0; j < v6; ++j )
word_4076A0[j] %= 16; // 计算机名每个 ANSI 字节按 signed char 执行 % 16
for ( k = 0; ; ++k )
{
result = k;
if ( k >= v4 )
break; // 遍历完反转卡密中的每个字符后 break
v3 = sub_402190(a1: String[k]); // 字符转数值,后续展开
if ( k >= v6 )
{
if ( k >= v7 + v6 )
{
// 如果卡密长度大于用户名+计算机名的长度,多出来的字符置 0(不允许超长)
String[k] = 0;
}
// v6 <= k < v6 + v7
else if ( v3 == Buffer[v7 - (k - v6) - 1] )
{
// 卡密与用户名的对应字符相同
String[k] = -1;
}
else
{
// 卡密与用户名的对应字符不同
String[k] = 0;
}
}
// 0 <= k < v6
else if ( v3 == word_4076A0[v6 - k - 1] )
{
// 卡密与计算机名的对应字符相同
String[k] = -1;
}
else
{
// 卡密与计算机名的对应字符不同
String[k] = 0;
}
}
}
}
}
return result;
}
这是整个 CrackMe 中最难搞懂的一部分。让我们从字符转数值函数 sub_402190 开始看:
int __cdecl sub_402190(unsigned __int8 a1)
{
int v2; // [esp+10h] [ebp-4h]
if ( a1 > 70u )
return -1; // > 'F',返回 -1
v2 = a1 - 48; // 减去 '0'
if ( a1 > 57u )
return a1 - 55; // > '9' <= 'F',返回 a1 - 55
return v2; // <= '9',返回 a1 - 48
}
这是一个十六进制字符转数值函数(重命名为 hex_char_to_nibble)。输入字符和输出数字的映射关系是:'0'-'9' → 0-9、'A'-'F'→10-15,':'..'@' → 3–9,小于 '0' 的字节返回 a1 - '0'(负值),大于 'F' 的字节统一返回 -1。
verify_key 中用户名和计算机名使用的是 ANSI 字节,并且先按有符号 char 解释再执行 % 16。因此字节大于 0x7F 时结果可能为负数;例如 0xD5 会得到 -11。这类负值可以由小于 '0' 的字符表示(例如 -11 对应 '%'),而结果 -1 也会被 >'F' 的字符映射到。因此用 Python 写注册机的时候不能简单地对无符号字节取模。
校验算法的核心是 for ( k = 0; ; ++k ) 这个循环。在 strrev 发生的通常路径下,该循环遍历反转后的卡密,0 <= k < v6 时与计算机名中的每个字符从后向前比较(因为卡密是被反转过的,比较时又是反向遍历计算机名,其实就是倒序逐字符比对,用户名同理),v6 <= k < v6 + v7 时与用户名中的每个字符从后向前比较。循环上界是卡密自身的 strlen,所以短卡密也可能通过:它只需匹配完整卡密的后缀;超过用户名和计算机名总长度的字符则会被置 0 并导致失败。可得(正向)完整卡密的计算方式为:
key = encode_signed_mod16(用户名 ANSI 字节) + encode_signed_mod16(计算机名 ANSI 字节)
非负结果编码为 {'0'..'9','A'..'F'},负结果编码为 chr(ord('0') + x)
完整卡密长度 = len(用户名 ANSI 字节) + len(计算机名 ANSI 字节)
校验时正确的卡密字符会被覆盖为 -1(0xFF),调用方 check_authdata 通过检查被修改后的卡密字符串是否每个字符都是 -1 来判断卡密是否正确。
unlock_check 中的所有解锁条件已经都清楚,补全刚刚的表格:
| 全局变量和解锁条件 |
解锁条件说明 |
word_40703E != 0 |
程序已经初始化过 |
word_407034 == 0 |
没有传命令行参数 |
word_404024 == 2 |
没有检测到调试器 |
dword_404044 == 0 |
verify_key 与 check_authdata 的指纹通过 |
word_407038 == 1 |
authdata.dat 签名校验通过 |
word_40703A == 1 |
认证文件中的用户名和计算机名均不是 "x";在指纹状态允许时,verify_key 还会把它更新为是否与当前机器一致(与函数指纹状态独立) |
word_40703C == 0 |
authdata.dat 卡密校验通过 |
至此校验认证文件的逻辑也全部分析完毕了。这是整个 CrackMe 中最难的部分,先前定义的 authdata_file_t 再次发力,极大地提升了部分伪代码的可读性。大家自己分析时如果难以理解核心校验算法,可以配合 x32dbg 动态调试,在关键位置下断点(比如 00401EFE, ++k 循环汇合处,所有匹配分支都会跳到这里),观察一下程序的执行流程(怎么跳转的)和数据的变化(比如反转后卡密是怎么被逐字符覆盖的)。
玩法一:暴力破解
分析到这里,四种玩法需要的知识已经全部集齐。先从最"无脑"的玩法开始:完全不管卡密算法是什么,直接修改程序,让它无条件走到解锁分支。
从最终判定反推 patch 点
回顾 unlock_check 的伪代码:
void __cdecl unlock_check()
{
verify_fingerprint(a1: init_check);
verify_fingerprint(a1: install);
fetch_machine_info();
if ( word_40703E != 0 && word_407034 == 0 && word_404024 == 2 )
{
verify_fingerprint(a1: nullptr);
check_authdata();
if ( dword_404044 == 0 && word_407038 == 1 && word_40703A == 1 && word_40703C == 0 )
{
decrypt_str(a1: (unsigned __int8 *)off_40401C[0]); // Success!! The program has been unlocked!
MessageBoxA(hWnd: nullptr, lpText: Text, lpCaption: &byte_40524E, uType: 0x40u);
exit(Code: 0);
}
}
}
满足外层 if 中的 3 个条件只需要我们先带 -i 运行一次,随便输入一个卡密,然后再以无参方式直接运行 CrackMe 即可。随后再通过尾部的 4 个条件判断就能成功解锁程序。切到汇编视图看看这个判断长什么样:
.text:00401C39 mov eax, dword_404044 ; 1、后 2 项指纹结果(0 = 通过)
.text:00401C3E test eax, eax
.text:00401C40 jnz short locret_401CAE ; 非 0 → 跳到函数结尾
.text:00401C42 movzx eax, ds:word_407038 ; 2、文件签名有效标志(1 = 有效)
.text:00401C49 cmp ax, 1
.text:00401C4D jnz short locret_401CAE ; ≠ 1 → 跳到函数结尾
.text:00401C4F movzx eax, ds:word_40703A ; 3、机器码绑定标志(1 = 通过)
.text:00401C56 cmp ax, 1
.text:00401C5A jnz short locret_401CAE ; ≠ 1 → 跳到函数结尾
.text:00401C5C movzx eax, ds:word_40703C ; 4、解锁标志(0 = 成功)
.text:00401C63 test ax, ax
.text:00401C66 jnz short locret_401CAE ; 非 0 → 跳到函数结尾
.text:00401C68 mov eax, off_40401C ; Success 分支开始
4 个条件跳转的套路完全一致:条件不满足就跳到 locret_401CAE(函数结尾,紧跟着 leave; retn),线程静默返回,什么都不弹。我们可以把 4 个 jnz 全部改成 nop,这样无论 4 个全局标志是什么值,都必然进入 Success 分支。
另外别忘了 check_authdata 函数会校验认证文件,校验失败会弹 Illegal copy...,因此把调用该函数的指令 call check_authdata 一并 patch 掉会更好。
如果你不想 patch call check_authdata,那么 dword_404044 == 0 对应的 jnz 也可以不 patch,因为在通常执行路径下后 2 项函数(verify_key 与 check_authdata)都会完成指纹校验,而我们改动的 unlock_check 不在检测范围内。同时你还会发现随后 Success 弹窗显示的内容有问题:Success!! The program has been unlocked!d run with -u and then -i to reinstall with correct key.。这是因为 decrypt_str 函数解密完字符串没有往Text 缓冲区写字符串结尾 \0,先解密一个长字符串再解密一个短字符串就会触发这个 bug,感兴趣的朋友可以尝试一下。
patch 清单
汇总全部 patch 点:
| VA(IDA 地址) |
原指令 |
原字节 |
修改后 |
| 0x401C34 |
call check_authdata |
E8 DF 02 00 00 |
90 × 5 |
| 0x401C40 |
jnz short locret_401CAE |
75 6C |
90 90 |
| 0x401C4D |
jnz short locret_401CAE |
75 5F |
90 90 |
| 0x401C5A |
jnz short locret_401CAE |
75 52 |
90 90 |
| 0x401C66 |
jnz short locret_401CAE |
75 46 |
90 90 |
操作步骤:
- 复制一份
CrackMe.exe(可以命名为 CrackMe-bruteforce.exe),不动原 CrackMe 文件
- 在 IDA 中找到以上几处地址,在 Edit 菜单中找到 Patch program,然后使用 Change byte...(修改字节)或 Change word..(修改字) 或 Assemble(汇编)
- 在 Patch program 中找到 Apply patches to input files...,把改动应用到刚刚复制的文件
验证
- 运行
CrackMe-bruteforce.exe -i,卡密随便填
- 直接运行
CrackMe-bruteforce.exe,成功解锁程序
玩法二:计算得到合法卡密
我们在校验认证文件路径章节已经算出了卡密公式:
key = encode_signed_mod16(用户名 ANSI 字节) + encode_signed_mod16(计算机名 ANSI 字节)
非负结果编码为 {'0'..'9','A'..'F'},负结果编码为 chr(ord('0') + x)
完整卡密长度 = len(用户名 ANSI 字节) + len(计算机名 ANSI 字节)
可以在 powershell 中运行 "$env:USERNAME @ $env:COMPUTERNAME" 查看当前用户名和计算机名。
以我自己的环境举个例子:
用户名:13120,计算机名:ANDY_DESKTOP
- 用户名部分:
1,3,1,2,0 → signed char ASCII % 16 → 1,3,1,2,0 → 13120
- 计算机名部分:
A,N,D,Y,_,D,E,S,K,T,O,P → signed char ASCII % 16 → 1,E,4,9,F,4,5,3,B,4,F,0 → 1E49F453B4F0
合法卡密:131201E49F453B4F0
手工计算还是太麻烦了,在“玩法四:生成注册机”中我们会编写 python 脚本来计算卡密。
玩法三:修改认证文件
使用任意 Hex 编辑器修改认证文件 authdata.dat 中的卡密区域(偏移 112),填入一个合法的卡密来通过校验(别忘了字符串末尾的 \0),然后同步修改 checksum 文件中的字节和即可。和上一个玩法的重复度很高,只是需要我们了解 authdata.dat 和 checksum 的文件结构。
在接下来的“玩法四:生成注册机”中我们会编写 python 脚本直接生成认证文件。
玩法四:生成注册机
我们已经完全搞清楚卡密算法和认证文件格式了,写个注册机是水到渠成的事。下面给出完整的 Python 注册机代码 keygen.py:
"""
CrackMe 注册机
根据「用户名 + 计算机名」计算合法卡密,并可生成 authdata.dat / checksum。
算法:
对每个 ANSI 字节先按有符号 char 解释,再计算 C 语义的 % 16。
非负结果使用 '0'-'9','A'-'F';负结果使用 chr(ord('0') + value)。
key = encode(用户名正序) + encode(计算机名正序)
卡密长度 = len(用户名 ANSI 字节) + len(计算机名 ANSI 字节)
用法:
python keygen.py # 自动取当前机器信息,输出卡密
python keygen.py <USERNAME> <COMPUTERNAME> # 指定机器信息,输出卡密
python keygen.py --write <DIR> # 输出卡密并生成合法 authdata.dat + checksum 到目录
python keygen.py --install # 输出卡密并写入 %USERPROFILE%\\data(等效 -i 安装)
"""
import argparse
import ctypes
import os
import struct
import sys
import time
SIGN = b"cv2pr\x00"
FILE_LEN = 220
# authdata.dat 字段偏移
OFF_SIGNATURE = 0
OFF_TIMESTAMP = 8
OFF_USERNAME = 12
OFF_COMPNAME = 62
OFF_KEY = 112
OFF_TAIL_SIGN = 212
HEX = "0123456789ABCDEF"
def encode_name(s: str) -> bytes:
"""将用户名/计算机名编码为 ANSI 字节(与 GetUserNameA/GetComputerNameA 一致)"""
try:
return s.encode("mbcs")
except UnicodeEncodeError as e:
raise ValueError(f"无法编码为 ANSI: {s!r} ({e})")
def signed_byte_mod16(value: int) -> int:
"""复现样本对 signed char 执行 % 16 的结果(C 余数向 0 截断)"""
signed = value if value <= 0x7F else value - 0x100
if signed >= 0:
return signed % 16
return -((-signed) % 16)
def encode_nibble(value: int) -> str:
"""把样本的 -15..15 结果编码成 hex 字符或可被其解析的负数字符"""
if value >= 0:
return HEX[value]
# hex_char_to_nibble 对小于 '0' 的字节直接返回 byte - '0'。
return chr(ord("0") + value)
def make_key(username: bytes, compname: bytes) -> str:
"""生成合法卡密(认证文件中正向卡密 = 用户名正序 + 计算机名正序)"""
return "".join(encode_nibble(signed_byte_mod16(b)) for b in username + compname)
def build_authdata(username: bytes, compname: bytes, key: str) -> bytes:
"""构造 220 字节 authdata.dat(布局与 install 写入的一致,未覆盖处填 0)"""
if len(username) > 49:
raise ValueError(f"用户名过长: {len(username)} 字节(上限 49)")
if len(compname) > 49:
raise ValueError(f"计算机名过长: {len(compname)} 字节(上限 49)")
if len(key) > 99:
# 上限 99:卡密区 100 字节须以 NUL 结尾;100 字符时样本中 scanf 追加的 NUL 会踩掉尾签名
raise ValueError(f"卡密过长: {len(key)} 字符(上限 99)")
buf = bytearray(FILE_LEN)
buf[OFF_SIGNATURE:OFF_SIGNATURE + 6] = SIGN
buf[OFF_TIMESTAMP:OFF_TIMESTAMP + 4] = struct.pack("<I", int(time.time()) & 0xFFFFFFFF)
buf[OFF_USERNAME:OFF_USERNAME + len(username)] = username
buf[OFF_COMPNAME:OFF_COMPNAME + len(compname)] = compname
buf[OFF_KEY:OFF_KEY + len(key)] = key.encode("ascii")
buf[OFF_TAIL_SIGN:OFF_TAIL_SIGN + 6] = SIGN
return bytes(buf)
def checksum(buf: bytes) -> int:
"""verify_fingerprint(NULL) 要求的校验值:220 字节之和(模 2^32)"""
return sum(buf) & 0xFFFFFFFF
def write_license(out_dir: str, buf: bytes) -> None:
"""将 authdata.dat 和 checksum 写入指定目录"""
os.makedirs(out_dir, exist_ok=True)
dat_path = os.path.join(out_dir, "authdata.dat")
cks_path = os.path.join(out_dir, "checksum")
with open(dat_path, "wb") as f:
f.write(buf)
with open(cks_path, "wb") as f:
f.write(struct.pack("<I", checksum(buf)))
print(f"[+] 已生成 {dat_path}")
print(f"[+] 已生成 {cks_path}")
def current_machine() -> tuple[bytes, bytes]:
"""与 fetch_machine_info 相同:GetUserNameA / GetComputerNameA"""
def get(func):
buf = ctypes.create_string_buffer(256)
size = ctypes.c_ulong(256)
if not func(buf, ctypes.byref(size)):
raise OSError(f"获取机器信息失败: {func.__name__}")
return buf.value
advapi = ctypes.windll.advapi32.GetUserNameA
kernel = ctypes.windll.kernel32.GetComputerNameA
return get(advapi), get(kernel)
def main() -> int:
ap = argparse.ArgumentParser(
description="CrackMe 注册机:根据用户名+计算机名生成合法卡密与授权文件",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python keygen.py # 自动取当前机器信息,输出卡密
python keygen.py alice PC01 # 指定机器信息,输出卡密
python keygen.py --write out # 输出卡密并生成 authdata.dat + checksum 到 out/
python keygen.py --install # 输出卡密并写入 %USERPROFILE%\\data(等效 -i 安装)
""",
)
ap.add_argument("username", nargs="?", help="用户名(缺省取当前机器用户名)")
ap.add_argument("compname", nargs="?", help="计算机名(缺省取当前机器计算机名)")
group = ap.add_mutually_exclusive_group()
group.add_argument("--install", action="store_true",
help="直接写入 %%USERPROFILE%%\\data(等效 -i 安装)")
group.add_argument("--write", nargs="?", const=".", metavar="DIR",
help="生成 authdata.dat + checksum 到 DIR(缺省使用当前目录)")
args = ap.parse_args()
if (args.username is None) != (args.compname is None):
ap.error("请同时给出用户名和计算机名")
if args.username is None:
try:
user_b, comp_b = current_machine()
except OSError as e:
print(f"[!] {e},请显式指定: python keygen.py <用户名> <计算机名>")
return 1
print(f" 未指定机器信息,使用当前机器: 用户名={user_b.decode('mbcs')!r} 计算机名={comp_b.decode('mbcs')!r}")
else:
try:
user_b = encode_name(args.username)
comp_b = encode_name(args.compname)
except ValueError as e:
print(f"[!] {e}")
return 1
if not user_b or not comp_b:
print("[!] 用户名/计算机名为空,请显式指定: python keygen.py <用户名> <计算机名>")
return 1
# 长度校验
if len(user_b) > 49:
print(f"[!] 用户名过长: {len(user_b)} 字节(上限 49)")
return 1
if len(comp_b) > 49:
print(f"[!] 计算机名过长: {len(comp_b)} 字节(上限 49)")
return 1
# 用户名/计算机名各限 49 字节后,完整卡密最长 49+49=98,必然 <= 99,无需再查总长
# 生成卡密
key = make_key(user_b, comp_b)
print(f" 用户名 = {user_b.decode('mbcs')}")
print(f" 计算机名 = {comp_b.decode('mbcs')}")
print(f"[+] 合法卡密 = {key}")
# 输出文件
if args.install:
out_dir = os.path.join(os.environ.get("USERPROFILE", os.getcwd()), "data")
elif args.write is not None:
out_dir = args.write
else:
print("\n提示: 用 `CrackMe.exe -i` 输入上述卡密即可解锁;或加 --install 直接安装授权文件。")
return 0
buf = build_authdata(user_b, comp_b, key)
write_license(out_dir, buf)
print(f" 已生成合法授权文件(直接放 %USERPROFILE%\\data\\ 即可)")
return 0
if __name__ == "__main__":
sys.exit(main())
注册机的核心逻辑很短:make_key 算卡密,build_authdata 构造认证文件,checksum 算校验和。下面对几个值得注意的地方做一下说明。
对齐 GetUserNameA / GetComputerNameA 和 C 的有符号取余行为
CrackMe 使用 GetUserNameA 和 GetComputerNameA(注意后缀 A)获取机器信息,这两个 API 返回的是 ANSI 编码的字符串——在中文 Windows 上默认是 GBK(代码页 936),在英文 Windows 上是 Windows-1252(代码页 1252)。
如果用户名或计算机名包含非 ASCII 字符(比如中文用户名),编码方式和有符号取余都很重要。"张" 在 GBK 下是 2 字节 \xD5\xC5,其中 0xD5 按样本的 signed char % 16 得到 -11,注册机会用 '%' 表示这个结果;在 UTF-8 下是 3 字节 \xE5\xBC\xA0,算出来的卡密完全不同。
注册机中用两种方式确保与 CrackMe 的行为一致:
- 自动模式(
current_machine):通过 ctypes 直接调用 GetUserNameA 和 GetComputerNameA,拿到的字节与 CrackMe 运行时完全相同,不存在编码偏差。
- 手动模式(
encode_name):当用户通过命令行参数指定用户名/计算机名时,使用 Python 的 s.encode("mbcs") 编码。mbcs 是 Python 在 Windows 上的特殊编解码器,它使用的就是系统的 ANSI 代码页,与 Win32 API 的 *A 系列函数行为一致。
取到 ANSI 字节后,signed_byte_mod16 先将 0x80–0xFF 转为 signed char,再复现 C 语言向 0 截断的余数规则;encode_nibble 将负结果编码为小于 '0' 的字符,使样本的 hex_char_to_nibble 能解析回来。
Python 的 % 是“取模”,结果符号与除数相同;C 语言的 % 是“取余”:结果符号与被除数相同。
原因是对整数除法的取整方向不同:Python 的 // 是向下取整,C 语言的整数除法 / 是向 0 取整。
它们都满足基本的公式:被除数 = 除数 × 商 + 余数。只是“商”的取法不同,所以余数不同。
构造认证文件:全零填充 vs 栈垃圾
回顾 -i 安装路径中 install 函数的行为:它在栈上分配 220 字节的 Buffer,只往签名、时间戳、用户名、计算机名、卡密这几个字段写入数据,其余字节是未初始化的栈内存(垃圾数据)。这也是为什么每次 -i 生成的 authdata.dat 内容不完全相同。
注册机的 build_authdata 用 bytearray(220) 分配缓冲区,Python 会将其初始化为全零。这意味着我们生成的文件和 -i 生成的文件在未使用区域的内容不同,但这完全不影响校验——verify_fingerprint(NULL) 只关心 220 字节的整体字节和是否等于 checksum 文件中存的值,而我们的 checksum 函数也是按实际内容计算的。
卡密长度上限为什么是 99 而不是 100
卡密区有 100 字节(偏移 112–211),紧随其后的是偏移 212 处的尾部签名。install 读取卡密使用的是:
scanf("%100[^\n]", Buffer.key);
这里存在一个小 bug:%100[^\n] 最多读入 100 个字符,而 scanf 在结束后还会追加一个 \0。如果输入恰好 100 个字符,前 100 字节填满卡密区(偏移 112–211),追加的 \0 就落到了偏移 212,把尾部签名的首字节 'c' 覆盖成 \0(install 先写尾签名,后调 scanf,所以必然被踩):
偏移 112 …… 211 212 213 214 215 216 217
内容 卡密(100字符) \0 'v' '2' 'p' 'r' \0
↑ 原为 'c'
这样生成的 authdata.dat:checksum 是按文件实际内容算的,字节和校验能过;但 check_authdata_signature 里 strcmp(偏移212, "cv2pr") 读到的是空串,比对失败 → word_407038 = 0 → word_40703C = 1,解锁失败。也就是说 100 字符的卡密永远不可能通过校验,有效卡密最长为 99 个字符(99 个字符 + 结尾 \0 恰好占满卡密区)。这就是 build_authdata 把上限定为 99 的原因(CrackMe 作者的正确写法应该是 %99[^\n])。
再进一步:完整卡密 = 用户名 + 计算机名 的逐字节编码(一个 ANSI 字节对应一个卡密字符),而这两个字段各只有 50 字节(最长 49 + \0),所以完整卡密最长 49 + 49 = 98 字符,天然不超过 99。因此 main 中对用户名、计算机名各限 49 字节之后,卡密长度必然合法;build_authdata 里的 len(key) > 99 只是函数自身的兜底检查。
使用方式
注册机支持四种用法:
# 1. 自动获取当前机器的用户名和计算机名,只输出卡密
python keygen.py
# 2. 指定机器信息,只输出卡密(适合给别人生成卡密)
python keygen.py alice PC01
# 3. 输出卡密,并生成 authdata.dat + checksum 到指定目录
python keygen.py --write out
# 4. 输出卡密,并直接写入 %USERPROFILE%\data(等效 CrackMe -i 安装)
python keygen.py --install
验证
> python keygen.py --install
未指定机器信息,使用当前机器: 用户名='13120' 计算机名='ANDY_DESKTOP'
用户名 = 13120
计算机名 = ANDY_DESKTOP
[+] 合法卡密 = 131201E49F453B4F0
[+] 已生成 C:\Users\13120\data\authdata.dat
[+] 已生成 C:\Users\13120\data\checksum
已生成合法授权文件(直接放 %USERPROFILE%\data\ 即可)
直接运行 CrackMe.exe,弹窗 Success!! The program has been unlocked!,轻松拿下。
另外再测一下用户名是中文的情况。以管理员身份打开 powershell,新建一个测试账户:
net user 测试 Test@12345 /add
把 CrackMe.exe 复制到公共目录:
copy CrackMe.exe C:\Users\Public\
在当前账户把卡密算好:
> python keygen.py 测试 ANDY_DESKTOP
用户名 = 测试
计算机名 = ANDY_DESKTOP
[+] 合法卡密 = ""*$1E49F453B4F0
提示: 用 `CrackMe.exe -i` 输入上述卡密即可解锁;或加 --install 直接安装授权文件。
以测试账户开一个 cmd:
runas /user:测试 cmd
在弹出的新 cmd 窗口里运行 CrackMe:
C:\Users\Public\CrackMe.exe -i
:: Enter the Key: 后粘贴 ""*$1E49F453B4F0
C:\Users\Public\CrackMe.exe
同样成功解锁。说明我们的注册机能够兼容中文用户名,中文计算机名同理。
结语
这是个比较初级的 CrackMe,可能是因为太简单了,原帖下面没什么人讨论解法。我断断续续花了一周多的时间编写并完善这篇 writeup,尽量使其通俗易懂。希望这篇文章能帮到刚入坑 Windows 逆向的朋友。