If you had told me in April 2026 one question “Mehmet, can we use managed PostgreSQL for this project?” would send me into the deepest rabbit hole of my life, I would have laughed.
Months later, I finally found the bottom of that rabbit hole. It was a 22-year-old zero-day in PostgreSQL core.
In this blog post you will read a story of how I ended up uncovering a new class of type-confusion vulnerabilities in PostgreSQL.
This is the 3rd part of blog post series named “Systemic Risks in the Managed PostgreSQL Industry”. For those who have missed the first two part, feel free to start from 1st and 2nd which will give you a complete context of this madness.
TLDR:
I gave PostgreSQL a value inside a composite type, changed the type of one of its fields, and asked it to read the value back. That type confusion led to one of the most reliable memory-corruption exploit I have seen. But I did not start by looking for a PostgreSQL core vulnerability.
I was exploiting pg_clickhouse, and getting that exploit to work left me with a question about how PostgreSQL understands its own data. With an AI agent as my teacher, I worked through the internals one question at a time. Then a seemingly unrelated conversation gave me the idea that connected it all, and eventually I found myself staring at a primitive that had been there for for awhile.
This is the full true story of how those pieces came together, I am sure you will enjoy this story.
Beginning of August 2026
During June and July, I had been exploiting memory-corruption vulnerabilities across several PostgreSQL extensions. One of the extensions that I achieved code execution was pg_clickhouse. That case study has its own story, which I will share in the future.
What matters here is what that exploit forced me to learn. pg_clickhouse translates ClickHouse values into native PostgreSQL values. A ClickHouse Array is not a PostgreSQL array, right ? So the extension had to rebuild its elements as Datum values in the format that PostgreSQL expected. A mistake in that conversion was what eventually gave me a code execution.
This was my first time meeting with things like Datum, OIDs etc in the PostgreSQL universe. I learned only enough to finish the exploit, but one question remained in the back of my mind:
How does PostgreSQL itself decide what a value is? And how does it process and represent it?
Somewhere around late July, I finally got my head out of the extensions and just followed that question into PostgreSQL core.
Disclaimer: I reported pg_clickhouse oob write + read chain to the vendor and they fixed it at Jun 23.
PostgreSQL Types 101
I started learning with a very simple query. What happens when I run the simplest query SELECT 1337?
While I avoided all the black magic in Postgres internals, I kept this crash course on the basics of data types.
I start with this query.
SET debug_print_parse = on; SELECT 1337;The database scanner looks at the query, recognises SELECT as a keyword, and 1337 as an integer token. Then it creates a parse tree for the query. Next, PostgreSQL walks the tree and transforms it into a typed query tree.
{QUERY :commandType 1 :targetList ( {TARGETENTRY :expr {CONST :consttype 23 :consttypmod -1 :constlen 4 :constbyval true :constisnull false :location 7 :constvalue 4 [ 57 5 0 0 0 0 0 0 ] } } ) }Parse tree looks like above. But I just wanted to better understand things like what is the consttype 23 ? why constvalue has [ 57 5 0 0 0 0 0 0 ] and where is my 1337 value ? At this point, I asked Opus 5 to prepare me a full deep-dive course on PostgreSQL types. Spending a few days around there and talking with my agent, whom I named pg_expert taught me these:
consttype = 23 → int4 constlen = 4 → four-byte payload constbyval = true → stored by value constisnull = false → this is not NULLconstvalue 4 [ 57 5 0 0 0 0 0 0 ] can be converted to hexadecimal, making the value easier to recognize:
57 decimal = 0x39 5 decimal = 0x05On my little-endian system, the bytes were stored least-significant first:
39 05 00 00 → 0x00000539 → 1337So 1337 had not disappeared. But why do we even have 8 bytes [ 57 5 0 0 0 0 0 0 ] instead of simply 4 bytes ?
That was because constvalue was not declared as an int32. It was declared as a Datum, which was 8 bytes Our value 1337::int4 value stored at the lower four bytes of that generic carrier.
How can that same 8-byte Datum represent a text, an array, or custom types ?
A Datum Does Not Know What It Is
Chatting with my pg_expert agent taught me more. A Datum does NOT know what it represents.
The eight bytes carrying 1337 did not contain a hidden label saying “I am an integer.”. PostgreSQL knew its type only because of this consttype = 23.
That number is an OID (Object IDentifiers) pointing to a row in the pg_type system catalog
SELECT oid, typname, typlen, typbyval, typalign, typinput::regproc, typoutput::regproc FROM pg_type WHERE oid = 23; oid | typname | typlen | typbyval | typalign | typinput | typoutput -----+---------+--------+----------+----------+----------+----------- 23 | int4 | 4 | t | i | int4in | int4outThat means the pg_type row shows what the value means and how PostgreSQL will use it. So far, it’s brilliant and easy to understand. But what about the text type ?
SELECT oid, typname, typlen, typbyval, typinput::regproc, typoutput::regproc FROM pg_type WHERE oid IN ('int4'::regtype, 'text'::regtype) // <== HERE FILTERIN THE INT4 and TEXT only ORDER BY oid; typname | typlen | typbyval | typinput | typoutput ---------+--------+----------+----------+----------- int4 | 4 | true | int4in | int4out text | -1 | false | textin | textoutFor text, typlen = -1 means that the representation have a variable length. And typbyval = false means that the Datum carried a pointer.
Datum ──> pointer ──> variable-length text representationSo the same 8-byte value stores a memory address for the text. Datum can pass the value either by value itself or by reference to it. Nice, I love the elegant abstraction Datums provides!
Unknown is a type!
So far everything makes sense, but I wanted to better understand the typinput and typoutput columns too. These functions convert the value provided by the query into a native Datum or vice versa. So I ran this again.
SELECT 1337, pg_typeof(1337), pg_column_size(1337); ?column? | pg_typeof | pg_column_size ----------+-----------+---------------- 1337 | integer | 4But then I added quotation marks to see if PostgreSQL has dynamic type casting/recognition or something:
SELECT pg_typeof('1337'); pg_typeof ----------- unknownI was a bit confused at this moment. I initially thought unknown meant “PostgreSQL does not know the type.” So I went back to chatting with my pg_expert agent, who is teaching me everything so far. I learned that unknown is an actual PostgreSQL type with OID 705, used for string literals whose final type hasn’t been decided yet!
Again, this makes super sense because PostgreSQL deliberately assigns untyped string literals this temporary type because the same text could eventually become many different things!
SELECT '1337'::int4;For example, the query supplies the target type int4. PostgreSQL looked up the typinput function for int4 and called int4in() and when its need to be printer it use int4out()
Critical Thinking: Wait a Minute! The Type Chooses the Code
Up until this point, typinput and typoutput looked like ordinary conversion mechanism to me. pg_type contained many different native functions for many different types.
While learning more about these functions, one detail stayed with me. An output function did not inspect a Datum and discover its type. It simply trusted all the values and flags around the Datum.
int4out() extracted an integer because PostgreSQL had already selected it as the output function for int4. A different output function could interpret the same generic carrier according to completely different rules.
I still remember that, at that very moment, I was actually thinking about the TOCTOU Vulnerabilities class of the web apps I have been exploiting/fixing for years and years.
“What if I could somehow change the type between the moment PostgreSQL constructed a value and the moment it used that value?”
If I can do this, we might be able to force PostgreSQL to process a Datum created for a completely different type as if it were the type the function expects. Having a type confusion in PostgreSQL can actually give me an opportunity to abuse all the other functions in PostgreSQL internals.
So I went back to learning more about what I can use to force PostgreSQL to natively call different types’ output functions for my int4 value. It turns out that the LANGUAGE internal feature can be used for that purpose.
LANGUAGE internal: Type Confusion I Could Create on Purpose
The LANGUAGE internal allows a superuser to create an SQL function pointing to native code already compiled into PostgreSQL. I created the following one.
CREATE FUNCTION pg_temp.confused_textout(integer) RETURNS cstring AS 'textout' LANGUAGE internal STRICT;Then I gave it the same int4 value. and my local-backend crashed!
SELECT pg_temp.confused_textout(1337); server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request.I’m pretty sure the PostgreSQL experts reading this wouldn’t be surprised by this crash, but it surprised me a lot!
It sent me in a completely different direction. I built a working exploit using this type-confusion feature to exploit LANGUAGE internal against security extensions deployed by managed PostgreSQL providers. That research eventually became a part of the Part 2 story.
For a while, I moved on and left this type-confusion question behind.
Roughly two weeks later, something completely unrelated brought me back to it.
Detour: Tomatoes and SQL Injection
Let me take a small detour. This Postgres article series is all about telling you my story in the end, isnt it ?
I do not remember the exact date, but during one of our runs or walks, my partner Bilge (Staff Machine Learning Engineer at EDB) and I were discussing second-order SQL injection in the PostgreSQL AI extensions and the possibility of tricking AI features of these extensions to hallucinate and produce malicious SQL queries.
That conversation led to chatting about prepared statements.
I first learned about SQL injection around 2005, and God knows how many times I have exploited it since.
Twenty years later, most libraries and frameworks still rely heavily on string escaping to generate secure SQL queries, even though RDBMSs have a beautiful feature named prepared statements.
I studied them when I was a uni student, mostly to see what could still be exploited even if the target app uses prepared statements. Things like triggering excessive SQL cold parsing and stuff like that.
But more importantly, for years I taught SQL Injection to thousands of my trainees with this concept:
“Knowledge is knowing a tomato is a fruit; wisdom is not putting it in a fruit salad.”
– Miles Kington
In my version, the SQL query is the recipe and the values are the ingredients. Prepared statements let the database understand the recipe first, then receive the ingredients separately. So even a value like ' OR 1=1 -- may look evil, but it remains data and never gets the chance to rewrite the query.
I had always seen this separation as a security feature. But this time, I noticed something in the middle of the convo with Bilge. Databases can finish all the parsing first and wait for me to execute it. Before execution, I can run whatever query I want.
I can alter types before executing the query!
Uncovering 22-Year-Old PostgreSQL Type-Confusion Primitive
So the idea is simple; I could prepare a query, change something, and execute it later. But change what? PostgreSQL is not gonna let me redefine what the built-in types int4, text, or UUID mean.
I was not thinking about composite types at this point. Honestly, I barely knew what they were. I asked Claude agent pg_expert a much simpler question:
Which PostgreSQL types can I create, use inside a prepared query, and then alter it before running execute ?
That question led me to composite types. In PostgreSQL, a composite type represents the structure of a row. It is essentially a custom, user-defined data type that acts as a container for multiple fields, each with its own name and data type. [1, 2]
CREATE TYPE pg_temp.poc AS ( key uuid );It had its own type OID, but its attributes defined the current row layout. I can change the attribute a type from UUID to another type without replacing the composite type itself.
The Crash
I created a one-field composite where the attribute started as UUID. Then I prepared a query, changed that attribute to bytea, and executed the prepared statement.
CREATE TYPE pg_temp.poc AS (key uuid); PREPARE pc_crash AS SELECT '(01020000-0000-0000-0000-000000000000)' ::pg_temp.poc; ALTER TYPE pg_temp.poc ALTER ATTRIBUTE key TYPE bytea CASCADE; EXECUTE pc_crash;And the result was a segfault!
Related posts
The Application Is Running, but Port 443 Times Out: A CompTIA Security+ (SY0-701) Incident Triage Walkthrough
