558 lines
16 KiB
Ruby
558 lines
16 KiB
Ruby
require 'stringio'
|
|
require 'open3'
|
|
require 'English'
|
|
require 'tempfile'
|
|
require 'fileutils'
|
|
require 'timeout'
|
|
require 'shellwords'
|
|
|
|
module Acceptance
|
|
class ChildProcessError < ::StandardError
|
|
end
|
|
|
|
class ChildProcessTimeoutError < ::StandardError
|
|
end
|
|
|
|
class ChildProcessRecvError < ::StandardError
|
|
end
|
|
|
|
# A wrapper around ::Open3.popen2e - allows creating a process, writing to stdin, and reading the process output
|
|
# All of the data is stored for future retrieval/appending to test output
|
|
class ChildProcess
|
|
def initialize
|
|
super
|
|
|
|
@default_timeout = ENV['CI'] ? 120 : 40
|
|
@debug = false
|
|
@env ||= {}
|
|
@cmd ||= []
|
|
@options ||= {}
|
|
|
|
@stdin = nil
|
|
@stdout_and_stderr = nil
|
|
@wait_thread = nil
|
|
|
|
@buffer = StringIO.new
|
|
@all_data = StringIO.new
|
|
end
|
|
|
|
# @return [String] All data that was read from stdout/stderr of the running process
|
|
def all_data
|
|
@all_data.string
|
|
end
|
|
|
|
# Runs the process
|
|
# @return [nil]
|
|
def run
|
|
self.stdin, self.stdout_and_stderr, self.wait_thread = ::Open3.popen2e(
|
|
@env,
|
|
*@cmd,
|
|
**@options
|
|
)
|
|
|
|
stdin.sync = true
|
|
stdout_and_stderr.sync = true
|
|
|
|
nil
|
|
rescue StandardError => e
|
|
warn "popen failure #{e}"
|
|
raise
|
|
end
|
|
|
|
# @return [String] A line of input
|
|
def recvline(timeout: @default_timeout)
|
|
recvuntil($INPUT_RECORD_SEPARATOR, timeout: timeout)
|
|
end
|
|
|
|
alias readline recvline
|
|
|
|
# @param [String|Regexp] delim
|
|
def recvuntil(delim, timeout: @default_timeout, drop_delim: false)
|
|
buffer = ''
|
|
result = nil
|
|
|
|
with_countdown(timeout) do |countdown|
|
|
while alive? && !countdown.elapsed?
|
|
data_chunk = recv(timeout: [countdown.remaining_time, 1].min)
|
|
if !data_chunk
|
|
next
|
|
end
|
|
|
|
buffer += data_chunk
|
|
has_delimiter = delim.is_a?(Regexp) ? buffer.match?(delim) : buffer.include?(delim)
|
|
next unless has_delimiter
|
|
|
|
result, matched_delim, remaining = buffer.partition(delim)
|
|
unless drop_delim
|
|
result += matched_delim
|
|
end
|
|
unrecv(remaining)
|
|
# Reset the temporary buffer to avoid the `ensure` mechanism unrecv'ing the buffer unintentionally
|
|
buffer = ''
|
|
|
|
return result
|
|
end
|
|
ensure
|
|
unrecv(buffer)
|
|
end
|
|
|
|
result
|
|
rescue ChildProcessTimeoutError
|
|
raise ChildProcessRecvError, "Failed #{__method__}: Did not match #{delim.inspect}, process was alive?=#{alive?.inspect}, remaining buffer: #{self.buffer.string[self.buffer.pos..].inspect}"
|
|
end
|
|
|
|
# @return [String] Recv until additional reads would cause a block, or eof is reached, or a maximum timeout is reached
|
|
def recv_available(timeout: @default_timeout)
|
|
result = ''
|
|
finished_reading = false
|
|
|
|
with_countdown(timeout) do
|
|
until finished_reading do
|
|
data_chunk = recv(timeout: 0, wait_readable: false)
|
|
if !data_chunk
|
|
finished_reading = true
|
|
next
|
|
end
|
|
|
|
result += data_chunk
|
|
end
|
|
end
|
|
|
|
result
|
|
rescue EOFError, ChildProcessTimeoutError
|
|
result
|
|
end
|
|
|
|
# @param [String] data The string of bytes to put back onto the buffer; Future buffered reads will return these bytes first
|
|
def unrecv(data)
|
|
data.bytes.reverse.each { |b| buffer.ungetbyte(b) }
|
|
end
|
|
|
|
# @param [Integer] length Reads length bytes from the I/O stream
|
|
# @param [Integer] timeout The timeout in seconds
|
|
# @param [TrueClass] wait_readable True if blocking, false otherwise
|
|
def recv(length = 4096, timeout: @default_timeout, wait_readable: true)
|
|
buffer_result = buffer.read(length)
|
|
return buffer_result if buffer_result
|
|
|
|
retry_count = 0
|
|
|
|
# Eagerly read, and if we fail - await a response within the given timeout period
|
|
result = nil
|
|
begin
|
|
result = stdout_and_stderr.read_nonblock(length)
|
|
unless result.nil?
|
|
log("[read] #{result}")
|
|
@all_data.write(result)
|
|
end
|
|
rescue IO::WaitReadable
|
|
if wait_readable
|
|
IO.select([stdout_and_stderr], nil, nil, timeout)
|
|
retry_count += 1
|
|
retry if retry_count == 1
|
|
end
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
# @param [String] data Write the data to the tdin of the running process
|
|
def write(data)
|
|
log("[write] #{data}")
|
|
@all_data.write(data)
|
|
stdin.write(data)
|
|
stdin.flush
|
|
end
|
|
|
|
# @param [String] s Send line of data to the stdin of the running process
|
|
def sendline(s)
|
|
write("#{s}#{$INPUT_RECORD_SEPARATOR}")
|
|
end
|
|
|
|
# @return [TrueClass, FalseClass] True if the running process is alive, false otherwise
|
|
def alive?
|
|
wait_thread.alive?
|
|
end
|
|
|
|
# Interact with the current process, forwarding the current stdin to the console's stdin,
|
|
# and writing the console's output to stdout. Doesn't support using PTY/raw mode.
|
|
def interact
|
|
$stderr.puts
|
|
$stderr.puts '[*] Opened interactive mode - enter "!next" to continue, or "!exit" to stop entirely. !pry for an interactive pry'
|
|
$stderr.puts
|
|
|
|
without_debugging do
|
|
while alive?
|
|
ready = IO.select([stdout_and_stderr, $stdin], [], [], 10)
|
|
|
|
next unless ready
|
|
|
|
reads, = ready
|
|
|
|
reads.to_a.each do |read|
|
|
case read
|
|
when $stdin
|
|
input = $stdin.gets
|
|
if input.chomp == '!continue'
|
|
return
|
|
elsif input.chomp == '!exit'
|
|
exit
|
|
elsif input.chomp == '!pry'
|
|
require 'pry-byebug'; binding.pry
|
|
end
|
|
|
|
write(input)
|
|
when stdout_and_stderr
|
|
available_bytes = recv
|
|
$stdout.write(available_bytes)
|
|
$stdout.flush
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def close
|
|
begin
|
|
Process.kill('KILL', wait_thread.pid) if wait_thread.pid
|
|
rescue StandardError => e
|
|
warn "error #{e} for #{@cmd}, pid #{wait_thread.pid}"
|
|
end
|
|
stdin.close if stdin
|
|
stdout_and_stderr.close if stdout_and_stderr
|
|
end
|
|
|
|
# @return [IO] the stdin for the child process which can be written to
|
|
attr_reader :stdin
|
|
# @return [IO] the stdout and stderr for the child process which can be read from
|
|
attr_reader :stdout_and_stderr
|
|
# @return [Process::Waiter] the waiter thread for the current process
|
|
attr_reader :wait_thread
|
|
|
|
# @return [String] The cmd that was used to execute the current process
|
|
attr_reader :cmd
|
|
|
|
private
|
|
|
|
# @return [StringIO] the buffer for any data which was read from stdout/stderr which was read, but not consumed
|
|
attr_reader :buffer
|
|
# @return [IO] the stdin of the running process
|
|
attr_writer :stdin
|
|
# @return [IO] the stdout and stderr of the running process
|
|
attr_writer :stdout_and_stderr
|
|
# @return [Process::Waiter] The process wait thread which tracks if the process is alive, its pid, return value, etc.
|
|
attr_writer :wait_thread
|
|
|
|
# @param [String] s Log to stderr
|
|
def log(s)
|
|
return unless @debug
|
|
|
|
$stderr.puts s
|
|
end
|
|
|
|
def without_debugging
|
|
previous_debug_value = @debug
|
|
@debug = false
|
|
yield
|
|
ensure
|
|
@debug = previous_debug_value
|
|
end
|
|
|
|
# Yields a timer object that can be used to request the remaining time available
|
|
def with_countdown(timeout)
|
|
countdown = Acceptance::Countdown.new(timeout)
|
|
# It is the caller's responsibility to honor the required countdown limits,
|
|
# but let's wrap the full operation in an explicit for worse case scenario,
|
|
# which may leave object state in a non-determinant state depending on the call
|
|
::Timeout.timeout(timeout * 1.5) do
|
|
yield countdown
|
|
end
|
|
if countdown.elapsed?
|
|
raise ChildProcessTimeoutError
|
|
end
|
|
rescue ::Timeout::Error
|
|
raise ChildProcessTimeoutError
|
|
end
|
|
end
|
|
|
|
# Internally generates a temporary file with Dir::Tmpname instead of a ::Tempfile instance, otherwise windows won't allow the file to be executed
|
|
# at the same time as the current Ruby process having an open handle to the temporary file
|
|
class TempChildProcessFile
|
|
def initialize(basename, extension)
|
|
@file_path = Dir::Tmpname.create([basename, extension]) do |_path, _n, _opts, _origdir|
|
|
# noop
|
|
end
|
|
|
|
ObjectSpace.define_finalizer(self, self.class.finalizer_proc_for(@file_path))
|
|
end
|
|
|
|
def path
|
|
@file_path
|
|
end
|
|
|
|
def to_s
|
|
path
|
|
end
|
|
|
|
def inspect
|
|
"#<#{self.class} #{self.path}>"
|
|
end
|
|
|
|
def self.finalizer_proc_for(path)
|
|
proc { File.delete(path) if File.exist?(path) }
|
|
end
|
|
end
|
|
|
|
###
|
|
# Stores the data for a payload, including the options used to generate the payload,
|
|
###
|
|
class Payload
|
|
attr_reader :name, :execute_cmd, :generate_options, :datastore
|
|
|
|
def initialize(options)
|
|
@name = options.fetch(:name)
|
|
@execute_cmd = options.fetch(:execute_cmd)
|
|
@generate_options = options.fetch(:generate_options)
|
|
@datastore = options.fetch(:datastore)
|
|
@executable = options.fetch(:executable, false)
|
|
|
|
basename = "#{File.basename(__FILE__)}_#{name}".gsub(/[^a-zA-Z]/, '-')
|
|
extension = options.fetch(:extension, '')
|
|
|
|
@file_path = TempChildProcessFile.new(basename, extension)
|
|
end
|
|
|
|
# @return [TrueClass, FalseClass] True if the payload needs marked as executable before being executed
|
|
def executable?
|
|
@executable
|
|
end
|
|
|
|
# @return [String] The path to the payload on disk
|
|
def path
|
|
@file_path.path
|
|
end
|
|
|
|
# @return [Integer] The size of the payload on disk. May be 0 when the payload doesn't exist,
|
|
# or a smaller size than expected if the payload is not fully generated by msfconsole yet.
|
|
def size
|
|
File.size(path)
|
|
rescue StandardError => _e
|
|
0
|
|
end
|
|
|
|
def [](k)
|
|
options[k]
|
|
end
|
|
|
|
# @return [Array<String>] The command which can be used to execute this payload. For instance ["python3", "/tmp/path.py"]
|
|
def execute_command
|
|
@execute_cmd.map do |val|
|
|
val.gsub('${payload_path}', path)
|
|
end
|
|
end
|
|
|
|
# @param [Hash] default_global_datastore
|
|
# @return [String] The setg commands for setting the global datastore
|
|
def setg_commands(default_global_datastore: {})
|
|
commands = []
|
|
# Ensure the global framework datastore is always clear
|
|
commands << "irb -e '(self.respond_to?(:framework) ? framework : self).datastore.user_defined.clear'"
|
|
# Call setg
|
|
global_datastore = default_global_datastore.merge(@datastore[:global])
|
|
global_datastore.each do |key, value|
|
|
commands << "setg #{key} #{value}"
|
|
end
|
|
commands.join("\n")
|
|
end
|
|
|
|
# @param [Hash] default_module_datastore
|
|
# @return [String] The command which can be used on msfconsole to generate the payload
|
|
def generate_command(default_module_datastore: {})
|
|
generate_options = @generate_options.map do |key, value|
|
|
"#{key} #{value}"
|
|
end
|
|
"generate -o #{path} #{generate_options.join(' ')} #{datastore_options(default_module_datastore: default_module_datastore)}"
|
|
end
|
|
|
|
# @param [Hash] default_module_datastore
|
|
# @return [String] The command which can be used on msfconsole to create the listener
|
|
def handler_command(default_module_datastore: {})
|
|
"to_handler #{datastore_options(default_module_datastore: default_module_datastore)}"
|
|
end
|
|
|
|
# @param [Hash] default_module_datastore
|
|
# @return [String] The datastore options string
|
|
def datastore_options(default_module_datastore: {})
|
|
module_datastore = default_module_datastore.merge(@datastore[:module])
|
|
module_options = module_datastore.map do |key, value|
|
|
"#{key}=#{value}"
|
|
end
|
|
|
|
module_options.join(' ')
|
|
end
|
|
|
|
# @param [Hash] default_global_datastore
|
|
# @param [Hash] default_module_datastore
|
|
# @return [String] A human readable representation of the payload configuration object
|
|
def as_readable_text(default_global_datastore: {}, default_module_datastore: {})
|
|
<<~EOF
|
|
## Payload
|
|
use #{name}
|
|
|
|
## Set global datastore
|
|
#{setg_commands(default_global_datastore: default_global_datastore)}
|
|
|
|
## Generate command
|
|
#{generate_command(default_module_datastore: default_module_datastore)}
|
|
|
|
## Create listener
|
|
#{handler_command(default_module_datastore: default_module_datastore)}
|
|
|
|
## Execute command
|
|
#{Shellwords.join(execute_command)}
|
|
EOF
|
|
end
|
|
end
|
|
|
|
class PayloadProcess
|
|
# @return [Process::Waiter] the waiter thread for the current process
|
|
attr_reader :wait_thread
|
|
|
|
# @return [String] the executed command
|
|
attr_reader :cmd
|
|
|
|
# @return [String] the payload path on disk
|
|
attr_reader :payload_path
|
|
|
|
# @param [Array<String>] cmd The command which can be used to execute this payload. For instance ["python3", "/tmp/path.py"]
|
|
# @param [path] payload_path The payload path on disk
|
|
# @param [Hash] opts the opts to pass to the Process#spawn call
|
|
def initialize(cmd, payload_path, opts = {})
|
|
super()
|
|
|
|
@payload_path = payload_path
|
|
@debug = false
|
|
@env = {}
|
|
@cmd = cmd
|
|
@options = opts
|
|
end
|
|
|
|
# @return [Process::Waiter] the waiter thread for the payload process
|
|
def run
|
|
pid = Process.spawn(
|
|
@env,
|
|
*@cmd,
|
|
**@options
|
|
)
|
|
@wait_thread = Process.detach(pid)
|
|
@wait_thread
|
|
end
|
|
|
|
def alive?
|
|
@wait_thread.alive?
|
|
end
|
|
|
|
def close
|
|
begin
|
|
Process.kill('KILL', wait_thread.pid) if wait_thread.pid
|
|
rescue StandardError => e
|
|
warn "error #{e} for #{@cmd}, pid #{wait_thread.pid}"
|
|
end
|
|
[:in, :out, :err].each do |name|
|
|
@options[name].close if @options[name]
|
|
end
|
|
@wait_thread.join
|
|
end
|
|
end
|
|
|
|
class ConsoleDriver
|
|
def initialize
|
|
@console = nil
|
|
@payload_processes = []
|
|
ObjectSpace.define_finalizer(self, self.class.finalizer_proc_for(self))
|
|
end
|
|
|
|
# @param [Acceptance::Payload] payload
|
|
# @param [Hash] opts
|
|
def run_payload(payload, opts)
|
|
if payload.executable? && !File.executable?(payload.path)
|
|
FileUtils.chmod('+x', payload.path)
|
|
end
|
|
|
|
payload_process = PayloadProcess.new(payload.execute_command, payload.path, opts)
|
|
payload_process.run
|
|
@payload_processes << payload_process
|
|
payload_process
|
|
end
|
|
|
|
# @return [Acceptance::Console]
|
|
def open_console
|
|
@console = Console.new
|
|
@console.run
|
|
@console.recvuntil(Console.prompt, timeout: 120)
|
|
|
|
@console
|
|
end
|
|
|
|
def close_payloads
|
|
close_processes(@payload_processes)
|
|
end
|
|
|
|
def close
|
|
close_processes(@payload_processes + [@console])
|
|
end
|
|
|
|
def self.finalizer_proc_for(instance)
|
|
proc { instance.close }
|
|
end
|
|
|
|
private
|
|
|
|
def close_processes(processes)
|
|
while (process = processes.pop)
|
|
begin
|
|
process.close
|
|
rescue StandardError => e
|
|
$stderr.puts e.to_s
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
class Console < ChildProcess
|
|
def initialize
|
|
super
|
|
|
|
framework_root = Dir.pwd
|
|
@debug = true
|
|
@env = {
|
|
'BUNDLE_GEMFILE' => File.join(framework_root, 'Gemfile'),
|
|
'PATH' => "#{framework_root.shellescape}:#{ENV['PATH']}"
|
|
}
|
|
@cmd = [
|
|
'bundle', 'exec', 'ruby', 'msfconsole',
|
|
'--no-readline',
|
|
# '--logger', 'Stdout',
|
|
'--quiet'
|
|
]
|
|
@options = {
|
|
chdir: framework_root
|
|
}
|
|
end
|
|
|
|
def self.prompt
|
|
/msf6.*>\s+/
|
|
end
|
|
|
|
def reset
|
|
sendline('sessions -K')
|
|
recvuntil(Console.prompt)
|
|
|
|
sendline('jobs -K')
|
|
recvuntil(Console.prompt)
|
|
ensure
|
|
@all_data.reopen('')
|
|
end
|
|
end
|
|
end
|