Boston
Meridan ReportBoston: Biotech, AI research, institutional finance

Advertisement · Why this ad?

Technology

22-Year-Old PostgreSQL Bug Turned into Remote Code Execution

Katherine Langford
Editor-in-Chief · Thursday, November 12, 2026 at 6:22 PM · 6 min read
22-Year-Old PostgreSQL Bug Turned into Remote Code Execution — Boston Technology news
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_
Up nextDwarf planet candidate appears which takes roughly 280 years to orbit our sunTechnology · 5 min read
You're offline — showing the last loaded page