andy_wang425 发表于 2026-8-20 21:04

站内CrackMe WriteUp —— 32位无壳带反调试模拟真实校验精品CrackMe

# 引言

原帖地址:<https://www.52pojie.cn/thread-2121853-1-1.html>。

> ## CrackMe 说明
>
> 模拟实际环境下的卡密认证,适合初中级玩家。
>
> ## 技术要点
> - 32位
> - 无壳
> - 有若干反调试
> - 机器码
> - 校验和
>
> ## 程序用法
> 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):

```hexdump
0000000063 76 32 70 72 00 60 00 5f 7f 7c 6a 31 33 31 32|cv2pr.`._.|j1312|
0000001030 00 00 00 bc 03 0c 01 f7 19 1b 77 14 02 00 00|0...¼...÷..w....|
0000002000 00 0c 01 14 02 00 00 44 00 00 00 20 02 00 00|........D... ...|
0000003000 00 00 00 10 00 00 00 03 00 00 00 00 00 41 4e|..............AN|
0000004044 59 5f 44 45 53 4b 54 4f 50 00 00 19 f8 15 77|DY_DESKTOP...ø.w|
0000005008 00 00 00 f0 1f d0 11 14 02 00 00 00 00 00 00|....ð.Ð.........|
0000006014 02 00 00 f0 fd 60 00 17 00 09 00 fe ff ff ff|....ðý`.....þÿÿÿ|
0000007031 32 33 34 35 36 37 38 39 00 1b 77 8c c3 03 75|123456789..w.Ã.u|
0000008009 34 17 77 01 00 00 00 6d cc 76 cc 44 fb 60 00|.4.w....mÌvÌDû`.|
000000900c 00 00 00 cc ff 60 00 80 ce da 74 25 8f ff b8|....Ìÿ`..ÎÚt%.ÿ¸|
000000a0fe ff ff ff 68 fe 60 00 37 c8 fe 75 ff ff ff ff|þÿÿÿhþ`.7Èþuÿÿÿÿ|
000000b000 00 00 00 00 00 00 00 60 8b fe 74 90 16 0c 01|........`.þt....|
000000c004 00 00 00 00 00 00 00 94 fe 60 00 ec 9f 19 77|.........þ`.ì..w|
000000d033 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 视图里随便看看,很容易找到一些奇怪的字符串:

```assembly
.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` 是解密字符串的函数,所以每次显示加密文本前都要调用该函数。该函数并不复杂:

```c
int __cdecl sub_401781(unsigned __int8 *a1)
{
int result; // eax
CHAR *i; //

for ( i = Text; ; ++i )
{
    result = *a1;
    if ( (_BYTE)result == 0 )
      break;
    *i = *a1++ - 1;
}
return result;
}
```

该函数是一个凯撒密码解密函数。它把传入的字符串的每个字节减 1,将结果写到 `.bss` 段的 `Text` 偏移处。将其重命名为 `decrypt_str` 方便后续分析。

我们可以写一个 python 脚本把这些加密字符串全解密了:

