Skip to content

Buffer overflow (out-of-bounds write) in ext/pdo_dblib PDO::lastInsertId() when @@IDENTITY is a wide numeric value (e.g. numeric(38,0)) longer than the fixed 32-byte conversion buffer #22664

Description

@cxxz16

Description

Summary

PDO::lastInsertId() on the dblib driver queries SELECT @@IDENTITY, allocates a fixed 32-byte buffer, and converts the server-returned identity value into it with dbconvert(..., (DBINT)-1). The (DBINT)-1 destination-length argument tells FreeTDS/DB-Lib to write the full converted string with no destination bound. When the identity column is a wide numeric type — e.g. numeric(38,0) IDENTITY, which can return 38 decimal digits — the converted string is longer than 32 bytes and the conversion writes past the end of the buffer.

This is an out-of-bounds write driven by a server-returned value, reached from a benign application that simply inserts a row and calls lastInsertId(). No object-lifecycle trick, FFI, or abnormal call sequence is required. On the bundled PHP 8.4.20 the buffer is heap-allocated (emalloc(32)), so this is a heap buffer overflow; on the current master branch the buffer is a 32-byte stack array, so the same overflow becomes a stack buffer overflow.

Details

dblib_handle_last_id() issues SELECT @@IDENTITY, then converts the returned value into a fixed 32-byte buffer:

ext/pdo_dblib/dblib_driver.c (around lines 244, 270–271):

    if (FAIL == dbcmd(H->link, "SELECT @@IDENTITY")) {
        return NULL;
    }
    ...
    id = emalloc(32);
    len = dbconvert(NULL, (dbcoltype(H->link, 1)) , (dbdata(H->link, 1)) , (dbdatlen(H->link, 1)), SQLCHAR, (BYTE *)id, (DBINT)-1);
    dbcancel(H->link);

    ret_id = zend_string_init(id, len, 0);
    efree(id);
    return ret_id;

The destination buffer is exactly 32 bytes, but the final argument to dbconvert() is (DBINT)-1. In the DB-Lib API, a destination length of -1 means "the destination is a null-terminated string with no fixed length" — DB-Lib writes the entire converted representation. For a numeric(38,0) identity value the decimal representation is up to 38 characters (plus a NUL), which overruns the 32-byte buffer.

The same defect is present on the current master branch, where a prior change ("Use stack local array instead of heap allocation", commit referenced in the dblib history) replaced the heap allocation with a stack array:

    BYTE id[32];                                  // master: ext/pdo_dblib/dblib_driver.c
    ...
    len = dbconvert(NULL, (dbcoltype(H->link, 1)) , (dbdata(H->link, 1)) , (dbdatlen(H->link, 1)), SQLCHAR, (BYTE *)id, (DBINT)-1);

So on master the same oversized identity value causes a stack buffer overflow of the 32-byte id array; the unbounded (DBINT)-1 conversion was never given a destination capacity.

PoC

Environment:

  • PHP 8.4.20 (CLI), built with --enable-debug and AddressSanitizer (CFLAGS="-fsanitize=address -g -O0"), pdo_dblib enabled.
  • FreeTDS 1.3.6, TDS protocol 7.4.
  • A normal Microsoft SQL Server 2019 instance (no malicious DB-Lib implementation is required).

PoC script (pdodblib001.php). The table uses a numeric(38,0) IDENTITY seeded so the next identity is 38 digits:

<?php
$pdo = new PDO(
    'dblib:host=SQLSERVER:1433;dbname=tempdb;version=7.4;charset=UTF-8',
    'sa',
    '<password>',
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION],
);

$pdo->exec('DROP TABLE IF EXISTS dbo.ident');
$pdo->exec('CREATE TABLE dbo.ident (
    id numeric(38,0) IDENTITY(99999999999999999999999999999999999990,1) PRIMARY KEY,
    v int
)');
$pdo->exec('INSERT INTO dbo.ident(v) VALUES (1)');
var_dump($pdo->lastInsertId());

Run:

