Skip to content

pdo_sqlite on macOS: SELECT … ORDER BY raises SQLSTATE HY000 error 25 "column index out of range" (Apple ENABLE_API_ARMOR + sqlite3_data_count() guard bug in pdo_sqlite_stmt_get_col) #21936

@belisoful

Description

@belisoful

Description

Description

On macOS, any SELECT … ORDER BY query executed through pdo_sqlite against Apple's system SQLite library (/usr/lib/libsqlite3.dylib) raises a PDOException with SQLSTATE HY000, error code 25, message "column index out of range". The query is syntactically and semantically valid; the result set is never returned.

The bug has two cooperating causes, both in ext/pdo_sqlite/sqlite_statement.c, and is fully reproducible and root-caused.


PHP Version

Reproducible on all PHP versions that link pdo_sqlite against Apple's system SQLite. On macOS 12 (Monterey) and later, Apple no longer ships /usr/bin/php, so the affected PHP is typically one explicitly compiled against /usr/lib/libsqlite3.dylib. Homebrew PHP links against its own libsqlite3 and is not affected.

To confirm which SQLite library the active PHP links against:

php -r "echo SQLite3::version()['versionString'], PHP_EOL;"
otool -L "$(php -r 'echo ini_get("extension_dir");')/pdo_sqlite.so" | grep sqlite

The bug appears when the output shows /usr/lib/libsqlite3.dylib. Apple's build carries the source-ID suffix apl (e.g. ...114dcaapl) and is compiled with SQLITE_ENABLE_API_ARMOR.


Operating System

macOS (any version where PHP links pdo_sqlite against /usr/lib/libsqlite3.dylib). Not reproducible on Linux or on macOS with Homebrew PHP.


Steps to Reproduce

<?php
$pdo = new PDO('sqlite::memory:', null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec('CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)');
$pdo->exec("INSERT INTO t VALUES (1, 'b'), (2, 'a')");

// Any ORDER BY triggers the exception on affected builds:
$stmt = $pdo->prepare('SELECT id, val FROM t ORDER BY val ASC');
$stmt->execute();           // may raise PDOException here
$rows = $stmt->fetchAll();  // or here
var_dump($rows);

A single-column minimal case also reproduces: SELECT id FROM t ORDER BY id.


Expected Results

array(2) {
  [0]=> array(4) { ["id"]=> int(2) ["val"]=> string(1) "a" ... }
  [1]=> array(4) { ["id"]=> int(1) ["val"]=> string(1) "b" ... }
}

Actual Results

PHP Fatal error: Uncaught PDOException: SQLSTATE[HY000]: General error: 25 column index out of range

Root Cause

Background — Apple's SQLITE_ENABLE_API_ARMOR

Apple's /usr/lib/libsqlite3.dylib is compiled with SQLITE_ENABLE_API_ARMOR. This causes sqlite3_column_type() (and sqlite3_column_text(), sqlite3_column_int(), etc.) to call sqlite3_log(SQLITE_RANGE, "column index out of range") and set db->errCode = 25 when called with an index >= column_count. On standard SQLite builds these calls return SQLITE_NULL / 0 silently. sqlite3_column_name() and sqlite3_column_decltype() do not trigger this behaviour for out-of-bounds indices.

Cause 1 — Apple's internal ORDER BY processing calls sqlite3_column_type() with sort-key indices

When sqlite3_step() processes an ORDER BY query, Apple's SQLite accesses sort-key columns via the public sqlite3_column_type() API using internal result indices that are >= result_column_count. For example, for SELECT id FROM t ORDER BY val ASC, the sort key val is at internal index 1 while result_column_count = 1. SQLITE_ENABLE_API_ARMOR fires and sets db->errCode = SQLITE_RANGE (25).

Cause 2 — pdo_sqlite_stmt_get_col uses sqlite3_data_count() as a bounds guard

In ext/pdo_sqlite/sqlite_statement.c, pdo_sqlite_stmt_get_col begins:

if(colno >= sqlite3_data_count(S->stmt)) {
    pdo_sqlite_error_stmt(stmt);
    return 0;
}
switch (sqlite3_column_type(S->stmt, colno)) {

sqlite3_data_count() returns the number of columns in the current row — it returns 0 after sqlite3_reset(). After pdo_sqlite_stmt_execute sees SQLITE_DONE and calls sqlite3_reset(S->stmt), sqlite3_data_count() drops to 0. Any subsequent call to pdo_sqlite_stmt_get_col with any colno satisfies colno >= 0 == TRUE, causing pdo_sqlite_error_stmt(stmt) to be called unconditionally.

The full chain

  1. sqlite3_step() processes ORDER BY sort keys; Apple's ENABLE_API_ARMOR sets db->errCode = SQLITE_RANGE (25).
  2. pdo_sqlite_stmt_execute SQLITE_DONE branch calls sqlite3_reset(S->stmt)data_count drops to 0; db->errCode remains 25.
  3. pdo_sqlite_stmt_get_col guard: colno >= sqlite3_data_count() == colno >= 0 == TRUEpdo_sqlite_error_stmt(stmt) called.
  4. pdo_sqlite_error_stmt reads sqlite3_errcode(S->H->db) == 25 (SQLITE_RANGE) → raises PDOException SQLSTATE HY000 / 25 / "column index out of range".

Note on pdo_sqlite_stmt_describe: this function was audited including via git history going back 6+ years. It calls only sqlite3_column_name() and uses sqlite3_column_count() for bounds. It never calls sqlite3_column_type() and is not the bug site.


C-Level Proof

A standalone C probe tests /usr/lib/libsqlite3.dylib directly via the SQLite C API without PHP involvement. Building with -DSQLITE_ENABLE_API_ARMOR (which exposes the header declarations; the runtime armor code is always present in Apple's dylib) and calling sqlite3_column_type() with an out-of-bounds index reproducibly sets sqlite3_extended_errcode() to 25. sqlite3_column_name() and sqlite3_column_decltype() called with the same index do not.

Probe FINDINGS SUMMARY output on Apple SQLite:

FINDINGS SUMMARY
============================================================
  Part 1  Hidden sort-key columns / NULL column names   CLEAN
  Part 2  ENABLE_COLUMN_METADATA functions              CLEAN
  Part 3  ENABLE_API_ARMOR out-of-bounds detection      CONFIRMED

The Part 3 output shows, per query shape and per accessor:

sqlite3_column_name     [index=1, before step]  ec_before=0   ec_after=0   (no change)
sqlite3_column_decltype [index=1, before step]  ec_before=0   ec_after=0   (no change)
sqlite3_column_type     [index=1, before step]  ec_before=0   ec_after=25  <--- SQLITE_RANGE SET — API_ARMOR CONFIRMED

Proposed Fix

Fix 1 — Correct the bounds guard in pdo_sqlite_stmt_get_col

File: ext/pdo_sqlite/sqlite_statement.c

Change:

if(colno >= sqlite3_data_count(S->stmt)) {

To:

if(colno >= sqlite3_column_count(S->stmt)) {

sqlite3_column_count() returns the prepared statement's column count regardless of execution state and is the correct guard for column metadata access. sqlite3_data_count() is intended to reflect whether a row is currently available, not to bound column index checks.

This fix alone is sufficient to prevent the PDOException from being raised.

Fix 2 — Clear stale error state after sqlite3_reset() in pdo_sqlite_stmt_execute

File: ext/pdo_sqlite/sqlite_statement.c, SQLITE_DONE branch

case SQLITE_DONE:
    php_pdo_stmt_set_column_count(stmt, sqlite3_column_count(S->stmt));
    stmt->row_count = sqlite3_changes(S->H->db);
    sqlite3_reset(S->stmt);
    /* Clear any error state Apple's armored SQLite may have set during
       internal ORDER BY sort-key processing (SQLITE_ENABLE_API_ARMOR). */
    sqlite3_exec(S->H->db, "SELECT 1", NULL, NULL, NULL);
    S->done = 1;
    return 1;

This prevents the stale db->errCode = 25 from persisting to subsequent queries on the same connection.


Notes

  • The bug is specific to Apple's /usr/lib/libsqlite3.dylib with SQLITE_ENABLE_API_ARMOR. Standard SQLite builds (Linux, Homebrew) are not affected because out-of-bounds sqlite3_column_type() calls return SQLITE_NULL silently.
  • Queries without ORDER BY are not affected because no sort-key column access occurs inside sqlite3_step().
  • The sqlite3_data_count() guard bug is latent on unarmored builds: for successful non-DONE executions data_count == column_count, so the wrong guard never misfires. It only misfires when data_count drops to 0 after reset and a stale non-zero db->errCode is present.

PHP Version

PHP 8.1.34 (cli) (built: Dec 16 2025 18:33:34) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.1.34, Copyright (c) Zend Technologies
    with Xdebug v3.2.1, Copyright (c) 2002-2023, by Derick Rethans
    with Zend OPcache v8.1.34, Copyright (c), by Zend Technologies

Operating System

macOS 26.3.1. I am seeing some of this behavior without apple's API ARMOR.

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions