diff --git a/lib/common/collections/wp_users.rb b/lib/common/collections/wp_users.rb index a5afc430..8316b2e7 100755 --- a/lib/common/collections/wp_users.rb +++ b/lib/common/collections/wp_users.rb @@ -2,9 +2,10 @@ require 'common/collections/wp_users/detectable' require 'common/collections/wp_users/output' +require 'common/collections/wp_users/brute_forcable' class WpUsers < WpItems extend WpUsers::Detectable include WpUsers::Output - + include WpUsers::BruteForcable end diff --git a/lib/common/collections/wp_users/brute_forcable.rb b/lib/common/collections/wp_users/brute_forcable.rb new file mode 100644 index 00000000..59685d3b --- /dev/null +++ b/lib/common/collections/wp_users/brute_forcable.rb @@ -0,0 +1,11 @@ +# encoding: UTF-8 + +class WpUsers < WpItems + module BruteForcable + + def brute_force(wordlist, options = {}) + self.each { |wp_user| wp_user.brute_force(wordlist, options) } + end + + end +end diff --git a/lib/common/hacks.rb b/lib/common/hacks.rb index 8f308844..e2d49416 100644 --- a/lib/common/hacks.rb +++ b/lib/common/hacks.rb @@ -64,7 +64,7 @@ end # Override for puts to enable logging def puts(o = '') # remove color for logging - if o.respond_to?('gsub') + if o.respond_to?(:gsub) temp = o.gsub(/\e\[\d+m(.*)?\e\[0m/, '\1') File.open(LOG_FILE, 'a+') { |f| f.puts(temp) } end diff --git a/lib/common/models/wp_user.rb b/lib/common/models/wp_user.rb index 46c13dd6..d10116d5 100755 --- a/lib/common/models/wp_user.rb +++ b/lib/common/models/wp_user.rb @@ -1,9 +1,11 @@ # encoding: UTF-8 require 'wp_user/existable' +require 'wp_user/brute_forcable' class WpUser < WpItem include WpUser::Existable + include WpUser::BruteForcable attr_accessor :id, :login, :display_name, :password diff --git a/lib/common/models/wp_user/brute_forcable.rb b/lib/common/models/wp_user/brute_forcable.rb new file mode 100644 index 00000000..6230f1f2 --- /dev/null +++ b/lib/common/models/wp_user/brute_forcable.rb @@ -0,0 +1,110 @@ +# encoding: UTF-8 + +class WpUser < WpItem + module BruteForcable + + # @param [ String ] wordlist The wordlist path + # @param [ Hash ] options + # + # @return [ void ] + def brute_force(wordlist, options = {}) + hydra = Browser.instance.hydra + number_of_passwords = BruteForcable.lines_in_file(wordlist) + login_url = @uri.merge('wp-login.php').to_s + + queue_count = 0 + request_count = 0 + + File.open(wordlist, 'r').each do |line| + line.strip! + # ignore file comments, but will miss passwords if they start with a hash... + next if line[0, 1] == '#' + + request_count += 1 + queue_count += 1 + login = self.login + password = line + + request = Browser.instance.forge_request(login_url, + { + method: :post, + body: { log: URI::encode(login), pwd: URI::encode(password) }, + cache_ttl: 0 + } + ) + + request.on_complete do |response| + puts "\n Trying Username : #{login} Password : #{password}" if options[:verbose] + + if valid_password?(response, password, options) + self.password = password + return # Used as break + end + end + + hydra.queue(request) + + print "\r Brute forcing user '#{login}' with #{number_of_passwords} passwords... #{(request_count * 100) / number_of_passwords}% complete." if options[:show_progression] + + # it can take a long time to queue 2 million requests, + # for that reason, we queue @threads, send @threads, queue @threads and so on. + # hydra.run only returns when it has recieved all of its, + # responses. This means that while we are waiting for @threads, + # responses, we are waiting... + if queue_count >= Browser.instance.max_threads + hydra.run + queue_count = 0 + puts "Sent #{Browser.instance.max_threads} requests ..." if options[:verbose] + end + end + + # run all of the remaining requests + hydra.run + end + + # @param [ Typhoeus::Response ] response + # @param [ Hash ] options + # + # @return [ Boolean ] + def valid_password?(response, password, options = {}) + if response.code == 302 + puts "\n " + green('[SUCCESS]') + " Login : #{login} Password : #{password}\n" if options[:show_progression] + return true + elsif response.body =~ /login_error/i + puts "\nIncorrect login and/or password." if options[:verbose] + elsif response.timed_out? + puts red('ERROR:') + ' Request timed out.' if options[:show_progression] + elsif response.code == 0 + puts red('ERROR:') + ' No response from remote server. WAF/IPS?' if options[:show_progression] + elsif response.code.to_s =~ /^50/ + puts red('ERROR:') + ' Server error, try reducing the number of threads.' if options[:show_progression] + else + puts "\n" + red('ERROR:') + " We received an unknown response for #{password}..." if options[:show_progression] + + # HACK to get the coverage :/ (otherwise some output is present in the rspec) + puts red("Code: #{response.code}") if options[:verbose] + puts red("Body: #{response.body}") if options[:verbose] + puts if options[:verbose] + end + false + end + + # Counts the number of lines in the wordlist + # It can take a couple of minutes on large + # wordlists, although bareable. + # + # Each line which starts with # is ignored + # + # @param [ String ] file_path + # + # @return [ Integer ] + def self.lines_in_file(file_path) + lines = 0 + File.open(file_path, 'r').each do |line| + lines += 1 if line.strip[0,1] != '#' + end + lines + end + + end +end diff --git a/lib/wpscan/wp_target.rb b/lib/wpscan/wp_target.rb index 21d41a28..51a6533b 100644 --- a/lib/wpscan/wp_target.rb +++ b/lib/wpscan/wp_target.rb @@ -3,7 +3,6 @@ require 'web_site' require 'wp_target/malwares' require 'wp_target/wp_readme' -require 'wp_target/brute_force' require 'wp_target/wp_registrable' require 'wp_target/wp_config_backup' require 'wp_target/wp_login_protection' @@ -13,7 +12,6 @@ require 'wp_target/wp_full_path_disclosure' class WpTarget < WebSite include WpTarget::Malwares include WpTarget::WpReadme - include WpTarget::BruteForce include WpTarget::WpRegistrable include WpTarget::WpConfigBackup include WpTarget::WpLoginProtection diff --git a/lib/wpscan/wp_target/brute_force.rb b/lib/wpscan/wp_target/brute_force.rb deleted file mode 100644 index c1e4b6ec..00000000 --- a/lib/wpscan/wp_target/brute_force.rb +++ /dev/null @@ -1,110 +0,0 @@ -# encoding: UTF-8 - -class WpTarget < WebSite - module BruteForce - - # param array of WpUsers wp_users - # param string wordlist_path - # param hash options - # boolean :show_progression If true, will output the details (Sucess, error etc) - def brute_force(wp_users, wordlist_path, options = {}) - hydra = Browser.instance.hydra - number_of_passwords = BruteForce.lines_in_file(wordlist_path) - login_url = login_url() - found = [] - show_progression = options[:show_progression] || false - - wp_users.each do |wp_user| - queue_count = 0 - request_count = 0 - password_found = false - - File.open(wordlist_path, 'r').each do |password| - # ignore file comments, but will miss passwords if they start with a hash... - next if password[0, 1] == '#' - - password.strip! - - # keep a count of the amount of requests to be sent - request_count += 1 - queue_count += 1 - - # create local vars for on_complete call back, Issue 51. - login = wp_user.login - password = password - - # the request object - request = Browser.instance.forge_request(login_url, - { - method: :post, - body: { log: URI::encode(login), pwd: URI::encode(password) }, - cache_ttl: 0 - } - ) - - # tell hydra what to do when the request completes - request.on_complete do |response| - - puts "\n Trying Username : #{login} Password : #{password}" if @verbose - - if response.body =~ /login_error/i - puts "\nIncorrect login and/or password." if @verbose - elsif response.code == 302 - puts "\n " + green('[SUCCESS]') + " Login : #{login} Password : #{password}\n" if show_progression - found << { name: login, password: password } - password_found = true - elsif response.timed_out? - puts red('ERROR:') + ' Request timed out.' if show_progression - elsif response.code == 0 - puts red('ERROR:') + ' No response from remote server. WAF/IPS?' if show_progression - # code is a fixnum, needs a string for regex - elsif response.code.to_s =~ /^50/ - puts red('ERROR:') + ' Server error, try reducing the number of threads.' if show_progression - else - puts "\n" + red('ERROR:') + " We received an unknown response for #{password}..." if show_progression - - # HACK to get the coverage :/ (otherwise some output is present in the rspec) - puts red("Code: #{response.code.to_s}") if @verbose - puts red("Body: #{response.body}") if @verbose - puts if @verbose - end - end - - # move onto the next login if we have found a valid password - break if password_found - - # queue the request to be sent later - hydra.queue(request) - - # progress indicator - print "\r Brute forcing user '#{login}' with #{number_of_passwords} passwords... #{(request_count * 100) / number_of_passwords}% complete." if show_progression - - # it can take a long time to queue 2 million requests, - # for that reason, we queue @threads, send @threads, queue @threads and so on. - # hydra.run only returns when it has recieved all of its, - # responses. This means that while we are waiting for @threads, - # responses, we are waiting... - if queue_count >= Browser.instance.max_threads - hydra.run - queue_count = 0 - puts "Sent #{Browser.instance.max_threads} requests ..." if @verbose - end - end - - # run all of the remaining requests - hydra.run - end - found - end - - # Counts the number of lines in the wordlist - # It can take a couple of minutes on large - # wordlists, although bareable. - def self.lines_in_file(file_path) - lines = 0 - File.open(file_path, 'r').each { |_| lines += 1 } - lines - end - - end -end diff --git a/spec/lib/common/collections/wp_users_spec.rb b/spec/lib/common/collections/wp_users_spec.rb new file mode 100644 index 00000000..8ff740dd --- /dev/null +++ b/spec/lib/common/collections/wp_users_spec.rb @@ -0,0 +1,11 @@ +# encoding: UTF-8 + +require 'spec_helper' + +describe WpUsers do + it_behaves_like 'WpUsers::BruteForcable' + + subject(:wp_users) { WpUsers.new } + let(:url) { 'http://example.com/' } + let(:uri) { URI.parse(url) } +end diff --git a/spec/lib/common/models/wp_user_spec.rb b/spec/lib/common/models/wp_user_spec.rb index 12d10856..ce5c6e14 100644 --- a/spec/lib/common/models/wp_user_spec.rb +++ b/spec/lib/common/models/wp_user_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' describe WpUser do it_behaves_like 'WpUser::Existable' + it_behaves_like 'WpUser::BruteForcable' subject(:wp_user) { WpUser.new(uri, options) } let(:uri) { URI.parse('http://example.com') } diff --git a/spec/lib/wpscan/wp_target_spec.rb b/spec/lib/wpscan/wp_target_spec.rb index 1aa51df8..b852790f 100644 --- a/spec/lib/wpscan/wp_target_spec.rb +++ b/spec/lib/wpscan/wp_target_spec.rb @@ -20,7 +20,6 @@ describe WpTarget do it_behaves_like 'WpTarget::Malwares' it_behaves_like 'WpTarget::WpReadme' - it_behaves_like 'WpTarget::BruteForce' it_behaves_like 'WpTarget::WpRegistrable' it_behaves_like 'WpTarget::WpConfigBackup' it_behaves_like 'WpTarget::WpLoginProtection' diff --git a/spec/samples/wpscan/wp_target/bruteforce/wordlist.txt b/spec/samples/common/models/wp_user/brute_forcable/wordlist.txt similarity index 100% rename from spec/samples/wpscan/wp_target/bruteforce/wordlist.txt rename to spec/samples/common/models/wp_user/brute_forcable/wordlist.txt diff --git a/spec/shared_examples/wp_target/brute_force.rb b/spec/shared_examples/wp_target/brute_force.rb deleted file mode 100644 index 3218f6b3..00000000 --- a/spec/shared_examples/wp_target/brute_force.rb +++ /dev/null @@ -1,57 +0,0 @@ -# encoding: UTF-8 - -shared_examples 'WpTarget::BruteForce' do - - let(:fixtures_dir) { SPEC_FIXTURES_WPSCAN_WP_TARGET_DIR + '/bruteforce' } - let(:wordlist) { fixtures_dir + '/wordlist.txt' } - - before :each do - wp_target.stub(:login_url).and_return('http://example.localhost/wp-login.php') - - Browser.instance.max_threads = 1 - end - - describe '#lines_in_file' do - it 'returns 6' do - lines = WpTarget::BruteForce.lines_in_file(wordlist) - lines.should == 6 - end - end - - describe '#brute_force' do - - it 'gets the correct password' do - passwords = [] - File.open(wordlist, 'r').each do |password| - # ignore comments - passwords << password.strip unless password.strip[0, 1] == '#' - end - # Last status must be 302 to get full code coverage - passwords.each do |password| - stub_request(:post, wp_target.login_url). - to_return( - { status: 200, body: 'login_error' }, - { status: 0, body: 'no reponse' }, - { status: 500, body: 'server error' }, - { status: 999, body: 'invalid' }, - { status: 302, body: 'FOUND!' } - ) - end - - user = WpUser.new(wp_target.uri, login: 'admin') - result = wp_target.brute_force([user], wordlist) - - result.length.should == 1 - result.should === [{ name: 'admin', password: 'root' }] - end - - it 'covers the timeout branch and return an empty array' do - stub_request(:post, wp_target.login_url).to_timeout - - user = WpUser.new(wp_target.uri, login: 'admin') - result = wp_target.brute_force([user], wordlist) - result.should == [] - end - end - -end diff --git a/spec/shared_examples/wp_user/brute_forcable.rb b/spec/shared_examples/wp_user/brute_forcable.rb new file mode 100644 index 00000000..4e41f839 --- /dev/null +++ b/spec/shared_examples/wp_user/brute_forcable.rb @@ -0,0 +1,116 @@ +# encoding: UTF-8 + +shared_examples 'WpUser::BruteForcable' do + let(:fixtures_dir) { MODELS_FIXTURES + '/wp_user/brute_forcable' } + let(:wordlist) { fixtures_dir + '/wordlist.txt' } + let(:mod) { WpUser::BruteForcable } + let(:login_url) { uri.merge('wp-login.php').to_s } + + before { Browser.instance.max_threads = 1 } + + describe '::lines_in_file' do + it 'returns 5 (1 line is a comment)' do + lines = mod.lines_in_file(wordlist) + lines.should == 5 + end + end + + describe '#valid_password?' do + let(:response) { Typhoeus::Response.new(resp_options) } + let(:resp_options) { {} } + + after do + wp_user.valid_password?(response, 'password').should == @expected + end + + context 'when 302' do + let(:resp_options) { { code: 302 } } + + it 'returns true' do + @expected = true + end + end + + context 'when login_error' do + let(:resp_options) { { body: '