这套Web题的名字里带“战疫”两个字,题面包装也跟着走——打卡、上报、签到、健康状态查询,全是日常得不能再日常的场景。我做完整套之后最大的感受是:单个知识点都不新,难的是在有限时间里不被这层包装带偏,把“打卡”还原成“参数可控”,把“上报”还原成“文件落盘”。这类赛题的设计思路其实很统一,它不考你知不知道某个漏洞的名字,而是考你能不能在看起来毫无攻击面的页面上,找到那个被藏起来的可控变量。
下面我按自己做题的实际顺序来拆:从题面怎么读、签到题怎么绕、注入怎么收尾,到上传和包含怎么拼成一条链,再到反序列化怎么从危险函数往回倒推。每个环节我都会说清楚“为什么这么选”,而不是只丢一个payload。适合刚接触Web方向、能把工具跑起来但一遇到过滤就卡住的同学,也适合想回头系统整理一遍思路的老手——这套题的知识密度正好卡在“有基础但不够细”的位置上。
1. 题面拆解:把“打卡”“上报”这类包装词还原成攻击面
拿到一套Web题,我做的第一件事永远不是打开扫描器,而是把题面文字抄到草稿里逐句拆。这套题的题干里反复出现“提交”“查询”“上传凭证”这类动词,动词背后对应的一定是参数入口。很多人在这一步就走偏了,看到“健康打卡”就真把它当成一个表单页去看业务逻辑,结果在页面上点了十几分钟,其实真正的入口藏在URL参数里。
1.1 三类线索:目录名、参数名、报错文案
我把题面里能提取的线索分成三类,这套题每一类都给了东西。第一类是目录名和文件名,题干里如果出现“在 admin 目录下”“访问 api 接口”之类的描述,直接就是送路径;即使没明说,功能描述里出现的英文单词也往往是真实目录名,比如“签到”对应checkin,“上报”对应report,拿去拼路径比盲扫字典快得多。
第二类是参数名。这套题有个页面地址是index.php?act=xxx,act这个参数名本身就是提示——它说明后端大概率有一张“动作映射表”,比如switch($act)或者包含文件。这种结构下,只要猜到某个动作名,就能进到对应的功能分支,act=login、act=upload、act=read这种枚举比扫目录高效一个数量级,因为它在同一个URL下切换,不会触发404。
第三类是报错文案。我特意输了一个不存在的act值,页面返回了“未知操作”四个字而不是空白页或500。这说明后端把异常吞掉了,用自定义提示代替了PHP原生报错——这种处理通常意味着代码里有一段显式的include或require,失败时被@抑制了。有include就有文件包含的可能,这条线索后来直接指向了第三题。
提示:题干里的中文描述不要跳读,出题人写“请提交你的凭证”和写“请上传凭证”是两回事,前者暗示参数提交,后者暗示文件落盘,攻击方向完全不同。
1.2 指纹先于扫描:从响应头和默认文件判断技术栈
确认了大概方向之后,我才开始看技术栈。这一步我一般不开大字典,先看三样东西:响应头、默认文件、报错页风格。这套题的响应头里带着X-Powered-By: PHP/7.3,Server字段是常见的Web服务器版本信息,没有做隐藏。版本号一出来,很多思路就能直接排除或锁定——比如PHP 7.3意味着反序列化的属性数量绕过还生效(这个点在第五节会展开)。
默认文件这块,我按固定顺序试了几个:robots.txt、www.zip、index.php.bak、.git/HEAD。这套题的robots.txt里写了两条 Disallow,其中一条指向一个没在导航里出现的目录,进去之后是源码备份的下载页。这一步其实是整套题的分水岭——拿到源码之后,前面所有的猜测都变成了确认,后面的注入、上传、反序列化全都是在读代码而不是猜。
需要说明一点,备份文件不是每次都能捡到,我一般会把常见后缀列成一个小清单固定去试,这个习惯比临时想文件名靠谱:
| 类型 | 常见路径 | 命中后的价值 |
|---|---|---|
| 压缩备份 | /www.zip、/web.zip、/backup.zip | 直接拿到全量源码 |
| 编辑器备份 | /index.php.bak、/index.php~ | 单文件源码 |
| 版本库泄漏 | /.git/HEAD、/.svn/entries | 可还原源码 |
| 编辑器临时文件 | /.index.php.swp | 部分源码片段 |
拿到源码之后我的习惯是先不读全部,而是用关键词快速定位危险函数,include、eval、unserialize、system、file_get_contents、move_uploaded_file这几个词扫一遍,基本就能把题目结构画出来。这套题扫出来四个可疑点,后面几节逐个说。
2. 签到题的两次翻车:前端校验和弱类型比较
签到题我一般是最后才回头做的,因为它通常不难但容易踩坑,而这套题的签到题我确实翻车了两次,值得单独拿出来讲。题面是一个登录框,要求输入特定的用户名和密码,页面上写着“仅限管理员登录”。我第一反应是直接看源码,发现密码校验写在JS里,把JS里的比较逻辑改掉或者干脆绕过前端提交,就进去了第一层。
2.1 前端校验从来只是提示,不是防线
第一层进去之后是一个“验证码校验”页面,前端用JS生成一个四位随机数,然后把用户输入和它比对。这种设计在真实业务里也常见,但本质上是把判断逻辑交给了客户端。我用浏览器开发者工具把这个JS函数改成永远返回 true,或者更省事的办法——直接把提交请求构造出来,跳过页面直接发POST,服务端因为压根没有校验验证码的逻辑,直接就放行了。
这里要强调的不是“前端校验没用”这个老生常谈,而是一个更实际的问题:当前端校验被绕过之后,服务端往往会暴露出它原本以为已经处理好的输入。这套题的第二层,服务端在收到POST之后会把用户名写进一个JSON返回,而用户名参数没有做任何过滤。我试了在用户名里塞单引号,返回的JSON里原样带出来了——这说明后端只是把值拼进字符串,没有做类型约束。这个发现直接把我送到了第二题。
从做题节奏上说,签到题翻车的原因通常不是不会,而是想复杂了。我第一次卡住是因为一直在找“验证码的生成规律”,试图预测服务端的随机数,结果这个页面根本就没有服务端校验。第二次卡住是因为我把用户名当成普通字符串处理,没意识到它后面会被拼进查询语句。
2.2 弱类型比较与参数污染的组合拳
第二层其实还埋了一个弱类型比较的点。源码里有一段判断是这样的:
if ($_POST['role'] == 'admin') { // 进入管理分支 }用的是==而不是===。如果只是普通字符串比较,这个点没什么用,但结合另一处逻辑——后端会先检查role是否等于guest,如果不等于就当作普通用户——就出现了经典的类型混淆空间。PHP 里"0" == "admin"在旧版本下会成立(字符串转数字后 0 与 0 比较),具体能不能用要看版本,7.x 之后这个行为已经修正,所以这条路我没走通。
真正有效的是参数污染。这套题的role参数同时从GET和POST读取,代码里先取$_GET['role']做权限判断,再取$_POST['role']做业务处理。我在URL里带role=admin,在POST体里带role=guest,权限判断过了,业务处理用的是guest的分支——反过来把两个值调换,就拿到了admin的业务视野。这种“前后读取来源不一致”的漏洞在真实系统里非常常见,尤其是老代码从$_REQUEST迁移过来的过程中。
注意:参数污染不是只看
?id=1&id=2这种同源重复,真正有价值的是跨来源的不一致(GET/POST/Cookie/Header),读源码的时候要顺着变量追它到底从哪来、又被谁覆盖。
这两个点加起来,签到题花了我将近二十五分钟,比我预期多一倍。事后复盘,问题在于我一开始把它当成“纯脑筋急转弯”,没有认真去读那几十行源码。签到题给的源码量通常很小,逐行读一遍成本极低,收益极高。
3. 注入题的三种收尾:报错、布尔盲注、时间盲注
第二题是本套里最标准的一道注入题,但它的价值在于把三种收尾方式都堵了一半,逼着你切换手段。入口是一个查询接口index.php?id=1,正常返回一条记录,id=1'返回报错页,id=1 and 1=1正常,id=1 and 1=2返回空——标准的数字型注入。
3.1 先把过滤规则摸清楚再写payload
很多人拿到注入点直接上union select,结果被过滤了才回头研究。我的顺序是反过来的:先用最基础的几个探测把过滤规则摸清楚,再决定用哪种手法。这套题我依次试了这些:
id=1 and 1=1正常,说明and没被拦;id=1 order by 3报错,order by 2正常,说明查询结果两列;id=0 union select 1,2返回了1和2的位置,说明union本身可用;- 但
id=0 union select 1,database()返回“非法字符”,说明过滤是针对函数名或关键字的。
继续细分,我发现database()被拦,但version()能过,user()能过。这说明过滤是黑名单式的关键词匹配,不是正则整体拦截。黑名单最大的问题是它只能列举它想到的,绕过手段非常多。这套题我用的是注释拆词和大小写混合,un/**/ion和UnIoN都能过,但真正稳定的解法是换函数——database()换成schema()走不通,最后用的是select group_concat(table_name) from information_schema.tables where table_schema=(select database())里把database()替换为用@@变量或者直接从information_schema.schemata里查。
这一步的经验是:黑名单过滤存在的时候,不要死磕一个函数,先横向找等价函数。database()的等价物有schema()、@@datadir间接推导、select schema_name from information_schema.schemata limit 0,1。多试几个,总有一个没进黑名单。
3.2 布尔盲注脚本里最容易忽略的两个细节
当我拿到库名之后,发现回显位其实被限制得很死——union能出数据,但出不了长字符串,超过一定长度会被截断。这种情况下只能退回盲注,而布尔盲注在脚本层面有两个坑,我踩过不止一次。
第一个坑是判断锚点选错。我一开始用“页面是否包含某段文字”作为true/false的依据,结果发现false的时候页面也会带那段文字(因为它在模板的公共部分)。正确做法是找一个只在结果集非空时才出现的元素,比如具体某条记录里的字段值。这套题里我用的是记录里的一个时间戳字符串,只有查询有结果时才会渲染。
第二个坑是请求频率和网络抖动。布尔盲注每个字符要发七八次请求(如果是线性遍历)或者六七次(二分),一套库表列跑下来上千次请求,中间只要有一次网络抖动导致判断错误,后面的字符就全歪了。我的处理是加一次重试:同一个条件连续请求两次,两次结果一致才采信,不一致就再发一次。
二分法的脚本骨架大概是这样:
import requests URL = "http://target/index.php" def check(cond): payload = f"1 and ({cond})" for _ in range(3): try: r = requests.get(URL, params={"id": payload}, timeout=5) return "2024-" in r.text # 锚点:结果里才有的时间戳前缀 except requests.RequestException: continue return False def get_char(expr, pos): lo, hi = 32, 126 while lo < hi: mid = (lo + hi) // 2 if check(f"ascii(substr(({expr}),{pos},1))>{mid}"): lo = mid + 1 else: hi = mid return chr(lo) def dump(expr, max_len=64): out = "" for i in range(1, max_len + 1): c = get_char(expr, i) if c == "\x00": break out += c return out二分把每个字符的请求数从平均五十多次压到七次左右,这是能不能在比赛时间内跑完的关键。另外timeout一定要设,不然一个卡住的请求会把整个脚本拖死。
3.3 时间盲注不稳时,换二分和换函数
这套题还留了一个更狠的分支:某个查询点完全没有回显,布尔判断也不成立(页面永远返回同一个模板)。这种情况只能上时间盲注。时间盲注的经典写法是if(条件, sleep(2), 0),但这套题里sleep被过滤了。
替代方案我准备了三个,按优先级排:benchmark(10000000, md5('a'))制造延迟、select 1 from information_schema.tables where 条件 and sleep(2)走子查询里的函数(有些过滤只查最外层)、以及用regexp配合超长字符串制造计算延迟。这套题里benchmark能用,但它的问题是不稳定——机器负载高的时候,benchmark的耗时波动很大,2秒和2.5秒的阈值很容易误判。
我的处理是把延迟拉到3秒,阈值设在2秒,并且每个判断都做一次“基线校准”:先发一个必然为false的请求测一下正常响应时间,再发条件请求,差值超过阈值才算true。这套流程写下来比较啰嗦,但跑起来很稳。时间盲注最大的敌人不是过滤,是不稳定的网络和高负载的靶机,脚本健壮性比payload技巧重要得多。
4. 上传点与包含点拼成一条链:从落盘到执行
第三题是我认为这套里设计得最好的一道,因为它把上传和包含拆成了两个页面,单独看每一个都“看起来没法打”,只有拼起来才成立。上传页限制得很死,包含页的参数只能传一个文件名,不能带路径。
4.1 上传判定的六个关卡,按顺序拆
上传点的判定我按固定顺序过一遍,这套题过了前五关,卡在第六关:
| 关卡 | 检测内容 | 这道题的情况 |
|---|---|---|
| 1 | 文件后缀黑名单 | 拦了 php、phtml、php5 |
| 2 | MIME 类型 | 校验Content-Type |
| 3 | 文件头魔术字节 | 校验前几个字节 |
| 4 | 文件内容关键词 | 拦了<?php |
| 5 | 文件名特殊字符 | 过滤了点号和斜杠 |
| 6 | 二次渲染 / 重命名 | 保存时重命名为随机串 |
前五关的处理思路是逐层叠:后缀用.pht或者大小写PHP(Linux下大小写敏感,有些配置会解析),MIME 在请求里改成image/png,文件头补上 PNG 的魔术字节\x89PNG,内容里的<?php用短标签<?=或者大小写混写<?Php(PHP 对标签大小写不敏感)。这些手法单独用都能过某一关,组合起来就能过前五关。
真正麻烦的是第六关——保存时把文件名重命名成了随机串。这意味着即使传上去了,我也不知道文件叫什么,包含页拿不到文件名。遇到这种情况,我一般有两条路:一是看重命名规则是不是可预测(比如基于时间戳、基于原文件名的哈希),二是找有没有地方会把文件名回显出来。这套题的做法比较直接,上传成功后的响应里带了一个文件ID,而包含页接受的正是这个ID。也就是说文件名虽然随机,但ID是可控的映射关系。
4.2 包含点怎么把上传的文件“吃”进去
包含页的逻辑是这样的:
$id = $_GET['id']; $file = './uploads/' . $id; include($file);这里有两个可利用点。第一,$id没有过滤../,理论上可以目录穿越,但include的后缀拼接是固定的,所以穿越到别的目录也还是要以某个已存在的文件收尾。第二,如果上传目录里能放一个我完全控制内容的文件,包含它就能执行。
问题回到第六关:文件名被重命名了。我的解法是利用重命名的时机差——有些实现是先按原文件名落盘、再改名,中间有一个极短的时间窗口,文件名还是我传的那个。这种条件竞争在比赛里不好打,成功率看靶机性能,我试了大概四十次才中一次。更稳的办法是找“文件名回显”的地方,这套题的上传响应里返回了一个path字段,虽然显示的是随机名,但它同时返回了一个hash,而包含页接受id参数,两者是映射关系,直接用返回的id就行。
包含成功之后,执行环境还有一个限制:system、exec、shell_exec全被disable_functions禁了。这套题最后我是用file_get_contents加scandir把目标目录列出来,再用copy把 flag 文件复制到可访问目录,绕开了命令执行。disable_functions禁的是一批函数,但文件操作类函数往往还留着,这条路在真实环境里也常常有效。
提示:
disable_functions的清单要自己整理一份,遇到禁用了执行类函数时,优先考虑文件读写、目录遍历、include新文件这几条替代路径,而不是死磕命令执行。
4.3 命令执行函数被禁用之后的替代路径
顺着上一段说,禁用函数环境下我常用的替代手段有这几类,按可用性从高到低排:
第一类是文件读写。file_get_contents、file_put_contents、fopen、copy、rename这些函数极少被一起禁用。如果能读源码,就能从配置里找到 flag 路径;如果能写文件,就可以往Web目录写一个只含<?= \命令` ?>的文件再访问它。反引号执行在部分环境下不受disable_functions` 限制,值得一试。
第二类是目录遍历。scandir、glob、opendir能把目录结构列出来,配合file_get_contents逐个读,比盲猜路径高效。
第三类是包含链。如果当前文件能include,而include的目标里又有可控内容,就能形成二次利用。这套题里我就是用glob('/tmp/*')找到临时文件,再包含它。
这套题在上传和包含之间还有一个细节值得说:上传目录是有执行权限的,但配置里open_basedir限制了访问范围。这种情况下即使拿到了shell,也只能在指定目录内活动,flag 如果在范围外,还得再想办法。我遇到这个限制时,会先试ini_set('open_basedir', ...)能不能改(一般不能),再试包含/proc/self/environ这类系统文件看能不能扩大视野。
5. 反序列化POP链:从危险函数往回倒推
第四题是PHP反序列化,给的入口是一个Cookie,值经过base64编码后是一段序列化字符串。这道题我一开始的思路是错的——我拿到源码之后从入口类往下找,看哪个属性可控、能走到哪个方法,结果链子走到一半断了,因为入口类的属性全是私有的,外部赋值不了。
5.1 找链头:__destruct、__wakeup、__toString
正确的做法是反过来的:先找危险函数,再往前倒推谁能调用它。这套题的源码里危险函数只有一个,在某个类的方法里调用了文件写入。于是问题变成:谁调用了这个方法?顺着调用关系往上找,发现是另一个类的__destruct里调了$this->obj->write()。而obj这个属性是 public 的,可以控制。
这就是链头。__destruct在对象销毁时自动触发,反序列化结束、脚本执行完毕时一定会触发,所以只要构造出这个对象,链子就会自己跑起来。除了__destruct,这套题里另一个可用的是__toString——它在对象被当作字符串使用时触发,比如拼接到字符串里或者被echo。
__wakeup在这道题里也有,但它的逻辑是“把某些属性重置为默认值”,也就是说如果属性被修改过,__wakeup会把它改回去,反而阻断了链子。这种情况要么绕过__wakeup,要么换一条不经过它的链。
5.2 串链时属性可见性的坑
串链的过程中,最耗时间的是私有属性和受保护属性的序列化格式。公开属性直接写属性名,私有属性前面要加类名,格式是\x00类名\x00属性名,受保护属性加\x00*\x00。手写序列化字符串的时候,长度字段必须严格对应,多一个字节少一个字节都会反序列化失败,而且失败时通常没有任何报错,只是静默返回 false,非常难排查。
我的做法是不手写,用脚本生成:
<?php class Logger { private $path; public $handler; public function __construct($path) { $this->path = $path; } } class Handler { public $cmd = "id"; } $h = new Handler(); $l = new Logger("/tmp/x"); $l->handler = $h; echo urlencode(serialize($l));注意handler是 public,path是 private,serialize会自动处理成正确的格式。这套题我就是用这个方式生成的payload,比手写靠谱得多。
另外有个细节:属性值的类型也要对。如果源码里某处做了类型声明或者is_string判断,传进去的对象会直接失败。这套题在链子的中段有一个file_exists判断,传对象进去会报类型错误,必须传字符串。
5.3 属性数量绕过与生成脚本
如果链子上必须经过__wakeup,而__wakeup的逻辑会破坏链子,可以考虑属性数量绕过。原理是反序列化时如果声明的属性数量大于实际属性数量,某些版本会跳过__wakeup的执行。PHP 7.4 之后这个行为被修复了,但这套题的环境版本正好在修复之前,所以我用的是这个方案。
具体做法是把序列化字符串里的属性计数改大,比如O:6:"Logger":2:{...}改成O:6:"Logger":3:{...}。注意这只是改变计数,实际属性还是那些,多余的那个并不存在。
# 把属性计数 +1,制造 __wakeup 绕过 s = 'O:6:"Logger":2:{...}' import re s2 = re.sub(r'O:(\d+):"([^"]+)":(\d+):', lambda m: f'O:{m.group(1)}:"{m.group(2)}":{int(m.group(3))+1}:', s) print(s2)生成之后记得做一次本地验证——搭一个同样版本的环境,把payload丢进去看是否报错,比直接在靶机上试省时间。反序列化这类题,本地调试的时间投入是值得的,因为失败时几乎没有任何反馈,全靠猜的话会极其被动。
6. 赛后复盘:时间分配和笔记模板
整套题做下来,我用时大约两小时二十分钟,其中签到题二十五分钟、注入题四十分钟、上传包含题四十五分钟、反序列化题三十分钟,剩下二十分钟在查一个我一开始理解错的配置项。这个时间分布不算好,签到题占比太高,而它其实是性价比最低的一道。
6.1 卡题超过二十分钟就该换思路
我给自己定的规矩是:一道题如果在同一条思路上卡超过二十分钟,必须换方向。这套题我在签到题上就违反了这条——我在“预测验证码”这个错误方向上耗了十几分钟,明明已经发现前端有JS,却没有第一时间去读它,而是想着怎么绕过。
判断“该不该换思路”有个简单的信号:如果你正在做的事需要大量猜测(猜文件名、猜参数值、猜编码方式),而没有任何正向反馈,那就该停。正常的解题过程应该是每一步都能拿到一点新信息——报错文案变了、响应长度变了、页面结构变了。连续三五步都是“没变化”,说明方向错了。
另外一点是工具的使用节奏。我前半小时几乎没开扫描器,全程手工,这在信息收集阶段是对的,但在明确了注入点之后还手工跑就会很慢。我的习惯是手工确认漏洞类型,确认之后立刻写脚本,不要用鼠标一个个试。脚本的复用性也比手工高,下次遇到同类题直接改URL就能跑。
6.2 我自己在用的题解笔记模板
最后分享一个我整理题解时用的模板,格式固定下来之后,回头查的时候效率高很多。每道题我记这几项:
- 入口点:URL、参数名、请求方法,精确到能复现;
- 关键线索:从哪一行源码或者哪个响应里得到的,写清楚位置;
- 卡点:在哪一步停住了,当时试了什么,为什么不行;
- 突破口:最后是哪个想法起了作用,这个想法是怎么来的;
- 可复用点:这道题的哪个技巧能迁移到别的场景。
其中“卡点”这一项最重要,也是绝大多数人写题解时跳过的。只写正确答案的题解,下次遇到同类变体还是会卡;把错误路径也记下来,才知道自己的思维盲区在哪。这套题我记下的卡点是“拿到源码之后没有先搜危险函数,而是从入口类往下读”,这个习惯问题在后面几道题里帮我省了不少时间。
这套题的知识点其实都不深,注入是最基础的盲注,上传的绕过手法也都是常规套路,反序列化的链子只有三层。它真正考的是两点:一是在信息不完整的时候能不能靠线索推出结构,二是被挡住了能不能快速切换到等价方案。这两点没法靠背payload解决,只能靠平时把每一种漏洞的“替代路径”都过一遍,形成条件反射。
我个人的体会是,做题时遇到“这个函数被禁了”“这个关键词被过滤了”这类反馈,不要当成终点,把它当成出题人给的提示——他在告诉你这条路走不通,让你去找另一条。很多时候另一条路就在同一份源码的隔壁几行,只是你还没翻到。