Files
metasploit-gs/spec/support/acceptance/child_process.rb
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

558 lines
16 KiB
Ruby
Raw Normal View History

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
2024-03-05 16:34:18 +00:00
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