Managed PG Systemic Risks-selection NeonDB SupaBase Postgis exploit

Part 1/6 | Systemic Risks in the Managed PostgreSQL Industry: Extension Risks Are Real! Exploiting PostGis Memory Corruption Bug at NeonDB, SupaBase and Many More

Back in April, I was talking with our system and software engineering teams at PRODAFT about the possibilities of using a managed database service. Due to the nature of our business, we simply cannot start using managed services right away. I told my team, “Alright, I will have a look at a few companies and let’s see how we can start using—more like trusting—these services,” and left the meeting.

I have a somewhat unconventional approach to vendor selection. Before we seriously consider adopting new open-source projects, I give myself a research window and read the source, which unsurprisingly almost always ends up with me reporting a critical vulnerability to the vendors[1][2]. Old habits die hard.

A few weeks later, I finally had time to try out different vendors to understand this industry better. Yes, we have been using PostgreSQL ever since I founded Prodaft with my partners more than a decade ago, but I have not reviewed the industry or how they provide those services, especially from a cybersecurity perspective.

On a lovely Monday morning, a few hours into reading source code and trying things out, I had to tweet the following post because I found a chain of issues that let me reach different customers’ production databases.

The same mistake was affecting Supabase as well as a few other vendors. Both of those vendors fixed that vulnerability within 30 minutes!

To be honest, I had no idea that over the following three months, I would spend all my free time outside my day job working on a research project I named “Systemic Risks in the Managed PostgreSQL Industry” finding vulnerabilities in number of different vendor that gave me cross-tenant access. I found critical issues in the underlying PostgreSQL infrastructure, such as CNPG, more than 40 vulnerabilities in the PostgreSQL extension ecosystem, as well as a logical vulnerability in the privilege-relaxation design, an inconsistency between PostgreSQL core features and managed-Postgres companies’ threat models, and many more.

I even ended up submitting a talk to PGConf EU 2026 in Valencia this October titled Nobody Gets to Be Superuser (Until I Broke In) to talk about all of these issues together. So if you are going to be around there, drop me a DM!

Ever since I submit that talk to pgconfeu, my findings has gotten way deeper and interesting so I wont be able to cover everything in a single session. Therefore I am planning to release 5 more articles that each of them will be dedicated to a different aspect of my research, but obviously I wanted to start with the most popular topic because it was my starting point to this journey too.

This article focuses on the well-known popular risks: extensions! It presents a detailed case study of how a single vulnerability in a PostgreSQL extension with a four-star rating on the GitHub repo enabled me to achieve privilege escalation on NeonDB, Supabase, Xata, and several other providers whose names I cannot disclose yet.

2nd article is going to be about my PostgreSQL core remote code execution 0-day I have exploited, everywhere.

3rd one is going to be more about underlying systems that runs postgres at scales and its burdens to whole threat model.

4rd one will be all about security hardening extensions that every vendors fails more or less the same way.

So as I gather my research notes, I will publish things I learn. So feel free to follow me on @X.

TLDR:

A missing bounds check in PostGIS’s address_standardizer extension turned a user-controlled rule value into an out-of-bounds write primitive. By chaining it with a separate memory-disclosure bug, I was able to escalate my privileges to superuser by using my own technique to flipping the rolsuper flag on local backend caches to stay under the radar, and reach RCE in production on NeonDB, Supabase, Xata, and several other managed PostgreSQL providers.

If you are not interested in the deep-dive exploitation details, feel free to jump straight to sections 12 and 13, or to My Thoughts and Closing Notes at the end.

1 – Choosing a Target:  address_standardizer Postgres Extension

I realised something in the early stages of my research months ago. There are quite a large number of layers in those tech stacks where I can actually start hunting for bugs, and a vulnerability in one of those layers can affect N companies instead of a single one.

Every managed Postgres provider—which I will refer to as an MPP for the rest of this series—has to provide almost the same features. So I opened an account with each vendor and created a test instance to find out which PostgreSQL extensions are the most common? The result was the PostGIS extensions. I realised that address_standardizer is actually quite a simple extension that is available on NeonDB, Supabase, Xata, AWS Aurora, Google AlloyDB, Azure Postgres, and all the others. Literally everywhere.

Many Postgres community members and engineers know this way better than me. Having a memory-corruption vulnerability in these extensions is exactly the same threat level as having a memory-corruption bug in PostgreSQL core. There is no such thing as an extension sandbox or a more secure way to let customers use these extensions.

