diff --git a/nselib/base32.lua b/nselib/base32.lua new file mode 100644 index 000000000..4f633b5af --- /dev/null +++ b/nselib/base32.lua @@ -0,0 +1,200 @@ +--- +-- Base32 encoding and decoding. Follows RFC 4648. +-- +-- @author Philip Pickering +-- @copyright Same as Nmap--See http://nmap.org/book/man-legal.html +-- @ported base64 to base32 + +-- thanks to Patrick Donnelly for some optimizations + +--module(... or "base32",package.seeall) +-- local bin = require 'bin' +-- local stdnse = require 'stdnse' +-- _ENV = stdnse.module("base32", stdnse.seeall) + +local bin = require "bin" +local stdnse = require "stdnse" +local string = require "string" +local table = require "table" +_ENV = stdnse.module("base32", stdnse.seeall) + +-- todo: make metatable/index --> '' for b32dctable + + +local b32standard = { + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', + 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', + 'Y', 'Z', '2', '3', '4', '5', '6', '7', + } + +local b32dcstandard = {} -- efficency +b32dcstandard['A'] = '00000' +b32dcstandard['B'] = '00001' +b32dcstandard['C'] = '00010' +b32dcstandard['D'] = '00011' +b32dcstandard['E'] = '00100' +b32dcstandard['F'] = '00101' +b32dcstandard['G'] = '00110' +b32dcstandard['H'] = '00111' +b32dcstandard['I'] = '01000' +b32dcstandard['J'] = '01001' +b32dcstandard['K'] = '01010' +b32dcstandard['L'] = '01011' +b32dcstandard['M'] = '01100' +b32dcstandard['N'] = '01101' +b32dcstandard['O'] = '01110' +b32dcstandard['P'] = '01111' +b32dcstandard['Q'] = '10000' +b32dcstandard['R'] = '10001' +b32dcstandard['S'] = '10010' +b32dcstandard['T'] = '10011' +b32dcstandard['U'] = '10100' +b32dcstandard['V'] = '10101' +b32dcstandard['W'] = '10110' +b32dcstandard['X'] = '10111' +b32dcstandard['Y'] = '11000' +b32dcstandard['Z'] = '11001' +b32dcstandard['2'] = '11010' +b32dcstandard['3'] = '11011' +b32dcstandard['4'] = '11100' +b32dcstandard['5'] = '11101' +b32dcstandard['6'] = '11110' +b32dcstandard['7'] = '11111' + +local b32hexExtend = { + '0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', + 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', + 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', +} + +local b32dchexExtend = {} -- efficency +b32dchexExtend['0'] = '00000' +b32dchexExtend['1'] = '00001' +b32dchexExtend['2'] = '00010' +b32dchexExtend['3'] = '00011' +b32dchexExtend['4'] = '00100' +b32dchexExtend['5'] = '00101' +b32dchexExtend['6'] = '00110' +b32dchexExtend['7'] = '00111' +b32dchexExtend['8'] = '01000' +b32dchexExtend['9'] = '01001' +b32dchexExtend['A'] = '01010' +b32dchexExtend['B'] = '01011' +b32dchexExtend['C'] = '01100' +b32dchexExtend['D'] = '01101' +b32dchexExtend['E'] = '01110' +b32dchexExtend['F'] = '01111' +b32dchexExtend['G'] = '10000' +b32dchexExtend['H'] = '10001' +b32dchexExtend['I'] = '10010' +b32dchexExtend['J'] = '10011' +b32dchexExtend['K'] = '10100' +b32dchexExtend['L'] = '10101' +b32dchexExtend['M'] = '10110' +b32dchexExtend['N'] = '10111' +b32dchexExtend['O'] = '11000' +b32dchexExtend['P'] = '11001' +b32dchexExtend['Q'] = '11010' +b32dchexExtend['R'] = '11011' +b32dchexExtend['S'] = '11100' +b32dchexExtend['T'] = '11101' +b32dchexExtend['U'] = '11110' +b32dchexExtend['V'] = '11111' + +local b32table = b32standard +local b32dctable = b32dcstandard + +local append = table.insert +local substr = string.sub +local bpack = bin.pack +local bunpack = bin.unpack +local concat = table.concat + +--- +-- Encode bits to a Base32-encoded character. +-- @param bits String of five bits to be encoded. +-- @return Encoded character. +local function b32enc5bit(bits) + local byte = tonumber(bits, 2) + 1 + return b32table[byte] +end + + +--- +-- Decodes a Base32-encoded character into a string of binary digits. +-- @param b32byte A single base32-encoded character. +-- @return String of five decoded bits. +local function b32dec5bit(b32byte) + local bits = b32dctable[b32byte] + if bits then return bits end + return '' +end + + +--- +-- Encodes a string to Base32. +-- @param bdata Data to be encoded. +-- @param hexExtend pass true to use the hex extended char set +-- @return Base32-encoded string. +function enc(bdata, hexExtend) + local _, bitstring = bunpack(">B".. #bdata,bdata) + local b32dataBuf = {} + + if hexExtend then + b32table = b32hexExtend + b32dctable = b32dchexExtend + end + + while #bitstring > 4 do + append(b32dataBuf,b32enc5bit(substr(bitstring,1,5))) + bitstring = substr(bitstring,6) + end + if #bitstring == 1 then + append(b32dataBuf, b32enc5bit(bitstring .. "0000")) + append(b32dataBuf, '====') + elseif #bitstring == 2 then + append(b32dataBuf, b32enc5bit(bitstring .. "000") ) + append(b32dataBuf, '=') + elseif #bitstring == 3 then + append(b32dataBuf, b32enc5bit(bitstring .. "00") ) + append(b32dataBuf, "======") + elseif #bitstring == 4 then + append(b32dataBuf, b32enc5bit(bitstring .. "0") ) + append(b32dataBuf, '===') + end + return concat(b32dataBuf) +end + + +--- +-- Decodes Base32-encoded data. +-- @param b32data Base32 encoded data. +-- @param hexExtend pass true to use the hex extended char set +-- @return Decoded data. +function dec(b32data, hexExtend) + local bdataBuf = {} + local pos = 1 + local byte + local nbyte = '' + + if hexExtend then + b32table = b32hexExtend + b32dctable = b32dchexExtend + end + + for pos = 1, #b32data do -- while pos <= string.len(b32data) do + byte = b32dec5bit(substr(b32data, pos, pos)) + if not byte then return end + nbyte = nbyte .. byte + if #nbyte >= 8 then + append(bdataBuf, bpack("B", substr(nbyte, 1, 8))) + nbyte = substr(nbyte, 9) + end +-- pos = pos + 1 + end + return concat(bdataBuf) +end + +return _ENV; \ No newline at end of file diff --git a/nselib/dns.lua b/nselib/dns.lua index fd02bf195..164abdf10 100644 --- a/nselib/dns.lua +++ b/nselib/dns.lua @@ -38,6 +38,7 @@ local nmap = require "nmap" local stdnse = require "stdnse" local string = require "string" local table = require "table" +local base32 = require "base32" _ENV = stdnse.module("dns", stdnse.seeall) get_servers = nmap.get_dns_servers @@ -60,6 +61,7 @@ types = { OPT = 41, SSHFP = 44, NSEC = 47, + NSEC3 = 50, AXFR = 252, ANY = 255 } @@ -1034,6 +1036,49 @@ decoder[types.NSEC] = function (entry, data, pos) end end end +-- Decodes NSEC3 records, puts result in entry.NSEC3. See RFC 5155. +-- +-- entry.NSEC3 has the fields dname, +-- hash.alg, and hash.base32. +-- hash.bin, and hash.hex. +-- salt.bin, and salt.hex. +-- iterations, and types. +-- @param entry RR in packet. +-- @param data Complete encoded DNS packet. +-- @param pos Position in packet after RR. +decoder[types.NSEC3] = function (entry, data, pos) + local np = pos - #entry.data + local _ + local flags + + entry.NSEC3 = {} + entry.NSEC3.dname = entry.dname + entry.NSEC3.salt, entry.NSEC3.hash = {}, {} + + np, entry.NSEC3.hash.alg,flags,entry.NSEC3.iterations = bin.unpack(">CBS", data, np) + -- do we even need to decode these do we care about opt out? + -- entry.NSEC3.flags = decodeFlagsNSEC3(flags) + + np, entry.NSEC3.salt.bin = bin.unpack(">p", data, np) + _, entry.NSEC3.salt.hex = bin.unpack("H" .. #entry.NSEC3.salt.bin, entry.NSEC3.salt.bin) + + np, entry.NSEC3.hash.bin = bin.unpack(">p" , data, np) + _, entry.NSEC3.hash.hex = bin.unpack(">H" .. #entry.NSEC3.hash.bin , entry.NSEC3.hash.bin) + entry.NSEC3.hash.base32 = base32.enc(entry.NSEC3.hash.bin, true) + + np, entry.NSEC3.WinBlockNo, entry.NSEC3.bmplength = bin.unpack(">CC", data, np) + np, entry.NSEC3.bin = bin.unpack(">B".. entry.NSEC3.bmplength, data, np) + entry.NSEC3.types = {} + if entry.NSEC3.bin == nil then + entry.NSEC3.bin = "" + end + for i=1, string.len(entry.NSEC3.bin) do + local bit = string.sub(entry.NSEC3.bin,i,i) + if bit == "1" then + table.insert(entry.NSEC3.types, (entry.NSEC3.WinBlockNo*256+i-1)) + end + end +end -- Decodes records that consist only of one domain, for example CNAME, NS, PTR. -- Puts result in entry.domain. diff --git a/scripts/dns-nsec3-enum.nse b/scripts/dns-nsec3-enum.nse new file mode 100644 index 000000000..17f8ebf95 --- /dev/null +++ b/scripts/dns-nsec3-enum.nse @@ -0,0 +1,424 @@ +local stdnse = require "stdnse" +local shortport = require "shortport" +local dns = require "dns" +local base32 = require "base32" +local openssl = require "openssl" +local msrpc = require "msrpc" -- just for random string generation +local math = require "math" + +description = [[ +Tries to enumerate domain names from the DNS server that supports DNSSEC +NSEC3 records. + +The script queries for nonexistant domains until it exhausts all domain +ranges keeping track of hashes. At the end, all hashes are printed along +with salt and number of iterations used. This technique is known as +"NSEC3 walking". + +That info should then be fed into an offline cracker, like +unhash from http://dnscurve.org/nsec3walker.html, to +bruteforce the actual names from the hashes. Assuming that the script +output was written into a text file hashes.txt like: + +domain example.com +salt 123456 +iterations 10 +nexthash d1427bj0ahqnpi4t0t0aaun18oqpgcda vhnelm23s1m3japt7gohc82hgr9un2at +nexthash k7i4ekvi22ebrim5b6celtaniknd6ilj prv54a3cr1tbcvqslrb7bftf5ji5l0p8 +nexthash 9ool6bk7r2diaiu81ctiemmb6n961mph nm7v0ig7h9c0agaedc901kojfj9bgabj +nexthash 430456af8svfvl98l66shhrgucoip7mi mges520acstgaviekurg3oksh9u31bmb + + +Run this command to recover the domain names: + +# ./unhash < hashes.txt > domains.txt +names: 8 +d1427bj0ahqnpi4t0t0aaun18oqpgcda ns.example.com. +found 1 private NSEC3 names (12%) using 235451 hash computations +k7i4ekvi22ebrim5b6celtaniknd6ilj vulpix.example.com. +found 2 private NSEC3 names (25%) using 35017190 hash computations + + +References: +* http://dnscurve.org/nsec3walker.html +]] +--- +-- @usage +--nmap.exe -sU -p 53 --script=dns-nsec3-enum --script-args dns-nsec3-enum.domains=example.com +--- +-- @args dns-nsec3-enum.domains The domain or list of domains to +-- enumerate. If not provided, the script will make a guess based on the +-- name of the target. +-- @args dns-nsec3-enum.timelimit Sets a script run time limit. Default 30 minutes. +-- +-- @output +-- PORT STATE SERVICE +-- 53/udp open domain +-- | dns-nsec3-enum: +-- | domain example.com +-- | salt 123456 +-- | iterations 10 +-- | nexthash d1427bj0ahqnpi4t0t0aaun18oqpgcda vhnelm23s1m3japt7gohc82hgr9un2at +-- | nexthash k7i4ekvi22ebrim5b6celtaniknd6ilj prv54a3cr1tbcvqslrb7bftf5ji5l0p8 +-- | nexthash 9ool6bk7r2diaiu81ctiemmb6n961mph nm7v0ig7h9c0agaedc901kojfj9bgabj +-- | nexthash 430456af8svfvl98l66shhrgucoip7mi mges520acstgaviekurg3oksh9u31bmb +-- |_ Total hashes found: 8 + +author = "Aleksandar Nikolic, John Bond" +license = "Simplified (2-clause) BSD license--See http://nmap.org/svn/docs/licenses/BSD-simplified" +categories = {"discovery", "intrusive"} + +portrule = shortport.port_or_service(53, "domain", {"tcp", "udp"}) + +all_results = {} + +-- get time (in miliseconds) when the script should finish +local function get_end_time() + local t = nmap.timing_level() + local limit = stdnse.parse_timespec(stdnse.get_script_args('dns-nsec3-enum.timelimit') or "30m") + local end_time = 1000 * limit + nmap.clock_ms() + return end_time +end + +local function remove_empty(t) + local result = {} + for _, v in ipairs(t) do + if v ~= "" then + result[#result + 1] = v + end + end + return result +end + +local function split(domain) + return stdnse.strsplit("%.", domain) +end + +local function join(components) + return stdnse.strjoin(".", remove_empty(components)) +end + +-- Remove the first component of a domain name. Return nil if the number of +-- components drops below min_length (default 0). +local function remove_component(domain, min_length) + local components + + min_length = min_length or 0 + components = split(domain) + if #components < min_length then + return nil + end + table.remove(components, 1) + + return join(components) +end + +-- Guess the domain given a host. Return nil on failure. This function removes +-- a domain name component unless the name would become shorter than 2 +-- components. +local function guess_domain(host) + local name + local components + + name = stdnse.get_hostname(host) + if name and name ~= host.ip then + return remove_component(name, 2) or name + else + return nil + end +end + +-- Remove a suffix from a domain (to isolate a subdomain from its parent). +local function remove_suffix(domain, suffix) + local dc, sc + + dc = split(domain) + sc = split(suffix) + while #dc > 0 and #sc > 0 and dc[#dc] == sc[#sc] do + dc[#dc] = nil + sc[#sc] = nil + end + + return join(dc), join(sc) +end + +-- Return the subset of authoritative records with the given label. +local function auth_filter(retPkt, label) + local result = {} + + for _, rec in ipairs(retPkt.auth) do + if rec[label] then + result[#result + 1] = rec[label] + end + end + + return result +end + + +local function empty(t) + return not next(t) +end + +local function random_string() + return msrpc.random_crap(8,"etaoinshrdlucmfw") +end + +-- generate a random hash with domains suffix +-- return both domain and it's hash +local function generate_hash(domain, iter, salt) + local rand_str = random_string() + local random_domain = rand_str .. "." .. domain + local packed_domain = "" + for word in string.gmatch(domain,"[^%.]+") do + packed_domain = packed_domain .. bin.pack("c",string.len(word)) .. word + end + local to_hash = bin.pack("c",string.len(rand_str)) .. rand_str .. packed_domain .. bin.pack("c",0) .. bin.pack("H",salt) + iter = iter - 1 + local hash = openssl.sha1(to_hash) + for i=0,iter do + hash = hash .. bin.pack("H",salt) + hash = openssl.sha1(hash) + end + return string.lower(base32.enc(hash,true)), random_domain +end + +-- convenience function , returns size of a table +local function table_size(tbl) + numItems = 0 + for k,v in pairs(tbl) do + numItems = numItems + 1 + end + return numItems +end + +-- convenience function , return first item in a table +local function get_first(tbl) + for k,v in pairs(tbl) do + return k,v + end +end + +-- convenience function , check if table cointains an element +local function table_contains(tbl,element) + for _, value in pairs(tbl) do + if value == element then + return true + end + end + return false +end + +-- queries the domain and parses the results +-- returns the list of new ranges +local function query_for_hashes(host,subdomain,domain) + local status + local result + local ranges = {} + status, result = dns.query(subdomain, {host = host.ip, dtype='NSEC3', retAll=true, retPkt=true, dnssec=true}) + for _, nsec3 in ipairs(auth_filter(result, "NSEC3")) do + h1 = string.lower(remove_suffix(nsec3.dname,domain)) + h2 = string.lower(nsec3.hash.base32) + if not table_contains(all_results,"nexthash " .. h1 .. " " .. h2) then + table.insert(all_results, "nexthash " .. h1 .. " " .. h2) + stdnse.print_debug("nexthash " .. h1 .. " " .. h2) + end + ranges[h1] = h2 + end + + return ranges +end + +-- does the actuall enumeration +local function enum(host, port, domain) + + local seen, seen_subdomain = {}, {} + local ALG ={} + ALG[1] = "SHA-1" + local todo = {} + local dnssec, status, result = false, false, "No Answer" + local result = {} + local subdomain = msrpc.random_crap(8,"etaoinshrdlucmfw") + local full_domain = join({subdomain, domain}) + local iter + local salt + local end_time = get_end_time() + + -- do one query to determine the hash and if DNSSEC is actually used + status, result = dns.query(full_domain, {host = host.ip, dtype='NSEC3', retAll=true, retPkt=true, dnssec=true}) + if status then + local is_nsec3 = false + for _, nsec3 in ipairs(auth_filter(result, "NSEC3")) do -- parse the results and add initial ranges + is_nsec3 = true + dnssec = true + iter = nsec3.iterations + salt = nsec3.salt.hex + h1 = string.lower(remove_suffix(nsec3.dname,domain)) + h2 = string.lower(nsec3.hash.base32) + if table_size(todo) == 0 then + table.insert(all_results, "domain " .. domain) + stdnse.print_debug("domain " .. domain) + table.insert(all_results, "salt " .. salt) + stdnse.print_debug("salt " .. salt) + table.insert(all_results, "iterations " .. iter) + stdnse.print_debug("iterations " .. iter) + if h1 < h2 then + todo[h2] = h1 + else + todo[h1] = h2 + end + else + for b,a in pairs(todo) do + if h1 == b and h2 == a then -- h2:a b:h1 case + todo[b] = nil + break + end + if h1 == b and h2 > h1 then -- a b:h1 h2 case + todo[b] = nil + todo[h2] = a + break + end + if h1 == b and h2 < a then -- h2 a b:h1 + todo[b] = nil + todo[b] = h2 + break + end + if h1 > b then -- a b h1 h2 + todo[b] = nil + todo[b] = h1 + todo[h2] = a + break + end + if h1 < a then -- h1 h2 a b + todo[b] = nil + todo[b] = h1 + todo[h2] = a + break + end + end -- for + end -- else + table.insert(all_results, "nexthash " .. h1 .. " " .. h2) + stdnse.print_debug("nexthash " .. h1 .. " " .. h2) + end + end + + -- find hash that falls into one of the ranges and query for it + while table_size(todo) > 0 and nmap.clock_ms() < end_time do + hash, subdomain = generate_hash(domain,iter,salt) + queried = false + for a,b in pairs(todo) do + if a == b then + todo[a] = nil + break + end + if a < b then -- [] range + if hash > a and hash < b then + -- do the query + hash_pairs = query_for_hashes(host,subdomain,domain) + queried = true + changed = false + for h1,h2 in pairs(hash_pairs) do + if h1 == a and h2 == b then -- h1:a h2:b case + todo[a] = nil + changed = true + end + if h1 == a then -- h1:a h2 b case + todo[a] = nil + todo[h2] = b + changed = true + end + if h2 == b then -- a h1 bh:2 case + todo[a] = nil + todo[a] = h1 + changed = true + end + if h1 > a and h2 < b then -- a h1 h2 b case + todo[a] = nil + todo[a] = h1 + todo[h2] = b + changed = true + end + end +-- if changed then +-- stdnse.print_debug("break[]") + --break +-- end + end + elseif a > b then -- ][ range + if hash > a or hash < b then + hash_pairs = query_for_hashes(host,subdomain,domain) + queried = true + changed = false + for h1,h2 in pairs(hash_pairs) do + if h1 == a and h2 == b then -- h2:b a:h1 case + todo[a] = nil + changed = true + end + if h1 == a and h2 > h1 then -- b a:h1 h2 case + todo[a] = nil + todo[h1] = b + changed = true + end + if h1 == a and h2 < b then -- h2 b a:h1 case + todo[a] = nil + todo[h2] = b + changed = true + end + if h1 > a and h2 > h1 then -- b a h1 h2 case + todo[a] = nil + todo[a] = h1 + todo[h2] = b + changed = true + end + if h1 > a and h2 < b then -- h2 b a h1 case + todo[a] = nil + todo[a] = h1 + todo[h2] = b + changed = true + end + if h1 < b then -- h1 h2 b a case + todo[a] = nil + todo[a] = h1 + todo[h2] = b + changed = true + end + end + if changed then + --break + end + end + end + if queried then + break + end + end + end + return dnssec, status, all_results +end + +action = function(host, port) + local output = {} + local domains + domains = stdnse.get_script_args('dns-nsec3-enum.domains') + if not domains then + domains = guess_domain(host) + end + if not domains then + return string.format("Can't determine domain for host %s; use %s.domains script arg.", host.ip, SCRIPT_NAME) + end + if type(domains) == 'string' then + domains = { domains } + end + + for _, domain in ipairs(domains) do + local dnssec, status, result = enum(host, port, domain) + if dnssec and type(result) == "table" then + output[#output + 1] = result + output[#output + 1] = "Total hashes found: " .. #result + + else + output[#output + 1] = "DNSSEC NSEC3 not supported" + end + end + return stdnse.format_output(true, output) +end diff --git a/scripts/script.db b/scripts/script.db index 1b7f8fff1..ab9115331 100644 --- a/scripts/script.db +++ b/scripts/script.db @@ -75,6 +75,7 @@ Entry { filename = "dns-client-subnet-scan.nse", categories = { "discovery", "sa Entry { filename = "dns-fuzz.nse", categories = { "fuzzer", "intrusive", } } Entry { filename = "dns-ip6-arpa-scan.nse", categories = { "discovery", "intrusive", } } Entry { filename = "dns-nsec-enum.nse", categories = { "discovery", "intrusive", } } +Entry { filename = "dns-nsec3-enum.nse", categories = { "discovery", "intrusive", } } Entry { filename = "dns-nsid.nse", categories = { "default", "discovery", } } Entry { filename = "dns-random-srcport.nse", categories = { "external", "intrusive", } } Entry { filename = "dns-random-txid.nse", categories = { "external", "intrusive", } }