Description
Summary
ext/pdo_odbc reads an ODBC diagnostic record into a fixed 512-byte buffer (einfo->last_err_msg) with SQLGetDiagRec(), then NUL-terminates it by writing at the index the driver reports as the diagnostic message length (errmsgsize). That index is not clamped to the buffer size. When the driver reports a length greater than or equal to the buffer size — which the ODBC contract permits whenever the message is truncated (SQL_SUCCESS_WITH_INFO) — einfo->last_err_msg[errmsgsize] = '\0' writes a NUL byte out of bounds, past the end of the buffer (which lives inside the heap-allocated handle/error-info structure).
The diagnostic message length is influenced by attacker-controlled SQL: an error such as no such table: <identifier> echoes a caller-supplied identifier into the message, so the message length (and therefore the write offset) tracks attacker input. With the standard SQLite3 ODBC driver the reported length is buffer-size + 1, producing a single-byte out-of-bounds NUL write; a driver that reports the full untruncated length on truncation (as allowed by the spec) places the NUL write further out of bounds, bounded by SQLSMALLINT range.
Details
pdo_odbc_error() declares errmsgsize and passes the 512-byte einfo->last_err_msg (minus one for the terminator) to SQLGetDiagRec():
ext/pdo_odbc/odbc_driver.c (around lines 58, 88–95):
SQLSMALLINT errmsgsize = 0;
...
rc = SQLGetDiagRec(htype, eh, recno++, (SQLCHAR *) einfo->last_state, &einfo->last_error,
(SQLCHAR *) einfo->last_err_msg, sizeof(einfo->last_err_msg)-1, &errmsgsize);
if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) {
errmsgsize = 0;
}
einfo->last_err_msg[errmsgsize] = '\0'; // <-- errmsgsize is the driver-reported length, not clamped to the buffer
The final argument of SQLGetDiagRec() (TextLengthPtr, here &errmsgsize) is, per the ODBC specification, "the total number of characters (excluding the null-termination) available to return" — i.e. on truncation it is the full length the message would have needed, which can be >= sizeof(einfo->last_err_msg). The code only resets errmsgsize to 0 on a hard error; for SQL_SUCCESS_WITH_INFO (the truncation case) it keeps the driver-reported value and uses it directly as the index for the NUL terminator. Because that index is never clamped to sizeof(einfo->last_err_msg) - 1, the write einfo->last_err_msg[errmsgsize] = '\0' lands outside the buffer.
einfo->last_err_msg is a fixed char[512] field inside the pdo_odbc_errinfo structure embedded in the heap-allocated PDO handle / statement, so the out-of-bounds NUL write corrupts adjacent structure memory.
The fix is to clamp the terminator index to sizeof(einfo->last_err_msg) - 1 (and to track truncation separately if needed).
The same code is present on the current master branch (verified 2026-04-13): einfo->last_err_msg[errmsgsize] = '\0' is still performed with the unclamped driver-reported length, with the errmsgsize reset only on hard error.
PoC
Environment:
- PHP 8.4.20 (CLI), built with
--enable-debug and AddressSanitizer/UBSAN (CFLAGS="-fsanitize=address,undefined -g -O0"), PDO_ODBC enabled.
- unixODBC 2.3.9.
- The ordinary SQLite3 ODBC driver from Ubuntu 22.04
libsqliteodbc (/etc/odbcinst.ini):
[SQLite3]
Description=SQLite3 ODBC Driver
Driver=libsqlite3odbc.so
Setup=libsqlite3odbc.so
PoC script (pdoodbc003.php). Querying a non-existent table whose name is a long, attacker-controlled identifier produces a long diagnostic message, so the driver reports a length beyond the 512-byte buffer:
<?php
$pdo = new PDO('odbc:Driver=SQLite3;Database=/tmp/odbc_audit.sqlite');
@$pdo->query('SELECT * FROM "' . str_repeat('A', 4096) . '"');
echo "done\n";
Run:
ASAN_OPTIONS=detect_leaks=0:halt_on_error=1:abort_on_error=1 \
UBSAN_OPTIONS=halt_on_error=1:abort_on_error=1:print_stacktrace=1 \
php pdoodbc003.php
Full UBSAN output:
/src/php/ext/pdo_odbc/odbc_driver.c:95:21: runtime error: index 512 out of bounds for type 'char [512]'
#0 0x55aa92faaef7 in pdo_odbc_error /src/php/ext/pdo_odbc/odbc_driver.c:95
#1 0x55aa92fac6d3 in odbc_handle_preparer /src/php/ext/pdo_odbc/odbc_driver.c:200
#2 0x55aa92ffceeb in zim_PDO_query /src/php/ext/pdo/pdo_dbh.c:1222
#3 0x55aa941660b7 in ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER /src/php/Zend/zend_vm_execute.h:1907
#4 0x55aa94462dbb in execute_ex /src/php/Zend/zend_vm_execute.h:58941
#5 0x55aa9448282d in zend_execute /src/php/Zend/zend_vm_execute.h:64328
#6 0x55aa9471ea0d in zend_execute_script /src/php/Zend/zend.c:1934
#7 0x55aa93bb88ab in php_execute_script_ex /src/php/main/main.c:2577
#8 0x55aa93bb8e41 in php_execute_script /src/php/main/main.c:2617
#9 0x55aa94726ff5 in do_cli /src/php/sapi/cli/php_cli.c:935
#10 0x55aa94729b87 in main /src/php/sapi/cli/php_cli.c:1310
#11 0x7fb8b6687d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
#12 0x7fb8b6687e3f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29e3f)
#13 0x55aa9200f2e4 in _start (/src/php/sapi/cli/php+0x360f2e4)
Aborted
Here the SQLite3 ODBC driver reports errmsgsize = 512, so the NUL is written at last_err_msg[512], one byte past the 512-byte buffer. The index is the driver-reported diagnostic length and is not bounded by the buffer; a driver that returns the full untruncated message length on truncation would write the NUL further out of bounds (up to SQLSMALLINT range), at an offset that tracks the attacker-influenced error-message length.
Impact
This is an out-of-bounds write (CWE-787) of a NUL byte at an index taken from an unclamped, driver-reported length (CWE-193 off-by-one in the observed case; CWE-131 incorrect buffer-size/index handling in general). The write corrupts memory adjacent to the last_err_msg buffer inside the heap-allocated PDO handle/error-info structure, which can crash the process or corrupt adjacent state; the write offset is influenced by attacker-controlled SQL that lengthens the diagnostic message.
Who is impacted: applications using ext/pdo_odbc that surface diagnostics for failing statements (the normal error path) where the failing SQL embeds attacker-influenced text that lengthens the driver's error message (for example a long identifier echoed into a "no such table"/syntax error). No malicious driver, FFI, callback, or object-lifecycle misuse is required. The severity of a given instance depends on how the ODBC driver reports truncated-message lengths: the standard SQLite3 driver yields a single-byte over-write, while spec-conforming drivers that report the full length yield a larger out-of-bounds write.
PHP Version
Operating System
ubuntu22.04
Description
Summary
ext/pdo_odbcreads an ODBC diagnostic record into a fixed 512-byte buffer (einfo->last_err_msg) withSQLGetDiagRec(), then NUL-terminates it by writing at the index the driver reports as the diagnostic message length (errmsgsize). That index is not clamped to the buffer size. When the driver reports a length greater than or equal to the buffer size — which the ODBC contract permits whenever the message is truncated (SQL_SUCCESS_WITH_INFO) —einfo->last_err_msg[errmsgsize] = '\0'writes a NUL byte out of bounds, past the end of the buffer (which lives inside the heap-allocated handle/error-info structure).The diagnostic message length is influenced by attacker-controlled SQL: an error such as
no such table: <identifier>echoes a caller-supplied identifier into the message, so the message length (and therefore the write offset) tracks attacker input. With the standard SQLite3 ODBC driver the reported length is buffer-size + 1, producing a single-byte out-of-bounds NUL write; a driver that reports the full untruncated length on truncation (as allowed by the spec) places the NUL write further out of bounds, bounded bySQLSMALLINTrange.Details
pdo_odbc_error()declareserrmsgsizeand passes the 512-byteeinfo->last_err_msg(minus one for the terminator) toSQLGetDiagRec():ext/pdo_odbc/odbc_driver.c(around lines 58, 88–95):The final argument of
SQLGetDiagRec()(TextLengthPtr, here&errmsgsize) is, per the ODBC specification, "the total number of characters (excluding the null-termination) available to return" — i.e. on truncation it is the full length the message would have needed, which can be>= sizeof(einfo->last_err_msg). The code only resetserrmsgsizeto 0 on a hard error; forSQL_SUCCESS_WITH_INFO(the truncation case) it keeps the driver-reported value and uses it directly as the index for the NUL terminator. Because that index is never clamped tosizeof(einfo->last_err_msg) - 1, the writeeinfo->last_err_msg[errmsgsize] = '\0'lands outside the buffer.einfo->last_err_msgis a fixedchar[512]field inside thepdo_odbc_errinfostructure embedded in the heap-allocated PDO handle / statement, so the out-of-bounds NUL write corrupts adjacent structure memory.The fix is to clamp the terminator index to
sizeof(einfo->last_err_msg) - 1(and to track truncation separately if needed).The same code is present on the current
masterbranch (verified 2026-04-13):einfo->last_err_msg[errmsgsize] = '\0'is still performed with the unclamped driver-reported length, with theerrmsgsizereset only on hard error.PoC
Environment:
--enable-debugand AddressSanitizer/UBSAN (CFLAGS="-fsanitize=address,undefined -g -O0"),PDO_ODBCenabled.libsqliteodbc(/etc/odbcinst.ini):PoC script (
pdoodbc003.php). Querying a non-existent table whose name is a long, attacker-controlled identifier produces a long diagnostic message, so the driver reports a length beyond the 512-byte buffer:Run:
Full UBSAN output:
Here the SQLite3 ODBC driver reports
errmsgsize = 512, so the NUL is written atlast_err_msg[512], one byte past the 512-byte buffer. The index is the driver-reported diagnostic length and is not bounded by the buffer; a driver that returns the full untruncated message length on truncation would write the NUL further out of bounds (up toSQLSMALLINTrange), at an offset that tracks the attacker-influenced error-message length.Impact
This is an out-of-bounds write (CWE-787) of a NUL byte at an index taken from an unclamped, driver-reported length (CWE-193 off-by-one in the observed case; CWE-131 incorrect buffer-size/index handling in general). The write corrupts memory adjacent to the
last_err_msgbuffer inside the heap-allocated PDO handle/error-info structure, which can crash the process or corrupt adjacent state; the write offset is influenced by attacker-controlled SQL that lengthens the diagnostic message.Who is impacted: applications using
ext/pdo_odbcthat surface diagnostics for failing statements (the normal error path) where the failing SQL embeds attacker-influenced text that lengthens the driver's error message (for example a long identifier echoed into a "no such table"/syntax error). No malicious driver, FFI, callback, or object-lifecycle misuse is required. The severity of a given instance depends on how the ODBC driver reports truncated-message lengths: the standard SQLite3 driver yields a single-byte over-write, while spec-conforming drivers that report the full length yield a larger out-of-bounds write.PHP Version
Operating System
ubuntu22.04