At the time of this research, address_standardizer had only FOUR STARS on its GitHub repository. Used everywhere!

2 – What address_standardizer Actually Does

Before I take you into the world of hacking and exploitation, let’s have a look at the normal world where I had to learn tons of things about Postgres.

address_standardizer does what its name says. It takes an address, splits it into normalised fields such as the city, street type, and house number using lookup tables, applies a table of grammar rules, and returns a normalised address.

A normal call looks like this:

SELECT standardize_address(
   'us_lex',
   'us_gaz',
   'us_rules',
   '123 Main St',
   'Springfield'
);

house_num | name | suftype |    city
----------+------+---------+-------------
 123      | MAIN | STREET  | SPRINGFIELD

The important thing is that the caller can choose these lookup tables and rules instead of using the default one, us_rules. Internally, the extension constructs and executes a query equivalent to:

SELECT rule  FROM <caller_supplied_rules_lookup_tables>  ORDER BY id;

The complete SQL-to-C route looks something like this:

standardize_address()

   -> load_rules()  -> parse_rule()  -> rules_add_rule()  -> classify_link()

The last function, classify_link(), is where our bug lives. Let’s first understand the function’s arguments and where the values of these arguments come from, and then we will dive into the function internals to understand the bug I found.

3 – Custom Rules and the classify_link() Function

As I said earlier, I can create my own table and ask the extension to parse my rules instead. The custom rules table has only two columns, id and rule. The rule column is just a text field with the following format:

<input token symbols...>  -1  <output token symbols...>  -1  <Type>  <Weight>

The PostGIS docs have a quite detailed explanation of that rule format. I actually didn’t need to learn the whole concept of that rule. My understanding was that when you see the sequence of input tokens in the rule, you remove the sequence of output tokens and file the result under class Type with Weight. Those -1 values are just list terminators.

Rule:      29      -1     1      -1     2      1
            |       |     |       |     |      |
            |       |     |       |     |      +-- Weight
            |       |     |       |     +--------- Type
            |       |     |       +--------------- end of output list
            |       |     +----------------------- output token symbol
            |       +----------------------------- end of input list
            +------------------------------------- input token symbol

That rule is gonna be loaded by the extension during the call chain I mentioned earlier. Now that we have a general understanding of the rules table, we can start focusing on our function classify_link(), whose arguments are as follows:

classify_link(rules->r_p, o_l, keyw, u, w, t);

This function is called through a chain of functions that takes rules from the custom table and parses them (remember the chain I shared above). I followed those function calls and traced the values in order to learn each argument’s purpose, what type of data it stores, and, most importantly, whether we could control any of them through the custom rules table.

The answer is quite clear: we can actually directly control two arguments.

ArgumentMeaningWhat the SQL caller controls
rules->r_pInternal rule-processing stateNot a supplied pointer
o_lExtension-owned output-link tableNot a supplied pointer
keywThe current KW objectRule order related stuff
uTree node input symbols influence the node
wWeightDirectly controlled integer!!!
tTypeDirectly controlled integer!!!

At the time of my research, the following function, rules_add_rule(), which calls classify_link(), was missing important validation.

int rules_add_rule(RULES *rules, int num, int *rule)
{
    int i, w;
    SYMB t;
    NODE u;
    KW *keyw;
    KW ***o_l;

    o_l  = rules->r_p->output_link;
    keyw = rules->r_p->key_space + rules->rule_number;
    u    = EPSILON;

    /*
     * All the INPUT and OUTPUT tokens from custom rule receive their own validation here
    */

    i++;
    t = rule[i];         // Type that we can control!!

    i++;
    w = rule[i];         // Weight  that we can control!!

    classify_link(
        rules->r_p, 
        o_l,        
        keyw,       
        u,          
        w,          
        t           
    );

    return 0;
}

The Type and Weight values from the custom rules table were not validated against minimum or maximum values. You already got the idea where this is leading. Now we can get into the details of classify_link() to better understand the bug.

4 – CVE-2026-73514 – The classify_link() OOB-Write Primitive

Now that we know where every argument comes from and that we can control the fifth and sixth arguments, let’s look at classify_link().

static void  classify_link(RULE_PARAM *r_p, KW ***o_l, KW *k,  NODE u, SYMB w, SYMB c)
{
    KW *last_key;
    KW *penult;

    k->Type = c;                                       // <--[1] Type we control!!
    k->Weight = w;                                     // <--[2] Weight we control!!

    last_key = o_l[u][c];                              // <--[3] THE BUG. We control `c` there.

    if (last_key == NULL) {
        o_l[u][c] = k;                                 // <--[4] // NULL branch: direct OOB write!!!
    } else {
        while ((penult = last_key->OutputNext) != NULL)
            last_key = penult;

        last_key->OutputNext = k;                      // <--[5] // Append k at the first NULL OutputNext.
                                                       //         indirect OOB write
    }
}

