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 kubernetes enum module #14

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
513 changes: 513 additions & 0 deletions documentation/modules/auxiliary/cloud/kubernetes/enum_kubernetes.md

Large diffs are not rendered by default.

Binary file not shown.
2 changes: 1 addition & 1 deletion lib/msf/core/auxiliary/report.rb
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ def store_loot(ltype, ctype, host, data, filename=nil, info=nil, service=nil)
ext = 'bin'
if filename
parts = filename.to_s.split('.')
if parts.length > 1 and parts[-1].length < 4
if parts.length > 1 and parts[-1].length <= 4
ext = parts[-1]
end
end
Expand Down
29 changes: 29 additions & 0 deletions lib/msf/core/exploit/remote/http/jwt.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Minimal JWT wrapper which only decodes the base64 header/claim values,
# and doesn't encode/validate JWT tokens.
#
# Note that swapping this out for a third-party gem will work, but
# there may be potential security issues with the key id (kid) claim etc,
# which would need to be reviewed.
class Msf::Exploit::Remote::HTTP::JWT
attr_reader :payload, :header, :signature

def initialize(payload:, header:, signature:)
@payload = payload
@header = header
@signature = signature
end

def self.encode(payload, key, algorithm = 'HS256', header_fields = {})
raise NotImplementedError
end

def self.decode(jwt, _key = nil, _verify = true, _options = {})
header, payload, signature = jwt.split('.', 3)
raise ArgumentError, 'Invalid JWT format' if header.nil? || payload.nil? || signature.nil?

header = JSON.parse(Rex::Text.decode_base64(header))
payload = JSON.parse(Rex::Text.decode_base64(payload))

self.new(payload: payload, header: header, signature: signature)
end
end
99 changes: 99 additions & 0 deletions lib/msf/core/exploit/remote/http/kubernetes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# -*- coding: binary -*-

# Base mixin for Kubernetes exploits,
module Msf::Exploit::Remote::HTTP::Kubernetes
include Msf::PostMixin
include Msf::Post::File

def initialize(info = {})
super

register_options(
[
Msf::OptString.new('TOKEN', [false, 'Kubernetes API token']),
Msf::OptString.new('NAMESPACE', [false, 'The Kubernetes namespace', 'default']),
]
)
end

def connect_ws(opts = {}, *args)
opts['comm'] = session
opts['vhost'] = rhost
super
end

def send_request_raw(opts = {}, *args)
opts['comm'] = session
opts['vhost'] = rhost
super
end

def api_token
@api_token || datastore['TOKEN']
end

def rhost
@rhost || datastore['RHOST']
end

def rport
@rport || datastore['RPORT']
end

def namespace
@namespace || datastore['NAMESPACE']
end

def configure_via_session
vprint_status("Configuring options via session #{session.sid}")

unless directory?('/run/secrets/kubernetes.io')
# This would imply that the target is not a Kubernetes container
fail_with(Msf::Module::Failure::NotFound, 'The kubernetes.io directory was not found')
end

if api_token.blank?
token = read_file('/run/secrets/kubernetes.io/serviceaccount/token')
fail_with(Msf::Module::Failure::NotFound, 'The API token was not found, manually set the TOKEN option') if token.blank?

print_good("API Token: #{token}")
@api_token = token
end

if namespace.blank?
ns = read_file('/run/secrets/kubernetes.io/serviceaccount/namespace')
fail_with(Msf::Module::Failure::NotFound, 'The namespace was not found, manually set the NAMESPACE option') if ns.blank?

print_good("Namespace: #{ns}")
@namespace = ns
end

service_host = service_port = nil
if rhost.blank?
service_host = get_env('KUBERNETES_SERVICE_HOST')
fail_with(Msf::Module::Failure::NotFound, 'The KUBERNETES_SERVICE_HOST environment variable was not found, manually set the RHOSTS option') if service_host.blank?

@rhost = service_host
end

if rport.blank?
service_port = get_env('KUBERNETES_SERVICE_PORT_HTTPS')
fail_with(Msf::Module::Failure::NotFound, 'The KUBERNETES_SERVICE_PORT_HTTPS environment variable was not found, manually set the RPORT option') if service_port.blank?

@rport = service_port.to_i
end

if service_host || service_port
service = "#{Rex::Socket.is_ipv6?(service_host) ? '[' + service_host + ']' : service_host}:#{service_port}"
print_good("Kubernetes service host: #{service}")
end
end

def validate_configuration!
fail_with(Msf::Module::Failure::BadConfig, 'Missing option: RHOSTS') if rhost.blank?
fail_with(Msf::Module::Failure::BadConfig, 'Missing option: RPORT') if rport.blank?
fail_with(Msf::Module::Failure::BadConfig, 'Invalid option: RPORT') unless rport.to_i > 0 && rport.to_i < 65536
fail_with(Msf::Module::Failure::BadConfig, 'Missing option: TOKEN') if api_token.blank?
fail_with(Msf::Module::Failure::BadConfig, 'Missing option: NAMESPACE') if namespace.blank?
end
end
190 changes: 190 additions & 0 deletions lib/msf/core/exploit/remote/http/kubernetes/auth_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# -*- coding: binary -*-

# Parses the succinct Kubernetes authentication API response and converts it into
# a more consumable format
class Msf::Exploit::Remote::HTTP::Kubernetes::AuthParser
def initialize(auth_response)
@auth_response = auth_response
end

# Extracts the list of rules associated with a kubernetes auth response
def rules
resource_rules = auth_response.dig(:status, :resourceRules) || []
non_resource_rules = auth_response.dig(:status, :nonResourceRules) || []
policy_rules = resource_rules + non_resource_rules

broke_down_policy_rules = policy_rules.flat_map do |policy_rule|
breakdown_policy_rule(policy_rule)
end
compacted_rules = compact_policy_rules(broke_down_policy_rules)
sorted_rules = compacted_rules.sort_by { |rule| human_readable_policy_rule(rule) }

sorted_rules
end

# Converts the kubernetes auth response into an array of human readable table
def as_table
columns = ['Resources', 'Non-Resource URLs', 'Resource Names', 'Verbs']
rows = rules.map do |rule|
[
combine_resource_groups(rule[:resources], rule[:apiGroups]),
"[#{rule[:nonResourceURLs].join(' ')}]",
"[#{rule[:resourceNames].join(' ')}]",
"[#{rule[:verbs].join(' ')}]"
]
end

{ columns: columns, rows: rows }
end

protected

attr :auth_response

def policy_rule_for(apiGroups: [], resources: [], verbs: [], resourceNames: [], nonResourceURLs: [])
{
apiGroups: apiGroups,
resources: resources,
verbs: verbs,
resourceNames: resourceNames,
nonResourceURLs: nonResourceURLs
}
end

# Converts the original policy rule into its smaller policy rules, where
# there is at most one verb for each rule
def breakdown_policy_rule(policy_rule)
sub_rules = []
policy_rule.fetch(:apiGroups, []).each do |group|
policy_rule.fetch(:resources, []).each do |resource|
policy_rule.fetch(:verbs, []).each do |verb|
if policy_rule.fetch(:resourceNames, []).any?
sub_rules += policy_rule[:resourceNames].map do |resource_name|
policy_rule_for(
apiGroups: [group],
resources: [resource],
verbs: [verb],
resourceNames: [resource_name]
)
end
else
sub_rules << policy_rule_for(
apiGroups: [group],
resources: [resource],
verbs: [verb]
)
end
end
end
end

sub_rules += policy_rule.fetch(:nonResourceURLs, []).flat_map do |non_resource_url|
policy_rule[:verbs].map do |verb|
policy_rule_for(
nonResourceURLs: [non_resource_url],
verbs: [verb]
)
end
end

sub_rules
end

# Finds the original policy rule associated with a simplified rule
def find_policy(existing_simple_rules, simple_rule)
return nil if simple_rule.nil?

existing_simple_rules.each do |existing_simple_rule, policy|
is_match = (
existing_simple_rule[:group] == simple_rule[:group] &&
existing_simple_rule[:resource] == simple_rule[:resource] &&
existing_simple_rule[:resourceName] == simple_rule[:resourceName]
)

if is_match
return policy
end
end

nil
end

# Merge policy rules together, by joining rules that are associated with the same resource, but different
# verbs
def compact_policy_rules(policy_rules)
compact_rules = []
simple_rules = {}
policy_rules.each do |policy_rule|
simple_rule = as_simple_rule(policy_rule)
if simple_rule
existing_rule = find_policy(simple_rules, simple_rule)

if existing_rule
existing_rule[:verbs] ||= []
existing_rule[:verbs] = (existing_rule[:verbs] + policy_rule[:verbs]).uniq
else
simple_rules[simple_rule] = policy_rule.clone
end
else
compact_rules << policy_rule
end
end

compact_rules += simple_rules.values
compact_rules
end

# returns nil if it's not possible to simplify this rule
def as_simple_rule(policy_rule)
return nil if policy_rule[:resourceNames].count > 1 || policy_rule[:nonResourceURLs].count > 0
return nil if policy_rule[:apiGroups].count != 1 || policy_rule[:resources].count != 1

allowed_keys = %i[apiGroups resources verbs resourceNames nonResourceURLs]
unsupported_keys = policy_rule.keys - allowed_keys
return nil if unsupported_keys.any?

simple_rule = {
group: policy_rule[:apiGroups][0],
resource: policy_rule[:resources][0]
}

if policy_rule[:resourceNames].any?
simple_rule.merge(
{
resourceName: policy_rule[:resourceNames][0]
}
)
end

simple_rule
end

def human_readable_policy_rule(rule)
parts = []

parts << "APIGroups:[#{rule[:apiGroups].join(' ')}]" if rule[:apiGroups].any?
parts << "Resources:[#{rule[:resources].join(' ')}]" if rule[:resources].any?
parts << "NonResourceURLs:[#{rule[:nonResourceURLs].join(' ')}]" if rule[:nonResourceURLs].any?
parts << "ResourceNames:[#{rule[:resourceNames].join(' ')}]" if rule[:resourceNames].any?
parts << "Verbs:[#{rule[:verbs].join(' ')}]" if rule[:verbs].any?

parts.join(', ')
end

def combine_resource_groups(resources, groups)
return '' if resources.empty?

parts = resources[0].split('/', 2)
result = parts[0]

if groups.count > 0 && groups[0] != ''
result = result + '.' + groups[0]
end

if parts.count == 2
result = result + '/' + parts[1]
end

result
end
end
Loading