之前写过 ThinkPHP 的 POP 链调试,这次换 WordPress——一个让我调了最久的链。这篇是自己的调试笔记,源码逐行核实过(WordPress 6.9.4,本地授权靶机)。一句话总结:RCE 不是新漏洞,是同一个 batch 错位 + 同一个注入点,从”读”升级成”写”。上一篇(盲注 check)是把注入当”眼睛”偷看数据;这一篇是把它当”手”伪造一行数据,再借 WordPress 自己的机制把这行假数据”洗”成真权限。
0x01 承接:同一个错位,换三样东西
错位机制在 check 篇已经讲过:batch 路由按 matches[] 匹配 handler,探针让 matches[] 短一格 → 载体请求偷到下一个路由的 handler → author_exclude 参数原样进 SQL。这次从读变写,只改三处:
| check(只读) | RCE(写) | |
|---|---|---|
| 注入语句 | SELECT IF(...SLEEP...) | 1) AND 1=0 UNION ALL SELECT 伪造行... -- - |
| 内层载体 | /wp/v2/users?... | /wp/v2/widgets?author_exclude=...&per_page=-1&orderby=none&context=view |
| 额外请求 | 无 | 追加 2 次 POST /wp/v2/users(创建管理员) |
两个关键开关:
- 载体从 users 换成 widgets:阶段一校验用”载体自己的路由参数表”检查参数,表里没有的参数被跳过——users 的参数表里有
per_page(会拦),widgets 的表里没有(放行); per_page=-1是”从读变写”的总开关:它让 widgets 接口返回全量行,伪造行才有机会进缓存。
外层还有一次同样的错位:[PRIMER, postsCarrier, /batch/v1] 里 /batch/v1 只是”献出 handler 的牺牲品”,真正执行内层批的是它前面的载体——机制是同一个,套了两层。
0x02 六环总览
环1 SQL 注入 → 内存对象投毒 UNION 伪造行被 get_results() 取回,
update_post_caches() 写进内存缓存(可信!)
环2 oEmbed 缓存 → 持久化写入 oEmbed 渲染时把伪造字段经 wp_update_post
"稀疏合并"写进数据库 ← 假数据落地成真
环3 层级循环检测 → 状态转换 伪造的父子关系触发修环,post 状态 future → publish
环4 Customizer 发布 → 权限提升 发布流程把当前用户切成管理员
环5 parse_request 动态钩子 → REST 重入 第二次修环触发钩子,重入 REST API
环6 创建管理员 → webshell → RCE 管理员身份 POST /wp/v2/users 建号,登录传马
设计上最巧的是环 2 的 7 条毒化记录一次注入(1 条 seed + 一组 changeset,含两条父子环):后续每一环需要的”数据形态”都在这一次注入里预置好了,后面全靠 WordPress 自己的机制接力——攻击者只写一次,平台替他洗白六次。
调试上我的经验两条:
- 断点按环分组,25 个点一条链:条件断点只停关键请求(按 URL 片段过滤),否则批处理请求一进来断点就淹没;
- 三层超时是调试杀手:PHP/网关/浏览器逐层超时,
debug 一会就中断不是错觉——时间盲注的--sleep判据要和断点停驻时间一起预算。
0x03 check vs RCE 对照(收尾)
| 维度 | check 盲注 | RCE 六环 |
|---|---|---|
| 注入的角色 | 眼睛(读) | 手(写) |
| 关键语句 | SELECT IF(...SLEEP...) | UNION ALL SELECT 伪造行 |
| 剩余攻击面 | 数据库内容 | 内存缓存 → 数据库 → 权限系统 |
| 调试重心 | 响应时间差 | 25 个断点的状态传递 |
这条链给我的最大启发:注入点的价值上限不取决于注入本身,取决于取回数据的代码怎么用——update_post_caches 无条件信任查询结果的那一刻,数据库的边界就成了权限系统的边界。
相关旧文
- SQL 注入课堂深挖、PHP 代码审计入门靶场复盘
- 环境配置:VSCode + Xdebug 调试 PHP(线上,断点环境同款)