virustotal.rb 14 KB

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