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

New process launch API #19108

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
43 changes: 43 additions & 0 deletions lib/msf/base/sessions/command_shell.rb
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,49 @@ def process_autoruns(datastore)
end
end

# Perform command line escaping wherein most chars are able to be escaped by quoting them,
# but others don't have a valid way of existing inside quotes, so we need to "glue" together
# a series of sections of the original command line; some sections inside quotes, and some outside
# @param arg [String] The command line arg to escape
# @param quote_requiring [Array<String>] The chars that can successfully be escaped inside quotes
# @param unquotable_char [String] The character that can't exist inside quotes
# @param escaped_unquotable_char [String] The escaped form of unquotable_char
# @param quote_char [String] The char used for quoting
def self._glue_cmdline_escape(arg, quote_requiring, unquotable_char, escaped_unquotable_char, quote_char)
current_token = ""
result = ""
in_quotes = false

arg.each_char do |char|
if char == unquotable_char
if in_quotes
# This token has been in an inside-quote context, so let's properly wrap that before continuing
current_token = "#{quote_char}#{current_token}#{quote_char}"
end
result += current_token
result += escaped_unquotable_char # Escape the offending percent

# Start a new token - we'll assume we're remaining outside quotes
current_token = ''
in_quotes = false
next
elsif quote_requiring.include?(char)
# Oh, it turns out we should have been inside quotes for this token.
# Let's note that, so that when we actually append the token
in_quotes = true
end
current_token += char
end

if in_quotes
# This token has been in an inside-quote context, so let's properly wrap that before continuing
current_token = "#{quote_char}#{current_token}#{quote_char}"
end
result += current_token

result
end

attr_accessor :arch
attr_accessor :platform
attr_accessor :max_threads
Expand Down
32 changes: 32 additions & 0 deletions lib/msf/base/sessions/command_shell_unix.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,41 @@ def initialize(*args)
self.platform = "unix"
super
end

def shell_command_token(cmd,timeout = 10)
shell_command_token_unix(cmd,timeout)
end

# Convert the executable and argument array to a command that can be run in this command shell
# @param executable [String] The process to launch
# @param args [Array<String>] The arguments to the process
def to_cmd(executable, args)
self.class.to_cmd(executable, args)
end

# Convert the executable and argument array to a command that can be run in this command shell
# @param executable [String] The process to launch, or nil if only processing arguments
# @param args [Array<String>] The arguments to the process
def self.to_cmd(executable, args)
quote_requiring = ['\\', '`', '(', ')', '<', '>', '&', '|', ' ', '@', '"', '$', ';']

if executable.nil?
cmd_and_args = args
else
cmd_and_args = [executable] + args
end

escaped = cmd_and_args.map do |arg|
result = CommandShell._glue_cmdline_escape(arg, quote_requiring, "'", "\\'", "'")
if result == ''
result = "''"
end

result
end

escaped.join(' ')
end
end

end
101 changes: 100 additions & 1 deletion lib/msf/base/sessions/command_shell_windows.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,113 @@

module Msf::Sessions

class CommandShellWindows < CommandShell
def initialize(*args)
self.platform = "windows"
super
end

def shell_command_token(cmd,timeout = 10)
shell_command_token_win32(cmd,timeout)
end

# Convert the executable and argument array to a command that can be run in this command shell
# @param executable [String] The process to launch
# @param args [Array<String>] The arguments to the process
def to_cmd(executable, args)
self.class.to_cmd(executable, args)
end

# Escape a process for the command line
# @param executable [String] The process to launch
def self.escape_cmd(executable)
space_chars = [' ', '\t', '\v']
needs_quoting = space_chars.any? do |char|
executable.include?(char)
end

if needs_quoting
executable = "\"#{executable}\""
end

executable
end

# Convert the executable and argument array to a commandline that can be passed to CreateProcessAsUserW.
# @param args [Array<String>] The arguments to the process
# @remark The difference between this and `to_cmd` is that the output of `to_cmd` is expected to be passed
# to cmd.exe, whereas this is expected to be passed directly to the Win32 API, anticipating that it
# will in turn be interpreted by CommandLineToArgvW.
def self.argv_to_commandline(args)
space_chars = [' ', '\t', '\v']
escaped_args = args.map do |arg|
needs_quoting = space_chars.any? do |char|
arg.include?(char)
end

