#!/usr/bin/env ruby ## # This module requires Metasploit: https://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 # 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 'msfenv' require 'rex' require 'msf/core' require 'digest/sha2' require 'optparse' require 'json' require 'timeout' # # Prints a status message # def print_status(msg='') $stdout.puts "[*] #{msg}" end # # Prints an error message # def print_error(msg='') $stdout.puts "[-] #{msg}" end module VirusTotalUtility 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 hash # @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 hash) # 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 OptsConsole # # 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 ", "(Optional) Virusl API key to use") do |v| options['api_key'] = v end opts.on("-d", "-d ", "(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("-q", nil, "(Optional) Do a hash search without uploading the sample") do |v| options['quick'] = true end opts.on("-f", "-f ", "Files to scan") do |v| files = v.split.delete_if { |e| e.nil? } bad_files = [] files.each do |f| unless ::File.exist?(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['samples'].nil? options['samples'] = [] end if options['quick'].nil? options['quick'] = false end 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 attr_reader :opts 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 # # Retrieves a report from VirusTotal # @param vt [VirusTotal] VirusTotal object # @param res [Hash] Last submission response # @param delay [Integer] 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) if res['response_code'] != 1 print_status("VirusTotal: #{res['verbose_msg']}") return end short_filename = File.basename(sample) tbl = Rex::Text::Table.new( 'Header' => "Analysis Report: #{short_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 print_status tbl.to_s end # # Displays hashes # def show_hashes(res) print_status("Sample MD5 hash : #{res['md5']}") if res['md5'] print_status("Sample SHA1 hash : #{res['sha1']}") if res['sha1'] print_status("Sample SHA256 hash : #{res['sha256']}") if res['sha256'] print_status("Analysis link: #{res['permalink']}") if res['permalink'] end # # Executes a scan by uploading a sample and produces a report # def scan_by_upload @opts['samples'].each do |sample| vt = VirusTotal.new({'api_key' => @opts['api_key'], 'sample' => sample}) print_status("Please wait while I upload #{sample}...") res = vt.scan_sample print_status("VirusTotal: #{res['verbose_msg']}") show_hashes(res) res = wait_report(vt, res, @opts['delay']) generate_report(res, sample) if res puts end end # # Executes a hash search and produces a report # def scan_by_hash @opts['samples'].each do |sample| vt = VirusTotal.new({'api_key' => @opts['api_key'], 'sample' => sample}) print_status("Please wait I look for a report for #{sample}...") res = vt.retrieve_report show_hashes(res) generate_report(res, sample) if res puts end end end end # VirusTotalUtility # # main # if __FILE__ == $PROGRAM_NAME begin driver = VirusTotalUtility::Driver.new if driver.opts['quick'] driver.scan_by_hash else driver.scan_by_upload end rescue Interrupt $stdout.puts $stdout.puts "Good bye" end end