Land #2851, @wchen-r7's virustotal integration

bug/bundler_fix
jvazquez-r7 2014-01-10 19:12:56 -06:00
commit bd91e36e06
No known key found for this signature in database
GPG Key ID: 38D99152B9352D83
3 changed files with 922 additions and 0 deletions

View File

@ -0,0 +1,129 @@
##
# This module requires Metasploit: http//metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
require 'msf/core'
require 'net/http'
require 'uri'
class Metasploit3 < Msf::Post
include Msf::Post::File
def initialize(info={})
super( update_info( info,
'Name' => 'Multi Gather Malware Verifier',
'Description' => %q{
This module will check a file for malware on VirusTotal based on the checksum.
},
'License' => MSF_LICENSE,
'Author' => [ 'sinn3r'],
'Platform' => [ 'osx', 'win', 'linux' ],
'SessionTypes' => [ "shell", "meterpreter" ]
))
register_options(
[
OptString.new('APIKEY', [true, "VirusTotal API key", '501caf66349cc7357eb4398ac3298fdd03dec01a3e2f3ad576525aa7b57a1987']),
OptString.new('REMOTEFILE', [true, "A file to check from the remote machine"])
], self.class)
end
def rhost
session.session_host
end
def get_report(api_key, checksum)
#
# We have to use Net::HTTP instead of HttpClient because of the following error:
# The supplied module name is ambiguous: undefined method `register_autofilter_ports'
#
url = URI.parse("https://www.virustotal.com/vtapi/v2/file/report")
req = Net::HTTP::Post.new(url.path, initheader={'Host'=>'www.virustotal.com'})
req.set_form_data({'apikey'=>api_key, 'resource'=>checksum})
http = Net::HTTP::new(url.host, url.port)
http.use_ssl = true
res = http.start {|http| http.request(req)}
unless res
print_error("#{rhost} - Connection timed out")
return ''
end
case res.code
when 204
print_error("#{rhost} - You have reached the request limit, please wait for one minute to try again")
return ''
when 403
print_error("#{rhost} - No privilege to execute this request probably due to an invalye API key")
return ''
end
body = ''
begin
body = JSON.parse(res.body)
rescue JSON::ParserError
print_error("#{rhost} - Unable to parse the response")
return body
end
body
end
def show_report(res, filename)
md5 = res['md5'] || ''
sha1 = res['sha1'] || ''
sha256 = res['sha256'] || ''
print_status("#{rhost} - MD5: #{md5}") unless md5.blank?
print_status("#{rhost} - SHA1: #{sha1}") unless sha1.blank?
print_status("#{rhost} - SHA256: #{sha256}") unless sha256.blank?
tbl = Rex::Ui::Text::Table.new(
'Header' => "Analysis Report: #{filename} (#{res['positives']} / #{res['total']}): #{res['sha256']}",
'Indent' => 1,
'Columns' => ['Antivirus', 'Detected', 'Version', 'Result', 'Update']
)
res['scans'].each do |result|
product = result[0]
detected = result[1]['detected'].to_s
version = result[1]['version'] || ''
sig_name = result[1]['result'] || ''
timestamp = result[1]['update'] || ''
tbl << [product, detected, version, sig_name, timestamp]
end
report_note({
:host => session,
:type => 'malware.sample',
:data => tbl.to_csv
})
print_status tbl.to_s
end
def run
filename = datastore['REMOTEFILE']
api_key = datastore['APIKEY']
unless file?(filename)
print_error("#{rhost} - File not found: #{filename}")
return
end
checksum = file_remote_digestsha1(filename)
print_status("#{rhost} - Checking: #{filename}...")
report = get_report(api_key, checksum)
return if report.blank?
print_status("#{rhost} - VirusTotal message: #{report['verbose_msg']}")
if report['response_code'] == 1
show_report(report, File.basename(filename))
end
end
end

View File

@ -0,0 +1,264 @@
require 'spec_helper'
load Metasploit::Framework.root.join('tools/virustotal.rb').to_path
require 'fastlib'
require 'msfenv'
require 'msf/base'
require 'digest/sha2'
describe "virustotal.rb" do
context "Classes" do
let(:api_key) do
'FAKE_API_KEY'
end
let(:filename) do
'MALWARE.EXE'
end
let(:malware_data) do
'DATA'
end
describe ToolConfig do
context "Class methods" do
let(:tool_config) do
ToolConfig.new
end
context ".Initializer" do
it "should init the config file path as Metasploit's default config path" do
tool_config.instance_variable_get(:@config_file).should eq(Msf::Config.config_file)
end
it "should init the group name as 'VirusTotal'" do
tool_config.instance_variable_get(:@group_name).should eq('VirusTotal')
end
end
end
end
describe VirusTotal do
context "Class methods" do
let(:malware_sha256) do
Digest::SHA256.hexdigest(malware_data)
end
let(:sample) do
{
'filename' => filename,
'data' => malware_data,
'sha256' => malware_sha256
}
end
let(:boundary) do
'THEREAREMANYLIKEITBUTTHISISMYDATA'
end
let(:scan_sample_opts) do
opts = {
'boundary' => boundary,
'api_key' => api_key,
'filename' => filename,
'data' => malware_data
}
return opts
end
let(:retrieve_report_opts) do
opts = {
'uri' => '/vtapi/v2/file/report',
'method' => 'POST',
'vhost' => 'www.virustotal.com',
'vars_post' => {
'apikey' => api_key,
'resource' => malware_sha256
}
}
return opts
end
let(:vt) do
file = double(File, read: malware_data)
File.stub(:open).with(filename, 'rb') {|&block| block.yield file}
VirusTotal.new({'api_key'=>api_key, 'sample'=>filename})
end
context ".Initializer" do
it "should have an API key" do
vt.instance_variable_get(:@api_key).should eq(api_key)
end
it "should have a checksum for the malware sample" do
vt.instance_variable_get(:@sample_info)['sha256'].should eq(malware_sha256)
end
end
context "._load_sample" do
it "should contain sample info including data, filename, and sha256" do
vt.send(:_load_sample, filename).should eq(sample)
end
end
context ".scan_sample" do
it "should return with data" do
vt.stub(:_execute_request).and_return('')
vt.scan_sample.should eq('')
end
end
context ".retrieve_report" do
it "should return with data" do
vt.stub(:_execute_request).and_return('')
vt.retrieve_report.should eq('')
end
end
context "._execute_request" do
it "should return status code 204" do
res = double(Rex::Proto::Http::Response)
res.stub(:code).and_return(204)
vt.stub(:send_request_cgi).with(scan_sample_opts).and_return(res)
expect { vt.send(:_execute_request, scan_sample_opts) }.to raise_error(RuntimeError)
end
it "should return status code 403" do
res = double(Rex::Proto::Http::Response)
res.stub(:code).and_return(403)
vt.stub(:send_request_cgi).with(scan_sample_opts).and_return(res)
expect { vt.send(:_execute_request, scan_sample_opts) }.to raise_error(RuntimeError)
end
end
context "._create_upload_data" do
let(:form_opts) do
{
'boundary' => boundary,
'api_key' => api_key,
'filename' => filename,
'data' => malware_data
}
end
before(:each) do
@upload_data = vt.send(:_create_upload_data, form_opts)
end
it "should create form-data with a boundary" do
@upload_data.should match(/#{boundary}/)
end
it "should create form-data with the API key" do
@upload_data.should match(/#{api_key}/)
end
it "should create form-data with the malware filename" do
@upload_data.should match(/#{filename}/)
end
it "should create form-data with the malware data" do
@upload_data.should match(/#{malware_data}/)
end
end
end
end
describe Driver do
# Get stdout:
# http://stackoverflow.com/questions/11349270/test-output-to-command-line-with-rspec
def get_stdout(&block)
out = $stdout
$stdout = fake = StringIO.new
begin
yield
ensure
$stdout = out
end
fake.string
end
before do
$stdin = StringIO.new("Y\n")
end
after do
$stdin = STDIN
end
let(:driver) do
argv = "-k #{api_key} -f #{filename}".split
options = {
'samples' => filename,
'api_key' => api_key,
'delay' => 60
}
OptsConsole.stub(:parse).with(anything).and_return(options)
tool_config = double("tool_config")
ToolConfig.stub(:new).and_return(tool_config)
tool_config.stub(:has_privacy_waiver?).and_return(true)
tool_config.stub(:load_api_key).and_return(api_key)
tool_config.stub(:save_privacy_waiver)
tool_config.stub(:save_api_key).with(anything)
d = nil
out = get_stdout {
d = Driver.new
}
d
end
context ".Class methods" do
context ".initialize" do
it "should return a Driver object" do
driver.class.should eq(Driver)
end
end
context ".ask_privacy" do
it "should have a link of VirusTotal's terms of service" do
tos = 'https://www.virustotal.com/en/about/terms-of-service'
out = get_stdout { driver.ack_privacy }
out.should match(/#{tos}/)
end
end
context ".upload_sample" do
it "should upload a sample" do
vt = double(VirusTotal)
vt.stub(:scan_sample).and_return({})
out = get_stdout { driver.upload_sample(vt, filename) }
out.should match(/Please wait while I upload/)
end
end
context ".generate_report" do
it "should show a report" do
res = {
"scans" => {
"Bkav" => { "detected" => false, "version" => "1.3.0.4613", "result" => nil, "update" => "20140107" }
}
}
out = get_stdout { driver.generate_report(res, filename) }
out.should match(/#{res['scans']['Bkav']['version']}/)
end
end
end
end
end
end

529
tools/virustotal.rb Executable file
View File

@ -0,0 +1,529 @@
#!/usr/bin/env ruby
##
# This module requires Metasploit: http//metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
#
# This script will check multiple files against VirusTotal's public analysis service. You are
# limited to at most 4 requests (of any nature in any given 1 minute time frame), because
# VirusTotal says so. If you prefer your own API key, you may get one at virustotal.com
#
# VirusTotal Terms of Service:
# https://www.virustotal.com/en/about/terms-of-service/
#
# Public API documentations can be found here:
# https://www.virustotal.com/en/documentation/public-api/
# https://api.vtapi.net/en/doc/
#
# WARNING:
# When you upload or otherwise submit content, you give VirusTotal (and those we work with) a
# worldwide, royalty free, irrevocable and transferable licence to use, edit, host, store,
# reproduce, modify, create derivative works, communicate, publish, publicly perform, publicly
# display and distribute such content.
#
# Author:
# sinn3r <sinn3r[at]metasploit.com>
#
msfbase = __FILE__
while File.symlink?(msfbase)
msfbase = File.expand_path(File.readlink(msfbase), File.dirname(msfbase))
end
$:.unshift(File.expand_path(File.join(File.dirname(msfbase), '..', 'lib')))
require 'fastlib'
require 'msfenv'
require 'rex'
require 'msf/core'
require 'digest/sha2'
require 'optparse'
require 'json'
require 'timeout'
class ToolConfig
def initialize
@config_file ||= Msf::Config.config_file
@group_name ||= 'VirusTotal'
end
#
# Saves the VirusTotal API key to Metasploit's config file
# @param key [String] API key
# @return [void]
#
def save_api_key(key)
_set_setting('api_key', key)
end
#
# Returns the VirusTotal API key from Metasploit's config file
# @return [String] the API key
#
def load_api_key
_get_setting('api_key') || ''
end
#
# Sets the privacy waiver to true after the tool is run for the very first time
# @return [void]
#
def save_privacy_waiver
_set_setting('waiver', true)
end
#
# Returns whether a waver is set or not
# @return [Boolean]
#
def has_privacy_waiver?
_get_setting('waiver') || false
end
private
#
# Sets a setting in Metasploit's config file
# @param key_name [String] The Key to set
# @param value [String] The value to set
# @return [void]
#
def _set_setting(key_name, value)
ini = Rex::Parser::Ini.new(@config_file)
ini.add_group(@group_name) if ini[@group_name].nil?
ini[@group_name][key_name] = value
ini.to_file(@config_file)
end
#
# Returns a setting from Metasploit's config file
# @param key_name [String] The setting to get
# @return [void]
#
def _get_setting(key_name)
ini = Rex::Parser::Ini.new(@config_file)
group = ini[@group_name]
return nil if group.nil?
return nil if group[key_name].nil?
group[key_name]
end
end
class VirusTotal < Msf::Auxiliary
include Msf::Exploit::Remote::HttpClient
def initialize(opts={})
@api_key = opts['api_key']
@sample_info = _load_sample(opts['sample'])
# It should resolve to 74.125.34.46, and the HOST header (HTTP) must be www.virustotal.com, or
# it will return a 404 instead.
rhost = Rex::Socket.resolv_to_dotted("www.virustotal.com") rescue '74.125.34.46'
# Need to configure HttpClient to enable SSL communication
super(
'DefaultOptions' =>
{
'SSL' => true,
'RHOST' => rhost,
'RPORT' => 443
}
)
end
#
# Submits a malware sample for VirusTotal to scan
# @param sample [String] Data to analyze
# @return [Hash] JSON response
#
def scan_sample
opts = {
'boundary' => 'THEREAREMANYLIKEITBUTTHISISMYDATA',
'api_key' => @api_key,
'filename' => @sample_info['filename'],
'data' => @sample_info['data']
}
_execute_request({
'uri' => '/vtapi/v2/file/scan',
'method' => 'POST',
'vhost' => 'www.virustotal.com',
'ctype' => "multipart/form-data; boundary=#{opts['boundary']}",
'data' => _create_upload_data(opts)
})
end
#
# Returns the report of a specific malware checksum
# @return [Hash] JSON response
#
def retrieve_report
_execute_request({
'uri' => '/vtapi/v2/file/report',
'method' => 'POST',
'vhost' => 'www.virustotal.com',
'vars_post' => {
'apikey' => @api_key,
'resource' => @sample_info['sha256']
}
})
end
private
#
# Returns the JSON response of a HTTP request
# @param opts [Hash] HTTP options
# @return [Hash] JSON response
#
def _execute_request(opts)
res = send_request_cgi(opts)
return '' if res.nil?
case res.code
when 204
raise RuntimeError, "You have hit the request limit."
when 403
raise RuntimeError, "No privilege to execute this request probably due to an invalye API key"
end
json_body = ''
begin
json_body = JSON.parse(res.body)
rescue JSON::ParserError
json_body = ''
end
json_body
end
#
# Returns malware sample information
# @param sample [String] The sample path to load
# @return [Hash] Information about the sample (including the raw data, and SHA256 checksum)
#
def _load_sample(sample)
info = {
'filename' => '',
'data' => ''
}
File.open(sample, 'rb') do |f|
info['data'] = f.read
end
info['filename'] = File.basename(sample)
info['sha256'] = Digest::SHA256.hexdigest(info['data'])
info
end
#
# Creates a form-data message
# @param opts [Hash] A hash that contains keys including boundary, api_key, filename, and data
# @return [String] The POST request data
#
def _create_upload_data(opts={})
boundary = opts['boundary']
api_key = opts['api_key']
filename = opts['filename']
data = opts['data']
# Can't use Rex::MIME::Message, or you WILL be increditably outraged, it messes with your data.
# See VT report for example: 4212686e701286ab734d8a67b7b7527f279c2dadc27bd744abebecab91b70c82
data = %Q|--#{boundary}
Content-Disposition: form-data; name="apikey"
#{api_key}
--#{boundary}
Content-Disposition: form-data; name="file"; filename="#{filename}"
Content-Type: application/octet-stream
#{data}
--#{boundary}--
|
data
end
end
class DriverBase
#
# Prints a status message
#
def print_status(msg='')
$stdout.puts "[*] #{msg}"
end
#
# Prints an error message
#
def print_error(msg='')
$stdout.puts "[-] #{msg}"
end
end
class OptsConsole < DriverBase
#
# Return a hash describing the options.
#
def self.parse(args)
options = {}
opts = OptionParser.new do |opts|
opts.banner = "Usage: #{__FILE__} [options]"
opts.separator ""
opts.separator "Specific options:"
opts.on("-k", "-k <key>", "(Optional) Virusl API key to use") do |v|
options['api_key'] = v
end
opts.on("-d", "-delay <seconds>", "(Optional) Number of seconds to wait for the report") do |v|
if v !~ /^\d+$/
print_error("Invalid input for -d. It must be a number.")
exit
end
options['delay'] = v.to_i
end
opts.on("-f", "-files <filenames>", "Files to scan") do |v|
files = v.split.delete_if { |e| e.nil? }
bad_files = []
files.each do |f|
unless ::File.exists?(f)
bad_files << f
end
end
unless bad_files.empty?
print_error("Cannot find: #{bad_files * ' '}")
exit
end
if files.length > 4
print_error("Sorry, I can only allow 4 files at a time.")
exit
end
options['samples'] = files
end
opts.separator ""
opts.separator "Common options:"
opts.on_tail("-h", "--help", "Show this message") do
puts opts
exit
end
end
# Set default
if options['delay'].nil?
options['delay'] = 60
end
if options['api_key'].nil?
# Default key is from Metasploit, see why this key can be shared:
# http://blog.virustotal.com/2012/12/public-api-request-rate-limits-and-tool.html
options['api_key'] = '501caf66349cc7357eb4398ac3298fdd03dec01a3e2f3ad576525aa7b57a1987'
end
begin
opts.parse!(args)
rescue OptionParser::InvalidOption
print_error "Invalid option, try -h for usage"
exit
end
if options.empty?
print_error "No options specified, try -h for usage"
exit
end
options
end
end
class Driver < DriverBase
def initialize
opts = {}
# Init arguments
options = OptsConsole.parse(ARGV)
# Init config manager
config = ToolConfig.new
# User must ack for research privacy before using this tool
unless config.has_privacy_waiver?
ack_privacy
config.save_privacy_waiver
end
# Set the API key
config.save_api_key(options['api_key']) unless options['api_key'].blank?
api_key = config.load_api_key
if api_key.blank?
print_status("No API key found, using the default one. You may set it later with -k.")
exit
else
print_status("Using API key: #{api_key}")
opts['api_key'] = api_key
end
@opts = opts.merge(options)
end
#
# Prompts the user about research privacy. They will not be able to get out until they enter 'Y'
# @return [Boolean] True if ack
#
def ack_privacy
print_status "WARNING: When you upload or otherwise submit content, you give VirusTotal"
print_status "(and those we work with) a worldwide, royalty free, irrevocable and transferable"
print_status "licence to use, edit, host, store, reproduce, modify, create derivative works,"
print_status "communicate, publish, publicly perform, publicly display and distribute such"
print_status "content. To read the complete Terms of Service for VirusTotal, please go to the"
print_status "following link:"
print_status "https://www.virustotal.com/en/about/terms-of-service/"
print_status
print_status "If you prefer your own API key, you may obtain one at VirusTotal."
while true
$stdout.print "[*] Enter 'Y' to acknowledge: "
if $stdin.gets =~ /^y|yes$/i
return true
end
end
end
#
# Submits a malware sample to VirusTotal
# @param vt [VirusTotal] VirusTotal object
# @param sample [String] The malware sample name
# @return [Hash] VirusTotal response of the upload
#
def upload_sample(vt, sample)
print_status("Please wait while I upload #{sample}...")
res = vt.scan_sample
print_status("VirusTotal: #{res['verbose_msg']}")
print_status("Sample MD5 checksum: #{res['md5']}")
print_status("Sample SHA256 checksum: #{res['sha256']}")
print_status("Analysis link: #{res['permalink']}")
res
end
#
# Retrieves a report from VirusTotal
# @param vt [VirusTotal] VirusTotal object
# @param res [Hash] Last submission response
# @param delay [Fixnum] Delay
# @return [Hash] VirusTotal response that contains the report
#
def wait_report(vt, res, delay)
sha256 = res['sha256']
print_status("Requesting the report...")
res = nil
# 3600 seconds = 1 hour
begin
::Timeout.timeout(3600) {
while true
res = vt.retrieve_report
break if res['response_code'] == 1
select(nil, nil, nil, delay)
print_status("Received code #{res['response_code']}. Waiting for another #{delay.to_s} seconds...")
end
}
rescue ::Timeout::Error
print_error("No report collected. Please manually check the analysis link later.")
return nil
end
res
end
#
# Shows the scan report
# @param res [Hash] VirusTotal response
# @param sample [String] Malware name
# @return [void]
#
def generate_report(res, sample)
tbl = Rex::Ui::Text::Table.new(
'Header' => "Analysis Report: #{sample} (#{res['positives']} / #{res['total']}): #{res['sha256']}",
'Indent' => 1,
'Columns' => ['Antivirus', 'Detected', 'Version', 'Result', 'Update']
)
res['scans'].each do |result|
product = result[0]
detected = result[1]['detected'].to_s
version = result[1]['version'] || ''
sig_name = result[1]['result'] || ''
timestamp = result[1]['update'] || ''
tbl << [product, detected, version, sig_name, timestamp]
end
print_status tbl.to_s
end
#
# Executes a scan and produces a report
#
def scan
@opts['samples'].each do |sample|
vt = VirusTotal.new({'api_key' => @opts['api_key'], 'sample' => sample})
res = upload_sample(vt, sample)
res = wait_report(vt, res, @opts['delay'])
generate_report(res, sample) if res
puts
end
end
end
#
# main
#
if __FILE__ == $PROGRAM_NAME
begin
driver = Driver.new
driver.scan
rescue Interrupt
$stdout.puts
$stdout.puts "Good bye"
end
end