1
0
mirror of https://github.com/nmap/nmap.git synced 2025-12-06 04:31:29 +00:00

Renames the original http-wordpress-enum to http-wordpress-users and adds the new version of http-wordpress-enum which detects plugins and themes of Wordpress installations

This commit is contained in:
paulino
2015-02-09 07:14:55 +00:00
parent f160b590aa
commit 617be2ea28
5 changed files with 3054 additions and 112 deletions

View File

@@ -1,146 +1,295 @@
local coroutine = require "coroutine"
local http = require "http"
local io = require "io"
local nmap = require "nmap"
local shortport = require "shortport"
local stdnse = require "stdnse"
local string = require "string"
local table = require "table"
description = [[
Enumerates usernames in Wordpress blog/CMS installations by exploiting an information disclosure vulnerability existing in versions 2.6, 3.1, 3.1.1, 3.1.3 and 3.2-beta2 and possibly others.
Enumerates themes and plugins of Wordpress installations. The script can also detect
outdated plugins by comparing version numbers with information pulled from api.wordpress.org.
Original advisory:
* http://www.talsoft.com.ar/index.php/research/security-advisories/wordpress-user-id-and-user-name-disclosure
The script works with two separate databases for themes (wp-themes.lst) and plugins (wp-plugins.lst).
The databases are sorted by popularity and the script will search only the top 100 entries by default.
The theme database has around 32,000 entries while the plugin database has around 14,000 entries.
The script determines the version number of a plugin by looking at the readme.txt file inside the plugin
directory and it uses the file style.css inside a theme directory to determine the theme version.
If the script argument check-latest is set to true, the script will query api.wordpress.org to obtain
the latest version number available. This check is disabled by default since it queries an external service.
This script is a combination of http-wordpress-plugins.nse and http-wordpress-themes.nse originally
submited by Ange Gutek and Peter Hill.
TODO:
-Implement version checking for themes.
]]
---
-- @usage
-- nmap -p80 --script http-wordpress-enum <target>
-- nmap -sV --script http-wordpress-enum --script-args limit=50 <target>
--
-- @usage nmap -sV --script http-wordpress-enum <target>
-- @usage nmap --script http-wordpress-enum --script-args check-latest=true,search-limit=10 <target>
-- @usage nmap --script http-wordpress-enum --script-args type="themes" <target>
--
-- @args http-wordpress-enum.root Base path. By default the script will try to find a WP directory
-- installation or fall back to '/'.
-- @args http-wordpress-enum.search-limit Number of entries or the string "all". Default:100.
-- @args http-wordpress-enum.type Search type. Available options:plugins, themes or all. Default:all.
-- @args http-wordpress-enum.check-latest Enables version check. Default:false.
--
-- @output
-- PORT STATE SERVICE REASON
-- 80/tcp open http syn-ack
-- | http-wordpress-enum:
-- | Username found: admin
-- | Username found: mauricio
-- | Username found: cesar
-- | Username found: lean
-- | Username found: alex
-- | Username found: ricardo
-- |_Search stopped at ID #25. Increase the upper limit if necessary with 'http-wordpress-enum.limit'
-- PORT STATE SERVICE
-- 80/tcp open http
-- | http-wordpress-enum:
-- | Search limited to top 100 themes/plugins
-- | plugins
-- | akismet
-- | contact-form-7 4.1 (latest version:4.1)
-- | all-in-one-seo-pack (latest version:2.2.5.1)
-- | google-sitemap-generator 4.0.7.1 (latest version:4.0.8)
-- | jetpack 3.3 (latest version:3.3)
-- | wordfence 5.3.6 (latest version:5.3.6)
-- | better-wp-security 4.6.4 (latest version:4.6.6)
-- | google-analytics-for-wordpress 5.3 (latest version:5.3)
-- | themes
-- | twentytwelve
-- |_ twentyfourteen
--
-- @args http-wordpress-enum.limit Upper limit for ID search. Default: 25
-- @args http-wordpress-enum.basepath Base path to Wordpress. Default: /
-- @args http-wordpress-enum.out If set it saves the username list in this file.
-- @xmloutput
-- <table key="google-analytics-for-wordpress">
-- <elem key="installation_version">5.1</elem>
-- <elem key="latest_version">5.3</elem>
-- <elem key="name">google-analytics-for-wordpress</elem>
-- <elem key="path">/wp-content/plugins/google-analytics-for-wordpress/</elem>
-- <elem key="category">plugins</elem>
-- </table>
-- <table key="twentytwelve">
-- <elem key="category">themes</elem>
-- <elem key="path">/wp-content/themes/twentytwelve/</elem>
-- <elem key="name">twentytwelve</elem>
-- </table>
-- <elem key="title">Search limited to top 100 themes/plugins</elem>
---
author = "Paulino Calderon <calderon@websec.mx>"
license = "Same as Nmap--See http://nmap.org/book/man-legal.html"
categories = {"auth", "intrusive", "vuln"}
author = {"Ange Gutek", "Peter Hill", "Gyanendra Mishra", "Paulino Calderon"}
license = "Same as Nmap--See http://nmap.org/book/man-legal.html"
categories = {"discovery", "intrusive"}
local DEFAULT_SEARCH_LIMIT = 100
local DEFAULT_PLUGINS_PATH = '/wp-content/plugins/'
local WORDPRESS_API_URL = 'http://api.wordpress.org/plugins/info/1.0/'
portrule = shortport.http
---
-- Returns the username extracted from the url corresponding to the id passed
-- If user id doesn't exists returns false
-- @param host Host table
-- @param port Port table
-- @param path Base path to WP
-- @param id User id
-- @return false if not found otherwise it returns the username
---
local function get_wp_user(host, port, path, id)
stdnse.debug2("Trying to get username with id %s", id)
local req = http.get(host, port, path.."?author="..id, { no_cache = true})
if req.status then
stdnse.debug1("User id #%s returned status %s", id, req.status)
if req.status == 301 then
local _, _, user = string.find(req.header.location, 'https?://.*/.*/(.*)/')
return user
elseif req.status == 200 then
-- Users with no posts get a 200 response, but the name is in an RSS link.
-- http://seclists.org/nmap-dev/2011/q3/812
local _, _, user = string.find(req.body, 'https?://.-/author/(.-)/feed/')
return user
--Reads database
local function read_data_file(file)
return coroutine.wrap(function()
for line in file:lines() do
if not line:match("^%s*#") and not line:match("^%s*$") then
coroutine.yield(line)
end
end
end
return false
end)
end
---
--Returns true if WP installation exists.
--We assume an installation exists if wp-login.php is found
--@param host Host table
--@param port Port table
--@param path Path to WP
--@return True if WP was found
--
local function check_wp(host, port, path)
stdnse.debug2("Checking %swp-login.php ", path)
local req = http.get(host, port, path.."wp-login.php", {no_cache=true})
if req.status and req.status == 200 then
return true
--Checks if the plugin/theme file exists
local function existence_check_assign(act_file)
if not act_file then
return false
end
return false
local temp_file = io.open(act_file,"r")
if not temp_file then
return false
end
return temp_file
end
--Obtains version from readme.txt or style.css
local function get_version(path, typeof, host, port)
local pattern, version, versioncheck
if typeof == 'plugins' then
path = path .. "readme.txt"
pattern = 'Stable tag: ([.0-9]*)'
else
path = path .. "style.css"
pattern = 'Version: ([.0-9]*)'
end
stdnse.debug1("Extracting version of path:%s", path)
versioncheck = http.get(host, port, path)
if versioncheck.body then
version = versioncheck.body:match(pattern)
end
stdnse.debug1("Version found:", version)
return version
end
---
--Writes string to file
--Taken from: hostmap.nse
--@param filename Target filename
--@param contents String to save
--@return true when successful
local function write_file(filename, contents)
local f, err = io.open(filename, "w")
if not f then
return f, err
end
f:write(contents)
f:close()
return true
end
-- check if the plugin is the latest
local function get_latest_plugin_version(plugin)
stdnse.debug1("Retrieving the latest version of %s", plugin)
local apiurl = WORDPRESS_API_URL .. plugin .. ".json"
local latestpluginapi = http.get('api.wordpress.org', '80', apiurl)
local latestpluginpattern = '","version":"([.0-9]*)'
local latestpluginversion = latestpluginapi.body:match(latestpluginpattern)
stdnse.debug1("Latest version:%s", latestpluginversion)
return latestpluginversion
end
---
--MAIN
---
action = function(host, port)
local basepath = stdnse.get_script_args("http-wordpress-enum.basepath") or "/"
local limit = stdnse.get_script_args("http-wordpress-enum.limit") or 25
local filewrite = stdnse.get_script_args("http-wordpress-enum.out")
local output = {""}
local users = {}
--First, we check this is WP
if not(check_wp(host, port, basepath)) then
if nmap.verbosity() >= 2 then
return "[Error] Wordpress installation was not found. We couldn't find wp-login.php"
local result = {}
local file = {}
local all = {}
local bfqueries = {}
local wp_autoroot
local output_table = stdnse.output_table()
--Read script arguments
local operation_type_arg = stdnse.get_script_args(SCRIPT_NAME .. ".type") or "all"
local apicheck = stdnse.get_script_args(SCRIPT_NAME .. ".check-latest")
local wp_root = stdnse.get_script_args(SCRIPT_NAME .. ".root")
local resource_search_arg = stdnse.get_script_args(SCRIPT_NAME .. ".search-limit") or DEFAULT_SEARCH_LIMIT
local wp_themes_file = nmap.fetchfile("nselib/data/wp-themes.lst")
local wp_plugins_file = nmap.fetchfile("nselib/data/wp-plugins.lst")
if operation_type_arg == "themes" or operation_type_arg == "all" then
local theme_db = existence_check_assign(wp_themes_file)
if not theme_db then
return false, "Couldn't find wp-themes.lst in /nselib/data/"
else
return
file['themes'] = theme_db
end
end
--Incrementing ids to enum users
for i=1, tonumber(limit) do
local user = get_wp_user(host, port, basepath, i)
if user then
stdnse.debug1("Username found -> %s", user)
output[#output+1] = string.format("Username found: %s", user)
users[#users+1] = user
end
end
if filewrite and #users>0 then
local status, err = write_file(filewrite, stdnse.strjoin("\n", users))
if status then
output[#output+1] = string.format("Users saved to %s\n", filewrite)
end
if operation_type_arg == "plugins" or operation_type_arg == "all" then
local plugin_db = existence_check_assign(wp_plugins_file)
if not plugin_db then
return false, "Couldn't find wp-plugins.lst in /nselib/data/"
else
output[#output+1] = string.format("Error saving %s: %s\n", filewrite, err)
file['plugins'] = plugin_db
end
end
if resource_search_arg == "all" then
resource_search = nil
else
resource_search = tonumber(resource_search_arg)
end
-- search the website root for evidences of a Wordpress path
if not wp_root then
local target_index = http.get(host,port, "/")
if target_index.status and target_index.body then
wp_autoroot = string.match(target_index.body, "http://[%w%-%.]-/([%w%-%./]-)wp%-content")
if wp_autoroot then
wp_autoroot = "/" .. wp_autoroot
stdnse.debug(1,"WP root directory: %s", wp_autoroot)
else
stdnse.debug(1,"WP root directory: wp_autoroot was unable to find a WP content dir (root page returns %d).", target_index.status)
end
end
end
if #output > 1 then
output[#output+1] = string.format("Search stopped at ID #%s. Increase the upper limit if necessary with 'http-wordpress-enum.limit'", limit)
return stdnse.strjoin("\n", output)
--identify the 404, the script cant handle ambiguous responses
local status_404, result_404, body_404 = http.identify_404(host, port)
if not status_404 then
return stdnse.format_output(false, SCRIPT_NAME .. " unable to handle 404 pages (" .. result_404 .. ")")
end
--build a table of both directories to brute force and the corresponding WP resources' name
local resource_count=0
for key,value in pairs(file) do
local l_file = value
resource_count = 0
for line in read_data_file(l_file) do
if resource_search and resource_count >= resource_search then
break
end
local target
if wp_root then
-- Give user-supplied argument the priority
target = wp_root .. string.gsub(DEFAULT_PLUGINS_PATH, "plugins", key) .. line .. "/"
elseif wp_autoroot then
-- Maybe the script has discovered another Wordpress content directory
target = wp_autoroot .. string.gsub(DEFAULT_PLUGINS_PATH, "plugins", key) .. line .. "/"
else
-- Default WP directory is root
target = string.gsub(DEFAULT_PLUGINS_PATH, "plugins", key) .. line .. "/"
end
target = string.gsub(target, "//", "/")
table.insert(bfqueries, {target, line})
all = http.pipeline_add(target, nil, all, "GET")
resource_count = resource_count + 1
end
-- release hell...
local pipeline_returns = http.pipeline_go(host, port, all)
if not pipeline_returns then
stdnse.print_verbose(1,"got no answers from pipelined queries")
return nil
end
local response = {}
response['name'] = key
for i, data in pairs(pipeline_returns) do
-- if it's not a four-'o-four, it probably means that the plugin is present
if http.page_exists(data, result_404, body_404, bfqueries[i][1], true) then
stdnse.debug(1,"Found a plugin/theme:%s", bfqueries[i][2])
local version = get_version(bfqueries[i][1],key,host,port)
local output = nil
--We format the table for XML output
bfqueries[i].path = bfqueries[i][1]
bfqueries[i].category = key
bfqueries[i].name = bfqueries[i][2]
bfqueries[i][1] = nil
bfqueries[i][2] = nil
if version then
output = bfqueries[i].name .." ".. version
bfqueries[i].installation_version = version
--Right now we can only get the version number of plugins through api.wordpress.org
if apicheck == "true" and key=="plugins" then
latestversion = get_latest_plugin_version(bfqueries[i].name)
if latestversion then
output = output .. " (latest version:" .. latestversion .. ")"
bfqueries[i].latest_version = latestversion
end
end
else
output = bfqueries[i].name
end
output_table[bfqueries[i].name] = bfqueries[i]
table.insert(response, output)
end
end
table.insert(result, response)
bfqueries={}
all = {}
end
local len = 0
for i, v in ipairs(result) do len = len >= #v and len or #v end
if len > 0 then
output_table.title = string.format("Search limited to top %s themes/plugins", resource_count)
result.name = output_table.title
return output_table, stdnse.format_output(true, result)
else
if nmap.verbosity()>1 then
return string.format("Nothing found amongst the top %s resources,"..
"use --script-args search-limit=<number|all> for deeper analysis)", resource_count)
else
return nil
end
end
end