CTFShow平台的Web入门系列,一路刷下来,前40题基本是热身和熟悉套路,真正开始上强度的时候就是40到50这个区间。这段时间你会明显感觉到,题目不再是把flag摆在明面上让你"找",而是逼着你理解漏洞的原理,自己去构造攻击链。我爬完这十道题最大的感受是:这个阶段的题目设置很巧妙,就像打游戏过新手村之后遇到的第一批精英怪,看着唬人,打法其实有规律可循。这篇文章就把我在这组题上沉淀下来的思路、踩过的坑和一些解题细节好好梳理一遍,希望能给正在这条路上爬坡的朋友一些参考。
1. 这组题到底在考什么——从“找flag”到“利用漏洞”的思维转变
1.1 题目难度的真实分布曲线
第40到50题,从整体知识栈来看,主要压在两个大方向上:服务端请求伪造(SSRF)和文件包含漏洞(LFI/RFI),中间穿插一些命令注入和日志利用的变体。很多新手做到这里会有点懵,因为前30多题可能还在玩SQL注入的联合查询、布尔盲注,或者简单的绕waf拼字符串,到了这个区间,题目环境突然变成了一个实实在在的内网环境,你的对手不再是SQL语句的过滤规则,而是"服务端到底会不会把请求发出去""包含文件时能不能把参数可控点利用起来"这种更底层的逻辑问题。
这里说句实在话,第40到50题刚好卡在一个分水岭:往前的题目,很多是可以靠搜wp、背payload混过去的;往后做就不行了,你要是搞不懂file://和http://在服务端请求时有什么区别,连题目在问什么都不知道。这个阶段熬过去,你的Web渗透思路基本就成型了。
1.2 为什么40题是个坎
我见过不少朋友刷题时卡在这里,反复看wp都看不明白。问题不在于题目多难,而是思维方式没转过来。
前面做CTF题目,大家习惯是"找到注入点-丢进工具-出结果",本质上还是在用工具,不理解数据在代码层面是怎么流动的。到了SSRF和文件包含,工具帮不了你了,你必须去看服务端代码到底做了什么。比如一个URL参数,你传http://xxx.com它就去请求外网,你传file:///etc/passwd它可能就把服务器上的文件内容加载出来了。这个"参数到函数再到资源"的链条,不亲手画一遍、不亲自在靶场里试错,光靠背payload是记不住的。
另外,这个阶段的题目还考察一个容易被忽略的能力:信息收集的耐心。很多题不是一步到位的,拿到一个flag可能需要先探测端口、发现内网服务、再通过协议利用拿到shell,最后才有flag。这种"层层递进"的做题节奏,对新手来说是很好的思维训练。
2. SSRF专题:从“能发请求”到“会打内网”的跨越
2.1 SSRF到底是怎么产生的
SSRF这个概念,用大白话说就是:本来是你(客户端)去访问一个网址,但因为服务端代码把"URL地址"这个参数拿来直接用了,比如curl($_GET['url'])或者file_get_contents($_GET['url']),变成服务器替你去访问了。
这时候你给的URL地址就很关键了:你给的是一个公网地址,服务器就帮你访问公网;你给的是http://127.0.0.1:6379/,服务器就会去尝试连接它自己本地的Redis端口。这个行为本身极其危险,因为服务器和"内网"是天然信任的,很多内网服务不会做严格的访问控制,一旦服务器能主动去访问它们,相当于给攻击者递了一把伸进内网的手。
CTF里最常见的SSRF出题点就是URL参数。进入内网之后,目标通常有两类:
- 读文件:用
file://协议直接读取服务器上的本地文件。 - 探端口、打内网服务:用
http://或dict://协议探测内网开放的服务端口,再结合漏洞利用。
2.2 协议选型:file、http、dict、gopher各自怎么用
这个阶段的题目,玩的就是一个"协议选型"问题。你手上握着几种协议,每种协议的利用方式差别很大,不能搞混。
file://协议最简单直接,用来读本地文件。比如第一个SSRF题,常常就是构造一个file:///etc/passwd的请求,flag文件直接摆在服务器某个路径下,读出来就行。这类题属于送分题,基本考的是你知不知道有file://这种协议存在,以及路径拼接的细节——比如是file:///flag(三个斜杠)还是file://flag,这跟服务端解析URL的方式有关,需要在靶场里实测。
http://协议适合做内网端口探测。当你猜到一个内网IP段(最常见的就是127.0.0.1和10.x.x.x这类),可以用http://127.0.0.1:端口/的形式去探测端口是否开放。很多题目还会给你一个内网地址列表,让你通过SSRF去访问某个内网Web服务,拿到只有内网才暴露的页面上的flag。
dict://协议是一个很容易被忽略但很好用的协议。它本来是用来访问字典服务的,但可以用来向任意TCP端口发送简单的请求。比如你想探测内网一个Redis服务的版本,可以构造dict://127.0.0.1:6379/info,它会把info命令发过去并接受返回。这种探测方式在CTF里很常用,因为它不需要你写复杂的脚本,直接在URL里就能完成。
gopher://协议是最重量级的。它允许你向任意TCP端口发送构造好的、任意内容的请求,相当于你可以伪造Redis命令、FastCGI请求,甚至整个HTTP请求。在CTF里最常见的利用是通过gopher打内网的Redis服务,用set命令写一句话木马到Web目录,或者写计划任务反弹shell(这里注意,反弹shell也好、写木马也好,都必须在CTF靶场环境内进行,不能对真实系统操作)。构造gopher协议payload的核心难点在于:URL编码层数、协议头构造、目标服务的协议细节。我用过的工具是Gopherus,可以一键生成Redis、MySQL、FastCGI等服务的gopher payload,非常省时间,但前提是你得知道目标服务开在哪个端口、服务类型是什么。
2.3 绕过过滤的思路:让服务器“认错”地址
前几道SSRF题可能是直给的,后面的题就开始设卡了。最常见的过滤是:服务端检查你传入的URL,必须是以http://开头才能请求,或者干脆做了关键词黑名单,把127.0.0.1、localhost、file这些词直接拦截掉。
面对这种情况,需要掌握的绕过思路基本有这几类:
- IP地址的进制混淆:
127.0.0.1可以写成整数形式2130706433,或者八进制形式0177.0.0.1,十六进制0x7f.0.0.1。很多过滤是基于字符串匹配的,看到127就拦截,但它不认识数字换成进制之后的等价表示。 - 域名解析绕过:用一些特殊域名指向127.0.0.1,比如
localtest.me这类,如果服务端只过滤localhost这个关键词而不过滤域名,就能直接绕过。 - URL解析差异绕过:利用
http://baidu.com@127.0.0.1/这种形式,很多URL解析库解析到@后面的地址,但语法检查时看起来是"一个正常的域名"。还有一种情况是利用#锚点截断,http://127.0.0.1#.com,某些解析器看到结尾的.com就认为是外网域名放过了,但实际curl请求的是127.0.0.1。 - 302重定向绕过:这是比较骚的一招,思路是你自己准备一个公网服务器,上面放一个302跳转,跳转到内网地址。服务端如果对重定向不设防,就会跟着跳过去。实操时可以在自己的服务器上用几行代码搞一个简单的302跳转服务。
提示:这些绕过技巧我建议你在靶场里一个一个试,不要只看wp。每个平台的过滤方式不一样,你亲手试过才知道"为什么这个写法能过、那个写法过不了",这对理解URL解析的底层机制非常有帮助。
3. 文件包含专题:PHP伪协议是绕不过去的坎
3.1 从包含漏洞到代码执行的链路
过了SSRF那几道题,马上就会撞上文件包含。文件包含漏洞的产生原因很简单:服务端为了代码复用,会把一些模板文件、页面组件用include($_GET['page'])这种形式加载进来,但变量page是用户可控的,导致你能控制"被包含的文件名"。
这个漏洞的核心玩法在于:当"包含什么"由你说了算的时候,你能不能把包含目标变成一个可以产生实际影响的东西?比如包含一个远程服务器上的恶意代码文件,或者包含一个PHP伪协议来读取源码,甚至包含一个日志文件把一句话木马写进去。
3.2 php://filter 读取源码:最稳的起手式
在文件包含题里,flag往往不是明晃晃放在 /flag 下让你直接include,而藏在PHP源码里。那怎么在不能直接执行代码的情况下看到源码?这时候就要用到php://filter这个伪协议。
它的标准用法是:
php://filter/read=convert.base64-encode/resource=index.php这行的意思用大白话说:把index.php的内容读出来,然后base64编码之后返回。为什么要base64编码?因为PHP文件本身有<?php ?>标签,如果不编码就直接包含,这些代码会被服务器当成PHP执行,你就看不到源代码了。先base64编码一下,输出的就是一堆纯文本,源码内容全都能看到。
实际操作的时候,我一般会先包含index.php看看源代码里有没有flag或者线索,没有的话再通过代码里的提示,去包含flag.php。这里有个细节:resource=index.php里的路径是相对路径,要从当前工作目录开始写,有时候直接写文件名不行,得写完整的./index.php或者根据目录结构写../xxx/index.php,这是个容易卡住新手的坑。
这个阶段还考过一个经典变形:把读取的文件换成攻击者的思路,比如php://filter/convert.base64-encode/resource=flag.php,如果直接读不了,可以考虑用多级过滤器链,比如convert.iconv.*系列过滤器来构造任意文件读取甚至RCE。这里我不展开全部filter链的细节,但提醒一句:filter链算是文件包含的高阶玩法,第40-50题里可能会碰到一次,能掌握基础用法就行,后面进阶再深入。
3.3 php://input 与 data://:从读到执行
当文件包含漏洞遇上 "allow_url_include=On" 的配置,就会产生质变——你不仅能包含文件,还能执行代码。
php://input这个伪协议,作用是把POST请求的请求体当作文件内容来处理。配合文件包含,攻击方式就是:用POST方法发送一段PHP代码,然后把这个请求体作为被包含的"文件"执行。
POST /?page=php://input HTTP/1.1 Host: target <?php system('ls /'); ?>由于POST体里的内容被当成PHP代码执行了,你就有了一条直接命令执行通道。这个方法简单粗暴,前提是环境开了allow_url_include,不然php://input不可用。
另一个思路是data://协议,它可以直接把URL里的数据当作文件内容。构造方式如下:
/?page=data://text/plain,<?php system('ls /'); ?>如果代码里对<?php这种尖括号做了过滤,还可以用base64编码绕:
/?page=data://text/plain;base64,PD9waHAgc3lzdGVtKCdscyAvJyk7Pz4=这两个思路我放在一起说,是因为本质都是"把可控数据变成可执行代码",区别只在于数据传输的载体不同。做题时先看phpinfo或者报错信息判断哪些伪协议可用,再决定走哪条路。
3.4 日志包含:服务器自己写出的“后门”
并不是所有文件包含题都开了allow_url_include,这时候php://input和data://就废了。那怎么办?还有一条经典路径:包含日志文件。
Web服务器(比如Nginx)会把每个访问请求记录到日志文件里,比如access.log,里面包含请求的User-Agent、URL等信息。思路就是:你先构造一个带恶意PHP代码的请求,服务器把这段代码当作正常请求内容写进日志,然后你再通过文件包含漏洞去包含这个日志文件,日志里的PHP代码就被执行了。
具体做法是这样:
- 先给一个正常的GET请求,URL参数不变,但在User-Agent头里写入
<?php system('ls'); ?>。 - 服务器把这行原样写进
/var/log/nginx/access.log。 - 然后你构造
?page=/var/log/nginx/access.log,把日志文件包含进来,User-Agent里那段PHP代码就执行了。
我用这种方法时踩过一个坑:日志文件特别大,包含的时候响应会非常慢,而且日志里的PHP代码被触发时会把整个日志都输出出来,造成页面内容很杂乱。这时候可以在payload里加点特征字符串(比如LEO),包含之后先搜索LEO出现的上下文,就能精准定位输出。
类似的路子还有包含/proc/self/environ文件,这个文件里也存着HTTP请求头信息,包含它也能触发代码执行。但Nginx环境下通常读不到,Apache + CGI环境读到的概率更大。做题时多试试几个路径,哪个好用用哪个。
4. 命令注入与日志利用:getshell的另类路径
4.1 命令注入的过滤绕过套路
这一阶段有些题表面上是文件包含或者别的漏洞,但最终getshell的路径是通过命令注入。命令注入的产因是:服务端代码把输入拼进了系统命令的字符串里,比如system("ping " . $ip)这种。你传入的不是一个IP,而是127.0.0.1; cat /flag,分号分隔了前后两条命令,后面的cat /flag就被执行了。
过滤拦不住你的情况很常见,经典的绕过姿势我整理过一批:
| 过滤点 | 绕过方式 |
|---|---|
| 分号过滤 | 用%0a(换行符)或者&&、||替代 |
| 空格过滤 | 用${IFS}替代,或者$IFS$9、<重定向字符 |
| 关键字(cat、ls)过滤 | 用变量拼接,比如c''at、c\at、或$(printf 'cat') |
| 反引号过滤 | 用$(命令)形式替代 |
有个很实用的经验:过滤规则是黑名单还是白名单,做题方式完全不同。如果只过滤了某几个关键词(黑名单),可以无限换写法去绕;如果只允许某几个字符(白名单),那就得考虑无字母数字webshell的路子了,那个复杂度就上去了。第40-50题目前最多卡在黑名单层面,每个都记录下来,后面考命令执行的题目可以直接套。
4.2 通过文件包含getshell的完整攻击链
在CTF实战里,文件包含和命令注入常常是串联起来的。比如一个文件包含题,常规思路有三种,我按优先级排:
- 伪协议直接执行(如果能用php://input、data://)。
- 日志包含getshell(当伪协议受限,但能包含绝对路径文件)。
- 远程文件包含(allow_url_include打开时,直接包含一个放在你自己的临时服务器上的txt文件,里面写着PHP恶意代码)。
一个典型的攻击链长这样:先用php://filter读源码,在源码里发现Nginx日志路径;再用普通请求往日志里注入PHP代码;最后用include('/var/log/nginx/access.log')触发代码执行,拿到shell,然后找flag。
这套链路里每一步都有坑。往日志里注入的时候,URL里直接写PHP代码会被 URL 编码转义,最好的地方是User-Agent,因为它通常不会被转义太多。还有日志路径不确定的问题,我一般是读/etc/nginx/nginx.conf找到日志路径,或者从源码里直接找。这个阶段如果能独立打通一条完整的日志包含getshell链路,那你对文件包含的理解基本就到位了。
5. 实战解题流程复盘:从读题到拿flag的标准作业
5.1 我自己沉淀的解题SOP
整个40-50题刷下来,我整理出一套自己用的做题流程,每次碰到新题都先按这个走,效率高不少:
- 读题找参数:先看URL上有哪些参数是可用的,常见的
?url=、?page=、?file=、?img=,这些参数名本身就是强烈暗示。 - 先试无害请求:把参数改成内网地址或本地文件,观察响应差异。比如你传
http://www.baidu.com看它会不会真的请求这个网站并返回内容,传file:///etc/passwd看会不会报错提示"不应该出现的信息"。 - 读源码看逻辑:如果存在文件包含,优先用
php://filter读index.php等关键文件,搞清楚服务端到底过滤了什么、拼接了什么。这一步往往能直接看到flag文件路径或pass。 - 确定利用协议:根据能不能读源码、有没有开远程包含、过滤规则严不严,决定走伪协议执行、gopher还是日志包含。
- 按需构造payload:先在本地环境把payload测通,再去靶场打。
第5步是我特别想强调的。CTF题目里的payload不是纯靠背的,要结合题目环境变量来改。比如gopher协议构造Redis命令时,命令里的路径、文件名都要根据源码和目录结构来定制,直接拿网上的通用payload去怼,很容易失败。
5.2 我常用的效率和排错工具
这个阶段我用到的工具其实不多,但每样都用得比较深:
- Burp Suite:SSRF题必须用Burp来抓包改包,尤其是往请求头里注入日志payload的场景,只在浏览器地址栏里改URL完全不够用。我用Burp的Repeater功能反复试探各种payload变体,比每次在浏览器里重新输入省太多时间。
- Gopherus:生成gopher协议payload的神器。之前手动写gopher载荷,要在URL编码和原始请求之间反复改来改去,很容易出错,Gopherus直接按目标服务类型生成好,省心。
- 本地PHP环境:有些payload拿不准,我在本地搭一个相同版本PHP的环境先跑一遍,确认语法和执行效果再上靶机。这一步建议有条件的一定要做,尤其是filter链和伪协议相关的payload,本地验证一下能少走很多弯路。
6. 踩坑记录与排错思路:那些让我卡了一个小时的蠢问题
6.1 文件包含题常见问题速查
刷这一组题的时候,我在同一个坑里掉进去过好几次,有些问题事后回头看特别蠢,但当时确实卡了很久:
| 症状 | 原因 | 解决办法 |
|---|---|---|
| payload代码没执行,原样显示在页面上 | 伪协议没有生效,allow_url_include没开 | 换日志包含思路,别死磕php://input |
php://filter读出来是乱码 | base64编码后直接当文本显示了,没有解码 | 把响应内容丢进Burp的Decoder或在线工具base64解码 |
| 包含日志文件后页面卡死 | 日志文件太大,输出内容过多 | payload加特征字符串,请求头里用随机标记,再在响应里搜索标记定位 |
| gopher payload打过去没反应 | URL编码层数不够,请求没被正确还原 | 检查编码层数,服务端解析一层就多编一层,一般至少两层 |
| 内网IP探不到 | 网段不对,payload写死了IP | 根据题目提示(比如源码里的ip段)换网段试,别盲打 |
6.2 几个我印象深刻的教训
第一个教训是:不要轻视"读源码"这一步。有一道题,我没读源码就直接拿payload去试,试了半小时都失败,后来老老实实php://filter读源码,发现里面过滤了localhost和所有IP形式,但没过滤127.0.0.1的八进制写法0177.0.0.1,伪造一个八进制IP就进去了。你说这玩意靠盲试得试到什么时候。
第二个教训是:日志用户代理注入时一定要保持格式干净。我有次往User-Agent里写PHP代码,那行代码里带了空格和分号,导致日志被截断或者转义,折腾了很久才发现是User-Agent里不能带空格(会被记录成 %20 之类)。后来我改成在User-Agent里写<?php system("ls");?>并且确保没有多余空格,问题才解决。这个细节靶场里不同环境还不一样,需要多试几次。
第三个也是最重要的教训:多注意payload里的编码问题。无论是SSRF里绕过IP形式,还是文件包含里的base64,编码都是最容易出错的环节。看起来一模一样的payload,差一个URL编码层就是执行和不执行的区别。我现在做任何payload,都习惯在Burp里把原始请求和编码后的请求对照着看一遍,确认没有重复编码或漏编码。
6.3 最后给后来者的一些心得
刷到第40-50题这个阶段,我的心态已经从"求数量"变成了"求理解"。题目做完了不是终点,我会回头想三件事:
第一,这题的漏洞点本质是什么代码逻辑导致的?如果让我自己写一段有漏洞的代码,我会怎么写?把这两件事对上号,下次换个马甲出现我就不慌了。
第二,这道题的过滤有没有别的绕过姿势?我每次解完题都会顺手把其他绕过方法也试一遍,比如题里用file://读的,我会把http://的绕过也试一遍,不为别的,就是练手感。
第三,能不能把这道题的思路迁移到别的题型?SSRF里的IP进制绕过,在命令执行的waf绕过里也能用;日志包含的思路,遇到LFI题目都能拿来当备选方案。知识就是这样一点一点串成网的。
希望这篇文章能帮你在这个阶段少踩几个坑。刷题这件事本身没有捷径,但站在别人踩过的坑上,至少能让你爬坡的时候稍微稳一点。