WordPress SQL 注入漏洞分析:从 author__not_in 参数到 REST API 全链路

发布时间:2026/8/25 14:05:57
WordPress SQL 注入漏洞分析:从 author__not_in 参数到 REST API 全链路 摘要本文基于 WordPress 7.0.1 源码完整梳理了一条 SQL 注入漏洞的形成与利用链路author__not_in 参数在 WP_Query 中被拼入 post_author NOT IN (...)而开发者仅用 is_array() 做了安全检查导致字符串类型的输入可以绕过 absint() 强制整数转换原始恶意字符串直接进入 SQL。为说明攻击者如何让内部参数接收外部输入文章进一步分析 GET /wp/v2/posts 路由从注册、参数映射到 WP_REST_Posts_Controller::get_items() 调用 WP_Query 的完整过程打通“HTTP 请求 → REST API → WP_Query → SQL 执行”的数据流。由于个人水平有限文中若有不足之处欢迎批评指正。sql漏洞怎么形成的author__not_in是什么从注释信息可以得到author__not_in 是一个作者ID数组表示这些作者的文章不要查询这里把author__not_in放进了 $array_keys紧接着在下面如果author__not_in没有提供WordPress会给它一个空数组作为默认值。$query_vars是WordPress全局数组author__not_in怎么变成 SQL语句if ( ! empty( $query_vars[author__not_in] ) ) { if ( is_array( $query_vars[author__not_in] ) ) { $query_vars[author__not_in] array_unique( array_map( absint, $query_vars[author__not_in] ) ); sort( $query_vars[author__not_in] ); } $author__not_in implode( ,, (array) $query_vars[author__not_in] ); $where . AND {$wpdb-posts}.post_author NOT IN ($author__not_in) ; }第一步判断author__not_in是否为空如果用户/程序提供了author__not_in并且它不是空的就处理它。于是进入这个if第二步检查传入的是不是数组这里非常关键因为前面我们已经知道author__not_in设计上应该是数组。所以开发者这里进一步检查传进来的东西到底是不是数组如果是[1, 2, 3]那么is_array(...)返回true。进入下面。第三步真实代码中的absint()absint()强制类型转换把传入变量强行变成整数所以这里的原本作用就是把作者ID按整数处理。然后array_unique()去除重复值。最后sort()排序。第四步把数组转换成 SQL 中需要的逗号分隔字符串。$author__not_in implode( ,, (array) $query_vars[author__not_in] );arrary把变量强制转换为数组比如[1, 2, 3]经过implode(,, [1,2,3])得到1,2,3。于是$author__not_in现在就是1,2,3第五步真正进入SQL查询然后就是最关键的一行$wpdbWordPress全局数据库对象$wpdb-posts →存储文章的表名带表前缀。示例$sql SELECT * FROM {$wpdb-posts} WHERE ID1执行后字符串被解析成SELECT * FROM wp_posts WHERE ID1回到本文的环境中$where . AND {$wpdb-posts}.post_author NOT IN ($author__not_in) ;原本程序写的是post_author NOT IN (...)。而$author__not_in会被放进去。正常情况下$author__not_in 1,2,3于是post_author NOT IN (1,2,3)这完全符合预期。sql漏洞到底在哪里关键问题在于if ( is_array( $query_vars[author__not_in] ) )只有数组才执行 absint()。如果这个变量在进入这里的时候是string那么is_array(...)就是false于是array_map(absint, ...)根本不执行。总结is_array()成为了安全处理的条件而非数组输入可以绕过这个条件后续(array)只是类型转换不是安全校验最终原始字符串进入了SQL拼接。正常情况[1,2,3]最后变成NOT IN (1,2,3)漏洞情况变成NOT IN (lastname,123)漏洞是怎么被利用首先思考的是攻击者实际上是怎么把这个“本来只能接收数组的内部参数”变成一个可控字符串并让它进入 SQL 的攻击者真正需要解决的问题其实只有一个怎么让author__not_in以字符串形式到达这里因为如果直接传author__not_in [攻击者数据]它是数组攻击者的数据就会被转换成整数。所以这就是为什么不能简单理解成“找到 SQL 拼接点然后传 Payload”。什么是 REST API1. API 是什么API 可以简单理解成程序和程序之间对话、拿数据、执行功能的通道。REST 把资源的状态以某种表述格式在客户端与服务端之间转移。REST‑API遵循 REST 这套设计风格写出来的 API 接口。可以把 WordPress 看成一个程序。正常情况下浏览器访问 WordPressWordPress 返回 HTML这是给人/浏览器看的。但是现在很多程序并不需要 HTML。比如手机 App、前端 JavaScript、WordPress 区块编辑器、其他程序它们更希望得到{ id: 123, title: ..., author: 1 }所以 WordPress 提供了一个专门的接口WordPress REST API官方文档对它的定义就是它提供一个接口让应用通过 HTTP 与 WordPress 站点交互并以 JSON 形式交换数据。2. 为什么 WordPress 需要 REST API这个问题非常重要。不是因为“漏洞需要 REST API”而是REST API 本来就是 WordPress 正常功能的一部分。WordPress 需要让其他程序能够读取文章、读取页面、读取用户、创建文章、修改文章、删除文章。所以它提供了一套标准 HTTP 接口。例如 WordPress 官方 API 中/wp/v2/posts对应文章。/wp/v2/users对应用户。/wp/v2/pages对应页面。这些都是 WordPress REST API 的资源路由。例如 WordPress 的 PostsGET /wp/v2/posts意思是获取文章列表。WordPress 官方文档明确把GET /wp/v2/posts定义为获取 Posts 集合的接口3. 这里的posts是什么这个就和漏洞直接开始产生关系了。WordPress 里面有一个非常重要的数据类型Post也就是文章。REST API 把 WordPress 中的文章作为一个资源暴露出来。所以/wp/v2/posts就是由WordPress REST API v2版本接口前缀指向posts资源文章集合映射数据库wp_posts文章表URL分段/wp/v2/标识WordPress程序指定接口版本2作为REST API根路径用来区分普通网页访问/posts命名资源posts代表文章集合REST风格把资源名称写在路径中不使用actionxxx形式对应wp_posts表HTTP方法语义GET /wp/v2/posts通过路径定位全部文章资源使用GET执行获取操作读取全部文章返回JSON格式列表因此当程序请求GET /wp-json/wp/v2/posts时WordPress 就需要找到文章然后把文章数据转换成 JSON 返回。官方文档也明确说明这个请求大致对应 WordPress 内部的WP_Query。这一点对研究 SQL 注入非常重要。4.为什么这一点和我们的 SQL 注入有关在前面的解释中我们已经可以知道author__not_in最终会影响文章查询的SQL。但是现在问题来了谁会调用WP_Query我们不能凭空说只要 HTTP 传来 author__not_in就一定自动交给 WP_Query 处理。需要确认哪段代码会接收 HTTP 参数并把这个参数传给 WP_Query只有代码做了这一步链路才成立如果没有代码做参数映射即使请求带上 author__not_inWP_Query 也接收不到不会修改 SQL。而/wp/v2/posts恰好就是WordPress对外提供的查询文章接口该接口内部实现了这层逻辑读取HTTP传入的查询参数把author__not_in这类参数直接传递给WP_Query再由WP_Query拼接生成SQL语句执行数据库查询完整打通从HTTP请求到SQL执行的整条链路。5.小总结目前已经知道WP_Query用来查询WordPress文章。而 REST API接口GET /wp‑json/wp/v2/posts的作用同样用来查询WordPress文章。所以两者会产生调用联系HTTP请求发送到WordPress REST API接口执行Posts资源查询操作内部调用WP_Query最终访问数据库完成数据读取。官方文档说明GET /wp‑json/wp/v2/posts大致等价于使用WP_Query获取文章集合。6.WP_Query是什么WP_Query是WordPress内置的核心PHP类文件位于wp‑includes/class‑wp‑query.php。作用专门封装文章、页面、自定义类型数据的查询逻辑接收一组查询参数内部自动拼接 SQL向 MySQL 数据库查询内容。关键点WP_Query本身不会自动读取HTTP请求参数。只有上层代码主动读取HTTP输入把参数传给WP_Query实例参数才会生效REST‑API接口内部就做了这一步参数搬运所以外部HTTP参数可以影响SQL。也就是说WP_Query属于WordPress的内部查询机制。攻击者只能发起HTTP请求无法直接在服务器PHP代码里直接赋值$query_vars[author__not_in]。攻击者必须找到一条可用数据流HTTP请求流入WordPress某个功能模块再由该功能模块把参数传递给WP_Query。REST API就是一类很重要的入口完成从HTTP到WordPress内部功能的对接。因此整体攻击数据流为攻击者发送HTTP请求请求到达REST API接口处理Posts资源查询内部调用WP_Query最后由WP_Query拼接生成SQL语句执行数据库查询。调用WP_Query在哪里“哪一处代码会调用WP_Query做文章查询在wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php中的WP_REST_Posts_Controller::get_items()分析如下第一层谁负责 Posts REST API文件wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php这里已经告诉我们Core class to access posts via the REST API。也就是用 REST API 访问 Posts 的核心类。WP_REST_Posts_Controller就是Posts 的 REST API Controller。第二层这个 Controller 怎么知道自己负责/posts看这个文件中的构造函数public function __construct()作用实例化这个类的时候自动执行接收文章类型参数做初始化。$this-namespace 是什么wp/v2$this-namespace ! empty( $obj-rest_namespace )? $obj-rest_namespace: wp/v2;默认注册文章类型时没有设置 rest_namespace$obj-rest_namespace为空。这里最终会得到$this-namespace的值为wp/v2。所以wp/v2就是这个 REST API Controller 所使用的 namespace。$this-rest_base 是什么posts$this-rest_base ! empty( $obj-rest_base )? $obj-rest_base: $obj-name;它决定这个资源在 REST API URL 中使用什么名字。$obj-name post数据类型内部名称单数注册 post 类型时手动设置了rest_baseposts$obj-rest_base不为空所以 $this-rest_base posts所以接口路径是 /wp‑json/wp/v2/posts而不是 /wp‑json/wp/v2/post对于 WordPress 默认的文章 Post Typepost对应的 REST base 是posts所以现在有$this-namespace的值为wp/v2$this-rest_base的值为posts这两个东西稍后会被拼起来第三层真正把/wp/v2/posts注册出来的代码在哪里核心代码是常量实际 HTTP 方法对应图中回调作用READABLEGETget_items()读取资源获取文章列表只读操作不会修改数据库CREATABLEPOSTcreate_item()新建资源创建一篇新文章写入数据库提交数据在这里调用了register_rest_route(函数register_rest_route(这个函数的作用向 WordPress REST API 注册一个路由。也就是说“以后有人访问这个 URL应该交给哪个代码处理”就是在这里建立关系。trim — 去除字符串首尾处的空白字符或者其他字符这里是/注册/wp/v2/posts这儿full_route最终拼接成/wp/v2/posts调用register_route完成注册894‑914 行判断命名空间是否存在不存在就自动注册一条命名空间索引路由$this‑endpoints作用内存里的 REST 路由注册表保存全部 API 接口的配置信息$this-endpoints[/wp/v2/posts] $route_args;$route_args单条 REST 接口全部运行配置。包含请求方法、业务回调、权限回调、请求参数校验规则、命名空间。注册时存入 endpoints请求到来读取它完成鉴权、参数校验、执行控制器函数。所以/wp/v2/posts 这个REST API 路由就是在这里注册出来的这里最重要的是callbackcallback array( $this, get_items ),它的意思不是“调用 get_items”。而是告诉 REST API以后有人请求这个路由并且符合这个方法时应该交给谁处理。这里指定访问接口 /wp/v2/posts程序匹配到对应的 REST 路由读取路由配置中的 callback 回调设置。callback 指定使用 WP_REST_Posts_Controller 对象的 get_items 方法程序调用该对象的 get_items () 方法该方法完成数据查询返回文章列表的 JSON 数据。所以GET /wp-json/wp/v2/posts最终对应WP_REST_Posts_Controller::get_items()所以现在整个关系已经是客户端发起 HTTP 请求GET /wp‑json/wp/v2/postsWordPress 框架剥离 URL 前缀/wp‑json得到内部路由路径/wp/v2/postsREST 服务在$this‑endpoints路由表匹配到/wp/v2/posts找到对应控制器 WP_REST_Posts_Controller根据 HTTP 方法为 GET调度执行控制器方法 get_items()get_items() 查询文章数据最终返回 JSON 响应。get_itms()查询public function get_items( $request )这就是刚才callback指向的函数。prepare_items_query将 REST 接口请求参数转换成WP_Query可用的查询条件数组$query_argsnew WP_Query()实例化 WordPress 文章查询对象$posts_query-query($query_args)传入查询条件执行数据库查询获取文章数据$query_result $posts_query-query( $query_args );这就是Posts REST API 最终调用WP_Query查询文章的真实代码prepare_items_query将 REST 接口的 HTTP 请求参数转换为 WP_Query 可识别的查询参数数组完成参数映射、适配处理返回组装好的$query_args供后续 WP_Query 执行数据库查询。例如GET /wp-json/wp/v2/posts?author_exclude2,6WordPress内部“翻译”请求到达服务器后prepare_items_query() 方法会负责将REST API 的参数author_exclude“翻译”成 WP_Query 能理解的参数。相关的映射关系在 WordPress 核心代码中有明确定义用于转换参数。author_exclude 这个用户友好的参数会被映射为 author__not_in。翻译后WP_Query 接收到的查询参数就是 author__not_in [2, 6]。执行查询WP_Query 拿到 author__not_in 参数后会构建 SQL 查询语句从数据库中排除作者 ID 为 2 和 6 的所有文章最终返回符合条件的结果。$query_args array( posts_per_page 10, // 默认值 paged 1, // 默认值 post_status publish, // 默认值 post_type post, // 默认值 orderby date, // 默认值 order DESC, // 默认值 author__not_in array(2, 6), // author_exclude 映射过来的 ignore_sticky_posts true, // 默认值 suppress_filters true, // 默认值 );真正执行WP_Query 查询文章的地方保存参数把传入的 $query 保存到对象的 query 和 query_vars 属性中。执行查询调用 return $this-get_posts()这个方法内部会构建 SQL 语句连接数据库把文章数据查出来并返回。这里也就回到了文章开头的查询部分。