CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
rapid7

CoCalc provides the best real-time collaborative environment for Jupyter Notebooks, LaTeX documents, and SageMath, scalable from individual users to large groups and classes!

GitHub Repository: rapid7/metasploit-framework
Path: blob/master/tools/exploit/virustotal.rb
Views: 1904
1
#!/usr/bin/env ruby
2
3
##
4
# This module requires Metasploit: https://metasploit.com/download
5
# Current source: https://github.com/rapid7/metasploit-framework
6
##
7
8
#
9
# This script will check multiple files against VirusTotal's public analysis service. You are
10
# limited to at most 4 requests (of any nature in any given 1 minute time frame), because
11
# VirusTotal says so. If you prefer your own API key, you may get one at virustotal.com
12
#
13
# VirusTotal Terms of Service:
14
# https://www.virustotal.com/en/about/terms-of-service/
15
#
16
# Public API documentations can be found here:
17
# https://www.virustotal.com/en/documentation/public-api/
18
# https://api.vtapi.net/en/doc/
19
#
20
# WARNING:
21
# When you upload or otherwise submit content, you give VirusTotal (and those we work with) a
22
# worldwide, royalty free, irrevocable and transferable licence to use, edit, host, store,
23
# reproduce, modify, create derivative works, communicate, publish, publicly perform, publicly
24
# display and distribute such content.
25
#
26
# Author:
27
# sinn3r <sinn3r[at]metasploit.com>
28
#
29
begin
30
msfbase = __FILE__
31
while File.symlink?(msfbase)
32
msfbase = File.expand_path(File.readlink(msfbase), File.dirname(msfbase))
33
end
34
35
$:.unshift(File.expand_path(File.join(File.dirname(msfbase), '..', '..', 'lib')))
36
require 'msfenv'
37
require 'rex'
38
require 'digest/sha2'
39
require 'optparse'
40
require 'json'
41
require 'timeout'
42
43
#
44
# Prints a status message
45
#
46
def print_status(msg='')
47
$stdout.puts "[*] #{msg}"
48
end
49
50
51
#
52
# Prints an error message
53
#
54
def print_error(msg='')
55
$stdout.puts "[-] #{msg}"
56
end
57
58
59
module VirusTotalUtility
60
61
class ToolConfig
62
63
def initialize
64
@config_file ||= Msf::Config.config_file
65
@group_name ||= 'VirusTotal'
66
end
67
68
#
69
# Saves the VirusTotal API key to Metasploit's config file
70
# @param key [String] API key
71
# @return [void]
72
#
73
def save_api_key(key)
74
_set_setting('api_key', key)
75
end
76
77
78
#
79
# Returns the VirusTotal API key from Metasploit's config file
80
# @return [String] the API key
81
#
82
def load_api_key
83
_get_setting('api_key') || ''
84
end
85
86
87
#
88
# Sets the privacy waiver to true after the tool is run for the very first time
89
# @return [void]
90
#
91
def save_privacy_waiver
92
_set_setting('waiver', true)
93
end
94
95
96
#
97
# Returns whether a waver is set or not
98
# @return [Boolean]
99
#
100
def has_privacy_waiver?
101
_get_setting('waiver') || false
102
end
103
104
105
private
106
107
108
#
109
# Sets a setting in Metasploit's config file
110
# @param key_name [String] The Key to set
111
# @param value [String] The value to set
112
# @return [void]
113
#
114
def _set_setting(key_name, value)
115
ini = Rex::Parser::Ini.new(@config_file)
116
ini.add_group(@group_name) if ini[@group_name].nil?
117
ini[@group_name][key_name] = value
118
ini.to_file(@config_file)
119
end
120
121
122
#
123
# Returns a setting from Metasploit's config file
124
# @param key_name [String] The setting to get
125
# @return [void]
126
#
127
def _get_setting(key_name)
128
ini = Rex::Parser::Ini.new(@config_file)
129
group = ini[@group_name]
130
return nil if group.nil?
131
return nil if group[key_name].nil?
132
133
group[key_name]
134
end
135
136
end
137
138
139
class VirusTotal < Msf::Auxiliary
140
141
include Msf::Exploit::Remote::HttpClient
142
143
def initialize(opts={})
144
@api_key = opts['api_key']
145
@sample_info = _load_sample(opts['sample'])
146
147
# It should resolve to 74.125.34.46, and the HOST header (HTTP) must be www.virustotal.com, or
148
# it will return a 404 instead.
149
rhost = Rex::Socket.resolv_to_dotted("www.virustotal.com") rescue '74.125.34.46'
150
151
# Need to configure HttpClient to enable SSL communication
152
super(
153
'DefaultOptions' =>
154
{
155
'SSL' => true,
156
'RHOST' => rhost,
157
'RPORT' => 443
158
}
159
)
160
end
161
162
163
#
164
# Submits a malware sample for VirusTotal to scan
165
# @param sample [String] Data to analyze
166
# @return [Hash] JSON response
167
#
168
def scan_sample
169
opts = {
170
'boundary' => 'THEREAREMANYLIKEITBUTTHISISMYDATA',
171
'api_key' => @api_key,
172
'filename' => @sample_info['filename'],
173
'data' => @sample_info['data']
174
}
175
176
_execute_request({
177
'uri' => '/vtapi/v2/file/scan',
178
'method' => 'POST',
179
'vhost' => 'www.virustotal.com',
180
'ctype' => "multipart/form-data; boundary=#{opts['boundary']}",
181
'data' => _create_upload_data(opts)
182
})
183
end
184
185
186
#
187
# Returns the report of a specific malware hash
188
# @return [Hash] JSON response
189
#
190
def retrieve_report
191
_execute_request({
192
'uri' => '/vtapi/v2/file/report',
193
'method' => 'POST',
194
'vhost' => 'www.virustotal.com',
195
'vars_post' => {
196
'apikey' => @api_key,
197
'resource' => @sample_info['sha256']
198
}
199
})
200
end
201
202
private
203
204
#
205
# Returns the JSON response of a HTTP request
206
# @param opts [Hash] HTTP options
207
# @return [Hash] JSON response
208
#
209
def _execute_request(opts)
210
res = send_request_cgi(opts)
211
212
return '' if res.nil?
213
case res.code
214
when 204
215
raise RuntimeError, "You have hit the request limit."
216
when 403
217
raise RuntimeError, "No privilege to execute this request probably due to an invalye API key"
218
end
219
220
json_body = ''
221
222
begin
223
json_body = JSON.parse(res.body)
224
rescue JSON::ParserError
225
json_body = ''
226
end
227
228
json_body
229
end
230
231
#
232
# Returns malware sample information
233
# @param sample [String] The sample path to load
234
# @return [Hash] Information about the sample (including the raw data, and SHA256 hash)
235
#
236
def _load_sample(sample)
237
info = {
238
'filename' => '',
239
'data' => ''
240
}
241
242
File.open(sample, 'rb') do |f|
243
info['data'] = f.read
244
end
245
246
info['filename'] = File.basename(sample)
247
info['sha256'] = Digest::SHA256.hexdigest(info['data'])
248
249
info
250
end
251
252
253
#
254
# Creates a form-data message
255
# @param opts [Hash] A hash that contains keys including boundary, api_key, filename, and data
256
# @return [String] The POST request data
257
#
258
def _create_upload_data(opts={})
259
boundary = opts['boundary']
260
api_key = opts['api_key']
261
filename = opts['filename']
262
data = opts['data']
263
264
# Can't use Rex::MIME::Message, or you WILL be increditably outraged, it messes with your data.
265
# See VT report for example: 4212686e701286ab734d8a67b7b7527f279c2dadc27bd744abebecab91b70c82
266
data = %Q|--#{boundary}
267
Content-Disposition: form-data; name="apikey"
268
269
#{api_key}
270
--#{boundary}
271
Content-Disposition: form-data; name="file"; filename="#{filename}"
272
Content-Type: application/octet-stream
273
274
#{data}
275
--#{boundary}--
276
|
277
278
data
279
end
280
281
end
282
283
class OptsConsole
284
#
285
# Return a hash describing the options.
286
#
287
def self.parse(args)
288
options = {}
289
290
opts = OptionParser.new do |opts|
291
opts.banner = "Usage: #{__FILE__} [options]"
292
293
opts.separator ""
294
opts.separator "Specific options:"
295
296
opts.on("-k", "-k <key>", "(Optional) Virusl API key to use") do |v|
297
options['api_key'] = v
298
end
299
300
opts.on("-d", "-d <seconds>", "(Optional) Number of seconds to wait for the report") do |v|
301
if v !~ /^\d+$/
302
print_error("Invalid input for -d. It must be a number.")
303
exit
304
end
305
306
options['delay'] = v.to_i
307
end
308
309
opts.on("-q", nil, "(Optional) Do a hash search without uploading the sample") do |v|
310
options['quick'] = true
311
end
312
313
opts.on("-f", "-f <filenames>", "Files to scan") do |v|
314
files = v.split.delete_if { |e| e.nil? }
315
bad_files = []
316
files.each do |f|
317
unless ::File.exist?(f)
318
bad_files << f
319
end
320
end
321
322
unless bad_files.empty?
323
print_error("Cannot find: #{bad_files * ' '}")
324
exit
325
end
326
327
if files.length > 4
328
print_error("Sorry, I can only allow 4 files at a time.")
329
exit
330
end
331
332
options['samples'] = files
333
end
334
335
opts.separator ""
336
opts.separator "Common options:"
337
338
opts.on_tail("-h", "--help", "Show this message") do
339
puts opts
340
exit
341
end
342
end
343
344
# Set default
345
if options['samples'].nil?
346
options['samples'] = []
347
end
348
349
if options['quick'].nil?
350
options['quick'] = false
351
end
352
353
if options['delay'].nil?
354
options['delay'] = 60
355
end
356
357
if options['api_key'].nil?
358
# Default key is from Metasploit, see why this key can be shared:
359
# http://blog.virustotal.com/2012/12/public-api-request-rate-limits-and-tool.html
360
options['api_key'] = '501caf66349cc7357eb4398ac3298fdd03dec01a3e2f3ad576525aa7b57a1987'
361
end
362
363
begin
364
opts.parse!(args)
365
rescue OptionParser::InvalidOption
366
print_error("Invalid option, try -h for usage")
367
exit
368
end
369
370
if options.empty?
371
print_error("No options specified, try -h for usage")
372
exit
373
end
374
375
options
376
end
377
end
378
379
class Driver
380
381
attr_reader :opts
382
383
def initialize
384
opts = {}
385
386
# Init arguments
387
options = OptsConsole.parse(ARGV)
388
389
# Init config manager
390
config = ToolConfig.new
391
392
# User must ack for research privacy before using this tool
393
unless config.has_privacy_waiver?
394
ack_privacy
395
config.save_privacy_waiver
396
end
397
398
# Set the API key
399
config.save_api_key(options['api_key']) unless options['api_key'].blank?
400
api_key = config.load_api_key
401
if api_key.blank?
402
print_status("No API key found, using the default one. You may set it later with -k.")
403
exit
404
else
405
print_status("Using API key: #{api_key}")
406
opts['api_key'] = api_key
407
end
408
409
@opts = opts.merge(options)
410
end
411
412
413
#
414
# Prompts the user about research privacy. They will not be able to get out until they enter 'Y'
415
# @return [Boolean] True if ack
416
#
417
def ack_privacy
418
print_status "WARNING: When you upload or otherwise submit content, you give VirusTotal"
419
print_status "(and those we work with) a worldwide, royalty free, irrevocable and transferable"
420
print_status "licence to use, edit, host, store, reproduce, modify, create derivative works,"
421
print_status "communicate, publish, publicly perform, publicly display and distribute such"
422
print_status "content. To read the complete Terms of Service for VirusTotal, please go to the"
423
print_status "following link:"
424
print_status "https://www.virustotal.com/en/about/terms-of-service/"
425
print_status
426
print_status "If you prefer your own API key, you may obtain one at VirusTotal."
427
428
while true
429
$stdout.print "[*] Enter 'Y' to acknowledge: "
430
if $stdin.gets =~ /^y|yes$/i
431
return true
432
end
433
end
434
end
435
436
437
#
438
# Retrieves a report from VirusTotal
439
# @param vt [VirusTotal] VirusTotal object
440
# @param res [Hash] Last submission response
441
# @param delay [Integer] Delay
442
# @return [Hash] VirusTotal response that contains the report
443
#
444
def wait_report(vt, res, delay)
445
sha256 = res['sha256']
446
print_status("Requesting the report...")
447
res = nil
448
449
# 3600 seconds = 1 hour
450
begin
451
::Timeout.timeout(3600) {
452
while true
453
res = vt.retrieve_report
454
break if res['response_code'] == 1
455
select(nil, nil, nil, delay)
456
print_status("Received code #{res['response_code']}. Waiting for another #{delay.to_s} seconds...")
457
end
458
}
459
rescue ::Timeout::Error
460
print_error("No report collected. Please manually check the analysis link later.")
461
return nil
462
end
463
464
res
465
end
466
467
468
#
469
# Shows the scan report
470
# @param res [Hash] VirusTotal response
471
# @param sample [String] Malware name
472
# @return [void]
473
#
474
def generate_report(res, sample)
475
if res['response_code'] != 1
476
print_status("VirusTotal: #{res['verbose_msg']}")
477
return
478
end
479
480
short_filename = File.basename(sample)
481
tbl = Rex::Text::Table.new(
482
'Header' => "Analysis Report: #{short_filename} (#{res['positives']} / #{res['total']}): #{res['sha256']}",
483
'Indent' => 1,
484
'Columns' => ['Antivirus', 'Detected', 'Version', 'Result', 'Update']
485
)
486
487
(res['scans'] || []).each do |result|
488
product = result[0]
489
detected = result[1]['detected'].to_s
490
version = result[1]['version'] || ''
491
sig_name = result[1]['result'] || ''
492
timestamp = result[1]['update'] || ''
493
494
tbl << [product, detected, version, sig_name, timestamp]
495
end
496
497
print_status tbl.to_s
498
end
499
500
501
#
502
# Displays hashes
503
#
504
def show_hashes(res)
505
print_status("Sample MD5 hash : #{res['md5']}") if res['md5']
506
print_status("Sample SHA1 hash : #{res['sha1']}") if res['sha1']
507
print_status("Sample SHA256 hash : #{res['sha256']}") if res['sha256']
508
print_status("Analysis link: #{res['permalink']}") if res['permalink']
509
end
510
511
512
#
513
# Executes a scan by uploading a sample and produces a report
514
#
515
def scan_by_upload
516
@opts['samples'].each do |sample|
517
vt = VirusTotal.new({'api_key' => @opts['api_key'], 'sample' => sample})
518
print_status("Please wait while I upload #{sample}...")
519
res = vt.scan_sample
520
print_status("VirusTotal: #{res['verbose_msg']}")
521
show_hashes(res)
522
res = wait_report(vt, res, @opts['delay'])
523
generate_report(res, sample) if res
524
525
puts
526
end
527
end
528
529
530
#
531
# Executes a hash search and produces a report
532
#
533
def scan_by_hash
534
@opts['samples'].each do |sample|
535
vt = VirusTotal.new({'api_key' => @opts['api_key'], 'sample' => sample})
536
print_status("Please wait I look for a report for #{sample}...")
537
res = vt.retrieve_report
538
show_hashes(res)
539
generate_report(res, sample) if res
540
541
puts
542
end
543
end
544
545
end
546
547
end # VirusTotalUtility
548
549
550
#
551
# main
552
#
553
if __FILE__ == $PROGRAM_NAME
554
begin
555
driver = VirusTotalUtility::Driver.new
556
if driver.opts['quick']
557
driver.scan_by_hash
558
else
559
driver.scan_by_upload
560
end
561
rescue Interrupt
562
$stdout.puts
563
$stdout.puts "Good bye"
564
end
565
end
566
rescue SignalException => e
567
puts("Aborted! #{e}")
568
end
569
570