Skip to content

pdo_odbc: NULL values come back as garbage binary strings (8.5.9 regression) #23016

Closed

Description

@yinheli

Description

After upgrading from PHP 8.5.8 to 8.5.9, NULL values fetched through pdo_odbc come back as several kilobytes of binary garbage instead of empty values. The garbage looks like raw memory contents: pointers, leftover values from previous rows, random process data.

I hit this with the Snowflake ODBC driver, where every NULL cell in the result set is affected. Nothing else changed — same data, same driver version, same queries, only the PHP version. Reproduced in clean php:8.5.8 and php:8.5.9 Docker containers: 8.5.8 returns an empty string, 8.5.9 returns garbage.

Reproduction

With the help of AI, I put together a minimal PoC that reproduces this without an external database: a small ODBC wrapper driver (full source at the bottom) that forwards everything to the system SQLite3 ODBC driver and only changes two things to behave like the Snowflake driver — it reports a huge display size for the column, and reports NULL values the same way Snowflake does.

$ gcc -shared -fPIC -o wrapodbc.so wrapodbc.c -lodbc -ldl
$ printf '[WrapODBC]\nDriver=%s\n' "$PWD/wrapodbc.so" >> /etc/odbcinst.ini
$ php repro.php

repro.php:

$pdo = new PDO('odbc:Driver={WrapODBC};Database=:memory:');
$row = $pdo->query('SELECT CAST(NULL AS TEXT) AS data')->fetch(PDO::FETCH_ASSOC);
$v = $row['data'];
printf("type=%s len=%d\n", gettype($v), is_string($v) ? strlen($v) : -1);
if (is_string($v) && strlen($v) > 0) {
    printf("first 32 bytes (hex): %s\n", bin2hex(substr($v, 0, 32)));
}

Expected (PHP 8.5.8):

type=string len=0

Actual (PHP 8.5.9):

type=string len=4064
first 32 bytes (hex): d019a9fdff7f00000090a9fdff7f000000000000000000008400000001000000

Every NULL value comes back as one of these garbage strings; with multiple rows, the leading bytes are typically the previous row's value left in the reused fetch buffer. Besides breaking NULL semantics, this leaks process memory into query results.

wrapodbc.c

#define _GNU_SOURCE
#include <sql.h>
#include <sqlext.h>
#include <dlfcn.h>
#include <string.h>

#define REAL_DRIVER "/usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so"
#define HUGE_DISPLAY_SIZE 16777216

typedef SQLRETURN (SQL_API *fnptr_t)();

static void *g_real = NULL;

static fnptr_t real_sym(const char *name)
{
    if (g_real == NULL) {
        g_real = dlopen(REAL_DRIVER, RTLD_NOW | RTLD_GLOBAL);
    }
    return (fnptr_t)dlsym(g_real, name);
}

