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.
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.
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.
#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);
}
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.8andphp:8.5.9Docker 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.
repro.php:Expected (PHP 8.5.8):
Actual (PHP 8.5.9):
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