[php-src] Issue #23016: pdo_odbc: NULL values come back as garbage binary strings (8.5.9 regression)

[email protected] (yinheli)
Newsgroups php.bugs
Message-ID <[email protected]>
Issue: https://github.com/php/php-src/issues/23016
Author: 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.

```console
$ 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`:

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

```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);
}
```
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.