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