```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,这是程序的入口:

```c
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` 函数:

```c
int __cdecl main(int argc, const char **argv, const char **envp)
{
HANDLE hHandle; //
CHAR *lpDst; //

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, Str2: "-i") == 0 )
    {
      sub_401874(); // -i 安装认证文件路径,install
    }
    else if ( strcmp(Str1: argv, Str2: "-u") == 0 )
    {
      sub_40174F(); // -u 卸载认证文件路径,remove_data_directory
    }
    else
    {
      decrypt_str(a1: (unsigned __int8 *)off_40400C);
      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` 卸载认证文件路径:

```c
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 的 (https://github.com/mrexodia/ida-pro-mcp) 搭配任意模型(deepseek-v4-flash 就足够),看看 AI 是怎么凭借纯静态分析解决这道题的。

## 调试器检测

在 `main` 函数中很容易找到这样一段:

```c
if ( IsDebuggerPresent() )
    word_404024 = 1;
```

检测到调试器后,程序把 `word_404024` 置 `1`。查一下 `word_404024` 的定义:

```assembly
.data:00404024 word_404024   dw 2                  ; DATA XREF: _main+8C↑w
.data:00404024                                       ; unlock_check+43↑r
```

它的初始值是 `2`,再根据 xref 找到 `unlock_check` 中读取它的地方:

```c
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`。

```c
beginthread(StartAddress: StartAddress, StackSize: 0, ArgList: (void *)1);
```

`StartAddress` 的伪代码:

```c
void __cdecl StartAddress(void *a1)
{
int v1; // eax
DWORD TickCount; //

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 次是空指针。

```c
int __cdecl sub_4021C9(_BYTE *a1)
{
int result; // eax
_BYTE Buffer; // BYREF
int v3; // BYREF
FILE *Stream; //
int v5; //
int v6; //
int v7; //
unsigned int i; //
int v9; //

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 != -1 ) // dword_404028: 函数指纹数组
    {
      if ( v6 == dword_404028 )
      dword_404028 = 0; // 指纹吻合,把数组对应项置 0
      ++v7;
    }
    if ( dword_404028 == 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;
    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 , eax` 的金丝雀放置。因此,本 CrackMe 未启用栈保护(未开 MinGW GCC 的 `-fstack-protector` 选项,其效果类似于 MSVC 的 `/GS-`),cookie 被初始化了但没有任何栈帧使用它做检测。

# -i 安装认证文件路径

根据之前对 `main` 函数的分析,安装认证文件的逻辑集中在 `install` 函数中:

```c
int install()
{
int v1; // BYREF
_BYTE Buffer; // BYREF // 疑点:Buffer 作为认证文件数据长度是 220,为什么这里显示只有 8?
__time32_t v3; //
char v4; // BYREF
char v5; // BYREF
_BYTE v6; // BYREF
int v7; // BYREF
FILE *Stream; //
__time32_t v9; //
unsigned int j; //
unsigned __int8 *v11; //
unsigned int i; //
_BYTE *v13; //

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);
printf(Format: "%s", Text);
scanf(Format: "%100[^\n]", v6);
if ( v6 == 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 打开栈变量窗口:

```c
-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;
-00000000000000BE   char var_BE;
-000000000000008C   _BYTE var_8C;
-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` 定义一个结构体:

```c
struct authdata_file_t {            // 总长 220 (0xDC)
char signature;                // 0x00签名 "cv2pr\0"
char pad0;                     // 0x06填充,未知内容
char usernameAndUnknown;      // 0x0C用户名 + 未知内容
char computernameAndUnknown;// 0x3E计算机名 + 未知内容
char keyAndUnknown;          // 0x70卡密 + 未知内容
char tail_signature;         // 0xD4尾部签名 "cv2pr\0"
char pad1;                     // 0xDA填充,未知内容
};
```

打开 Local Types 视图,使用这个 C 结构体新增类型 `authdata_file_t`。然后回到伪代码视图,将 `_BYTE Buffer` 变量的类型设置为 `authdata_file_t`。IDA 会自动重新反编译,新的伪代码如下:

```c
int install()
{
int v1; // BYREF
authdata_file_t Buffer; // BYREF
FILE *Stream; //
__time32_t v4; //
unsigned int j; //
authdata_file_t *v6; //
unsigned int i; //
authdata_file_t *p_Buffer; //

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 = v4; // 时间戳被放置在 pad0 位置
strcpy(Destination: Buffer.usernameAndUnknown, Source: Source); // strcpy 的目标都看懂了
strcpy(Destination: Buffer.computernameAndUnknown, Source: word_4076A0); // 同上
decrypt_str(a1: (unsigned __int8 *)off_404014);
printf(Format: "%s", Text);
scanf(Format: "%100[^\n]", Buffer.keyAndUnknown); // 使用 scanf 读取卡密,写到 Buffer 的卡密区域
if ( Buffer.keyAndUnknown == 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;
      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;
    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` 类型。从启动时间差检测线程之后的地方开始:

```c
while ( word_407036 == 0 )
    ;
```

这段代码会一直阻塞后续逻辑直到 `word_407036` 变为非 0 值。查一下 xref,唯一的写入在 `sub_401B69`。

```c
int sub_401B69()
{
int result; // eax
DWORD pcbBuffer; // BYREF

pcbBuffer = 100;
GetUserNameA(lpBuffer: Source, pcbBuffer); // 获取用户名,写到 Source
pcbBuffer = 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` 执行完毕。

继续往下看:

```c
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` 的实现:

```c
int __cdecl sub_401841(int a1, int a2, int a3) // a1: 签名,a2: 签名写入位置,a3: 签名写入长度
{
int result; // eax
int i; //

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`。

接着往下看:

```c
*(_DWORD *)&Buffer.pad0 = v4; // 回顾:__time32_t v4; v4 = time(Time: nullptr);
```

这里我们找到了时间戳的位置,`&Buffer.pad0`。之后优化 `authdata_file_t` 结构体时我们可以将 `pad0` 填充的长度改为 2,在它的后面加上时间戳字段。

最后再看一段:

```c
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);
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 视图中编辑类型:

```c
struct authdata_file_t {    // 总长 220 (0xDC)
char signature;      // 0x00"cv2pr\0"
char pad0;             // 0x06填充
int timestamp;            // 0x08time() 时间戳(安装认证文件的时间)
char username;      // 0x0C用户名
char computername;    // 0x3E计算机名
char key;            // 0x70卡密区
char tail_signature;   // 0xD4"cv2pr\0"
char pad1;             // 0xDA填充
};
```

此时再重新反编译,`install` 函数的代码就非常好懂了,这里贴一小段:

```c
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);
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` 后面也是。这些看似随机的数据是从哪来的?

```hexdump
0000000063 76 32 70 72 00 60 00 5f 7f 7c 6a 31 33 31 32|cv2pr.`._.|j1312|
0000001030 00 00 00 bc 03 0c 01 f7 19 1b 77 14 02 00 00|0...¼...÷..w....|
```

要回答这个问题,需要关注 `authdata_file_t` 结构体中的两处填充和三个字符串字段的实际写入方式:

1. **两处填充字段从未被写入**:`pad0`(偏移 6,2 字节)和 `pad1`(偏移 218,2 字节)在 `install` 函数中没有任何代码向它们写入数据。
2. **字符串字段只写了一部分**:用户名、计算机名和卡密三个字段分别有 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`:

```c
DWORD init_check()
{
DWORD result; // eax
struct _WIN32_FIND_DATAA FindFileData; // BYREF

FindFirstFileA(lpFileName: ".\\data", lpFindFileData: &FindFileData); // 返回值被忽略
result = GetLastError();
if ( result == 2 )
{
    decrypt_str(a1: (unsigned __int8 *)off_404010);
    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`:

```c
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); // 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` 存放认证文件内容。修改其类型为我们之前定义的 `authdata_file_t` 后重新反编译:

```c
int check_authdata()
{
_BOOL2 v0; // ax
int result; // eax
DWORD pcbBuffer; // BYREF
authdata_file_t Buffer; // BYREF
signed int v4; //
FILE *Stream; //
signed int i; //
int v7; //

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 != -1 ) // -1 即 0xFF,出现非 0xFF 即校验失败
      {
      decrypt_str(a1: (unsigned __int8 *)off_404018); // "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`:

```c
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` 即可)。再次反编译,结果如下:

```c
int __cdecl verify_key(authdata_file_t *authdata) // 为了可读性,已将参数重命名为 authdata
{
int result; // eax
_BOOL2 v2; // ax
int v3; //
signed int v4; //
char *String; //
signed int v6; //
signed int v7; //
int k; //
signed int j; //
signed int i; //

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 %= 16; // 用户名每个 ANSI 字节按 signed char 执行 % 16
      for ( j = 0; j < v6; ++j )
          word_4076A0 %= 16; // 计算机名每个 ANSI 字节按 signed char 执行 % 16
      for ( k = 0; ; ++k )
      {
          result = k;
          if ( k >= v4 )
            break; // 遍历完反转卡密中的每个字符后 break
          v3 = sub_402190(a1: String); // 字符转数值,后续展开
          if ( k >= v6 )
          {
            if ( k >= v7 + v6 )
            {
            // 如果卡密长度大于用户名+计算机名的长度,多出来的字符置 0(不允许超长)
            String = 0;
            }
            // v6 <= k < v6 + v7
            else if ( v3 == Buffer )
            {
            // 卡密与用户名的对应字符相同
            String = -1;
            }
            else
            {
            // 卡密与用户名的对应字符不同
            String = 0;
            }
          }
          // 0 <= k < v6
          else if ( v3 == word_4076A0 )
          {
            // 卡密与计算机名的对应字符相同
            String = -1;
          }
          else
          {
            // 卡密与计算机名的对应字符不同
            String = 0;
          }
      }
      }
    }
}
return result;
}
```

这是整个 CrackMe 中最难搞懂的一部分。让我们从字符转数值函数 `sub_402190` 开始看:

```c
int __cdecl sub_402190(unsigned __int8 a1)
{
int v2; //

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 并导致失败。可得(正向)完整卡密的计算方式为:

```c
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` 的伪代码:

```c
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); // Success!! The program has been unlocked!
      MessageBoxA(hWnd: nullptr, lpText: Text, lpCaption: &byte_40524E, uType: 0x40u);
      exit(Code: 0);
    }
}
}
```

满足外层 if 中的 3 个条件只需要我们先带 `-i` 运行一次,随便输入一个卡密,然后再以无参方式直接运行 CrackMe 即可。随后再通过尾部的 4 个条件判断就能成功解锁程序。切到汇编视图看看这个判断长什么样:

```assembly
.text:00401C39moveax, dword_404044      ; 1、后 2 项指纹结果(0 = 通过)
.text:00401C3Etest eax, eax
.text:00401C40jnzshort locret_401CAE    ; 非 0 → 跳到函数结尾
.text:00401C42movzx eax, ds:word_407038   ; 2、文件签名有效标志(1 = 有效)
.text:00401C49cmpax, 1
.text:00401C4Djnzshort locret_401CAE    ; ≠ 1 → 跳到函数结尾
.text:00401C4Fmovzx eax, ds:word_40703A   ; 3、机器码绑定标志(1 = 通过)
.text:00401C56cmpax, 1
.text:00401C5Ajnzshort locret_401CAE    ; ≠ 1 → 跳到函数结尾
.text:00401C5Cmovzx eax, ds:word_40703C   ; 4、解锁标志(0 = 成功)
.text:00401C63test ax, ax
.text:00401C66jnzshort locret_401CAE    ; 非 0 → 跳到函数结尾
.text:00401C68moveax, 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` |

操作步骤:

1. 复制一份 `CrackMe.exe`(可以命名为 `CrackMe-bruteforce.exe`),不动原 CrackMe 文件
2. 在 IDA 中找到以上几处地址,在 Edit 菜单中找到 Patch program,然后使用 Change byte...(修改字节)或 Change word..(修改字) 或 Assemble(汇编)
3. 在 Patch program 中找到 Apply patches to input files...,把改动应用到刚刚复制的文件



## 验证

1. 运行 `CrackMe-bruteforce.exe -i`,卡密随便填
2. 直接运行 `CrackMe-bruteforce.exe`,成功解锁程序

# 玩法二:计算得到合法卡密

我们在校验认证文件路径章节已经算出了卡密公式:

```c
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`:

```python
"""
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
    # 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 = SIGN
    buf = struct.pack("<I", int(time.time()) & 0xFFFFFFFF)
    buf = username
    buf = compname
    buf = key.encode("ascii")
    buf = 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:
    """与 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 的行为一致:

