Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New, license violation-free version of WPScan #16336

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
195 changes: 195 additions & 0 deletions w3af/plugins/crawl/wpscan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""
wpscan.py

Copyright 2017 jose nazario

This file is part of w3af, http://w3af.org/ .

w3af 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 version 2 of the License.

w3af 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 w3af; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA

"""
import os

from itertools import repeat, izip

import w3af.core.controllers.output_manager as om
import w3af.core.data.constants.severity as severity
import w3af.core.data.kb.knowledge_base as kb

from w3af import ROOT_PATH

from w3af.core.controllers.plugins.crawl_plugin import CrawlPlugin
from w3af.core.controllers.exceptions import RunOnce
from w3af.core.controllers.core_helpers.fingerprint_404 import is_404

from w3af.core.data.options.opt_factory import opt_factory
from w3af.core.data.options.option_list import OptionList
from w3af.core.data.options.option_types import BOOL
from w3af.core.data.fuzzer.utils import rand_alnum
from w3af.core.data.db.disk_set import DiskSet
from w3af.core.data.request.fuzzable_request import FuzzableRequest
from w3af.core.data.kb.info import Info
from w3af.core.data.kb.info_set import InfoSet
from w3af.core.data.kb.vuln import Vuln
from w3af.core.controllers.misc.decorators import runonce

class wpscan(CrawlPlugin):
"""
Finds WordPress plugins by bruteforcing.

:author: jose nazario (jose@monkey.org)
"""

BASE_PATH = os.path.join(ROOT_PATH, 'plugins', 'crawl', 'wpscan')

def __init__(self):
CrawlPlugin.__init__(self)
self._update_plugins = False
self._plugin_list = []
# Internal variables
self._exec = True
self._already_tested = DiskSet(table_prefix='wpscan')

def crawl(self, fuzzable_request):
"""
Get the file and parse it.

:param fuzzable_request: A fuzzable_request instance that contains
(among other things) the URL to test.
"""
self._plugin_list = open(os.path.join(self.BASE_PATH, 'plugins.txt'), 'r').readlines()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several problems here:

  • The plugin list is kept in memory even when not used
  • The plugin list is read every time crawl is called.

Move this to the place where you use it (_dir_name_generator) and use xreadlines() to prevent the whole file from being in-memory

if not self._exec:
raise RunOnce()
else:
domain_path = fuzzable_request.get_url().get_domain_path()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plugin tries to find wordpress plugins in all site paths at least one time.

The plugin list has ~70k entries. This means that if the site has 10 paths, this plugin will generate 700k HTTP requests, which is something we can't do (at least not as default).

What do you think about implementing something like

# Check if there is a wordpress installation in this directory
domain_path = fuzzable_request.get_url().get_domain_path()
wp_unique_url = domain_path.url_join('wp-login.php')
response = self._uri_opener.GET(wp_unique_url, cache=True)
# If wp_unique_url is not 404, wordpress = true
if not is_404(response):
self._enum_users(fuzzable_request)
? What this does is check if the wp-login.php file exists in the path, if it does it will perform all the checks it wants, otherwise it just ignores the path.

If something like this is implemented, I would still keep the self._already_tested in order to prevent checking if wp-login.php exists in the path more than once.

Also, if we do it like this, we could remove this code:

        if not self._exec:
            raise RunOnce()

And everything related with it. We want to check if there site has more than one wordpress installation (in different paths), so raising RunOnce doesn't make sense.

if domain_path not in self._already_tested:
self._already_tested.add(domain_path)
self._bruteforce_plugins(domain_path)

def _dir_name_generator(self, base_path):
"""
Simple generator that returns the names of the plugins to test.

@yields: (A string with the directory,
a URL object with the dir name)
"""
for directory_name in self._plugin_list:
directory_name = "wp-content/plugins/" + directory_name.strip()
try:
dir_url = base_path.url_join(directory_name + '/')
except ValueError, ve:
msg = 'The "%s" line at "%s" generated an ' \
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This happens with the chinese / russian names in plugins.txt?

'invalid URL: %s'
om.out.debug(msg % (directory_name,
os.path.join(self.BASE_PATH, 'plugins.txt'),
ve))
else:
yield directory_name, dir_url

def _send_and_check(self, base_path, (directory_name, dir_url)):
"""
Performs a GET and verifies that the response is a 200.