#define FWD(name, args, callargs) \
SQLRETURN SQL_API name args { return real_sym(#name) callargs; }

FWD(SQLAllocHandle, (SQLSMALLINT t, SQLHANDLE i, SQLHANDLE *o), (t, i, o))
FWD(SQLFreeHandle, (SQLSMALLINT t, SQLHANDLE h), (t, h))
FWD(SQLSetEnvAttr, (SQLHENV h, SQLINTEGER a, SQLPOINTER v, SQLINTEGER l), (h, a, v, l))
FWD(SQLGetEnvAttr, (SQLHENV h, SQLINTEGER a, SQLPOINTER v, SQLINTEGER bl, SQLINTEGER *sl), (h, a, v, bl, sl))
FWD(SQLSetConnectAttr, (SQLHDBC h, SQLINTEGER a, SQLPOINTER v, SQLINTEGER l), (h, a, v, l))
FWD(SQLGetConnectAttr, (SQLHDBC h, SQLINTEGER a, SQLPOINTER v, SQLINTEGER bl, SQLINTEGER *sl), (h, a, v, bl, sl))
FWD(SQLDriverConnect, (SQLHDBC h, SQLHWND w, SQLCHAR *i, SQLSMALLINT il, SQLCHAR *o, SQLSMALLINT ol, SQLSMALLINT *olp, SQLUSMALLINT d), (h, w, i, il, o, ol, olp, d))
FWD(SQLConnect, (SQLHDBC h, SQLCHAR *s, SQLSMALLINT sl, SQLCHAR *u, SQLSMALLINT ul, SQLCHAR *p, SQLSMALLINT pl), (h, s, sl, u, ul, p, pl))
FWD(SQLDisconnect, (SQLHDBC h), (h))
FWD(SQLGetFunctions, (SQLHDBC h, SQLUSMALLINT f, SQLUSMALLINT *s), (h, f, s))
FWD(SQLGetInfo, (SQLHDBC h, SQLUSMALLINT t, SQLPOINTER v, SQLSMALLINT ml, SQLSMALLINT *tl), (h, t, v, ml, tl))
FWD(SQLPrepare, (SQLHSTMT s, SQLCHAR *q, SQLINTEGER l), (s, q, l))
FWD(SQLExecute, (SQLHSTMT s), (s))
FWD(SQLExecDirect, (SQLHSTMT s, SQLCHAR *q, SQLINTEGER l), (s, q, l))
FWD(SQLNumResultCols, (SQLHSTMT s, SQLSMALLINT *n), (s, n))
FWD(SQLRowCount, (SQLHSTMT s, SQLLEN *n), (s, n))
FWD(SQLFetch, (SQLHSTMT s), (s))
FWD(SQLFetchScroll, (SQLHSTMT s, SQLSMALLINT o, SQLLEN off), (s, o, off))
FWD(SQLGetDiagRec, (SQLSMALLINT t, SQLHANDLE h, SQLSMALLINT r, SQLCHAR *st, SQLINTEGER *ne, SQLCHAR *mt, SQLSMALLINT ml, SQLSMALLINT *tl), (t, h, r, st, ne, mt, ml, tl))
FWD(SQLGetDiagField, (SQLSMALLINT t, SQLHANDLE h, SQLSMALLINT r, SQLSMALLINT f, SQLPOINTER v, SQLSMALLINT ml, SQLSMALLINT *tl), (t, h, r, f, v, ml, tl))
FWD(SQLMoreResults, (SQLHSTMT s), (s))
FWD(SQLGetStmtAttr, (SQLHSTMT s, SQLINTEGER a, SQLPOINTER v, SQLINTEGER bl, SQLINTEGER *sl), (s, a, v, bl, sl))
FWD(SQLSetStmtAttr, (SQLHSTMT s, SQLINTEGER a, SQLPOINTER v, SQLINTEGER sl), (s, a, v, sl))
FWD(SQLEndTran, (SQLSMALLINT t, SQLHANDLE h, SQLSMALLINT c), (t, h, c))
FWD(SQLNumParams, (SQLHSTMT s, SQLSMALLINT *n), (s, n))
FWD(SQLCancel, (SQLHSTMT s), (s))
FWD(SQLCloseCursor, (SQLHSTMT s), (s))
FWD(SQLBindCol, (SQLHSTMT s, SQLUSMALLINT c, SQLSMALLINT t, SQLPOINTER v, SQLLEN bl, SQLLEN *i), (s, c, t, v, bl, i))
FWD(SQLAllocStmt, (SQLHDBC h, SQLHSTMT *s), (h, s))
FWD(SQLFreeStmt, (SQLHSTMT s, SQLUSMALLINT o), (s, o))
FWD(SQLExtendedFetch, (SQLHSTMT s, SQLUSMALLINT t, SQLLEN r, SQLULEN *n, SQLUSMALLINT *st), (s, t, r, n, st))
FWD(SQLColAttributes, (SQLHSTMT s, SQLUSMALLINT c, SQLUSMALLINT f, SQLPOINTER cp, SQLSMALLINT cl, SQLSMALLINT *clp, SQLLEN *np), (s, c, f, cp, cl, clp, np))
FWD(SQLError, (SQLHENV e, SQLHDBC d, SQLHSTMT s, SQLCHAR *st, SQLINTEGER *ne, SQLCHAR *mt, SQLSMALLINT ml, SQLSMALLINT *tl), (e, d, s, st, ne, mt, ml, tl))
FWD(SQLNativeSql, (SQLHDBC h, SQLCHAR *in, SQLINTEGER inl, SQLCHAR *out, SQLINTEGER outl, SQLINTEGER *outlen), (h, in, inl, out, outl, outlen))
FWD(SQLDescribeParam, (SQLHSTMT s, SQLUSMALLINT p, SQLSMALLINT *t, SQLULEN *sz, SQLSMALLINT *d, SQLSMALLINT *n), (s, p, t, sz, d, n))

/* ---- overrides ---- */

SQLRETURN SQL_API SQLDescribeCol(SQLHSTMT s, SQLUSMALLINT col, SQLCHAR *name,
                                 SQLSMALLINT nameMax, SQLSMALLINT *nameLen,
                                 SQLSMALLINT *type, SQLULEN *size,
                                 SQLSMALLINT *digits, SQLSMALLINT *nullable)
{
    SQLRETURN rc = real_sym("SQLDescribeCol")(s, col, name, nameMax, nameLen, type, size, digits, nullable);
    if (SQL_SUCCEEDED(rc) && size != NULL) {
        *size = HUGE_DISPLAY_SIZE;   /* pretend Snowflake VARCHAR(16777216) */
    }
    return rc;
}

SQLRETURN SQL_API SQLColAttribute(SQLHSTMT s, SQLUSMALLINT col, SQLUSMALLINT field,
                                  SQLPOINTER charAttr, SQLSMALLINT charMax,
                                  SQLSMALLINT *charLen, SQLLEN *numAttr)
{
    SQLRETURN rc = real_sym("SQLColAttribute")(s, col, field, charAttr, charMax, charLen, numAttr);
    if (SQL_SUCCEEDED(rc) && field == SQL_DESC_DISPLAY_SIZE && numAttr != NULL) {
        *numAttr = HUGE_DISPLAY_SIZE;
    }
    return rc;
}

SQLRETURN SQL_API SQLGetData(SQLHSTMT s, SQLUSMALLINT col, SQLSMALLINT targetType,
                             SQLPOINTER buf, SQLLEN bufLen, SQLLEN *indicator)
{
    if (col == 1) {
        /* Mirrors the Snowflake driver's observed sequence for a NULL column:
         * first call reports SQL_NULL_DATA; further reads return SQL_NO_DATA. */
        if (bufLen == 0) {
            *indicator = 0;
            return SQL_NO_DATA;
        }
        *indicator = SQL_NULL_DATA;
        return SQL_SUCCESS;
    }
    return real_sym("SQLGetData")(s, col, targetType, buf, bufLen, indicator);
}

Activity

  1. NattyNarwhal commented on Aug 4, 2026

    @NattyNarwhal
    Member

    I think this is either a regression in GH-22672 or GH-22674. I'll take a look, also cc @iliaal

  2. NattyNarwhal commented on Aug 4, 2026

    @NattyNarwhal
    Member

    Also while I set this up to repro, could you get a unixODBC driver trace of this to look at? Put in /etc/odbcinst.ini:

    [ODBC]
    Trace=yes
    TraceFile/tmp/odbc.log
    

    Then paste the log here.

  3. self-assigned this
    on Aug 4, 2026
  4. iliaal commented on Aug 4, 2026

    @iliaal
    Member

    Going to look into this, does seem to be related to the two issues you mentioned

  5. NattyNarwhal commented on Aug 4, 2026

    @NattyNarwhal
    Member

    Can reproduce with Db2i driver on macOS in master:

    (lldb) run
    Process 57344 launched: '/Users/calvin/src/php-src/sapi/cli/php' (arm64)
    Process 57344 stopped
    * thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x103ffffe9)
        frame #0: 0x0000000181553580 libsystem_platform.dylib`_platform_memmove + 544
    libsystem_platform.dylib`_platform_memmove:
    ->  0x181553580 <+544>: ldp    q0, q1, [x1, #-0x20]
        0x181553584 <+548>: sub    x1, x1, #0x20
        0x181553588 <+552>: subs   x2, x2, #0x20
        0x18155358c <+556>: b.hi   0x181553578    ; <+536>
    Target 0: (php) stopped.
    (lldb) bt
    * thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x103ffffe9)
      * frame #0: 0x0000000181553580 libsystem_platform.dylib`_platform_memmove + 544
        frame #1: 0x0000000100633010 php`odbc_stmt_get_col(stmt=0x0000000104090300, colno=0, result=0x000000016fdfdad0, type=0x0000000000000000) at odbc_stmt.c:794:6
        frame #2: 0x000000010064ee18 php`fetch_value(stmt=0x0000000104090300, dest=0x000000016fdfdad0, colno=0, type_override=0x0000000000000000) at pdo_stmt.c:496:2
        frame #3: 0x000000010064dab8 php`do_fetch(stmt=0x0000000104090300, return_value=0x0000000104016140, how=PDO_FETCH_ASSOC, ori=PDO_FETCH_ORI_NEXT, offset=0, group_key=0x0000000000000000) at pdo_stmt.c:855:3
        frame #4: 0x000000010064ccd4 php`zim_PDOStatement_fetch(execute_data=0x00000001040162d0, return_value=0x0000000104016140) at pdo_stmt.c:1060:7
        frame #5: 0x0000000100bafa08 php`ZEND_DO_FCALL_SPEC_RETVAL_USED_TAILCALL_HANDLER(execute_data=0x0000000104016020, opline=0x00000001040a21c0) at zend_vm_execute.h:55100:4
        frame #6: 0x0000000100adde0c php`execute_ex(ex=0x0000000104016020) at zend_vm_execute.h:110525:12
        frame #7: 0x0000000100ade250 php`zend_execute(op_array=0x00000001040913c0, return_value=0x0000000000000000) at zend_vm_execute.h:115963:2
        frame #8: 0x0000000100cf18b4 php`zend_execute_script(type=8, retval=0x0000000000000000, file_handle=0x000000016fdfe850) at zend.c:1980:3
        frame #9: 0x000000010098348c php`php_execute_script_ex(primary_file=0x000000016fdfe850, retval=0x0000000000000000) at main.c:2598:13
        frame #10: 0x0000000100983754 php`php_execute_script(primary_file=0x000000016fdfe850) at main.c:2638:9
        frame #11: 0x0000000100cf4c04 php`do_cli(argc=2, argv=0x0000000103b89d50) at php_cli.c:933:5
        frame #12: 0x0000000100cf3d3c php`do_php_cli(argc=2, argv=0x0000000103b89d50) at php_cli.c:1346:18
        frame #13: 0x0000000100cf380c php`main(argc=2, argv=0x000000016fdff5a8) at php_cli_main.c:18:9
        frame #14: 0x000000018118be00 dyld`start + 6992
    

    @iliaal I think the issue is we're getting into the copying logic when previously it might have bailed earlier on incidental conditions... simple fix might just be to be explicit:

    diff --git a/ext/pdo_odbc/odbc_stmt.c b/ext/pdo_odbc/odbc_stmt.c
    index b7772ec3ac3..aec87e39a16 100644
    --- a/ext/pdo_odbc/odbc_stmt.c
    +++ b/ext/pdo_odbc/odbc_stmt.c
    @@ -728,6 +728,10 @@ static int odbc_stmt_get_col(pdo_stmt_t *stmt, int colno, zval *result, enum pdo
      			LONG_COLUMN_BUFFER_SIZE, &C->fetched_len);
     		orig_fetched_len = C->fetched_len;
     
    +		if (rc == SQL_SUCCESS && C->fetched_len == SQL_NULL_DATA) {
    +			goto in_data;
    +		}
    +
     		if (rc == SQL_SUCCESS && C->fetched_len < LONG_COLUMN_BUFFER_SIZE) {
     			/* all the data fit into our little buffer;
     			 * jump down to the generic bound data case */

    With that, I get output from the user's repro:

    type=NULL len=-1
    
  6. iliaal commented on Aug 4, 2026

    @iliaal
    Member

    The guard above it never fires for NULL. LONG_COLUMN_BUFFER_SIZE is ZEND_MM_PAGE_SIZE - ZSTR_MAX_OVERHEAD and ZSTR_MAX_OVERHEAD comes from offsetof, so the macro is size_t. C->fetched_len < LONG_COLUMN_BUFFER_SIZE promotes SQL_NULL_DATA to SIZE_MAX and we fall into the long column branch. seed_len has the same promotion, so -1 > LONG_COLUMN_BUFFER_SIZE - 1 is true and we seed 4064 bytes of uninitialized C->data. That is the reporter's len=4064.

    Both comparisons predate 8.5.9. The 8.5 rewrite replaced the signed literal 256 with the macro, which is why 8.4 is fine; 8.5.9 only changed the consequence, since the old seed was 0 bytes.

    Your patch covers SQL_SUCCESS. A NULL with SQL_SUCCESS_WITH_INFO still enters the loop with to_fetch_len = -1, so used is SIZE_MAX and the memcpy is your backtrace. With a stock sqlite3 driver and a large display size, the unpatched path is heap corruption rather than disclosure.

    Working on a PR: cast the macro to SQLLEN and route negative indicators other than SQL_NO_TOTAL to in_data.

  7. added theissue type on Aug 4, 2026
  8. added a commit that references this issue on Aug 4, 2026
    b8bcbbc
  9. added a commit that references this issue on Aug 4, 2026
    0520256
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions