diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-17 07:42:04 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-17 07:42:04 +0000 |
commit | 0d47952611198ef6b1163f366dc03922d20b1475 (patch) | |
tree | 3d840a3b8c0daef0754707bfb9f5e873b6b1ac13 /scripts/smb-brute.nse | |
parent | Initial commit. (diff) | |
download | nmap-upstream.tar.xz nmap-upstream.zip |
Adding upstream version 7.94+git20230807.3be01efb1+dfsg.upstream/7.94+git20230807.3be01efb1+dfsgupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | scripts/smb-brute.nse | 1114 |
1 files changed, 1114 insertions, 0 deletions
diff --git a/scripts/smb-brute.nse b/scripts/smb-brute.nse new file mode 100644 index 0000000..4931afb --- /dev/null +++ b/scripts/smb-brute.nse @@ -0,0 +1,1114 @@ +local msrpc = require "msrpc" +local nmap = require "nmap" +local smb = require "smb" +local stdnse = require "stdnse" +local string = require "string" +local stringaux = require "stringaux" +local table = require "table" +local unpwdb = require "unpwdb" +local rand = require "rand" + +description = [[ +Attempts to guess username/password combinations over SMB, storing discovered combinations +for use in other scripts. Every attempt will be made to get a valid list of users and to +verify each username before actually using them. When a username is discovered, besides +being printed, it is also saved in the Nmap registry so other Nmap scripts can use it. That +means that if you're going to run <code>smb-brute.nse</code>, you should run other <code>smb</code> scripts you want. +This checks passwords in a case-insensitive way, determining case after a password is found, +for Windows versions before Vista. + +This script is specifically targeted towards security auditors or penetration testers. +One example of its use, suggested by Brandon Enright, was hooking up <code>smb-brute.nse</code> to the +database of usernames and passwords used by the Conficker worm (the password list can be +found at http://www.skullsecurity.org/wiki/index.php/Passwords, among other places. +Then, the network is scanned and all systems that would be infected by Conficker are +discovered. + +From the penetration tester perspective its use is pretty obvious. By discovering weak passwords +on SMB, a protocol that's well suited for bruteforcing, access to a system can be gained. +Further, passwords discovered against Windows with SMB might also be used on Linux or MySQL +or custom Web applications. Discovering a password greatly beneficial for a pen-tester. + +This script uses a lot of little tricks that I (Ron Bowes) describe in detail in a blog +posting, http://www.skullsecurity.org/blog/?p=164. The tricks will be summarized here, but +that blog is the best place to learn more. + +Usernames and passwords are initially taken from the unpwdb library. If possible, the usernames +are verified as existing by taking advantage of Windows' odd behaviour with invalid username +and invalid password responses. As soon as it is able, this script will download a full list +of usernames from the server and replace the unpw usernames with those. This enables the +script to restrict itself to actual accounts only. + +When an account is discovered, it's saved in the <code>smb</code> module (which uses the Nmap +registry). If an account is already saved, the account's privileges are checked; accounts +with administrator privileges are kept over accounts without. The specific method for checking +is by calling <code>GetShareInfo("IPC$")</code>, which requires administrative privileges. Once this script +is finished (all other smb scripts depend on it, it'll run first), other scripts will use the saved account +to perform their checks. + +The blank password is always tried first, followed by "special passwords" (such as the username +and the username reversed). Once those are exhausted, the unpwdb password list is used. + +One major goal of this script is to avoid account lockouts. This is done in a few ways. First, +when a lockout is detected, unless you user specifically overrides it with the <code>smblockout</code> +argument, the scan stops. Second, all usernames are checked with the most common passwords first, +so with not-too-strict lockouts (10 invalid attempts), the 10 most common passwords will still +be tried. Third, one account, called the canary, "goes out ahead"; that is, three invalid +attempts are made (by default) to ensure that it's locked out before others are. + +In addition to active accounts, this script will identify valid passwords for accounts that +are disabled, guest-equivalent, and require password changes. Although these accounts can't +be used, it's good to know that the password is valid. In other cases, it's impossible to +tell a valid password (if an account is locked out, for example). These are displayed, too. +Certain accounts, such as guest or some guest-equivalent, will permit any password. This +is also detected. When possible, the SMB protocol is used to its fullest to get maximum +information. + +When possible, checks are done using a case-insensitive password, then proper case is +determined with a fairly efficient bruteforce. For example, if the actual password is +"PassWord", then "password" will work and "PassWord" will be found afterwards (on the +14th attempt out of a possible 256 attempts, with the current algorithm). +]] +--- +--@usage +-- nmap --script smb-brute.nse -p445 <host> +-- sudo nmap -sU -sS --script smb-brute.nse -p U:137,T:139 <host> +-- +--@output +-- Host script results: +-- | smb-brute: +-- | bad name:test => Valid credentials +-- | consoletest:test => Valid credentials, password must be changed at next logon +-- | guest:<anything> => Valid credentials, account disabled +-- | mixcase:BuTTeRfLY1 => Valid credentials +-- | test:password1 => Valid credentials, account expired +-- | this:password => Valid credentials, account cannot log in at current time +-- | thisisaverylong:password => Valid credentials +-- | thisisaverylongname:password => Valid credentials +-- | thisisaverylongnamev:password => Valid credentials +-- |_ web:TeSt => Valid credentials, account disabled +-- +-- @args smblockout This argument will force the script to continue if it +-- locks out an account or thinks it will lock out an account. +-- @args brutelimit Limits the number of usernames checked in the script. In some domains, +-- it's possible to end up with 10,000+ usernames on each server. By default, this +-- will be <code>5000</code>, which should be higher than most servers and also prevent infinite +-- loops or other weird things. This will only affect the user list pulled from the +-- server, not the username list. +-- @args canaries Sets the number of tests to do to attempt to lock out the first account. +-- This will lock out the first account without locking out the rest of the accounts. +-- The default is 3, which will only trigger strict lockouts, but will also bump the +-- canary account up far enough to detect a lockout well before other accounts are +-- hit. +----------------------------------------------------------------------- + + +author = "Ron Bowes" +license = "Same as Nmap--See https://nmap.org/book/man-legal.html" + +categories = {"intrusive", "brute"} + + +---The maximum number of usernames to check (can be modified with smblimit argument) +-- The limit exists because domains may have hundreds of thousands of accounts, +-- potentially. +local LIMIT = 5000 + +hostrule = function(host) + return smb.get_port(host) ~= nil +end + +---The possible result codes. These are simplified from the actual codes that SMB returns. +local results = +{ + SUCCESS = 1, -- Login was successful + GUEST_ACCESS = 2, -- Login was successful, but was granted guest access + NOT_GRANTED = 3, -- Password was correct, but user wasn't allowed to log in (often happens with blank passwords) + DISABLED = 4, -- Password was correct, but user's account is disabled + EXPIRED = 5, -- Password was correct, but user's account is expired + CHANGE_PASSWORD = 6, -- Password was correct, but user can't log in without changing it + ACCOUNT_LOCKED = 7, -- User's account is locked out (hopefully not by us!) + ACCOUNT_LOCKED_NOW = 8, -- User's account just became locked out (oops!) + FAIL = 9, -- User's password was incorrect + INVALID_LOGON_HOURS = 10, -- Password was correct, but user's account has logon time restrictions in place + INVALID_WORKSTATION = 11 -- Password was correct, but user's account has workstation restrictions in place +} + +---Strings for debugging output +local result_short_strings = {} +result_short_strings[results.SUCCESS] = "SUCCESS" +result_short_strings[results.GUEST_ACCESS] = "GUEST_ACCESS" +result_short_strings[results.NOT_GRANTED] = "NOT_GRANTED" +result_short_strings[results.DISABLED] = "DISABLED" +result_short_strings[results.EXPIRED] = "EXPIRED" +result_short_strings[results.CHANGE_PASSWORD] = "CHANGE_PASSWORD" +result_short_strings[results.ACCOUNT_LOCKED] = "LOCKED" +result_short_strings[results.ACCOUNT_LOCKED_NOW] = "LOCKED_NOW" +result_short_strings[results.FAIL] = "FAIL" +result_short_strings[results.INVALID_LOGON_HOURS] = "INVALID_LOGON_HOURS" +result_short_strings[results.INVALID_WORKSTATION] = "INVALID_WORKSTATION" + + +---The strings that the user will see +local result_strings = {} +result_strings[results.SUCCESS] = "Valid credentials" +result_strings[results.GUEST_ACCESS] = "Valid credentials, account granted guest access only" +result_strings[results.NOT_GRANTED] = "Valid credentials, but account wasn't allowed to log in (often happens with blank passwords)" +result_strings[results.DISABLED] = "Valid credentials, account disabled" +result_strings[results.EXPIRED] = "Valid credentials, account expired" +result_strings[results.CHANGE_PASSWORD] = "Valid credentials, password must be changed at next logon" +result_strings[results.ACCOUNT_LOCKED] = "Valid credentials, account locked (hopefully not by us!)" +result_strings[results.ACCOUNT_LOCKED_NOW] = "Valid credentials, account just became locked (oops!)" +result_strings[results.FAIL] = "Invalid credentials" +result_strings[results.INVALID_LOGON_HOURS] = "Valid credentials, account cannot log in at current time" +result_strings[results.INVALID_WORKSTATION] = "Valid credentials, account cannot log in from current host" + +---Constants for special passwords. These each contain a null character, which is illegal in +-- actual passwords. +local USERNAME = "\0username" +local USERNAME_REVERSED = "\0username reversed" +local special_passwords = { USERNAME, USERNAME_REVERSED } + +---Generates a random string of the requested length. This can be used to check how hosts react to +-- weird username/password combinations. +--@param length (optional) The length of the string to return. Default: 8. +--@param set (optional) The set of letters to choose from. Default: upper, lower, numbers, and underscore. +--@return The random string. +local function get_random_string(length) + return rand.random_string(length, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_") +end + +---Splits a string in the form "domain\user" into domain and user. +--@param str The string to split +--@return (domain, username) The domain and the username. If no domain was given, nil is returned +-- for domain. +local function split_domain(str) + local username, domain + local split = stringaux.strsplit("\\", str) + + if(#split > 1) then + domain = split[1] + username = split[2] + else + domain = nil + username = str + end + + return domain, username +end + +---Formats a username/password pair with an optional result. Just a way to keep things consistent +-- throughout the program. Currently, the format is "username:password => result". +--@param username The username. +--@param password [optional] The password. Default: "<unknown>". +--@param result [optional] The result, as a constant. Default: not used. +--@return A string representing the input values. +local function format_result(username, password, result) + + if(username == "") then + username = "<blank>" + end + + if(password == nil) then + password = "<unknown>" + elseif(password == "") then + password = "<blank>" + end + + if(result == nil) then + return string.format("%s:%s", username, password) + else + return string.format("%s:%s => %s", username, password, result_strings[result]) + end +end + +---Decides which login type to use (lanman, ntlm, or other). Designed to keep things consistent. +--@param hostinfo The hostinfo table. +--@return A string representing the login type to use (that can be passed to SMB functions). +local function get_type(hostinfo) + -- Check if the user requested a specific type + if(nmap.registry.args.smbtype ~= nil) then + return nmap.registry.args.smbtype + end + + -- Otherwise, base the type on the operating system (TODO: other versions of Windows (7, 2008)) + -- 2k8 example: "Windows Server (R) 2008 Datacenter without Hyper-V 6001 Service Pack 1" + if(string.find(string.lower(hostinfo['os']), "vista") ~= nil) then + return "ntlm" + elseif(string.find(string.lower(hostinfo['os']), "2008") ~= nil) then + return "ntlm" + elseif(string.find(string.lower(hostinfo['os']), "Windows 7") ~= nil) then + return "ntlm" + end + + return "lm" +end + +---Stops the session, if one exists. This can be called as frequently as needed, it'll just return if no +-- session is present, but it should generally be paired with a <code>restart_session</code> call. +--@param hostinfo The hostinfo table. +--@return (status, err) If status is false, err is a string corresponding to the error; otherwise, err is undefined. +local function stop_session(hostinfo) + local status, err + + if(hostinfo['smbstate'] ~= nil) then + stdnse.debug2("Stopping the SMB session") + status, err = smb.stop(hostinfo['smbstate']) + if(status == false) then + return false, err + end + + hostinfo['smbstate'] = nil + end + + + return true +end + +---Starts or restarts a SMB session with the host. Although this will automatically stop a session if +-- one exists, it's a little cleaner to pair this with a <code>stop_session</code> call. +--@param hostinfo The hostinfo table. +--@return (status, err) If status is false, err is a string corresponding to the error; otherwise, err is undefined. +local function restart_session(hostinfo) + local status, err, smbstate + + -- Stop the old session, if it exists + stop_session(hostinfo) + + stdnse.debug2("Starting the SMB session") + status, smbstate = smb.start_ex(hostinfo['host'], true, nil, nil, nil, true) + if(status == false) then + return false, smbstate + end + + hostinfo['smbstate'] = smbstate + + return true +end + +---Attempts to log into an account, returning one of the <code>results</code> constants. Will always return to the +-- state where another login can be attempted. Will also differentiate between a hash and a password, and choose the +-- proper login method (unless overridden). Will interpret the result as much as possible. +-- +-- The session has to be active (ie, <code>restart_session</code> has to be called) before calling this function. +-- +--@param hostinfo The hostinfo table. +--@param username The username to try. +--@param password The password to try. +--@param logintype [optional] The logintype to use. Default: <code>get_type</code> is called. If <code>password</code> +-- is a hash, this is ignored. +--@return Result, an integer value from the <code>results</code> constants. +local function check_login(hostinfo, username, password, logintype) + local result + local domain = "" + local smbstate = hostinfo['smbstate'] + if(logintype == nil) then + logintype = get_type(hostinfo) + end + + -- Determine if we have a password hash or a password + local status, err + if(#password == 32 or #password == 64 or #password == 65) then + -- It's a hash (note: we always use NTLM hashes) + status, err = smb.start_session(smbstate, smb.get_overrides(username, domain, nil, password, "ntlm"), false) + else + status, err = smb.start_session(smbstate, smb.get_overrides(username, domain, password, nil, logintype), false) + end + + if(status == true) then + if(smbstate['is_guest'] == 1) then + result = results.GUEST_ACCESS + else + result = results.SUCCESS + end + + smb.logoff(smbstate) + else + if(err == "NT_STATUS_LOGON_TYPE_NOT_GRANTED") then + result = results.NOT_GRANTED + elseif(err == "NT_STATUS_ACCOUNT_LOCKED_OUT") then + result = results.ACCOUNT_LOCKED + elseif(err == "NT_STATUS_ACCOUNT_DISABLED") then + result = results.DISABLED + elseif(err == "NT_STATUS_PASSWORD_MUST_CHANGE") then + result = results.CHANGE_PASSWORD + elseif(err == "NT_STATUS_INVALID_LOGON_HOURS") then + result = results.INVALID_LOGON_HOURS + elseif(err == "NT_STATUS_INVALID_WORKSTATION") then + result = results.INVALID_WORKSTATION + elseif(err == "NT_STATUS_ACCOUNT_EXPIRED") then + result = results.EXPIRED + else + result = results.FAIL + end + end + + --io.write(string.format("Result: %s\n\n", result_strings[result])) + + return result +end + +---Determines whether or not a login was successful, based on what's known about the server's settings. This +-- is fairly straight forward, but has a couple little tricks. +-- +--@param hostinfo The hostinfo table. +--@param result The result code. +--@return <code>true</code> if the password used for logging in was correct, <code>false</code> otherwise. Keep +-- in mind that this doesn't imply the login was successful (only results.SUCCESS indicates that), rather +-- that the password was valid. + +function is_positive_result(hostinfo, result) + -- If result is a FAIL, it's always bad + if(result == results.FAIL) then + return false + end + + -- If result matches what we discovered for invalid passwords, it's always bad + if(result == hostinfo['invalid_password']) then + return false + end + + -- If result was ACCOUNT_LOCKED, it's always bad (locked accounts should already be taken care of, but this + -- makes the function a bit more generic) + if(result == results.ACCOUNT_LOCKED) then + return false + end + + -- Otherwise, it's good + return true +end + +---Determines whether or not a login was "bad". A bad login is one where an account becomes locked out. +-- +--@param hostinfo The hostinfo table. +--@param result The result code. +--@return <code>true</code> if the password used for logging in was correct, <code>false</code> otherwise. Keep +-- in mind that this doesn't imply the login was successful (only results.SUCCESS indicates that), rather +-- that the password was valid. + +function is_bad_result(hostinfo, result) + -- If result is LOCKED, it's always bad. + if(result == results.ACCOUNT_LOCKED or result == results.ACCOUNT_LOCKED_NOW) then + return true + end + + -- Otherwise, it's good + return false +end + +---Count the number of one bits in a binary representation of the given number. This is used for case-sensitive +-- checks. +-- +--@param num The number to count the ones for. +--@return The number of ones in the number +local function count_ones(num) + local count = 0 + + while num ~= 0 do + if((num & 1) == 1) then + count = count + 1 + end + num = num >> 1 + end + + return count +end + +---Converts a string's case based on a binary number. For every '1' bit, the character is uppercased, and for every '0' +-- bit it's lowercased. For example, "test" and 8 (1000) becomes "Test", while "test" and 11 (1011) becomes "TeST". +-- +--@param str The string to convert. +--@param num The binary number representing the case. This value isn't checked, so if it's too large it's truncated, and if it's +-- too small it's effectively zero-padded. +--@return The converted string. +local function convert_case(str, num) + local pos = #str + + -- Don't bother with blank strings (we probably won't get here anyway, but it doesn't hurt) + if(str == "") then + return "" + end + + while(num ~= 0) do + -- Check if the bit we're at is '1' + if((num & 1) == 1) then + -- Check if we're at the beginning or end (or both) of the string -- those are special cases + if(pos == #str and pos == 1) then + str = string.upper(string.sub(str, pos, pos)) + elseif(pos == #str) then + str = string.sub(str, 1, pos - 1) .. string.upper(string.sub(str, pos, pos)) + elseif(pos == 1) then + str = string.upper(string.sub(str, pos, pos)) .. string.sub(str, pos + 1, #str) + else + str = string.sub(str, 1, pos - 1) .. string.upper(string.sub(str, pos, pos)) .. string.sub(str, pos + 1, #str) + end + end + + num = num >> 1 + + pos = pos - 1 + end + + return str +end + +---Attempts to determine the case of a password. This is done by trying every possible combination of upper and lowercase +-- characters in the password, in the most efficient possible ordering, until the correct case is found. +-- +-- A session has to be active when this function is called. +-- +--@param hostinfo The hostinfo table. +--@param username The username. +--@param password The password (it's assumed that it's all lowercase already, but it doesn't matter) +--@return The password with the proper case, or the original password if it couldn't be determined (either the proper +-- case wasn't found or the login type is incorrect). +local function find_password_case(hostinfo, username, password) + -- Only do this if we're using lanman, otherwise we already have the proper password + if(get_type(hostinfo) ~= "lm") then + return password + end + + -- Figure out how many possibilities exist + local max = (1 << #password) - 1 + + -- Create an array of them, starting with all the values whose binary representation has no ones, then one one, then two ones, etc. + local ordered = {} + + -- Cheat a bit, by adding all lower then all upper right at the start + ordered = {0, max} + + -- Loop backwards from the length of the password to 0. At each spot, put all numbers that have that many '1' bits + for i = 1, #password - 1, 1 do + for j = max, 0, -1 do + if(count_ones(j) == i) then + table.insert(ordered, j) + end + end + end + + -- Create the list of converted passwords + for i = 1, #ordered, 1 do + local thispassword = convert_case(password, ordered[i]) + + -- We specify "ntlm" for the login type because it's case sensitive + local result = check_login(hostinfo, username, thispassword, 'ntlm') + if(is_positive_result(hostinfo, result)) then + return thispassword + end + end + + -- Print an error message + stdnse.debug1("ERROR: smb-brute: Was unable to determine case of %s's password", username) + + -- If all else fails, just return the actual password (we probably shouldn't get here) + return password +end + +---Unless the user is ok with lockouts, check the lockout policy of the host. Take the most restrictive +-- portion among the domains. Returns true if lockouts could happen, false otherwise. +local function bad_lockout_policy(host) + -- If the user is ok with locking out accounts, just return + if(stdnse.get_script_args( "smblockout" )) then + stdnse.debug1("Not checking server's lockout policy") + return true, false + end + + local status, result = msrpc.get_domains(host) + if(not(status)) then + stdnse.debug1("Couldn't detect lockout policy: %s", result) + return false, "Couldn't retrieve lockout policy: " .. result + end + + for domain, data in pairs(result) do + if(data and data.lockout_threshold) then + stdnse.debug1("Server's lockout policy: lock out after %d attempts", data.lockout_threshold) + return true, true + end + end + + stdnse.debug1("Server has no lockout policy") + return true, false +end + +---Initializes and returns the hostinfo table. This includes queuing up the username and password lists, determining +-- the server's operating system, and checking the server's response for invalid usernames/invalid passwords. +-- +--@param host The host object. +local function initialize(host) + local os, result + local status, bad_lockout_policy_result + local hostinfo = {} + + hostinfo['host'] = host + hostinfo['invalid_usernames'] = {} + hostinfo['locked_usernames'] = {} + hostinfo['accounts'] = {} + hostinfo['special_password'] = 1 + + -- Get the OS (identifying windows versions tells us which hash to use) + result, os = smb.get_os(host) + if(result == false or os['os'] == nil) then + hostinfo['os'] = "<Unknown>" + else + hostinfo['os'] = os['os'] + end + stdnse.debug1("Remote operating system: %s", hostinfo['os']) + + -- Check lockout policy + status, bad_lockout_policy_result = bad_lockout_policy(host) + if(not(status)) then + stdnse.debug1("WARNING: couldn't determine lockout policy: %s", bad_lockout_policy_result) + else + if(bad_lockout_policy_result) then + return false, "Account lockouts are enabled on the host. To continue (and risk lockouts), add --script-args=smblockout=1 -- for more information, run smb-enum-domains." + end + end + + -- Attempt to enumerate users + stdnse.debug1("Trying to get user list from server") + local _ + hostinfo['have_user_list'], _, hostinfo['user_list'] = msrpc.get_user_list(host) + hostinfo['user_list_index'] = 1 + if(hostinfo['have_user_list'] and #hostinfo['user_list'] == 0) then + hostinfo['have_user_list'] = false + end + + -- If the enumeration failed, try using the built-in list + if(not(hostinfo['have_user_list'])) then + stdnse.debug1("Couldn't enumerate users (normal for Windows XP and higher), using unpwdb initially") + status, hostinfo['user_list_default'] = unpwdb.usernames() + if(status == false) then + return false, "Couldn't open username file" + end + end + + -- Open the password file + stdnse.debug1("Opening password list") + status, hostinfo['password_list'] = unpwdb.passwords() + if(status == false) then + return false, "Couldn't open password file" + end + + -- Start the SMB session + stdnse.debug1("Starting the initial SMB session") + local err + status, err = restart_session(hostinfo) + if(status == false) then + stop_session(hostinfo) + return false, err + end + + -- Some hosts will accept any username -- check for this by trying to log in with a totally random name. If the + -- server accepts it, it'll be impossible to bruteforce; if it gives us a weird result code, we have to remember + -- it. + hostinfo['invalid_username'] = check_login(hostinfo, get_random_string(8), get_random_string(8), "ntlm") + hostinfo['invalid_password'] = check_login(hostinfo, "Administrator", get_random_string(8), "ntlm") + + stdnse.debug1("Server's response to invalid usernames: %s", result_short_strings[hostinfo['invalid_username']]) + stdnse.debug1("Server's response to invalid passwords: %s", result_short_strings[hostinfo['invalid_password']]) + + -- If either of these comes back as success, there's no way to tell what's valid/invalid + if(hostinfo['invalid_username'] == results.SUCCESS) then + stop_session(hostinfo) + return false, "Invalid username was accepted; unable to bruteforce" + end + if(hostinfo['invalid_password'] == results.SUCCESS) then + stop_session(hostinfo) + return false, "Invalid password was accepted; unable to bruteforce" + end + + -- Print a message to the user if we can identify passwords + if(hostinfo['invalid_username'] ~= hostinfo['invalid_password']) then + stdnse.debug1("Invalid username and password response are different, so identifying valid accounts is possible") + end + + -- Print a warning message if invalid_username and invalid_password go to the same thing that isn't FAIL + if(hostinfo['invalid_username'] ~= results.FAIL and hostinfo['invalid_username'] == hostinfo['invalid_password']) then + stdnse.debug1("WARNING: Difficult to recognize invalid usernames/passwords; may not get good results") + end + + -- Restart the SMB connection so we have a clean slate + stdnse.debug1("Restarting the session before the bruteforce") + status, err = restart_session(hostinfo) + if(status == false) then + stop_session(hostinfo) + return false, err + end + + -- Stop the SMB session (we're going to let the scripts look after their own sessions) + stop_session(hostinfo) + + -- Return the results + return true, hostinfo +end + +---Retrieves the next password in the password database we're using. Will never return the empty string. +-- May also return one of the <code>special_passwords</code> constants. +-- +--@param hostinfo The hostinfo table (the password list is stored there). +--@return The new password, or nil if the end of the list has been reached. +local function get_next_password(hostinfo) + local new_password + + -- If we're out of special passwords, move onto actual ones + if(hostinfo['special_password'] > #special_passwords) then + -- Pick the next non-blank password from the list + repeat + new_password = hostinfo['password_list']() + until new_password ~= '' + else + -- Get the next non-blank password + new_password = special_passwords[hostinfo['special_password']] + hostinfo['special_password'] = hostinfo['special_password'] + 1 + end + + return new_password +end + +---Reset to the first password. This is normally done when the user list changes. +-- +--@param hostinfo The hostinfo table. +local function reset_password(hostinfo) + hostinfo['password_list']("reset") +end + +---Retrieves the next username. This can be from the username database, or from an array stored in the +-- hostinfo table. This won't return any names that have been determined to be invalid, locked, or +-- have already had their password found. +-- +--@param hostinfo The hostinfo table +--@return The next username, or nil if the end of the list has been reached. +local function get_next_username(hostinfo) + local username + + repeat + if(hostinfo['have_user_list']) then + local index = hostinfo['user_list_index'] + hostinfo['user_list_index'] = hostinfo['user_list_index'] + 1 + + username = hostinfo['user_list'][index] + if(username ~= nil) then + local _ + _, username = split_domain(username) + end + + else + username = hostinfo['user_list_default']() + end + + -- Make the username lowercase (usernames aren't case sensitive, so making it lower case prevents duplicates) + if(username ~= nil) then + username = string.lower(username) + end + + until username == nil or (hostinfo['invalid_usernames'][username] ~= true and hostinfo['locked_usernames'][username] ~= true and hostinfo['accounts'][username] == nil) + + return username +end + +---Reset to the first username. +-- +--@param hostinfo The hostinfo table. +local function reset_username(hostinfo) + if(hostinfo['have_user_list']) then + hostinfo['user_list_index'] = 1 + else + hostinfo['user_list_default']("reset") + end +end + +---Do a little trick to detect account lockouts without bringing every user to the lockout threshold -- bump the lockout counter of +-- the first user ahead. If lockouts are happening, this means that the first account will trigger before the rest of the accounts. +-- A canary in the mineshaft, in a way. +-- +-- The number of checks defaults to three, but it can be controlled with the <code>canary</code> argument. +-- +-- Times it'll fail are when: +-- * Accounts are locked out due to the initial checks (happens if the user runs smb-brute twice in a row, the canary won't help) +-- * A valid user list isn't pulled, and we create a canary that doesn't exist (won't be as bad, though, because it means we also +-- don't have every account on the server/domain +function test_lockouts(hostinfo) + local i + local username = get_next_username(hostinfo) + + -- It's possible that every username was accounted for already, so our list is empty. + if(username == nil) then + return + end + + if(stdnse.get_script_args( "smblockout" )) then + return + end + + while(string.lower(username) == "administrator") do + username = get_next_username(hostinfo) + if(username == nil) then + return + end + end + + if(username ~= nil) then + -- Try logging in as the "canary" account + local canaries = nmap.registry.args.canaries + if(canaries == nil) then + canaries = 3 + else + canaries = tonumber(canaries) + end + + if(canaries > 0) then + stdnse.debug1("Detecting server lockout on '%s' with %d canaries", username, canaries) + end + + local result + for i=1, canaries, 1 do + result = check_login(hostinfo, username, get_random_string(8), "ntlm") + end + + -- If the account just became locked (it's already been put on the 'valid' list), we're in trouble + if(result == results.LOCKED) then + -- If the canary just became locked, we're one step from locking out every account. Loop through the usernames and invalidate them to + -- prevent them from being locked out + stdnse.debug1("Canary (%s) became locked out -- aborting", username) + + -- Add it to the locked username list (so it can be reported) + hostinfo['locked_usernames'][username] = true + + -- Mark all the usernames as invalid (a bit of a hack, but it's safer this way) + while(username ~= nil) do + stdnse.debug1("Marking '%s' as 'invalid'", username) + hostinfo['invalid_usernames'][username] = true + username = get_next_username(hostinfo) + end + end + end + + -- Go back to the beginning of the list + reset_username(hostinfo) +end + +---Attempts to validate the current list of usernames by logging in with a blank password, marking invalid ones (and ones that had +-- a blank password). Determining the validity of a username works best if invalid usernames are redirected to 'guest'. +-- +-- If a username accepts the blank password, a random password is tested. If that's accepted as well, the account is marked as +-- accepting any password (the 'guest' account is normally like that). +-- +-- This also checks whether the server locks out users, and raises the lockout threshold of the first user (see the +-- <code>check_lockouts</code> function for more information on that. If accounts on the system are locked out, they aren't +-- checked. +-- +--@param hostinfo The hostinfo table. +--@return (status, err) If status is false, err is a string corresponding to the error; otherwise, err is undefined. +local function validate_usernames(hostinfo) + local status, err + local result + local username, password + + stdnse.debug1("Checking which account names exist (based on what goes to the 'guest' account)") + + -- Start a session + status, err = restart_session(hostinfo) + if(status == false) then + return false, err + end + + -- Make sure we start at the beginning + reset_username(hostinfo) + + username = get_next_username(hostinfo) + while(username ~= nil) do + result = check_login(hostinfo, username, "", "ntlm") + + if(result ~= hostinfo['invalid_password'] and result == hostinfo['invalid_username']) then + -- If the account matches the value of 'invalid_username', but not the value of 'invalid_password', it's invalid + stdnse.debug1("Blank password for '%s' -> '%s' (invalid account)", username, result_short_strings[result]) + hostinfo['invalid_usernames'][username] = true + + elseif(result == hostinfo['invalid_password']) then + + -- If the account matches the value of 'invalid_password', and 'invalid_password' is reliable, it's probably valid + if(hostinfo['invalid_username'] ~= results.FAIL and hostinfo['invalid_username'] == hostinfo['invalid_password']) then + stdnse.debug1("Blank password for '%s' => '%s' (can't determine validity)", username, result_short_strings[result]) + else + stdnse.debug1("Blank password for '%s' => '%s' (probably valid)", username, result_short_strings[result]) + end + + elseif(result == results.ACCOUNT_LOCKED) then + -- If the account is locked out, don't try it + hostinfo['locked_usernames'][username] = true + stdnse.debug1("Blank password for '%s' => '%s' (locked out)", username, result_short_strings[result]) + + elseif(result == results.FAIL) then + -- If none of the standard options work, check if it's FAIL. If it's FAIL, there's an error somewhere (probably, the + -- 'administrator' username is changed so we're getting invalid data). + stdnse.debug1("Blank password for '%s' => '%s' (may be valid)", username, result_short_strings[result]) + + else + -- If none of those came up, either the password is legitimately blank, or any account works. Figure out what! + local new_result = check_login(hostinfo, username, get_random_string(14), "ntlm") + if(new_result == result) then + -- Any password works (often happens with 'guest' account) + stdnse.debug1("All passwords accepted for %s (goes to %s)", username, result_short_strings[result]) + status, err = found_account(hostinfo, username, "<anything>", result) + if(status == false) then + return false, err + end + else + -- Blank password worked, but not random one + status, err = found_account(hostinfo, username, "", result) + if(status == false) then + return false, err + end + end + end + + username = get_next_username(hostinfo) + end + + -- Start back at the beginning of the list + reset_username(hostinfo) + + -- Check for lockouts + test_lockouts(hostinfo) + + -- Stop the session + stop_session(hostinfo) + + return true +end + +---Marks an account as discovered. The login with this account doesn't have to be successful, but <code>is_positive_result</code> should +-- return <code>true</code>. +-- +-- If the result IS successful, and this hasn't been done before, this function will attempt to pull a userlist from the server. +-- +-- The session should be stopped before entering this function, and restarted after -- that allows this function to make its own SMB calls. +-- +--@param hostinfo The hostinfo table. +--@param username The username. +--@param password The password. +--@param result The result, as an integer constant. +--@return (status, err) If status is false, err is a string corresponding to the error; otherwise, err is undefined. +function found_account(hostinfo, username, password, result) + local status, err + + -- Save the username + hostinfo['accounts'][username] = {} + hostinfo['accounts'][username]['password'] = password + hostinfo['accounts'][username]['result'] = result + + -- Save the account (smb will automatically decide if it's better than the account it already has) + if(result == results.SUCCESS) then + -- Stop the connection -- this lets us do some queries + status, err = stop_session(hostinfo) + if(status == false) then + return false, err + end + + -- Check if we have an 'admin' account + -- Try getting information about "IPC$". This determines whether or not the user is administrator + -- since only admins can get share info. Note that on Vista and up, unless UAC is disabled, all + -- accounts are non-admin. + local is_admin = smb.is_admin(hostinfo['host'], username, '', password, nil, nil) + + -- Add the account + smb.add_account(hostinfo['host'], username, '', password, nil, nil, is_admin) + + -- Check lockout policy + local status, bad_lockout_policy_result = bad_lockout_policy(hostinfo['host']) + if(not(status)) then + stdnse.debug1("WARNING: couldn't determine lockout policy: %s", bad_lockout_policy_result) + else + if(bad_lockout_policy_result) then + return false, "Account lockouts are enabled on the host. To continue (and risk lockouts), add --script-args=smblockout=1 -- for more information, run smb-enum-domains." + end + end + + -- If we haven't retrieved the real user list yet, do so + if(hostinfo['have_user_list'] == false) then + -- Attempt to enumerate users + stdnse.debug1("Trying to get user list from server using newly discovered account") + local _ + hostinfo['have_user_list'], _, hostinfo['user_list'] = msrpc.get_user_list(hostinfo['host']) + hostinfo['user_list_index'] = 1 + if(hostinfo['have_user_list'] and #hostinfo['user_list'] == 0) then + hostinfo['have_user_list'] = false + end + + -- If the list was found, let the user know and reset the password list + if(hostinfo['have_user_list']) then + stdnse.debug1("Found %d accounts to check!", #hostinfo['user_list']) + reset_password(hostinfo) + + -- Validate them (pick out the ones that can't possibly log in) + validate_usernames(hostinfo) + end + end + + -- Start the session again + status, err = restart_session(hostinfo) + if(status == false) then + return false, err + end + + end +end + +---This is the main function that does all the work (loops through the lists and checks the results). +-- +--@param host The host table. +--@return (status, accounts, locked_accounts) If status is false, accounts is an error message. Otherwise, accounts +-- is a table of passwords/results, indexed by the username and locked_accounts is a table indexed by locked +-- usernames. +local function go(host) + local status, err + local result, hostinfo + local password, temp_password, username + local response = {} + + -- Initialize the hostinfo object, which sets up the initial variables + result, hostinfo = initialize(host) + if(result == false) then + return false, hostinfo + end + + -- If invalid accounts don't give guest, we can determine the existence of users by trying to + -- log in with an invalid password and checking the value + status, err = validate_usernames(hostinfo) + if(status == false) then + return false, err + end + + -- Start up the SMB session + status, err = restart_session(hostinfo) + if(status == false) then + return false, err + end + + -- Loop through the password list + temp_password = get_next_password(hostinfo) + while(temp_password ~= nil) do + -- Loop through the user list + username = get_next_username(hostinfo) + while(username ~= nil) do + -- Check if it's a special case (we do this every loop because special cases are often + -- based on the username + if(temp_password == USERNAME) then + password = username + --io.write(string.format("Trying matching username/password (%s:%s)\n", username, password)) + elseif(temp_password == USERNAME_REVERSED) then + password = string.reverse(username) + --io.write(string.format("Trying reversed username/password (%s:%s)\n", username, password)) + else + password = temp_password + end + + --io.write(string.format("%s:%s\n", username, password)) + local result = check_login(hostinfo, username, password, get_type(hostinfo)) + + -- Check if the username was locked out + if(is_bad_result(hostinfo, result)) then + -- Add it to the list of locked usernames + hostinfo['locked_usernames'][username] = true + + -- Unless the user requested to keep going, stop the check + if(not(stdnse.get_script_args( "smblockout" ))) then + -- Mark it as found, which is technically true + status, err = found_account(hostinfo, username, nil, results.ACCOUNT_LOCKED_NOW) + if(status == false) then + return err + end + + -- Let the user know that it went badly + stdnse.debug1("'%s' became locked out; stopping", username) + + return true, hostinfo['accounts'], hostinfo['locked_usernames'] + else + stdnse.debug1("'%s' became locked out; continuing", username) + end + end + + if(is_positive_result(hostinfo, result)) then + -- Reset the connection + stdnse.debug2("Found an account; resetting connection") + status, err = restart_session(hostinfo) + if(status == false) then + return false, err + end + + -- Find the case of the password, unless it's a hash + local case_password + if(not(#password == 32 or #password == 64 or #password == 65)) then + stdnse.debug1("Determining password's case (%s)", format_result(username, password)) + case_password = find_password_case(hostinfo, username, password, result) + stdnse.debug1("Result: %s", format_result(username, case_password)) + else + case_password = password + end + + -- Take normal actions for finding an account + status, err = found_account(hostinfo, username, case_password, result) + if(status == false) then + return err + end + end + username = get_next_username(hostinfo) + end + + reset_username(hostinfo) + temp_password = get_next_password(hostinfo) + end + + stop_session(hostinfo) + return true, hostinfo['accounts'], hostinfo['locked_usernames'] +end + +action = function(host) + + local status, result + local response = {} + + local username + local usernames = {} + local locked = {} + local i + local locked_result + + status, result, locked_result = go(host) + if(status == false) then + return stdnse.format_output(false, result) + end + + -- Put the usernames in their own table + for username in pairs(result) do + table.insert(usernames, username) + end + + -- Sort the usernames alphabetically + table.sort(usernames) + + -- Display the usernames + if(#usernames == 0) then + table.insert(response, "No accounts found") + else + for i=1, #usernames, 1 do + local username = usernames[i] + table.insert(response, format_result(username, result[username]['password'], result[username]['result'])) + end + end + + -- Make a list of locked accounts + for username in pairs(locked_result) do + table.insert(locked, username) + end + if(#locked > 0) then + -- Sort the list + table.sort(locked) + + -- Display the list + table.insert(response, string.format("Locked accounts found: %s", table.concat(locked, ", "))) + end + + return stdnse.format_output(true, response) +end + |