[1] and [2] copy my two numbers into the object.

[3] is where we have the vulnerability! If I can choose a Type value larger than the size of the o_l[u] array, we can actually have an OOB read.

[4] If the value held at the selected OOB address is NULL, we can write the address of k back to that same OOB slot. We can influence the destination via Type, but we cannot choose the exact value going to be written. It is always the address of the current KW object. This is not the strongest OOB-write vulnerability. (To be honest, I spent the following six or seven days trying to find a way to exploit this OOB write, and this article is gonna be all about that specific OOB write.)

[5] If the value read at [3] is not NULL, we end up in the while loop that was originally designed to build a linked list, find the end of it, and append something new there. That is the second OOB-write possibility, which I named the indirect write.

Now it’s time to better understand o_l. The output-link table is named o_l. As you can see on line 9, each o_l[u] row is allocated using calloc(), which places it in the PostgreSQL backend’s libc-managed heap instead of in a PostgreSQL memory context allocated via palloc(), which is familiar to PostgreSQL exploit core developers. After reading the calloc macro’s code and debugging a 64-bit build, I realised that each row has an allocation of exactly 40 bytes containing exactly five eight-byte pointers!

one o_l[u] row

+0x00  [0] MACRO  → KW * list head
+0x08  [1] MICRO  → KW * list head
+0x10  [2] ARC    → KW * list head
+0x18  [3] CIVIC  → KW * list head
+0x20  [4] EXTRA  → KW * list head
+0x28              end of the allocation

We know that we can choose whatever value we want for our Type. But I learned that if we choose anything outside [0,4], this reads a value after or before the 40-byte allocation area!

The idea is simple: we will put a Type value larger than 4 into the rules table, and that will try to read beyond the fifth element of the o_l[u] row and crash.

Here is the PoC code that chooses a large number to load a value from God knows where, way beyond the end of the allocated area.

CREATE TEMP TABLE poc_rules_oob
(
    id   serial PRIMARY KEY,
    rule text NOT NULL
);

INSERT INTO poc_rules_oob(rule)
VALUES (
    '29 -1 1 -1 2147483647 1'
);

SELECT standardize_address(
    'us_lex',
    'us_gaz',
    'poc_rules_oob',    // Use our own custom rule table
    '123 Main St',
    'Springfield'
);

And the backend crashed!

Even to a keen eye, this bug initially looks like a dead end. It looked dead to me. I spent a week trying to find a way to exploit this. Let me summarise the limitations of the OOB-write bug:

  • We do not have an arbitrary read because the OOB value is never disclosed.
  • We do not directly control the value loaded into last_key
  • We do not have an arbitrary write because the payload is always the live pointer to the k object.
  • Type selects a relative address from o_l[u]; it is not an absolute pointer.
  • An unmapped selected address can crash the backend before any write.
  • The primitive provides no address disclosure and does not defeat ASLR.

At the end of the day, we have two paths, both of which start with the same OOB read. The left branch is the path I named the direct write, while the right one is the indirect path with the linked-list traversal loop we saw earlier.

                         last_key = o_l[u][Type]
                                  |
                    +-------------+-------------+
                    |                           |
                    v                           v
             selected qword = 0          selected qword != 0
                    |                            |
                    v                            v
           store current KW there       treat it as a KW pointer
                                                 |
                                                 v
                                          follow OutputNext
                                                 |
                                                 v
                                         append current KW

We don’t have many options for doing nasty stuff, do we?

5 – Choosing the First Path: The Direct One

I started with the direct-write branch because it was simpler; as you can see, it has fewer moving parts than the linked-list branch.

last_key = o_l[u][Type];
if (last_key == NULL)
    o_l[u][Type] = k;

We already learned that o_l[u] is an array of KW * pointers. If B is the address of its first slot, the address selected by Type can be represented like this:

Selected Address = B + (Type * sizeof(KW *)).

// on 64-bit system KW pointers is 8 eight-byte so
Selected Address = B + (Type * 8)

So whatever Type value we choose for our custom role, it’s always gonna be represented in memory as follows.

