1
0
mirror of https://github.com/nmap/nmap.git synced 2025-12-06 04:31:29 +00:00
Files
nmap/scripts/http-enum.nse
ron c9a62acf31 A major overhaul of the http-enum.nse script:
* Cleaned up and function-ized the code. Planning to move the functions to http.lua or similar to let other scripts (like http-userdir-enum.nse) use them. 
* Better support for different HTTP error codes
* Significantly improved detection of 404 pages to prevent false positives. Some of the techniques used:
  - Request a non-existent page and check the status code
    - If it's 200, try to remove anything that may change (times, URI, filenames, etc), md5 it if ssl is available, and store it. Request a second 404 page and do the same. If they don't match, abort. 
    - If it's any other error code, store it, and look for it in addition to 404 Not Found
  - Request the root folder, /
    - If it returns a 301 Move Permanently or 401 Authentication Required, abort
* Abstracted the fingerprints into files in the nselib/data directory:
  - http-fingerprints: common files/folders
  - yokoso-fingerprints: common applications (from the Yokoso project, by InGuardians -- used under the Nmap license with pemission from Kevin Johnson -- http://seclists.org/nmap-dev/2009/q3/0685.html)
* Added a script-arg that can display all output (except known 404s), instead of just 200 OK and 401 Authentication Required
* Added a lot of debugging messages
2009-08-22 23:19:16 +00:00

447 lines
18 KiB
Lua

description = [[
Enumerates directories used by popular web applications and servers.
This parses fingerprint files that are properly formatted. Multiple files are included
with Nmap, including:
* http-fingerprints: These attempt to find common files and folders. For the most part, they were in the original http-enum.nse.
* yokoso-fingerprints: These are application-specific fingerprints, designed for finding the presense of specific applications/hardware, including Sharepoint, Forigate's Web interface, Arcsight SmartCollector appliances, Outlook Web Access, etc. These are from the Yokoso project, by InGuardians, and included with permission from Kevin Johnson <http://seclists.org/nmap-dev/2009/q3/0685.html>.
Initially, this script attempts to access two different random files in order to detect servers
that don't return a proper 404 Not Found status. In the event that they return 200 OK, the body
has any non-static-looking data removed (URI, time, etc), and saved. If the two random attempts
return different results, the script aborts (since a 200-looking 404 cannot be distinguished from
an actual 200). This will prevent most false positives.
In addition, if the root folder returns a 301 Moved Permanently or 401 Authentication Required,
this script will also abort. If the root folder has disappeared or requires authentication, there
is little hope of finding anything inside it.
By default, only pages that return 200 OK or 401 Authentication Required are displayed. If the
script-arg <code>displayall</code> is set, however, then all results will be displayed (except
for 404 Not Found and the status code returned by the random files).
]]
---
--@output
-- Interesting ports on test.skullsecurity.org (208.81.2.52):
-- PORT STATE SERVICE REASON
-- 80/tcp open http syn-ack
-- | http-enum:
-- | /icons/ Icons and images
-- |_ /x_logo.gif Xerox Phaser Printer
--
--
--@args displayall Set to '1' or 'true' to display all status codes that may indicate a valid page, not just
-- "200 OK" and "401 Authentication Required" pages. Although this is more likely to find certain
-- hidden folders, it also generates far more false positives.
--@args limit Limit the number of folders to check. This option is useful if using a list from, for example,
-- the DirBuster projects which can have 80,000+ entries.
author = "Ron Bowes <ron@skullsecurity.net>, Andrew Orr <andrew@andreworr.ca>, Rob Nicholls <robert@everythingeverything.co.uk>"
license = "Same as Nmap--See http://nmap.org/book/man-legal.html"
categories = {"discovery", "intrusive", "vuln"}
require 'stdnse'
require 'http'
require 'stdnse'
---Use ssl if we have it
local have_ssl = (nmap.have_ssl() and pcall(require, "openssl"))
-- The 404 used for URL checks
local URL_404 = '/Nmap404Check' .. os.time(os.date('*t'))
-- The directory where the fingerprint files are stored
local FILENAME_BASE = "nselib/data/"
-- List of fingerprint files
local fingerprint_files = { "http-fingerprints", "yokoso-fingerprints" }
portrule = function(host, port)
local svc = { std = { ["http"] = 1, ["http-alt"] = 1 },
ssl = { ["https"] = 1, ["https-alt"] = 1 } }
if port.protocol ~= 'tcp'
or not ( svc.std[port.service] or svc.ssl[port.service] ) then
return false
end
-- Don't bother running on SSL ports if we don't have SSL.
if (svc.ssl[port.service] or port.version.service_tunnel == 'ssl')
and not nmap.have_ssl() then
return false
end
return true
end
local function get_status_string(data)
-- Make sure we have valid data
if(data == nil) then
return "<unknown status>"
elseif(data['status-line'] == nil) then
if(data['status'] ~= nil) then
return data['status']
end
return "<unknown status>"
end
-- We basically want everything after the space
local space = string.find(data['status-line'], ' ')
if(space == nil) then
return data['status-line']
else
return string.sub(data['status-line'], space + 1)
end
end
local function add_from_files(entries)
local PREAUTH = "# Pre-Auth"
local POSTAUTH = "# Post-Auth"
local i
for i = 1, #fingerprint_files, 1 do
local filename = FILENAME_BASE .. fingerprint_files[i]
local filename_full = nmap.fetchfile(filename)
local count = 0
if(filename_full == nil) then
stdnse.print_debug(1, "http-enum: Couldn't find fingerprints file: %s", filename)
else
stdnse.print_debug(1, "http-enum: Attempting to parse fingerprint file %s", filename)
local product = ""
for line in io.lines(filename) do
-- Ignore "Pre-Auth", "Post-Auth", and blank lines
if(string.sub(line, 1, #PREAUTH) ~= PREAUTH and string.sub(line, 1, #POSTAUTH) ~= POSTAUTH and #line > 0) then
-- Commented lines indicate products
if(string.sub(line, 1, 1) == "#") then
product = string.sub(line, 3)
else
table.insert(entries, {checkdir=line, checkdesc=product})
count = count + 1
end
end
end
stdnse.print_debug(1, "http-enum: Added %d entries from file %s", count, filename)
end
end
return entries
end
---Determine whether or not the server supports HEAD by requesting '/' and verifying that it returns
-- 200, and doesn't return data. We implement the check like this because can't always rely on OPTIONS to
-- tell the truth.
--
--@param host The host object.
--@param port The port to use -- note that SSL will automatically be used, if necessary.
--@return A boolean value: true if HEAD is usable, false otherwise.
local function check_head(host, port)
local data = http.head( host, port, '/' )
if data then
if data.status and data.status == 302 and data.header and data.header.location then
stdnse.print_debug(1, "http-enum.nse: Warning: Host returned 302 and not 200 when performing HEAD.")
return false
end
if data.status and data.status == 200 and data.header then
-- check that a body wasn't returned
if string.len(data.body) > 0 then
stdnse.print_debug(1, "http-enum.nse: Warning: Host returned data when performing HEAD.")
return false
end
stdnse.print_debug(1, "http-enum.nse: Host supports HEAD.")
return true
end
stdnse.print_debug(1, "http-enum.nse: Didn't receive expected response to HEAD request (got %s).", get_status_string(data))
return false
end
stdnse.print_debug(1, "http-enum.nse: HEAD request completely failed.")
return false
end
---Determine whether or not we can actually scan the server (if a 301 is returned, that's bad).
--
--@param host The host object.
--@param port The port to use -- note that SSL will automatically be used, if necessary.
--@return (result, message) result is a boolean: true means we're good to go, false means there's an error.
-- The error is returned in message.
local function check_get(host, port)
stdnse.print_debug(1, "Checking if a GET request is going to work out")
-- Try getting the root directory
local data = http.get( host, port, '/' )
if(data == nil) then
return false, "GET request returned nil. Is the server still up?"
end
-- If the root directory is a permanent redirect, we're going to run into troubles
if(data.status == 301) then
if(data.header and data.header.location) then
return false, string.format("GET request returned %s -- try scanning %s instead, if possible", get_status_string(data), data.header.location)
else
return false, string.format("GET request returned %s -- site is trying to redirect us, but didn't say where", get_status_string(data))
end
end
-- If the root directory requires authentication, we're outta luck
if(data.status == 401) then
return false, string.format("Root directory required authentication -- giving up (%s)", get_status_string(data))
end
stdnse.print_debug(1, "It appears that the GET request will work")
return true
end
---Try and remove anything that might change within a 404. For example:
-- * A file path (includes URI)
-- * A time
-- * A date
-- * An execution time (numbers in general, really)
--
-- The intention is that two 404 pages from different URIs and taken hours apart should, whenever
-- possible, look the same.
--
-- During this function, we're likely going to over-trim things. This is fine -- we want enough to match on that it'll a) be unique,
-- and b) have the best chance of not changing. Even if we remove bits and pieces from the file, as long as it isn't a significant
-- amount, it'll remain unique.
--
-- One case this doesn't cover is if the server generates a random haiku for the user.
--
--@param body The body of the page.
--@param uri The URI that the page came from.
local function clean_404(body)
-- Remove anything that looks like time
body = string.gsub(body, '%d?%d:%d%d:%d%d', "")
body = string.gsub(body, '%d%d:%d%d', "")
body = string.gsub(body, 'AM', "")
body = string.gsub(body, 'am', "")
body = string.gsub(body, 'PM', "")
body = string.gsub(body, 'pm', "")
-- Remove anything that looks like a date (this includes 6 and 8 digit numbers)
-- (this is probably unnecessary, but it's getting pretty close to 11:59 right now, so you never know!)
body = string.gsub(body, '%d%d%d%d%d%d%d%d', "") -- 4-digit year (has to go first, because it overlaps 2-digit year)
body = string.gsub(body, '%d%d%d%d%-%d%d%-%d%d', "")
body = string.gsub(body, '%d%d%d%d/%d%d/%d%d', "")
body = string.gsub(body, '%d%d%-%d%d%-%d%d%d%d', "")
body = string.gsub(body, '%d%d%/%d%d%/%d%d%d%d', "")
body = string.gsub(body, '%d%d%d%d%d%d', "") -- 2-digit year
body = string.gsub(body, '%d%d%-%d%d%-%d%d', "")
body = string.gsub(body, '%d%d%/%d%d%/%d%d', "")
-- Remove anything that looks like a path (note: this will get the URI too) (note2: this interferes with the date removal above, so it can't be moved up)
body = string.gsub(body, "/[^ ]+", "") -- Unix - remove everything from a slash till the next space
body = string.gsub(body, "[a-zA-Z]:\\[^ ]+", "") -- Windows - remove everything from a "x:\" pattern till the next space
-- If we have SSL available, save us a lot of memory by hashing the page (if SSL isn't available, this will work fine, but
-- take up more memory). If we're debugging, don't hash (it makes things far harder to debug).
if(have_ssl and nmap.debugging() == 0) then
return openssl.md5(body)
end
--io.write(body .. "\n\n")
return body
end
---Try requesting a non-existent file to determine how the server responds to unknown pages ("404 pages"). If the server
-- responds with a 404 status code, as it is supposed to, then this function simply returns 404. If it contains one
-- of a series of common error codes, including unauthorized, moved, and others, it is returned like a 404.
--
-- If, however, the 404 page returns a 200 status code, it gets interesting. First, it attempts to clean the returned
-- body (see <code>clean_404</code> for details). Once any dynamic-looking data has been removed from the string, another
-- 404 page is requested. If the response isn't identical to the first 404 page, an error is returned. The reason is,
-- obviously, because we now have no way to tell a valid page from an invalid one.
--
--@param host The host object.
--@param port The port to which we are establishing the connection.
--@return (status, result, body) If status is false, result is an error message. Otherwise, result is the code to expect and
-- body is the cleaned-up body (or a hash of the cleaned-up body).
local function identify_404(host, port)
local data
local bad_responses = { 301, 302, 401, 403, 499, 501 }
data = http.get(host, port, URL_404)
if(data == nil) then
stdnse.print_debug(1, "http-enum.nse: Failed while testing for 404 error message")
return false, "Failed while testing for 404 error message"
end
if(data.status and data.status == 404) then
stdnse.print_debug(1, "http-enum.nse: Host returns proper 404 result.")
return true, 404
end
if(data.status and data.status == 200) then
stdnse.print_debug(1, "http-enum.nse: Host returns 200 instead of 404.")
-- Clean up the body (for example, remove the URI). This makes it easier to validate later
if(data.body) then
-- Obtain another 404, with a different URI, to make sure things are consistent -- if they aren't, there's little hope
local data2 = http.get(host, port, URL_404 .. "-2")
if(data2 == nil) then
stdnse.print_debug(1, "http-enum.nse: Failed while testing for second 404 error message")
return false, "Failed while testing for second 404 error message"
end
-- Check if the return code became something other than 200
if(data2.status ~= 200) then
if(data2.status == nil) then
data2.status = "<unknown>"
end
stdnse.print_debug(1, "http-enum.nse: HTTP 404 status changed during request (become %d; server is acting very strange).", data2.status)
return false, string.format("HTTP 404 status changed during request (became %d; server is acting very strange).", data2.status)
end
-- Check if the returned body (once cleaned up) matches the first returned body
local clean_body = clean_404(data.body)
local clean_body2 = clean_404(data2.body)
if(clean_body ~= clean_body2) then
stdnse.print_debug(1, "http-enum.nse: Two known 404 pages returned valid and different pages; unable to identify valid response.")
stdnse.print_debug(1, "http-enum.nse: If you investigate the server and it's possible to clean up the pages, please post to nmap-dev mailing list.")
return false, string.format("Two known 404 pages returned valid and different pages; unable to identify valid response.")
end
return true, 200, clean_body
end
stdnse.print_debug(1, "http-enum.nse: The 200 response didn't contain a body.")
return true, 200
end
-- Loop through any expected error codes
for _,code in pairs(bad_responses) do
if(data.status and data.status == code) then
stdnse.print_debug(1, "http-enum.nse: Host returns %s instead of 404 File Not Found.", get_status_string(data))
return true, code
end
end
stdnse.print_debug(1, "Unexpected response returned for 404 check: %s", get_status_string(data))
-- io.write("\n\n" .. nsedebug.tostr(data) .. "\n\n")
return false, string.format("Unexpected response returned for 404 check: %s", get_status_string(data))
end
action = function(host, port)
local safeURLcheck = { }
local response = " \n"
-- Add URLs from external files
safeURLcheck = add_from_files(safeURLcheck)
-- Check if we can use HEAD requests
local use_head = check_head(host, port)
-- If we can't use HEAD, make sure we can use GET requests
if(use_head == false) then
local result, err = check_get(host, port)
if(result == false) then
if(nmap.debugging() > 0) then
return "ERROR: " .. err
else
return nil
end
end
end
-- Check what response we get for a 404
local result, result_404, known_404 = identify_404(host, port)
if(result == false) then
if(nmap.debugging() > 0) then
return "ERROR: " .. result_404
else
return nil
end
end
-- need to be able to check body if returned
if(known_404 ~= nil) then
use_head = false
end
-- Queue up the checks
local all = {}
local i
for i = 1, #safeURLcheck, 1 do
if(nmap.registry.args.limit and i > tonumber(nmap.registry.args.limit)) then
stdnse.print_debug(1, "http-enum.nse: Reached the limit (%d), stopping", nmap.registry.args.limit)
break;
end
if(use_head) then
all = http.pHead(host, port, safeURLcheck[i].checkdir, nil, nil, all)
else
all = http.pGet(host, port, safeURLcheck[i].checkdir, nil, nil, all)
end
end
local results = http.pipeline(host, port, all, nil)
-- check for http.pipeline error
if(results == nil) then
stdnse.print_debug(1, "http-enum.nse: http.pipeline returned nil")
if(nmap.debugging() > 0) then
return "ERROR: http.pipeline returned nil"
else
return nil
end
end
for i, data in pairs(results) do
if(data and data.status) then
-- Handle the most complicated case first: the "200 Ok" response
if(data.status == 200) then
-- If the 404 response is also "200", deal with it
if(result_404 == 200) then
if(clean_404(data.body) ~= known_404) then
stdnse.print_debug(1, "http-enum.nse: Page returned that doesn't match the 404 body (%s: %s)", safeURLcheck[i].checkdir, safeURLcheck[i].checkdesc)
response = response .. safeURLcheck[i].checkdir .. " " .. safeURLcheck[i].checkdesc .. "\n"
end
else
-- If 404s return something other than 200, and we got a 200, we're good to go
stdnse.print_debug(1, "http-enum.nse: Page was '%s' (%s: %s)", get_status_string(data), safeURLcheck[i].checkdir, safeURLcheck[i].checkdesc)
response = response .. safeURLcheck[i].checkdir .. " " .. safeURLcheck[i].checkdesc .. "\n"
end
else
-- If the response isn't a 200, check it against what we expect a 404 to be
if(data.status ~= result_404 and data.status ~= 404) then
-- If this check succeeded, then the page isn't a standard 404 -- it could be a redirect, authentication request, etc. Unless the user
-- asks for everything (with a script argument), only display 401 Authentication Required here.
stdnse.print_debug(1, "http-enum.nse: Page didn't match the 404 response (%s: %s [%s])", safeURLcheck[i].checkdir, safeURLcheck[i].checkdesc, get_status_string(data))
if(data.status == 401) then -- "Authentication Required"
response = response .. safeURLcheck[i].checkdir .. " " .. safeURLcheck[i].checkdesc .. " (" .. get_status_string(data) .. ")\n"
else
--
if(nmap.registry.args.displayall == '1' or nmap.registry.args.displayall == "true") then
response = response .. safeURLcheck[i].checkdir .. " " .. safeURLcheck[i].checkdesc .. " (" .. get_status_string(data) .. ")\n"
end
end
end
end
else
stdnse.print_debug(1, "http-enum.nse: HTTP request failed to return either a status or data")
end
end
if string.len(response) > 2 then
return response
end
return nil
end