From 0d47952611198ef6b1163f366dc03922d20b1475 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 17 Apr 2024 09:42:04 +0200 Subject: Adding upstream version 7.94+git20230807.3be01efb1+dfsg. Signed-off-by: Daniel Baumann --- scripts/http-form-brute.nse | 592 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 592 insertions(+) create mode 100644 scripts/http-form-brute.nse (limited to 'scripts/http-form-brute.nse') 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 +-- +-- @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 +-- 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 -- cgit v1.2.3