lib/arachni/element/capabilities/auditable/rdiff.rb in arachni-0.4.5.2 vs lib/arachni/element/capabilities/auditable/rdiff.rb in arachni-0.4.6

- old
+ new

@@ -1,7 +1,7 @@ =begin - Copyright 2010-2013 Tasos Laskos <tasos.laskos@gmail.com> + Copyright 2010-2014 Tasos Laskos <tasos.laskos@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at @@ -16,60 +16,71 @@ module Arachni module Element::Capabilities +module Auditable + +# Performs boolean injection and behavioral analysis (using differential analysis +# techniques based on {Support::Signature} comparisons) in order to determine +# whether the web application is responding to the injected data and how. # -# Performs boolean, fault injection and behavioral analysis (using the rDiff algorithm) -# in order to determine whether the web application is responding to the injected data and how. +# If the behavior can be manipulated by the injected data in ways that it's not +# supposed to (like when evaluating injected code) then the element is deemed +# vulnerable. # -# If the behavior can be manipulated by the injected data in ways that it's not supposed to -# (like when evaluating injected code) then the element is deemed vulnerable. -# # @author Tasos "Zapotek" Laskos <tasos.laskos@gmail.com> -# -module Auditable::RDiff +module RDiff - def self.included( mod ) - # the rdiff attack performs it own redundancy checks so we need this to - # keep track of audited elements - @@rdiff_audited ||= Support::LookUp::HashSet.new - end - RDIFF_OPTIONS = { - # append our seeds to the default values - format: [Mutable::Format::APPEND], + # Append our seeds to the default values. + format: [Mutable::Format::STRAIGHT], - # allow duplicate requests - redundant: true, + # Amount of refinement operations to remove context-irrelevant dynamic + # content -- like banners etc. + precision: 2, - # amount of rdiff iterations - precision: 2, + # Override global fuzzing settings and only use the default method of the + # element under audit. + respect_method: true, - respect_method: true + # Don't generate or submit any mutations with default or sample inputs. + skip_orig: true, + + # Allow redundant audits, we need multiple ones for noise-reduction. + redundant: true, + + # Don't let #audit print output, we'll handle that ourselves. + silent: true, + + # Default value for a forceful 'false' response. + false: '-1' } # # Performs differential analysis and logs an issue should there be one. # # opts = { + # false: 'false resource id', # pairs: [ # { 'true expression' => 'false expression' } # ] # } # # element.rdiff_analysis( opts ) # # Here's how it goes: # - # * let `control` be the control/control response - # * let `true_response` be the response of the injection of 'true expression' - # * let `false_response` be the response of the injection of 'false expression' + # * let `control` be the response of the injection of 'false resource id' + # * let `true_response` be the response of the injection of 'true expression' + # * let `false_response` be the response of the injection of 'false expression' + # * let `control_verification` be a fresh control # # A vulnerability is logged if: # - # control == true_response AND true_response.code == 200 AND false_response != true_response + # control == control_verification && control == false_response AND + # true_response.code == 200 AND false_response != true_response # # The `bool` response is also checked in order to determine if it's a custom # 404, if it is then it'll be skipped. # # If a block has been provided analysis and logging will be delegated to it. @@ -80,154 +91,399 @@ # @option opts [Integer] :precision # Amount of {String#rdiff refinement} iterations to perform. # @option opts [Array<Hash>] :pairs # Pair of strings that should yield different results when interpreted. # Keys should be the `true` expressions. - # @param [Block] block - # To be used for custom analysis of gathered data. + # @option opts [String] :false + # A string which would illicit a 'false' response but without any code. # # @return [Bool] # `true` if the audit was scheduled successfully, `false` otherwise (like # if the resource is out of scope or already audited). # - def rdiff_analysis( opts = {}, &block ) + def rdiff_analysis( opts = {} ) + return if self.auditable.empty? + + return false if audited? audit_id + audited audit_id + if skip_path? self.action print_debug "Element's action matches skip rule, bailing out." return false end opts = self.class::MUTATION_OPTIONS.merge( RDIFF_OPTIONS.merge( opts ) ) - return false if auditable.empty? + mutations_size = 0 + each_mutation( opts[:false], opts ) { mutations_size += 1 } + mutations_size *= opts[:precision] - # Don't continue if there's a missing value. - auditable.values.each { |val| return if val.to_s.empty? } + @data_gathering = { + mutations_size: mutations_size, + expected_responses: mutations_size + (mutations_size * opts[:pairs].size * 2), + received_responses: 0, + done: false, + controls: {} + } - return false if rdiff_audited? - rdiff_audited + # Holds all the data from the probes. + signatures = { + # Control baseline per input. + controls: {}, - responses = {} - control = nil + # Verification control baseline per input. + controls_verification: {}, + + # Corrupted baselines per input. + corrupted: {}, + + # Rest of the data are dynamically populated using input pairs + # as keys. + } + + # Populate the baseline/control forced-false signatures. + populate_control_signatures( opts, signatures ) + + http.after_run do + # Populate the 'true' signatures. + populate_true_signatures( opts, signatures ) + + # Populate the 'false' signatures. + populate_false_signatures( opts, signatures ) + end + + true + end + + private + + # Performs requests using the 'false' control seed and generates/stores + # signatures based on the response bodies. + def populate_control_signatures( opts, signatures ) + gathered = {} opts[:precision].times do - # Get the default response. - submit do |res| - if control - print_status 'Got default/control response.' + audit( opts[:false], opts ) do |res, _, elem| + altered_hash = elem.altered.hash + + next if signatures[:corrupted][altered_hash] + + gathered[altered_hash] ||= 0 + gathered[altered_hash] += 1 + + response_check( res, signatures, elem ) + + if gathered[altered_hash] == @data_gathering[:mutations_size] + print_status "Got default/control response for #{elem.type} " + + "variable '#{elem.altered}' with action '#{elem.action}'." + + @data_gathering[:controls][altered_hash] = true end - # Remove context-irrelevant dynamic content like banners and such. - control = (control ? control.rdiff( res.body ) : res.body) + # Create a signature from the response body and refine it with + # subsequent ones to remove noise (like context-irrelevant dynamic + # content such as banners etc.). + signatures[:controls][altered_hash] = + signatures[:controls][altered_hash] ? + signatures[:controls][altered_hash].refine!(res.body) : + Support::Signature.new(res.body) + + @data_gathering[:received_responses] += 1 + + finalize_if_done( opts, signatures ) end end + end + # Performs requests using the 'true' seeds and generates/stores signatures + # based on the response bodies. + def populate_true_signatures( opts, signatures ) + gathered = {} + opts[:pairs].each do |pair| - responses[pair] ||= {} - true_expr, false_expr = pair.to_a.first + pair_hash = pair.hash + signatures[pair_hash] ||= {} + @data_gathering[pair_hash] ||= {} + gathered[pair_hash] ||= {} + + true_expr = pair.to_a.first[0] + + print_status "Gathering 'true' data for #{self.type} with " << + "action '#{self.action}' using seed: #{true_expr}" + opts[:precision].times do - mutations( true_expr, opts ).each do |elem| - print_status elem.status_string + audit( true_expr, opts ) do |res, _, elem| + altered_hash = elem.altered.hash - # Submit the mutation and store the response. - elem.submit( opts ) do |res| - if responses[pair][elem.altered][:true] - elem.print_status "Gathering data for '#{elem.altered}' " << - "#{type} input -- Got true response:" << - " #{true_expr}" - end + gathered[pair_hash][altered_hash] ||= 0 + gathered[pair_hash][altered_hash] += 1 - responses[pair][elem.altered] ||= {} - responses[pair][elem.altered][:mutation] = elem + signatures[pair_hash][altered_hash] ||= {} + @data_gathering[pair_hash][altered_hash] ||= {} - # Keep the latest response for the {Arachni::Issue}. - responses[pair][elem.altered][:response] = res - responses[pair][elem.altered][:injected_string] = true_expr + next if signatures[pair_hash][altered_hash][:corrupted] || + signatures[:corrupted][altered_hash] - responses[pair][elem.altered][:true] ||= res.body.clone - # Remove context-irrelevant dynamic content like banners - # and such from the error page. - responses[pair][elem.altered][:true] = - responses[pair][elem.altered][:true].rdiff( res.body.clone ) + response_check( res, signatures, elem, pair_hash ) + + next if signature_sieve( altered_hash, signatures, pair_hash ) + + if gathered[pair_hash][altered_hash] == opts[:precision] + elem.print_status "Got 'true' response for #{elem.type} " << + "variable '#{elem.altered}' with action '#{elem.action}'" << + " using seed: #{true_expr}" + @data_gathering[pair_hash][altered_hash][:true_probes] = true end + + # Store the mutation for the {Arachni::Issue}. + signatures[pair_hash][altered_hash][:mutation] = elem + + # Keep the latest response for the {Arachni::Issue}. + signatures[pair_hash][altered_hash][:response] = res + + signatures[pair_hash][altered_hash][:injected_string] = true_expr + + # Create a signature from the response body and refine it with + # subsequent ones to remove noise (like context-irrelevant dynamic + # content such as banners etc.). + signatures[pair_hash][altered_hash][:true] = + signatures[pair_hash][altered_hash][:true] ? + signatures[pair_hash][altered_hash][:true].refine!(res.body) : + Support::Signature.new(res.body) + + signature_sieve( altered_hash, signatures, pair_hash ) + + @data_gathering[:received_responses] += 1 + finalize_if_done( opts, signatures ) end + end + end + end - mutations( false_expr, opts ).each do |elem| - responses[pair][elem.altered] ||= {} + # Performs requests using the 'false' seeds and generates/stores signatures + # based on the response bodies. + def populate_false_signatures( opts, signatures ) + gathered = {} - # Submit the mutation and store the response. - elem.submit( opts ) do |res| - if responses[pair][elem.altered][:false] - elem.print_status "Gathering data for '#{elem.altered}'" << - " #{type} input -- Got false " << - "response: #{false_expr}" - end + opts[:pairs].each do |pair| + pair_hash = pair.hash - responses[pair][elem.altered][:false] ||= res.body.clone + signatures[pair_hash] ||= {} + @data_gathering[pair_hash] ||= {} + gathered[pair_hash] ||= {} - # Remove context-irrelevant dynamic content like banners - # and such from the error page. - responses[pair][elem.altered][:false] = - responses[pair][elem.altered][:false].rdiff( res.body.clone ) + false_expr = pair.to_a.first[1] + + print_status "Gathering 'false' data for #{self.type} with " << + "action '#{self.action}' using seed: #{false_expr}" + + opts[:precision].times do + audit( false_expr, opts ) do |res, _, elem| + altered_hash = elem.altered.hash + + gathered[pair_hash][altered_hash] ||= 0 + gathered[pair_hash][altered_hash] += 1 + + signatures[pair_hash][altered_hash] ||= {} + @data_gathering[pair_hash][altered_hash] ||= {} + + next if signatures[pair_hash][altered_hash][:corrupted] || + signatures[:corrupted][altered_hash] + + response_check( res, signatures, elem, pair_hash ) + + next if signature_sieve( altered_hash, signatures, pair_hash ) + + if gathered[pair_hash][altered_hash] == opts[:precision] + elem.print_status "Got 'false' response for #{elem.type} " << + "variable '#{elem.altered}' with action '#{elem.action}'" << + " using seed: #{false_expr}" + @data_gathering[pair_hash][altered_hash][:false_probes] = true end + + # Create a signature from the response body and refine it with + # subsequent ones to remove noise (like context-irrelevant dynamic + # content such as banners etc.). + signatures[pair_hash][altered_hash][:false] = + signatures[pair_hash][altered_hash][:false] ? + signatures[pair_hash][altered_hash][:false].refine!(res.body) : + Support::Signature.new(res.body) + + signature_sieve( altered_hash, signatures, pair_hash ) + + @data_gathering[:received_responses] += 1 + finalize_if_done( opts, signatures ) end end end + end + # Check if we're done with data gathering and proceed to establishing a + # {#populate_control_verification_signatures verification control baseline} + # and {#match_signatures final analysis}. + def finalize_if_done( opts, signatures ) + return if @data_gathering[:done] || + @data_gathering[:expected_responses] != @data_gathering[:received_responses] + @data_gathering[:done] = true - # When this runs the "responses" hash will have been populated and we - # can continue with analysis. - http.after_run do - responses.each do |pair, data| - if block - exception_jail( false ){ block.call( pair, data ) } + # Lastly, we need to re-establish a new baseline in order to compare + # it with the initial one so as to be sure that server behavior + # hasn't suddenly changed in a way that would corrupt our analysis. + populate_control_verification_signatures( opts, signatures ) + end + + # Re-establishes a control baseline at the end of the audit, to make sure + # that website behavior has remained stable, otherwise its behavior won't + # be trustworthy. + def populate_control_verification_signatures( opts, signatures ) + received_responses = 0 + gathered = {} + + opts[:precision].times do + audit( opts[:false], opts ) do |res, _, elem| + altered_hash = elem.altered.hash + + gathered[altered_hash] ||= 0 + gathered[altered_hash] += 1 + + next if signatures[:corrupted][altered_hash] + + response_check( res, signatures, elem ) + + if gathered[altered_hash] == opts[:precision] + print_status 'Got control verification response ' << + "for #{elem.type} variable '#{elem.altered}' with" << + " action '#{elem.action}'." + end + + # Create a signature from the response body and refine it with + # subsequent ones to remove noise (like context-irrelevant dynamic + # content such as banners etc.). + signatures[:controls_verification][altered_hash] = + signatures[:controls_verification][altered_hash] ? + signatures[:controls_verification][altered_hash].refine!(res.body) : + Support::Signature.new(res.body) + + received_responses += 1 + next if received_responses != @data_gathering[:mutations_size] + + # Once the new baseline has been established and we've got all the + # data we need, crunch them and see if server behavior indicates + # a vulnerability. + match_signatures( signatures ) + end + end + end + + def match_signatures( signatures ) + controls = signatures.delete( :controls ) + controls_verification = signatures.delete( :controls_verification ) + corrupted = signatures.delete( :corrupted ) + + signatures.each do |_, data| + data.each do |input, result| + next if result[:corrupted] || corrupted[input] + + # If the initial and verification baselines differ, bail out; + # server behavior is too unstable. + if controls[input] != controls_verification[input] + result[:mutation].print_bad 'Control baseline too unstable, ' << + "aborting analysis for #{result[:mutation].type} " << + "variable '#{result[:mutation].altered}' with action " << + "'#{result[:mutation].action}'" next end - data.each do |input_name, result| - # if default_response_body == true_response_body AND - # false_response_body != true_response_code AND - # true_response_code == 200 - if control == result[:true] && - result[:false] != result[:true] && - result[:response].code == 200 + # To have gotten here the following must be true: + # + # force_false_baseline == false_response_body AND + # false_response_body != true_response_body AND + # force_false_response_code == 200 AND + # true_response_code == 200 AND + # false_response_code == 200 - # Check to see if the `true` response we're analyzing - # is a custom 404 page. - http.custom_404?( result[:response] ) do |custom_404| - # If this is a custom 404 page bail out. - next if custom_404 + # Check to see if the `true` response we're analyzing + # is a custom 404 page. + http.custom_404?( result[:response] ) do |is_custom_404| + # If this is a custom 404 page bail out. + next if is_custom_404 - @auditor.log({ - var: input_name, - opts: { - injected_orig: result[:injected_string], - combo: result[:mutation].auditable - }, - injected: result[:mutation].altered_value, - elem: type - }, result[:response] - ) - end - end + @auditor.log({ + var: result[:mutation].altered, + opts: { + injected_orig: result[:injected_string], + combo: result[:mutation].auditable + }, + injected: result[:mutation].altered_value, + elem: type + }, result[:response] + ) end end end - - true end - private - def rdiff_audited - @@rdiff_audited << rdiff_audit_id - end + def response_check( response, signatures, elem, pair = nil ) + corrupted = false - def rdiff_audited? - @@rdiff_audited.include?( rdiff_audit_id ) + if response.code != 200 + print_status 'Server returned non 200 status,' << + " aborting analysis for #{elem.type} variable " << + "'#{elem.altered}' with action '#{elem.action}'." + corrupted = true + end + + if response.body.to_s.empty? + print_status 'Server returned empty response body,' << + " aborting analysis for #{elem.type} variable " << + "'#{elem.altered}' with action '#{self.action}'." + corrupted = true + end + + return if !corrupted + + if pair + signatures[pair][elem.altered.hash][:corrupted] = true + else + signatures[:corrupted][elem.altered.hash] = true + end end - def rdiff_audit_id - @action + @auditable.keys.to_s + def signature_sieve( input, signatures, pair ) + gathered = @data_gathering[pair][input] + signature = signatures[pair][input] + + # If data has been corrupted for the given input, remove it. + if signature[:corrupted] + signatures[pair].delete( input ) + return true + end + + # 1st check: force_false_baseline == false_response_body + # + # * Make sure the necessary data has been gathered. + # * Remove the data if forced-false and boolean-false signatures + # don't match. + if (@data_gathering[:controls][input] && gathered[:false_probes]) && + (signatures[:controls][input] != signature[:false]) + signatures[pair].delete( input ) + return true + end + + # 2nd check: false_response_baseline != true_response_baseline + # + # * Make sure the necessary data has been gathered. + # * Remove the data if boolean-false and boolean-true signatures + # are too similar. + if (gathered[:false_probes] && gathered[:true_probes]) && + signature[:false].similar?( signature[:true], 5 ) + signatures[pair].delete( input ) + return true + end + + false end +end end end end