Blog

A NUL byte breaks PDO’s protection: how an escaped string turns into a SQL injection, and an ordinary parameter crashes your server

pdo-dual-cve
CVE / Database / Linux / PHP / Security / SQL

A NUL byte breaks PDO’s protection: how an escaped string turns into a SQL injection, and an ordinary parameter crashes your server

A developer does everything right: escapes user input with PDO::quote(), drops the result into a SQL query, calls PDO::prepare(). It’s the standard pattern people have trusted for years. But if that input contains a single NUL byte, the escaping breaks — not where anyone checked, but one step later, inside the Firebird driver itself. The closing quote goes missing, and the next chunk of the query, which was supposed to stay data, turns into executable SQL.

That’s CVE-2025-14179, a SQL injection in the pdo_firebird driver, found by Aleksey Solovev and Nikita Sveshnikov at Positive Technologies. NVD scores it CVSS 9.8, Critical: network-reachable, low complexity, no authentication needed, no user interaction required, full compromise of your data as the outcome. PHP Group itself rates it a bit more conservatively — 7.4, High, under the newer CVSS 4.0 methodology — but that’s still a serious problem, just measured on a different scale. CWE-89, textbook SQL injection.

Right alongside it, a second, unrelated story surfaced — CVE-2025-14180, a denial of service in the pdo_pgsql driver for PostgreSQL. CVSS 7.5, High here: also network-reachable, also low complexity, but it only hits availability — the server crashes, it doesn’t leak anyone’s data. No working public exploit for either hole yet, but the technical mechanics in both cases are laid out clearly enough that writing a PoC is an evening’s work, not a research project.

WHAT PDO IS

PDO is the layer PHP uses to talk to databases. The idea is simple: developers write queries through one common interface, and under the hood, whichever driver applies — MySQL, PostgreSQL, Firebird, SQLite — translates those calls into that database’s own language. The payoff is huge: nobody has to memorize each database’s escaping syntax separately, PDO handles it.

Which is exactly why a bug inside PDO itself isn’t one application’s problem — it’s thousands of applications’ problem at once. When the protection breaks inside the driver instead of in the developer’s own code, every piece of code that did everything right and simply trusted the abstraction suddenly turns out to be vulnerable, without a single mistake on its own part.

HOW THE FIREBIRD BUG WORKS

The driver builds a SQL query token by token, and in the PHP source that assembly lives in the function php_firebird_preprocess. For string tokens, it calls strncat(sql_out, start, p - start) — an ordinary C string-copy function, instead of a safer byte-for-byte memcpy().

And that’s exactly where the trap sits. In C, a string traditionally ends wherever a NUL byte shows up — it’s baked into the language itself. If user input contains one, strncat() stops copying right there. The closing quote that PDO::quote() honestly placed during escaping never makes it into the query buffer — it sat right after the NUL byte, and the copy never got that far. The escaping itself did its job correctly; everything breaks one step later, when the driver reassembles the query and glues the token — minus its closing quote — onto whatever comes next.

The result: the query loses the line between “data” and “code.” Everything that was supposed to stay plain string content gets read by Firebird’s parser as a continuation of that same literal, or as fresh SQL. PHP’s own developers say that’s enough for a trivial injection — as long as the next chunk of the query is also attacker-controlled.

HOW IT GETS EXPLOITED

What makes the trigger condition dangerous is exactly how ordinary it is: it needs precisely the pattern developers have considered safe for years — escape with PDO::quote(), drop it into a dynamically built query string, hand it to PDO::prepare(). Most people never even think about the risk here, because the input formally went through proper escaping. The problem is that the protection tears not where anyone was checking, but deeper — on the driver’s side.

All an attacker needs is a NUL byte in some value that ends up in a query like that. The advisory itself shows this in three lines: escape a string containing a single NUL byte with PDO::quote(), escape a second string — say, or 1=1-- — with that same PDO::quote(), and drop both results into WHERE name = ... AND name = .... After escaping, the query looks perfectly safe. After the driver reassembles it before sending it to Firebird, the quote after the NUL byte is gone, and both WHERE conditions collapse into one — the query returns every row in the table instead of one specific row. In a real attack, that demonstration or 1=1-- becomes a full UNION SELECT, pulling out Firebird’s internal data, including the engine version — exactly the kind of thing an application was never supposed to hand out.

HOW THE POSTGRESQL BUG WORKS

The second hole lives in the pdo_pgsql driver and only fires under one specific condition — if the application explicitly turned on PDO::ATTR_EMULATE_PREPARES, PHP’s own emulation of prepared statements instead of PostgreSQL’s native ones. In that mode, PDO doesn’t send the query and its parameters separately — it substitutes values into the SQL template on the PHP side, escapes them, and ships the finished string to the server.

Escaping here runs through PostgreSQL’s own library function, PQescapeStringConn. If the input contains a byte sequence that’s invalid for the connection’s current encoding — the researchers’ report uses \x99 as the example — that function returns NULL instead of an escaped string. That NULL, completely unchecked, gets assigned straight into the plc->quoted field of PDO’s internal structure.

From there, pdo_parse_params() takes over, reaching for that string’s length through the macro ZSTR_LEN(plc->quoted) — except the pointer is already NULL at that point. Null pointer dereference, segfault, and the OS kills the PHP process on the spot.

WHY THIS IS DANGEROUS EVEN WITHOUT RCE

