diff --git a/scripts/http-default-accounts.nse b/scripts/http-default-accounts.nse
index fbf38ec9a..5fbb86bcb 100644
--- a/scripts/http-default-accounts.nse
+++ b/scripts/http-default-accounts.nse
@@ -7,12 +7,16 @@ local stdnse = require "stdnse"
local table = require "table"
description = [[
-Tests for access with default credentials used by a variety of web applications and devices.
+Tests for access with default credentials used by a variety of web applications
+and devices. It detects applications by matching web responses of known paths
+and launching a login routine using default credentials when found.
-It works similar to http-enum, we detect applications by matching known paths and launching a login routine using default credentials when found.
-This script depends on a fingerprint file containing the target's information: name, category, location paths, default credentials and login routine.
+This script depends on a fingerprint file containing the target's information:
+name, category, location paths, default credentials, and detection and login
+logic routines.
-You may select a category if you wish to reduce the number of requests. We have categories like:
+You may select a category if you wish to reduce the number of requests.
+We have categories like:
* web - Web applications
* routers - Routers
* security - CCTVs and other security devices
@@ -22,13 +26,21 @@ You may select a category if you wish to reduce the number of requests. We have
* virtualization - Virtualization systems
* console - Remote consoles
-You can also select a specific fingerprint or a brand, such as BIG-IQ or Siemens. This matching is based on case-insensitive words. This means that "nas" will select Seagate BlackArmor NAS storage but not Netgear ReadyNAS.
+You can also select a specific fingerprint or a brand, such as BIG-IQ or
+Siemens. This matching is based on case-insensitive words in the fingerprint
+name. This means that "nas" will select fingerprint "Seagate BlackArmor NAS",
+but not "Netgear ReadyNAS".
-For a fingerprint to be used it needs to satisfy both the category and name criteria.
+For a fingerprint to be used, it needs to satisfy both the category and name
+criteria.
-By default, the script produces output only when default credentials are found, while staying silent when the target only matches some fingerprints (but no credentials are found). With increased verbosity (option -v), the script will also report all matching fingerprints.
+By default, the script produces output only when default credentials are found,
+while staying silent when the target only matches some fingerprints (but no
+credentials are found). With increased verbosity (option -v), the script will
+also report all matching fingerprints.
-Please help improve this script by adding new entries to nselib/data/http-default-accounts.lua
+Please help improve this script by adding new entries to
+nselib/data/http-default-accounts-fingerprints.lua
Remember each fingerprint must have:
* name - Descriptive name
@@ -38,7 +50,8 @@ Remember each fingerprint must have:
* login_check - Login function of the target
In addition, a fingerprint should have:
-* target_check - Target validation function. If defined, it will be called to validate the target before attempting any logins.
+* target_check - Target validation function. If defined, it will
+ be called to validate the target before attempting any logins.
* cpe - Official CPE Dictionary entry (see https://nvd.nist.gov/cpe.cfm)
Default fingerprint file: /nselib/data/http-default-accounts-fingerprints.lua
@@ -80,10 +93,15 @@ This script was based on http-enum.
--
--
--
--- @args http-default-accounts.basepath Base path to append to requests. Default: "/"
--- @args http-default-accounts.fingerprintfile Fingerprint filename. Default: http-default-accounts-fingerprints.lua
--- @args http-default-accounts.category Selects a fingerprint category (or a list of categories).
--- @args http-default-accounts.name Selects fingerprints by a word (or a list of alternate words) included in their names.
+-- @args http-default-accounts.basepath Base path to append to requests.
+-- Default: "/"
+-- @args http-default-accounts.fingerprintfile Fingerprint file name (assumed
+-- in directory nselib/data).
+-- Default: http-default-accounts-fingerprints.lua
+-- @args http-default-accounts.category Selects a fingerprint category
+-- (or a list of categories).
+-- @args http-default-accounts.name Selects fingerprints by a word
+-- (or a list of alternate words) in their names.
-- Revision History
-- 2013-08-13 nnposter
@@ -91,19 +109,25 @@ This script was based on http-enum.
-- 2014-04-27
-- * changed category from safe to intrusive
-- 2016-08-10 nnposter
--- * added sharing of probe requests across fingerprints
+-- * Share probe requests across fingerprints
-- 2016-10-30 nnposter
--- * removed a limitation that prevented testing of systems returning
+-- * Rectify a limitation that prevented testing of systems returning
-- status 200 for non-existent pages.
-- 2016-12-01 nnposter
--- * implemented XML structured output
--- * changed classic output to report empty credentials as
+-- * Implement XML structured output
+-- * Change classic output to report empty credentials as
-- 2016-12-04 nnposter
--- * added CPE entries to individual fingerprints (where known)
+-- * Add CPE entries to individual fingerprints (where known)
-- 2018-12-17 nnposter
--- * added ability to select fingerprints by their name
+-- * Add ability to select fingerprints by their name
-- 2020-07-11 nnposter
--- * added reporting of all matched fingerprints when verbosity is increased
+-- * Report all matched fingerprints when verbosity is increased
+-- 2025-11-12 nnposter
+-- * Enforce mandatory fingerprint elements
+-- * Stop testing of passwords as soon as the correct password for a given
+-- username is found
+-- * The default target_check function is now only built when some
+-- of the loaded fingerprints lack their own.
---
author = {"Paulino Calderon ", "nnposter"}
@@ -113,71 +137,138 @@ categories = {"discovery", "auth", "intrusive"}
portrule = shortport.http
---
---validate_fingerprints(fingerprints)
---Returns an error string if there is something wrong with
---fingerprint table.
---Modified version of http-enums validation code
---@param fingerprints Fingerprint table
---@return Error string if its an invalid fingerprint table
+-- Tests if a given argument is an array, namely that its type is table
+-- and that its indices represent an uninterrupted sequence of integers,
+-- starting with 1. Empty table is considered to be an array.
+-- @param tbl Argument to test
+-- @return verdict (true or false)
---
-local function validate_fingerprints(fingerprints)
+local function is_array (tbl)
+ if type(tbl) ~= "table" then return false end
- for i, fingerprint in pairs(fingerprints) do
- if(type(i) ~= 'number') then
- return "The 'fingerprints' table is an array, not a table; all indexes should be numeric"
- end
- -- Validate paths
- if(not(fingerprint.paths) or
- (type(fingerprint.paths) ~= 'table' and type(fingerprint.paths) ~= 'string') or
- (type(fingerprint.paths) == 'table' and #fingerprint.paths == 0)) then
- return "Invalid path found in fingerprint entry #" .. i
- end
- if(type(fingerprint.paths) == 'string') then
- fingerprint.paths = {fingerprint.paths}
- end
- for i, path in pairs(fingerprint.paths) do
- -- Validate index
- if(type(i) ~= 'number') then
- return "The 'paths' table is an array, not a table; all indexes should be numeric"
- end
- -- Convert the path to a table if it's a string
- if(type(path) == 'string') then
- fingerprint.paths[i] = {path=fingerprint.paths[i]}
- path = fingerprint.paths[i]
- end
- -- Make sure the paths table has a 'path'
- if(not(path['path'])) then
- return "The 'paths' table requires each element to have a 'path'."
- end
- end
- -- Check login combos
- for i, combo in pairs(fingerprint.login_combos) do
- -- Validate index
- if(type(i) ~= 'number') then
- return "The 'login_combos' table is an array, not a table; all indexes should be numeric"
- end
- -- Make sure the login_combos table has at least one login combo
- if(not(combo['username']) or not(combo["password"])) then
- return "The 'login_combos' table requires each element to have a 'username' and 'password'."
- end
+ local max, count = 0, 0
+ for k in next, tbl do
+ -- keys must be positive integers
+ if type(k) ~= "number" or k <= 0 or k % 1 ~= 0 then
+ return false
end
+ if k > max then max = k end
+ count = count + 1
+ end
- -- Make sure they include the login function
- if(type(fingerprint.login_check) ~= "function") then
- return "Missing or invalid login_check function in entry #"..i
+ -- there must be no index gaps
+ return count == max
+end
+
+local fingerprint_checks = stdnse.output_table()
+
+fingerprint_checks.struct = function (fpr)
+ if type(fpr) ~= "table" then
+ return false, "Fingerprint is not a table"
+ end
+ return true, fpr
+end
+
+fingerprint_checks.name = function (fpr)
+ local name = fpr.name
+ if type(name) ~= "string" then
+ return false, "Missing or invalid name"
+ end
+ return true, name
+end
+
+fingerprint_checks.category = function (fpr)
+ local category = fpr.category
+ if type(category) ~= "string" then
+ return false, "Missing or invalid category"
+ end
+ return true, category
+end
+
+fingerprint_checks.paths = function (fpr)
+ local paths = fpr.paths
+ if type(paths) == "string" then
+ paths = {paths}
+ fpr.paths = paths
+ end
+ if not is_array(paths) then
+ return false, "Invalid or missing 'paths' array"
+ end
+ if #paths == 0 then
+ return false, "Empty 'paths' array"
+ end
+ for i, path in ipairs(paths) do
+ -- Convert the path to a table if necessary
+ if type(path) == "string" then
+ path = {['path'] = path}
+ paths[i] = path
end
- -- Make sure that the target validation is a function
- if(fingerprint.target_check and type(fingerprint.target_check) ~= "function") then
- return "Invalid target_check function in entry #"..i
+ if type(path) ~= "table" then
+ return false, ("'paths' entry #%d is not a table"):format(i)
end
- -- Are they missing any fields?
- if(fingerprint.category and type(fingerprint.category) ~= "string") then
- return "Missing or invalid category in entry #"..i
- end
- if(fingerprint.name and type(fingerprint.name) ~= "string") then
- return "Missing or invalid name in entry #"..i
+ if type(path.path) ~= "string" then
+ return false, ("'paths' entry #%d is missing element 'path'"):format(i)
end
end
+ return true, paths
+end
+
+fingerprint_checks.combos = function (fpr)
+ local combos = fpr.login_combos
+ if not is_array(combos) then
+ return false, "Invalid or missing 'login_combos' array"
+ end
+ if #combos == 0 then
+ return false, "Empty 'login_combos' array"
+ end
+ for i, combo in pairs(combos) do
+ if type(combo) ~= "table" then
+ return false, ("'login_combos' entry #%d is not a table"):format(i)
+ end
+ if not (type(combo.username) == "string"
+ and type(combo.password) == "string") then
+ return false, ("'login_combos' entry #%d requires to have a 'username' and 'password'"):format(i)
+ end
+ end
+return true, combos
+end
+
+fingerprint_checks.target_check = function (fpr)
+ local target_check = fpr.target_check
+ if target_check and type(target_check) ~= "function" then
+ return "Invalid target_check function"
+ end
+ return true, target_check
+end
+
+fingerprint_checks.login_check = function (fpr)
+ local login_check = fpr.login_check
+ if type(login_check) ~= "function" then
+ return "Missing or invalid login_check function"
+ end
+ return true, login_check
+end
+
+---
+-- Validates that a given argument is a properly structed collection
+-- of fingerprints.
+-- @param fingerprints Fingerprint table
+-- @return Verdict (true or false)
+-- @return Error string describing the first encountered irregularity
+---
+local function validate_fingerprints (fingerprints)
+ if not is_array(fingerprints) then
+ return false, "Invalid or missing 'fingerprints' array"
+ end
+ for i, fpr in ipairs(fingerprints) do
+ for _, check in pairs(fingerprint_checks) do
+ local status, err = check(fpr)
+ if not status then
+ return status, ("Fingerprint #%d: %s"):format(i, err)
+ end
+ end
+ end
+ return true
end
-- Simplify unlocking the mutex, ensuring we don't try to load the fingerprints
@@ -239,9 +330,9 @@ local function load_fingerprints(filename, catlist, namelist)
fingerprints = env.fingerprints
-- Validate fingerprints
- local valid_flag = validate_fingerprints(fingerprints)
- if type(valid_flag) == "string" then
- return bad_prints(mutex, valid_flag)
+ local status, err = validate_fingerprints(fingerprints)
+ if not status then
+ return bad_prints(mutex, err)
end
-- Category filter
@@ -290,12 +381,35 @@ local function load_fingerprints(filename, catlist, namelist)
return bad_prints(mutex, "No fingerprints were loaded after processing ".. filename)
end
- -- Cache the fingerprints for other scripts, so we aren't reading the files every time
+ -- Cache the fingerprints for other invocations, so we aren't reading the files every time
nmap.registry.http_default_accounts_fingerprints = fingerprints
mutex "done"
return true, fingerprints
end
+---
+-- Generates the default target_check function, which will be used with
+-- fingerprints that lack their own. This default check is just testing
+-- for existence of the probe path on the target.
+-- @param host table as received by the scripts action method
+-- @param port table as received by the scripts action method
+-- @return target_check function
+---
+local function target_check_404 (host, port)
+ -- Determine the target's response to "404" HTTP requests
+ local status_404, result_404, known_404 = http.identify_404(host, port)
+ -- To reduce false-positives, the default target_check will fail if "404"
+ -- responses from the target either cannot be properly identified or they
+ -- have HTTP status 200
+ if not status_404 or result_404 == 200 then
+ return function () return false end
+ end
+ -- The default target_check is the existence of the probe path on the target
+ return function (_host, _port, path, response)
+ return http.page_exists(response, result_404, known_404, path, true)
+ end
+end
+
---
-- format_basepath(basepath)
-- Modifies a given path so that it can be later prepended to another absolute
@@ -325,78 +439,81 @@ end
-- @return txtout table suitable for inclusion in the script textual output
---
local function test_credentials (host, port, fingerprint, path)
- local credlst = {}
+ local credhits = stdnse.output_table()
for _, login_combo in ipairs(fingerprint.login_combos) do
local user = login_combo.username
local pass = login_combo.password
- stdnse.debug(1, "[%s] Trying login combo %s:%s", fingerprint.name,
- stdnse.string_or_blank(user), stdnse.string_or_blank(pass))
- if fingerprint.login_check(host, port, path, user, pass) then
- stdnse.debug(1, "[%s] Valid default credentials found", fingerprint.name)
- local cred = stdnse.output_table()
- cred.username = user
- cred.password = pass
- table.insert(credlst, cred)
+ if not credhits[user] then
+ stdnse.debug(1, "[%s] Trying login combo %s:%s", fingerprint.name,
+ stdnse.string_or_blank(user), stdnse.string_or_blank(pass))
+ if fingerprint.login_check(host, port, path, user, pass) then
+ stdnse.debug(1, "[%s] Valid default credentials found", fingerprint.name)
+ credhits[user] = pass
+ end
end
end
- if #credlst == 0 and nmap.verbosity() < 2 then return nil end
+ if #credhits == 0 and nmap.verbosity() < 2 then return nil end
-- Some credentials found or increased verbosity. Generate the output report
local out = stdnse.output_table()
out.cpe = fingerprint.cpe
out.path = path
- out.credentials = credlst
+ out.credentials = {}
local txtout = {}
txtout.name = ("[%s] at %s"):format(fingerprint.name, path)
- if #credlst == 0 then
+ if #credhits == 0 then
table.insert(txtout, "(no valid default credentials found)")
return out, txtout
end
- for _, cred in ipairs(credlst) do
- table.insert(txtout,("%s:%s"):format(stdnse.string_or_blank(cred.username),
- stdnse.string_or_blank(cred.password)))
+ for user, pass in pairs(credhits) do
+ local cred = stdnse.output_table()
+ cred.username = user
+ cred.password = pass
+ table.insert(out.credentials, cred)
+ table.insert(txtout,("%s:%s"):format(stdnse.string_or_blank(user),
+ stdnse.string_or_blank(pass)))
end
-- Register the credentials
local credreg = creds.Credentials:new(SCRIPT_NAME, host, port)
- for _, cred in ipairs(credlst) do
- credreg:add(cred.username, cred.password, creds.State.VALID )
+ for user, pass in pairs(credhits) do
+ credreg:add(user, pass, creds.State.VALID )
end
return out, txtout
end
action = function(host, port)
- local fingerprint_filename = stdnse.get_script_args("http-default-accounts.fingerprintfile") or "http-default-accounts-fingerprints.lua"
+ local fingerprint_filename = stdnse.get_script_args("http-default-accounts.fingerprintfile")
+ or "http-default-accounts-fingerprints.lua"
local catlist = stdnse.get_script_args("http-default-accounts.category")
local namelist = stdnse.get_script_args("http-default-accounts.name")
local basepath = stdnse.get_script_args("http-default-accounts.basepath") or "/"
local output = stdnse.output_table()
local text_output = {}
- -- Determine the target's response to "404" HTTP requests.
- local status_404, result_404, known_404 = http.identify_404(host,port)
- -- The default target_check is the existence of the probe path on the target.
- -- To reduce false-positives, fingerprints that lack target_check() will not
- -- be tested on targets on which a "404" response is 200.
- local default_target_check =
- function (host, port, path, response)
- if status_404 and result_404 == 200 then return false end
- return http.page_exists(response, result_404, known_404, path, true)
- end
-
- --Load fingerprint data or abort
+ -- Load fingerprint data or abort
local status, fingerprints = load_fingerprints(fingerprint_filename, catlist, namelist)
- if(not(status)) then
+ if not status then
return stdnse.format_output(false, fingerprints)
end
stdnse.debug(1, "%d fingerprints were loaded", #fingerprints)
- --Format basepath: Removes or adds slashs
+ -- Build the default target_check function
+ -- This requires extra web requests to the target so we do it only if needed
+ local default_target_check = nil
+ for _, fpr in ipairs(fingerprints) do
+ if not fpr.target_check then
+ default_target_check = target_check_404(host, port)
+ break
+ end
+ end
+
+ -- Format basepath: Removes or adds slashes
+ stdnse.debug(1, "Trying known locations under path '%s' (change with '%s.basepath' argument)", basepath, SCRIPT_NAME)
basepath = format_basepath(basepath)
-- Add requests to the http pipeline
local pathmap = {}
local requests = nil
- stdnse.debug(1, "Trying known locations under path '%s' (change with '%s.basepath' argument)", basepath, SCRIPT_NAME)
for _, fingerprint in ipairs(fingerprints) do
for _, probe in ipairs(fingerprint.paths) do
-- Multiple fingerprints may share probe paths so only unique paths will