summaryrefslogtreecommitdiffstats
path: root/scripts/dns-nsec3-enum.nse
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--scripts/dns-nsec3-enum.nse427
1 files changed, 427 insertions, 0 deletions
diff --git a/scripts/dns-nsec3-enum.nse b/scripts/dns-nsec3-enum.nse
new file mode 100644
index 0000000..1932ce5
--- /dev/null
+++ b/scripts/dns-nsec3-enum.nse
@@ -0,0 +1,427 @@
+local stdnse = require "stdnse"
+local shortport = require "shortport"
+local dns = require "dns"
+local base32 = require "base32"
+local nmap = require "nmap"
+local string = require "string"
+local stringaux = require "stringaux"
+local table = require "table"
+local tableaux = require "tableaux"
+local rand = require "rand"
+
+local openssl = stdnse.silent_require "openssl"
+
+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
+<code>unhash</code> from https://dnscurve.org/nsec3walker.html, to
+bruteforce the actual names from the hashes. Assuming that the script
+output was written into a text file <code>hashes.txt</code> like:
+<code>
+domain example.com
+salt 123456
+iterations 10
+nexthash d1427bj0ahqnpi4t0t0aaun18oqpgcda vhnelm23s1m3japt7gohc82hgr9un2at
+nexthash k7i4ekvi22ebrim5b6celtaniknd6ilj prv54a3cr1tbcvqslrb7bftf5ji5l0p8
+nexthash 9ool6bk7r2diaiu81ctiemmb6n961mph nm7v0ig7h9c0agaedc901kojfj9bgabj
+nexthash 430456af8svfvl98l66shhrgucoip7mi mges520acstgaviekurg3oksh9u31bmb
+</code>
+
+Run this command to recover the domain names:
+<code>
+# ./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
+</code>
+
+Use the <code>dns-nsec-enum</code> script to handle servers that use NSEC
+rather than NSEC3.
+
+References:
+* https://dnscurve.org/nsec3walker.html
+]]
+---
+-- @usage
+-- nmap -sU -p 53 <target> --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.
+--
+-- @see dns-nsec-enum.nse
+-- @see dns-ip6-arpa-scan.nse
+-- @see dns-brute.nse
+-- @see dns-zone-transfer.nse
+--
+-- @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 R. Bond"}
+license = "Simplified (2-clause) BSD license--See https://nmap.org/svn/docs/licenses/BSD-simplified"
+categories = {"discovery", "intrusive"}
+
+portrule = shortport.port_or_service(53, "domain", {"tcp", "udp"})
+
+all_results = {}
+
+-- get time (in milliseconds) 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 stringaux.strsplit("%.", domain)
+end
+
+local function join(components)
+ return table.concat(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
+
+-- generate a random hash with domains suffix
+-- return both domain and its hash
+local function generate_hash(domain, iter, salt)
+ local rand_str = rand.random_string(8, "etaoinshrdlucmfw")
+ local random_domain = rand_str .. "." .. domain
+ local packed_domain = {}
+ for word in string.gmatch(random_domain, "[^%.]+") do
+ packed_domain[#packed_domain+1] = string.pack("s1", word)
+ end
+ salt = stdnse.fromhex( salt)
+ local to_hash = ("%s\0%s"):format(table.concat(packed_domain), salt)
+ iter = iter - 1
+ local hash = openssl.sha1(to_hash)
+ for i=0,iter do
+ hash = openssl.sha1(hash .. salt)
+ end
+ return string.lower(base32.enc(hash,true)), random_domain
+end
+
+-- convenience function , returns size of a table
+local function table_size(tbl)
+ local 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
+
+-- 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})
+ if status then
+ for _, nsec3 in ipairs(auth_filter(result, "NSEC3")) do
+ local h1 = string.lower(remove_suffix(nsec3.dname,domain))
+ local h2 = string.lower(nsec3.hash.base32)
+ if not tableaux.contains(all_results,"nexthash " .. h1 .. " " .. h2) then
+ table.insert(all_results, "nexthash " .. h1 .. " " .. h2)
+ stdnse.debug1("nexthash " .. h1 .. " " .. h2)
+ end
+ ranges[h1] = h2
+ end
+ else
+ stdnse.debug1("DNS error: %s", result)
+ end
+ return ranges
+end
+
+-- does the actual 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 = rand.random_string(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
+ local h1 = string.lower(remove_suffix(nsec3.dname,domain))
+ local h2 = string.lower(nsec3.hash.base32)
+ if table_size(todo) == 0 then
+ table.insert(all_results, "domain " .. domain)
+ stdnse.debug1("domain " .. domain)
+ table.insert(all_results, "salt " .. salt)
+ stdnse.debug1("salt " .. salt)
+ table.insert(all_results, "iterations " .. iter)
+ stdnse.debug1("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.debug1("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
+ local hash
+ hash, subdomain = generate_hash(domain,iter,salt)
+ local 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
+ local hash_pairs = query_for_hashes(host,subdomain,domain)
+ queried = true
+ local 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.debug1("break[]")
+ --break
+ -- end
+ end
+ elseif a > b then -- ][ range
+ if hash > a or hash < b then
+ local hash_pairs = query_for_hashes(host,subdomain,domain)
+ queried = true
+ local 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