:return: None, data is stored in self.output_queue
"""
try:
http_response = self._uri_opener.GET(dir_url, cache=False)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does an HTTP GET to target.com/wp-content/plugins/{plugin-name}/, correct?

What if the plugin is installed, but "directory indexing" is off? What is shown in the output when a request like this one is sent? Maybe the plugins.txt file should contain not only the plugin name but also a file in the plugin? A readme.txt or something?

except:
pass
else:
if not http_response.get_code() == 200:
return
#
# Looking good, but lets see if this is a false positive
# or not...
#
dir_url = base_path.url_join(directory_name + rand_alnum(5) + '/')
invalid_http_response = self._uri_opener.GET(dir_url,
cache=False)
if is_404(invalid_http_response):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a stupid comment, but after coding a lot in python I got used to:

if not is_404(invalid_http_response):
    return

That way, the rest of the code that goes below is indented at the same level of the if statement and you don't have "problems" with the 80 column 'restriction' imposed by PEP-8.

#
# Good, the directory_name + rand_alnum(5) return a
# 404, the original directory_name is not a false positive.
#
fr = FuzzableRequest.from_http_response(http_response)
self.output_queue.put(fr)
msg = ('wpscan plugin found "%s" at URL %s with HTTP response '
'code %s and Content-Length: %s.')
plugin_name = directory_name.split('/')[-1]
om.out.information(msg % (plugin_name,
http_response.get_url(),
http_response.get_code(),
len(http_response.get_body())))
desc = 'Found plugin: "%s"' % plugin_name
i = Info('WordPress plugin', desc, http_response.id,
self.get_name())
i.set_uri(http_response.get_uri())
i['content'] = plugin_name
i['where'] = http_response.get_url()
self.kb_append_uniq_group(self, 'wordpress-plugin', i,
group_klass=WordpressPluginInfoSet)

def _bruteforce_plugins(self, base_path):
"""
:param base_path: The base path to use in the bruteforcing process,
can be something like http://host.tld/ or
http://host.tld/images/ .

:return: None, the data is stored in self.output_queue
"""
dir_name_generator = self._dir_name_generator(base_path)
base_path_repeater = repeat(base_path)
arg_iter = izip(base_path_repeater, dir_name_generator)
self.worker_pool.map_multi_args(self._send_and_check, arg_iter,
chunksize=20)

def end(self):
self._already_tested.cleanup()

def get_options(self):
"""
:return: A list of option objects for this plugin.
"""
ol = OptionList()
return ol

def set_options(self, option_list):
"""
This method sets all the options that are configured using the user interface
generated by the framework using the result of get_options().

:param OptionList: A dictionary with the options for the plugin.

:return: No value is returned.
"""
pass

def get_long_desc(self):
"""
:return: A DETAILED description of the plugin functions and features.
"""

return """
This plugin finds WordPress plugins.
While it is not possible to fingerprint the plugin version automatically,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Challenge: Since all wordpress plugins seem to be in http://plugins.svn.wordpress.org/ and we have the different releases (tags: http://plugins.svn.wordpress.org/1000eb/tags/) it should be possible to not only identify that a plugin is present but also identify its version?

For example:

  • In the initial commit of plugin http://plugins.svn.wordpress.org/1000eb/trunk/, the file screenshot-1.png had md5sum X
  • In release 2.0 the same file has md5sum Y
  • In release 3.0 the file was removed and file screenshot-2.png was added

Using that information it should be possible to fingerprint the version, right?

I'm not saying that this should be implemented in order for the PR to be approved, but it would be a nice thing to have. After that we just need a list of vulnerable wordpress plugins and we can cross-reference them to give the user very valuable results.

they are informational findings.
"""

class WordpressPluginInfoSet(InfoSet):
ITAG = 'wordpress_plugin'
TEMPLATE = (
'The application has a WordPress plugin {{ content }} located'
' at "{{ where }}" which looks interesting and should be manually'
' reviewed.'
)