Add dev tool MSFT MSU finder (msu_finder.rb)

You can use this tool to find MSFT patches. Please see -h for more
information.
bug/bundler_fix
wchen-r7 2015-09-28 18:44:31 -05:00
parent 26c8380333
commit 8f1999e227
1 changed files with 746 additions and 0 deletions

746
tools/msu_finder.rb Normal file
View File

@ -0,0 +1,746 @@
#!/usr/bin/env ruby
###
#
# This sceript will enumerate download links for Microsoft patches.
#
# 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 'rex'
require 'nokogiri'
require 'uri'
require 'json'
require 'optparse'
module MicrosoftPatch
module SiteInfo
TECHNET = {
ip: '157.56.148.23',
vhost: 'technet.microsoft.com'
}
MICROSOFT = {
ip: '104.72.230.162',
vhost: 'www.microsoft.com'
}
GOOGLEAPIS = {
ip: '74.125.28.95',
vhost: 'www.googleapis.com'
}
end
# This class provides whatever other classes need.
class Base
# Prints a debug message.
#
# @param msg [String] The message to print.
# @return [void]
def print_debug(msg='')
$stderr.puts "[DEBUG] #{msg}"
end
# Prints a status message.
#
# @param msg [String] The message to print.
# @return [void]
def print_status(msg='')
$stderr.puts "[*] #{msg}"
end
# Prints an error message.
#
# @param msg [String] The message to print.
# @return [void]
def print_error(msg='')
$stderr.puts "[Error] #{msg}"
end
# Prints a regular message.
#
# @param msg [String] The message to print.
# @return pvoid
def print_line(msg='')
$stdout.puts msg
end
# Sends an HTTP request with Rex.
#
# @param rhost [Hash] Information about the target host. Use MicrosoftPatch::SiteInfo.
# @option rhost [String] :vhost
# @option rhost [String] :ip IPv4 address
# @param opts [Hash] Information about the Rex request.
# @return [Rex::Proto::Http::Response]
def send_http_request(rhost, opts={})
res = nil
opts.merge!({'vhost'=>rhost[:vhost]})
print_debug("Requesting: #{opts['uri']}")
cli = Rex::Proto::Http::Client.new(rhost[:ip], 443, {}, true, 'TLS1')
tries = 1
begin
cli.connect
req = cli.request_cgi(opts)
res = cli.send_recv(req)
rescue ::EOFError, Errno::ETIMEDOUT ,Errno::ECONNRESET, Rex::ConnectionError, OpenSSL::SSL::SSLError, ::Timeout::Error => e
if tries < 3
print_error("Failed to make a request, but will try again in 5 seconds...")
sleep(5)
tries += 1
retry
else
raise "[x] Unable to make a request: #{e.class} #{e.message}\n#{e.backtrace * "\n"}"
end
ensure
cli.close
end
res
end
end
# Collects MSU download links from Technet.
class PatchLinkCollector < Base
# Returns a response of an advisory page.
#
# @param msb [String] MSB number in this format: msxx-xxx
# @return [Rex::Proto::Http::Response]
def download_advisory(msb)
send_http_request(SiteInfo::TECHNET, {
'uri' => "/en-us/library/security/#{msb}.aspx"
})
end
# Returns the most appropriate pattern that could be used to parse and extract links from an advisory.
#
# @param n [Nokogiri::HTML::Document] The advisory page parsed by Nokogiri
# @return [Hash]
def get_appropriate_pattern(n)
# These pattern checks need to be in this order.
patterns = [
# This works from MS14-001 until the most recent
{
check: '//div[@id="mainBody"]//div//h2//div//span[contains(text(), "Affected Software")]',
pattern: '//div[@id="mainBody"]//div//div[@class="sectionblock"]//table//a'
},
# This works from ms03-040 until MS07-029
{
check: '//div[@id="mainBody"]//ul//li//a[contains(text(), "Download the update")]',
pattern: '//div[@id="mainBody"]//ul//li//a[contains(text(), "Download the update")]'
},
# This works from sometime until ms03-039
{
check: '//div[@id="mainBody"]//div//div[@class="sectionblock"]//p//strong[contains(text(), "Download locations")]',
pattern: '//div[@id="mainBody"]//div//div[@class="sectionblock"]//ul//li//a'
},
# This works from MS07-030 until MS13-106 (the last update in 2013)
# The check is pretty short so if it kicks in too early, it tends to create false positives.
# So it goes last.
{
check: '//div[@id="mainBody"]//p//strong[contains(text(), "Affected Software")]',
pattern: '//div[@id="mainBody"]//table//a'
},
]
patterns.each do |pattern|
if n.at_xpath(pattern[:check])
return pattern[:pattern]
end
end
nil
end
# Returns the details page for an advisory.
#
# @param res [Rex::Proto::Http::Response]
# @return [Array<URI::HTTP>] An array of URI objects.
def get_details_aspx(res)
links = []
page = res.body
n = ::Nokogiri::HTML(page)
appropriate_pattern = get_appropriate_pattern(n)
n.search(appropriate_pattern).each do |anchor|
found_link = anchor.attributes['href'].value
if /https:\/\/www\.microsoft\.com\/downloads\/details\.aspx\?familyid=/i === found_link
begin
links << URI(found_link)
rescue ::URI::InvalidURIError
print_error "Unable to parse URI: #{found_link}"
end
end
end
links
end
# Returns the redirected page.
#
# @param rhost [Hash] From MicrosoftPatch::SiteInfo
# @param res [Rex::Proto::Http::Response]
# @return [Rex::Proto::Http::Response]
def follow_redirect(rhost, res)
opts = {
'method' => 'GET',
'uri' => res.headers['Location']
}
send_http_request(rhost, opts)
end
# Returns the download page of an advisory.
#
# @param uri [URI::HTTP]
# @return [Rex::Proto::Http::Response]
def get_download_page(uri)
opts = {
'method' => 'GET',
'uri' => uri.request_uri
}
res = send_http_request(SiteInfo::MICROSOFT, opts)
if res.headers['Location']
return follow_redirect(SiteInfo::MICROSOFT, res)
end
res
end
# Returns a collection of found MSU download links from an advisory.
#
# @param page [String] The HTML page of the advisory.
# @return [Array<String>] An array of links
def get_download_links(page)
page = ::Nokogiri::HTML(page)
relative_uri = page.search('a').select { |a|
a.attributes['href'] && a.attributes['href'].value.include?('confirmation.aspx?id=')
}.first
return [] unless relative_uri
relative_uri = relative_uri.attributes['href'].value
absolute_uri = URI("https://www.microsoft.com/en-us/download/#{relative_uri}")
opts = {
'method' => 'GET',
'uri' => absolute_uri.request_uri
}
res = send_http_request(SiteInfo::MICROSOFT, opts)
n = ::Nokogiri::HTML(res.body)
n.search('a').select { |a|
a.attributes['href'] && a.attributes['href'].value.include?('http://download.microsoft.com/download/')
}.map! { |a| a.attributes['href'].value }.uniq
end
# Returns whether the page is an advisory or not.
#
# @param res [Rex::Proto::Http::Response]
# @return [Boolean] true if the page is an advisory, otherwise false.
def has_advisory?(res)
!res.body.include?('We are sorry. The page you requested cannot be found')
end
# Returns whether the number is in valid MSB format or not.
#
# @param msb [String] The number to check.
# @return [Boolean] true if the number is in MSB format, otherwise false.
def is_valid_msb?(msb)
/^ms\d\d\-\d\d\d$/i === msb
end
end
# A class that searches advisories from Technet.
class TechnetMsbSearch < Base
def initialize
opts = {
'method' => 'GET',
'uri' => '/en-us/security/bulletin/dn602597.aspx'
}
res = send_http_request(SiteInfo::TECHNET, opts)
@firstpage ||= res.body
end
# Returns a collection of found MSB numbers either from the product list, or generic search.
#
# @param keyword [String] The product to look for.
# @return [Array<String>]
def find_msb_numbers(keyword)
product_list_matches = get_product_dropdown_list.select { |p| /^#{keyword}/ === p[:option_text] }
if product_list_matches.empty?
print_debug("Did not find a match from the product list, attempting a generic search")
search_by_keyword(keyword)
else
product_names = []
ids = []
product_list_matches.each do |e|
ids << e[:option_value]
product_names << e[:option_text]
end
print_debug("Matches from the product list (#{product_names.length}): #{ product_names * ', ' }")
search_by_product_ids(ids)
end
end
# Returns the search results in JSON format.
#
# @param keyword [String] The keyword to search.
# @return [Hash] JSON data.
def search(keyword)
opts = {
'method' => 'GET',
'uri' => '/security/bulletin/services/GetBulletins',
'vars_get' => {
'searchText' => keyword,
'sortField' => '0',
'sortOrder' => '1',
'currentPage' => '1',
'bulletinsPerPage' => '9999',
'locale' => 'en-us'
}
}
res = send_http_request(SiteInfo::TECHNET, opts)
begin
return JSON.parse(res.body)
rescue JSON::ParserError
end
{}
end
# Performs a search based on product IDs
#
# @param ids [Array<Fixnum>] An array of product IDs.
# @return [Array<String>] An array of found MSB numbers.
def search_by_product_ids(ids)
msb_numbers = []
ids.each do |id|
j = search(id)
msb = j['b'].collect { |e| e['Id']}.map{ |e| e.downcase}
msb_numbers.concat(msb)
end
msb_numbers
end
# Performs a search based on a keyword
#
# @param keyword [String]
# @return [Array<String>] An array of found MSB numbers
def search_by_keyword(keyword)
j = search(keyword)
j['b'].collect { |e| e['Id']}.map{ |e| e.downcase }
end
# Returns the product list that Technet currently supports for searching.
#
# @return [Array<Hash>]
def get_product_dropdown_list
@product_dropdown_list ||= lambda {
list = []
page = ::Nokogiri::HTML(firstpage)
page.search('//div[@class="sb-search"]//select[@id="productDropdown"]//option').each do |product|
option_value = product.attributes['value'].value
option_text = product.text
next if option_value == '-1' # This is the ALL option
list << { option_value: option_value, option_text: option_text }
end
list
}.call
end
attr_reader :firstpage
end
class GoogleMsbSearch < Base
# API Doc:
# https://developers.google.com/custom-search/json-api/v1/using_rest
# Known bug:
# * Always gets 20 MSB results. Weird.
def initialize(opts={})
@api_key = opts[:api_key]
@search_engine_id = opts[:search_engine_id]
end
# Returns the MSB numbers associated with the keyword.
#
# @param keyword [String] The keyword to search for in an advisory.
# @return [Array<String>] MSB numbers
def find_msb_numbers(keyword)
msb_numbers = []
starting_index = 1
begin
while ( starting_index = get_next_index( (results = search(keyword: keyword, starting_index: starting_index))) ) > 0
items = results['items']
items.each do |item|
title = item['title']
msb = title.scan(/Microsoft Security Bulletin (MS\d\d\-\d\d\d)/).flatten.first
if msb
msb_numbers << msb.downcase
end
end
end
rescue RuntimeError => e
print_error(e.message)
return msb_numbers
end
msb_numbers.uniq
end
# Performs a search using Google API
#
# @param opts [Hash]
# @options opts [String] :keyword The keyword to search
# @return [Hash] JSON data
def search(opts={})
starting_index = opts[:starting_index]
search_string = [
opts[:keyword],
'intitle:"Microsoft Security Bulletin"',
'-"Microsoft Security Bulletin Summary"'
].join(' ')
opts = {
'method' => 'GET',
'uri' => '/customsearch/v1',
'vars_get' => {
'key' => api_key,
'cx' => search_engine_id,
'q' => search_string,
'start' => starting_index.to_s,
'num' => '10', # 10 is max
'c2coff' => '1' # 1 = Disabled, 0 = Enabled
}
}
res = send_http_request(SiteInfo::GOOGLEAPIS, opts)
results = parse_results(res)
if starting_index == 1
print_debug("Number of search results: #{get_total_results(results)}")
end
results
end
# Parse Google API search results
#
# @param res [Rex::Proto::Http::Response]
# @raise [RuntimeError] If Google returns an error
# @return [Hash]
def parse_results(res)
j = JSON.parse(res.body)
if j['error']
message = j['error']['errors'].first['message']
reason = j['error']['errors'].first['reason']
raise "Google Search failed. #{message} (#{reason})"
end
j
end
# Returns the total results.
#
# @param j [Hash] JSON data from Google.
# @return [Fixnum]
def get_total_results(j)
j['queries']['request'].first['totalResults']
end
# Returns the next index.
#
# @param j [Hash] JSON data from Google.
# @return [Fixnum]
def get_next_index(j)
j['queries']['nextPage'] ? j['queries']['nextPage'].first['startIndex'] : 0
end
# @!attribute api_key
# @return [String] The Google API key
attr_reader :api_key
# @!attribute search_engine_id
# @return [String] The Google Custom Search Engine ID
attr_reader :search_engine_id
end
class OptsConsole
def self.banner
%Q|
Usage: #{__FILE__} [options]
The following example will download all IE 10 update links:
#{__FILE__} -q "Internet Explorer 10"
Searching advisories via Technet:
When you submit a query, the Technet search engine will first look it up from a product list.
If there is more than one match found, then the script will collect all of the advisories.
For example, the current product list has Internet Explorer 10 and 9, and if the query is
"Internet Explorer", then it will return results for both of them. If there's no match found,
then the Technet search engine will try a more generic search that doesn't use the product list.
But keep in mind this tends to return irrelevant results. If the generic search function kicks
in, you can use it to search via the MSB, KB, or even the CVE number.
Searching advisories via Google:
Searching via Google requires an API key and an Search Engine ID from Google. To obtain these,
make sure you have a Google account (such as Gmail), and then do the following:
1. Go to Google Developer's Console
1. Enable Custom Search API
2. Create a browser type credential. The credential is the API key.
2. Go to Custom Search
1. Create a new search engine
2. Under Sites to Search, set: technet.microsoft.com
3. In your search site, get the Search Engine ID under the Basics tab.
By default, Google has a quota limit of 1000 queries per day. You can raise this limit with
a fee.
The way this tool uses Google to find advisories is the same as doing the following manually:
[Query] site:technet.microsoft.com intitle:"Microsoft Security Bulletin" -"Microsoft Security Bulletin Summary"
Dryrun:
If you'd like to double check on false positives, you can use the -d flag and manually verify
the accuracy of the search results before actually collecting the download links.
Download:
The following trick demonstrates how you can automatically download the updates:
ruby #{__FILE__} -q "ms15-100" -r x86 > /tmp/list.txt && wget -i /tmp/list.txt
|
end
def self.get_parsed_options
options = {}
parser = OptionParser.new do |opt|
opt.banner = banner.strip.gsub(/^[[:blank:]]{4}/, '')
opt.separator ''
opt.separator 'Specific options:'
opt.on('-q', '--query <keyword>', 'Keyword to search') do |v|
options[:keyword] = v
end
opt.on('-s', '--search-engine <engine>', '(Optional) The type of search engine to use (Technet or Google). Default: Technet') do |v|
case v.to_s
when /^google$/i
options[:search_engine] = :google
when /^technet$/i
options[:search_engine] = :technet
else
raise OptionParser::MissingArgument, "Invalid search engine: #{v}"
end
end
opt.on('-r', '--regex <string>', '(Optional) Search filter') do |v|
options[:regex] = v
end
opt.on('--apikey <key>', '(Optional) Google API key. Set this if the search engine is Google') do |v|
options[:google_api_key] = v
end
opt.on('--cx <id>', '(Optional) Google search engine ID. Set this if the search engine is Google') do |v|
options[:google_search_engine_id] = v
end
opt.on('-d', '--dryrun', '(Optional) Perform a search, but do not fetch download links. Default: no') do |v|
options[:dryrun] = true
end
opt.on_tail('-h', '--help', 'Show this message') do
$stderr.puts opt
exit
end
end
parser.parse!
if options.empty?
raise OptionParser::MissingArgument, 'No options set, try -h for usage'
elsif options[:keyword].nil? || options[:keyword].empty?
raise OptionParser::MissingArgument, '-q is required'
end
unless options[:search_engine]
options[:search_engine] = :technet
end
if options[:search_engine] == :google
if options[:google_api_key].nil? || options[:google_search_engine_id].empty?
raise OptionParser::MissingArgument, 'Search engine is Google, but no API key specified'
elsif options[:google_search_engine_id].nil? || options[:google_search_engine_id].empty?
raise OptionParser::MissingArgument, 'Search engine is Google, but no search engine ID specified'
end
end
options
end
end
class Module < Base
def initialize
begin
@args = MicrosoftPatch::OptsConsole.get_parsed_options
rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
print_error(e.message)
exit
end
end
# Returns download links.
#
# @param msb [String] MSB number.
# @param regex [String] The regex pattern to use to collect specific download URLs.
# @return [Array<String>] Download links
def get_download_links(msb, regex=nil)
msft = MicrosoftPatch::PatchLinkCollector.new
unless msft.is_valid_msb?(msb)
print_error "Not a valid MSB format."
print_error "Example of a correct one: ms15-100"
return []
end
res = msft.download_advisory(msb)
if !msft.has_advisory?(res)
print_error "The advisory cannot be found"
return []
end
links = msft.get_details_aspx(res)
if links.length == 0
print_error "Unable to find download.microsoft.com links. Please manually navigate to the page."
return []
else
print_debug("Found #{links.length} affected products for this advisory.")
end
link_collector = []
links.each do |link|
download_page = msft.get_download_page(link)
download_links = msft.get_download_links(download_page.body)
if regex
filtered_links = download_links.select { |l| Regexp.new(regex) === l }
link_collector.concat(filtered_links)
else
link_collector.concat(download_links)
end
end
link_collector
end
# Performs a search via Google
#
# @param keyword [String] The keyword to search
# @param api_key [String] Google API key
# @param cx [String] Google Search Engine Key
# @return [Array<String>] See MicrosoftPatch::GoogleMsbSearch#find_msb_numbers
def google_search(keyword, api_key, cx)
search = MicrosoftPatch::GoogleMsbSearch.new(api_key: api_key, search_engine_id: cx)
search.find_msb_numbers(keyword)
end
# Performs a search via Technet
#
# @param keyword [String] The keyword to search
# @return [Array<String>] See MicrosoftPatch::TechnetMsbSearch#find_msb_numbers
def technet_search(keyword)
search = MicrosoftPatch::TechnetMsbSearch.new
search.find_msb_numbers(keyword)
end
def run
links = []
msb_numbers = []
keyword = args[:keyword]
regex = args[:regex] ? args[:regex] : nil
api_key = args[:google_api_key]
cx = args[:google_search_engine_id]
case args[:search_engine]
when :technet
print_debug("Searching advisories for #{keyword} via Technet")
msb_numbers = technet_search(keyword)
when :google
print_debug("Searching advisories for #{keyword} via Google")
msb_numbers = google_search(keyword, api_key, cx)
end
print_debug("Advisories found (#{msb_numbers.length}): #{msb_numbers * ', '}") unless msb_numbers.empty?
return if args[:dryrun]
msb_numbers.each do |msb|
print_debug("Finding download links for #{msb}")
links.concat(get_download_links(msb, regex))
end
unless links.empty?
print_status "Found these links:"
print_line links * "\n"
print_status "Total downloadable updates found: #{links.length}"
end
end
attr_reader :args
end
end
if __FILE__ == $PROGRAM_NAME
mod = MicrosoftPatch::Module.new
begin
mod.run
rescue Interrupt
$stdout.puts
$stdout.puts "Good bye"
end
end