584 lines
18 KiB
Ruby
584 lines
18 KiB
Ruby
# -*- coding: binary -*-
|
|
require "rex/parser/nokogiri_doc_mixin"
|
|
require 'rex'
|
|
require 'uri'
|
|
|
|
module Rex
|
|
module Parser
|
|
|
|
# If Nokogiri is available, define the Acunetix document class.
|
|
load_nokogiri && class AcunetixDocument < Nokogiri::XML::SAX::Document
|
|
|
|
include NokogiriDocMixin
|
|
|
|
# The resolver prefers your local /etc/hosts (or windows equiv), but will
|
|
# fall back to regular DNS. It retains a cache for the import to avoid
|
|
# spamming your network with DNS requests.
|
|
attr_reader :resolv_cache
|
|
|
|
# If name resolution of the host fails out completely, you will not be
|
|
# able to import that Scan task. Other scan tasks in the same report
|
|
# should be unaffected.
|
|
attr_reader :parse_warnings
|
|
|
|
def start_document
|
|
@parse_warnings = []
|
|
@resolv_cache = {}
|
|
@host_object = nil
|
|
end
|
|
|
|
def start_element(name=nil,attrs=[])
|
|
attrs = normalize_attrs(attrs)
|
|
block = @block
|
|
@state[:current_tag][name] = true
|
|
case name
|
|
when "Scan" # Start of the thing.
|
|
@state[:report_item] = {}
|
|
when "Name", "StartURL", "StartTime", "Banner", "Os", "Text", "Severity", "CWE", "URL", "Parameter"
|
|
@state[:has_text] = true
|
|
when "LoginSequence" # Skipping for now
|
|
when "ReportItem"
|
|
@state[:report_item] = {}
|
|
when "Crawler"
|
|
record_crawler(attrs)
|
|
when "FullURL"
|
|
@state[:has_text] = true
|
|
when "Variable"
|
|
record_variable(attrs)
|
|
when "Request", "Response"
|
|
@state[:has_text] = true
|
|
end
|
|
end
|
|
|
|
def end_element(name=nil)
|
|
block = @block
|
|
case name
|
|
when "Scan"
|
|
# Clears most of the @state out, we're done with this web site.
|
|
@state.delete_if {|k| k != :current_tag}
|
|
when "Name"
|
|
@state[:has_text] = false
|
|
collect_scan_name
|
|
collect_report_item_name
|
|
@text = nil
|
|
when "StartURL" # Populates @state[:starturl_uri], we use this a lot
|
|
@state[:has_text] = false
|
|
# StartURL does not always include the scheme
|
|
@text.prepend("http://") unless URI.parse(@text).scheme
|
|
collect_host
|
|
collect_service_from_url
|
|
@text = nil
|
|
handle_parse_warnings &block
|
|
@host_object = report_host &block
|
|
if @host_object
|
|
report_starturl_service(&block)
|
|
db.report_import_note(@args[:wspace],@host_object)
|
|
end
|
|
when "StartTime"
|
|
@state[:has_text] = false
|
|
@state[:timestamp] = @text.to_s.tr!(',','').tr!('/','-')
|
|
@text = nil
|
|
when "Text"
|
|
@state[:has_text] = false
|
|
service = collect_service_from_kbitem_text
|
|
@text = nil
|
|
return unless service
|
|
handle_parse_warnings &block
|
|
if @host_object
|
|
report_kbitem_service(service,&block)
|
|
end
|
|
when "Severity"
|
|
@state[:has_text] = false
|
|
collect_report_item_severity
|
|
@text = nil
|
|
when "CWE"
|
|
@state[:has_text] = false
|
|
collect_report_item_cwe
|
|
@text = nil
|
|
when "URL"
|
|
@state[:has_text] = false
|
|
collect_report_item_reference_url
|
|
@text = nil
|
|
when "Parameter"
|
|
@state[:has_text] = false
|
|
collect_report_item_parameter
|
|
@text = nil
|
|
when "ReportItem"
|
|
vuln = collect_vuln_from_report_item
|
|
if vuln.nil?
|
|
@state[:page_request] = @state[:page_response] = nil
|
|
return
|
|
end
|
|
handle_parse_warnings &block
|
|
if @state[:vuln_info][:refs].nil?
|
|
report_web_vuln(&block)
|
|
else
|
|
report_other_vuln(&block)
|
|
end
|
|
@state[:page_request] = @state[:page_response] = nil
|
|
when "Banner"
|
|
@state[:has_text] = false
|
|
collect_and_report_banner
|
|
when "Os"
|
|
@state[:has_text] = false
|
|
report_os_fingerprint
|
|
when "LoginSequence" # This comes up later in the report anyway
|
|
when "Crawler"
|
|
report_starturl_web_site(&block)
|
|
when "FullURL"
|
|
@state[:has_text] = false
|
|
report_web_site(@text,&block)
|
|
@text = nil
|
|
when "Inputs"
|
|
report_web_form(&block)
|
|
when "Request"
|
|
@state[:has_text] = false
|
|
collect_page_request
|
|
@text = nil
|
|
when "Response"
|
|
@state[:has_text] = false
|
|
collect_page_response
|
|
@text = nil
|
|
report_web_page(&block)
|
|
end
|
|
@state[:current_tag].delete name
|
|
end
|
|
|
|
def collect_page_response
|
|
return unless in_tag("TechnicalDetails")
|
|
return unless in_tag("ReportItem")
|
|
return unless @text
|
|
return if @text.to_s.empty?
|
|
@state[:page_response] = @text
|
|
end
|
|
|
|
def collect_page_request
|
|
return unless in_tag("TechnicalDetails")
|
|
return unless in_tag("ReportItem")
|
|
return unless @text
|
|
return if @text.to_s.empty?
|
|
@state[:page_request] = @text
|
|
end
|
|
|
|
def collect_scan_name
|
|
return unless in_tag("Scan")
|
|
return if in_tag("ReportItems")
|
|
return if in_tag("Crawler")
|
|
return unless @text
|
|
return if @text.strip.empty?
|
|
@state[:scan_name] = @text.strip
|
|
end
|
|
|
|
def collect_host
|
|
return unless in_tag("Scan")
|
|
return unless @text
|
|
return if @text.strip.empty?
|
|
uri = URI.parse(@text) rescue nil
|
|
return unless uri
|
|
address = resolve_scan_starturl_address(uri)
|
|
@report_data[:host] = address
|
|
@report_data[:state] = Msf::HostState::Alive
|
|
end
|
|
|
|
def collect_service_from_url
|
|
return unless @report_data[:host]
|
|
return unless in_tag("Scan")
|
|
return unless @text
|
|
return if @text.strip.empty?
|
|
uri = URI.parse(@text) rescue nil
|
|
return unless uri
|
|
@state[:starturl_uri] = uri
|
|
@report_data[:ports] ||= []
|
|
@report_data[:ports] << @state[:starturl_port]
|
|
end
|
|
|
|
def collect_service_from_kbitem_text
|
|
return unless @host_object
|
|
return unless in_tag("Scan")
|
|
return unless in_tag("KBase")
|
|
return unless in_tag("KBItem")
|
|
return unless @text
|
|
return if @text.strip.empty?
|
|
return unless @text =~ /server is running/
|
|
matched = / (?<name>\w+) server is running on (?<proto>\w+) port (?<portnum>\d+)\./.match(@text)
|
|
@report_data[:ports] ||= []
|
|
@report_data[:ports] << matched[:portnum]
|
|
return matched
|
|
end
|
|
|
|
def collect_vuln_from_report_item
|
|
@state[:vuln_info] = nil
|
|
return unless @host_object
|
|
return unless in_tag("Scan")
|
|
return unless in_tag("ReportItems")
|
|
return unless in_tag("ReportItem")
|
|
return unless @state[:report_item][:name]
|
|
return unless @state[:report_item][:severity]
|
|
return unless @state[:report_item][:severity].downcase == "high"
|
|
|
|
@state[:vuln_info] = {}
|
|
@state[:vuln_info][:name] = @state[:report_item][:name]
|
|
if @state[:page_request_verb].nil? && @state[:report_item][:name] =~ /deprecated/
|
|
# Treating this as a regular vuln, not web-specific
|
|
@state[:vuln_info][:refs] = ["ACX-#{@state[:report_item][:reference_url]}"]
|
|
unless @state[:report_item_cwe].nil?
|
|
@state[:vuln_info][:refs][0] << ",#{@state[:report_item][:cwe]}"
|
|
end
|
|
end
|
|
@state[:vuln_info][:severity] = @state[:report_item][:severity].downcase
|
|
@state[:vuln_info][:cwe] = @state[:report_item][:cwe]
|
|
return @state[:vuln_info]
|
|
end
|
|
|
|
def collect_and_report_banner
|
|
return unless (svc = @state[:starturl_service_object]) # Yes i want assignment
|
|
return unless @text
|
|
return if @text.strip.empty?
|
|
return unless in_tag("Scan")
|
|
svc_info = {
|
|
:host => svc.host,
|
|
:port => svc.port,
|
|
:proto => svc.proto,
|
|
:info => @text.strip
|
|
}
|
|
db_report(:service, svc_info)
|
|
@text = nil
|
|
end
|
|
|
|
def collect_report_item_name
|
|
return unless in_tag("ReportItem")
|
|
return unless @text
|
|
return if @text.strip.empty?
|
|
@state[:report_item][:name] = @text
|
|
end
|
|
|
|
def collect_report_item_severity
|
|
return unless in_tag("ReportItem")
|
|
return unless @text
|
|
return if @text.strip.empty?
|
|
@state[:report_item][:severity] = @text
|
|
end
|
|
|
|
def collect_report_item_cwe
|
|
return unless in_tag("ReportItem")
|
|
return unless @text
|
|
return if @text.strip.empty?
|
|
@state[:report_item][:cwe] = @text
|
|
end
|
|
|
|
def collect_report_item_reference_url
|
|
return unless in_tag("ReportItem")
|
|
return unless in_tag("References")
|
|
return unless in_tag("Reference")
|
|
return unless @text
|
|
return if @text.strip.empty?
|
|
@state[:report_item][:reference_url] = @text
|
|
end
|
|
|
|
def collect_report_item_parameter
|
|
return unless in_tag("ReportItem")
|
|
return unless @text
|
|
return if @text.strip.empty?
|
|
@state[:report_item][:parameter] = @text
|
|
end
|
|
|
|
# @state[:fullurl] is set by report_web_site
|
|
def record_variable(attrs)
|
|
return unless in_tag("Inputs")
|
|
return unless @state[:fullurl].kind_of? URI
|
|
method = attr_hash(attrs)["Type"]
|
|
return unless method
|
|
return if method.strip.empty?
|
|
@state[:form_variables] ||= []
|
|
@state[:form_variables] << [attr_hash(attrs)["Name"],method]
|
|
end
|
|
|
|
def record_crawler(attrs)
|
|
return unless in_tag("Scan")
|
|
return unless @state[:starturl_service_object]
|
|
starturl = attr_hash(attrs)["StartUrl"]
|
|
return unless starturl
|
|
@state[:crawler_starturl] = starturl
|
|
end
|
|
|
|
def report_web_form(&block)
|
|
return unless in_tag("SiteFiles")
|
|
return unless @state[:web_site]
|
|
return unless @state[:fullurl].kind_of? URI
|
|
return unless @state[:form_variables].kind_of? Array
|
|
return if @state[:form_variables].empty?
|
|
method = parse_method(@state[:form_variables].first[1])
|
|
vars = @state[:form_variables].map {|x| x[0]}
|
|
form_info = {}
|
|
form_info[:web_site] = @state[:web_site]
|
|
form_info[:path] = @state[:fullurl].path
|
|
form_info[:query] = @state[:fullurl].query
|
|
form_info[:method] = method
|
|
form_info[:params] = vars
|
|
url = @state[:fullurl].to_s
|
|
db.emit(:web_form,url,&block) if block
|
|
db_report(:web_form,form_info)
|
|
@state[:fullurl] = nil
|
|
@state[:form_variables] = nil
|
|
end
|
|
|
|
def report_web_page(&block)
|
|
return if should_skip_this_page
|
|
return unless @state[:web_site]
|
|
@state[:page_request_verb] = nil
|
|
return unless @state[:page_request]
|
|
return if @state[:page_request].strip.empty?
|
|
verb,path,query_string = parse_request(@state[:page_request])
|
|
return unless path
|
|
@state[:page_request_verb] = verb
|
|
web_page_info = {}
|
|
if @state[:page_response].strip.blank?
|
|
web_page_info[:code] = ""
|
|
web_page_info[:headers] = {}
|
|
web_page_info[:body] = ""
|
|
else
|
|
parsed_response = parse_response(@state[:page_response])
|
|
return unless parsed_response
|
|
web_page_info[:code] = parsed_response[:code].to_i
|
|
web_page_info[:headers] = parsed_response[:headers]
|
|
web_page_info[:body] = parsed_response[:body]
|
|
end
|
|
web_page_info[:web_site] = @state[:web_site]
|
|
web_page_info[:path] = path
|
|
web_page_info[:query] = query_string || ""
|
|
url = ""
|
|
url << @state[:web_site].service.name.to_s << "://"
|
|
url << @state[:web_site].vhost.to_s << ":"
|
|
url << path
|
|
uri = URI.parse(url) rescue nil
|
|
return unless uri # Sanity checker
|
|
db.emit(:web_page, url, &block) if block
|
|
web_page_object = db_report(:web_page,web_page_info)
|
|
@state[:web_page] = web_page_object
|
|
end
|
|
|
|
def report_web_vuln(&block)
|
|
return if should_skip_this_page
|
|
return unless @state[:web_page]
|
|
return unless @state[:web_site]
|
|
return unless @state[:vuln_info]
|
|
|
|
web_vuln_info = {}
|
|
web_vuln_info[:web_site] = @state[:web_site]
|
|
web_vuln_info[:path] = @state[:web_page][:path]
|
|
web_vuln_info[:query] = @state[:web_page][:query]
|
|
web_vuln_info[:method] = @state[:page_request_verb]
|
|
web_vuln_info[:pname] = ""
|
|
if @state[:page_response].blank?
|
|
web_vuln_info[:proof] = "<empty response>"
|
|
else
|
|
web_vuln_info[:proof] = @state[:page_response]
|
|
end
|
|
web_vuln_info[:risk] = 5
|
|
web_vuln_info[:params] = []
|
|
unless @state[:report_item][:parameter].blank?
|
|
# Acunetix only lists a single paramter...
|
|
web_vuln_info[:params] << [ @state[:report_item][:parameter].to_s, "" ]
|
|
end
|
|
web_vuln_info[:category] = "imported"
|
|
web_vuln_info[:confidence] = 100
|
|
web_vuln_info[:name] = @state[:vuln_info][:name]
|
|
|
|
db.emit(:web_vuln, web_vuln_info[:name], &block) if block
|
|
vuln = db_report(:web_vuln, web_vuln_info)
|
|
end
|
|
|
|
def report_other_vuln(&block)
|
|
return if should_skip_this_page
|
|
return unless @state[:vuln_info]
|
|
|
|
db.emit(:vuln, @state[:vuln_info][:name], &block) if block
|
|
db_report(:vuln, @state[:vuln_info].merge(:host => @host_object))
|
|
end
|
|
|
|
# Reasons why we shouldn't collect a particular web page.
|
|
def should_skip_this_page
|
|
if @state[:report_item][:name] =~ /Unrestricted File Upload/
|
|
# This means that the page being collected is something the
|
|
# auditor put there, so it's not useful to report on.
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
# XXX Rex::Proto::Http::Packet seems broken for
|
|
# actually parsing requests and responses, but all I
|
|
# need are the headers anyway
|
|
def parse_request(request)
|
|
headers = Rex::Proto::Http::Packet::Header.new
|
|
headers.from_s request.dup # It's destructive.
|
|
return unless headers.cmd_string
|
|
verb,req = headers.cmd_string.split(/\s+/)
|
|
return unless verb
|
|
return unless req
|
|
path,query_string = req.split(/\?/)[0,2]
|
|
return verb,path,query_string
|
|
end
|
|
|
|
def parse_response(response)
|
|
headers = Rex::Proto::Http::Packet::Header.new
|
|
headers.from_s response.dup # It's destructive.
|
|
return unless headers.cmd_string
|
|
http,code,msg = headers.cmd_string.split(/\s+/)
|
|
return unless code
|
|
return unless code.to_i.to_s == code
|
|
parsed = {}
|
|
parsed[:code] = code
|
|
parsed[:headers] = {}
|
|
headers.each do |k,v|
|
|
parsed[:headers][k.to_s.downcase] = []
|
|
parsed[:headers][k.to_s.downcase] << v
|
|
end
|
|
parsed[:body] = "" # We never seem to get this from Acunetix
|
|
parsed
|
|
end
|
|
|
|
# Don't cause the web report to die just because we can't tell
|
|
# what method was used -- default to GET. Sometimes it's just "POST," and
|
|
# sometimes it's "URL encoded POST," and sometimes it might be something
|
|
# else.
|
|
def parse_method(meth)
|
|
verbs = "(GET|POST|PATH)"
|
|
real_method = meth.match(/^\s*#{verbs}/)
|
|
real_method ||= meth.match(/\s*#{verbs}\s*$/)
|
|
( real_method && real_method[1] ) ? real_method[1] : "GET"
|
|
end
|
|
|
|
def report_host(&block)
|
|
return unless @report_data[:host]
|
|
return unless in_tag("Scan")
|
|
if host_is_okay
|
|
db.emit(:address,@report_data[:host],&block) if block
|
|
host_info = @report_data.merge(:workspace => @args[:wspace])
|
|
db_report(:host,host_info)
|
|
end
|
|
end
|
|
|
|
# The service is super important, so we hang on to it for the
|
|
# rest of the scan.
|
|
def report_starturl_service(&block)
|
|
return unless @host_object
|
|
return unless @state[:starturl_uri]
|
|
name = @state[:starturl_uri].scheme
|
|
port = @state[:starturl_uri].port
|
|
addr = @host_object.address
|
|
svc = {
|
|
:host => @host_object,
|
|
:port => port,
|
|
:name => name.dup,
|
|
:proto => "tcp"
|
|
}
|
|
if name and port
|
|
db.emit(:service,[addr,port].join(":"),&block) if block
|
|
@state[:starturl_service_object] = db_report(:service,svc)
|
|
end
|
|
end
|
|
|
|
def report_kbitem_service(service,&block)
|
|
return unless @host_object
|
|
return unless @state[:starturl_uri]
|
|
addr = @host_object.address
|
|
svc = {
|
|
:host => @host_object,
|
|
:port => service[:portnum].to_i,
|
|
:name => service[:name].dup.downcase,
|
|
:proto => service[:proto].dup.downcase
|
|
}
|
|
if service[:name] and service[:portnum]
|
|
db.emit(:service,[addr,service[:portnum]].join(":"),&block) if block
|
|
db_report(:service,svc)
|
|
end
|
|
end
|
|
|
|
def report_web_site(url,&block)
|
|
return unless in_tag("Crawler")
|
|
return unless url
|
|
return if url.strip.empty?
|
|
uri = URI.parse(url) rescue nil
|
|
return unless uri
|
|
host = uri.host
|
|
port = uri.port
|
|
scheme = uri.scheme
|
|
return unless scheme[/^https?/]
|
|
return unless (host && port && scheme)
|
|
address = resolve_address(host)
|
|
return unless address
|
|
# If we didn't create the service, we don't care about the site
|
|
service_object = db.get_service @args[:wspace], address, "tcp", port
|
|
return unless service_object
|
|
web_site_info = {
|
|
:workspace => @args[:wspace],
|
|
:service => service_object,
|
|
:vhost => host,
|
|
:ssl => (scheme == "https")
|
|
}
|
|
@state[:web_site] = db_report(:web_site,web_site_info)
|
|
@state[:fullurl] = uri
|
|
end
|
|
|
|
def report_starturl_web_site(&block)
|
|
return unless @state[:crawler_starturl]
|
|
starturl = @state[:crawler_starturl].dup
|
|
report_web_site(starturl,&block)
|
|
end
|
|
|
|
def report_os_fingerprint
|
|
return unless @state[:starturl_service_object]
|
|
return unless @text
|
|
return if @text.strip.empty?
|
|
return unless in_tag("Scan")
|
|
host = @state[:starturl_service_object].host
|
|
fp_note = {
|
|
:workspace => host.workspace,
|
|
:host => host,
|
|
:type => 'host.os.acunetix_fingerprint',
|
|
:data => {:os => @text}
|
|
}
|
|
db_report(:note, fp_note)
|
|
@text = nil
|
|
end
|
|
|
|
def resolve_port(uri)
|
|
@state[:port] = uri.port
|
|
unless @state[:port]
|
|
@parse_warnings << "Could not determine a port for '#{@state[:scan_name]}'"
|
|
end
|
|
@state[:port] = uri.port
|
|
end
|
|
|
|
def resolve_address(host)
|
|
return @resolv_cache[host] if @resolv_cache[host]
|
|
address = Rex::Socket.resolv_to_dotted(host) rescue nil
|
|
@resolv_cache[host] = address
|
|
return address
|
|
end
|
|
|
|
def resolve_scan_starturl_address(uri)
|
|
if uri.host
|
|
address = resolve_address(uri.host)
|
|
unless address
|
|
@parse_warnings << "Could not resolve address for '#{uri.host}', skipping '#{@state[:scan_name]}'"
|
|
end
|
|
else
|
|
@parse_warnings << "Could not determine a host for '#{@state[:scan_name]}'"
|
|
end
|
|
address
|
|
end
|
|
|
|
def handle_parse_warnings(&block)
|
|
return if @parse_warnings.empty?
|
|
@parse_warnings.each do |pwarn|
|
|
db.emit(:warning, pwarn, &block) if block
|
|
end
|
|
end
|
|
|
|
end
|
|
end
|
|
end
|
|
|