CTF 的逆向题里,CrackMe 属于最友好的入口,没有之一。你拿到的不是需要对抗的某个商业软件,而是一个专门写出来让你解的谜题:程序接收输入,内部做一次字符串比对,结果决定输出恭喜还是报错。字符串比对恰恰是这类题目里最常见、也最适合练手的逻辑,所以从它下手,等于用最小成本把整条逆向分析流程串起来。这篇文章就用一个我们内部复刻的入门题 crackme0 做例子,从静态分析一路走到动态调试,把每一步为什么这么做、工具怎么用、踩了什么坑都讲透。适合刚接触逆向、能敲点 Linux 命令行、想打 CTF 但拿到 ELF 文件不知道从哪下刀的同学。
1. 先搞明白 CrackMe 这类题到底在考什么
1.1 一句话理解 CrackMe 的运行逻辑
CrackMe 是专门设计出来给逆向学习者破解的小程序,逻辑通常非常简单:先是打印一句“请输入密码/输入 flag”,然后读入你的输入,再和程序内部某个“预期值”做比较,相等就走成功分支,不相等就走失败分支。比较的位置就是整个题目的题眼。
你可能会问:这不就是个带密码的玩具吗?对,它的价值恰恰在于“玩具”。因为程序是故意给你看的,所以编译时不会上太强的保护,函数符号可能还留着,比较函数也大概率是公开的 libc 函数。逆向的本质就是阅读程序并还原它的行为,CrackMe 把“读程序”这件事压缩到一个函数里,非常适合入门。
打个比方:这就像拿到一个透明表壳的密码锁,厂家还贴心地附上了说明书,你的任务是找出那个能打开锁的数字。虽然理论上你甚至可以暴力试一万遍,但更优雅的方式是直接看锁芯结构,一次就得出答案。
1.2 为什么字符串比对是逆向入门第一课
CrackMe 家族的题五花八门,但字符串比对几乎是出镜率最高的基础款,原因很实在:
第一,strcmp是动态库函数。只要程序是动态链接的,反汇编里就会有一个非常显眼的call strcmp@plt,哪怕你不懂汇编,光凭函数名都能猜到这一行在干什么。
第二,strcmp的返回值语义固定:相等返回 0,不等返回非 0。这个语义直接催生了汇编里最经典的一段流程——test eax, eax配上jne或jz。你只要见过三次这种组合,第四次闭着眼都能认出来:这是在根据比较结果跳分支。
第三,它是后面一切变体的源头。把strcmp换成逐字节循环,就是“单字节比对”;在比之前先 XOR 一下,就是“编码比对”;先检查strlen,就是“长度校验型”。这些都不是新东西,都是在字符串比对的基本盘上加料。
所以我认为,字符串比对类题目是性价比最高的练习素材:它强迫你把文件识别、字符串提取、反汇编阅读、动态调试这几个基础技能各用一遍,但难度又刚好不会让你劝退。
1.3 工具选型:够用就行,别一上来就上重炮
做这类题,我的原则是“先用命令行小工具,再用重型武器”。命令行的file、strings、objdump、readelf都是 binutils 自带,任何 Linux 发行版都有,不需要额外安装,零成本起步。
动态部分主力是gdb,再加一个ltrace作为快速旁路。很多教程一上来就让你装 IDA 或者 Ghidra,我不是反对图形工具,而是觉得入门阶段,命令行工具反而更能逼你理解指令级的东西。比如 Ghidra 会把test eax, eax; jne反编译成if (iVar1 != 0),看着爽,但你少了一次“自己读汇编、自己总结规律”的训练。
等到你开始做稍微大一点的题,或者遇到符号被 strip 的情况,再上 Ghidra 或 radare2 不迟。工具是手段,读懂程序才是目的。后面我会在实操环节说明,哪些场景下图形工具反而更省力。
2. 静态分析:先不动代码,把纸面线索翻干净
2.1 拿到二进制先验明正身
所有逆向题的第一步都是同一个动作:file。这一步花不了十秒钟,但信息量极大。
$ file crackme0 crackme0: ELF 64-bit LSB executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, not stripped一行输出,至少能读出四件事:
ELF 64-bit:这是 Linux 下的 ELF 格式,不是 Windows 的 PE,所以后面的工具链全按 Linux 思路走。x86-64:架构是 64 位,寄存器要用rdi、rsi、rax这种名字,不能套 32 位的那套eax为主的规则。dynamically linked:程序依赖动态库,说明strcmp这类函数会走 PLT,反汇编里可以一眼看到调用点。not stripped:符号表还留着,意思是main函数可以直接按名字定位,省掉一大段找入口的功夫。
如果看到stripped,也不用慌,后面第 4 节我会讲怎么在没有main符号的情况下找入口。保护属性方面,入门题一般也就开个 NX,PIE 都不一定开,但养成习惯总是好的,可以用checksec工具或者readelf -l快速扫一眼。
2.2 strings 先把纸面上的线索翻出来
接下来是最有“捡漏”感的一步:strings。它会把二进制里所有连续可打印字符串打出来,很多入门题在这一步直接白给。
$ strings crackme0 /lib64/ld-linux-x86-64.so.2 ... Please input your password: Congratulation! The flag is: Wrong, try again.从这些字符串我们能锁定三件事:程序会用Please input your password:提示输入;成功和失败两条分支都有对应输出;正确答案本身并没有以连续字符串的形式暴露在文件里。如果它直接出现在 strings 输出里,这题基本就是送分的,直接./crackme0输进去就能拿 flag。
我们这个例子里,答案没直接露出来,原因很可能是作者把关键字符串拆成了单个字符或者一段一段存放,用来给新手设一个小绊子。这正是需要动分析手段的情况。
需要注意的是,strings只提取“连续可打印字符序列”,如果目标字符串被 XOR 编码过、或者按字节拆开存放,它就看不见。所以你应当把 strings 当成情报收集工具,而不是唯一答案来源。
2.3 反汇编定位核心比较逻辑
在静态阶段,真正硬核的动作是反汇编。先用objdump -d把main的片段翻出来:
$ objdump -d crackme0 | grep -A 30 "<main>:"看到的节选大致长这样(我做了简化,方便阅读):
lea rdi,[rip+0x2a7a] # "Please input your password:" call printf@plt lea rsi,[rbp-0x40] # 输入缓冲区 lea rdi,[rip+0x29c2] # "%s" call __isoc99_scanf@plt ... lea rsi,[rip+0x29d5] # 指向某个全局区域 lea rdi,[rbp-0x40] # 刚才的输入 call strcmp@plt test eax, eax jne 0x4012ab # 不相等跳到失败 lea rdi,[rip+0x29b4] # "Congratulation!" jmp 0x4012b7读这段汇编不需要懂太多,抓住两个重点:
第一,scanf之后,程序把输入缓冲区地址放进了rdi,把另一个全局地址放进了rsi,然后调用strcmp。在 x86-64 的 System V 调用约定里,函数参数从左到右依次放在rdi、rsi、rdx,所以这一行strcmp的意图就是“拿我的输入和某个固定值做比较”。
第二,test eax, eax配合jne是经典的判断套路。strcmp返回 0 表示相等,test指令检查结果是否为 0,jne(不相等则跳)就会在返回值非 0 时跳到失败分支。换句话说,只要让这个jne不跳,程序就会走进成功分支。
看到这里,题眼已经定位了:那个被放在rsi里的全局地址,就是我们要找的答案所在。
2.4 静态阶段的收尾工作
因为 strings 没直接显示答案,我们得去对应的内存区域把内容扒出来。用objdump的-s参数直接 dump 数据段:
$ objdump -s -j .data crackme0假设看到这样一行:
403050 53 33 63 72 33 74 5f 4b 33 79 21 00 00 00 00 00 S3cr3t_K3y!.......右边的 ASCII 列已经把答案写出来了:S3cr3t_K3y!。把十六进制字节挨个翻译成字符,结果完全一致。这个动作本质上就是在“读”程序内部存的那个固定值。
静态分析到这里,题目其实已经解完了。你可以直接运行程序,输入这串字符,大概率会看到恭喜信息。但我还是建议继续做动态调试,原因很简单:静态分析给的是“纸面证据”,动态调试给的是“运行时的实锤”,而且真实比赛里很多题单靠静态看不出来,比如运行时会解码、会动态拼接字符串。所以下一节我们把 gdb 用起来,验证一下刚才的判断。
3. 动态调试:让程序亲口把答案说出来
3.1 静态之后为什么还要动态
有人可能会想:静态都解出来了,还费劲调什么?我一开始也这么觉得,直到遇到反调试和自解密题目才明白,静态分析经常建立在对代码的“推测”上,而动态调试能看到程序真实执行时的状态。
具体到字符串比对题,动态调试能帮你解决三个实际问题:验证你静态推断的答案对不对;当答案被拆成多段或者动态生成时,从内存里拿到拼接后的完整字符串;观察比较前后有没有隐藏的预处理,比如先 XOR 再比对、比之前先检查长度。
可以说,静态分析负责“缩小范围”,动态调试负责“一锤定音”。两个都要会,缺一个都会在后面的题里吃瘪。
3.2 三个 gdb 命令解决绝大多数字符串比对
打开 gdb,直接对strcmp下断点:
$ gdb ./crackme0 (gdb) break strcmp (gdb) run程序跑起来后会要求输入,随便敲几个字符比如AAAA,回车。由于动态链接的程序首次调用strcmp时会先解析到 libc 的实际实现,断点会在比较真正发生的那一刻命中。
这里有个细节:break strcmp断的是所有strcmp调用,如果程序内部在别处也调用了strcmp,你可能会先断到不相关的位置。处理办法很简单,多按几次continue,直到停在我们的比较上。判断依据是看参数里有没有出现你刚输入的内容。
如果想更精准,可以先用静态分析里找到的地址下断点。比如从objdump里查到call strcmp@plt这条指令的地址是0x401230,就执行:
(gdb) break *0x401230这样只会在这个调用点停下,不担心误入其他strcmp。
3.3 从寄存器里把答案“读”出来
断在strcmp入口时,rdi和rsi正好是函数的前两个参数,也就是将要比较的两个字符串。用x/s命令按字符串模式查看内存:
(gdb) x/s $rdi 0x7ffffffde70: "AAAA" (gdb) x/s $rsi 0x403050: "S3cr3t_K3y!"第一行是我们的输入AAAA,第二行就是程序拿来比对的固定值S3cr3t_K3y!。答案直接写在眼前,比任何静态分析都直观。
有人会问:为什么rsi才是答案而不是rdi?这取决于源码里调用顺序。例子里程序是strcmp(buf, secret),按调用约定buf进rdi、secret进rsi。如果作者闲得没事写成strcmp(secret, buf),那答案就跑rdi里了。所以别死记“第二个参数是答案”,一定要结合反汇编里参数是怎么放的来判断。
确认完参数后,我们还可以看看返回值,验证一下“不相等返回非 0”的语义:
(gdb) finish Run till exit from #0 __strcmp_sse2 ... (gdb) p $eax $1 = -120-120是非 0 值,对应的正是test eax, eax; jne跳向失败分支。现在退出 gdb,重新运行程序,输入S3cr3t_K3y!,就能看到成功输出。到这里,整个“静态定位 + 动态验证”的闭环就完整了。
3.4 更快的旁门:ltrace 一句话出答案
如果说 gdb 是正规军,那ltrace就是侦察兵,出手极快。它的作用是拦截程序对动态库函数的调用,并把字符串参数直接打印出来:
$ ltrace ./crackme0 ... strcmp("AAAA", "S3cr3t_K3y!") = -120输入一个错误字符串,ltrace会把strcmp的两个参数原样打出来,答案S3cr3t_K3y!直接暴露。整个过程连反汇编都不用做,几秒钟结束。
这条路线对“输入→字符串比对”的题目几乎是绝杀,连调用顺序都不用管,因为它把两个参数都打印了。不过别高兴太早,ltrace只对动态链接的库函数有效,如果程序自己写了个循环比对逻辑,或者用了静态链接,ltrace就看不到任何有价值的东西,这时候还是得老老实实回 gdb。
我的习惯是:看到疑似字符串比对的题,先花十秒跑一次ltrace,没结果再上静态分析。这个顺序帮我省过不少时间。
3.5 一条暴力路线:直接 patch 掉比较分支
有些场合你的目标只是拿到 flag,不在乎“正常通过验证”,那就可以用更粗暴的手段:修改比较分支,让它无论输入什么都走进成功分支。
原理很简单,我们发现跳转逻辑是test eax, eax; jne fail,其中jne的机器码通常是75 xx。只要把75改成74,指令就变成了je fail,语义反了过来:只有相等才跳失败,不相等反而走进成功分支。更彻底的做法是用90(NOP)把jne指令直接抹掉,让流程无视比较结果直接往下走。
具体操作可以用objdump找到那条jne指令的文件偏移,然后用二进制编辑工具改写对应字节。这个动作在 CTF 里叫“patch 二进制”,是合法且有价值的练习,你是在处理主办方明示授权的赛题。但我要补一句很关键的话:这套技术只应用在你拥有权限的目标上,比如 CTF 赛题、自己写的程序;拿去处理别人没有授权的商业软件就是另一回事了,那是底线问题。
4. 常见问题与排查技巧实录
4.1 典型问题速查表
我整理了一张对照表,基本上覆盖了字符串比对入门题里最容易踩的坑。
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
file显示不是 ELF,或工具直接报格式错 | 可能是 Windows PE、ARM/MIPS 等其他平台 | 确认目标平台,Windows 用对应工具链,ARM 用qemu模拟运行 |
找不到main符号 | 程序被strip过 | gdb 里info functions找入口,或在 Ghidra 里从字符串交叉引用反推 |
| 每次断点地址都不一样 | 开启 PIE 随机化 | gdb 里执行set disable-randomization on,或用符号断点而不是绝对地址 |
打断strcmp却一直断不到目标位置 | 库里内部也有strcmp调用 | 用 objdump 找到具体call地址,下break *0x... |
| 输入正确答案还是失败 | 比较前有长度检查、前缀拼接或编码 | 先用任意输入跑一遍,观察是否真的走到了strcmp;检查比较前有没有strlen或 XOR 循环 |
| 答案在 strings 里不完整 | 字符串被拆段或编码 | 用objdump -s看原始字节;动态调试时看rsi指向的完整内存 |
| 文本正常但比较失败 | 字符串可能是宽字符,中间夹0x00 | 别用x/s,改用x/40bx看十六进制原始字节 |
4.2 三个实战排查思路
场景一:秘密被拆成多段存储。strings 只看到一半,比如只有S3cr,后面接的内容在另一个数据区里。这种题静态也能解,但效率低。更好的办法是直接断在strcmp处,x/s $rsi会把运行时拼接好的完整字符串显示出来。动态调试在这种场景下明显优于静态。
场景二:长度校验先行。有的题会先比较输入长度,长度不对直接返回失败,根本不会调用strcmp。表现是:你输入任何字符串,gdb 里打断点却等不到比较发生。排查思路是先找strlen调用,或者找反汇编里有没有立即数比较,比如cmp $0x9, eax; jne。长度对上之后,strcmp才会出现。
场景三:PIE 随机化导致手工算地址失败。如果你习惯用 objdump 拿到偏移,再在 gdb 里用“基址 + 偏移”手工算断点地址,遇到 PIE 会非常痛苦,因为每次运行基址都变。最省心的做法是set disable-randomization on关闭随机化,或者干脆用符号断点,让 gdb 自己去处理符号解析,人类少算一步就少一个出错机会。
4.3 练习方式与边界
想练这类题目,现在渠道很多,不少 CTF 训练平台和学校的内部靶场都会长期挂 CrackMe 存档,还有专门的逆向练习仓库,把几千道题按难度排好,从签到题一路到加壳混淆。我的建议是精做二十个入门字符串比对题,再往上走,别贪多。每一个题都坚持走完“file → strings → 反汇编 → gdb 验证”这条完整流程,速度自然就上来了。
关于边界我多啰嗦一句:分析 CTF 赛题、分析你拥有授权的样本,这是正当的安全研究和学习;但同样的技术拿去破解没有授权的商业软件、绕过别人的收费验证,性质完全不同。逆向这个技能本身是中性的,用在哪里决定了它的价值。入门阶段养成只在授权范围内动手的习惯,对你将来的安全从业之路只有好处。
5. 复盘与一个实用小习惯
最后说说我个人复盘这套流程时的体会。字符串比对类 CrackMe 的最佳套路其实是固定的:先用file和strings摸清底细,再用反汇编找到那个strcmp,最后用 gdb 或ltrace把比对双方拉到眼前。整个过程从“猜”变成“读”,从“黑盒”变成“白盒”。
我刚开始接触这类题时,最常犯的错误有三个:拿到二进制就急着运行,不看file输出;看到strcmp但没去确认两个参数的顺序;用 gdb 打断点时傻等,不知道怎么用continue绕过库内部的无用调用。这几个坑我都踩过,所以特意在第 3 节和第 4 节写得很细,希望能帮你少走几步冤枉路。
如果你想把这一套练得更扎实,我建议你做一个逆向工程里特别有效的动作:拿最简单的 C 源码,自己编译一个 CrackMe,故意把答案拆成几段存,再故意开一次 PIE。编译完自己尝试用今天这套流程去解,你会发现“出题人视角”和“解题人视角”一旦切换过,再看到陌生二进制时,思路会清晰很多。逆向这东西,练的是手感和路径,今天这篇文章就是给你铺的第一段路。