ASAN_OPTIONS=detect_leaks=0:halt_on_error=1:abort_on_error=1 \
UBSAN_OPTIONS=halt_on_error=1:abort_on_error=1 \
php pdodblib001.php

Full AddressSanitizer output:

=================================================================
==1464476==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x503000074ee0 at pc 0x7f02ebb0d2c3 bp 0x7ffedffc0000 sp 0x7ffedffbf7a8
WRITE of size 38 at 0x503000074ee0 thread T0
    #0 0x7f02ebb0d2c2 in __interceptor_memcpy ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:827
    #1 0x7f02eb7670b1 in dbconvert_ps (/lib/x86_64-linux-gnu/libsybdb.so.5+0xf0b1)
    #2 0x7f02eb76774d in dbconvert (/lib/x86_64-linux-gnu/libsybdb.so.5+0xf74d)
    #3 0x560967b89c95 in dblib_handle_last_id /src/php/ext/pdo_dblib/dblib_driver.c:271
    #4 0x560967c66253 in zim_PDO_lastInsertId /src/php/ext/pdo/pdo_dbh.c:1101
    #5 0x560968f974f7 in ZEND_DO_FCALL_SPEC_RETVAL_USED_HANDLER /src/php/Zend/zend_vm_execute.h:2025
    #6 0x56096929262e in execute_ex /src/php/Zend/zend_vm_execute.h:58946
    #7 0x5609692b202d in zend_execute /src/php/Zend/zend_vm_execute.h:64328
    #8 0x56096954e20d in zend_execute_script /src/php/Zend/zend.c:1934
    #9 0x5609689e80ab in php_execute_script_ex /src/php/main/main.c:2577
    #10 0x5609689e8641 in php_execute_script /src/php/main/main.c:2617
    #11 0x5609695567f5 in do_cli /src/php/sapi/cli/php_cli.c:935
    #12 0x560969559387 in main /src/php/sapi/cli/php_cli.c:1310

0x503000074ee0 is located 0 bytes to the right of 32-byte region [0x503000074ec0,0x503000074ee0)
allocated by thread T0 here:
    #0 0x7f02ebb87887 in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:145
    #1 0x560968d5e7a1 in __zend_malloc /src/php/Zend/zend_alloc.c:3294
    #2 0x560968d592bd in _emalloc /src/php/Zend/zend_alloc.c:2740
    #3 0x560967b89ad4 in dblib_handle_last_id /src/php/ext/pdo_dblib/dblib_driver.c:270
    #4 0x560967c66253 in zim_PDO_lastInsertId /src/php/ext/pdo/pdo_dbh.c:1101

SUMMARY: AddressSanitizer: heap-buffer-overflow in __interceptor_memcpy

WRITE of size 38 lands 0 bytes to the right of the 32-byte buffer allocated at dblib_driver.c:270 and written at :271, matching the analysis. On master the corresponding allocation is the 32-byte stack array id, so the overflow is a stack overflow.

Impact

This is an out-of-bounds write (CWE-787) caused by passing (DBINT)-1 (no destination bound) to dbconvert() together with a fixed 32-byte buffer (CWE-131, incorrect buffer-size handling). The overflow content is the decimal digits of the server-returned identity value. On bundled 8.4.20 it is a heap buffer overflow; on master it is a stack buffer overflow (potentially clobbering adjacent stack state / triggering stack-protector aborts).

Who is impacted: applications using ext/pdo_dblib against SQL Server (or Sybase) where a table has a wide numeric IDENTITY column (e.g. numeric(38,0)/decimal(38,0) IDENTITY) and the application calls PDO::lastInsertId(). A wide numeric identity is a legitimate schema choice, and lastInsertId() is the normal API for retrieving it; once the identity value exceeds ~31 digits the conversion overflows the fixed buffer. The trigger is the server-returned identity value rather than direct request input, so the "attacker-controlled" framing is weaker than a pure request-value bug; however it is a clear fixed-buffer overflow reachable by a benign application through ordinary API usage, and PHP — not the application — chooses both the 32-byte buffer and the unbounded conversion length.

PHP Version

php<=8.5.6
ext/pdo_dblib

Operating System

ubuntu 22.04

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions