Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 5 min read

4 SQL statements and a segfault: inside PostgreSQL's 22-year-old type confusion, CVE-2026-6460

Four SQL statements. A login role with zero privileges. A segmentation fault. That is the entry ticket to Mehmet Ince's three-part series on CVE-2026-6460, patched in PostgreSQL's November 9 security release (52 CVEs, si

Four SQL statements. A login role with zero privileges. A segmentation fault. That is the entry ticket to Mehmet Ince's three-part series on CVE-2026-6460, patched in PostgreSQL's November 9 security release (52 CVEs, six of them from this research). The primitive dates to May 5, 2004: every PostgreSQL ever shipped since ALTER TABLE ... ALTER COLUMN ... TYPE landed carries it. This morning I built the vulnerable 17.11 from source on my laptop, ran the crash PoC as a role with no privileges at all, and then ran the same four statements on the fixed 17.12. One server died, the other answered with what might be the most elegant error message of the year. Both outputs, unedited, below.

The bug: prepare, mutate, execute

The confusion lives between what a prepared statement captured and what the type system says when it runs:

CREATE TYPE pg_temp.poc2 AS (key uuid);
PREPARE stmt2 AS SELECT ('(01020000-0000-0000-0000-000000000000)')::pg_temp.poc2;
ALTER TYPE pg_temp.poc2 ALTER ATTRIBUTE key TYPE bytea CASCADE;
EXECUTE stmt2;

The literal is analyzed at PREPARE time, so the plan holds the value as binary UUID bytes (16 bytes, starting 01 02). Then ALTER TYPE rewrites the composite's descriptor to bytea. At output time, record_out() picks the output function by the new type, so those 16 UUID bytes are handed to byteaout() as a varlena pointer. A datum that was inline data becomes, retroactively, a pointer. One detail matters and cost me a first failed attempt: the crafted literal must live inside the prepared statement. Pass it as $1 and the parsing defers to EXECUTE time, after the type changed, and the value converts cleanly; no confusion, no crash. The bug is precisely the gap between captured state and current descriptor.

The crash, live, on a server I built for it

To have a vulnerable server on hand I compiled PostgreSQL 17.11 (pre-fix) from source (./configure --without-icu --without-zlib && make -j8, about four minutes on an M1), created a throwaway cluster, and gave myself the weakest possible position: CREATE ROLE victim LOGIN with a single grant. Then the four statements, as victim:

$ psql -h /tmp -p 5544 -U victim -d postgres
CREATE TYPE
PREPARE
ALTER TYPE
server closed the connection unexpectedly
        This probably means the server terminated abnormally
        before or while processing the request.
connection to server was lost

-- /tmp/pg17/pg.log, same second:
LOG:  server process (PID 21497) was terminated by signal 11: Segmentation fault: 11

Why it crashes: UUID bytes beginning 01 02 are read by byteaout() as an external expanded-object varlena header. The next 8 bytes become an ExpandedObjectHeader *; with zeros there, detoast_external_attr() -> DatumGetEOHP() -> EOH_get_flat_size(NULL) dereferences NULL. Signal 11.

And the same four statements on the fixed 17.12 (installed side by side, same cluster recipe):

CREATE TYPE
PREPARE
ALTER TYPE
ERROR:  row type of datum tuple was altered after the datum's creation
HINT:  See server log for additional details.
-- server log: 0 segfaults

That error is the fix talking: "composite attribute versioning" stamps each composite datum with its descriptor's version and refuses the output when the world changed under it. Not a bandage on byteaout, a structural detection of exactly this class.

From crash to RCE, per the writeup

The crash is the demo; the series' finale is the weapon, and its shape is a small masterclass in turning a type confusion into native execution:

  1. Control-flow hijack from a fake object. EOH_get_flat_size performs eohptr->eoh_methods->get_flat_size(eohptr): an indirect call through a per-object "methods" table. Forge the fake expanded-object header so its first 8 bytes hold your command string and the next 8 a pointer to a fake methods table containing system@plt. The indirect call executes system(eohptr), and eohptr points at the header, whose first 8 bytes are the command. The object is the argument.
  2. ASLR defeat with the same confusion, differently. Three UUID fields (48 controlled bytes) forge a fake PostgreSQL array; after altering the attribute to bigint[], unnest() returns a text element by reference (a pointer) while the SQL contract now reads it by value as a bigint. The heap address prints as an integer. The writeup shows the leak output.
  3. No privileges required. Everything runs in pg_temp as the lowest-privileged login role: no superuser, no extensions, no file access, just SQL. Ince tested it against the major managed Postgres fleets (Supabase, RDS, Aurora, AlloyDB, Databricks, and more): anywhere a tenant can run SQL, this was the distance between a login and a server-side native code execution primitive.

The November 9 release fixed six CVEs from this work (CVE-2026-6460 plus five variants covering anonymous RECORD descriptors, statistics imports, tuple-size overflows) out of 21 cases he reported. And one more line worth the attention of this blog's ongoing thread: 17 other researchers reported overlapping issues, including the OpenAI and Anthropic security teams, while Ince notes his own finds came "without any cyber enabled models". The humans got there too, same quarter, same ground.

Audit your fleet tonight

-- every instance you control: which minor are you on?
SELECT version();
-- fixed lines: 18.x >= Nov 9 release, 17.12+, 16.12+, 15.16+ (the Nov 9, 2026 security release set)
  • Self-hosted: update to the November 9 release of your major. The fix detects the class, not just the PoC, but the crash primitive is 22 years wide; there is no configuration that keeps it and feels safe.
  • Managed Postgres: the providers were in the disclosure loop; check your engine version and the provider's post-Nov-9 status page, and force the minor upgrade if your maintenance window lets you defer it.
  • Multi-tenant SQL platforms (Supabase-alikes): this was your worst case, a tenant-to-host native execution primitive. The Nov 9 patch is the reason to check that every shared cluster rebooted.

And for the pattern watchers: this blog has now covered a 16-year-old UEFI overflow, 9-bit AES-256, and a 22-year-old type confusion in the same quarter. Old bugs do not die; they wait for someone to read the versioning gap carefully. The PostgreSQL team's answer, versioning the datum to its descriptor, is the structural fix the others lacked, and the error message it produced is the most reassuring six words a DBA can read this month.

TL;DR: CVE-2026-6460 is a 22-year-old type confusion (composite type + ALTER TYPE between PREPARE and EXECUTE; the plan holds UUID bytes, output reads them as bytea/varlena) that crashes, then leaks, then executes native code, as the lowest-privileged role, on essentially every PostgreSQL since 2004 and every managed fleet tested. Fixed structurally in the November 9 release. I built vulnerable 17.11 from source and reproduced the segfault with four statements as a zero-privilege role; the fixed 17.12 answers with a clean detection error. Update tonight; the commands above tell you where you stand.

Sources: Mehmet Ince: turning a 22-year-old PostgreSQL type confusion into RCE Β· PostgreSQL November 9, 2026 security release Β· vulnerable 17.11 built from ftp.postgresql.org source; fixed 17.12 via Homebrew; both PoC runs reproduced above

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.