Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/spec/support/acceptance/child_process.rb
Views: 11780
require 'stringio'1require 'open3'2require 'English'3require 'tempfile'4require 'fileutils'5require 'timeout'6require 'shellwords'78module Acceptance9class ChildProcessError < ::StandardError10end1112class ChildProcessTimeoutError < ::StandardError13end1415class ChildProcessRecvError < ::StandardError16end1718# A wrapper around ::Open3.popen2e - allows creating a process, writing to stdin, and reading the process output19# All of the data is stored for future retrieval/appending to test output20class ChildProcess21def initialize22super2324@default_timeout = ENV['CI'] ? 480 : 4025@debug = false26@env ||= {}27@cmd ||= []28@options ||= {}2930@stdin = nil31@stdout_and_stderr = nil32@wait_thread = nil3334@buffer = StringIO.new35@all_data = StringIO.new36end3738# @return [String] All data that was read from stdout/stderr of the running process39def all_data40@all_data.string41end4243# Runs the process44# @return [nil]45def run46self.stdin, self.stdout_and_stderr, self.wait_thread = ::Open3.popen2e(47@env,48*@cmd,49**@options50)5152stdin.sync = true53stdout_and_stderr.sync = true5455nil56rescue StandardError => e57warn "popen failure #{e}"58raise59end6061# @return [String] A line of input62def recvline(timeout: @default_timeout)63recvuntil($INPUT_RECORD_SEPARATOR, timeout: timeout)64end6566alias readline recvline6768# @param [String|Regexp] delim69def recvuntil(delim, timeout: @default_timeout, drop_delim: false)70buffer = ''71result = nil7273with_countdown(timeout) do |countdown|74while alive? && !countdown.elapsed?75data_chunk = recv(timeout: [countdown.remaining_time, 1].min)76if !data_chunk77next78end7980buffer += data_chunk81has_delimiter = delim.is_a?(Regexp) ? buffer.match?(delim) : buffer.include?(delim)82next unless has_delimiter8384result, matched_delim, remaining = buffer.partition(delim)85unless drop_delim86result += matched_delim87end88unrecv(remaining)89# Reset the temporary buffer to avoid the `ensure` mechanism unrecv'ing the buffer unintentionally90buffer = ''9192return result93end94ensure95unrecv(buffer)96end9798result99rescue ChildProcessTimeoutError100raise ChildProcessRecvError, "Failed #{__method__}: Did not match #{delim.inspect}, process was alive?=#{alive?.inspect}, remaining buffer: #{self.buffer.string[self.buffer.pos..].inspect}"101end102103# @return [String] Recv until additional reads would cause a block, or eof is reached, or a maximum timeout is reached104def recv_available(timeout: @default_timeout)105result = ''106finished_reading = false107108with_countdown(timeout) do109until finished_reading do110data_chunk = recv(timeout: 0, wait_readable: false)111if !data_chunk112finished_reading = true113next114end115116result += data_chunk117end118end119120result121rescue EOFError, ChildProcessTimeoutError122result123end124125# @param [String] data The string of bytes to put back onto the buffer; Future buffered reads will return these bytes first126def unrecv(data)127data.bytes.reverse.each { |b| buffer.ungetbyte(b) }128end129130# @param [Integer] length Reads length bytes from the I/O stream131# @param [Integer] timeout The timeout in seconds132# @param [TrueClass] wait_readable True if blocking, false otherwise133def recv(length = 4096, timeout: @default_timeout, wait_readable: true)134buffer_result = buffer.read(length)135return buffer_result if buffer_result136137retry_count = 0138139# Eagerly read, and if we fail - await a response within the given timeout period140result = nil141begin142result = stdout_and_stderr.read_nonblock(length)143unless result.nil?144log("[read] #{result}")145@all_data.write(result)146end147rescue IO::WaitReadable148if wait_readable149IO.select([stdout_and_stderr], nil, nil, timeout)150retry_count += 1151retry if retry_count == 1152end153end154155result156end157158# @param [String] data Write the data to the tdin of the running process159def write(data)160log("[write] #{data}")161@all_data.write(data)162stdin.write(data)163stdin.flush164end165166# @param [String] s Send line of data to the stdin of the running process167def sendline(s)168write("#{s}#{$INPUT_RECORD_SEPARATOR}")169end170171# @return [TrueClass, FalseClass] True if the running process is alive, false otherwise172def alive?173wait_thread.alive?174end175176# Interact with the current process, forwarding the current stdin to the console's stdin,177# and writing the console's output to stdout. Doesn't support using PTY/raw mode.178def interact179$stderr.puts180$stderr.puts '[*] Opened interactive mode - enter "!next" to continue, or "!exit" to stop entirely. !pry for an interactive pry'181$stderr.puts182183without_debugging do184while alive?185ready = IO.select([stdout_and_stderr, $stdin], [], [], 10)186187next unless ready188189reads, = ready190191reads.to_a.each do |read|192case read193when $stdin194input = $stdin.gets195if input.chomp == '!continue'196return197elsif input.chomp == '!exit'198exit199elsif input.chomp == '!pry'200require 'pry-byebug'; binding.pry201end202203write(input)204when stdout_and_stderr205available_bytes = recv206$stdout.write(available_bytes)207$stdout.flush208end209end210end211end212end213214def close215begin216Process.kill('KILL', wait_thread.pid) if wait_thread.pid217rescue StandardError => e218warn "error #{e} for #{@cmd}, pid #{wait_thread.pid}"219end220stdin.close if stdin221stdout_and_stderr.close if stdout_and_stderr222end223224# @return [IO] the stdin for the child process which can be written to225attr_reader :stdin226# @return [IO] the stdout and stderr for the child process which can be read from227attr_reader :stdout_and_stderr228# @return [Process::Waiter] the waiter thread for the current process229attr_reader :wait_thread230231# @return [String] The cmd that was used to execute the current process232attr_reader :cmd233234private235236# @return [StringIO] the buffer for any data which was read from stdout/stderr which was read, but not consumed237attr_reader :buffer238# @return [IO] the stdin of the running process239attr_writer :stdin240# @return [IO] the stdout and stderr of the running process241attr_writer :stdout_and_stderr242# @return [Process::Waiter] The process wait thread which tracks if the process is alive, its pid, return value, etc.243attr_writer :wait_thread244245# @param [String] s Log to stderr246def log(s)247return unless @debug248249$stderr.puts s250end251252def without_debugging253previous_debug_value = @debug254@debug = false255yield256ensure257@debug = previous_debug_value258end259260# Yields a timer object that can be used to request the remaining time available261def with_countdown(timeout)262countdown = Acceptance::Countdown.new(timeout)263# It is the caller's responsibility to honor the required countdown limits,264# but let's wrap the full operation in an explicit for worse case scenario,265# which may leave object state in a non-determinant state depending on the call266::Timeout.timeout(timeout * 1.5) do267yield countdown268end269if countdown.elapsed?270raise ChildProcessTimeoutError271end272rescue ::Timeout::Error273raise ChildProcessTimeoutError274end275end276277# Internally generates a temporary file with Dir::Tmpname instead of a ::Tempfile instance, otherwise windows won't allow the file to be executed278# at the same time as the current Ruby process having an open handle to the temporary file279class TempChildProcessFile280def initialize(basename, extension)281@file_path = Dir::Tmpname.create([basename, extension]) do |_path, _n, _opts, _origdir|282# noop283end284285ObjectSpace.define_finalizer(self, self.class.finalizer_proc_for(@file_path))286end287288def path289@file_path290end291292def to_s293path294end295296def inspect297"#<#{self.class} #{self.path}>"298end299300def self.finalizer_proc_for(path)301proc { File.delete(path) if File.exist?(path) }302end303end304305###306# Stores the data for a payload, including the options used to generate the payload,307###308class Payload309attr_reader :name, :execute_cmd, :generate_options, :datastore310311def initialize(options)312@name = options.fetch(:name)313@execute_cmd = options.fetch(:execute_cmd)314@generate_options = options.fetch(:generate_options)315@datastore = options.fetch(:datastore)316@executable = options.fetch(:executable, false)317318basename = "#{File.basename(__FILE__)}_#{name}".gsub(/[^a-zA-Z]/, '-')319extension = options.fetch(:extension, '')320321@file_path = TempChildProcessFile.new(basename, extension)322end323324# @return [TrueClass, FalseClass] True if the payload needs marked as executable before being executed325def executable?326@executable327end328329# @return [String] The path to the payload on disk330def path331@file_path.path332end333334# @return [Integer] The size of the payload on disk. May be 0 when the payload doesn't exist,335# or a smaller size than expected if the payload is not fully generated by msfconsole yet.336def size337File.size(path)338rescue StandardError => _e3390340end341342def [](k)343options[k]344end345346# @return [Array<String>] The command which can be used to execute this payload. For instance ["python3", "/tmp/path.py"]347def execute_command348@execute_cmd.map do |val|349val.gsub('${payload_path}', path)350end351end352353# @param [Hash] default_global_datastore354# @return [String] The setg commands for setting the global datastore355def setg_commands(default_global_datastore: {})356commands = []357# Ensure the global framework datastore is always clear358commands << "irb -e '(self.respond_to?(:framework) ? framework : self).datastore.user_defined.clear'"359# Call setg360global_datastore = default_global_datastore.merge(@datastore[:global])361global_datastore.each do |key, value|362commands << "setg #{key} #{value}"363end364commands.join("\n")365end366367# @param [Hash] default_module_datastore368# @return [String] The command which can be used on msfconsole to generate the payload369def generate_command(default_module_datastore: {})370generate_options = @generate_options.map do |key, value|371"#{key} #{value}"372end373"generate -o #{path} #{generate_options.join(' ')} #{datastore_options(default_module_datastore: default_module_datastore)}"374end375376# @param [Hash] default_module_datastore377# @return [String] The command which can be used on msfconsole to create the listener378def handler_command(default_module_datastore: {})379"to_handler #{datastore_options(default_module_datastore: default_module_datastore)}"380end381382# @param [Hash] default_module_datastore383# @return [String] The datastore options string384def datastore_options(default_module_datastore: {})385module_datastore = default_module_datastore.merge(@datastore[:module])386module_options = module_datastore.map do |key, value|387"#{key}=#{value}"388end389390module_options.join(' ')391end392393# @param [Hash] default_global_datastore394# @param [Hash] default_module_datastore395# @return [String] A human readable representation of the payload configuration object396def as_readable_text(default_global_datastore: {}, default_module_datastore: {})397<<~EOF398## Payload399use #{name}400401## Set global datastore402#{setg_commands(default_global_datastore: default_global_datastore)}403404## Generate command405#{generate_command(default_module_datastore: default_module_datastore)}406407## Create listener408#{handler_command(default_module_datastore: default_module_datastore)}409410## Execute command411#{Shellwords.join(execute_command)}412EOF413end414end415416class PayloadProcess417# @return [Process::Waiter] the waiter thread for the current process418attr_reader :wait_thread419420# @return [String] the executed command421attr_reader :cmd422423# @return [String] the payload path on disk424attr_reader :payload_path425426# @param [Array<String>] cmd The command which can be used to execute this payload. For instance ["python3", "/tmp/path.py"]427# @param [path] payload_path The payload path on disk428# @param [Hash] opts the opts to pass to the Process#spawn call429def initialize(cmd, payload_path, opts = {})430super()431432@payload_path = payload_path433@debug = false434@env = {}435@cmd = cmd436@options = opts437end438439# @return [Process::Waiter] the waiter thread for the payload process440def run441pid = Process.spawn(442@env,443*@cmd,444**@options445)446@wait_thread = Process.detach(pid)447@wait_thread448end449450def alive?451@wait_thread.alive?452end453454def close455begin456Process.kill('KILL', wait_thread.pid) if wait_thread.pid457rescue StandardError => e458warn "error #{e} for #{@cmd}, pid #{wait_thread.pid}"459end460[:in, :out, :err].each do |name|461@options[name].close if @options[name]462end463@wait_thread.join464end465end466467class ConsoleDriver468def initialize469@console = nil470@payload_processes = []471ObjectSpace.define_finalizer(self, self.class.finalizer_proc_for(self))472end473474# @param [Acceptance::Payload] payload475# @param [Hash] opts476def run_payload(payload, opts)477if payload.executable? && !File.executable?(payload.path)478FileUtils.chmod('+x', payload.path)479end480481payload_process = PayloadProcess.new(payload.execute_command, payload.path, opts)482payload_process.run483@payload_processes << payload_process484payload_process485end486487# @return [Acceptance::Console]488def open_console489@console = Console.new490@console.run491@console.recvuntil(Console.prompt, timeout: 120)492493@console494end495496def close_payloads497close_processes(@payload_processes)498end499500def close501close_processes(@payload_processes + [@console])502end503504def self.finalizer_proc_for(instance)505proc { instance.close }506end507508private509510def close_processes(processes)511while (process = processes.pop)512begin513process.close514rescue StandardError => e515$stderr.puts e.to_s516end517end518end519end520521class Console < ChildProcess522def initialize523super524525framework_root = Dir.pwd526@debug = true527@env = {528'BUNDLE_GEMFILE' => File.join(framework_root, 'Gemfile'),529'PATH' => "#{framework_root.shellescape}:#{ENV['PATH']}"530}531@cmd = [532'bundle', 'exec', 'ruby', 'msfconsole',533'--no-readline',534# '--logger', 'Stdout',535'--quiet'536]537@options = {538chdir: framework_root539}540end541542def self.prompt543/msf6.*>\s+/544end545546def reset547sendline('sessions -K')548recvuntil(Console.prompt)549550sendline('jobs -K')551recvuntil(Console.prompt)552ensure553@all_data.reopen('')554end555end556end557558559