Type         Selected address    Value
Type -1      B - 0x08            !First qword before the allocation
Type  0      B + 0x00            Valid o_l[u][0] slot
Type  1      B + 0x08            Valid o_l[u][1] slot
Type  2      B + 0x10            Valid o_l[u][2] slot
Type  3      B + 0x18            Valid o_l[u][3] slot
Type  4      B + 0x20            Valid o_l[u][4] slot
Type  5      B + 0x28            !first qword after the allocation
Type  6      B + 0x30            !second qword after the allocation

All that means we can influence the code to load an eight-byte value from before or after these valid slots by choosing a different integer for Type. And if the value at that memory address is NULL, our current K object pointer will be written back to the same address from which we read NULL.

I have never actually needed to exploit such a weird OOB-write vulnerability before. I didn’t even know what I could actually do with it. But there was something even more unclear to me: writing a heap pointer somewhere in the address space had to change something in PostgreSQL that I could use to my advantage. That’s where I had to take a short detour.

The Detour: Prior Work on PostgreSQL Exploitation at ZeroDay.Cloud 2025

In December 2025, I was watching fellow hackers pop shells at Wiz.io’s ZeroDay.Cloud event. I watched people successfully exploit memory bugs in PostgreSQL extensions. I kind of knew what type of bugs they were working on. I was more interested in the question: what was the end-game primitive?

