Repository navigation
pdo_odbc: NULL values come back as garbage binary strings (8.5.9 regression) #23016
Description
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.phprepro.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
NattyNarwhal commentedon Aug 4, 2026
I think this is either a regression in GH-22672 or GH-22674. I'll take a look, also cc @iliaal
NattyNarwhal commentedon Aug 4, 2026
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:Then paste the log here.
iliaal commentedon Aug 4, 2026
Going to look into this, does seem to be related to the two issues you mentioned
NattyNarwhal commentedon Aug 4, 2026
Can reproduce with Db2i driver on macOS in master:
@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:
With that, I get output from the user's repro:
iliaal commentedon Aug 4, 2026
The guard above it never fires for NULL.
LONG_COLUMN_BUFFER_SIZEisZEND_MM_PAGE_SIZE - ZSTR_MAX_OVERHEADandZSTR_MAX_OVERHEADcomes fromoffsetof, so the macro issize_t.C->fetched_len < LONG_COLUMN_BUFFER_SIZEpromotesSQL_NULL_DATAtoSIZE_MAXand we fall into the long column branch.seed_lenhas the same promotion, so-1 > LONG_COLUMN_BUFFER_SIZE - 1is true and we seed 4064 bytes of uninitializedC->data. That is the reporter'slen=4064.Both comparisons predate 8.5.9. The 8.5 rewrite replaced the signed literal
256with 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 withSQL_SUCCESS_WITH_INFOstill enters the loop withto_fetch_len = -1, sousedisSIZE_MAXand 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
SQLLENand route negative indicators other thanSQL_NO_TOTALtoin_data.Merge branch 'PHP-8.5'
Fix phpGH-23016: pdo_odbc returns garbage for NULL long columns