1. **自动模式**(`current_machine`):通过 `ctypes` 直接调用 `GetUserNameA` 和 `GetComputerNameA`,拿到的字节与 CrackMe 运行时完全相同,不存在编码偏差。
2. **手动模式**(`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` 读取卡密使用的是:

```c
scanf("%100[^\n]", Buffer.key);
```

这里存在一个小 bug:`%100[^\n]` 最多读入 100 个字符,而 `scanf` 在结束后还会追加一个 `\0`。如果输入恰好 100 个字符,前 100 字节填满卡密区(偏移 112–211),追加的 `\0` 就落到了偏移 212,把尾部签名的首字节 `'c'` 覆盖成 `\0`(`install` 先写尾签名,后调 `scanf`,所以必然被踩):

```
偏移    112 …… 211    212213214215216217
内容    卡密(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` 只是函数自身的兜底检查。

## 使用方式

注册机支持四种用法:

```bash
# 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
```

## 验证

```cmd
> 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,新建一个测试账户:

```cmd
net user 测试 Test@12345 /add
```

把 `CrackMe.exe` 复制到公共目录:

```cmd
copy CrackMe.exe C:\Users\Public\
```

在当前账户把卡密算好:

```cmd
> python keygen.py 测试 ANDY_DESKTOP
[*] 用户名   = 测试
[*] 计算机名   = ANDY_DESKTOP
[+] 合法卡密   = ""*$1E49F453B4F0

提示: 用 `CrackMe.exe -i` 输入上述卡密即可解锁;或加 --install 直接安装授权文件。
```

以测试账户开一个 cmd:

```cmd
runas /user:测试 cmd
```

在弹出的新 cmd 窗口里运行 CrackMe:

```cmd
C:\Users\Public\CrackMe.exe -i
:: Enter the Key: 后粘贴 ""*$1E49F453B4F0
C:\Users\Public\CrackMe.exe
```

同样成功解锁。说明我们的注册机能够兼容中文用户名,中文计算机名同理。

# 结语

这是个比较初级的 CrackMe,可能是因为太简单了,原帖下面没什么人讨论解法。我断断续续花了一周多的时间编写并完善这篇 writeup,尽量使其通俗易懂。希望这篇文章能帮到刚入坑 Windows 逆向的朋友。

caixukun12 发表于 2026-8-22 02:12

觉得是很适合入门 Windows 逆向的一道题,故写下这篇 writeup。本文的目的是帮助新手朋友完整地走一遍静态分析流程,学

weijiajin 发表于 2026-9-15 16:01

多谢!正在学习的路上
页: [1]
查看完整版本: 站内CrackMe WriteUp —— 32位无壳带反调试模拟真实校验精品CrackMe