The PHP core and its bundled extensions are often viewed as a mature and well-hardened attack surface, but low-level implementation bugs can still slip through. In our earlier research article Hack the Elephant One Bite at a Time: JPEG-Related Memory-Safety Bugs in PHP, we showed memory-safety issues triggered during JPEG parsing in PHP.
As we continued in this direction, we launched a deeper review of PDO as a whole, along with its individual components: the DBMS-specific drivers used to connect to different backends. The scope of this work grew to the point where the analysis surfaced additional vulnerabilities in third-party dependencies. In particular, we identified an integer overflow in PostgreSQL’s client library (libpq), documented in our write-up Attack arithmetic: how an integer overflow in PostgreSQL libpq leads to denial of service. We also uncovered an information disclosure issue in the Firebird 3 client library (fbclient): when a Firebird 3 client communicates with Firebird 4 or later server versions, incorrect length values in XSQLDA fields can trigger an out-of-bounds read and expose unintended data.
However, the core outcomes of our work relate to the PDO extension’s architecture itself. During the audit, we documented a number of driver-specific behaviors and multiple flaws in how drivers interact with their target DBMSs. In this article, we take a detailed look at two representative vulnerabilities:
- CVE-2025-14179: SQL injection in pdo_firebird via NUL bytes in quoted strings
- CVE-2025-14180: NULL Pointer Dereference in PDO quoting
Through hands-on examples, we show how a deep review of key driver routines exposed serious weaknesses in the defensive mechanisms that web application developers have trusted for years.
Interaction with a DBMS
PHP does not talk to databases directly. Connections and query execution always go through extensions. In practice, DB-related extensions are usually grouped into two categories: the generic PDO interface (which relies on DB-specific drivers such as pdo_pgsql) and native extensions such as mysqli, pgsql, or sqlite3. Internally, these components may use the DBMS vendor’s official client-side C library (for example, libpq), an embedded in-process engine (as with SQLite), or an intermediate ODBC layer. As a result, the extension you choose and the details of its low-level implementation shape not only functionality and compatibility, but also the attack surface and the resulting vulnerability vectors.

The diagram illustrates the overall architecture of DBMS access in PHP. Application code calls into a PHP extension (either through the abstract PDO interface or through a native extension). From there, queries are forwarded either to the target DBMS’s client library, to an embedded in-process engine (as with SQLite), or to a generic ODBC layer. This multi-stage structure is critical: the extension layer, together with the underlying library it relies on, determines compatibility, the available feature set, OS-specific behavior, and the relevant vulnerability vectors.
Below, we break down three key nuances of this architecture that often prompt questions.
1. What is ODBC and why is it shown as a separate layer?
Open Database Connectivity (ODBC) is a standardized, vendor-neutral interface for database access. An application (or a PHP extension) calls the ODBC API, and an ODBC driver manager routes those calls to the DBMS-specific ODBC driver. In architecture diagrams, ODBC is typically drawn as its own layer because it cleanly separates the common API surface from the DBMS-specific, low-level implementation details.
2. Why is the MS SQL Server stack more complex than for other databases?
In PHP, SQL Server support is typically provided by the pdo_sqlsrv or sqlsrv extensions. These are built on top of the Microsoft ODBC Driver for SQL Server, and on Unix-like systems they also require an ODBC driver manager such as unixODBC. This layered model is intentional: Microsoft owns and maintains the low-level driver that implements SQL Server-specific behavior (authentication, TLS, and protocol details), while the PHP extension exposes the familiar PDO surface. An alternative exists in the form of pdo_dblib, which bypasses ODBC by using the FreeTDS library, but it is not the primary option.
3. How do libmysqlclient and mysqlnd differ in the context of MySQL?
Historically, PHP’s MySQL extensions could be compiled against two different client libraries:
libmysqlclientis the traditional external client library maintained by the MySQL ecosystem.mysqlnd(MySQL Native Driver) is PHP’s native MySQL driver, built specifically for the PHP runtime and tightly integrated with the Zend Engine memory manager.
PHP’s official documentation recommends using the bundled mysqlnd library instead of libmysqlclient, because mysqlnd provides better performance, uses less memory, and does not require installing third-party system components. At the same time, legacy deployments may still support either backend.
It is recommended to use the mysqlnd library instead of the MySQL Client Server library (libmysqlclient). Both libraries are supported and constantly being improved.
https://www.php.net/manual/en/mysqlinfo.library.choosing.php
PHP Data Objects
PDO (PHP Data Objects) is PHP’s standard database abstraction layer, providing a consistent, object-oriented API for interacting with multiple DBMSs. As the name implies, connections and result sets are exposed as objects (PDO and PDOStatement). As a result, common workflows such as preparing and executing statements, managing transactions, and fetching rows look largely identical regardless of the backend driver. This reduces database-specific coupling and makes application code easier to maintain over time.

