SWPUCTF 2021 新生赛的 classic 题目 easy_md5,我到现在还记得当时群里一帮人对着它折腾到凌晨。原因很简单,这题名字里挂着“easy”,实际上一进来就给你个 PHP 文件源码,里面就一行核心判断:md5($_GET['a']) == md5($_GET['b'])。你要是真以为是考 MD5 加密,去算哈希找碰撞,那基本就走进死胡同了。CTF 入门阶段最容易翻车的点就在这:你以为它考密码学,其实它考的是 PHP 语言的“性格”。这道题适合所有刚入坑 Web 方向、开始接触代码审计和 PHP 弱类型比较的新手,也适合想系统梳理 MD5 绕过家族的选手当作复习提纲。本文就围绕这道题,把弱比较、数组绕过、0e 魔法串、强比较破局这些套路全部拆干净,顺手给你一份可以直接照着抄的实操记录和避坑清单。
1. 题目拆解:easy_md5 到底在考什么
1.1 从题目名字看命题人的心思
新生赛的名字一般都很“诚实”,easy_md5 说它考 MD5 真没骗你,但考的不是 MD5 的算法细节,而是 MD5 在 PHP 里被拿来比较时产生的那些奇奇怪怪的行为。这类题目的标准“长相”是这样:页面上有高亮源码,你往里传两个参数a和b,后端核心逻辑类似下面这样:
<?php error_reporting(0); include "flag.php"; highlight_file(__FILE__); if (isset($_GET['a']) && isset($_GET['b'])) { if ($_GET['a'] != $_GET['b'] && md5($_GET['a']) == md5($_GET['b'])) { echo $flag; } } ?>这里有两个条件:一是$a和$b的值本身不相等,二是它们的 MD5 结果用==比较时相等。如果你没有意识到==和===的区别,脑子里第一个方案就是找 MD5 碰撞。但真正的 MD5 碰撞需要构造特定二进制内容,在 URL 参数里可不好传——再说了,题目名叫“easy”,命题人想要的显然不是那种硬核解法。
所以这道题真正的考点就两条线:PHP 弱类型比较和PHP 对数组参数的异常处理。两个思路都能独立击穿这个判断逻辑,它俩的共同点是“不碰 MD5 算法本身”,而是利用 PHP -engine 层面的设计疏漏。
1.2 PHP 的==和===:看起来像,骨子里完全不同
很多新手写过 PHP 但没认真想过==的规则。==是松散比较,比较前会做类型转换;===是严格比较,类型和值都得一致。问题就出在那个“自动类型转换”上。
用一个生活化的类比:==就像你问两个人“你们工资一样吗”,只要数值对齐就算一样,哪怕一个人领的是人民币一个人领的是美元;===则是“你俩不仅数字一样,币种还得一样”。PHP 的==会尝试把两侧变量转成同一种类型再比,于是出现了很多反直觉的案例,比如"0e123" == "0e456"这样的字符串比较时,PHP 会把它们都按科学计数法解释成 0,于是两个完全不同的字符串就被判定为相等。
这正是 CTF 里大名鼎鼎的“0e 绕过”。MD5 值是 16 进制字符串,如果某个字符串的 MD5 长成0e...开头(后面全是数字),PHP 在==比较时会把它当浮点数 0 处理。两个不同的字符串,各自的 MD5 都以0e开头时,md5($a) == md5($b)就变成0 == 0,直接成立。
网上一搜能查到一大堆这类“魔法字符串”,比如最经典的QNKCDZO,它的 MD5 是0e830400451993494058024219903391,正好满足条件;s878926199a的 MD5 是0e545993274517709034328855841020,也是经典选手。这类字符串不需要两个都存在,只要其中一个以 0e 开头,和另一个同类的组合就能绕过。
1.3 MD5 拿到数组时的“罢工”行为
另一条路线更无赖:直接给a和b传数组。PHP 里md5()这个函数要求参数是字符串,你给个数组,它会直接报错并返回NULL,这时候关键点来了:
md5(array()) // PHP 7.x 下返回 NULL NULL == NULL // 成立所以只要md5($_GET['a']) == md5($_GET['b'])两边都是NULL,比较自然成立。同时$_GET['a'] != $_GET['b']因为一个是数组一个是不同内容的数组,也成立。几个条件全满足,flag 就到手了。
我为啥一直强调 PHP 7.x?因为 PHP 8 对数组传给标量函数的行为改了,直接抛TypeError,整个脚本直接崩,不再返回NULL。CTF 赛题为了兼容经典解法,通常跑 PHP 5 或 PHP 7,但如果比赛平台环境比较新,你会发现数组大法“失灵”了,这就是后面要专门聊的坑。
2. 绕过原理:一句弱比较为什么能被玩出花
2.1 0e 魔法串:科学计数法下的一堆“零”
先看第一种解法的完整原理。PHP 的字符串比较有个 hidden 规则:如果两个字符串都能被当作数字解释——比如一个是"0e123",一个是"0e456"——就会先转成数字再比较。"0e123"的含义是0 乘以 10 的 123 次方,结果还是 0。"0e456"一样是 0。于是两个完全不同的字符串被认定相等。
在 MD5 场景里,我们要的是某个字符串哈希后恰好以0e开头、且第 3 位之后全部是数字。因为 MD5 输出是 32 位十六进制字符,0e后面如果出现a~f就坏事了——那就不再是“纯数字科学计数法”了。想让某个字符串的哈希撞上这种形态,靠手算不现实,网上流传的常用串直接用就行。
我自己整理过一份常见的可直接用于 CTF 的 0e 串列表,这类内容在很多 writeup 里都能看到,建议收藏一份:
| 原始字符串 | MD5 值 |
|---|---|
QNKCDZO | 0e830400451993494058024219903391 |
s878926199a | 0e545993274517709034328855841020 |
s155964671a | 0e342768416822451524974117254469 |
240610708 | 0e462097431906509019562988736854 |
s214587387a | 0e848240448830537924465865611904 |
构造请求时,任选两个不同的串分别塞给a和b,比如:
/?a=QNKCDZO&b=s878926199a页面直接返回 flag。整个过程完全不依赖算法碰撞,是纯“类型特性”解法。
这里有个容易被忽略的细节:0e绕过能否成功,取决于 PHP 是不是把两侧都当数字字符串处理。如果题目代码变成了md5($a) === md5($b)(严格比较),这套就完全失效,因为"0e..." === "0e..."对两个不同哈希来说永远是 false。这也是为什么很多题目表面上像 easy_md5,实际代码里把等号换成三个,难度直接抬升一截。
2.2 数组绕过:让函数直接“空手而归”
数组绕过的核心价值在于它不止能打==,在某些情况下连===都能打。原理很简单:PHP 7 及以前,给md5()传数组会触发 warning,但脚本不会停止,函数返回NULL。于是:
md5($_GET['a']) -> NULL md5($_GET['b']) -> NULL NULL == NULL -> true NULL === NULL -> true // 强比较也成立!这段逻辑非常有意思:很多新手认为数组绕过只能欺负弱比较,但实际上因为NULL === NULL为真,遇到强比较的题,只要 PHP 版本是 7 以下,数组法依然能打穿。前提是另一个判断条件$_GET['a'] != $_GET['b'](或者!==)也得过。如果两边都传空数组a[]=&b[]=,那$a和$b完全一样,第一个条件直接 fail;正确的姿势是传不同内容的数组:
/?a[]=1&b[]=2这时候a数组是[1],b数组是[2],它俩肯定不相等,但 md5 结果都是NULL,flag 顺利拿到。
我经常看到有人在这个细节上翻车:直接传a[]=1&b[]=1,然后发现第二个条件过了第一个条件不过。记住,数组内容不同才是正确姿势。如果你不确定目标 PHP 版本,0e 串在某些环境里可能因为php.ini的md5()行为变更失效,数组法在高版本下又直接崩,所以实战里最好两种都试,哪个响应不报错就用哪个。
2.3 两种解法怎么选:从题目特征反推环境
比赛现场没有“试错成本”的概念,你可以同时验两套 payload。我的经验是先把数组法丢上去,如果页面白屏或者 500,八成是 PHP 8+ 环境,立刻转 0e;如果页面正常返回 flag,那就是 PHP 7 老环境,数组法更稳。0e 串的好处是不挑 PHP 版本(5、7、8 都能跑,==的字符串转数字逻辑一直在),坏处是一旦目标代码用了===,它立刻失效;数组法在 PHP 7 下通吃两种比较,但在 PHP 8 下直接死。两套互为补充,组合使用覆盖绝大多数题面。
3. 完整通关实录:从读题到拿到 flag
3.1 第一步:打开页面先看源码
SWPUCTF 这类新生赛题目通常开着highlight_file(__FILE__),也就是源码直接高亮显示在页面上。你不需要任何扫描器,打开题目页就等于拿到答案纸。第一步要做的就是逐行看条件,别急着传参。我见过太多人上来就admin、password一顿乱试,这种习惯在 CTF 里非常浪费时间。源码里写着include "flag.php",flag 就在服务器上,你只需要让那个 if 判断通过,所以目标就一个字:让两根参数满足“值不同但哈希值相等”。
如果页面没有直接显示源码,右键查看源代码也不会看到 PHP 代码(它已经被执行了)。这种情况通常需要结合备份文件、源码泄露或参数注入去读文件,但 easy_md5 不会搞这么复杂,题目敢叫 easy,就说明入口就是高亮源码。
3.2 第二步:构造 Payload,多套方案轮着试
明确了目标以后,直接构造请求。最省事的方式是浏览器地址栏,但为了方便记录和后续修改,我更推荐用 curl 或 Burp Suite。比如用 curl:
curl "http://目标地址/?a=QNKCDZO&b=s878926199a"如果拿到 flag,题目结束。如果返回空白或报错,换数组套路:
curl "http://目标地址/?a[]=1&b[]=2"这里面有一个动作很多人会忽略:观察响应的差异。页面返回空白不等于失败,有的题目把 flag 藏在响应头里,有的藏在注释里,有的是直接输出但被前端样式隐藏了。所以建议用 curl 的时候加个-v看完整响应,或者直接看 Burp 的 Raw 报文。我习惯把每次请求保存到历史里,方便对比到底哪一步开始出现异常。
3.3 第三步:验证输出,顺手记下判断依据
拿到 flag 之后别急着关页面。作为新手,建议做一次“逆向复盘”:把?a=QNKCDZO&b=QNKCDZO这种相同参数试一下,你会发现第一个条件$a != $b不满足,输出为空,这能帮你反向验证代码逻辑;再试?a[]=1&b[]=2,体会数组报错和 NULL 相等的过程。这些动作看着多余,但对建立“漏洞触发链路”的直觉特别有帮助。
我接手的很多入门学员就是卡在“看 writeup 全会,自己动手全废”。核心原因就是只看结论不验证过程。你在本地搭个 PHP 7 环境,把题面代码跑一遍,把两种 payload 分别在 PHP 7 和 PHP 8 下试一次,十分钟就能彻底吃透这个知识点的所有边界条件。本地环境用php -S 127.0.0.1:8080起个服务,配合浏览器改参数,比在线平台更容易观察 warning 和错误日志。
4. 常见问题与排查技巧实录
4.1 0e 串打不动?先看等号和 PHP 版本
遇到过不少读者反馈:“QNKCDZO 和 s878926199a 都试了,flag 就是不出来。”这种情况最可能的原因有三个。第一个是题面代码用了===而不是==,0e 绕过直接失效,得换思路;第二个是 PHP 8 环境对字符串和数字比较的行为做了调整,部分弱比较场景下0e串不再被当成 0;第三个是目标可能对参数值做了过滤,比如检测QNKCDZO这种特征字符串。
排查方法很简单:看标题或者题目描述里有没有“PHP 8”之类的提示;看报错类型,如果是TypeError,妥妥高版本;如果题目源码里明确写着===,那就赶紧放弃 0e 改数组或真碰撞。如果三者都不是,还有个大概率原因——参数名不是a和b。很多题目会把参数名改成x、y或者v1、v2,你拿着通用 payload 直接打当然没效果。务必以源码为准。
4.2 数组传参报 500?多半是 PHP 8 在制裁
PHP 8.0 之后,内部函数对参数类型要求更严格,md5([])不再是返回 NULL,而是直接抛TypeError,程序中断,于是你看到 500 或者一片空白。这个问题在新生赛旧题里不常见,但在一些新平台或者复现老题目的自定义环境里,还真能碰到。
遇到这种情况,先切 0e 串;如果目标又是强比较,那就只能走真正的 MD5 碰撞了。好在 CTF 题面一般不会把难度拉这么高,真遇到强比较的新生赛,通常还有别的线索——比如题目本身给了某种限制让你可以控制$a和$b为对象,利用__toString等魔术方法绕过去。对于 easy_md5 来说,不会走到那一步,但如果 PHP 8 频繁出现,建议本地直接装个 PHP 7.4 用 Docker 复现,省时省力。
4.3 参数被 WAF 或过滤拦了怎么办
有些平台会对请求做简单过滤,看到[]就直接拦。这种情况优先尝试 URL 编码:a%5B%5D=1&b%5B%5D=2。PHP 解析后依然能得到数组。如果过滤的是某些特定字符串(比如QNKCDZO),可以试大小写变形或者换一组 0e 串,比如s155964671a这种冷门一些的。再不行,用 Burp 的 Intruder 批量拉一个 0e 串字典跑一轮,几千条常见串几十秒就能过完。
但注意,乱跑字典前先确认题目环境能接受高频请求。有些线上靶场对单 IP 有速率限制,扫太快会被封。我一般先手工验证两三个 payload,确定方向没错再上工具,上来就全量爆破是新手最爱的错误姿势。
4.4 问题速查表:遇到情况直接对照
| 现象 | 可能原因 | 解法 |
|---|---|---|
| 0e 串无效 | 代码用的是=== | 改用数组法或真碰撞 |
| 数组法报 500/TypeError | PHP 8+ 环境 | 改用 0e 串 |
| 传空数组不弹 flag | $a == $b导致第一个条件失败 | 传不同内容:a[]=1&b[]=2 |
| 页面一直空白 | 参数名与源码不一致 | 看源码确认a、b实际叫什么 |
| flag 有输出但被截断 | 页面布局遮挡/编码问题 | curl 查看原始响应,检查响应头 |
报错里出现syntax error | 手滑把[]写错位置 | 确认a[]=1中无多余空格 |
5. 举一反三:从这道题看懂整个 MD5 考点家族
5.1 变体一:这里是强比较的“===”怎么办
如果把 easy_md5 的条件改成:
if ($_GET['a'] !== $_GET['b'] && md5($_GET['a']) === md5($_GET['b']))这就是更高级的“强比较 MD5”题。PHP 7 环境下数组法依然能打穿,因为NULL === NULL成立;但 PHP 8 下就得用真正的 MD5 碰撞。你可能听过fastcoll这个工具,它能生成两个内容不同但 MD5 完全相同的二进制文件,然后把内容传给参数即可。注意,二进制碰撞串在 URL 里直接传会乱码,通常需要配合POST表单或者写脚本用 requests 发。这也是为什么碰到强比较别急着关,先去试数组,数组失效再考虑碰撞,难度阶梯就这么一路往上爬。
顺带提一个常见套路:强比较 +is_string($a)限制。如果代码先检查参数必须是字符串,数组法直接出局,只能碰撞。这时候可以关注题目有没有“文件包含”入口,把碰撞二进制写进文件再读出来,属于进阶玩法了。
5.2 变体二:嵌套的md5(md5($a))怎么处理
有些题会把比较写成md5(md5($a)) == md5(md5($b)),以为多套一层就安全了。实际上数组法依然适用:md5(md5([1])),内层md5([1])返回 NULL,外层md5(NULL)实际上是md5(""),两边结果一样,比较照样成立。这类嵌套题验证起来特别快,你把a[]=1&b[]=2丢上去通常直接过。
0e 串在嵌套场景下就不太好使了,因为你得找一个串,让它的 MD5 值再 MD5 一次后依然以 0e 开头,这种“二次 0e”字符串比较稀有,网上有跑好的字典,但没必要背。所以遇到嵌套,第一优先级永远是数组法。
5.3 从题到知识体系:MD5 考点还能怎么考
easy_md5 本质上是 PHP 代码审计里的一个“点”,但它串联出来的知识面非常典型。以 MD5 为核心,整个考点家族包括:普通弱比较(0e 串)、数组返回 NULL、强比较(真碰撞)、魔术方法触发(__toString里调 md5)、变量覆盖导致$a值可控、反序列化配合 md5 校验等。每一类都能在 SWPUCTF 和各类 CTF 练习平台找到对应题目。
如果你想系统练这块,我建议按这个顺序刷:
- 这道 easy_md5,把弱比较和数组基础打牢;
- 再找一道强比较的题,比如用
===的变体,逼自己研究fastcoll和 requests 脚本; - 找一道变量覆盖或参数覆盖的题,理解
extract($_GET)这种函数如何破坏判断逻辑; - 最后去刷反序列化相关的 md5 利用,把魔术方法串联进来。
至于工具,平时做题建议备上“随波逐流 CTF 编码工具”这类合集、本地 PHP 环境、Burp Suite 和一个能写脚本的 Python 环境。MD5 校验工具也别落下,不只是做题,平时下载文件验证完整性也会用到。练习平台方面,除了 SWPUCTF 对应的随波逐流社区,还有 buuoj 等热门平台都有大量同款题目。ctfshow 上也能搜到衍生题,适合批量刷。
最后分享一个我的个人习惯:每次做这类“看似简单”的 Web 题,我会特意把 payload 的所有变体都跑一遍,包括失败的组合。因为新手阶段最容易出问题的地方不是不知道解法,而是不知道解法为什么有效、什么时候失效。你把==和===在 PHP 7、PHP 8 下的行为差异亲手验证一遍,把 0e 串和数组法各自的应用边界摸清楚,以后再遇到任何 MD5 相关的题目,你都不会再被“easy”两个字欺骗。这个知识点的性价比在 CTF Web 入门阶段数一数二,花两个小时彻底搞懂,能帮你省下以后无数个凌晨。