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

AWS SSM Sessions #17430

Merged
merged 37 commits into from
Jun 1, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
3624bee
Initial implementation for AWS SSM shells
Dec 31, 2022
9850534
Initial WebSocket connection wrapper
Jan 1, 2023
cfc24f1
Implement SSM WebSocket init/auth
Jan 1, 2023
c733dbc
Start processing AWS SSM WebSocket session frames
Jan 1, 2023
43d746c
Implement SSM WebSocket Sessions
Jan 3, 2023
46c030a
Finalize SSM Shell via WebSocket
Jan 3, 2023
7666b30
Rudimentary enumeration module for EC2+SSM
Jan 3, 2023
eba4c4b
Spoonfeed the skiddies: auto-sessions for SSM enum
Jan 4, 2023
955fb2e
SSM WebSocket session keep-alive
Jan 4, 2023
60c2f0a
SSM enumeration module filter and throttle
Jan 4, 2023
274bf6d
Make SSM keepalive optional
Jan 13, 2023
14f992a
Address some of @smcityre-r7's comments
Jan 13, 2023
99b2e1d
add aws ssm gem to lock file
jmartin-tech Jan 4, 2023
3e54ae6
Resolve crashes noted by @smcintyre-r7, simplify
Jan 21, 2023
589c225
Implement reporting and pretty output
Jan 21, 2023
453baca
Drop mask_write, tweak logging
Jan 21, 2023
27d6a89
Use keepalive in SSM aux module
Jan 21, 2023
61c2726
Fix NoMethodError for #opcode
smcintyre-r7 Feb 1, 2023
687e82a
Satisfy rubocop
smcintyre-r7 Feb 3, 2023
7e19141
Standardize DS names and set OS platforms
Feb 5, 2023
153f950
Add AwsSsmCommandShellBind session type
Feb 5, 2023
8ac5ae2
Fix sessions opening over and over again
smcintyre-r7 Apr 18, 2023
d8c8255
Set the platform in enum_ssm
smcintyre-r7 Apr 18, 2023
15ff487
Combine AWS SSM modules, autodetect platform
smcintyre-r7 Apr 18, 2023
a7d8bc6
Fix sessions opening over and over again
smcintyre-r7 Apr 18, 2023
59b3c0e
Set the platform in enum_ssm
smcintyre-r7 Apr 18, 2023
2e3a2b6
Combine AWS SSM modules, autodetect platform
smcintyre-r7 Apr 18, 2023
5b94077
Merge remote-tracking branch 'origin/pr/38' into feature/aws_ssm_sess…
Apr 22, 2023
5132302
Filter control bytes from SSM output
Apr 22, 2023
d797e5e
Simplify SSM shell output filtering
Apr 22, 2023
3a4cb35
shell_command_token_base get 0th output index
Apr 22, 2023
867902e
SSM start/stop publication
Apr 28, 2023
e926951
Fix linux tests, remove Windows support (#39)
smcintyre-r7 May 10, 2023
d8dd9bb
Move the publish timeout logic (#40)
smcintyre-r7 May 11, 2023
713ec6a
Merge branch 'master' into feature/aws_ssm_sessions
sempervictus May 16, 2023
f929d2c
Drop redundant shell_command in powershell.rb
May 16, 2023
120dc87
Pr/collab/17430 (#41)
smcintyre-r7 May 22, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ PATH
aws-sdk-ec2
aws-sdk-iam
aws-sdk-s3
aws-sdk-ssm
bcrypt
bcrypt_pbkdf
bson
Expand Down Expand Up @@ -146,6 +147,9 @@ GEM
aws-sdk-core (~> 3, >= 3.165.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sdk-ssm (1.146.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2)
bcrypt (3.1.18)
Expand Down
372 changes: 372 additions & 0 deletions lib/msf/core/handler/bind_aws_ssm.rb
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'.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# 'bind_tcp'.
# 'bind_aws_ssm'.

#
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible retry_until_truthy call from retry mixin?

def retry_until_truthy(timeout:)

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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