CTF Web安全:PHP弱类型与MD5哈希漏洞实战解析
1. 题目场景与核心挑战解析
拿到这道题,很多刚接触CTF Web安全的朋友可能会有点懵。题目叫“Easy MD5”,听起来像是要考MD5算法的漏洞,比如碰撞或者SQL注入。但实际打开题目链接,往往是一个极其简洁的页面,可能只有一个输入框,或者干脆就是一段PHP代码的审计题。这种“名不副实”正是CTF比赛的常态,它考验的是你能否跳出思维定式,将知识点进行关联和迁移。
这道题的核心,通常围绕PHP中一个非常经典且容易被忽视的特性展开:MD5/SHA1等哈希函数的“弱类型比较”漏洞。在PHP里,==(松散比较)和===(严格比较)的行为天差地别。当使用==比较一个字符串和一个数字,或者比较两个以0e开头的科学计数法字符串时,PHP会尝试将它们转换为数字再进行比对。而0e开头的字符串,在转换为数字时,会被视为“0乘以10的n次方”,结果就是0。
举个例子,字符串"0e123456"和"0e987654"在==比较下是相等的,因为PHP将它们都转换为数字0。但它们的MD5值完全不同。所以,题目的“Easy”可能是一种反讽,它指代的是一种看似简单、实则需要对PHP语言特性有深刻理解的漏洞利用方式。你的目标,就是找到或构造这样一组数据,绕过服务器的验证逻辑,最终获取到Flag。
2. 典型解题环境与信息收集
在实际的BUUCTF平台或类似CTF环境中,这道题可能以几种形式出现。最常见的是给你一个包含PHP源码的网页,你需要通过查看网页源代码、使用开发者工具(F12)的Network或Sources面板,或者直接访问/index.php?source=1这类常见的源码泄露路径来获取后端逻辑。
假设我们通过信息收集,拿到了类似下面这样的后端PHP代码片段:
<?php error_reporting(0); include “flag.php”; highlight_file(__FILE__); if (isset($_GET[‘a’]) && isset($_GET[‘b’])) { $a = $_GET[‘a’]; $b = $_GET[‘b’]; if ($a != $b && md5($a) == md5($b)) { echo “Wow, you passed level 1!<br>”; if (isset($_POST[‘c’]) && isset($_POST[‘d’])) { $c = $_POST[‘c’]; $d = $_POST[‘d’]; if ($c != $d && md5($c) === md5($d)) { echo “Congratulations! Here is your flag: “ . $flag; } else { echo “Sorry, level 2 failed.”; } } else { echo “Please provide c and d via POST.”; } } else { echo “Sorry, level 1 failed.”; } } else { echo “Please provide a and b via GET.”; } ?>这段代码清晰地展示了一个两阶段的挑战。第一阶段使用==比较,第二阶段使用了===比较。这几乎是这类题目的标准模板。我们的任务就是分步攻克它们。首先,我们需要理解每一关到底在考验什么。第一关的md5($a) == md5($b)就是典型的弱类型比较漏洞利用点。而第二关的md5($c) === md5($d)则要求真正的MD5值相等,这通常需要通过MD5碰撞或者利用PHP的另一个特性——数组绕过来实现。
3. 第一关突破:利用弱类型比较(==)
第一关的条件是:$a != $b且md5($a) == md5($b)。这里的关键在于==。我们需要找到两个不同的字符串,它们的MD5值都以0e开头,且后面全是数字。
为什么是0e开头?因为PHP在将字符串转换为数字时,会识别科学计数法。0e123456会被计算为 0 × 10^123456 = 0。如果两个MD5哈希值都是这种形式,那么它们在==比较时,都会被转换为数字0,从而满足0 == 0的条件,判断为相等。
经过前辈们的收集和整理,有一些已知的字符串符合这个特征。我们不需要自己去碰撞,直接使用这些“魔术字符串”即可。以下是一些经典的例子:
- 字符串
“240610708”:其MD5值为0e462097431906509019562988736854 - 字符串
“QNKCDZO”:其MD5值为0e830400451993494058024219903391 - 字符串
“s878926199a”:其MD5值为0e545993274517709034328855841020 - 字符串
“s155964671a”:其MD5值为0e342768416822451524974117254469
你可以任选其中两个不同的字符串作为a和b的参数值。例如,在浏览器地址栏中构造如下请求:http://靶机地址/?a=240610708&b=QNKCDZO
提交后,服务器会计算:
md5(“240610708”)=0e462097431906509019562988736854md5(“QNKCDZO”)=0e830400451993494058024219903391- 在
==比较下,两者都被转为数字0,0 == 0成立。 - 同时,
“240610708” != “QNKCDZO”也成立。
于是,第一关顺利通过,你会看到“Wow, you passed level 1!”的提示。这一步相对简单,是这道题送分的基础部分,旨在让你理解PHP弱类型比较的概念。
注意:这些魔术字符串的MD5值必须是纯
0e+数字的形式。有些字符串的MD5值可能以0e开头但后面跟了字母(如c),这在转换为数字时会被截断,可能无法得到0,导致比较失败。所以最好使用上面列举的、经过验证的字符串。
4. 第二关攻克:应对严格比较(===)与数组技巧
通过第一关后,题目提示我们需要通过POST方法提交参数c和d。第二关的条件更加苛刻:$c != $d且md5($c) === md5($d)。
这里的===是严格比较,它要求两个值的类型和内容完全一致。这意味着我们不能再用0e科学计数法这种取巧的方式了,因为字符串“0e123”和“0e456”在内容上完全不同。
那么,如何让两个不同的输入,产生完全相同的MD5值呢?这里有两条主流思路:
思路一:寻找真正的MD5碰撞MD5算法虽然已经不安全,存在碰撞漏洞,但要手动找到两个能产生相同MD5值的、有意义的字符串是极其困难的,需要巨大的计算量。在CTF比赛中,这通常不是预期解。出题人不会期望选手现场进行哈希碰撞。
思路二:利用PHP中MD5函数处理数组时的特性这才是本题以及许多类似题目的预期解和核心考点。在PHP中,md5()、sha1()等哈希函数,如果传入的参数是一个数组,函数会返回NULL,并产生一个警告(Warning)。但更重要的是,当两个数组分别传入md5()函数时,它们都会返回NULL。
让我们看看这个逻辑:
- 如果
$c是一个数组,md5($c)返回NULL。 - 如果
$d是另一个数组,md5($d)也返回NULL。 - 那么,
md5($c) === md5($d)就变成了NULL === NULL,这个比较结果是true。 - 同时,只要
$c和$d不是完全相同的数组(例如,数组内容不同),条件$c != $d也很容易满足。
因此,我们可以通过传递数组来绕过第二关的严格比较。具体操作上,我们不能直接在浏览器的地址栏或表单的input框里输入数组。我们需要借助一些工具来发送包含数组数据的POST请求。
最常用的工具是Burp Suite或者直接使用Python 的 requests 库、curl 命令。
使用Burp Suite抓包改包:
- 浏览器打开题目页面,并通过第一关。
- 打开Burp Suite,配置好代理,并开启拦截(Intercept is on)。
- 在通过第一关后的页面上,随便输入
c和d的值然后提交。 - 此时Burp会拦截到这个POST请求。
- 在Burp的拦截界面,找到POST数据部分,通常格式是
c=test&d=123。 - 将其修改为数组形式:
c[]=1&d[]=2。这里的c[]表示参数c是一个数组,其第一个元素为1。同理d[]。 - 点击“Forward”发送修改后的请求。
使用Python requests库:
import requests url = “http://靶机地址/“ # 替换为实际地址 # 首先通过第一关 get_params = {‘a’: ‘240610708’, ‘b’: ‘QNKCDZO’} r = requests.get(url, params=get_params) print(r.text) # 确认第一关通过 # 然后构造POST请求,传递数组 post_data = {‘c[]’: ‘value1’, ‘d[]’: ‘value2’} r2 = requests.post(url, data=post_data) print(r2.text) # 输出响应,应该包含flag使用curl命令:
# 先通过GET请求过第一关 curl “http://靶机地址/?a=240610708&b=QNKCDZO” # 再通过POST发送数组数据 curl -X POST “http://靶机地址/“ -d “c[]=1&d[]=2”当服务器收到c[]=1&d[]=2这样的数据时,$_POST[‘c’]和$_POST[‘d’]在PHP中就会被解析为数组:Array( [0] => 1 )和Array( [0] => 2 )。它们不相等,但它们的MD5值都是NULL,从而完美绕过===的严格检查。
5. 完整解题流程与工具实战演练
让我们串联起整个解题过程,假设靶机地址是http://123.456.789.101:8000/。
步骤1:信息收集与代码审计访问目标地址。查看网页源代码,没发现线索。尝试访问http://123.456.789.101:8000/index.php?source=1,成功获取到第2节中展示的PHP源代码。确认了题目是两段式MD5验证。
步骤2:突破第一关(GET请求)根据代码,我们需要通过URL的GET参数传递a和b。直接在浏览器地址栏输入:http://123.456.789.101:8000/?a=240610708&b=QNKCDZO回车后,页面显示“Wow, you passed level 1! Please provide c and d via POST.”。第一关通过。
步骤3:突破第二关(POST请求)现在需要在同一个页面下,用POST方法提交c和d。浏览器表单不方便直接提交数组,我们使用HackBar(浏览器插件)或Burp Suite。
使用HackBar插件(以Firefox为例):
- 按F12打开开发者工具,找到HackBar标签页。
- 在URL框输入当前地址
http://123.456.789.101:8000/?a=240610708&b=QNKCDZO(注意,GET参数要带上,以维持第一关通过的状态)。 - 在Post data框输入:
c[]=1&d[]=2。 - 选择请求方法为
POST,点击Execute执行。 - 查看下方返回的结果,应该就能看到包含Flag的字符串。
使用Burp Suite:
- 确保Burp代理开启,浏览器代理设置正确。
- 在浏览器中访问
http://123.456.789.101:8000/?a=240610708&b=QNKCDZO,此时页面提示等待POST输入。 - 在页面表单里随便输入
c=1, d=2并提交,目的是让Burp捕获到这个POST请求。 - 在Burp的Proxy -> Intercept标签页,你会看到拦截到的请求。
- 将请求体(Body)从
c=1&d=2修改为c[]=1&d[]=2。 - 点击
Forward发送。 - 在Burp的HTTP history或Target -> Site map中找到这个请求的响应,查看Response,即可获得Flag。
步骤4:获取Flag无论用哪种方法,成功的响应页面会显示类似“Congratulations! Here is your flag: flag{th1s_1s_an_3asy_md5_ch4ll3ng3}”的内容。这就是本题的最终答案,提交即可得分。
6. 漏洞原理深度剖析与防御思考
这道题虽然“Easy”,但背后涉及的知识点却非常深刻,是PHP安全中类型相关漏洞的典范。
1. 弱类型比较(==)的隐患:PHP的松散比较在设计上是为了方便,但带来了巨大的安全风险。除了0e科学计数法字符串相等,还有更多诡异的比较结果,例如:
“123abc” == 123为 true(字符串被转为数字123)。“abc” == 0为 true(非数字开头的字符串被转为0)。false == “0”为 true。null == ““为 true。
在安全编码中,必须杜绝使用==来比较用户输入,尤其是涉及哈希值、身份验证令牌等敏感数据时。一律使用===进行严格比较。
2. 哈希函数处理非字符串输入:md5()、sha1()等函数期望接收字符串参数。当传入数组或对象时,它们的行为是返回NULL并报一个Warning。这个特性本身不是漏洞,但结合程序逻辑(如上述的===比较),就成了一个可利用的“特性”。这提醒开发者,在处理用户输入、尤其是将其传入敏感函数前,必须进行严格的类型检查。使用is_string()函数进行判断是一个好习惯。
3. 防御方案:
- 输入验证与过滤:使用
filter_var()函数或类型转换(string)确保传入哈希函数的是字符串。 - 使用严格比较:在所有安全相关的逻辑判断中,使用
===和!==。 - 错误报告:在生产环境中,应设置
error_reporting(0)或合理配置,避免Warning信息泄露给用户,但开发阶段应开启所有错误提示以便排查此类问题。 - 使用更安全的比较函数:例如
hash_equals()函数用于比较哈希字符串,它可以防止时序攻击,并且对参数类型有严格要求。
// 安全的写法示例 if (isset($_GET[‘a’]) && isset($_GET[‘b’])) { $a = (string)$_GET[‘a’]; // 强制类型转换 $b = (string)$_GET[‘b’]; if (is_string($a) && is_string($b) && $a !== $b && md5($a) === md5($b)) { // 安全逻辑 } } // 或者使用hash_equals (需要两个参数都是字符串) if (isset($_POST[‘c’]) && isset($_POST[‘d’]) && is_string($_POST[‘c’]) && is_string($_POST[‘d’])) { if ($_POST[‘c’] !== $_POST[‘d’] && hash_equals(md5($_POST[‘c’]), md5($_POST[‘d’]))) { // 安全逻辑 } }7. 举一反三:相关CTF题型与扩展
掌握了[BJDCTF2020]Easy MD5的原理后,你可以轻松解决一大批变种题目:
SHA1弱类型比较:将
md5()换成sha1(),原理完全一样。寻找SHA1值为0e开头的字符串,例如“aaK1STfY”的SHA1是0e76658526655756207688271159624026011393,“aaO8zKZF”的SHA1是0e89257456677279068558073954252716165668。双重MD5绕过:题目条件可能是
md5(md5($a)) == md5(md5($b))。你需要找到两个字符串,它们经过两次MD5哈希后,结果都以0e开头。这需要重新寻找或碰撞这样的字符串,但思路不变。JSON或序列化中的比较:题目可能将用户输入进行
json_decode或unserialize后,再进行==比较。这时你可能需要构造特殊的JSON数字0或布尔值false来绕过。与SQL注入结合:有时题目会把你传入的、用于绕过MD5比较的字符串,直接拼接到SQL语句中。例如
SELECT * FROM users WHERE username=‘$a’ AND password=‘md5($b)’。如果你传入的a是240610708‘ OR ‘1’=‘1,在绕过MD5检查的同时,还可能引发SQL注入。这就需要联合利用多种漏洞。变量覆盖与MD5:结合
extract()、parse_str()等函数造成的变量覆盖漏洞,你可能需要同时控制多个变量来满足复杂的比较条件。
解决这些题目的通用步骤是:审计源码 -> 理解比较逻辑(==还是===) -> 确定利用点(科学计数法、数组、其他类型转换) -> 构造Payload -> 利用工具发送请求。这道“Easy MD5”就像一把钥匙,为你打开了PHP类型安全这个庞大而重要的知识领域的大门。在实际的代码审计和渗透测试中,对变量类型和比较操作的警惕,是发现深层漏洞的关键。
