summaryrefslogtreecommitdiffstats
path: root/scripts/http-form-brute.nse
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/http-form-brute.nse')
-rw-r--r--scripts/http-form-brute.nse592
1 files changed, 592 insertions, 0 deletions
diff --git a/scripts/http-form-brute.nse b/scripts/http-form-brute.nse
new file mode 100644
index 0000000..9682281
--- /dev/null
+++ b/scripts/http-form-brute.nse
@@ -0,0 +1,592 @@
+local brute = require "brute"
+local creds = require "creds"
+local http = require "http"
+local nmap = require "nmap"
+local shortport = require "shortport"
+local stdnse = require "stdnse"
+local string = require "string"
+local table = require "table"
+local tableaux = require "tableaux"
+local url = require "url"
+local rand = require "rand"
+
+description = [[
+Performs brute force password auditing against http form-based authentication.
+
+This script uses the unpwdb and brute libraries to perform password
+guessing. Any successful guesses are stored in the nmap registry, using
+the creds library, for other scripts to use.
+
+The script automatically attempts to discover the form method, action, and
+field names to use in order to perform password guessing. (Use argument
+path to specify the page where the form resides.) If it fails doing so
+the form components can be supplied using arguments method, path, uservar,
+and passvar. The same arguments can be used to selectively override
+the detection outcome.
+
+The script contains a small database of known web apps' form information. This
+improves form detection and also allows for form mangling and custom success
+detection functions. If the script arguments aren't expressive enough, users
+are encouraged to edit the database to fit.
+
+After attempting to authenticate using a HTTP GET or POST request the script
+analyzes the response and attempts to determine whether authentication was
+successful or not. The script analyzes this by checking the response using
+the following rules:
+
+1. If the response was empty the authentication was successful.
+2. If the onsuccess argument was provided then the authentication either
+ succeeded or failed depending on whether the response body contained
+ the message/pattern passed in the onsuccess argument.
+3. If no onsuccess argument was passed, and if the onfailure argument
+ was provided then the authentication either succeeded or failed
+ depending on whether the response body does not contain
+ the message/pattern passed in the onfailure argument.
+4. If neither the onsuccess nor onfailure argument was passed and the
+ response contains a form field named the same as the submitted
+ password parameter then the authentication failed.
+5. Authentication was successful.
+]]
+
+---
+-- @usage
+-- nmap --script http-form-brute -p 80 <host>
+--
+-- @output
+-- PORT STATE SERVICE REASON
+-- 80/tcp open http syn-ack
+-- | http-form-brute:
+-- | Accounts
+-- | Patrik Karlsson:secret - Valid credentials
+-- | Statistics
+-- |_ Perfomed 60023 guesses in 467 seconds, average tps: 138
+--
+-- @args http-form-brute.path identifies the page that contains the form
+-- (default: "/"). The script analyses the content of this page to
+-- determine the form destination, method, and fields. If argument
+-- passvar is specified then the form detection is not performed and
+-- the path argument is instead used as the form submission destination
+-- (the form action). Use the other arguments to define the rest of
+-- the form manually as necessary.
+-- @args http-form-brute.method sets the HTTP method (default: "POST")
+-- @args http-form-brute.hostname sets the host header in case of virtual
+-- hosting
+-- @args http-form-brute.uservar (optional) sets the form field name that
+-- holds the username used to authenticate.
+-- @args http-form-brute.passvar sets the http-variable name that holds the
+-- password used to authenticate. If this argument is set then the form
+-- detection is not performed. Use the other arguments to define
+-- the form manually.
+-- @args http-form-brute.onsuccess (optional) sets the message/pattern
+-- to expect on successful authentication
+-- @args http-form-brute.onfailure (optional) sets the message/pattern
+-- to expect on unsuccessful authentication
+-- @args http-form-brute.sessioncookies Attempt to grab session cookies before
+-- submitting the form. Setting this to "false" could speed up cracking
+-- against forms that do not require any cookies to be set before logging
+-- in. Default: true
+
+--
+-- Version 0.5
+-- Created 07/30/2010 - v0.1 - created by Patrik Karlsson <patrik@cqure.net>
+-- Revised 05/23/2011 - v0.2 - changed so that uservar is optional
+-- Revised 06/05/2011 - v0.3 - major re-write, added onsuccess, onfailure and
+-- support for redirects
+-- Revised 08/12/2014 - v0.4 - added support for GET method
+-- Revised 08/14/2014 - v0.5 - major revision
+-- - added support for submitting to a different URL
+-- than where the form resides
+-- - added detection of form action method
+-- - improved effectiveness of detection logic and
+-- patterns
+-- - added debug messages for inspection of detection
+-- results
+-- - added retry capability
+--
+
+author = {"Patrik Karlsson", "nnposter"}
+license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
+categories = {"intrusive", "brute"}
+
+
+portrule = shortport.port_or_service( {80, 443}, {"http", "https"}, "tcp", "open")
+
+
+-- Miscellaneous script-wide constants
+local max_rcount = 2 -- how many times a form submission can be redirected
+local form_debug = 1 -- debug level for printing form components
+
+--- Database of known web apps for form detection
+--
+local known_apps = {
+ joomla = {
+ match = {
+ action = "/administrator/index.php",
+ },
+ uservar = "username",
+ passvar = "passwd",
+ -- http-joomla-brute just checks for name="passwd" to indicate failure,
+ -- so default onfailure should work. TODO: get onsuccess for this app.
+ },
+ django = {
+ match = {
+ action = "/login/",
+ id = "login-form"
+ },
+ uservar = "username",
+ passvar = "password",
+ onsuccess = "Set%-Cookie:%s*sessionid=",
+ },
+ drupal = {
+ match = {
+ action = "user$",
+ id = "user%-login",
+ },
+ uservar = "name",
+ passvar = "pass",
+ onsuccess = "Location: .+user/%d",
+ sessioncookies = false,
+ },
+ mediawiki = {
+ match = {
+ action = "action=submitlogin"
+ },
+ uservar = "wpName",
+ passvar = "wpPassword",
+ onsuccess = "Set%-Cookie:[^\n]*%wUserID=%d",
+ },
+ wordpress = {
+ match = {
+ action = "wp%-login%.php$",
+ },
+ uservar = "log",
+ passvar = "pwd",
+ onsuccess = "Location:[^\n]*/wp%-admin/",
+ mangle = function(form)
+ for i, f in ipairs(form.fields) do
+ if f.name and f.name == "testcookie" then
+ table.remove(form.fields, i)
+ break
+ end
+ end
+ end,
+ sessioncookies = false,
+ },
+ websphere = {
+ match = {
+ action = "/ibm/console/j_security_check"
+ },
+ uservar = "j_username",
+ passvar = "j_password",
+ onfailure = function(response)
+ local body = response.body
+ local rpath = response.header.location
+ return response.status < 300 and body and not (
+ (rpath and rpath:match('logonError%.jsp'))
+ or (
+ body:match('Unable to login%.') or
+ body:match('Login failed%.') or
+ body:match('Invalid User ID or password')
+ )
+ )
+ end,
+ sessioncookies = false,
+ },
+}
+
+---
+-- Test whether a given string (presumably a HTML fragment) contains
+-- a given form field
+--
+-- @param html The HTML string to analyze
+-- @param fldname The field name to look for
+-- @return Verdict (true or false)
+local contains_form_field = function (html, fldname)
+ for _, f in pairs(http.grab_forms(html)) do
+ local form = http.parse_form(f)
+ for _, fld in pairs(form.fields) do
+ if fld.name == fldname then return true end
+ end
+ end
+ return false
+end
+
+local function urlencode_form(fields, uservar, username, passvar, password)
+ local parts = {}
+ for _, field in ipairs(fields) do
+ if field.name then
+ local val = field.value or ""
+ if field.name == uservar then
+ val = username
+ elseif field.name == passvar then
+ val = password
+ end
+ parts[#parts+1] = url.escape(field.name) .. "=" .. url.escape(val)
+ end
+ end
+ return table.concat(parts, "&")
+end
+
+---
+-- Detect a login form in a given HTML page
+--
+-- @param host HTTP host
+-- @param port HTTP port
+-- @param path Path for retrieving the page
+-- @return Form object (see http.parse_form() for description)
+-- or nil (if the operation failed)
+-- @return Error string that describes any failure
+-- @return cookies that were set by the request
+local detect_form = function (host, port, path, hostname)
+ local response = http.get(host, port, path, {
+ bypass_cache = true,
+ header = {Host = hostname}
+ })
+ if not (response and response.body and response.status == 200) then
+ return nil, string.format("Unable to retrieve a login form from path %q", path)
+ end
+
+ for _, f in pairs(http.grab_forms(response.body)) do
+ local form = http.parse_form(f)
+ for app, val in pairs(known_apps) do
+ local match = true
+ -- first check the 'match' table and be sure all values match
+ for k, v in pairs(val.match) do
+ -- ensure that corresponding field exists in form table also
+ match = match and form[k] and string.match(form[k], v)
+ end
+ -- then check that uservar and passvar are in this form
+ if match then
+ -- how many field names must match?
+ match = 2 - (val.uservar and 1 or 0) - (val.passvar and 1 or 0)
+ for _, field in pairs(form.fields) do
+ if field.name and
+ field.name == val.uservar or field.name == val.passvar then
+ -- found one, decrement
+ match = match - 1
+ end
+ -- Have we found them all?
+ if match <= 0 then break end
+ end
+ if match <= 0 then
+ stdnse.debug1("Detected %s login form.", app)
+ -- copy uservar, passvar, etc. from the fingerprint
+ for k, v in pairs(val) do
+ form[k] = v
+ end
+ -- apply any special mangling
+ if val.mangle then
+ val.mangle(form)
+ end
+ return form, nil, response.cookies
+ end
+ -- failed to match uservar and passvar
+ end
+ -- failed to match form
+ end
+ -- No known apps match, try generic matching
+ local unfld, pnfld, ptfld
+ for _, fld in pairs(form.fields) do
+ if fld.name then
+ local name = fld.name:lower()
+ if not unfld and name:match("^user") then
+ unfld = fld
+ end
+ if not pnfld and (name:match("^pass") or name:match("^key")) then
+ pnfld = fld
+ end
+ if not ptfld and fld.type and fld.type == "password" then
+ ptfld = fld
+ end
+ end
+ end
+ if pnfld or ptfld then
+ form.method = form.method or "GET"
+ form.uservar = (unfld or {}).name
+ form.passvar = (ptfld or pnfld).name
+ return form, nil, response.cookies
+ end
+ end
+
+ return nil, string.format("Unable to detect a login form at path %q", path)
+end
+
+-- TODO: expire cookies
+local function update_cookies (old, new)
+ for i, c in ipairs(new) do
+ local add = true
+ for j, oc in ipairs(old) do
+ if oc.name == c.name then
+ old[j] = c
+ add = false
+ break
+ end
+ end
+ if add then
+ table.insert(old, c)
+ end
+ end
+end
+
+-- make sure this path is ok as a form action.
+-- Also make sure we stay on the same host.
+local function path_ok (path, hostname, port)
+ local pparts = url.parse(path)
+ if pparts.authority then
+ if pparts.userinfo
+ or ( pparts.host ~= hostname )
+ or ( pparts.port and pparts.port ~= port.number ) then
+ return false
+ end
+ end
+ return true
+end
+
+Driver = {
+
+ new = function (self, host, port, options)
+ local o = {}
+ setmetatable(o, self)
+ self.__index = self
+ if not options.http_options then
+ -- we need to supply the no_cache directive, or else the http library
+ -- incorrectly tells us that the authentication was successful
+ options.http_options = {
+ no_cache = true,
+ bypass_cache = true,
+ redirect_ok = false,
+ cookies = options.cookies,
+ header = {
+ -- nil just means not set, so default http.lua behavior
+ Host = options.hostname,
+ ["Content-Type"] = "application/x-www-form-urlencoded"
+ }
+ }
+ end
+ o.host = host
+ o.port = port
+ o.options = options
+ -- each thread may store its params table here under its thread id
+ options.threads = options.threads or {}
+ return o
+ end,
+
+ connect = function (self)
+ -- This will cause problems, as there is no way for us to "reserve"
+ -- a socket. We may end up here early with a set of credentials
+ -- which won't be guessed until the end, due to socket exhaustion.
+ return true
+ end,
+
+ submit_form = function (self, username, password)
+ local path = self.options.path
+ local tid = stdnse.gettid()
+ local thread = self.options.threads[tid]
+ if not thread then
+ thread = {
+ -- copy of form fields so we don't clobber another thread's passvar
+ params = tableaux.tcopy(self.options.formfields),
+ -- copy of options so we don't clobber another thread's cookies
+ opts = tableaux.tcopy(self.options.http_options),
+ }
+ self.options.threads[tid] = thread
+ end
+ if self.options.sessioncookies and not (thread.opts.cookies and next(thread.opts.cookies)) then
+ -- grab new session cookies
+ local form, errmsg, cookies = detect_form(self.host, self.port, path, self.options.hostname)
+ if not form then
+ stdnse.debug1("Failed to get new session cookies: %s", errmsg)
+ else
+ thread.opts.cookies = cookies
+ thread.params = form.fields
+ end
+ end
+ local params = thread.params
+ local opts = thread.opts
+ local response
+ if self.options.method == "POST" then
+ response = http.post(self.host, self.port, path, opts, nil,
+ urlencode_form(params, self.options.uservar, username, self.options.passvar, password))
+ else
+ local uri = path
+ .. (path:find("?", 1, true) and "&" or "?")
+ .. urlencode_form(params, self.options.uservar, username, self.options.passvar, password)
+ response = http.get(self.host, self.port, uri, opts)
+ end
+ local rcount = 0
+ while response do
+ if self.options.is_success and self.options.is_success(response) then
+ -- "log out"
+ opts.cookies = nil
+ return response, true
+ end
+ -- set cookies
+ update_cookies(opts.cookies, response.cookies)
+ if self.options.is_failure and self.options.is_failure(response) then
+ return response, false
+ end
+ local status = tonumber(response.status) or 0
+ local rpath = response.header.location
+ if not (status > 300 and status < 400 and rpath and rcount < max_rcount) then
+ break
+ end
+ rcount = rcount + 1
+ path = url.absolute(path, rpath)
+ if path_ok(path, self.options.hostname, self.port) then
+ -- clean up the url (cookie check fails if path contains hostname)
+ -- this strips off the smallest prefix followed by a non-doubled /
+ path = path:gsub("^.-%f[/](/%f[^/])","%1")
+ response = http.get(self.host, self.port, path, opts)
+ else
+ -- being redirected off-host. Stop and assume failure.
+ response = nil
+ end
+ end
+ if response and self.options.is_failure then
+ -- "log out" to avoid dumb login attempt limits
+ opts.cookies = nil
+ end
+ -- Neither is_success nor is-failure condition applied. The login is deemed
+ -- a success if the script is looking for a failure (which did not occur).
+ return response, (response and self.options.is_failure)
+ end,
+
+ login = function (self, username, password)
+ local response, success = self:submit_form(username, password)
+ if not response then
+ local err = brute.Error:new("Form submission failed")
+ err:setRetry(true)
+ return false, err
+ end
+ if not success then
+ return false, brute.Error:new("Incorrect password")
+ end
+ return true, creds.Account:new(username, password, creds.State.VALID)
+ end,
+
+ disconnect = function (self)
+ return true
+ end,
+
+ check = function (self)
+ return true
+ end,
+
+}
+
+
+action = function (host, port)
+ local path = stdnse.get_script_args('http-form-brute.path') or "/"
+ local method = stdnse.get_script_args('http-form-brute.method')
+ local uservar = stdnse.get_script_args('http-form-brute.uservar')
+ local passvar = stdnse.get_script_args('http-form-brute.passvar')
+ local onsuccess = stdnse.get_script_args('http-form-brute.onsuccess')
+ local onfailure = stdnse.get_script_args('http-form-brute.onfailure')
+ local hostname = stdnse.get_script_args('http-form-brute.hostname') or stdnse.get_hostname(host)
+ local sessioncookies = stdnse.get_script_args('http-form-brute.sessioncookies')
+ -- Originally intended more granular control with "always" or other strings
+ -- to say when to grab new session cookies. For now, only boolean, though.
+ if not sessioncookies then
+ sessioncookies = true
+ elseif sessioncookies == "false" then
+ sessioncookies = false
+ end
+
+ local formfields = {}
+ local cookies = {}
+ if not passvar then
+ local form, errmsg, dcookies = detect_form(host, port, path, hostname)
+ if not form then
+ return stdnse.format_output(false, errmsg)
+ end
+ path = form.action and url.absolute(path, form.action) or path
+ method = method or form.method
+ uservar = uservar or form.uservar
+ passvar = passvar or form.passvar
+ onsuccess = onsuccess or form.onsuccess
+ onfailure = onfailure or form.onfailure
+ formfields = form.fields or formfields
+ cookies = dcookies or cookies
+ sessioncookies = form.sessioncookies == nil and sessioncookies or form.sessioncookies
+ end
+
+ -- path should not change the origin
+ if not path_ok(path, hostname, port) then
+ return stdnse.format_output(false, string.format("Unusable form action %q", path))
+ end
+ stdnse.debug(form_debug, "Form submission path: %s", path)
+
+ -- HTTP method POST is the default
+ method = string.upper(method or "POST")
+ if not (method == "GET" or method == "POST") then
+ return stdnse.format_output(false, string.format("Invalid HTTP method %q", method))
+ end
+ stdnse.debug(form_debug, "HTTP method: %s", method)
+
+ -- passvar must be specified or detected, uservar is optional
+ if not passvar then
+ return stdnse.format_output(false, "No passvar was specified or detected (see http-form-brute.passvar)")
+ end
+ stdnse.debug(form_debug, "Username field: %s", uservar or "(not set)")
+ stdnse.debug(form_debug, "Password field: %s", passvar)
+
+ if onsuccess and onfailure then
+ return stdnse.format_output(false, "Either the onsuccess or onfailure argument should be passed, not both.")
+ end
+
+ -- convert onsuccess and onfailure to functions
+ local is_success = onsuccess and (
+ type(onsuccess) == "function" and onsuccess
+ or function (response)
+ return http.response_contains(response, onsuccess, true)
+ end
+ )
+ local is_failure = onfailure and (
+ type(onfailure) == "function" and onfailure
+ or function (response)
+ return http.response_contains(response, onfailure, true)
+ end
+ )
+ -- the fallback test is to look for passvar field in the response
+ if not (is_success or is_failure) then
+ is_failure = function (response)
+ return response.body and contains_form_field(response.body, passvar)
+ end
+ end
+
+ local options = {
+ path = path,
+ method = method,
+ uservar = uservar,
+ passvar = passvar,
+ is_success = is_success,
+ is_failure = is_failure,
+ hostname = hostname,
+ formfields = formfields,
+ cookies = cookies,
+ sessioncookies = sessioncookies,
+ }
+
+ -- validate that the form submission behaves as expected
+ local username = uservar and rand.random_alpha(8)
+ local password = rand.random_alpha(8)
+ local testdrv = Driver:new(host, port, options)
+ local response, success = testdrv:submit_form(username, password)
+ if not response then
+ return stdnse.format_output(false, string.format("Failed to submit the form to path %q", path))
+ end
+ if success then
+ return stdnse.format_output(false, "Failed to recognize failed authentication. See http-form-brute.onsuccess and http-form-brute.onfailure")
+ end
+
+ local engine = brute.Engine:new(Driver, host, port, options)
+ -- there's a bug in http.lua that does not allow it to be called by
+ -- multiple threads
+ -- TODO: is this even true any more? We should fix it if not.
+ engine:setMaxThreads(1)
+ engine.options.script_name = SCRIPT_NAME
+ engine.options:setOption("passonly", not uservar)
+
+ local status, result = engine:start()
+ return result
+end