Skip to content

Heap buffer overflow (out-of-bounds write) in ext/pdo_odbc when an INPUT_OUTPUT / output bound parameter is given a runtime value longer than the declared maxlen #22666

Description

@cxxz16

Description

Summary

ext/pdo_odbc sizes the output buffer for an INPUT_OUTPUT (or output) bound parameter from the maxlen length the caller declares in PDOStatement::bindParam(), allocating maxlen + 1 bytes. At execute time, however, it copies the runtime input value into that buffer using the input value's length (Z_STRLEN) instead of the allocated capacity, without checking that it fits. When the runtime value is longer than the declared maxlen, the memcpy() writes past the end of the heap buffer.

This is an out-of-bounds heap write whose length and content are taken from the runtime parameter value. In the common deployment where the bound value originates from request input, an attacker who can make that value longer than the application's declared maxlen triggers a heap buffer overflow (process crash / heap corruption).

Reachability is narrower than a plain result fetch: it requires the application to bind a parameter as PDO::PARAM_INPUT_OUTPUT (or as an output parameter) and declare a maxlen smaller than the value actually supplied. PARAM_INPUT_OUTPUT is an uncommon binding mode, so this is reported as a real but lower-reachability memory-safety defect. The underlying cause is entirely inside PHP — PHP controls both the allocation size and the copy length and computes them from two different quantities.

Details

When binding a parameter that is not pure input, odbc_stmt_param_hook() (event PDO_PARAM_EVT_ALLOC) sizes the output buffer from param->max_value_len (the maxlen argument to bindParam()) and allocates P->len + 1 bytes:

ext/pdo_odbc/odbc_stmt.c (around lines 379–386):

            if (P->paramtype != SQL_PARAM_INPUT) {
                if (PDO_PARAM_TYPE(param->param_type) != PDO_PARAM_NULL) {
                    /* need an explicit buffer to hold result */
                    P->len = param->max_value_len > 0 ? param->max_value_len : precision;
                    if (P->is_unicode) {
                        P->len *= 2;
                    }
                    P->outbuf = emalloc(P->len + (P->is_unicode ? 2:1));
                }
            }

At execute time (event PDO_PARAM_EVT_EXEC_PRE), it converts the bound variable to a string and, if an output buffer exists, replaces P->len with the input string's length and memcpy()s that many bytes into P->outbuf — with no comparison against the buffer capacity that was allocated above:

