[php-src] Issue #22665: Out-of-bounds write in `ext/pdo_odbc` (`pdo_odbc_error()`) when the ODBC driver reports a diagnostic message length beyond the fixed `last_err_msg` buffer

[email protected] (cxxz16) Fri, 10 Jul 2026 07:48:00 +0000
Newsgroups php.bugs
Message-ID <rYbmas7NZ0ZdyBhQeq66lNcRTdbNxwEdSjZlfkCUO9w@main.internal.php.net>
Issue: https://github.com/php/php-src/issues/22665
Author: cxxz16

### 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):

```c
    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`):

```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
<?php
$pdo = new PDO('odbc:Driver=SQLite3;Database=/tmp/odbc_audit.sqlite');
@$pdo->query('SELECT * FROM "' . str_repeat('A', 4096) . '"');
echo "done\n";
```

Run:

```sh
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:

```text
/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

```plain
php<=8.5.6
```

### Operating System

ubuntu22.04