Repository navigation
Description
Description
pdo_pgsql indexes S->cols and stmt->columns with the column count of the current result, but both arrays are still sized for the first result of that statement. When a prepared statement is executed a second time and the second result has more columns, the reads run past the end of both arrays. The stmt->columns side is the dangerous one: pdo_stmt.c uses the new stmt->column_count as the bound and then hands stmt->columns[colno].name to zend_symtable_update() as a zend_string *, so a stale pointer is dereferenced.
This needs PDO::ATTR_EMULATE_PREPARES to be enabled. With a native prepared statement the server rejects the changed result shape first with cached plan must not change result type.
Triggering it is routine: SELECT * followed by a concurrent ALTER TABLE ... ADD COLUMN, a view swap, or a changed search_path between two executions. A malicious or compromised server can also extend the result of the second execute.
--TEST--
PDO PgSQL: out-of-bounds read when a re-executed statement returns more columns
--EXTENSIONS--
pdo_pgsql
--SKIPIF--
<?php
require __DIR__ . '/config.inc';
require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
PDOTest::skip();
?>
--FILE--
<?php
require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
$pdo = PDOTest::test_factory(__DIR__ . '/common.phpt');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// the query has to be sent again on every execute, otherwise the server rejects
// the changed result type of a cached plan
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, true);
$pdo->exec('CREATE TEMPORARY TABLE pdo_pgsql_repro (a integer)');
$pdo->exec('INSERT INTO pdo_pgsql_repro VALUES (1)');
$stmt = $pdo->prepare('SELECT *, ? AS extra FROM pdo_pgsql_repro');
$stmt->execute([7]);
var_dump($stmt->columnCount());
var_dump($stmt->fetch(PDO::FETCH_NUM));
// the second result has one more column than the arrays that describe it
$pdo->exec('ALTER TABLE pdo_pgsql_repro ADD COLUMN b text');
$pdo->exec("UPDATE pdo_pgsql_repro SET b = 'x'");
$stmt->execute([7]);
var_dump($stmt->columnCount());
var_dump($stmt->fetch(PDO::FETCH_NUM));
echo "Done\n";
?>
--EXPECT--
int(2)
array(2) {
[0]=>
string(1) "1"
[1]=>
string(1) "7"
}
int(3)
array(3) {
[0]=>
string(1) "1"
[1]=>
string(1) "x"
[2]=>
string(1) "7"
}
DoneObserved on an ASan build of an unpatched tree:
==3==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
READ of size 4
#0 pgsql_stmt_get_col ext/pdo_pgsql/pgsql_statement.c:751
#1 fetch_value ext/pdo/pdo_stmt.c:512
#2 do_fetch ext/pdo/pdo_stmt.c:861
#3 zim_PDOStatement_fetch ext/pdo/pdo_stmt.c:1066
0x... is located 0 bytes after 8-byte region
allocated by thread T0 here:
#3 pgsql_stmt_execute ext/pdo_pgsql/pgsql_statement.c:458
SUMMARY: AddressSanitizer: heap-buffer-overflow
Fix for reference:
diff --git a/ext/pdo_pgsql/pgsql_statement.c b/ext/pdo_pgsql/pgsql_statement.c
index e2c8a35944c..8f627b2825f 100644
--- a/ext/pdo_pgsql/pgsql_statement.c
+++ b/ext/pdo_pgsql/pgsql_statement.c
@@ -453,9 +453,19 @@ static int pgsql_stmt_execute(pdo_stmt_t *stmt)
return 0;
}
- stmt->column_count = (int) PQnfields(S->result);
- if (S->cols == NULL) {
- S->cols = ecalloc(stmt->column_count, sizeof(pdo_pgsql_column));
+ /* A re-executed statement may return more columns than the first time, so
+ * both descriptions are resized together: S->cols here, and stmt->columns
+ * by letting PDO describe the columns again on the next fetch */
+ int column_count = (int) PQnfields(S->result);
+ if (S->cols == NULL || stmt->column_count != column_count) {
+ if (S->cols != NULL) {
+ efree(S->cols);
+ }
+ S->cols = column_count > 0
+ ? ecalloc(column_count, sizeof(pdo_pgsql_column))
+ : NULL;
+ php_pdo_stmt_set_column_count(stmt, column_count);
+ stmt->executed = 0;
}
if (status == PGRES_COMMAND_OK) {PHP Version
master
Operating System
No response
Activity
devnexen commentedon Nov 4, 2026
Hi @dixyes feel free to open a PR. Cheers.
dixyes commentedon Nov 4, 2026
I will no longer be submitting PRs to PHP. I don’t have the time or energy to maintain them and push for their merger. However, my code is still shared with waived copyright/attribution rights—feel free to use it however you like.
dixyes commentedon Nov 4, 2026
HackingRepo commentedon Nov 4, 2026
this probably a security issue, why did you posted publicly @dixyes, very weird to me, this should bd a security advisory?
what it do not meet
you said already a
malicious serverthis is clearly a security issue, this is not the place to do so, can you confirm this, thank's, and correct medixyes commentedon Nov 5, 2026
Does a user actively connecting to a malicious PostgreSQL server constitute a security risk? Can this bug be exploited remotely (or even locally)? I can't imagine that...
HackingRepo commentedon Nov 5, 2026
yes some hosting provider written in php say enter your postgresql server and then entering the malicious server will take over the hosting provider and can achieve RCE @dixyes, think consider these scenarios
Resize the column description of a re-executed statement
Resize the column description of a re-executed statement
Resize the column description of a re-executed statement