First commit for more generic enumerating and scanning

This commit is contained in:
Christian Mehlmauer
2012-09-15 20:30:06 +02:00
parent bf940b2065
commit 8bc9f47cc7
10 changed files with 370 additions and 192 deletions

View File

@@ -0,0 +1,66 @@
#--
# WPScan - WordPress Security Scanner
# Copyright (C) 2012
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#++
module WpItem
attr_accessor :path, :base_url, :wp_content_dir
@version = nil
def get_url
URI.parse("#{@base_url.to_s}#@wp_content_dir/#@path")
end
def version
unless @version
response = Browser.instance.get(get_url.merge("readme.txt").to_s)
@version = response.body[%r{stable tag: #{WpVersion.version_pattern}}i, 1]
end
@version
end
# Is directory listing enabled?
def directory_listing?
# Need to remove to file part from the url
Browser.instance.get(location_uri_from_file_url(get_url.to_s)).body[%r{<title>Index of}] ? true : false
end
def extract_name_from_url(url)
url.to_s[%r{^(https?://.*/([^/]+)/)}i, 2]
end
def to_s
item_version = version
"#@name#{' v' + item_version if item_version}"
end
def ==(item)
item.name == @name
end
def <=>(item)
item.name <=> @name
end
def location_uri_from_file_url(location_url)
valid_location_url = location_url[%r{^(https?://.*/)[^.]+\.[^/]+$}, 1]
unless valid_location_url
valid_location_url = add_trailing_slash(location_url)
end
URI.parse(valid_location_url)
end
end

View File

@@ -37,11 +37,8 @@ module WpLoginProtection
plugin_name = symbol_to_call[@@login_protection_method_pattern, 1].gsub('_', '-')
return @login_protection_plugin = WpPlugin.new(
WpPlugin::create_location_url_from_name(
plugin_name,
@uri.to_s
),
:name => plugin_name
:name => plugin_name,
:base_url => @uri.to_s
)
end
end

View File

@@ -19,92 +19,18 @@
module WpPlugins
# Enumerate installed plugins.
# Available options : see #targets_url
# :show_progress_bar - default false
#
# return array of WpPlugin
def plugins_from_aggressive_detection(options = {})
browser = Browser.instance
hydra = browser.hydra
found_plugins = options[:only_vulnerable_ones] ? [] : plugins_from_passive_detection()
request_count = 0
queue_count = 0
local_404_hash = error_404_hash()
valid_response_codes = WpPlugins.valid_response_codes()
targets_url = plugins_targets_url(options)
show_progress_bar = options[:show_progress_bar] || false
targets_url.each do |target_url|
request = browser.forge_request(target_url, :cache_timeout => 0, :follow_location => true)
request_count += 1
request.on_complete do |response|
print "\rChecking for #{targets_url.size} total plugins... #{(request_count * 100) / targets_url.size}% complete." if show_progress_bar
if valid_response_codes.include?(response.code)
if Digest::MD5.hexdigest(response.body) != local_404_hash
found_plugins << WpPlugin.new(target_url)
end
end
end
hydra.queue(request)
queue_count += 1
if queue_count == browser.max_threads
hydra.run
queue_count = 0
end
end
hydra.run
found_plugins
def plugins_from_aggressive_detection(options)
options[:file] = "#{DATA_DIR}/plugins.txt"
options[:vulns_file] = "#{DATA_DIR}/plugin_vulns.xml"
options[:vulns_xpath] = "//plugin[@name='#{@name}']/vulnerability"
options[:type] = "plugins"
result = WpDetector.aggressive_detection(options)
result
end
def self.valid_response_codes
[200, 403, 301, 302]
end
# Available options :
# :only_vulnerable_ones - default false
# :plugins_file - default DATA_DIR/plugins.txt
# :plugin_vulns_file - default DATA_DIR/plugin_vulns.xml
#
# @return Array of String
def plugins_targets_url(options = {})
only_vulnerable = options[:only_vulnerable_ones] || false
plugins_file = options[:plugins_file] || "#{DATA_DIR}/plugins.txt"
plugin_vulns_file = options[:plugin_vulns_file] || "#{DATA_DIR}/plugin_vulns.xml"
targets_url = []
if only_vulnerable == false
# Open and parse the 'most popular' plugin list...
File.open(plugins_file, 'r') do |file|
file.readlines.collect do |line|
targets_url << WpPlugin.create_url_from_raw(line.chomp, @uri)
end
end
end
xml = Nokogiri::XML(File.open(plugin_vulns_file)) do |config|
config.noblanks
end
# We check if the plugin name from the plugin_vulns_file is already in targets, otherwise we add it
xml.xpath("//plugin").each do |node|
plugin_name = node.attribute('name').text
if targets_url.grep(%r{/#{plugin_name}/}).empty?
targets_url << WpPlugin.create_location_url_from_name(plugin_name, url())
end
end
targets_url.flatten!
targets_url.uniq!
# randomize the plugins array to *maybe* help in some crappy IDS/IPS/WAF detection
targets_url.sort_by! { rand }
end
private
# http://code.google.com/p/wpscan/issues/detail?id=42
# plugins can be found in the source code :
@@ -112,18 +38,16 @@ module WpPlugins
# <link rel='stylesheet' href='http://example.com/wp-content/plugins/wp-minify/..' type='text/css' media='screen'/>
# ...
# return array of WpPlugin
def plugins_from_passive_detection
plugins = []
response = Browser.instance.get(url())
plugins_names = response.body.scan(%r{(?:[^=:]+)\s?(?:=|:)\s?(?:"|')[^"']+\\?/wp-content\\?/plugins\\?/([^/\\"']+)\\?(?:/|"|')}i)
def plugins_from_passive_detection(wp_content_dir)
plugins = []
temp = WpDetector.passive_detection(url(), "plugins", wp_content_dir)
plugins_names.flatten!
plugins_names.uniq!
plugins_names.each do |plugin_name|
temp.each do |item|
plugins << WpPlugin.new(
WpPlugin.create_location_url_from_name(plugin_name, url()),
:name => plugin_name
:base_url => item[:base_url],
:name => item[:name],
:path => item[:path],
:wp_content_dir => wp_content_dir
)
end
plugins

57
lib/wpscan/wp_detector.rb Normal file
View File

@@ -0,0 +1,57 @@
#--
# WPScan - WordPress Security Scanner
# Copyright (C) 2012
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#++
class WpDetector
def self.aggressive_detection(options, items = [])
WpOptions.check_options(options)
result = items
unless items == nil or items.length == 0
result = passive_detection(options[:url], options[:type], options[:wp_content_dir])
end
enum_results = WpEnumerator.enumerate(options)
enum_results.each do |enum_result|
result << enum_result
end
result
end
# plugins and themes can be found in the source code :
# <script src='http://example.com/wp-content/plugins/s2member/...' />
# <link rel='stylesheet' href='http://example.com/wp-content/plugins/wp-minify/..' type='text/css' media='screen'/>
# ...
def self.passive_detection(url, type, wp_content_dir)
items = []
response = Browser.instance.get(url)
regex1 = %r{(?:[^=:]+)\s?(?:=|:)\s?(?:"|')[^"']+\\?/}
regex2 = %r{\\?/}
regex3 = %r{\\?/([^/\\"']+)\\?(?:/|"|')}
# Custom wp-content dir is now used in this regex
names = response.body.scan(/#{regex1}#{wp_content_dir}#{regex2}#{type}#{regex3}/i)
names.flatten!
names.uniq!
names.each do |item|
items << { :base_url => url, :name => item, :path => "#{type}/#{item}" }
end
items
end
end

118
lib/wpscan/wp_enumerator.rb Normal file
View File

@@ -0,0 +1,118 @@
#--
# WPScan - WordPress Security Scanner
# Copyright (C) 2012
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#++
# Enumerate over a given set of items and check if they exist
class WpEnumerator
# Enumerate the given Targets
#
# ==== Attributes
#
# * +targets+ - targets to enumerate
# * * +:base_url+ - Base URL
# * * +:wp_content+ - wp-content directory
# * * +:path+ - Path to plugin
# * +type+ - "plugins" or "themes", item to enumerate
# * +filename+ - filename in the data directory with paths
# * +show_progress_bar+ - Show a progress bar during enumeration
def self.enumerate(options = {})
WpOptions.check_options(options)
targets = self.generate_items(options)
found = []
queue_count = 0
request_count = 0
enum_browser = Browser.instance
enum_hydra = enum_browser.hydra
enumerate_size = targets.size
targets.each do |target|
url = target.get_url
request = enum_browser.forge_request(url, :cache_timeout => 0, :follow_location => true)
request_count += 1
request.on_complete do |response|
if options[:show_progress_bar]
print "\rChecking for #{enumerate_size} total #{options[:type]}... #{(request_count * 100) / enumerate_size}% complete."
end
if WpTarget.valid_response_codes.include?(response.code)
if Digest::MD5.hexdigest(response.body) != options[:error_404_hash]
found << target
end
end
end
enum_hydra.queue(request)
queue_count += 1
if queue_count == enum_browser.max_threads
enum_hydra.run
queue_count = 0
end
end
enum_hydra.run
found
end
private
def self.generate_items(options = {})
only_vulnerable = options[:only_vulnerable_ones]
plugins_file = options[:file] || "#{DATA_DIR}/plugins.txt"
plugin_vulns_file = options[:vulns_file] || "#{DATA_DIR}/plugin_vulns.xml"
wp_content_dir = options[:wp_content_dir]
url = options[:base_url]
type = options[:type]
targets_url = []
if only_vulnerable == false
# Open and parse the 'most popular' plugin list...
File.open(plugins_file, 'r') do |file|
file.readlines.collect do |line|
targets_url << WpPlugin.new(:base_url => url, :path => line.strip, :wp_content_dir => wp_content_dir)
end
end
end
xml = Nokogiri::XML(File.open(plugin_vulns_file)) do |config|
config.noblanks
end
# We check if the plugin name from the plugin_vulns_file is already in targets, otherwise we add it
xml.xpath("//plugin").each do |node|
plugin_name = node.attribute('name').text
if targets_url.grep(%r{/#{plugin_name}/}).empty?
targets_url << WpPlugin.new(
:base_url => url,
:path => "#{type}/#{plugin_name}",
:wp_content_dir => wp_content_dir,
:name => plugin_name
)
end
end
targets_url.flatten!
targets_url.uniq!
# randomize the plugins array to *maybe* help in some crappy IDS/IPS/WAF detection
targets_url.sort_by! { rand }
end
end

50
lib/wpscan/wp_options.rb Normal file
View File

@@ -0,0 +1,50 @@
#--
# WPScan - WordPress Security Scanner
# Copyright (C) 2012
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#++
class WpOptions
def self.get_empty_options
options = {
:url => "",
:only_vulnerable_ones => true,
:file => "",
:vulns_file => "",
:vulns_xpath => "",
:wp_content_dir => "",
:show_progress_bar => true,
:error_404_hash => "",
:type => ""
}
options
end
def self.check_options(options)
raise("url must be set") unless options[:url]
raise("only_vulnerable_ones must be set") unless options[:only_vulnerable_ones]
raise("file must be set") unless options[:file]
raise("vulns_file must be set") unless options[:vulns_file]
raise("vulns_xpath must be set") unless options[:vulns_xpath]
raise("wp_content_dir must be set") unless options[:wp_content_dir]
raise("show_progress_bar must be set") unless options[:show_progress_bar]
raise("error_404_hash must be set") unless options[:error_404_hash]
raise("type must be set") unless options[:type]
unless options[:type] =~ /plugins/i or options[:type] =~ /themes/i
raise("Unknown type #{options[:type]}")
end
end
end

View File

@@ -19,38 +19,22 @@
require "#{WPSCAN_LIB_DIR}/vulnerable"
class WpPlugin < Vulnerable
@@location_url_pattern = %r{^(https?://.*/([^/]+)/)}i
include WpItem
attr_reader :name
def initialize(options = {})
@base_url = options[:base_url]
@path = options[:path]
@wp_content_dir = options[:wp_content_dir]
@name = options[:name] || extract_name_from_url(get_url)
@vulns_xml = options[:vulns_xml] || DATA_DIR + '/plugin_vulns.xml'
@vulns_xpath = "//plugin[@name='#@name']/vulnerability"
@version = nil
def initialize(location_url, options = {})
@location_uri = WpPlugin.location_uri_from_url(location_url)
@name = options[:name] || WpPlugin.extract_name_from_location_url(location_url)
@vulns_xml = options[:vulns_xml] || DATA_DIR + '/plugin_vulns.xml'
@vulns_xpath = "//plugin[@name='#{@name}']/vulnerability"
end
def location_url
@location_uri.to_s
end
def ==(plugin)
plugin.name == @name
end
def <=>(plugin)
plugin.name <=> @name
end
# http://code.google.com/p/wpscan/issues/detail?id=97
def version
response = Browser.instance.get(@location_uri.merge("readme.txt").to_s)
response.body[%r{stable tag: #{WpVersion.version_pattern}}i, 1]
end
def to_s
version = version()
"#{@name}#{' v' + version if version}"
raise("base_url not set") unless @base_url
raise("path not set") unless @path
raise("wp_content_dir not set") unless @wp_content_dir
raise("name not set") unless @name
raise("vulns_xml not set") unless @vulns_xml
end
# Discover any error_log files created by WordPress
@@ -64,39 +48,7 @@ class WpPlugin < Vulnerable
end
def error_log_url
@location_uri.merge("error_log").to_s
get_url.merge("error_log").to_s
end
# Is directory listing enabled?
# WordPress denies directory listing however,
# forgets about the plugin directory.
def directory_listing?
Browser.instance.get(location_url()).body[%r{<title>Index of}] ? true : false
end
def self.create_location_url_from_name(name, target_uri)
if target_uri.is_a?(String)
target_uri = URI.parse(target_uri)
end
target_uri.merge(URI.escape("$wp-plugins$/#{name}/")).to_s
end
def self.create_url_from_raw(raw, target_uri)
target_uri.merge(URI.escape("$wp-plugins$/#{raw}")).to_s
end
protected
def self.extract_name_from_location_url(location_url)
location_url[@@location_url_pattern, 2]
end
def self.location_uri_from_url(location_url)
valid_location_url = location_url[%r{^(https?://.*/)[^.]+\.[^/]+$}, 1]
unless valid_location_url
valid_location_url = add_trailing_slash(location_url)
end
URI.parse(valid_location_url)
end
end

View File

@@ -50,7 +50,7 @@ class WpTarget
url = @uri.merge("wp-login.php").to_s
# Let's check if the login url is redirected (to https url for example)
if redirection = redirection(url)
if redirection == redirection(url)
url = redirection
end
@@ -70,6 +70,11 @@ class WpTarget
@error_404_hash
end
# Valid HTTP return codes
def self.valid_response_codes
[200, 403, 301, 302]
end
# return WpTheme
def theme
WpTheme.find(@uri)