For a web application, a crash like this isn’t just brief downtime. The PHP worker dies mid-request, and try/catch is powerless here: the segfault happens at the level of the interpreter’s own C code, not a PHP exception, so the application’s error handling never even sees it.

Positive Technologies walked through this using a hypothetical digital licensing service as an example: the user’s balance has already been charged, and then the process crashes trying to save an order comment containing a bad byte. End result — money’s gone, no order record exists, inventory is untouched, no invoice was generated, and the user just sees a generic backend error with no idea what happened to their payment.

The risk climbs in multi-step operations that don’t use explicit transactions. On autocommit, part of the changes can easily land in the database before the process dies — and every step that was supposed to follow simply never runs. In a distributed setup, where services call each other, the same kind of crash can leave several systems out of sync at once — especially if the component had already fired off an outbound request or committed some intermediate step elsewhere before it went down.

WHAT DETERMINES THE OUTCOME

For the Firebird SQL injection, the outcome is always the same — if your code uses pdo_firebird with the “quote plus dynamic string assembly” pattern, it’s vulnerable, full stop, no softer version of this exists.

For the PostgreSQL crash, everything hinges on one setting — whether PDO::ATTR_EMULATE_PREPARES is turned on. With PostgreSQL’s own native prepared statements, the attack simply doesn’t fire: escaping through PQescapeStringConn never comes into play in that code path at all. Plenty of modern frameworks already default to native prepares — but emulation still gets switched on by hand fairly often, usually to work around specific PostgreSQL limitations on prepared statements. Those are exactly the applications sitting in the blast radius.

UPDATE

PHP Group has already closed both. The Firebird SQL injection got fixed in 8.2.31, 8.3.31, 8.4.21, and 8.5.6. The PostgreSQL crash was patched earlier, in 8.1.34, 8.2.30, 8.3.29, 8.4.16, and 8.5.1. Check your own version like this:

php -v

If your version is below what’s needed for your branch, it’s time to update. Here’s the catch: Debian and Ubuntu’s default repositories update PHP fairly slowly, and getting a fresh patch release like 8.3.31 out of the box doesn’t always happen. Plenty of production servers run PHP through Ondřej Surý’s third-party repository (packages.sury.org) instead — that’s the one that actually publishes current minor versions for these distributions. If you’ve got that repository connected, updating within your branch is a routine apt update followed by installing the package you need (php8.3, php8.4, and so on). Before that, it’s worth checking apt-cache policy php8.3 (swap in your own version) to see which version is actually available as a candidate in your repository.

After updating, running php -v again should show a version number at or above the patched one. If your distro or image hasn’t shipped the needed release yet, PHP’s own GitHub always lets you check the specific commit that closes a given CVE, so you can confirm the version your package manager is offering actually includes the fix — not just a newer number with unrelated changes.

If updating right now isn’t an option, the PostgreSQL hole has a working temporary fix — just turn off the emulation mode:

$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

That switches PDO over to PostgreSQL’s native prepared statements — a code path where the vulnerable PQescapeStringConn never gets called at all. But it’s a workaround, not a fix: it only holds as long as nobody flips that attribute back on later.

For the Firebird SQL injection, there’s no equally simple workaround — the protection breaks inside the driver after escaping has already happened, and no configuration setting saves you there. The only thing that actually cuts the risk before you update is explicitly rejecting input containing a NUL byte at the application level, before it ever reaches PDO::quote():

if (strpos($input, "\0") !== false) {
    throw new InvalidArgumentException('NUL byte detected in input');
}

That closes this specific attack vector, but not the underlying vulnerability in the driver — you still need the PHP update.

WHY IT MATTERS

Both stories hit the same sore spot: a PHP application’s security depends not just on what the developer wrote, but on how the runtime and low-level drivers behave underneath the abstractions everyone relies on. An escaping mechanism people have trusted for years can break not where anyone was checking, but one layer deeper — somewhere a developer physically can’t reach without knowing the internals of a specific driver.

For owners of WordPress and Joomla sites with custom-configured connections to Firebird or PostgreSQL — and honestly for any custom PHP code touching these databases — the risk is direct: SQL injection means someone else’s data, altered or deleted records, while the PostgreSQL driver crash is a real outage that hits conversion and reputation. For hosting providers running a pile of PHP sites on shared infrastructure, this is also a good reason to run a scheduled PHP version audit across the whole fleet — clients themselves almost never track runtime patches on their own.

CONCLUSIONS

Got PHP applications using the Firebird driver through PDO? Update to 8.2.31, 8.3.31, 8.4.21, or 8.5.6 right now — this is a critical injection with no reasonable workaround.

Working with PostgreSQL through PDO with PDO::ATTR_EMULATE_PREPARES enabled? Either update to 8.1.34, 8.2.30, 8.3.29, 8.4.16, or 8.5.1, or, if that’s not possible right now, explicitly disable that attribute as a stopgap.

Run hosting with other people’s PHP sites on it? Inventory PHP versions across your whole fleet and don’t count on clients updating themselves — both holes are exploitable remotely, without authentication, as long as the vulnerable code exists somewhere in the application.

And either way — don’t treat PDO’s escaping as an unconditional guarantee. As both of these cases show, the actual security boundary can sit somewhere you didn’t expect it — not in your own code, but at the driver level, where most developers never think to look.

Leave your thought here

Your email address will not be published. Required fields are marked *