# Fix the weird behaviour when backslashes are treated differently when immediately prior to a double-quote
# We need to send double the number of backslashes to make it work as expected
# See: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-commandlinetoargvw#remarks
arg = arg.gsub(/(\\*)"/, '\\1\\1"')

# Quotes need to be escaped
arg = arg.gsub('"', '\\"')

if needs_quoting
# At the end of the argument, we're about to add another quote - so any backslashes need to be doubled here too
arg = arg.gsub(/(\\*)$/, '\\1\\1')
arg = "\"#{arg}\""
end

# Empty string needs to be coerced to have a value
arg = '""' if arg == ''

arg
end

escaped_args.join(' ')
end

# Convert the executable and argument array to a command that can be run in this command shell
# @param executable [String] The process to launch
# @param args [Array<String>] The arguments to the process
def self.to_cmd(executable, args)
# The space, caret and quote chars need to be inside double-quoted strings.
# The percent character needs to be escaped using a caret char, while being outside a double-quoted string.
#
# Situations where these two situations combine are going to be the trickiest cases: something that has quote-requiring
# characters (e.g. spaces), but which also needs to avoid expanding an environment variable. In this case,
# the string needs to end up being partially quoted; with parts of the string in quotes, but others (i.e. bits with percents) not.
# For example:
# 'env var is %temp%, yes, %TEMP%' needs to end up as '"env var is "^%temp^%", yes, "^%TEMP^%'
#
# There is flexibility in how you might implement this, but I think this one looks the most "human" to me,
# which would make it less signaturable.
#
# To do this, we'll consider each argument character-by-character. Each time we encounter a percent sign, we break out of any quotes
# (if we've been inside them in the current "token"), and then start a new "token".

cmd_and_args = [executable] + args
quote_requiring = ['"', '^', ' ', "\t", "\v", '&', '<', '>', '|']

escaped_cmd_and_args = cmd_and_args.map do |arg|
# Double-up all quote chars
arg = arg.gsub('"', '""')

result = CommandShell._glue_cmdline_escape(arg, quote_requiring, '%', '^%', '"')

# Fix the weird behaviour when backslashes are treated differently when immediately prior to a double-quote
# We need to send double the number of backslashes to make it work as expected
# See: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-commandlinetoargvw#remarks
result.gsub!(/(\\*)"/, '\\1\\1"')

# Empty string needs to be coerced to have a value
result = '""' if result == ''

result
end

escaped_cmd_and_args.join(' ')
end
end

end
77 changes: 77 additions & 0 deletions lib/msf/base/sessions/powershell.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,83 @@ def shell_command(cmd, timeout = 1800)

include Mixin

# Convert the executable and argument array to a command that can be run in this command shell
# @param executable [String] The process to launch
# @param args [Array<String>] The arguments to the process
def to_cmd(executable, args)
self.class.to_cmd(executable, args)
end

# Convert the executable and argument array to a command that can be run in this command shell
# @param executable [String] The process to launch
# @param args [Array<String>] The arguments to the process
def self.to_cmd(executable, args)
# The principle here is that we want to launch a process such that it receives *exactly* what is in `args`.
# This means we need to:
# - Escape all special characters
# - Not escape environment variables
# - Side-step any PowerShell magic
# If someone specifically wants to use the PowerShell magic, they can use other APIs

needs_wrapping_chars = ['$', '`', '(', ')', '@', '>', '<', '{','}', '&', ',', ' ']

result = ""
cmd_and_args = [executable] + args
cmd_and_args.each_with_index do |arg, index|
needs_single_quoting = false
if arg.include?("'")
arg = arg.gsub("'", "''")
needs_single_quoting = true
end

if arg.include?('"')
# PowerShell acts weird around quotes and backslashes
# First we need to escape backslashes immediately prior to a double-quote, because
# they're treated differently than backslashes anywhere else
arg = arg.gsub(/(\\+)"/, '\\1\\1"')

# Then we can safely prepend a backslash to escape our double-quote
arg = arg.gsub('"', '\\"')
needs_single_quoting = true
end

needs_wrapping_chars.each do |char|
if arg.include?(char)
needs_single_quoting = true
end
end

# PowerShell magic - https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_special_characters?view=powershell-7.4#stop-parsing-token---
if arg == '--%'
needs_single_quoting = true
end

if needs_single_quoting
arg = "'#{arg}'"
end

if arg == ''
# Pass in empty strings
arg = '\'""\''
end

if index == 0
if needs_single_quoting
# If the executable name (i.e. index 0) has beeen wrapped, then we'll have converted it to a string.
# We then need to use the call operator ('&') to call it.
# https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_operators?view=powershell-7.3#call-operator-
result = "& #{arg}"
else
result = arg
end
else
result = "#{result} #{arg}"
end
end

result
end

#
# Execute any specified auto-run scripts for this session
#
Expand Down
54 changes: 54 additions & 0 deletions lib/msf/core/post/common.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,60 @@ def peer
"#{rhost}:#{rport}"
end

# Create a new process, receiving the program's output
# @param executable [String] The path to the executable; either absolute or relative to the session's current directory
# @param args [Array<String>] The arguments to the executable
# @time_out [Integer] Number of seconds before the call will time out
# @param opts [Hash] Optional settings to parameterise the process launch
# @option Hidden [Boolean] Is the process launched without creating a visible window
# @option Channelized [Boolean] The process is launched with pipes connected to a channel, e.g. for sending input/receiving output
# @option Suspended [Boolean] Start the process suspended
# @option UseThreadToken [Boolean] Use the thread token (as opposed to the process token) to launch the process
# @option Desktop [Boolean] Run on meterpreter's current desktopt
# @option Session [Integer] Execute process in a given session as the session user
# @option Subshell [Boolean] Execute process in a subshell
# @option Pty [Boolean] Execute process in a pty (if available)
# @option ParentId [Integer] Spoof the parent PID (if possible)
# @option InMemory [Boolean,String] Execute from memory (`path` is treated as a local file to upload, and the actual path passed
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems strange InMemory could be both a String and a Boolean, this could lead to some confusion. Does it need to be a String?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Definitely agree that it's strange. For clarity, I didn't change the expected types - I'm just commenting the existing (admittedly confusing) behaviour.

# to meterpreter is this parameter's value, if provided as a String)
def create_process(executable, args: [], time_out: 15, opts: {})
case session.type
when 'meterpreter'
session.response_timeout = time_out
opts = {
'Hidden' => true,
'Channelized' => true,
# Well-behaving meterpreters will ignore the Subshell flag when using arg arrays.
# This is still provided for supporting old meterpreters.
'Subshell' => true
}.merge(opts)

if session.platform == 'windows'
opts[:legacy_args] = Msf::Sessions::CommandShellWindows.argv_to_commandline(args)
opts[:legacy_path] = Msf::Sessions::CommandShellWindows.escape_cmd(executable)
else
opts[:legacy_args] = Msf::Sessions::CommandShellUnix.to_cmd(nil, args)
opts[:legacy_path] = Msf::Sessions::CommandShellUnix.to_cmd(executable, [])
end

if opts['Channelized']
o = session.sys.process.capture_output(executable, args, opts, time_out)
else
session.sys.process.execute(executable, args, opts)
end
when 'powershell'
cmd = session.to_cmd(executable, args)
o = session.shell_command(cmd, time_out)
o.chomp! if o
when 'shell'
cmd = session.to_cmd(executable, args)
o = session.shell_command_token(cmd, time_out)
o.chomp! if o
end
return "" if o.nil?
return o
end

#
# Executes +cmd+ on the remote system
#
Expand Down
8 changes: 8 additions & 0 deletions lib/msf/core/session/provider/single_command_shell.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ def shell_command_token(cmd, timeout=10)
output
end

def to_cmd(cmd, args)
if platform == 'windows'
result = Msf::Sessions::CommandShellWindows.to_cmd(cmd, args)
else
result = Msf::Session::CommandShellUnix.to_cmd(cmd, args)
end
end

# We don't know initially whether the shell we have is one that
# echos input back to the output stream. If it is, we need to
# take this into account when using tokens to extract the data corresponding
Expand Down