Skip to content

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

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);
}

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions