Path: blob/master/lib/rex/parser/acunetix_document.rb
52660 views
# -*- coding: binary -*-1require "rex/parser/nokogiri_doc_mixin"2require 'rex'3require 'uri'45module Rex6module Parser78# If Nokogiri is available, define the Acunetix document class.9load_nokogiri && class AcunetixDocument < Nokogiri::XML::SAX::Document1011include NokogiriDocMixin1213# The resolver prefers your local /etc/hosts (or windows equiv), but will14# fall back to regular DNS. It retains a cache for the import to avoid15# spamming your network with DNS requests.16attr_reader :resolv_cache1718# If name resolution of the host fails out completely, you will not be19# able to import that Scan task. Other scan tasks in the same report20# should be unaffected.21attr_reader :parse_warnings2223def start_document24@parse_warnings = []25@resolv_cache = {}26@host_object = nil27end2829def start_element(name=nil,attrs=[])30attrs = normalize_attrs(attrs)31block = @block32@state[:current_tag][name] = true33case name34when "Scan" # Start of the thing.35@state[:report_item] = {}36when "Name", "StartURL", "StartTime", "Banner", "Os", "Text", "Severity", "CWE", "URL", "Parameter"37@state[:has_text] = true38when "LoginSequence" # Skipping for now39when "ReportItem"40@state[:report_item] = {}41when "Crawler"42record_crawler(attrs)43when "FullURL"44@state[:has_text] = true45when "Variable"46record_variable(attrs)47when "Request", "Response"48@state[:has_text] = true49end50end5152def end_element(name=nil)53block = @block54case name55when "Scan"56# Clears most of the @state out, we're done with this web site.57@state.delete_if {|k| k != :current_tag}58when "Name"59@state[:has_text] = false60collect_scan_name61collect_report_item_name62@text = nil63when "StartURL" # Populates @state[:starturl_uri], we use this a lot64@state[:has_text] = false65# StartURL does not always include the scheme66@text.prepend("http://") unless URI.parse(@text).scheme67collect_host68collect_service_from_url69@text = nil70handle_parse_warnings &block71@host_object = report_host &block72if @host_object73report_starturl_service(&block)74db.report_import_note(@args[:workspace],@host_object)75end76when "StartTime"77@state[:has_text] = false78@state[:timestamp] = @text.to_time79@text = nil80when "Text"81@state[:has_text] = false82service = collect_service_from_kbitem_text83@text = nil84return unless service85handle_parse_warnings &block86if @host_object87report_kbitem_service(service,&block)88end89when "Severity"90@state[:has_text] = false91collect_report_item_severity92@text = nil93when "CWE"94@state[:has_text] = false95collect_report_item_cwe96@text = nil97when "URL"98@state[:has_text] = false99collect_report_item_reference_url100@text = nil101when "Parameter"102@state[:has_text] = false103collect_report_item_parameter104@text = nil105when "ReportItem"106vuln = collect_vuln_from_report_item107if vuln.nil?108@state[:page_request] = @state[:page_response] = nil109return110end111handle_parse_warnings &block112if @state[:vuln_info][:refs].nil?113report_web_vuln(&block)114else115report_other_vuln(&block)116end117@state[:page_request] = @state[:page_response] = nil118when "Banner"119@state[:has_text] = false120collect_and_report_banner121when "Os"122@state[:has_text] = false123report_os_fingerprint124when "LoginSequence" # This comes up later in the report anyway125when "Crawler"126report_starturl_web_site(&block)127when "FullURL"128@state[:has_text] = false129report_web_site(@text,&block)130@text = nil131when "Inputs"132report_web_form(&block)133when "Request"134@state[:has_text] = false135collect_page_request136@text = nil137when "Response"138@state[:has_text] = false139collect_page_response140@text = nil141report_web_page(&block)142end143@state[:current_tag].delete name144end145146def collect_page_response147return unless in_tag("TechnicalDetails")148return unless in_tag("ReportItem")149return unless @text150return if @text.to_s.empty?151@state[:page_response] = @text152end153154def collect_page_request155return unless in_tag("TechnicalDetails")156return unless in_tag("ReportItem")157return unless @text158return if @text.to_s.empty?159@state[:page_request] = @text160end161162def collect_scan_name163return unless in_tag("Scan")164return if in_tag("ReportItems")165return if in_tag("Crawler")166return unless @text167return if @text.strip.empty?168@state[:scan_name] = @text.strip169end170171def collect_host172return unless in_tag("Scan")173return unless @text174return if @text.strip.empty?175uri = URI.parse(@text) rescue nil176return unless uri177address = resolve_scan_starturl_address(uri)178@report_data[:host] = address179@report_data[:state] = Msf::HostState::Alive180end181182def collect_service_from_url183return unless @report_data[:host]184return unless in_tag("Scan")185return unless @text186return if @text.strip.empty?187uri = URI.parse(@text) rescue nil188return unless uri189@state[:starturl_uri] = uri190@report_data[:ports] ||= []191@report_data[:ports] << @state[:starturl_port]192end193194def collect_service_from_kbitem_text195return unless @host_object196return unless in_tag("Scan")197return unless in_tag("KBase")198return unless in_tag("KBItem")199return unless @text200return if @text.strip.empty?201return unless @text =~ /server is running/202matched = / (?<name>\w+) server is running on (?<proto>\w+) port (?<portnum>\d+)\./.match(@text)203@report_data[:ports] ||= []204@report_data[:ports] << matched[:portnum]205return matched206end207208def collect_vuln_from_report_item209@state[:vuln_info] = nil210return unless @host_object211return unless in_tag("Scan")212return unless in_tag("ReportItems")213return unless in_tag("ReportItem")214return unless @state[:report_item][:name]215return unless @state[:report_item][:severity]216return unless @state[:report_item][:severity].downcase == "high"217218@state[:vuln_info] = {}219@state[:vuln_info][:name] = @state[:report_item][:name]220if @state[:page_request_verb].nil? && @state[:report_item][:name] =~ /deprecated/221# Treating this as a regular vuln, not web-specific222@state[:vuln_info][:refs] = ["ACX-#{@state[:report_item][:reference_url]}"]223unless @state[:report_item_cwe].nil?224@state[:vuln_info][:refs][0] << ",#{@state[:report_item][:cwe]}"225end226end227@state[:vuln_info][:severity] = @state[:report_item][:severity].downcase228@state[:vuln_info][:cwe] = @state[:report_item][:cwe]229return @state[:vuln_info]230end231232def collect_and_report_banner233return unless (svc = @state[:starturl_service_object]) # Yes i want assignment234return unless @text235return if @text.strip.empty?236return unless in_tag("Scan")237svc_info = {238:host => svc.host,239:port => svc.port,240:proto => svc.proto,241:info => @text.strip242}243db_report(:service, svc_info)244@text = nil245end246247def collect_report_item_name248return unless in_tag("ReportItem")249return unless @text250return if @text.strip.empty?251@state[:report_item][:name] = @text252end253254def collect_report_item_severity255return unless in_tag("ReportItem")256return unless @text257return if @text.strip.empty?258@state[:report_item][:severity] = @text259end260261def collect_report_item_cwe262return unless in_tag("ReportItem")263return unless @text264return if @text.strip.empty?265@state[:report_item][:cwe] = @text266end267268def collect_report_item_reference_url269return unless in_tag("ReportItem")270return unless in_tag("References")271return unless in_tag("Reference")272return unless @text273return if @text.strip.empty?274@state[:report_item][:reference_url] = @text275end276277def collect_report_item_parameter278return unless in_tag("ReportItem")279return unless @text280return if @text.strip.empty?281@state[:report_item][:parameter] = @text282end283284# @state[:fullurl] is set by report_web_site285def record_variable(attrs)286return unless in_tag("Inputs")287return unless @state[:fullurl].kind_of? URI288method = attr_hash(attrs)["Type"]289return unless method290return if method.strip.empty?291@state[:form_variables] ||= []292@state[:form_variables] << [attr_hash(attrs)["Name"],method]293end294295def record_crawler(attrs)296return unless in_tag("Scan")297return unless @state[:starturl_service_object]298starturl = attr_hash(attrs)["StartUrl"]299return unless starturl300@state[:crawler_starturl] = starturl301end302303def report_web_form(&block)304return unless in_tag("SiteFiles")305return unless @state[:web_site]306return unless @state[:fullurl].kind_of? URI307return unless @state[:form_variables].kind_of? Array308return if @state[:form_variables].empty?309method = parse_method(@state[:form_variables].first[1])310vars = @state[:form_variables].map {|x| x[0]}311form_info = {}312form_info[:web_site] = @state[:web_site]313form_info[:path] = @state[:fullurl].path314form_info[:query] = @state[:fullurl].query315form_info[:method] = method316form_info[:params] = vars317url = @state[:fullurl].to_s318db.emit(:web_form,url,&block) if block319db_report(:web_form,form_info)320@state[:fullurl] = nil321@state[:form_variables] = nil322end323324def report_web_page(&block)325return if should_skip_this_page326return unless @state[:web_site]327@state[:page_request_verb] = nil328return unless @state[:page_request]329return if @state[:page_request].strip.empty?330verb,path,query_string = parse_request(@state[:page_request])331return unless path332@state[:page_request_verb] = verb333web_page_info = {}334if @state[:page_response].strip.blank?335web_page_info[:code] = ""336web_page_info[:headers] = {}337web_page_info[:body] = ""338else339parsed_response = parse_response(@state[:page_response])340return unless parsed_response341web_page_info[:code] = parsed_response[:code].to_i342web_page_info[:headers] = parsed_response[:headers]343web_page_info[:body] = parsed_response[:body]344end345web_page_info[:web_site] = @state[:web_site]346web_page_info[:path] = path347web_page_info[:query] = query_string || ""348url = ""349url << @state[:web_site].service.name.to_s << "://"350url << @state[:web_site].vhost.to_s << ":"351url << path352uri = URI.parse(url) rescue nil353return unless uri # Sanity checker354db.emit(:web_page, url, &block) if block355web_page_object = db_report(:web_page,web_page_info)356@state[:web_page] = web_page_object357end358359def report_web_vuln(&block)360return if should_skip_this_page361return unless @state[:web_site]362return unless @state[:vuln_info]363364# If we have a web_page, report as a web vulnerability (detailed)365if @state[:web_page]366web_vuln_info = {}367web_vuln_info[:web_site] = @state[:web_site]368web_vuln_info[:path] = @state[:web_page][:path]369web_vuln_info[:query] = @state[:web_page][:query]370web_vuln_info[:method] = @state[:page_request_verb]371web_vuln_info[:pname] = ""372if @state[:page_response].blank?373web_vuln_info[:proof] = "<empty response>"374else375web_vuln_info[:proof] = @state[:page_response]376end377web_vuln_info[:risk] = 5378web_vuln_info[:params] = []379unless @state[:report_item][:parameter].blank?380# Acunetix only lists a single parameter...381web_vuln_info[:params] << [ @state[:report_item][:parameter].to_s, "" ]382end383web_vuln_info[:category] = "imported"384web_vuln_info[:confidence] = 100385web_vuln_info[:name] = @state[:vuln_info][:name]386387db.emit(:web_vuln, web_vuln_info[:name], &block) if block388vuln = db_report(:web_vuln, web_vuln_info)389else390# If web_page is not available, report as a regular vulnerability391# This allows vulnerabilities to be imported even without complete request/response data392report_other_vuln(&block)393end394end395396def report_other_vuln(&block)397return if should_skip_this_page398return unless @state[:vuln_info]399400db.emit(:vuln, @state[:vuln_info][:name], &block) if block401db_report(:vuln, @state[:vuln_info].merge(:host => @host_object))402end403404# Reasons why we shouldn't collect a particular web page.405def should_skip_this_page406if @state[:report_item][:name] =~ /Unrestricted File Upload/407# This means that the page being collected is something the408# auditor put there, so it's not useful to report on.409return true410end411return false412end413414# XXX Rex::Proto::Http::Packet seems broken for415# actually parsing requests and responses, but all I416# need are the headers anyway417def parse_request(request)418headers = Rex::Proto::Http::Packet::Header.new419headers.from_s request.dup # It's destructive.420return unless headers.cmd_string421verb,req = headers.cmd_string.split(/\s+/)422return unless verb423return unless req424path,query_string = req.split('?')[0,2]425return verb,path,query_string426end427428def parse_response(response)429headers = Rex::Proto::Http::Packet::Header.new430headers.from_s response.dup # It's destructive.431return unless headers.cmd_string432http,code,msg = headers.cmd_string.split(/\s+/)433return unless code434return unless code.to_i.to_s == code435parsed = {}436parsed[:code] = code437parsed[:headers] = {}438headers.each do |k,v|439parsed[:headers][k.to_s.downcase] = []440parsed[:headers][k.to_s.downcase] << v441end442parsed[:body] = "" # We never seem to get this from Acunetix443parsed444end445446# Don't cause the web report to die just because we can't tell447# what method was used -- default to GET. Sometimes it's just "POST," and448# sometimes it's "URL encoded POST," and sometimes it might be something449# else.450def parse_method(meth)451verbs = "(GET|POST|PATH)"452real_method = meth.match(/^\s*#{verbs}/)453real_method ||= meth.match(/\s*#{verbs}\s*$/)454( real_method && real_method[1] ) ? real_method[1] : "GET"455end456457def report_host(&block)458return unless @report_data[:host]459return unless in_tag("Scan")460if host_is_okay461db.emit(:address,@report_data[:host],&block) if block462host_info = @report_data.merge(:workspace => @args[:workspace])463db_report(:host,host_info)464end465end466467# The service is super important, so we hang on to it for the468# rest of the scan.469def report_starturl_service(&block)470return unless @host_object471return unless @state[:starturl_uri]472name = @state[:starturl_uri].scheme473port = @state[:starturl_uri].port474addr = @host_object.address475svc = {476:host => @host_object,477:port => port,478:name => name.dup,479:proto => "tcp"480}481if name and port482db.emit(:service,[addr,port].join(":"),&block) if block483@state[:starturl_service_object] = db_report(:service,svc)484end485end486487def report_kbitem_service(service,&block)488return unless @host_object489return unless @state[:starturl_uri]490addr = @host_object.address491svc = {492:host => @host_object,493:port => service[:portnum].to_i,494:name => service[:name].dup.downcase,495:proto => service[:proto].dup.downcase496}497if service[:name] and service[:portnum]498db.emit(:service,[addr,service[:portnum]].join(":"),&block) if block499db_report(:service,svc)500end501end502503def report_web_site(url,&block)504return unless in_tag("Crawler")505return unless url506return if url.strip.empty?507uri = URI.parse(url) rescue nil508return unless uri509host = uri.host510port = uri.port511scheme = uri.scheme512return unless scheme[/^https?/]513return unless (host && port && scheme)514address = resolve_address(host)515return unless address516# If we didn't create the service, we don't care about the site517service_object = db.services(:workspace => @args[:workspace],518:hosts => {address: address},519:proto => 'tcp',520:port => port).first521return unless service_object522web_site_info = {523:workspace => @args[:workspace],524:service => service_object,525:vhost => host,526:ssl => (scheme == "https")527}528@state[:web_site] = db_report(:web_site,web_site_info)529@state[:fullurl] = uri530end531532def report_starturl_web_site(&block)533return unless @state[:crawler_starturl]534starturl = @state[:crawler_starturl].dup535report_web_site(starturl,&block)536end537538def report_os_fingerprint539return unless @state[:starturl_service_object]540return unless @text541return if @text.strip.empty?542return unless in_tag("Scan")543host = @state[:starturl_service_object].host544fp_note = {545:workspace => host.workspace,546:host => host,547:type => 'host.os.acunetix_fingerprint',548:data => {:os => @text}549}550db_report(:note, fp_note)551@text = nil552end553554def resolve_port(uri)555@state[:port] = uri.port556unless @state[:port]557@parse_warnings << "Could not determine a port for '#{@state[:scan_name]}'"558end559@state[:port] = uri.port560end561562def resolve_address(host)563return @resolv_cache[host] if @resolv_cache[host]564address = Rex::Socket.resolv_to_dotted(host) rescue nil565@resolv_cache[host] = address566return address567end568569def resolve_scan_starturl_address(uri)570if uri.host571address = resolve_address(uri.host)572unless address573@parse_warnings << "Could not resolve address for '#{uri.host}', skipping '#{@state[:scan_name]}'"574end575else576@parse_warnings << "Could not determine a host for '#{@state[:scan_name]}'"577end578address579end580581def handle_parse_warnings(&block)582return if @parse_warnings.empty?583@parse_warnings.each do |pwarn|584db.emit(:warning, pwarn, &block) if block585end586end587588end589end590end591592593