Katherine oversees editorial operations across all six brands. Before joining The Meridan Group, she spent 14 years at the Minneapolis Star Tribune, where she led the metro desk through its digital transformation. She holds a Master's in Journalism from Northwestern's Medill School.
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:
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;
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 ]
}
}
)
}
{
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 NULL
consttype
=
23
→ int4
constlen
=
4
→ four
-
byte payload
constbyval
=
true
→ stored by value
constisnull
=
false
→ this
is not NULL
constvalue 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 = 0x05
57
decimal
=
0x39
5
decimal
=
0x05
On my little-endian system, the bytes were stored least-significant first:
39 05 00 00 → 0x00000539 → 1337
39
05
00
00
→ 0x00000539 →
1337
So
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 | int4out
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 | int4out
That 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 | textout
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 | textout
For 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 representation
Datum ──
>
pointer ──
>
variable
-
length
text representation
So 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 | 4
SELECT
1337
, pg_typeof(
1337
), pg_column_size(
1337
);
?
column
? | pg_