diff options
Diffstat (limited to 'scripts/dns-nsec-enum.nse')
-rw-r--r-- | scripts/dns-nsec-enum.nse | 393 |
1 files changed, 393 insertions, 0 deletions
diff --git a/scripts/dns-nsec-enum.nse b/scripts/dns-nsec-enum.nse new file mode 100644 index 0000000..10b5b95 --- /dev/null +++ b/scripts/dns-nsec-enum.nse @@ -0,0 +1,393 @@ +local dns = require "dns" +local nmap = require "nmap" +local shortport = require "shortport" +local stdnse = require "stdnse" +local string = require "string" +local stringaux = require "stringaux" +local table = require "table" +local tableaux = require "tableaux" + +description = [[ +Enumerates DNS names using the DNSSEC NSEC-walking technique. + +Output is arranged by domain. Within a domain, subzones are shown with +increased indentation. + +The NSEC response record in DNSSEC is used to give negative answers to +queries, but it has the side effect of allowing enumeration of all +names, much like a zone transfer. This script doesn't work against +servers that use NSEC3 rather than NSEC; for that, see +<code>dns-nsec3-enum</code>. +]] + +--- +-- @args dns-nsec-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. +-- +-- @usage +-- nmap -sSU -p 53 --script dns-nsec-enum --script-args dns-nsec-enum.domains=example.com <target> +-- +-- @see dns-nsec3-enum.nse +-- @see dns-ip6-arpa-scan.nse +-- @see dns-brute.nse +-- @see dns-zone-transfer.nse +-- +-- @output +-- 53/udp open domain udp-response +-- | dns-nsec-enum: +-- | example.com +-- | bulbasaur.example.com +-- | charmander.example.com +-- | dugtrio.example.com +-- | www.dugtrio.example.com +-- | gyarados.example.com +-- | johto.example.com +-- | blue.johto.example.com +-- | green.johto.example.com +-- | ns.johto.example.com +-- | red.johto.example.com +-- | ns.example.com +-- | snorlax.example.com +-- |_ vulpix.example.com + +author = "John R. Bond" +license = "Simplified (2-clause) BSD license--See https://nmap.org/svn/docs/licenses/BSD-simplified" + +categories = {"discovery", "intrusive"} + + +portrule = function (host, port) + if not shortport.port_or_service(53, "domain", {"tcp", "udp"})(host, port) then + return false + end + -- only check tcp if udp is not open or open|filtered + if port.protocol == 'tcp' then + local tmp_port = nmap.get_port_state(host, {number=port.number, protocol="udp"}) + if tmp_port then + return not string.match(tmp_port.state, '^open') + end + end + return true +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 + +-- RFC 952: "A 'name' is a text string up to 24 characters drawn from the +-- alphabet (A-Z), digits (0-9), minus sign (-), and period (.). ... The first +-- character must be an alpha character." +-- RFC 1123, section 2.1: "One aspect of host name syntax is hereby changed: +-- the restriction on the first character is relaxed to allow either a letter +-- or a digit." +-- RFC 2782: An underscore (_) is prepended to the service identifier to avoid +-- collisions with DNS labels that occur in nature. +local DNS_CHARS = { string.byte("-0123456789_abcdefghijklmnopqrstuvwxyz", 1, -1) } +local DNS_CHARS_INV = tableaux.invert(DNS_CHARS) + +-- Return the lexicographically next component, or nil if component is the +-- lexicographically last. +local function increment_component(name) + local i, bytes, indexes + + -- Easy cases first. + if #name == 0 then + return "0" + elseif #name < 63 then + return name .. "-" + elseif #name > 64 then + -- Shouldn't happen. + return nil + end + + -- Convert the string into an array of indexes into DNS_CHARS. + indexes = {} + for i, b in ipairs({ string.byte(name, 1, -1) }) do + indexes[i] = DNS_CHARS_INV[b] + end + -- Increment. + i = #name + while i >= 1 do + repeat + indexes[i] = indexes[i] + 1 + -- No "-" in first position. + until not (i == 1 and string.char(DNS_CHARS[indexes[i]]) == "-") + if indexes[i] > #DNS_CHARS then + -- Wrap around, next digit. + indexes[i] = 1 + else + break + end + i = i - 1 + end + -- Overflow. + if i == 0 then + return nil + end + -- Convert array of indexes back into string. + bytes = {} + for i, index in ipairs(indexes) do + bytes[i] = DNS_CHARS[index] + end + + return string.char(table.unpack(bytes)) +end + +-- Return the lexicographically next domain name that does not add a new +-- subdomain. This is used after enumerating a whole subzone to jump out of the +-- subzone and on to more names. +local function bump_domain(domain) + local components + + components = split(domain) + while #components > 0 do + components[1] = increment_component(components[1]) + if components[1] then + break + else + table.remove(components[1]) + end + end + + if #components == 0 then + return nil + else + return join(components) + end +end + +-- Return the lexicographically next domain name. This adds a new subdomain +-- consisting of the smallest character. This function never returns a domain +-- outside the current subzone. +local function next_domain(domain) + if #domain == 0 then + return "0" + else + return "0" .. "." .. domain + end +end + +-- Cut out a portion of an array and return it as a new array, setting the +-- elements in the original array to nil. +local function excise(t, i, j) + local result + + result = {} + if j < 0 then + j = #t + j + 1 + end + for i = i, j do + result[#result + 1] = t[i] + t[i] = nil + end + + return result +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 + +-- "Less than" function for two domain names. Compares starting with the last +-- component. +local function domain_lt(a, b) + local a_parts, b_parts + + a_parts = split(a) + b_parts = split(b) + while #a_parts > 0 and #b_parts > 0 do + if a_parts[#a_parts] < b_parts[#b_parts] then + return true + elseif a_parts[#a_parts] > b_parts[#b_parts] then + return false + end + a_parts[#a_parts] = nil + b_parts[#b_parts] = nil + end + + return #a_parts < #b_parts +end + +-- Find the NSEC record that brackets the given domain. +local function get_next_nsec(retPkt, domain) + for _, nsec in ipairs(auth_filter(retPkt, "NSEC")) do + -- The last NSEC record points backwards to the start of the subzone. + if domain_lt(nsec.dname, domain) and not domain_lt(nsec.dname, nsec.next_dname) then + return nsec + end + if domain_lt(nsec.dname, domain) and domain_lt(domain, nsec.next_dname) then + return nsec + end + end +end + +local function empty(t) + return not next(t) +end + +-- Enumerate a single domain. +local function enum(host, port, domain) + local all_results = {} + local seen = {} + local subdomain = next_domain("") + + while subdomain do + local result = {} + local status, result, nsec + stdnse.debug1("Trying %q.%q", subdomain, domain) + status, result = dns.query(join({subdomain, domain}), {host = host.ip, port=port.number, proto=port.protocol, dtype='A', retAll=true, retPkt=true, dnssec=true}) + nsec = status and get_next_nsec(result, join({subdomain, domain})) or nil + if nsec then + local first, last, remainder + local index + + first, remainder = remove_suffix(nsec.dname, domain) + if #remainder > 0 then + stdnse.debug1("Result name %q doesn't end in %q.", nsec.dname, domain) + subdomain = nil + break + end + last, remainder = remove_suffix(nsec.next_dname, domain) + if #remainder > 0 then + stdnse.debug1("Result name %q doesn't end in %q.", nsec.next_dname, domain) + subdomain = nil + break + end + if #last == 0 then + stdnse.debug1("Wrapped") + subdomain = nil + break + end + + if not seen[first] then + table.insert(all_results, join({first, domain})) + seen[first] = #all_results + end + index = seen[last] + if index then + -- Ignore if first is the original domain. + if #first > 0 then + subdomain = bump_domain(last) + -- Replace a chunk of the output with a sub-table for the zone. + all_results[index] = excise(all_results, index, -1) + end + else + stdnse.debug1("adding %s", last) + subdomain = next_domain(last) + table.insert(all_results, join({last, domain})) + seen[last] = #all_results + end + else + local parent = remove_component(subdomain, 1) + + -- This branch is entered if name resolution failed or + -- there were no NSEC records. If at the top, quit. + -- Otherwise continue to the next subdomain. + if parent then + subdomain = bump_domain(parent) + else + return nil + end + end + end + + return all_results +end + +action = function(host, port) + local output = {} + local domains + + domains = stdnse.get_script_args('dns-nsec-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 result = enum(host, port, domain) + if type(result) == "table" then + result["name"] = domain + output[#output + 1] = result + else + output[#output + 1] = "No NSEC records found" + end + end + + return stdnse.format_output(true, output) +end |