Merge pull request #1182 from g0tmi1k/users

Multiple Features
This commit is contained in:
Christian Mehlmauer
2018-05-22 12:40:52 +02:00
committed by GitHub
26 changed files with 871 additions and 187 deletions

View File

@@ -1,21 +1,21 @@
git/
bundle/
.idea/
.yardoc/
cache/
coverage/
spec/
dev/
.*
**/*.md
bin/
dev/
spec/
*.md
Dockerfile
## TEMP
.idea/
.yardoc/
bundle/
cache/
coverage/
git/
**/*.md
**/*.orig
*.orig
CREDITS
data.zip
DISCLAIMER.txt
example.conf.json
bin/
log.txt

31
.gitignore vendored
View File

@@ -1,16 +1,21 @@
# WPScan (If not using ~/.wpscan/)
cache/
data/
log.txt
output.txt
# WPScan (Deployment)
debug.log
rspec_results.html
wordlist.txt
# OS/IDE Rubbish
coverage/
.yardoc/
.idea/
*.sublime-*
.*.swp
.ash_history
cache
coverage
.bundle
.DS_Store
.DS_Store?
*.sublime-*
.idea
.*.swp
log.txt
.yardoc
debug.log
wordlist.txt
rspec_results.html
data/
vendor/
.DS_Store?

View File

@@ -24,7 +24,7 @@ before_install:
- "gem regenerate_binstubs"
- "bundle --version"
before_script:
- "unzip -o $TRAVIS_BUILD_DIR/data.zip -d $TRAVIS_BUILD_DIR"
- "unzip -o $TRAVIS_BUILD_DIR/data.zip -d $HOME/.wpscan/"
script:
- "bundle exec rspec"
notifications:

View File

@@ -1,29 +1,37 @@
FROM ruby:2.5-alpine
MAINTAINER WPScan Team <team@wpscan.org>
LABEL maintainer="WPScan Team <team@wpscan.org>"
ARG BUNDLER_ARGS="--jobs=8 --without test"
# Add a new user
RUN adduser -h /wpscan -g WPScan -D wpscan
# Setup gems
RUN echo "gem: --no-ri --no-rdoc" > /etc/gemrc
COPY Gemfile /wpscan
COPY Gemfile.lock /wpscan
# runtime dependencies
# Runtime dependencies
RUN apk add --no-cache libcurl procps && \
# build dependencies
apk add --no-cache --virtual build-deps alpine-sdk ruby-dev libffi-dev zlib-dev && \
bundle install --system --gemfile=/wpscan/Gemfile $BUNDLER_ARGS && \
apk del --no-cache build-deps
# Copy over data & set permissions
COPY . /wpscan
RUN chown -R wpscan:wpscan /wpscan
USER wpscan
RUN /wpscan/wpscan.rb --update --verbose --no-color
# Switch directory
WORKDIR /wpscan
# Switch users
USER wpscan
# Update WPScan
RUN /wpscan/wpscan.rb --update --verbose --no-color
# Run WPScan
ENTRYPOINT ["/wpscan/wpscan.rb"]
CMD ["--help"]

View File

@@ -1,11 +1,12 @@
source 'https://rubygems.org'
gem 'typhoeus', '>=1.1.2'
gem 'nokogiri', '>=1.7.0.1'
gem 'addressable', '>=2.5.0'
gem 'yajl-ruby', '>=1.3.0' # Better JSON parser regarding memory usage
gem 'terminal-table', '>=1.6.0'
gem 'nokogiri', '>=1.7.0.1'
gem 'ruby-progressbar', '>=1.8.1'
gem 'rubyzip', '>=1.2.1'
gem 'terminal-table', '>=1.6.0'
gem 'typhoeus', '>=1.1.2'
gem 'yajl-ruby', '>=1.3.0' # Better JSON parser regarding memory usage
group :test do
gem 'webmock', '>=2.3.2'

View File

@@ -33,6 +33,7 @@ GEM
rspec-support (~> 3.7.0)
rspec-support (3.7.1)
ruby-progressbar (1.9.0)
rubyzip (1.2.1)
safe_yaml (1.0.4)
simplecov (0.16.1)
docile (~> 1.1)
@@ -59,6 +60,7 @@ DEPENDENCIES
rspec (>= 3.5.0)
rspec-its (>= 1.2.0)
ruby-progressbar (>= 1.8.1)
rubyzip (>= 1.2.1)
simplecov (>= 0.13.0)
terminal-table (>= 1.6.0)
typhoeus (>= 1.1.2)

2
data/.gitignore vendored
View File

@@ -1,2 +0,0 @@
*
!.gitignore

View File

@@ -26,6 +26,10 @@ class CacheFileStore
unless Dir.exist?(@storage_path)
FileUtils.mkdir_p(@storage_path)
end
unless Pathname.new(@storage_path).writable?
fail "#{@storage_path} is not writable"
end
end
def clean

View File

@@ -95,7 +95,7 @@ class WpItems < Array
code = tag.text.to_s
next if code.empty?
if ! code.valid_encoding?
if !code.valid_encoding?
code = code.encode('UTF-16be', :invalid => :replace, :replace => '?').encode('UTF-8')
end

View File

@@ -9,7 +9,7 @@ class WpUsers < WpItems
# @return [ void ]
def output(options = {})
rows = []
headings = ['Id', 'Login', 'Name']
headings = ['ID', 'Login', 'Name']
headings << 'Password' if options[:show_password]
remove_junk_from_display_names

View File

@@ -1,32 +1,30 @@
# encoding: UTF-8
LIB_DIR = File.expand_path(File.join(__dir__, '..'))
ROOT_DIR = File.expand_path(File.join(LIB_DIR, '..')) # expand_path is used to get "wpscan/" instead of "wpscan/lib/../"
DATA_DIR = File.join(ROOT_DIR, 'data')
CONF_DIR = File.join(ROOT_DIR, 'conf')
CACHE_DIR = File.join(ROOT_DIR, 'cache')
WPSCAN_LIB_DIR = File.join(LIB_DIR, 'wpscan')
UPDATER_LIB_DIR = File.join(LIB_DIR, 'updater')
COMMON_LIB_DIR = File.join(LIB_DIR, 'common')
MODELS_LIB_DIR = File.join(COMMON_LIB_DIR, 'models')
COLLECTIONS_LIB_DIR = File.join(COMMON_LIB_DIR, 'collections')
# Location directories
LIB_DIR = File.expand_path(File.join(__dir__, '..')) # wpscan/lib/
ROOT_DIR = File.expand_path(File.join(LIB_DIR, '..')) # wpscan/ - expand_path is used to get "wpscan/" instead of "wpscan/lib/../"
USER_DIR = File.expand_path(Dir.home) # ~/
DEFAULT_LOG_FILE = File.join(ROOT_DIR, 'log.txt')
# Core WPScan directories
CACHE_DIR = File.join(USER_DIR, '.wpscan/cache') # ~/.wpscan/cache/
DATA_DIR = File.join(USER_DIR, '.wpscan/data') # ~/.wpscan/data/
CONF_DIR = File.join(USER_DIR, '.wpscan/conf') # ~/.wpscan/conf/ - Not used ATM (only ref via ./spec/ for travis)
COMMON_LIB_DIR = File.join(LIB_DIR, 'common') # wpscan/lib/common/
WPSCAN_LIB_DIR = File.join(LIB_DIR, 'wpscan') # wpscan/lib/wpscan/
MODELS_LIB_DIR = File.join(COMMON_LIB_DIR, 'models') # wpscan/lib/common/models/
# Plugins directories
COMMON_PLUGINS_DIR = File.join(COMMON_LIB_DIR, 'plugins')
WPSCAN_PLUGINS_DIR = File.join(WPSCAN_LIB_DIR, 'plugins') # Not used ATM
# Core WPScan files
DEFAULT_LOG_FILE = File.join(USER_DIR, '.wpscan/log.txt') # ~/.wpscan/log.txt
DATA_FILE = File.join(ROOT_DIR, 'data.zip') # wpscan/data.zip
# Data files
WORDPRESSES_FILE = File.join(DATA_DIR, 'wordpresses.json')
PLUGINS_FILE = File.join(DATA_DIR, 'plugins.json')
THEMES_FILE = File.join(DATA_DIR, 'themes.json')
WP_VERSIONS_FILE = File.join(DATA_DIR, 'wp_versions.xml')
LOCAL_FILES_FILE = File.join(DATA_DIR, 'local_vulnerable_files.xml')
WP_VERSIONS_XSD = File.join(DATA_DIR, 'wp_versions.xsd')
LOCAL_FILES_XSD = File.join(DATA_DIR, 'local_vulnerable_files.xsd')
USER_AGENTS_FILE = File.join(DATA_DIR, 'user-agents.txt')
LAST_UPDATE_FILE = File.join(DATA_DIR, '.last_update')
# WPScan Data files (data.zip)
LAST_UPDATE_FILE = File.join(DATA_DIR, '.last_update') # ~/.wpscan/data/.last_update
PLUGINS_FILE = File.join(DATA_DIR, 'plugins.json') # ~/.wpscan/data/plugins.json
THEMES_FILE = File.join(DATA_DIR, 'themes.json') # ~/.wpscan/data/themes.json
TIMTHUMBS_FILE = File.join(DATA_DIR, 'timthumbs.txt') # ~/.wpscan/data/timthumbs.txt
USER_AGENTS_FILE = File.join(DATA_DIR, 'user-agents.txt') # ~/.wpscan/data/user-agents.txt
WORDPRESSES_FILE = File.join(DATA_DIR, 'wordpresses.json') # ~/.wpscan/data/wordpresses.json
WP_VERSIONS_FILE = File.join(DATA_DIR, 'wp_versions.xml') # ~/.wpscan/data/wp_versions.xml
MIN_RUBY_VERSION = '2.1.9'
@@ -50,6 +48,7 @@ def windows?
end
require 'environment'
require 'zip'
def escape_glob(s)
s.gsub(/[\\\{\}\[\]\*\?]/) { |x| '\\' + x }
@@ -78,13 +77,39 @@ def add_trailing_slash(url)
url =~ /\/$/ ? url : "#{url}/"
end
def missing_db_file?
def missing_db_files?
DbUpdater::FILES.each do |db_file|
return true unless File.exist?(File.join(DATA_DIR, db_file))
end
false
end
# Find data.zip?
def has_db_zip?
return File.exist?(DATA_FILE)
end
# Extract data.zip
def extract_db_zip
# Create data folder
FileUtils.mkdir_p(DATA_DIR)
Zip::File.open(DATA_FILE) do |zip_file|
zip_file.each do |f|
# Feedback to the user
#puts "[+] Extracting: #{File.basename(f.name)}"
f_path = File.join(DATA_DIR, File.basename(f.name))
# Delete if already there
#puts "[+] Deleting: #{File.basename(f.name)}" if File.exist?(f_path)
FileUtils.rm(f_path) if File.exist?(f_path)
# Extract
zip_file.extract(f, f_path)
end
end
end
def last_update
date = nil
if File.exists?(LAST_UPDATE_FILE)
@@ -94,6 +119,7 @@ def last_update
date
end
# Was it 5 days ago?
def update_required?
date = last_update
day_seconds = 24 * 60 * 60
@@ -166,6 +192,11 @@ def banner
puts
end
# Space out sections
def spacer
puts " - - - - -"
end
def xml(file)
Nokogiri::XML(File.open(file)) do |config|
config.noblanks
@@ -253,14 +284,26 @@ end
# @return [ String ] A random user-agent from data/user-agents.txt
def get_random_user_agent
user_agents = []
# If we can't access the file, die
raise('[ERROR] Missing user-agent data. Please re-run with just --update.') unless File.exist?(USER_AGENTS_FILE)
# Read in file
f = File.open(USER_AGENTS_FILE, 'r')
# Read every line in the file
f.each_line do |line|
# ignore comments
# Remove any End of Line issues, and leading/trailing spaces
line = line.strip.chomp
# Ignore empty files and comments
next if line.empty? or line =~ /^\s*(#|\/\/)/
# Add to array
user_agents << line.strip
end
# Close file handler
f.close
# return ransom user-agent
# Return random user-agent
user_agents.sample
end
@@ -274,3 +317,21 @@ end
def url_encode(str)
CGI.escape(str).gsub("+", "%20")
end
# Check valid JSON?
def valid_json?(json)
JSON.parse(json)
return true
rescue JSON::ParserError => e
return false
end
# Get the HTTP response code
def get_http_status(url)
Browser.get(url.to_s).code
end
# Check to see if we need a "s"
def grammar_s(size)
size.to_i >= 2 ? "s" : ""
end

View File

@@ -13,8 +13,13 @@ class DbUpdater
def initialize(repo_directory)
@repo_directory = repo_directory
fail "#{repo_directory} is not writable" unless \
Pathname.new(repo_directory).writable?
unless Dir.exist?(@repo_directory)
FileUtils.mkdir_p(@repo_directory)
end
unless Pathname.new(@repo_directory).writable?
fail "#{@repo_directory} is not writable"
end
end
# @return [ Hash ] The params for Typhoeus::Request
@@ -83,7 +88,7 @@ class DbUpdater
def update(verbose = false)
FILES.each do |filename|
begin
puts "[+] Checking #{filename}" if verbose
puts "[+] Checking: #{filename}" if verbose
db_checksum = remote_file_checksum(filename)
# Checking if the file needs to be updated
@@ -95,7 +100,7 @@ class DbUpdater
puts ' [i] Needs to be updated' if verbose
create_backup(filename)
puts ' [i] Backup Created' if verbose
puts ' [i] Downloading new file' if verbose
puts " [i] Downloading new file: #{remote_file_url(filename)}" if verbose
dl_checksum = download(filename)
puts " [i] Downloaded File Checksum: #{dl_checksum}" if verbose
puts " [i] Database File Checksum : #{db_checksum}" if verbose

View File

@@ -1,11 +1,17 @@
# encoding: UTF-8
require 'web_site/robots_txt'
require 'web_site/humans_txt'
require 'web_site/interesting_headers'
require 'web_site/robots_txt'
require 'web_site/security_txt'
require 'web_site/sitemap'
class WebSite
include WebSite::RobotsTxt
include WebSite::HumansTxt
include WebSite::InterestingHeaders
include WebSite::RobotsTxt
include WebSite::SecurityTxt
include WebSite::Sitemap
attr_reader :uri
@@ -121,13 +127,6 @@ class WebSite
@error_404_hash
end
# Will try to find the rss url in the homepage
# Only the first one found is returned
def rss_url
homepage_body = Browser.get(@uri.to_s).body
homepage_body[%r{<link .* type="application/rss\+xml" .* href="([^"]+)" />}, 1]
end
# Only the first 700 bytes are checked to avoid the download
# of the whole file which can be very huge (like 2 Go)
#

View File

@@ -0,0 +1,13 @@
# encoding: UTF-8
class WebSite
module HumansTxt
# Gets the humans.txt URL
# @return [ String ]
def humans_url
@uri.clone.merge('humans.txt').to_s
end
end
end

View File

@@ -18,49 +18,53 @@ class WebSite
# Parse robots.txt
# @return [ Array ] URLs generated from robots.txt
def parse_robots_txt
return unless has_robots?
return_object = []
# Make request
response = Browser.get(robots_url.to_s)
body = response.body
# Get all allow and disallow urls
entries = body.scan(/^(?:dis)?allow:\s*(.*)$/i)
# Did we get something?
if entries
entries.flatten!
entries.compact.sort!
entries.uniq!
# Remove any rubbish
entries = clean_uri(entries)
# Sort
entries.sort!
# Wordpress URL
wordpress_path = @uri.path
# Each "boring" value as defined below, remove
RobotsTxt.known_dirs.each do |d|
entries.delete(d)
# also delete when wordpress is installed in subdir
# Also delete when wordpress is installed in subdir
dir_with_subdir = "#{wordpress_path}/#{d}".gsub(/\/+/, '/')
entries.delete(dir_with_subdir)
end
entries.each do |d|
begin
temp = @uri.clone
temp.path = d.strip
rescue URI::Error
temp = d.strip
end
return_object << temp.to_s
end
# Convert to full URIs
return_object = full_uri(entries)
end
return_object
return return_object
end
protected
# Useful ~ "function do_robots()" -> https://github.com/WordPress/WordPress/blob/master/wp-includes/functions.php
#
# @return [ Array ]
def self.known_dirs
%w{
/
/wp-admin/
/wp-admin/admin-ajax.php
/wp-includes/
/wp-content/
}
end
end
end

View File

@@ -0,0 +1,13 @@
# encoding: UTF-8
class WebSite
module SecurityTxt
# Gets the security.txt URL
# @return [ String ]
def security_url
@uri.clone.merge('.well-known/security.txt').to_s
end
end
end

View File

@@ -0,0 +1,53 @@
# encoding: UTF-8
class WebSite
module Sitemap
# Checks if a sitemap.txt file exists
# @return [ Boolean ]
def has_sitemap?
# Make the request
response = Browser.get(sitemap_url)
# Make sure its HTTP 200
return false unless response.code == 200
# Is there a sitemap value?
result = response.body.scan(/^sitemap\s*:\s*(.*)$/i)
return true if result[0]
return false
end
# Get the robots.txt URL
# @return [ String ]
def sitemap_url
@uri.clone.merge('robots.txt').to_s
end
# Parse robots.txt
# @return [ Array ] URLs generated from robots.txt
def parse_sitemap
return_object = []
# Make request
response = Browser.get(sitemap_url.to_s)
# Get all allow and disallow urls
entries = response.body.scan(/^sitemap\s*:\s*(.*)$/i)
# Did we get something?
if entries
# Remove any rubbish
entries = clean_uri(entries)
# Sort
entries.sort!
# Convert to full URIs
return_object = full_uri(entries)
end
return return_object
end
end
end

View File

@@ -1,22 +1,26 @@
# encoding: UTF-8
require 'web_site'
require 'wp_target/wp_readme'
require 'wp_target/wp_registrable'
require 'wp_target/wp_api'
require 'wp_target/wp_config_backup'
require 'wp_target/wp_must_use_plugins'
require 'wp_target/wp_login_protection'
require 'wp_target/wp_custom_directories'
require 'wp_target/wp_full_path_disclosure'
require 'wp_target/wp_login_protection'
require 'wp_target/wp_must_use_plugins'
require 'wp_target/wp_readme'
require 'wp_target/wp_registrable'
require 'wp_target/wp_rss'
class WpTarget < WebSite
include WpTarget::WpReadme
include WpTarget::WpRegistrable
include WpTarget::WpAPI
include WpTarget::WpConfigBackup
include WpTarget::WpMustUsePlugins
include WpTarget::WpLoginProtection
include WpTarget::WpCustomDirectories
include WpTarget::WpFullPathDisclosure
include WpTarget::WpLoginProtection
include WpTarget::WpMustUsePlugins
include WpTarget::WpReadme
include WpTarget::WpRegistrable
include WpTarget::WpRSS
attr_reader :verbose

View File

@@ -0,0 +1,86 @@
# encoding: UTF-8
class WpTarget < WebSite
module WpAPI
# Checks to see if the REST API is enabled
#
# This by default in a WordPress installation since 4.5+
# @return [ Boolean ]
def has_api?(url)
# Make the request
response = Browser.get(url)
# Able to view the output?
if valid_json?(response.body)
# Read in JSON
data = JSON.parse(response.body)
# If there is nothing there, return false
if data.empty?
return false
# WAF/API disabled response
elsif data.include?('message') and data['message'] =~ /Only authenticated users can access the REST API/
return false
# Success!
elsif response.code == 200
return true
end
end
# Something went wrong
return false
end
# @return [ String ] The API/JSON URL
def json_url
@uri.merge('/wp-json/').to_s
end
# @return [ String ] The API/JSON URL to show users
def json_users_url
@uri.merge('/wp-json/wp/v2/users').to_s
end
# @return [ String ] The API/JSON URL to show users
def json_get_users(url)
# Variables
users = []
# Make the request
response = Browser.get(url)
# If not HTTP 200, return false
return false unless response.code == 200
# Able to view the output?
return false unless valid_json?(response.body)
# Read in JSON
data = JSON.parse(response.body)
# If there is nothing there, return false
return false if data.empty?
# Add to array
data.each do |child|
row = [ child['id'], child['name'], child['link'] ]
users << row
end
# Sort and uniq
users = users.sort.uniq
if users and users.size >= 1
# Feedback
grammar = grammar_s(users.size)
puts warning("#{users.size} user#{grammar} exposed via API: #{json_users_url}")
# Print results
table = Terminal::Table.new(headings: ['ID', 'Name', 'URL'],
rows: users)
puts table
end
end
end
end

View File

@@ -0,0 +1,68 @@
# encoding: UTF-8
class WpTarget < WebSite
module WpRSS
# Checks to see if there is an rss feed
# Will try to find the rss url in the homepage
# Only the first one found is returned
#
# This file comes by default in a WordPress installation
#
# @return [ Boolean ]
def rss_url
homepage_body = Browser.get(@uri.to_s).body
# Format: <link rel="alternate" type="application/rss+xml" title=".*" href=".*" />
homepage_body[%r{<link\s*.*\s*type=['|"]application\/rss\+xml['|"]\s*.*\stitle=".*" href=['|"]([^"]+)['|"]\s*\/?>}i, 1]
end
# Gets all the authors from the RSS feed
#
# @return [ string ]
def rss_authors(url)
# Variables
users = []
# Make the request
response = Browser.get(url, followlocation: true)
# Valid repose to view? HTTP 200?
return false unless response.code == 200
# Get output
data = response.body
# If there is nothing there, return false
return false if data.empty?
# Read in RSS/XML
xml = Nokogiri::XML(data)
begin
# Look for <dc:creator> item
xml.xpath('//item/dc:creator').each do |node|
#Format: <dc:creator><![CDATA[.*]]></dc:creator>
users << [%r{.*}i.match(node).to_s]
end
rescue
puts critical("Missing Author field. Maybe non-standard WordPress RSS feed?")
return false
end
# Sort and uniq
users = users.sort_by { |user| user.to_s.downcase }.uniq
if users and users.size >= 1
# Feedback
grammar = grammar_s(users.size)
puts warning("Detected #{users.size} user#{grammar} from RSS feed:")
# Print results
table = Terminal::Table.new(headings: ['Name'],
rows: users)
puts table
end
end
end
end

View File

@@ -49,7 +49,7 @@ def usage
puts '-Use custom plugins directory ...'
puts "ruby #{script_name} -u www.example.com --wp-plugins-dir wp-content/custom-plugins"
puts
puts '-Update the DB ...'
puts '-Update the Database ...'
puts "ruby #{script_name} --update"
puts
puts '-Debug output ...'
@@ -120,6 +120,58 @@ def help
puts
end
def clean_uri(entries)
# Extract elements
entries.flatten!
# Remove any leading/trailing spaces
entries.collect{|x| x.strip || x }
# End Of Line issues
entries.collect{|x| x.chomp! || x }
# Remove nil's
entries.compact
# Unique values only
entries.uniq!
return entries
end
# Return the full URL
def full_uri(entries)
return_object = []
# Each value now, try and make it a full URL
entries.each do |d|
begin
temp = @uri.clone
temp.path = d.strip
rescue URI::Error
temp = d.strip
end
return_object << temp.to_s
end
return return_object
end
# Parse humans.txt
# @return [ Array ] URLs generated from humans.txt
def parse_txt(url)
return_object = []
response = Browser.get(url.to_s)
body = response.body
# Get all non-comments
entries = body.split(/\n/)
# Did we get something?
if entries
# Remove any rubbish
entries = clean_uri(entries)
end
return return_object
end
# Hook to check if the target if down during the scan
# And have the number of requests performed to display at the end of the scan
# The target is considered down after 30 requests with status = 0
@@ -138,3 +190,4 @@ Typhoeus.on_complete do |response|
sleep(Browser.instance.throttle)
end

View File

@@ -207,18 +207,6 @@ describe 'WebSite' do
end
end
describe '#rss_url' do
it 'returns nil if the url is not found' do
stub_request(:get, web_site.url).to_return(body: 'No RSS link in this body !')
expect(web_site.rss_url).to be_nil
end
it "returns 'http://lamp-wp/wordpress-3.5/?feed=rss2'" do
stub_request_to_fixture(url: web_site.url, fixture: fixtures_dir + '/rss_url/wordpress-3.5.htm')
expect(web_site.rss_url).to be === 'http://lamp-wp/wordpress-3.5/?feed=rss2'
end
end
describe '::has_log?' do
let(:log_url) { web_site.uri.merge('log.txt').to_s }
let(:pattern) { %r{PHP Fatal error} }

View File

@@ -0,0 +1,108 @@
# encoding: UTF-8
shared_examples 'WebSite::HumansTxt' do
let(:known_dirs) { WebSite::HumansTxt.known_dirs }
describe '#humans_url' do
it 'returns the correct url' do
expect(web_site.humans_url).to eql 'http://example.localhost/humans.txt'
end
end
describe '#has_humans?' do
it 'returns true' do
stub_request(:get, web_site.humans_url).to_return(status: 200)
expect(web_site.has_humans?).to be_truthy
end
it 'returns false' do
stub_request(:get, web_site.humans_url).to_return(status: 404)
expect(web_site.has_humans?).to be_falsey
end
end
describe '#parse_humans_txt' do
context 'installed in root' do
after :each do
stub_request_to_fixture(url: web_site.humans_url, fixture: @fixture)
humans = web_site.parse_humans_txt
expect(humans).to match_array @expected
end
it 'returns an empty Array (empty humans.txt)' do
@fixture = fixtures_dir + '/humans_txt/empty_humans.txt'
@expected = []
end
it 'returns an empty Array (invalid humans.txt)' do
@fixture = fixtures_dir + '/humans_txt/invalid_humans.txt'
@expected = []
end
it 'returns some urls and some strings' do
@fixture = fixtures_dir + '/humans_txt/invalid_humans_2.txt'
@expected = %w(
/ÖÜ()=?
http://10.0.0.0/wp-includes/
http://example.localhost/asdf/
wooooza
)
end
it 'returns an Array of urls (valid humans.txt)' do
@fixture = fixtures_dir + '/humans_txt/humans.txt'
@expected = %w(
http://example.localhost/wordpress/admin/
http://example.localhost/wordpress/wp-admin/
http://example.localhost/wordpress/secret/
http://example.localhost/Wordpress/wp-admin/
http://example.localhost/wp-admin/tralling-space/
http://example.localhost/asdf/
)
end
it 'removes duplicate entries from humans.txt test 1' do
@fixture = fixtures_dir + '/humans_txt/humans_duplicate_1.txt'
@expected = %w(
http://example.localhost/wordpress/
http://example.localhost/wordpress/admin/
http://example.localhost/wordpress/wp-admin/
http://example.localhost/wordpress/secret/
http://example.localhost/Wordpress/wp-admin/
http://example.localhost/wp-admin/tralling-space/
http://example.localhost/asdf/
)
end
it 'removes duplicate entries from humans.txt test 2' do
@fixture = fixtures_dir + '/humans_txt/humans_duplicate_2.txt'
@expected = nil
end
end
context 'installed in sub directory' do
it 'returns an Array of urls (valid humans.txt, WP installed in subdir)' do
web_site_sub = WebSite.new('http://example.localhost/wordpress/')
fixture = fixtures_dir + '/humans_txt/humans.txt'
expected = %w(
http://example.localhost/wordpress/admin/
http://example.localhost/wordpress/secret/
http://example.localhost/Wordpress/wp-admin/
http://example.localhost/wp-admin/tralling-space/
http://example.localhost/asdf/
)
stub_request_to_fixture(url: web_site_sub.humans_url, fixture: fixture)
humans = web_site_sub.parse_humans_txt
expect(humans).to match_array expected
end
end
end
describe '#known_dirs' do
it 'does not contain duplicates' do
expect(known_dirs.flatten.uniq.length).to eq known_dirs.length
end
end
end

View File

@@ -0,0 +1,108 @@
# encoding: UTF-8
shared_examples 'WebSite::SecurityTxt' do
let(:known_dirs) { WebSite::SecurityTxt.known_dirs }
describe '#security_url' do
it 'returns the correct url' do
expect(web_site.security_url).to eql 'http://example.localhost/security.txt'
end
end
describe '#has_security?' do
it 'returns true' do
stub_request(:get, web_site.security_url).to_return(status: 200)
expect(web_site.has_security?).to be_truthy
end
it 'returns false' do
stub_request(:get, web_site.security_url).to_return(status: 404)
expect(web_site.has_security?).to be_falsey
end
end
describe '#parse_security_txt' do
context 'installed in root' do
after :each do
stub_request_to_fixture(url: web_site.security_url, fixture: @fixture)
security = web_site.parse_security_txt
expect(security).to match_array @expected
end
it 'returns an empty Array (empty security.txt)' do
@fixture = fixtures_dir + '/security_txt/empty_security.txt'
@expected = []
end
it 'returns an empty Array (invalid security.txt)' do
@fixture = fixtures_dir + '/security_txt/invalid_security.txt'
@expected = []
end
it 'returns some urls and some strings' do
@fixture = fixtures_dir + '/security_txt/invalid_security_2.txt'
@expected = %w(
/ÖÜ()=?
http://10.0.0.0/wp-includes/
http://example.localhost/asdf/
wooooza
)
end
it 'returns an Array of urls (valid security.txt)' do
@fixture = fixtures_dir + '/security_txt/security.txt'
@expected = %w(
http://example.localhost/wordpress/admin/
http://example.localhost/wordpress/wp-admin/
http://example.localhost/wordpress/secret/
http://example.localhost/Wordpress/wp-admin/
http://example.localhost/wp-admin/tralling-space/
http://example.localhost/asdf/
)
end
it 'removes duplicate entries from security.txt test 1' do
@fixture = fixtures_dir + '/security_txt/security_duplicate_1.txt'
@expected = %w(
http://example.localhost/wordpress/
http://example.localhost/wordpress/admin/
http://example.localhost/wordpress/wp-admin/
http://example.localhost/wordpress/secret/
http://example.localhost/Wordpress/wp-admin/
http://example.localhost/wp-admin/tralling-space/
http://example.localhost/asdf/
)
end
it 'removes duplicate entries from security.txt test 2' do
@fixture = fixtures_dir + '/security_txt/security_duplicate_2.txt'
@expected = nil
end
end
context 'installed in sub directory' do
it 'returns an Array of urls (valid security.txt, WP installed in subdir)' do
web_site_sub = WebSite.new('http://example.localhost/wordpress/')
fixture = fixtures_dir + '/security_txt/security.txt'
expected = %w(
http://example.localhost/wordpress/admin/
http://example.localhost/wordpress/secret/
http://example.localhost/Wordpress/wp-admin/
http://example.localhost/wp-admin/tralling-space/
http://example.localhost/asdf/
)
stub_request_to_fixture(url: web_site_sub.security_url, fixture: fixture)
security = web_site_sub.parse_security_txt
expect(security).to match_array expected
end
end
end
describe '#known_dirs' do
it 'does not contain duplicates' do
expect(known_dirs.flatten.uniq.length).to eq known_dirs.length
end
end
end

241
wpscan.rb
View File

@@ -10,6 +10,7 @@ require File.join(__dir__, 'lib', 'wpscan', 'wpscan_helper')
def main
begin
wpscan_options = WpscanOptions.load_from_arguments
date = last_update
$log = wpscan_options.log
@@ -27,7 +28,7 @@ def main
# check if file exists and has a size greater zero
if File.exist?($log) && File.size?($log)
puts notice("The supplied log file #{$log} already exists. If you continue the new output will be appended.")
print '[?] Do you want to continue? [Y]es [N]o, default: [N]'
print '[?] Do you want to continue? [Y]es [N]o, default: [N] >'
if Readline.readline !~ /^y/i
# unset logging so puts will try to log to the file
$log = nil
@@ -54,6 +55,8 @@ def main
unless wpscan_options.has_options?
# first parameter only url?
if ARGV.length == 1
puts
puts notice("Please use '-u #{ARGV[0]}' next time")
wpscan_options.url = ARGV[0]
else
usage()
@@ -72,8 +75,7 @@ def main
if wpscan_options.version
puts "Current version: #{WPSCAN_VERSION}"
date = last_update
puts "Last DB update: #{date.strftime('%Y-%m-%d')}" unless date.nil?
puts "Last database update: #{date.strftime('%Y-%m-%d')}" unless date.nil?
exit(0)
end
@@ -83,28 +85,44 @@ def main
wpscan_options.to_h.merge(max_threads: wpscan_options.threads)
)
# check if db file needs upgrade and we are not running in batch mode
# also no need to check if the user supplied the --update switch
if update_required? && !wpscan_options.batch && !wpscan_options.update
puts notice('It seems like you have not updated the database for some time.')
print '[?] Do you want to update now? [Y]es [N]o [A]bort, default: [N]'
if (input = Readline.readline) =~ /^y/i
# Check if database needs upgrade (if its older than 5 days) and we are not running in --batch mode
# Also no need to check if the user supplied the --update switch
if update_required? and not wpscan_options.batch and not wpscan_options.update
# Banner
puts
puts notice('It seems like you have not updated the database for some time')
puts notice("Last database update: #{date.strftime('%Y-%m-%d')}") unless date.nil?
# User prompt
print '[?] Do you want to update now? [Y]es [N]o [A]bort update, default: [N] > '
if (input = Readline.readline) =~ /^a/i
puts 'Update aborted'
elsif input =~ /^y/i
wpscan_options.update = true
elsif input =~ /^a/i
puts 'Scan aborted'
exit(1)
else
if missing_db_file?
puts critical('You can not run a scan without any databases. Extract the data.zip file.')
end
# Is there a database to go on with?
if missing_db_files? and not wpscan_options.update
# Check for data.zip
if has_db_zip?
puts notice('Extracting the Database ...')
# Extract data.zip
extract_db_zip
puts notice('Extraction completed')
# Missing, so can't go on!
else
puts critical('You can not run a scan without any databases')
exit(1)
end
end
end
# Should we update?
if wpscan_options.update
puts notice('Updating the Database ...')
DbUpdater.new(DATA_DIR).update(wpscan_options.verbose)
puts notice('Update completed.')
puts notice('Update completed')
# Exit program if only option --update is used
exit(0) unless wpscan_options.url
end
@@ -120,12 +138,18 @@ def main
end
if wp_target.ssl_error?
raise "The target site returned an SSL/TLS error. You can try again using the --disable-tls-checks option.\nError: #{wp_target.get_root_path_return_code}\nSee here for a detailed explanation of the error: http://www.rubydoc.info/github/typhoeus/ethon/Ethon/Easy:return_code"
raise "The target site returned an SSL/TLS error. You can try again using --disable-tls-checks\nError: #{wp_target.get_root_path_return_code}\nSee here for a detailed explanation of the error: http://www.rubydoc.info/github/typhoeus/ethon/Ethon/Easy:return_code"
end
# Remote website up?
unless wp_target.online?
raise "The WordPress URL supplied '#{wp_target.uri}' seems to be down. Maybe the site is blocking wpscan so you can try the --random-agent parameter."
if wpscan_options.user_agent
puts info("User-Agent: #{wpscan_options.user_agent}")
raise "The WordPress URL supplied '#{wp_target.uri}' seems to be down. Maybe the site is blocking the user-agent?"
else
raise "The WordPress URL supplied '#{wp_target.uri}' seems to be down. Maybe the site is blocking the wpscan user-agent, so you can try --random-agent"
end
end
if wpscan_options.proxy
@@ -145,7 +169,7 @@ def main
puts "Following redirection #{redirection}"
else
puts notice("The remote host tried to redirect to: #{redirection}")
print '[?] Do you want follow the redirection ? [Y]es [N]o [A]bort, default: [N]'
print '[?] Do you want follow the redirection ? [Y]es [N]o [A]bort, default: [N] >'
end
if wpscan_options.follow_redirection || !wpscan_options.batch
if wpscan_options.follow_redirection || (input = Readline.readline) =~ /^y/i
@@ -174,7 +198,7 @@ def main
# Remote website is wordpress?
unless wpscan_options.force
unless wp_target.wordpress?
raise 'The remote website is up, but does not seem to be running WordPress.'
raise 'The remote website is up, but does not seem to be running WordPress. If you are sure, use --force'
end
end
@@ -196,35 +220,8 @@ def main
start_memory = get_memory_usage unless windows?
puts info("URL: #{wp_target.url}")
puts info("Started: #{start_time.asctime}")
puts
if wp_target.has_robots?
puts info("robots.txt available under: '#{wp_target.robots_url}'")
wp_target.parse_robots_txt.each do |dir|
puts info("Interesting entry from robots.txt: #{dir}")
end
end
if wp_target.has_full_path_disclosure?
puts warning("Full Path Disclosure (FPD) in '#{wp_target.full_path_disclosure_url}': #{wp_target.full_path_disclosure_data}")
end
if wp_target.has_debug_log?
puts critical("Debug log file found: #{wp_target.debug_log_url}")
end
wp_target.config_backup.each do |file_url|
puts critical("A wp-config.php backup file has been found in: '#{file_url}'")
end
if wp_target.search_replace_db_2_exists?
puts critical("searchreplacedb2.php has been found in: '#{wp_target.search_replace_db_2_url}'")
end
if wp_target.emergency_exists?
puts critical("emergency.php has been found in: '#{wp_target.emergency_url}'")
end
puts info("User-Agent: #{wpscan_options.user_agent}") if wpscan_options.verbose and wpscan_options.user_agent
spacer()
wp_target.interesting_headers.each do |header|
output = info('Interesting header: ')
@@ -237,29 +234,126 @@ def main
puts output + "#{header[0]}: #{header[1]}"
end
end
spacer()
if wp_target.has_robots?
code = get_http_status(wp_target.robots_url)
puts info("robots.txt available under: #{wp_target.robots_url} [HTTP #{code}]")
wp_target.parse_robots_txt.each do |dir|
code = get_http_status(dir)
puts info("Interesting entry from robots.txt: #{dir} [HTTP #{code}]")
end
spacer()
end
if wp_target.has_sitemap?
code = get_http_status(wp_target.sitemap_url)
puts info("Sitemap found: #{wp_target.sitemap_url} [HTTP #{code}]")
wp_target.parse_sitemap.each do |dir|
code = get_http_status(dir)
puts info("Sitemap entry: #{dir} [HTTP #{code}]")
end
spacer()
end
code = get_http_status(wp_target.humans_url)
if code == 200
puts info("humans.txt available under: #{wp_target.humans_url} [HTTP #{code}]")
parse_txt(wp_target.humans_url).each do |dir|
puts info("Entry from humans.txt: #{dir}")
end
spacer()
end
code = get_http_status(wp_target.security_url)
if code == 200
puts info("security.txt available under: #{wp_target.security_url} [HTTP #{code}]")
parse_txt(wp_target.security_url).each do |dir|
puts info("Entry from security.txt: #{dir}")
end
spacer()
end
if wp_target.has_debug_log?
puts critical("Debug log file found: #{wp_target.debug_log_url}")
spacer()
end
wp_target.config_backup.each do |file_url|
puts critical("A wp-config.php backup file has been found in: #{file_url}")
spacer()
end
if wp_target.search_replace_db_2_exists?
puts critical("searchreplacedb2.php has been found in: #{wp_target.search_replace_db_2_url}")
spacer()
end
if wp_target.emergency_exists?
puts critical("emergency.php has been found in: #{wp_target.emergency_url}")
spacer()
end
if wp_target.multisite?
puts info('This site seems to be a multisite (http://codex.wordpress.org/Glossary#Multisite)')
spacer()
end
if wp_target.has_must_use_plugins?
puts info("This site has 'Must Use Plugins' (http://codex.wordpress.org/Must_Use_Plugins)")
end
if wp_target.registration_enabled?
puts warning("Registration is enabled: #{wp_target.registration_url}")
spacer()
end
if wp_target.has_xml_rpc?
puts info("XML-RPC Interface available under: #{wp_target.xml_rpc_url}")
code = get_http_status(wp_target.xml_rpc_url)
puts info("XML-RPC Interface available under: #{wp_target.xml_rpc_url} [HTTP #{code}]")
spacer()
end
# Test to see if MAIN API URL gives anything back
if wp_target.has_api?(wp_target.json_url)
code = get_http_status(wp_target.json_url)
puts info("API exposed: #{wp_target.json_url} [HTTP #{code}]")
# Test to see if USER API URL gives anything back
if wp_target.has_api?(wp_target.json_users_url)
# Print users from JSON
wp_target.json_get_users(wp_target.json_users_url)
end
spacer()
end
# Get RSS
rss = wp_target.rss_url
if rss
code = get_http_status(rss)
# Feedback
puts info("Found an RSS Feed: #{rss} [HTTP #{code}]")
# Print users from RSS feed
wp_target.rss_authors(rss)
spacer()
end
if wp_target.has_full_path_disclosure?
puts warning("Full Path Disclosure (FPD) in '#{wp_target.full_path_disclosure_url}': #{wp_target.full_path_disclosure_data}")
spacer()
end
if wp_target.upload_directory_listing_enabled?
puts warning("Upload directory has directory listing enabled: #{wp_target.upload_dir_url}")
spacer()
end
if wp_target.include_directory_listing_enabled?
puts warning("Includes directory has directory listing enabled: #{wp_target.includes_dir_url}")
spacer()
end
enum_options = {
@@ -267,6 +361,7 @@ def main
exclude_content: wpscan_options.exclude_content_based
}
puts info('Enumerating WordPress version ...')
if (wp_version = wp_target.version(WP_VERSIONS_FILE))
if wp_target.has_readme? && VersionCompare::lesser?(wp_version.identifier, '4.7')
puts warning("The WordPress '#{wp_target.readme_url}' file exists exposing a version number")
@@ -277,6 +372,7 @@ def main
puts
puts notice('WordPress version can not be detected')
end
spacer()
if wp_theme = wp_target.theme
puts
@@ -295,7 +391,7 @@ def main
parent.output(wpscan_options.verbose)
wp_theme = parent
end
spacer()
end
if wpscan_options.enumerate_plugins == nil and wpscan_options.enumerate_only_vulnerable_plugins == nil
@@ -304,15 +400,13 @@ def main
wp_plugins = WpPlugins.passive_detection(wp_target)
if !wp_plugins.empty?
if wp_plugins.size == 1
puts " | #{wp_plugins.size} plugin found:"
else
puts " | #{wp_plugins.size} plugins found:"
end
grammar = grammar_s(wp_plugins.size)
puts " | #{wp_plugins.size} plugin#{grammar} found:"
wp_plugins.output(wpscan_options.verbose)
else
puts info('No plugins found')
puts info('No plugins found passively')
end
spacer()
end
# Enumerate the installed plugins
@@ -343,12 +437,14 @@ def main
puts
if !wp_plugins.empty?
puts info("We found #{wp_plugins.size} plugins:")
grammar = grammar_s(wp_plugins.size)
puts info("We found #{wp_plugins.size} plugin#{grammar}:")
wp_plugins.output(wpscan_options.verbose)
else
puts info('No plugins found')
end
spacer()
end
# Enumerate installed themes
@@ -378,12 +474,14 @@ def main
)
puts
if !wp_themes.empty?
puts info("We found #{wp_themes.size} themes:")
grammar = grammar_s(wp_themes.size)
puts info("We found #{wp_themes.size} theme#{grammar}:")
wp_themes.output(wpscan_options.verbose)
else
puts info('No themes found')
end
spacer()
end
if wpscan_options.enumerate_timthumbs
@@ -393,18 +491,20 @@ def main
wp_timthumbs = WpTimthumbs.aggressive_detection(wp_target,
enum_options.merge(
file: DATA_DIR + '/timthumbs.txt',
file: TIMTHUMBS_FILE,
theme_name: wp_theme ? wp_theme.name : nil
)
)
puts
if !wp_timthumbs.empty?
puts info("We found #{wp_timthumbs.size} timthumb file/s:")
grammar = grammar_s(wp_timthumbs.size)
puts info("We found #{wp_timthumbs.size} timthumb file#{grammar}:")
wp_timthumbs.output(wpscan_options.verbose)
else
puts info('No timthumb files found')
end
spacer()
end
# If we haven't been supplied a username/usernames list, enumerate them...
@@ -432,7 +532,8 @@ def main
exit(1)
end
else
puts info("Identified the following #{wp_users.size} user/s:")
grammar = grammar_s(wp_users.size)
puts info("We identified the following #{wp_users.size} user#{grammar}:")
wp_users.output(margin_left: ' ' * 4)
if wp_users[0].login == "admin"
puts warning("Default first WordPress username 'admin' is still used")
@@ -442,10 +543,12 @@ def main
else
wp_users = WpUsers.new
# Username file?
if wpscan_options.usernames
File.open(wpscan_options.usernames).each do |username|
wp_users << WpUser.new(wp_target.uri, login: username.chomp)
end
# Single username?
else
wp_users << WpUser.new(wp_target.uri, login: wpscan_options.username)
end
@@ -455,7 +558,6 @@ def main
bruteforce = true
if wpscan_options.wordlist
if wp_target.has_login_protection?
protection_plugin = wp_target.login_protection_plugin()
puts
@@ -481,6 +583,7 @@ def main
else
puts critical('Brute forcing aborted')
end
spacer()
end
stop_time = Time.now
@@ -489,9 +592,9 @@ def main
puts
puts info("Finished: #{stop_time.asctime}")
puts info("Requests Done: #{@total_requests_done}")
puts info("Memory used: #{used_memory.bytes_to_human}") unless windows?
puts info("Elapsed time: #{Time.at(elapsed).utc.strftime('%H:%M:%S')}")
puts info("Requests made: #{@total_requests_done}")
puts info("Memory used: #{used_memory.bytes_to_human}") unless windows?
# do nothing on interrupt
rescue Interrupt