#!/usr/bin/env ruby # # This script is intended to manage a single AWS EC2 instance for use during # pentests. The script offers following functionalities: # # start - Starts an EC2 instance. If it does not exist, it is to be created # stop - Stops the EC2 instance. It does not terminate it. # restart - Restarts the EC2 instance # terminate - Terminates the EC2 instance. # address - Gets an IPv4 address of the EC2 instance. If verbose options is set, will return more FQDN also. # status - Checks what is a status of picked EC2 instance. # ssh - Opens a ssh connection with specified instance. If it is not running, it is to be created and started. # notify - Sends gnome notification using "notify-send" with running instance uptime # # The basic use case for this script is to have yourself launched an EC2 instance for quick # verification of Web Application vulnerabilities like out-of-bound communication: blind-XXE for instance. # Everytime you will be in position of needing a machine with public IPv4 address - take this script for a spin, # and it will provide you with your own EC2 instance. # # Requirements: # - gem "aws-sdk-ec2" # # Author: Mariusz B., '19, <mb@binary-offensive.com> # require 'aws-sdk-ec2' require 'base64' require 'optparse' VERSION = '0.1' $aws_config_path = File.join((ENV['OS'] == 'Windows_NT') ? ENV['UserProfile'] : File.expand_path('~'), '.aws') $config = { # # Notably interesting configuration to fill up # :image_id => 'ami-07360d1b1c9e13198', :key_name => 'ec2-pentest-key', :sg_name => 'ec2-pentest-usage', :instance_name => 'pentestec2', :instance_type => 't2.micro', :ssh_user => 'ec2-user', :ssh_identity_file => File.join($aws_config_path, 'ec2-pentest-key.pem'), # ---- :verbose => false, :quiet => false, :debug => false, :aws_path => "", :aws_profile => 'default', :region => 'us-east-1', } $supported_funcs = { 'start' => 'Starts an EC2 instance. If it does not exist, it is to be created', 'stop' => 'Stops the EC2 instance. It does not terminate it.', 'restart' => 'Restarts the EC2 instance', 'terminate' => 'Terminates the EC2 instance.', 'address' => 'Gets an IPv4 address of the EC2 instance. If verbose options is set, will return more FQDN also.', 'status' => 'Checks what is a status of picked EC2 instance.', 'ssh' => 'Opens a ssh connection with specified instance. If it is not running, it is to be created and started.', 'notify' => 'Sends gnome notification using "notify-send" with running instance uptime.' } class Logger def Logger._out(x) if not $config[:quiet] and ($config[:verbose] or $config[:debug]) STDOUT.write(x + "\n") end end def Logger.dbg(x) if $config[:debug] Logger._out("[dbg] #{x}") end end def Logger.info(x) Logger._out("[.] #{x}") end def Logger.fatal(x) unless $config[:quiet] STDOUT.write(x + "\n") end exit 1 end def Logger.fail(x) Logger._out("[-] #{x}") end def Logger.ok(x) Logger._out("[+] #{x}") end end class AwsEc2Manager attr_reader :instance_name def initialize(func, instance_name, config) @instance_name = instance_name @checked = false @func = func @config = config @instance_id = "" @instance_id_file = File.join($aws_config_path, instance_name+'_id') try_to_load_instance_id @aws_access_key_id = "" @aws_secret_access_key = "" if @config[:aws_profile] != 'default' or not @config[:aws_path].to_s.empty? path = @config[:aws_path].to_s.empty? ? $aws_config_path : @config[:aws_path] Logger.dbg("Initializing AWS config with creds from path: #{path} - profile: #{@config[:aws_profile]}") shared_creds = Aws::SharedCredentials.new( path: path, profile_name: @config[:aws_profile] ) Aws.config.update({ credentials: shared_creds }) end Aws.config.update({region: @config[:region]}) @ec2_client = Aws::EC2::Client.new(region: @config[:region]) @ec2 = Aws::EC2::Resource.new(client: @ec2_client) end def try_to_load_instance_id if File.file? @instance_id_file File.open(@instance_id_file) do |f| @instance_id = f.gets.strip Logger.dbg("Using instance ID: #{@instance_id}") end else Logger.fail("No instance id file: #{@instance_id_file}") end end def get_security_group_id group_id = "" @ec2_client.describe_security_groups do |security_group| if security_group.group_name == @config[:sg_name] group_id = security_group.group_id break end end group_id end def try_to_find_instance_id Logger.dbg("Trying to find that specific instance online...") insts = @ec2.instances({ filters: [ { name: 'image-id', values: [@config[:image_id]] }, { name: 'instance-type', values: [@config[:instance_type]] }, { name: 'tag:name', values: [@config[:instance_name]] }, { name: 'key-name', values: [@config[:key_name]] }, ] }) insts.each do |instance| Logger.dbg("Checking instance with ID: #{instance.id}") if instance.state.code == 48 Logger.fail("Instance is terminated. Leaving id file empty.") File.open(@instance_id_file, 'w') do |f| f.puts("") end @instance_id = "" break end Logger.dbg("Found proper instance: #{instance.id} / #{instance.image_id}. Clobberring id file...") @instance_id = instance.id File.open(@instance_id_file, 'w') do |f| f.puts(instance.id) end break end if not insts.any? Logger.fail("Did not found any instance matching our criterias.") end @instance_id end def get_instance_id if @checked and not @instance_id.to_s.empty? Logger.dbg("Returning cached checked instance id.") elsif not @instance_id.to_s.empty? Logger.dbg("Checking if instance with ID = #{@instance_id} still exists.") i = @ec2.instance(@instance_id) if i.exists? Logger.dbg("Instance still exists.") @checked = true else Logger.dbg("Instance does not exist, clearing cached instance id in file.") File.open(@instance_id_file, 'w') do |f| f.puts("") end try_to_find_instance_id end end Logger.info("Working on instance: #{@instance_id}") @instance_id end def create(wait: false) # group_id = get_security_group_id # unless group_id # Logger.fatal("Could not obtain EC2 Security Group ID by name: #{@config[:sg_name]}") # end Logger.dbg(%Q(Creating an instance: AMI Image ID: #{@config[:image_id]} EC2 Key Name: #{@config[:key_name]} Security Group Name: #{@config[:sg_name]} )) # Security Group ID: #{group_id} instance = @ec2.create_instances({ image_id: @config[:image_id], min_count: 1, max_count: 1, key_name: @config[:key_name], security_groups: [@config[:sg_name]], instance_type: @config[:instance_type], tag_specifications: [ { resource_type: 'instance', tags: [ key: 'name', value: @config[:instance_name], ], }, ], }) if instance.any? Logger.ok("Instance created. Waiting for it to get into running state...") else Logger.fail("Could not spin up an instance! Something went wrong but will proceed anyway...") end # Wait for the instance to be created, running, and passed status checks inst = instance.first if wait puts "Waiting for instance to get it up & running. This might take a couple of minutes." @ec2.client.wait_until(:instance_status_ok, {instance_ids: [inst.id]}) else Logger.ok("Instance up & initializing.") end File.open(@instance_id_file, 'w') do |f| f.puts(inst.id) end if @config[:quiet] puts "created" elsif @config[:verbose] or @config[:debug] puts "Created instance: inst.id" end end def start(wait: false) state = status(raw: true) Logger.info("Instance is in state: #{state}") if state == 'notcreated' create(wait: wait) else if get_instance_id.to_s.empty? Logger.fatal("No instance that could be started.") end i = @ec2.instance(@instance_id) if i.exists? case i.state.code when 0 # pending puts "#{i.id} is pending, so it will be running in a bit" when 16 # started puts "#{i.id} is already started" when 48 # terminated puts "#{i.id} is terminated, gotta create another one." create(wait: wait) else puts "Started instance. Please wait couple of minutes before doing SSH." begin i.start return true rescue Aws::EC2::Errors::IncorrectInstanceState => e if e.include? "is not in a state from which it can be started." Logger.fatal("EC2 instance is in a state from which it can't be started right now. Try later.") else Logger.fatal("Could not start EC2 instance: #{e}") end end end end end return false end def stop if get_instance_id.to_s.empty? Logger.fatal("No instance that could be stopped.") end i = @ec2.instance(@instance_id) if i.exists? case i.state.code when 48 # terminated puts "#{i.id} is already stopped." when 64 # stopping puts "#{i.id} is stopping, so it become stopped in a while." when 80 # stopped puts "#{i.id} is already stopped." else puts "Stopped an instance." i.stop end end end def restart if get_instance_id.to_s.empty? Logger.fatal("No instance that could be restarted.") end i = @ec2.instance(@instance_id) if i.exists? case i.state.code when 48 # terminated start else puts "Issued instance reboot signal." i.reboot end end end def terminate if get_instance_id.to_s.empty? Logger.fatal("No instance that could be terminated.") end i = @ec2.instance(@instance_id) if i.exists? case i.state.code when 48 # terminated puts "#{i.id} is already terminated." else puts "Terminated instance. Cleared instance id file." i.terminate File.open(@instance_id_file, 'w') do |f| f.puts("") end end end end def address(raw: false) if get_instance_id.to_s.empty? if @config[:quiet] exit 1 end Logger.fatal("No instance id to operate on. Instance will need to be created first.") end addr = "" i = @ec2.instance(@instance_id) if i.exists? Logger.dbg("Instance found.") addr = i.public_ip_address if not raw if @config[:verbose] puts "Public IPv4:\t#{i.public_ip_address}" puts "Public FQDN:\t#{i.public_dns_name}" puts "Private IPv4:\t#{i.private_ip_address}" puts "Private FQDN:\t#{i.private_dns_name}" else puts i.public_ip_address end end end addr end def status(raw: false, quitOnFailure: true) get_instance_id if @instance_id.to_s.empty? Logger.fail("No instance id stored locally. Will try to look up online.") if not try_to_find_instance_id puts("No instance created.") exit 1 end end if @instance_id.to_s.empty? and quitOnFailure state = "notcreated" if not raw puts "State: notcreated" end return state end state = "" inst = @ec2.instance(@instance_id) if inst.exists? state = inst.state.name if not raw if @config[:verbose] or @config[:debug] puts "State:\t\tInstance is #{inst.state.name} (#{inst.state.code})" if inst.state.code == 16 dif = ((Time.now - inst.launch_time) / 60).ceil t = inst.launch_time stime = t.strftime("%Y-%m-%d %I:%M%P UTC") puts "Launched time:\t#{stime}; #{dif.to_s} minutes ago." end else puts inst.state.name end end else state = "notcreated" if not raw puts "State: notcreated" end end state end def ssh state = status(raw: true) if state == 'stopped' puts "Instance is stopped. Creating it first." if not start(wait: true) Logger.fatal("Could not create EC2 instance.") end state = 'running' end if state == 'running' addr = address(raw: true) cmd = "ssh -i #{@config[:ssh_identity_file]} -o ConnectTimeout=10 -oStrictHostKeyChecking=no #{@config[:ssh_user]}@#{addr}" Logger.dbg("Running command: #{cmd}") puts "Attempting to ssh #{@config[:ssh_user]}@#{addr} ...\n\n" exec(cmd) end raise "Unsupported EC2 machine state: #{state}" end def notify get_instance_id if @instance_id.to_s.empty? if not try_to_find_instance_id exit 1 end end inst = @ec2.instance(@instance_id) if inst.exists? Logger.dbg('Instance exists.') if inst.state.code == 16 Logger.dbg('Instance is running.') minutes = ((Time.now - inst.launch_time) / 60).ceil title = "EC2 Instance #{@config[:instance_name]} is running." body = "" if minutes < 60 body = "Your instance has been running #{minutes} minutes by now. Consider stopping it." else hours = (minutes / 60).floor restm = minutes % 60 body = "Your instance's been running #{hours}h and #{restm}mins by now. Consider stopping it." end cmd = "notify-send '#{title}' '#{body}'" Logger.dbg("Executing notification command: #{cmd}") exec(cmd) puts cmd else Logger.dbg("Instance is not running.") end end end end def parse_options options = {} parser = OptionParser.new do |opts| funcs = "" $supported_funcs.each do |k, v| funcs += " - #{k}\t\t\t#{v}\n" end opts.banner = %Q( Usage: aws-manager.rb [options] <func> <name> Available 'func' values: #{funcs} Options: ) opts.on('-h', '--help', 'Display this screen' ) do puts opts exit end opts.on('-q', '--quiet', 'Surpress informative output.') do |q| $config[:quiet] = q end opts.on('-v', '--verbose', 'Turn on verbose logging.') do |v| $config[:verbose] = v end opts.on('--debug', 'Turn on debug logging.') do |d| $config[:debug] = d end opts.on('-dPATH', '--aws-path=PATH', "Path to shared AWS credentials file. Default value that will be used: $AWS_PATH/credentials ") do |p| $config[:aws_path] = p end opts.on('-pNAME', '--profile=NAME', 'AWS credentials profile to use. Should no option is given, "default" is used.') do |n| $config[:aws_profile] = n end opts.on('-pREGION', '--region=REGION', 'AWS regoin to use. Default one: "us-east-1".') do |n| $config[:region] = n end opts.on('-iID', '--image-id=ID', "AWS image ID to create an EC2 from. Default: '#{$config[:image_id]}") do |i| $config[:image_id] = i end opts.on('-kKEY', '--key-name=KEY', "AWS EC2 Key Name to use. Default: '#{$config[:key_name]}") do |k| $config[:key_name] = k end opts.on('-sNAME', '--security-group-name=NAME', "AWS EC2 Security Group name to use. Default: '#{$config[:sg_name]}") do |s| $config[:sg_name] = s end opts.on('-tTYPE', '--instance-type=TYPE', "Instance type to spin. Default: '#{$config[:instance_type]}") do |s| $config[:instance_type] = s end opts.on('-uUSER', '--user=USER', "SSH user to log into when doing 'ssh'. Default: '#{$config[:ssh_user]}") do |s| $config[:ssh_user] = s end end args = parser.parse! func = args.shift.downcase raise "Need to specify <func> parameter to invoke." unless func unless $supported_funcs.keys.include? func Logger.fatal("Unsupported function specified. You need to pick on of these: #{$supported_funcs.keys.join(', ')}") end instance_name = args.shift.downcase $config[:instance_name] = instance_name raise "Need to specify <name> parameter to invoke." unless instance_name raise "EC2 Security Group name must be specified." unless $config[:sg_name] raise "EC2 Key pair name must be specified." unless $config[:key_name] raise "EC2 image id (AMI) must be specified." unless $config[:image_id] raise "EC2 instance type must be specified." unless $config[:instance_type] raise "EC2 instance name must be specified." unless $config[:instance_name] raise "EC2 instance name must be specified." unless $config[:ssh_user] return func, instance_name end def main func, instance_name = parse_options Logger.dbg("Using AWS configuration path: #{$aws_config_path}") Logger.dbg("Action to take: #{func} #{instance_name}") manager = AwsEc2Manager.new(func, instance_name, $config) manager.send func end main