ext/pdo_odbc/odbc_stmt.c (around lines 473–490):

            } else {
                convert_to_string(parameter);
                if (P->outbuf) {
                    zend_ulong ulen;
                    switch (pdo_odbc_utf82ucs2(stmt, P->is_unicode,
                            Z_STRVAL_P(parameter),
                            Z_STRLEN_P(parameter),
                            &ulen)) {
                        case PDO_ODBC_CONV_FAIL:
                        case PDO_ODBC_CONV_NOT_REQUIRED:
                            P->len = Z_STRLEN_P(parameter);
                            memcpy(P->outbuf, Z_STRVAL_P(parameter), P->len);   // <-- copies Z_STRLEN bytes into a (max_value_len+1) buffer
                            break;
                        case PDO_ODBC_CONV_OK:
                            P->len = ulen;
                            memcpy(P->outbuf, S->convbuf, P->len);
                            break;
                    }
                } else {

The buffer is emalloc(max_value_len + 1) but the copy length is Z_STRLEN_P(parameter) (the runtime value length). If Z_STRLEN > max_value_len, the memcpy() writes Z_STRLEN - (max_value_len + 1) + 1 bytes past the end of the allocation. The overflow length and the overflow bytes are both taken directly from the runtime parameter value.

The same unchecked-copy pattern exists in the LOB-string branch a few lines above:

ext/pdo_odbc/odbc_stmt.c (around lines 462–466):

                } else {
                    convert_to_string(parameter);
                    if (P->outbuf) {
                        P->len = Z_STRLEN_P(parameter);
                        memcpy(P->outbuf, Z_STRVAL_P(parameter), P->len);   // same: copies Z_STRLEN into a max_value_len-sized buffer
                    } else {

Both copy sites should clamp to (or validate against) the originally allocated capacity rather than trusting the runtime value length.

The same code is present on the current master branch (verified 2026-04-13): the allocation still uses max_value_len, and the execute-time copies still use Z_STRLEN_P(parameter) with no bound check.

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 (pdoodbc002.php). The parameter is bound as INPUT_OUTPUT with maxlen = 4 (a 5-byte buffer), but the runtime value is 6 bytes:

<?php
$pdo = new PDO('odbc:Driver=SQLite3;Database=/tmp/odbc_audit.sqlite');
$value = str_repeat('A', 6);
$stmt = $pdo->prepare('SELECT ?');
$stmt->bindParam(1, $value, PDO::PARAM_STR | PDO::PARAM_INPUT_OUTPUT, 4);
$stmt->execute();

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

Full AddressSanitizer output:

=================================================================
==1135799==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x50200000a4d5 at pc 0x7f7c9c3e12c3 bp 0x7ffd0e756ee0 sp 0x7ffd0e756688
WRITE of size 6 at 0x50200000a4d5 thread T0
    #0 0x7f7c9c3e12c2 in __interceptor_memcpy ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:827
    #1 0x562809bb7d41 in odbc_stmt_param_hook /src/php/ext/pdo_odbc/odbc_stmt.c:484
    #2 0x562809c099e8 in dispatch_param_event /src/php/ext/pdo/pdo_stmt.c:112
    #3 0x562809c0fef1 in zim_PDOStatement_execute /src/php/ext/pdo/pdo_stmt.c:458
    #4 0x56280ad660b7 in ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER /src/php/Zend/zend_vm_execute.h:1907
    #5 0x56280b062dbb in execute_ex /src/php/Zend/zend_vm_execute.h:58941
    #6 0x56280b08282d in zend_execute /src/php/Zend/zend_vm_execute.h:64328
    #7 0x56280b31ea0d in zend_execute_script /src/php/Zend/zend.c:1934
    #8 0x56280a7b88ab in php_execute_script_ex /src/php/main/main.c:2577
    #9 0x56280a7b8e41 in php_execute_script /src/php/main/main.c:2617
    #10 0x56280b326ff5 in do_cli /src/php/sapi/cli/php_cli.c:935
    #11 0x56280b329b87 in main /src/php/sapi/cli/php_cli.c:1310
    #12 0x7f7c95651d8f  (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
    #13 0x7f7c95651e3f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29e3f)
    #14 0x562808c0f2e4 in _start (/src/php/sapi/cli/php+0x360f2e4)

0x50200000a4d5 is located 0 bytes to the right of 5-byte region [0x50200000a4d0,0x50200000a4d5)
allocated by thread T0 here:
    #0 0x7f7c9c45b887 in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:145
    #1 0x56280ab2efa1 in __zend_malloc /src/php/Zend/zend_alloc.c:3294
    #2 0x56280ab29abd in _emalloc /src/php/Zend/zend_alloc.c:2740
    #3 0x562809bb6185 in odbc_stmt_param_hook /src/php/ext/pdo_odbc/odbc_stmt.c:386
    #4 0x562809c0e0a9 in really_register_bound_param /src/php/ext/pdo/pdo_stmt.c:367
    #5 0x562809c2249c in register_bound_param /src/php/ext/pdo/pdo_stmt.c:1460
    #6 0x562809c2399a in zim_PDOStatement_bindParam /src/php/ext/pdo/pdo_stmt.c:1518
    #7 0x56280ad660b7 in ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER /src/php/Zend/zend_vm_execute.h:1907
    #8 0x56280b062dbb in execute_ex /src/php/Zend/zend_vm_execute.h:58941
    #9 0x56280b08282d in zend_execute /src/php/Zend/zend_vm_execute.h:64328
    #10 0x56280b31ea0d in zend_execute_script /src/php/Zend/zend.c:1934
    #11 0x56280a7b88ab in php_execute_script_ex /src/php/main/main.c:2577
    #12 0x56280a7b8e41 in php_execute_script /src/php/main/main.c:2617
    #13 0x56280b326ff5 in do_cli /src/php/sapi/cli/php_cli.c:935
    #14 0x56280b329b87 in main /src/php/sapi/cli/php_cli.c:1310
    #15 0x7f7c95651d8f  (/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 5-byte allocation comes from odbc_stmt_param_hook() at line 386 (maxlen = 4, so emalloc(4 + 1)); the 6-byte write comes from the same function at line 484 (copying the 6-byte runtime value). With a larger runtime value the overflow grows accordingly, and both the length and the written bytes are attacker-controlled.

Impact

This is an out-of-bounds heap write (CWE-787 / CWE-122) caused by computing the destination buffer size and the copy length from two different quantities (CWE-131): the buffer is sized from the declared maxlen, but the copy length is the runtime value length. The overflow length and content are taken from the runtime parameter value, so where the pattern is reachable it is a controlled-length, controlled-content heap overflow (heap corruption, crash, and potentially worse), not merely an over-read.

Who is impacted: applications using ext/pdo_odbc that bind a parameter with PDO::PARAM_INPUT_OUTPUT (or an output parameter) declaring a maxlen and then execute with a bound value longer than that maxlen — typically when the bound value comes from request input. Because PARAM_INPUT_OUTPUT is an uncommon binding mode and the application must also under-declare maxlen, the reachability is narrower than the result-fetch over-reads; the impact when reached is high (attacker-controlled heap write). PHP should bound the copy to the allocated capacity (or reallocate / reject) rather than trusting the runtime value length.

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