最近在bugku刷web方向的题,刷到一道叫“前女友”的关卡时愣了一下。名字起得挺有个性,点进去才发现是一道相当经典的题目,核心考点是PHP代码审计里的弱类型比较漏洞,拿flag的关键就藏在几行看似人畜无害的判断逻辑里。这篇文章我会把这道题从源码分析、漏洞原理、payload构造到延伸思考完整拆一遍,适合刚入坑CTF Web的新手,也适合想系统梳理PHP弱类型绕过思路的老手。
当时我登录进题目页面,没有输入框、没有上传点,连个表单都没有,白底黑字直接甩了几行PHP代码过来。这种形态在CTF里属于典型的基础源码审计题,Web方向做得多的朋友应该很熟悉。这篇文章不光是讲题,我更想把这些年做代码审计总结出来的思考方式也带进去,让看完的朋友再碰到类似题目时,能有一个清晰的分析路径。
1. 初识“前女友”:一段带情绪的PHP源码
1.1 打开题目的第一眼
访问题目给出的地址,页面很简单,有几行PHP代码,没有多余的交互接口。我第一次看到的时候第一反应是找输入点,但找了一圈发现根本没有form表单,也没有上传点,唯一的可能性就是通过URL参数直接交互。
这里有个很实用的经验:如果页面输出的是源码,不要急着瞎试,先静下心把源码读懂。很多新手一看到代码就开始到处乱传参数,结果把环境搞坏了还不知道问题在哪。正确的做法是先还原题目的完整逻辑,确认每个变量、每个判断、每个输出点在哪。
当时的页面还原一下大概是这样的感觉:
php复制<?php
error_reporting(0);
require_once("flag.php");
if (isset($_GET['a']) && isset($_GET['b'])) {
$a = $_GET['a'];
$b = $_GET['b'];
if ($a != $b && md5($a) == md5($b)) {
echo $flag;
} else {
echo "不对不对";
}
} else {
echo "请输入a和b";
}
?>
不同的复现版本可能参数名会有差异,有的平台可能是v1和v2,但核心判断逻辑基本就是这个样子。这就是标准的“代码审计”玩法——题目把源码给你,你得从一个看似正常的逻辑里,找到能绕过它的方法。
1.2 三个关键判断的逐个分析
把源码拆开来看,进入flag输出分支要同时满足两个条件:
一是$a != $b,也就是两个参数的内容不能相等。这个条件很好理解,题目就是想让你构造两个“不同的值”。
二是md5($a) == md5($b),也就是两个参数分别取MD5之后要相等。这里用的是双等号==,而不是PHP里的全等号===。
很多人看到这个代码可能会觉得矛盾:两个值不同,MD5怎么可能相同?除非是做哈希碰撞,但MD5碰撞听起来好像是个需要大量计算的事。
实际上这就是题目的巧妙之处。把两个条件放在一起,利用的就是PHP弱类型比较的漏洞。$a != $b保证字符串本身不同,但md5($a) == md5($b)在PHP的类型转换机制下,可以让两个完全不同的字符串,因为MD5值都长成某个特殊形式而变得“相等”。
1.3 定位漏洞:双等号带来的突破点
做代码审计有一个很重要的习惯:看到比较运算符,先分清是==还是===。这一步往往决定了你能不能往下走。
在这个题目里,==就是整道题的关键突破口。PHP里的==在比较时会先做类型转换,如果两边是字符串,且字符串符合某种数字格式,它就可能被转换成数字后再比较。这意味着,表面上“两个MD5值相等”这个要求,实际上会被PHP用自己的规则重新解释一遍。
这就像一个面试官嘴上说“我们只要完全一样的人”,实际上他看到两个人都戴着同一个工牌就放行了,根本不管长相和身份是不是一样。==就是那个看工牌就放行的保安,===才会认真核对身份证。
定位到这个点,解题方向就清晰了:我需要找到两个不同的字符串,让它们的MD5值经过PHP的==比较后被认为是“相等的”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心考点拆解:PHP弱类型比较与0e魔术哈希
2.1 “双等号”背后的类型转换机制
要把这道题彻底吃透,必须理解PHP的弱类型比较到底在做什么。PHP是一门动态类型语言,变量的类型不是固定的,==在比较时会尝试进行类型转换。
关键在于字符串转数字的规则:当一个字符串以数字开头,或者在上下文中被当作数字处理时,PHP会尝试把它解析成数字。
这里有个特别有意思的点:"0e123"这样的字符串,在PHP里参与数值比较时,会被解析成什么呢?答案是0。因为0e123是科学计数法的写法,表示0乘以10的123次方,结果还是0。
那这跟MD5有什么关系?关系大了。MD5函数的输出是一个32位的十六进制字符串,正常情况是字母+数字的混合。但有一种特殊情况:某些字符串的MD5值恰好以0e开头,后面跟的全是数字,比如0e830400451993494058024219903391这种样子。
在PHP的==比较中,0e830400451993494058024219903391会被当作科学计数法数字,转换成0。两个这样的字符串比较,结果就是0 == 0,为真。
这就是“魔术哈希”也叫Magic Hash的由来。两道不同的字符串,经过MD5之后都是0e开头的一串数字,用==一比较,全被当成0,自然就“相等”了。
2.2 常见的0e开头的MD5值
这类字符串在CTF圈子里几乎是常识级别的存在。我列几个最常用、最经典的:
| 原始字符串 | MD5值 |
|---|---|
QNKCDZO |
0e830400451993494058024219903391 |
s878926199a |
0e545993274517709034328855841020 |
s155964671a |
0e342768416822977524858901752587 |
s214587387a |
0e848240448830537924465865611904 |
s1502113478a |
0e861580163291561247404381396064 |
s1885207154a |
0e509367213418206700842008763329 |
这些字符串的来源已经找不到了,但它们是很多年前就有人通过脚本批量跑出来的结果,在CTF题里反复出现。选择其中任意两个不同的字符串,用==比较它们的MD5值,都会得到true。
有意思的是,不只是MD5有这个问题。只要某个哈希函数的结果能构造出0e加纯数字的形式,在PHP弱比较下都可能被利用。比如sha1也有类似的情况,只不过这道题用的是md5。
2.3 为什么强比较就绕不过去了
如果把源码里的==换成===,情况就完全不一样了。===要求两边的值和类型都完全相同,0e830400451993494058024219903391和0e342768416822977524858901752587虽然数值上都等于0,但它们的字符串内容不同,类型虽然都是字符串,但内容不同,所以===会直接判定为不相等。
所以在实际刷题过程中,遇到MD5相关题目,第一件事就是确认用的是==还是===。这决定了你的攻击思路:弱比较走0e字符串,强比较只能走数组绕过或者真正的MD5碰撞。
这道题用的就是弱比较,思路就非常直接了。
3. 解题实操:从参数确认到提交Payload拿Flag
3.1 先用简单参数确认接口
正式构造payload之前,我一般会先探一下接口。直接在浏览器地址栏访问:
code复制http://目标地址/?a=1&b=2
如果页面返回“不对不对”或者类似的提示,说明参数名a和b是对的,只是校验没通过。如果返回“请输入a和b”,那说明参数名可能不对,需要换v1、v2等常见命名再试。
这个探测步骤虽然简单,但能帮你快速确认参数名,避免后面所有精力都花在错误的方向上。尤其是有些题目会做URL解码、二次编码之类的处理,提前确认接口状态很重要。
3.2 构造第一组有效Payload
确认参数名后,把URL改成两个经典的0e字符串:
code复制http://目标地址/?a=s878926199a&b=s155964671a
这时候再看页面输出,flag就出来了。
我当时第一次跑通这个payload的时候,确实有一种“原来如此”的感觉。两个看起来毫无关系的字符串,因为MD5值都是0e开头,在PHP的眼里居然被判定为相等,直接骗过了校验逻辑。
如果不想用s878926199a和s155964671a,换成QNKCDZO和s214587387a也一样能过。只要这两个字符串不相同,且MD5值都是0e开头即可。
3.3 多一种提交方式:用工具构造请求
如果你手边有Burp Suite或者HackBar,也可以用工具来提交。以Burp Suite为例,先随便发一个带参数的请求,然后在Repeater里修改参数值:
code复制GET /?a=QNKCDZO&b=s155964671a HTTP/1.1
Host: 目标地址
发送之后直接看响应体,flag同样会出现在页面里。用工具的优点是能看到完整的请求和响应,方便后续调试。
不过这道题本身没有过WAF或者IP限制,直接用浏览器地址栏输入也可以,工具不是必须的。但养成用Burp Suite的习惯对后面做更复杂的Web题很有帮助。
3.4 拿到Flag之后的确认
拿到flag后,把它填到bugku平台的对应空格里,这道题的分数就到手了。整个过程如果熟练的话,一分钟就能搞定。
但我想说的是,刷题的目的不应该只是“拿分”和“过关”。这道题虽然简单,但它背后牵出的PHP弱类型知识体系,在今天很多真实业务系统里依然存在。理解了原理,你在以后做渗透测试或代码审计时,看到if(md5($a) == md5($b))这样的代码,一眼就能判断出这是可利用的点,这才是真正的收获。
4. 换个思路:PHP数组绕过又是怎么回事
4.1 md5()遇到数组时的异常行为
这道题用0e字符串就能解决,但很多writeup还会提到另一种绕过方式:数组绕过。这里也展开讲一下,因为它在很多变形题里会出现。
PHP的md5()函数在PHP 8以下版本中,接收数组类型的参数时会发生一个特殊行为:函数无法处理数组,会返回NULL,同时抛出一个warning警告。虽然页面上会显示警告信息,但函数本身已经执行完了,结果是NULL。
于是就会出现这种组合:
$a是数组[1],$b是数组[2]md5($a)返回NULL,md5($b)返回NULLNULL == NULL,结果为true$a != $b,两个数组内容不同,也为true
四个条件全部满足,成功绕过。
4.2 数组绕过的具体构造
在地址栏里这样传:
code复制http://目标地址/?a[]=1&b[]=2
很多情况下同样能拿到flag。注意PHP会把a[]=1解析成一个数组[1],b[]=2解析成[2]。
这个方法的适用性比0e字符串更广。因为0e字符串只对==有效,而数组绕过在面对===时也能发挥作用。毕竟NULL === NULL,类型和值都相等。
但这里有个大坑:如果你的测试环境是PHP 8或更高版本,md5()接收数组时会直接报TypeError错误,导致整个脚本终止。所以在本地测试时,先确认一下PHP版本。这也是为什么很多早期的CTF题在现在的环境里“打不了”的原因——不是题过期了,是PHP版本变了。
4.3 两种绕过方式的选择逻辑
面对一道MD5校验题,我的分析流程是这样的:
先看比较符号。如果是==,优先用0e字符串,最稳定、最不容易触发异常。如果0e字符串不行,再试数组绕过。如果是===,直接上数组绕过,一般都能过。如果题目对参数值做了is_string()之类的类型检查,那数组绕过就无效了,只能考虑真正的MD5碰撞。
这道“前女友”属于最友好的==弱比较,所以用0e字符串就够了。数组绕过作为备用方案了解一下就行,但后续刷题遇到变种时会感谢这个知识储备。
5. 避坑指南:这些坑我替你先踩了
5.1 0e字符串为什么不生效
有时候在网上搜到writeup,跟着帖子的payload去打,却发现打不通。原因通常出在这几个方面:
第一,PHP版本不同。新版PHP在某些版本中对字符串转数字的规则做了调整,0e字符串不一定还会被解析成0。尤其是PHP 8之后,这个问题更明显。
第二,判断逻辑被改了。有些复现题会把==改成===,写writeup的人可能没注意到,直接复制了别人的payload,结果当然是失败的。
第三,参数名不对。前面提到的a和b在不同版本里可能是v1和v2,如果参数名不对,整个请求连第一层isset都过不去。
遇到payload不生效的时候,先把这三个点逐个排查,基本都能找到原因。不要盲目换字符串,更不要觉得是自己环境坏了。
5.2 不要把“弱比较”和“哈希碰撞”混为一谈
我见过不少新手把这道题叫做“MD5碰撞”,这个说法其实是不严谨的。
MD5碰撞指的是找到两个不同的内容,让它们的MD5值在二进制层面完全一致。这是一个计算问题,在MD5上已经被研究人员实现,但对普通做题来说成本比较高。
“0e字符串”利用的则是PHP的弱类型比较机制,它并不是真的让两个MD5值在字节上相等,而是让它们在特定上下文中被解释为相等的数值。一个是算法层面的真正碰撞,一个是语言层面的类型转换漏洞。
虽然结果都是绕过校验,但原理完全不同。在技术交流时把这两件事说清楚,不仅显得专业,更重要的是能帮助自己建立准确的知识体系。
5.3 本地验证建议:五分钟搭个测试环境
如果你在做这类题时总是不确定payload是否有效,我强烈建议你本地搭一个PHP环境来验证。
最快的办法是用Docker或者系统自带的PHP。以Ubuntu环境为例,装好PHP后,在目录里写一个test.php:
php复制<?php
$a = $_GET['a'];
$b = $_GET['b'];
var_dump($a != $b);
var_dump(md5($a) == md5($b));
?>
然后在终端启动:
bash复制php -S 127.0.0.1:8080
浏览器访问:
code复制http://127.0.0.1:8080/test.php?a=QNKCDZO&b=s155964671a
如果两个var_dump都输出bool(true),说明你的payload在这个PHP版本下是有效的。再换数组绕过试试,观察页面有什么不同。通过这种本地实验,你对漏洞的理解会比单纯看十篇writeup都更深。
6. 从“前女友”出发:一道小题背后的知识宇宙
6.1 从一个点长出一棵树
这道题名字起得像个梗,实际上承载的知识点一点都不少。围绕它的核心考点,可以延伸出很多Web安全里重要的分支:
第一个分支是PHP弱类型漏洞。除了MD5比较,还有strcmp()函数、in_array()函数、switch语句里都可能出现类型转换导致的绕过问题。这些都是代码审计中的高频考点。
第二个分支是哈希比较漏洞。从MD5到SHA1,从弱比较到真正的碰撞攻击,再到hash_equals()函数的安全用法,这是一个完整的知识链条。
第三个分支是PHP变量相关的漏洞。比如变量覆盖、数组越界、类型转换等,都是审计老手非常关注的点。
可以说,一道简单的“前女友”,串联起了PHP代码审计中好几块重要内容。这也是为什么很多CTF入门教程都会推荐从这类简单题开始刷起。
6.2 适合新手的练习路线
如果你现在刚接触Web安全,想系统地把这类内容学好,我建议按下面的顺序来:
先在bugku、CTFshow、BUUCTF这些平台上刷基础题。优先做源码审计类的简单题目,积累常见的代码模式。每做完一道题,不管有没有做出来,都写一篇自己的笔记,把漏洞原理用自己的话讲一遍。这比闷头刷题有效得多。
再深入一点,可以找一些经典的PHP漏洞环境,比如南邮的在线环境或者自己搭的DVWA、Pikachu靶场,把弱类型、SQL注入、XSS这些基础漏洞都实际触发一遍。靶场环境是合法的,适合练习。
工具方面,学会用Burp Suite的Proxy和Repeater就够应付大部分题目了。浏览器插件HackBar也值得装一个,构造请求参数非常方便。
6.3 我的个人体会
刷题刷多了之后,我越来越觉得,CTF不是目的,只是手段。它逼着你以攻击者的视角去理解代码、理解协议、理解系统,在这个过程中积累下来的分析习惯和底层知识,才是真正值钱的东西。
这道“前女友”题目,我已经不是第一次遇到类似的了,但每次从头过一遍仍然有收获。尤其是本地搭环境验证之后,那种“知其然也知其所以然”的感觉,比直接背payload要踏实得多。如果你也刚接触Web方向,不妨把这道题当成一个起点,顺着PHP弱类型这个分支继续挖下去,你会发现后面还有一整片值得探索的森林。
