diff --git a/docs/3rd-party-licenses.txt b/docs/3rd-party-licenses.txt index f7dcf7ecc..24f2346c7 100644 --- a/docs/3rd-party-licenses.txt +++ b/docs/3rd-party-licenses.txt @@ -38,6 +38,9 @@ On Windows only, Nmap uses: distributed with Nmap in the subdirectory mswin32/winpcap. http://www.winpcap.org/ +Certain Nmap Scripting Engine scripts use the simplified BSD license in +licenses/BSD-simplified. + Zenmap and Ndiff require: o Python. The binary distributions of Nmap include a Python interpreter and various libraries, built using either py2exe or py2app. diff --git a/docs/licenses/BSD-simplified b/docs/licenses/BSD-simplified new file mode 100644 index 000000000..dd0e8e759 --- /dev/null +++ b/docs/licenses/BSD-simplified @@ -0,0 +1,26 @@ +Copyright. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR IMPLIED +WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT +SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +The views and conclusions contained in the software and documentation are those +of the authors and should not be interpreted as representing official policies, +either expressed or implied, of the authors. diff --git a/nselib/dns.lua b/nselib/dns.lua index 952c1a424..05bae112c 100644 --- a/nselib/dns.lua +++ b/nselib/dns.lua @@ -43,7 +43,6 @@ get_servers = nmap.get_dns_servers -- @class table types = { A = 1, - AAAA = 28, NS = 2, SOA = 6, CNAME = 5, @@ -51,8 +50,11 @@ types = { HINFO = 13, MX = 15, TXT = 16, + AAAA = 28, SRV = 33, + OPT = 41, SSHFP = 44, + NSEC = 47, AXFR = 252, ANY = 255 } @@ -277,6 +279,10 @@ function query(dname, options) addQuestion(pkt, dname, dtype) if options.norecurse then pkt.flags.RD = false end + if options.dnssec then + addOPT(pkt, {DO = true}) + end + local data = encode(pkt) local status, response = sendPackets(data, host, port, options.timeout, options.sendCount, options.multiple) @@ -484,6 +490,29 @@ answerFetcher[types.SRV] = function(dec, retAll) return true, answers end +-- Answer fetcher for NSEC records. +-- @param dec Decoded DNS response. +-- @param retAll If true, return all entries, not just the first. +-- @return True if one or more answers of the required type were found - otherwise false. +-- @return String first dns NSEC record or Table of NSEC records or String Error message. +-- Note that the format of a returned NSEC answer is "name:dname:types". +answerFetcher[types.NSEC] = function(dec, retAll) + local nsec, answers = {}, {} + for _, auth in ipairs(dec.auth) do + if auth.NSEC then nsec[#nsec+1] = auth.NSEC end + if not retAll then break end + end + if #nsec == 0 then + stdnse.print_debug(1, "dns.answerFetcher found no records of the required type: NSEC") + return false, "No Answers" + end + for _, nsecrec in ipairs(nsec) do + table.insert( answers, ("%s:%s:%s"):format(nsecrec.name or "-", nsecrec.dname or "-", stdnse.strjoin(":", nsecrec.types) or "-")) + end + if not retAll then return true, answers[1] end + return true, answers +end + -- Answer fetcher for NS records. -- @name answerFetcher[types.NS] -- @class function @@ -537,8 +566,7 @@ function findNiceAnswer(dtype, dec, retAll) if answerFetcher[dtype] then return answerFetcher[dtype](dec, retAll) else - stdnse.print_debug(1, "dns.findNiceAnswer() does not have an answerFetcher for dtype %s", - (type(dtype) == 'string' and dtype) or type(dtype) or "nil") + stdnse.print_debug(1, "dns.findNiceAnswer() does not have an answerFetcher for dtype %s", tostring(dtype)) return false, "Unable to handle response" end elseif (dec.flags.RC3 and dec.flags.RC4) then @@ -729,6 +757,21 @@ local function encodeUpdates(updates) return encQ end +--- +-- Encodes the additional part of a DNS request. +-- @param additional Table of additional records. Each must have the keys +-- type, class, ttl, rdlen, +-- and rdata. +-- @return Encoded additional string. +local function encodeAdditional(additional) + if type(additional) ~= "table" then return nil end + local encA = "" + for _, v in ipairs(additional) do + encA = encA .. bin.pack(">xSSISA", v.type, v.class, v.ttl, v.rdlen, v.rdata) + end + return encA +end + --- -- Encodes DNS flags to a binary digit string. -- @param flags Flag table, each entry representing a flag (QR, OCx, AA, TC, RD, @@ -758,14 +801,15 @@ end --- -- Encode a DNS packet. -- --- Caution: doesn't encode answer, authority and additional part. +-- Caution: doesn't encode answer and authority part. -- @param pkt Table representing DNS packet, initialized by -- newPacket. -- @return Encoded DNS packet. function encode(pkt) if type(pkt) ~= "table" then return nil end local encFlags = encodeFlags(pkt.flags) - local data = encodeQuestions(pkt.questions) + local questions = encodeQuestions(pkt.questions) + local additional = encodeAdditional(pkt.additional) local qorzlen = #pkt.questions local aorplen = #pkt.answers local aorulen = #pkt.auth @@ -779,7 +823,7 @@ function encode(pkt) data = data .. encodeUpdates( pkt.updates ) end - local encStr = bin.pack(">SBS4", pkt.id, encFlags, qorzlen, aorplen, aorulen, #pkt.additional) .. data + local encStr = bin.pack(">SBS4", pkt.id, encFlags, qorzlen, aorplen, aorulen, #pkt.additional) .. questions .. additional return encStr end @@ -911,6 +955,32 @@ decoder[types.SOA] = function(entry, data, pos) = bin.unpack(">I5", data, np) end +-- Decodes NSEC records, puts result in entry.NSEC. +-- +-- entry.NSEC has the fields dname, +-- NSEC, name, WinBlockNo, +-- bmplength, bin, and types. +-- @param entry RR in packet. +-- @param data Complete encoded DNS packet. +-- @param pos Position in packet after RR. +decoder[types.NSEC] = function (entry, data, pos) + local np = pos - #entry.data + entry.NSEC = {} + entry.NSEC.dname = entry.dname + entry.NSEC.NSEC = true + np, entry.NSEC.name = decStr(data, np) + np, entry.NSEC.WinBlockNo, entry.NSEC.bmplength = bin.unpack(">CC", data, np) + np, entry.NSEC.bin = bin.unpack("B".. entry.NSEC.bmplength, data, np) + entry.NSEC.types = {} + for i=1, string.len(entry.NSEC.bin) do + local bit = string.sub(entry.NSEC.bin,i,i) + if bit == "1" then + --the first bit represents window block 0 hence -1 + table.insert(entry.NSEC.types, (entry.NSEC.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. -- @param entry RR in packet. @@ -1030,7 +1100,9 @@ local function decodeRR(data, count, pos) pos, currRR.data = bin.unpack("A" .. reslen, data, pos) -- try to be smart: decode per type - decoder[currRR.dtype](currRR, data, pos) + if decoder[currRR.dtype] then + decoder[currRR.dtype](currRR, data, pos) + end table.insert(ans, currRR) end @@ -1153,6 +1225,42 @@ function addZone(pkt, dname) return pkt end +--- +-- Encodes the Z bitfield of an OPT record. +-- @param flags Flag table, each entry representing a flag (only DO flag implmented). +-- @return Binary digit string representing flags. +local function encodeOPT_Z(flags) + if type(flags) == "string" then return flags end + if type(flags) ~= "table" then return nil end + local bits = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} + for n, k in pairs({[1] = "DO"}) do + if flags[k] then + bits[n] = 1 + end + end + return table.concat(bits) +end + +--- +-- Adds an OPT RR to a DNS packet's additional section. Only the table of Z +-- flags is supported (i.e., not RDATA). See RFC 2671 section 4.3. +-- @param pkt Table representing DNS packet. +-- @param Z Table of Z flags. Only DO is supported. +function addOPT(pkt, Z) + if type(pkt) ~= "table" then return nil end + if type(pkt.additional) ~= "table" then return nil end + local _, Z_int = bin.unpack(">S", bin.pack("B", encodeOPT_Z(Z))) + local opt = { + type = types.OPT, + class = 4096, -- Actually the sender UDP payload size. + ttl = 0 * (0x01000000) + 0 * (0x00010000) + Z_int, + rdlen = 0, + rdata = "", + } + table.insert(pkt.additional, opt) + return pkt +end + --- -- Adds a update to a DNS packet table -- @param pkt Table representing DNS packet. diff --git a/scripts/dns-nsec-enum.nse b/scripts/dns-nsec-enum.nse new file mode 100644 index 000000000..dc63d280e --- /dev/null +++ b/scripts/dns-nsec-enum.nse @@ -0,0 +1,379 @@ +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. +]] + +--- +-- @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 +-- +-- @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 Bond" +license = "Simplified (2-clause) BSD license--See http://nmap.org/svn/docs/licenses/BSD-simplified" + +categories = {"discovery", "intrusive"} + +require "stdnse" +require "shortport" +require "dns" + +portrule = shortport.port_or_service(53, "domain", {"tcp", "udp"}) + +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 + +local function invert(t) + local result = {} + + for k, v in pairs(t) do + result[v] = k + end + + return result +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 = 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(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.name) then + return nsec + end + if domain_lt(nsec.dname, domain) and domain_lt(domain, nsec.name) 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.print_debug("Trying %q.%q", subdomain, domain) + status, result = dns.query(join({subdomain, domain}), {host = host.ip, 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.print_debug("Result name %q doesn't end in %q.", nsec.dname, domain) + subdomain = nil + break + end + last, remainder = remove_suffix(nsec.name, domain) + if #remainder > 0 then + stdnse.print_debug("Result name %q doesn't end in %q.", nsec.name, domain) + subdomain = nil + break + end + if #last == 0 then + stdnse.print_debug("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.print_debug("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 diff --git a/scripts/script.db b/scripts/script.db index cb70351ae..86c74ede8 100644 --- a/scripts/script.db +++ b/scripts/script.db @@ -26,6 +26,7 @@ Entry { filename = "dhcp-discover.nse", categories = { "default", "discovery", " Entry { filename = "dns-brute.nse", categories = { "discovery", "intrusive", } } Entry { filename = "dns-cache-snoop.nse", categories = { "discovery", "intrusive", } } Entry { filename = "dns-fuzz.nse", categories = { "fuzzer", "intrusive", } } +Entry { filename = "dns-nsec-enum.nse", categories = { "discovery", "intrusive", } } Entry { filename = "dns-random-srcport.nse", categories = { "external", "intrusive", } } Entry { filename = "dns-random-txid.nse", categories = { "external", "intrusive", } } Entry { filename = "dns-recursion.nse", categories = { "default", "intrusive", } }