diff --git a/nselib/http.lua b/nselib/http.lua index 8fc25951e..0914e5ab6 100644 --- a/nselib/http.lua +++ b/nselib/http.lua @@ -65,7 +65,8 @@ -- ** name -- ** value -- ** path --- * auth: A table containing the keys username and password, which will be used for HTTP Basic authentication +-- * auth: A table containing the keys username and password, which will be used for HTTP Basic authentication. +-- If a server requires HTTP Digest authentication, then there must also be a key digest, with value true. -- * bypass_cache: Do not perform a lookup in the local HTTP cache. -- * no_cache: Do not save the result of this request to the local HTTP cache. -- * no_cache_body: Do not save the body of the response to the local HTTP cache. @@ -106,6 +107,7 @@ local comm = require "comm" local coroutine = require "coroutine" local nmap = require "nmap" local os = require "os" +local sasl = require "sasl" local stdnse = require "stdnse" local string = require "string" local table = require "table" @@ -328,6 +330,20 @@ local function validate_options(options) stdnse.print_debug(1, "http: options.auth should be a table") bad = true end + elseif (key == 'digestauth') then + if(type(value) == 'table') then + local req_keys = {"username","realm","nonce","digest-uri","response"} + for _,k in ipairs(req_keys) do + if not value[k] then + stdnse.print_debug(1, "http: options.digestauth missing key: %s",k) + bad = true + break + end + end + else + bad = true + stdnse.print_debug(1, "http: options.digestauth should be a table") + end elseif(key == 'bypass_cache' or key == 'no_cache' or key == 'no_cache_body') then if(type(value) ~= 'boolean') then stdnse.print_debug(1, "http: options.bypass_cache, options.no_cache, and options.no_cache_body must be boolean values") @@ -1101,14 +1117,36 @@ local function build_request(host, port, method, path, options) mod_options.header["Cookie"] = cookies end end - -- Only Basic authentication is supported. - if options.auth then + + if options.auth and not options.auth.digest then local username = options.auth.username local password = options.auth.password local credentials = "Basic " .. base64.enc(username .. ":" .. password) mod_options.header["Authorization"] = credentials end + if options.digestauth then + local order = {"username", "realm", "nonce", "digest-uri", "algorithm", "response", "qop", "nc", "cnonce"} + local no_quote = {algorithm=true, qop=true, nc=true} + local creds = {} + for _,k in ipairs(order) do + local v = options.digestauth[k] + if v then + if no_quote[k] then + table.insert(creds, ("%s=%s"):format(k,v)) + else + if k == "digest-uri" then + table.insert(creds, ('%s="%s"'):format("uri",v)) + else + table.insert(creds, ('%s="%s"'):format(k,v)) + end + end + end + end + local credentials = "Digest "..table.concat(creds, ", ") + mod_options.header["Authorization"] = credentials + end + local body -- Build a form submission from a table, like "k1=v1&k2=v2". if type(options.content) == "table" then @@ -1217,6 +1255,30 @@ function generic_request(host, port, method, path, options) if(not(validate_options(options))) then return http_error("Options failed to validate.") end + + local digest_auth = options and options.auth and options.auth.digest + + if digest_auth and not have_ssl then + stdnse.print_debug("http: digest auth requires openssl.") + end + + if digest_auth and have_ssl then + -- If we want to do digest authentication, we have to make an initial + -- request to get realm, nonce and other fields. + local options_with_auth_removed = tcopy(options) + options_with_auth_removed["auth"] = nil + local r = generic_request(host, port, method, path, options_with_auth_removed) + local h = r.header['www-authenticate'] + if not r.status or (h and not string.find(h:lower(), "digest.-realm")) then + stdnse.print_debug("http: the target doesn't support digest auth or there was an error during request.") + return http_error("The target doesn't support digest auth or there was an error during request.") + end + -- Compute the response hash + local dmd5 = sasl.DigestMD5:new(h, options.auth.username, options.auth.password, method, path) + local _, digest_table = dmd5:calcDigest() + options.digestauth = digest_table + end + return request(host, port, build_request(host, port, method, path, options), options) end diff --git a/nselib/sasl.lua b/nselib/sasl.lua index c542b583f..ba596741c 100644 --- a/nselib/sasl.lua +++ b/nselib/sasl.lua @@ -56,79 +56,117 @@ end local MECHANISMS = { } if HAVE_SSL then - -- Calculates a DIGEST MD5 response - DigestMD5 = { + -- Calculates a DIGEST MD5 response + DigestMD5 = { - --- Instantiates DigestMD5 - -- - -- @param chall string containing the base64 decoded challenge - -- @return a new instance of DigestMD5 - new = function(self, chall, username, password, method, uri, service, realm) - local o = { nc = 0, - chall = chall, - challnvs = {}, - username = username, - password = password, - method = method, - uri = uri, - service = service, - realm = realm } - setmetatable(o, self) - self.__index = self - o:parseChallenge() - return o - end, + --- Instantiates DigestMD5 + -- + -- @param chall string containing the base64 decoded challenge + -- @return a new instance of DigestMD5 + new = function(self, chall, username, password, method, uri, service, realm) + local o = { nc = 0, + chall = chall, + challnvs = {}, + username = username, + password = password, + method = method, + uri = uri, + service = service, + realm = realm } + setmetatable(o, self) + self.__index = self + o:parseChallenge() + return o + end, - -- parses a challenge received from the server - -- takes care of both quoted and unqoted identifiers - -- regardless of what RFC says - parseChallenge = function(self) - local results = {} - local start, stop = 0,0 - while(true) do - local name, value - start, stop, name = self.chall:find("([^=]*)=%s*", stop + 1) - if ( not(start) ) then break end - if ( self.chall:sub(stop + 1, stop + 1) == "\"" ) then - start, stop, value = self.chall:find("(.-)\"", stop + 2) - else - start, stop, value = self.chall:find("([^,]*)", stop + 1) - end - self.challnvs[name:lower()] = value - start, stop = self.chall:find("%s*,%s*", stop + 1) - if ( not(start) ) then break end - end - end, + -- parses a challenge received from the server + -- takes care of both quoted and unqoted identifiers + -- regardless of what RFC says + parseChallenge = function(self) + local results = {} + local start, stop = 0,0 + while(true) do + local name, value + start, stop, name = self.chall:find("([^=]*)=%s*", stop + 1) + if ( not(start) ) then break end + if ( self.chall:sub(stop + 1, stop + 1) == "\"" ) then + start, stop, value = self.chall:find("(.-)\"", stop + 2) + else + start, stop, value = self.chall:find("([^,]*)", stop + 1) + end + name = name:lower() + if name == "digest realm" then name="realm" end + self.challnvs[name] = value + start, stop = self.chall:find("%s*,%s*", stop + 1) + if ( not(start) ) then break end + end + end, - --- Calculates the digest - calcDigest = function( self ) - local uri = self.uri or ("%s/%s"):format(self.service, "localhost") - local realm = self.realm or self.challnvs.realm or "" - local cnonce = stdnse.tohex(openssl.rand_bytes( 8 )) - local qop = "auth" - self.nc = self.nc + 1 - local A1_part1 = openssl.md5(self.username .. ":" .. (self.challnvs.realm or "") .. ":" .. self.password) - local A1 = stdnse.tohex(openssl.md5(A1_part1 .. ":" .. self.challnvs.nonce .. ':' .. cnonce)) - local A2 = stdnse.tohex(openssl.md5(("%s:%s"):format(self.method, uri))) - local digest = stdnse.tohex(openssl.md5(A1 .. ":" .. self.challnvs.nonce .. ":" .. - ("%08d"):format(self.nc) .. ":" .. cnonce .. ":" .. - qop .. ":" .. A2)) + --- Calculates the digest + calcDigest = function( self ) + local uri = self.uri or ("%s/%s"):format(self.service, "localhost") + local realm = self.realm or self.challnvs.realm or "" + local cnonce = stdnse.tohex(openssl.rand_bytes( 8 )) + local qop = "auth" + local qop_not_specified + if self.challnvs.qop then + qop_not_specified = false + else + qop_not_specified = true + end + self.nc = self.nc + 1 + local A1_part1 = openssl.md5(self.username .. ":" .. (self.challnvs.realm or "") .. ":" .. self.password) + local A1 = stdnse.tohex(openssl.md5(A1_part1 .. ":" .. self.challnvs.nonce .. ':' .. cnonce)) + local A2 = stdnse.tohex(openssl.md5(("%s:%s"):format(self.method, uri))) + local digest = stdnse.tohex(openssl.md5(A1 .. ":" .. self.challnvs.nonce .. ":" .. + ("%08d"):format(self.nc) .. ":" .. cnonce .. ":" .. + qop .. ":" .. A2)) - local response = "username=\"" .. self.username .. "\"" - response = response .. (",%s=\"%s\""):format("realm", realm) - response = response .. (",%s=\"%s\""):format("nonce", self.challnvs.nonce) - response = response .. (",%s=\"%s\""):format("cnonce", cnonce) - response = response .. (",%s=%08d"):format("nc", self.nc) - response = response .. (",%s=%s"):format("qop", "auth") - response = response .. (",%s=\"%s\""):format("digest-uri", uri) - response = response .. (",%s=%s"):format("response", digest) - response = response .. (",%s=%s"):format("charset", "utf-8") + local b1 + if not self.challnvs.algorithm or self.challnvs.algorithm == "MD5" then + b1 = stdnse.tohex(openssl.md5(self.username..":"..(self.challnvs.realm or "")..":"..self.password)) + else + b1 = A1 + end + -- should we make it work when qop == "auth-int" (we would need entity-body here, which + -- might be complicated)? - return response - end, + local digest_http + if not qop_not_specified then + digest_http = stdnse.tohex(openssl.md5(b1 .. ":" .. self.challnvs.nonce .. ":" .. + ("%08d"):format(self.nc) .. ":" .. cnonce .. ":" .. qop .. ":" .. A2)) + else + digest_http = stdnse.tohex(openssl.md5(b1 .. ":" .. self.challnvs.nonce .. ":" .. A2)) + end + local response = "username=\"" .. self.username .. "\"" + response = response .. (",%s=\"%s\""):format("realm", realm) + response = response .. (",%s=\"%s\""):format("nonce", self.challnvs.nonce) + response = response .. (",%s=\"%s\""):format("cnonce", cnonce) + response = response .. (",%s=%08d"):format("nc", self.nc) + response = response .. (",%s=%s"):format("qop", "auth") + response = response .. (",%s=\"%s\""):format("digest-uri", uri) + response = response .. (",%s=%s"):format("response", digest) + response = response .. (",%s=%s"):format("charset", "utf-8") + + -- response_table is used in http library because the request should + -- be a little bit different then the string generated above + local response_table = { + username = self.username, + realm = realm, + nonce = self.challnvs.nonce, + cnonce = cnonce, + nc = ("%08d"):format(self.nc), + qop = qop, + ["digest-uri"] = uri, + algorithm = self.challnvs.algorithm, + response = digest_http + } + + return response, response_table + end, - } + } -- The NTLM class handling NTLM challenge response authentication NTLM = { diff --git a/scripts/http-brute.nse b/scripts/http-brute.nse index 23d7163bd..b1d4b317f 100644 --- a/scripts/http-brute.nse +++ b/scripts/http-brute.nse @@ -40,9 +40,11 @@ Performs brute force password auditing against http basic authentication. -- -- Version 0.1 -- Created 07/30/2010 - v0.1 - created by Patrik Karlsson +-- Version 0.2 +-- 07/26/2012 - v0.2 - added digest auth support (Piotr Olma) -- -author = "Patrik Karlsson" +author = "Patrik Karlsson, Piotr Olma" license = "Same as Nmap--See http://nmap.org/book/man-legal.html" categories = {"intrusive", "brute"} @@ -50,17 +52,14 @@ categories = {"intrusive", "brute"} portrule = shortport.port_or_service( {80, 443}, {"http", "https"}, "tcp", "open") Driver = { - - new = function(self, host, port, method) - local o = {} - setmetatable(o, self) - self.__index = self - o.host = stdnse.get_script_args("http-brute.hostname") or host - o.port = port - o.path = stdnse.get_script_args("http-brute.path") or "/" - o.method = method - return o - end, + + new = function(self, host, port, opts) + local o = {port=port, path=opts.path, method=opts.method, digestauth=opts.digestauth} + setmetatable(o, self) + self.__index = self + o.host = stdnse.get_script_args("http-brute.hostname") or host + return o + end, connect = function( self ) -- This will cause problems, as ther is no way for us to "reserve" @@ -69,11 +68,24 @@ Driver = { return true end, - login = function( self, username, password ) - -- we need to supply the no_cache directive, or else the http library - -- incorrectly tells us that the authentication was successfull - local response = http.generic_request( self.host, self.port, self.method, self.path, { auth = { username = username, password = password }, no_cache = true }) - + login = function( self, username, password ) + local response + local opts_table + if not self.digestauth then + -- we need to supply the no_cache directive, or else the http library + -- incorrectly tells us that the authentication was successful + opts_table = { auth = { username = username, password = password }, no_cache = true } + else + opts_table = { auth = { username = username, password = password, digest = true }, no_cache = true } + end + response = http.generic_request( self.host, self.port, self.method, self.path, opts_table) + + if not response.status then + local err = brute.Error:new(response["status-line"]) + err:setRetry(true) + return false, err + end + -- Checking for ~= 401 *should* work to -- but gave me a number of false positives last time I tried. -- We decided to change it to ~= 4xx. @@ -95,12 +107,7 @@ Driver = { end, check = function( self ) - local response = http.generic_request( self.host, self.port, self.method, self.path, { no_cache = true } ) - - if ( response.status == 401 ) then - return true - end - return false + return true end, } @@ -110,8 +117,6 @@ action = function( host, port ) local status, result local path = stdnse.get_script_args("http-brute.path") or "/" local method = string.upper(stdnse.get_script_args("http-brute.method") or "GET") - local engine = brute.Engine:new(Driver, host, port, method ) - engine.options.script_name = SCRIPT_NAME if ( not(path) ) then return " \n ERROR: No path was specified (see http-brute.path)" @@ -122,9 +127,21 @@ action = function( host, port ) if ( response.status ~= 401 ) then return (" \n Path \"%s\" does not require authentication"):format(path) end - + + -- check if digest auth is required + local digestauth = false + local h = response.header['www-authenticate'] + if h then + h = h:lower() + if string.find(h, 'digest.-realm') then + digestauth = true + end + end - status, result = engine:start() - - return result + local engine = brute.Engine:new(Driver, host, port, {method=method, path=path, digestauth=digestauth}) + engine.options.script_name = SCRIPT_NAME + + status, result = engine:start() + + return result end