In practice, the target DBMS is selected when a connection is initialized. The DSN prefix, the part of the DSN before the colon (for example, pgsql:, mysql:, sqlite:), tells PHP which PDO driver to load. After that, all database access goes through the same PDO API, while differences are largely limited to supported DSN options, SQL dialect details, and driver-specific behavior.
<?php
// Connect to PostgreSQL
$dsn_pg = 'pgsql:host=localhost;port=5432;dbname=db';
$pdo_pg = new PDO($dsn_pg, 'user', 'password');
// Connect to Firebird
$dsn_fb = 'firebird:dbname=192.168.1.2:/data/db.fdb;charset=utf8;';
$pdo_fb = new PDO($dsn_fb, 'user', 'password');
Option 1. Emulation enabled (default mode)
In this mode, parameter preparation happens entirely on the PHP side. The pdo_mysql driver escapes the data itself, interpolates it into the SQL template, and sends a single fully formed text query to the server.
PHP code (pdo_prepare.php)
<?php
$pdo = new PDO('mysql:host=127.0.0.1;dbname=db;charset=utf8mb4', 'user', 'password');
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE id = ?');
$stmt->execute([1]); // Pass the number 1 as a parameter
foreach ($stmt->fetchAll() as $row) {
echo $row['id'] . ':' . $row['name'] . PHP_EOL;
}
MySQL general log
-- The server receives a plain text statement (the Query command).
-- Note: on the PHP side, the integer 1 ends up in the log as the quoted string '1'.
Id Command Argument
48 Connect user@localhost on db using TCP/IP
48 Query SELECT id, name FROM users WHERE id = '1'
48 Quit
Option 2. Emulation disabled (native statements)
With emulation turned off, PDO switches to MySQL’s native binary protocol. The SQL template and its bound parameters are transmitted separately in two steps: first, the statement is prepared, and then system calls are executed with the parameter values sent as unmodified data.
PHP code (pdo_prepare.php)
<?php
$options = [
PDO::ATTR_EMULATE_PREPARES => false, // Disable emulation
];
$pdo = new PDO('mysql:host=127.0.0.1;dbname=db;charset=utf8mb4', 'user', 'password', $options);
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE id = ?');
$stmt->execute([1]); // Pass the value 1
foreach ($stmt->fetchAll() as $row) {
echo $row['id'] . ':' . $row['name'] . PHP_EOL;
}
MySQL general log
-- The server handles the statement in two phases (Prepare and Execute).
-- Note: during Execute, the original value 1 is sent without quotes.
Id Command Argument
49 Connect user@localhost on db using TCP/IP
49 Prepare SELECT id, name FROM users WHERE id = ?
49 Execute SELECT id, name FROM users WHERE id = 1
49 Close stmt
49 Quit
The table below summarizes PDO driver support for prepared statement emulation and the resulting driver behavior across DBMS backends.
For drivers that use emulation, prepared statements effectively become client-side query rewriting: PDO replaces placeholders, escapes values, and then sends the server a final text query.
| PDO driver | ATTR_EMULATE_PREPARES | Default | How query is prepared |
|---|---|---|---|
| pdo_firebird | ❌ | — | always native prepare |
| pdo_mysql | ✅ | ON | emulation; can be disabled |
| pdo_odbc | ❌ | — | always native (via ODBC SQLPrepare) |
| pdo_pgsql | ✅ | OFF | native; emulation only when explicitly configured or in special modes |
| pdo_sqlite | ❌ | — | always native prepare |
| pdo_sqlsrv | ✅ | OFF | native; emulation is optional |
Discovered vulnerabilities
Vulnerability 1. SQL injection in pdo_firebird via NUL bytes in quoted strings
💥Security Issue, CVE-2025-14179, High, Aleksey Solovev, Nikita Sveshnikov (Positive Technologies)
Description
While analyzing how pdo_firebird tokenizes and prepares SQL queries, we found a vulnerability that breaks the intended query structure. What makes this case counterintuitive is that the driver-side quoting routine (quoter) itself operates as expected and produces properly escaped output. The problem occurs later: during PDO::prepare, pdo_firebird reparses and rebuilds the SQL and mishandles string literals containing a NUL byte (\0). As a result, the driver drops the terminating quote, allowing attacker-controlled input to spill out of the quoted literal and into the executable SQL context, enabling SQL injection.
Technical details
In the Firebird PDO driver, initial string sanitization is handled by firebird_handle_quoter, which is exposed through PDO::quote. This component behaves correctly.
Validating the quoting routine
To confirm this, we show that firebird_handle_quoter correctly escapes special characters across multiple encodings and representations, including quotes, backticks, and explicit NUL bytes.
PHP sanitization test code
<?php
$pdo = new PDO('firebird:dbname=127.0.0.1:/var/lib/firebird/data/mirror.fdb;charset=utf8;', 'user', 'password');
$p1 = $pdo->quote("alice'\x27\u{27}`\x60\u{60}\"\x22\u{22}\x00\u{00}");
$p2 = $pdo->quote("offensive'\x27\u{27}`\x60\u{60}\"\x22\u{22}\x00\u{00}");
$sql = "SELECT * FROM users WHERE username = $p1 AND department = $p2";
file_put_contents('./sql_query.dump', $sql);
Dump output (string and raw bytes via xxd)
$ ./php cli.php && cat sql_query.dump
SELECT * FROM users WHERE username = 'alice''''''```"""' AND department = 'offensive''''''```"""'
$ xxd sql_query.dump
00000000: 5345 4c45 4354 202a 2046 524f 4d20 7573 SELECT * FROM us
00000010: 6572 7320 5748 4552 4520 7573 6572 6e61 ers WHERE userna
00000020: 6d65 203d 2027 616c 6963 6527 2727 2727 me = 'alice'''''
00000030: 2760 6060 2222 2200 0027 2041 4e44 2064 '```"""..' AND d
00000040: 6570 6172 746d 656e 7420 3d20 276f 6666 epartment = 'off
00000050: 656e 7369 7665 2727 2727 2727 6060 6022 ensive''''''```"
00000060: 2222 0000 27 ""..'
firebird_handle_quoter behaves as intended: it doubles single quotes and correctly keeps NUL bytes (00) inside string literals delimited by ‘ (27). The quoted string context remains intact.
Breaking the logic inside PDO::prepare
The issue is triggered when an SQL string that has already been quoted and is syntactically valid is passed into the statement preparation path. The resulting destructive transformation of the query can be followed directly in the PHP core call flow:
1. Entry point into the PDO core
At the application layer, the code calls $pdo->prepare($query). The PDO core accepts the SQL string as-is and, without additional normalization or safety checks, forwards it to the database-specific driver by calling the driver’s preparer callback.
/* ext/pdo/pdo_dbh.c */
if (dbh->methods->preparer(dbh, query, &stmt, driver_options)) {
2. Control is transferred to the Firebird driver
At this point, execution moves into the Firebird PDO driver. The driver forwards the SQL string directly into its internal statement preparation path, php_firebird_alloc_prepare_stmt, without any additional normalization at the PDO layer.
/* ext/pdo_firebird/firebird_driver.c */
/* allocate and prepare statement */
if (!php_firebird_alloc_prepare_stmt(dbh, sql, &num_sqlda, &s, np)) {
break;
}
3. Query parsing and preprocessing is initiated
php_firebird_alloc_prepare_stmt calls php_firebird_preprocess, which tokenizes the incoming SQL string. The tokenizer, implemented as the php_firebird_get_token state machine, scans the SQL string byte-by-byte and splits it into logical tokens. The driver then rebuilds the statement by iterating over token types in a switch (tok) loop and appending the corresponding fragments into the sql_out buffer:
/* ext/pdo_firebird/firebird_driver.c */
while (p < end) {
start = p;
tok = php_firebird_get_token(&p, end);
switch (tok) {
4. Critical point: vulnerable strncat() call
Later in php_firebird_preprocess, control reaches the generic passthrough branch:
/* ext/pdo_firebird/firebird_driver.c */
case ttWhite:
case ttComment:
case ttString:
case ttOther:
strncat(sql_out, start, p - start);
break;
Rather than using a binary-safe, length-bounded copy routine such as memcpy(), the driver appends token data with strncat(). Since strncat() treats the first NUL byte (\0) as a string terminator, the append operation (copying the token contents into the sql_out buffer) truncates the token when NUL is present. This truncation causes the terminating single quote of the SQL string literal to be dropped, collapsing the intended quoting boundary and corrupting the resulting SQL statement after preprocessing.
Anatomy of statement reconstruction
The issue is rooted in how strncat(dest, src, n) handles NUL bytes in C. It stops appending as soon as it encounters the first NUL byte (\0) in the src string, completely ignoring the explicit byte count n (p - start) passed by the caller.
As a result, when PDO’s built-in quoting routine (PDO::quote) correctly quotes an input that contains a NUL byte, it produces a syntactically valid SQL literal such as ‘\0’ (opening quote, NUL byte, closing quote).
During statement reconstruction, however, strncat does the following:
- It appends the opening quote (‘).
- Ii immediately hits \0 and interprets it as end-of-string.
- It terminates the append early, effectively omitting the closing quote.
Once the closing quote is missing, the SQL parser’s intended string-literal boundary is lost. Subsequent attacker-controlled values can then be interpreted outside the quoted context, collapsing into the same literal and enabling user-controlled data to be parsed as executable SQL by the DBMS.
Expected SQL query (as intended by the developer):
SELECT * FROM users WHERE username = '\0' AND department = ' OR 1=1 --'
Actual SQL query (after the strncat() parsing failure):
SELECT * FROM users WHERE username = ' AND department = ' OR 1=1 --'
Reproduction steps
To illustrate exploitation, we use a simple PHP example that executes a typical query to a Firebird database.
A successful attack requires dynamic SQL construction where untrusted parameters are first quoted using PDO’s built-in quoting routine (PDO::quote), and the resulting SQL string is then passed into PDO::prepare for parsing and preparation. The attacker-controlled input must also include a NUL byte (\0).
In production web applications, developers often treat the pattern “quote with PDO::quote, then prepare with PDO::prepare” as inherently safe. This is deceptive: the quoting step is correct, but the later preprocessing and tokenization performed by the Firebird driver’s preparer logic corrupts the SQL string. That silent loss of quoting boundaries inside PHP’s statement preparation path can cause attacker-controlled data to be interpreted as executable SQL by the DBMS, enabling SQL injection.
Testbed setup (Firebird)
To support the SQL injection proof of concept, we set up an isolated Firebird database schema that models a company information system used to allocate personnel across security departments (“Offensive” and “Defensive”):
-- Recreate table (overwrites if already exists)
RECREATE TABLE users (
id INT NOT NULL PRIMARY KEY,
username VARCHAR(50) CHARACTER SET UTF8 NOT NULL,
department VARCHAR(50) CHARACTER SET UTF8 NOT NULL
);
-- Populate table with test data
INSERT INTO users (id, username, department) VALUES (1, 'alice', 'offensive');
INSERT INTO users (id, username, department) VALUES (2, 'bob', 'defensive');
-- Commit transaction to save changes in Firebird
COMMIT;
Realistic attack scenario (PHP)
To make the SQL injection impact explicit, we model a realistic workflow against the previously created users table.
We start with a common PHP pattern: looking up employees by department. The developer assumes the code is safe because every untrusted input parameter is passed through $dbh->quote() before being inserted into the dynamically constructed SQL string