Skip to content

odbc_prepare(): double free of param_info when SQLDescribeParam() fails (heap corruption since 8.4) #25036

Closed

Description

@steveabdelmoula

Description

odbc_prepare() frees result->param_info twice when SQLDescribeParam() fails, which corrupts the heap. It happens on a normal error path: a statement whose parameters the driver cannot describe. With the Windows {SQL Server} driver (SQLSRV32), this includes a parameter marker used as a function argument, in a derived table, a CTE, TOP (?) or OFFSET ? ROWS.

Reproduced with PHP 8.5.12 (NTS and ZTS). By reading the source, PHP-8.4 and master have the same code. PHP 8.3.36 is not affected (tested).

The following code (PHP started with -n -d extension=odbc, no other extension):

<?php
// DSN: Driver={SQL Server};Server=<any SQL Server>;Database=master;Trusted_Connection=Yes;
$c = odbc_connect($argv[1], '', '');
echo PHP_VERSION, ' ', PHP_ZTS ? 'ZTS' : 'NTS', "\n";
// The {SQL Server} driver cannot describe a parameter marker used as a function
// argument: SQLPrepare() succeeds, SQLDescribeParam() returns SQL_ERROR.
var_dump(@odbc_prepare($c, 'SELECT UPPER(?) AS v'));
// Small allocations (timelib copies 16-byte timezone abbreviations) reuse the
// twice-freed slot.
for ($i = 0; $i < 100; $i++) {
    $d = new DateTime('2025-04-01 10:00', new DateTimeZone('Europe/Paris'));
    $s = $d->format('c T');
}
echo "END\n";

Resulted in this output (PHP 8.5.12 NTS and ZTS, every run):

8.5.12 NTS
bool(false)
zend_mm_heap corrupted

The process exit code is 0xC0000409. With USE_ZEND_ALLOC=0, the process dies inside odbc_prepare() with 0xC0000374 (STATUS_HEAP_CORRUPTION), right after the SQLDescribeParameter warning.

But I expected this output instead (what PHP 8.3.36 prints, NTS and ZTS, with and without USE_ZEND_ALLOC=0):

8.3.36 NTS
bool(false)
END

Cause (line numbers from master at 118f06a, ext/odbc/php_odbc.c):

  1. PHP_FUNCTION(odbc_prepare) sets result->conn_ptr = conn; (line 953) before the SQLDescribeParam() loop.
  2. On SQL_ERROR, the error branch (lines 960-966) runs efree(result->param_info); without resetting the pointer, then zval_ptr_dtor(return_value);.
  3. That destroys the result object: odbc_result_free_obj() (line 262) sees conn_ptr set and calls odbc_result_free(), which runs efree(res->param_info) a second time (line 252).
  4. odbc_result_free() then calls zend_hash_index_del(&conn->results, res->index) (line 258) for a result that was never inserted (odbc_insert_new_result() was not reached). That would trip the ZEND_ASSERT(status == SUCCESS) in a debug build, or remove another live result that has the same index.

This looks like a side effect of the resources-to-objects migration (#12040, PHP 8.4). In 8.3, the same branch frees param_info and result directly, with no object destructor. The other early-return paths of odbc_prepare() call zval_ptr_dtor() before conn_ptr is set, so they don't double free.

A possible fix: in the SQLDescribeParam() error branch, either don't free param_info (the destructor does it) and don't let the destructor delete an entry that was never inserted, or set conn_ptr only after the loop and release the statement and param_info explicitly there.

Environment of the reproduction: official Windows builds php-8.5.12-Win32-vs17-x64 (NTS and TS), {SQL Server} driver 10.0.26100.3624, SQL Server 2025 LocalDB 17.0.4025.3. The driver error text was localized (SQLSTATE 37000, "Syntax error or access violation"). Any driver/statement pair where SQLDescribeParam() returns SQL_ERROR should take the same path.

PHP Version

PHP 8.5.12 (cli) (built: Oct 26 2026 09:51:07) (NTS Visual C++ 2022 x64)
Copyright (c) The PHP Group
Built by The PHP Group
Zend Engine v4.5.12, Copyright (c) Zend Technologies
    with Zend OPcache v8.5.12, Copyright (c), by Zend Technologies

Also reproduced with PHP 8.5.12 (cli) (built: Oct 26 2026 09:50:37) (ZTS Visual C++ 2022 x64).

Operating System

Windows 11 x64

Activity

  1. self-assigned this
    on Oct 29, 2026
  2. NattyNarwhal commented on Oct 29, 2026

    @NattyNarwhal
    Member

    Simplest fix is just setting param_info to null after, since the destructor already checks for a null pointer (in master at least)

  3. iliaal commented on Oct 29, 2026

    @iliaal
    Member

    I went a little bit a different route, still simple enough I think

  4. added a commit that references this issue on Oct 30, 2026
    083e1a6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions