-
Notifications
You must be signed in to change notification settings - Fork 13.7k
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
AWS SSM Sessions #17430
AWS SSM Sessions #17430
Changes from 13 commits
3624bee
9850534
cfc24f1
c733dbc
43d746c
46c030a
7666b30
eba4c4b
955fb2e
60c2f0a
274bf6d
14f992a
99b2e1d
3e54ae6
589c225
453baca
27d6a89
61c2726
687e82a
7e19141
153f950
8ac5ae2
d8c8255
15ff487
a7d8bc6
59b3c0e
2e3a2b6
5b94077
5132302
d797e5e
3a4cb35
867902e
e926951
d8dd9bb
713ec6a
f929d2c
120dc87
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
@@ -0,0 +1,372 @@ | ||||
# -*- 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 Rex::Proto::Http::WebSocket::AmazonSsm | ||||
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 | ||||
@cmd_doc = peer_info['CommandDocument'].blank? ? 'AWS-RunShellScript' : peer_info['CommandDocument'] | ||||
|
||||
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: @cmd_doc, | ||||
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]), | ||||
sempervictus marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
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) | ||||
|
||||
register_advanced_options( | ||||
[ | ||||
OptString.new('AWS_SSM_SESSION_DOC', [true, 'The SSM document to use for session requests', 'SSM-SessionManagerRunShell']), | ||||
OptString.new('AWS_SSM_COMMAND_DOC', [true, 'The SSM document to use for command requests', 'AWS-RunShellScript']), | ||||
OptBool.new('AWS_SSM_FORCE_COMMANDS', [false, 'Force the session to use command abstraction without WebSockets', false]), | ||||
OptBool.new('AWS_SSM_KEEP_ALIVE', [false, 'Keep AWS SSM session alive with empty messages', true]) | ||||
], Msf::Handler::BindAwsSsm) | ||||
|
||||
self.bind_thread = nil | ||||
self.conn_thread = nil | ||||
self.bind_sock = 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) { | ||||
ssm_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) | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Possible
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I kinda thought Handler is supposed to use Rex primitives instead of pulling in Exploit namespaces - this was ripped from a bind handler IIRC. Can switch up to the convenience method if the mixin is already in-play or add it if that's useful to folks. |
||||
begin | ||||
ssm_client, peer_info = get_ssm_session | ||||
rescue Rex::ConnectionError => e | ||||
vprint_error(e.message) | ||||
rescue | ||||
wlog("Exception caught in SSM handler: #{$!.class} #{$!}") | ||||
break | ||||
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, | ||||
} | ||||
|
||||
self.conn_thread = framework.threads.spawn("BindAwsSsmHandlerSession", false, ssm_client, peer_info) { |client_copy, info_copy| | ||||
begin | ||||
raise Rex::Proto::Http::WebSocket::ConnectionError if datastore['AWS_SSM_FORCE_COMMANDS'] | ||||
session_init = client_copy.start_session({ | ||||
target: datastore['AWS_EC2_ID'], | ||||
document_name: datastore['AWS_SSM_SESSION_DOC'] | ||||
}) | ||||
ssm_sock = connect_ssm_ws(session_init) | ||||
chan = ssm_sock.to_ssm_channel | ||||
chan._start_ssm_keepalive if datastore['AWS_SSM_KEEP_ALIVE'] | ||||
chan.params.comm = Rex::Socket::Comm::Local unless chan.params.comm | ||||
chan.params.peerhost = peer_info['IpAddress'] | ||||
chan.params.peerport = 0 | ||||
chan.params.peerhostname = peer_info['ComputerName'] | ||||
chan.update_term_size | ||||
rescue Rex::Proto::Http::WebSocket::ConnectionError | ||||
info_copy['CommandDocument'] = datastore['AWS_SSM_COMMAND_DOC'] | ||||
chan = AwsSsmSessionChannel.new(framework, client_copy, info_copy) | ||||
rescue => e | ||||
elog('Exception raised from BindAwsSsm.handle_connection', error: e) | ||||
end | ||||
self.bind_sock = chan | ||||
handle_connection(chan.lsock, { datastore: datastore }) | ||||
} | ||||
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: | ||||
attr_accessor :bind_sock # :nodoc: | ||||
|
||||
|
||||
module AwsSsmSessionChannelExt | ||||
attr_accessor :localinfo | ||||
attr_accessor :peerinfo | ||||
end | ||||
|
||||
end | ||||
end | ||||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.