I am not a binary exploitation expert, but I am old enough to know you would choose easier features to abuse instead of dealing with LIBC ROP exploitation shenanigans. CVE-2026-2005 and CVE-2026-2006 were published later, around May, and showed that once they managed to hijack the application’s control flow, they targeted one of two things:

  • overwriting the set_config function’s callback with ExecuteRecoveryCommand, which gives you command execution! Clever!!!!
  • reaching a global in PostgreSQL’s .data section that holds the OID of the currently authenticated role and setting it to 10 (BOOTSTRAP_SUPERUSERID

Both exploitation tricks are brilliant! Both require quite a strong OOB-write primitive. But these blog posts really helped me decide that, with the weird, limited OOB write I had, I shouldn’t be looking to overwrite any function callbacks. I just needed a way to escalate my privileges to rolsuper so that I could run COPY TO/FROM PROGRAM to reach RCE.

These exploits didn’t even have to care about how to find those addresses as part of the ZeroDay.Cloud hacking event. They already had a less-restricted privileged user who could read /proc/self/mem because the user had pg_readfile permissions. So, in practice, exploiting these memory bugs on managed-Postgres providers is even more complicated, which I will talk about later. Spoiler alert: I had to find a second OOB-read vulnerability!

And yes, a few weeks later, I learned that having priv esc to rolsuper was not even enough to reach COPY TO/FROM PROGRAM directly on managed-Postgres companies. That ‘feature’ is blocked by default. The following month, I found a third ‘feature’ within Postgres core that I abused to bypass these final protections, which I will also talk about at PGCONF.EU… God knows I got too involved with Postgres. Fun stuff.

6 – Targeting the FormData_pg_authid Struct! Long Live pg_authid.rolsuper

Both CVE-2026-2005 and CVE-2026-2006 use brilliant exploitation tricks to achieve remote code execution! Especially, the second exploit’s manipulation of an OID in PostgreSQL’s .data section got me thinking. Shout-out to both Team Xint Code and Team Bugz Bunnies and, of course, the Wiz.io team for organising such an amazing event!

Due to the limitations of my bug, I can’t just go and write anything I want. While reading Postgres internals, I had a constant battle with Claude, trying to convince it that this bug could somehow be exploitable. Then I realised that going after pg_authid might be a really promising target!

The idea was quite stupid and initially looked practically impossible to exploit. But before explaining the exploitation idea, I wanna show you the FormData_pg_authid code I was reading:

PostgreSQL stores role information in the pg_authid system catalog. One of its fixed fields is rolsuper.

typedef struct FormData_pg_authid
{
    Oid      oid;
    NameData rolname;
    bool     rolsuper;
    bool     rolinherit;
    bool     rolcreaterole;
    bool     rolcreatedb;
    bool     rolcanlogin;
    bool     rolreplication;
    bool     rolbypassrls;
    int32    rolconnlimit;
    /* nullable variable-length fields follow */
} FormData_pg_authid;

Internally, superuser() calls superuser_arg(GetUserId()), which looks up the role in the cache and reads rolsuper. Postgres even keeps a small one-entry cache for the most recently checked role in the private backend.

Let’s go back to what I thought would actually be a cool idea. The order of the  authid struct members led me to this idea:

I can create a new role with absolutely no privileges. That means rolsuper, rolinherit, rolcreaterole, and  rolcreatedb will be zero, which will give me four consecutive 0000 bytes in memory. Luckily, rolname is just before the rolsuper attribute, which means that if I choose a short name for my new role, I can have four more zero bytes just before those four consecutive 0000 bytes, creating a total of eight zero-filled bytes.

That means the direct-write condition requiring a NULL value would be satisfied! But the beautiful thing about that lucky order of the pg_authid struct members is that rolsuper, the most important member, falls within our eight-byte value. So our existing KW pointer value will be written exactly at this location!

If I can tweak a KW pointer to have 01 as its fifth byte, it will line up with the rolsuper attribute. We can temporarily override our new role’s privileges and enable superuser access only within our local backend.

I was super happy when I actually found something that looked like a viable exploitation method. I love reading code, and I genuinely believe that just reading your target’s source code will always bring some ideas. This has always been my number-one rule when doing vulnerability research, but it’s getting harder and harder to believe what you are reading when Claude has been gaslighting you for days and nights. Our future white-hat hacker colleagues who are born into the AI era will struggle a lot to distinguish which thoughts are their own and which are heavily influenced. Anyway, that is a topic for another day.

My idea was kind of viable. To better understand the potential success rate of this approach, I had to dive further into Postgres internals because of one important question:

Where is each role’s authid struct in memory? The answer turned out to be… multiple locations!

7 – Targeting the PG_AUTHID Struct in Memory: CATCACHE and Shared Buffers

As I learned during my journey through Postgres internals, pg_authid is stored on the file system first. Then, of course, its pg_authid page is loaded into the shared buffers so different backends can read it faster. But to make lookups and access to role attributes even faster, each backend has catcache tuples in its own local memory area.

That means the helper role I create with a short name and zero privileges will appear in two places: the shared buffer between Postgres backends and each backend’s local cache copy. I understood the general concept of SysCache, etc., but I needed to understand role caching in detail. I asked my Claude agent to teach me how the multiple local catcaches are managed and how many copies exist in memory, and to write a helper script to scan memory for all the candidates. It was really, really helpful. Thanks to this little crash course, I learned that the same role would be cached in two different places in the local backend for different purposes (more on that later)!

Before going too deep into deciding which target I would choose, I formed a plan for how I was gonna calculate the Type value needed to reach that target.

Our ability to reach out-of-bounds addresses starts from o_l[u][0], so we can use the following approach to calculate the Type value:

Out of bound Type value = (OUR_TARGET_ADDRESS - &o_l[u][0]) / 8

This is a classic reverse-address calculation approach used by every exploit developer. Whatever the result of that equation is, it is the exact value we need to use as the input for Type. Now we need to line up the rolsuper field with an eight-byte address and find OUR_TARGET_ADDRESS.

On a 64-bit system, let’s say MEMORY_ADDR_ROLE_STORED points to the first member of authid, which is an OID. Then we have rolname, which occupies a 64-byte area, followed by rolsuper, and so on.

MEMORY_ADDR_ROLE_STORED + 0  .. +3        role OID
MEMORY_ADDR_ROLE_STORED + 4  .. +67       rolname.data[0..63]
MEMORY_ADDR_ROLE_STORED + 68              rolsuper
MEMORY_ADDR_ROLE_STORED + 69              rolinherit
MEMORY_ADDR_ROLE_STORED + 70              rolcreaterole
MEMORY_ADDR_ROLE_STORED + 71              rolcreatedb

Remember that we will use the last four bytes of rolname.data and the following four bytes of the role’s attributes to create an eight-byte, pointer-shaped area. That means starting 64 bytes from MEMORY_ADDR_ROLE_STORED will land us exactly where we wanna be. OUR_TARGET_ADDRESS = MEMORY_ADDR_ROLE_STORED + 4 + 60 will point to an initially zeroed, eight-byte, pointer-shaped area!

Creating such a role is simple. The following two queries are enough to create the role we need.

CREATE ROLE exploit_helper
      NOSUPERUSER
      NOINHERIT
      NOCREATEROLE
      NOCREATEDB
      NOREPLICATION
      NOBYPASSRLS;

GRANT exploit_helper TO original_login;

Every byte from MEMORY_ADDR_ROLE_STORED + 64 through MEMORY_ADDR_ROLE_STORED + 71 will be zero. More importantly, the fifth byte is gonna be rolsuper.

I created 100 roles and asked my Claude agent to run a memory-analyser script from our previous work, find all these roles in memory, and verify that the eight-byte area was zero.

A few minutes later, it said: All verified. I double-checked it manually. They were all zero!

I reckon this could have been a different story if the order of the struct FormData_pg_authid members had been different. Having the name member followed by rolsuper was just pure luck!

Lessons learned: poison both AUTHOID and AUTHNAME caches

I thought that flipping rolsuper at one cache address would be enough, but it turned out that two different caches in the local backend were used for different operations. The AUTHNAME cache is used while resolving the helper by name during a SET ROLE query. On the other hand, the AUTHOID cache is used by superuser_arg() for internal authorization checks.

Poisoning only one could produce quite weird behaviour, and you could start gaslighting yourself for days, like I did. I lost another few days there. Anyway, the reality is that I had two targets instead of one. In the same backend, I had to load both the AUTHNAME and AUTHOID entries into memory first and calculate a separate Type value for each one.

You might be asking why on earth you need to run SET ROLE after the exploit? Remember that our helper role will have superuser as long as our backend lives. To turn this priv esc into permanent access, I need to run SET ROLE exploit_helper; to switch to our superuser account and then create another role with superuser privileges, making sure we have a role whose entry in pg_authid is updated on the file system!

During my research, becoming rolsuper triggered alerts or was blocked by hardening controls every where Vendors closely monitor superuser escalation, so I battle-tested my new shadow-superuser primitive in real environments to see if it could stay under the radar. It works like a charm!

8 – A Real, Successful PoC | Why We Don’t Need to Write Exactly 0x01 to rolsuper

I implemented all of this logic and let it run on my local stock Postgres instance. The next morning, I observed one successful run that used the following KW* pointer address!

0x00005e0b3d9d5cd0

Exploit development is one of those rare areas of computer science where successful attempts make things even more confusing. I was like, how the hell did this work? Where is the 0x01 in this?

The easiest way to understand what is going on is to imagine eight one-byte boxes. On a little-endian x64 system, that pointer will appear in memory as follows.

//T is a target memory address the exploit tried.

Offset from T   +0         +1         +2         +3         +4        +5          +6              +7
Catalog field   name[60]   name[61]   name[62]   name[63]   rolsuper  rolinherit  rolcreaterole   rolcreatedb
Before write    00         00         00         00         00        00          00              00
After write     d0         5c         9d         3d         0b        5e          00              00
                                                            ^^
                                                    rolsuper became 0x0b

We successfully wrote 0x0b to rolsuper. I was like, “Oooooh, I know what is going on here.” Let me explain why we don’t need exactly 0x01 there.

PostgreSQL’s source does not explicitly contain code like rolsuper & 1. However, because a valid C bool is expected to be either 0 or 1, the compiler actually decides to produce assembly code that performs that comparison!

For example, my local ARM version of the exploit showed that the compiled check_role() function in Postgres loads the raw rolsuper byte like this.

ldrb    w23, [x3, #68]     // load roleform->rolsuper

Later, the compiled SetCurrentRoleId() tests only that bit.

tst     x1, #1             // test is_superuser & 1
csel    x1, x1, x0, eq     // select "off" when bit zero is clear

What I am trying to say is that even though the following C code doesn’t exist in the PostgreSQL core source, the compiled binary can do something like this.

if ((is_superuser & 1) != 0)
      use_superuser_privileges();

So our magical rolsuper value 0x0b is binary 00001011; its lowest bit is 1. ANDing it with 1 results in 1.

0x0b = 00001011

Testing its lowest bit gives:

  00001011
& 00000001
----------
  00000001

Or in C:

(0x0b & 1) == 1

After poisoning the necessary backend-local cache copies, the actual authorization test succeeded:

mdisec=> SET ROLE exploit_helper;
mdisec=> SELECT current_user, current_setting('is_superuser');

current_user | current_setting
--------------+-----------------
exploit_helper     | on
(1 row)

It feels like solving one problem brings up more problems than it solves. How the hell are we actually going to know which version the NeonDB production instance was compiled with? What optimisation level did the compiler use? And so on… There are a few ways to learn more about PostgreSQL. For example, SELECT version(); returns quite a lot of useful information. But educated brute force is, of course, inevitable when it comes to memory-corruption bugs.

9 – Exploiting a PostgreSQL Memory-Corruption Vulnerability at Managed-Postgres Services

After a week of trying to build a kind of reliable exploit for the vulnerability, I reached a point where I could finally start thinking about whether it would be viable in the managed-Postgres industry.

Could this chain survive the constraints of a real managed PostgreSQL environment?

The biggest setback is that none of these providers let customers use pg_read_file to read the OS file system. Why? I will cover that topic in later articles. For now, the important thing is that we cannot learn our own memory map from /proc/self/maps or /proc/self/mem, which is a big problem. On top of that, I literally have no idea about the build information, CPUs, runtime environment, or any hardening that may be in place—too many unknown variables.

But first things first, I need a second vulnerability to leak the target cache addresses, as well as my own base address, to calculate the distance!

10 – CVE-2026-73515 – PostGIS Out-of-Bounds Read

The PostgreSQL extension ecosystem, including PostGIS extensions, has dozens of low-severity 0-days that you can use to leak memory addresses here and there. I have reported more than 15 OOB-leak vulnerabilities across five different extensions. The one I used in the exploit chain was from PostGIS itself.

To be clearer, my report hasn’t been acknowledged yet at the time of the writing this article. Therefore, I am not going to share any details of that vuln. It’s fixed. Same bug reported by Sarath Kumar, IITM Pravartak Security Team too!

Memory leaks are everywhere, and people seem kind of okay with having them around—at least, that is what I understood from my three months of interaction with the postgres industry.

Another week down, and I was confident enough that it was time to see how these vulnerabilities were really affecting real-world targets.

11 – Choosing the Real-Life Targets: NeonDB – Supabase – Xata

Even if I was 99.99% confident that crashing the Postgres pod I was given wouldn’t affect anything beyond my own instance, I still wanted to make my exploit work on an MPP provider that was a safe harbour to ‘hack’.

I had already been introduced to Xata.io‘s CTO, Tudor, as well as to a member of the Supabase.com security team due to my initial findings. Both companies were super cool about me wandering around the security of the Postgres ecosystem, and they actually wanted me to use their services to test my ideas.

As a third target, I chose NeonDB because it runs a quite popular bug-bounty programme, and it responded to a few of my prior findings very quickly (more about this later too). So I chose my three targets to see if I could really exploit that memory-corruption bug on all of them.

If you are asking why I didn’t choose the well-known big names ? Same reason, I didn’t know anyone from these companies. Also if I can make this working on 3 of these, I am pretty sure that it would have been same more or less for any company.

PoC Exploit in action against NeonDB

PoC Exploit in action against SUPABASE

PoC Exploit in action against Xata

12 – A Special Note for the Databricks Team Re: Reaching Out About the Neon Production Campaign

It was a Monday at around 7 p.m. here in London when I was testing a few ideas on my own NeonDB instance, and I received an email from the Databricks’s senior manager Aaron K.:

Hi Mehmet,

I’m reaching out to ask if you are running Neon security testing in production.  We’ve had multiple alarms go off, and I want to confirm that you are doing the testing.  We’ve seen correlated activity from 3 email addresses,

mehmet[@][redacted]

mehmet[@][redacted]

Can you confirm that you are the owner of all these accounts ?

Unfortunately, I don’t see a reachout for testing in production. Is there a specific reason you need to test in production, and did you reach out beforehand?  

He was absolutely right. They had provided details of their staging environment on the H1 page and asked researchers to use it. Everything was written there, yet even as someone with two decades of experience in this field, I didn’t pay attention to the details.

Later, we exchanged a few emails, and he was actually quite interested in my research, Systemic Risks in the Managed PostgreSQL Industry, which later led me to choose NeonDB as a ‘target’ when I decided to exploit this vuln in the wild. I was also quite interested in seeing how the managed-Postgres companies reacted to the 0-day vulnerabilities.

The moment I PoC’ed the NeonDB instance, I shared the proof with Aaron, without even providing the exploit source code or anything else. Just the screenshot. He immediately started discussing it internally with the relevant parties, and they started working on a hotfix!

I must say, kudos to the entire NeonDB team!

My Thoughts and Closing Notes

Some Postgres companies publicly state that vulnerabilities originating in third-party code are out of scope for their responsible disclosure programmes. And of course, PostgreSQL extensions are considered third-party libraries. Even PostgreSQL core itself is considered third-party software 🙂

While I do understand the reason behind that decision, I genuinely don’t believe that would work if customer data were stolen and the response was, ‘Not our mistake.‘ The world has changed, and we are living in an era where a kid with a $20 AI subscription can develop capabilities that once required years of specialist knowledge (I have been finding vulnerabilities since the ’00s, so yeah, we used to be the cool kids 🙂

I am not in a position to judge the defenders who spend their days and nights trying to operate MPPs securely. I know they are way more experienced in this industry than I am. I have just been wandering around for three months in my free time, and I genuinely believe they have solid reasons behind these decisions, such as AI slop, budgets, and CVE fatigue.

But here I am telling you there is an OOB-write bug in an extension that is used by every single managed-Postgres company. While I was trying to prove the impact of my OOB-write bug in the real world, the maintainers patched a different OOB write. It’s been five weeks, and I still haven’t seen a CVE. My own OOB-write bug is still there, exactly where they fixed something else five weeks ago. I have sent a PR, and it is waiting there. (Update: Fix is merged)

Almost no one knows about any of this, and I simply don’t have 10 days to adjust my exploit to make it work in every Postgres company’s environment just to prove it works, get past the initial triage process, and finally reach the relevant engineering team. Even when I did all of that, a few companies still told me, ‘Nah. It didn’t originate in our own codebase, so it’s not our bug. Report it to the OSS maintainer.’

On the other hands, few companies says “If we pay bounty, you can’t share the vulnerability with anyone else even if its OSS project and affects different vendors” which leaves rest of the industry vulnerable. Some of these vendors says “You should immediately report it to all impacted downstream services”

It’s a mad game going on out there. And I am just a buyer who is trying to understand the risk of using managed-Postgres services. At the end of the day, who is going to be accountable when everything goes sideways? is the question yet I need to figure out.

Regards,
M.

I would like to thank my dear friend @SinSinology for reviewing this article and providing amazing feedback! Special thanks for Tudor Golubenco, Etienne Stalmans, folks at PlanetScale, Aaron K. from DataBricks, Google Cloud VRP team personally took a lot of time address every question I asked and many more name I couldn’t ask their permission to mention their names.

APPENDIX

I spent an avg 4/5 days on each vendor for porting my exploit to different environments. I learned quite a lot of exploitation tricks and methods, especially around PostgreSQL.

A.I. Usage over the last 4 months

First and foremost, I don’t have an access to Mythos or something magical red-teaming LLMs. This particular research is done even before stronger models gpt-5.6 released.

I would like to share not the token maxxing number here with you but human-hours I have invested, as it is much easier to say ‘Yeah even I found the 0-days with A.I.’ and undermine the the importance of vulnerability researchers. Yes A.I. is changed the game, but it has changed all the other games too.

At the time of the writing this article, it was 4 months since I logged-in to first managed-Postgres provider. I actually record my daily invested hours for this research ever since:

  • Every week day avg 5 hours
  • Every weekend 7-10 hours

You do the math.

PostGIS Team

This article was written weeks ago and shared with trusted parties in advance. Up until PostGIS’s recent release, I had not received any response from the project. A few days ago, they explained that PostGIS historically has not had a formal CVE or coordinated-disclosure process. Security fixes are generally handled through the normal development cycle, partly because the volunteer maintainer team lacks the capacity to run a full disclosure process and partly because of past experiences where CVEs led vendors to cherry-pick patches instead of properly upgrading.

The reward for my 0-day PoC on NeonDB was $2,200. I double it up to $5k and contribute it back to the PostGIS project along with my PR to fix the bug as a small thank-you for their hard work to maintaining/securing the volunteer based project that is used by every single vendor.

Expdev Shenanigans – Life Was Great When pg_buffercache Was Available

The pg_buffercache extension was extremely useful for exploit development. It provides a live view of Postgres shared-buffer entries, including the buffer ID, relation filenode, etc. Especially when I needed to target the shared-buffer area to manipulate rolsuper instead of the backend-local cache, I had no idea how to determine which shared-buffer frame contained the relation block holding my helper role.

I asked the GPT-5.5 agent to go and try to use the existing extension features to inspect memory. I am pretty sure that trick is documented somewhere online; otherwise, I wouldn’t believe Codex could one-shot this in under one minute.

Expdev Shenanigans – pg_prewarm helps!

One real-world problem I ran into was that I needed to learn where the memory page was, and I didn’t have access to my good friend pg_buffercache. This is where pg_prewarm became unexpectedly useful.

Expdev Shenanigans – Why the indirect write suddenly mattered

The direct branch had already worked like a charm in my controlled environment. But real life is quite different. One of the target’s memory layouts was not reachable with a direct-branch write primitive. This was exactly where the second indirect OOB-write branch I had initially avoided became useful. Using that linked list to swing myself up into the shared-buffer address range was fun!

Leave a Reply

Your email address will not be published. Required fields are marked *