summaryrefslogtreecommitdiffstats
path: root/scripts/dns-fuzz.nse
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/dns-fuzz.nse')
-rw-r--r--scripts/dns-fuzz.nse306
1 files changed, 306 insertions, 0 deletions
diff --git a/scripts/dns-fuzz.nse b/scripts/dns-fuzz.nse
new file mode 100644
index 0000000..dade20a
--- /dev/null
+++ b/scripts/dns-fuzz.nse
@@ -0,0 +1,306 @@
+local comm = require "comm"
+local dns = require "dns"
+local math = require "math"
+local nmap = require "nmap"
+local shortport = require "shortport"
+local stdnse = require "stdnse"
+local string = require "string"
+local table = require "table"
+
+description = [[
+Launches a DNS fuzzing attack against DNS servers.
+
+The script induces errors into randomly generated but valid DNS packets.
+The packet template that we use includes one uncompressed and one
+compressed name.
+
+Use the <code>dns-fuzz.timelimit</code> argument to control how long the
+fuzzing lasts. This script should be run for a long time. It will send a
+very large quantity of packets and thus it's pretty invasive, so it
+should only be used against private DNS servers as part of a software
+development lifecycle.
+]]
+
+---
+-- @usage
+-- nmap -sU --script dns-fuzz --script-args timelimit=2h <target>
+--
+-- @args dns-fuzz.timelimit How long to run the fuzz attack. This is a
+-- number followed by a suffix: <code>s</code> for seconds,
+-- <code>m</code> for minutes, and <code>h</code> for hours. Use
+-- <code>0</code> for an unlimited amount of time. Default:
+-- <code>10m</code>.
+--
+-- @output
+-- Host script results:
+-- |_dns-fuzz: Server stopped responding... He's dead, Jim.
+
+author = "Michael Pattrick"
+license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
+categories = {"fuzzer", "intrusive"}
+
+
+portrule = shortport.portnumber(53, {"tcp", "udp"})
+
+-- How many ms should we wait for the server to respond.
+-- Might want to make this an argument, but 500 should always be more then enough.
+DNStimeout = 500
+
+-- Will the DNS server only respond to recursive questions
+recursiveOnly = false
+
+-- We only perform a DNS lookup of this site
+recursiveServer = "scanme.nmap.org"
+
+---
+-- Checks if the server is alive/DNS
+-- @param host The host which the server should be running on
+-- @param port The servers port
+-- @return Bool, true if and only if the server is alive
+function pingServer (host, port, attempts)
+ local status, response, result
+ -- If the server doesn't respond to the first in a multiattempt probe, slow down
+ local slowDown = 1
+ if not recursiveOnly then
+ -- try to get a server status message
+ -- The method that nmap uses by default
+ local data
+ local pkt = dns.newPacket()
+ pkt.id = math.random(65535)
+
+ pkt.flags.OC3 = true
+
+ data = dns.encode(pkt)
+
+ for i = 1, attempts do
+ status, result = comm.exchange(host, port, data, {timeout=DNStimeout^slowDown})
+ if status then
+ return true
+ end
+ slowDown = slowDown + 0.25
+ end
+
+ return false
+ else
+ -- just do a vanilla recursive lookup of scanme.nmap.org
+ for i = 1, attempts do
+ status, response = dns.query(recursiveServer, {host=host.ip, port=port.number, proto=port.protocol, tries=1, timeout=DNStimeout^slowDown})
+ if status then
+ return true
+ end
+ slowDown = slowDown + 0.25
+ end
+ return false
+ end
+end
+
+---
+-- Generate a random 'label', a string of ascii characters do be used in
+-- the requested domain names
+-- @return Random string of lowercase characters
+function makeWord ()
+ local len = math.random(3,7)
+ local name = {string.char(len)}
+ for i = 1, len do
+ -- this next line assumes ascii
+ name[i+1] = string.char(math.random(string.byte("a"),string.byte("z")))
+ end
+ return table.concat(name)
+end
+
+---
+-- Turns random labels from makeWord into a valid domain name.
+-- Includes the option to compress any given name by including a pointer
+-- to the first record. Obviously the first record should not be compressed.
+-- @param compressed Bool, whether or not this record should have a compressed field
+-- @return A dns host string
+function makeHost (compressed)
+ -- randomly choose between 2 to 4 levels in this domain
+ local levels = math.random(2,4)
+ local name = {}
+ for i = 1, levels do
+ name[#name+1] = makeWord ()
+ end
+ if compressed then
+ name[#name+1] = "\xc0\x0c"
+ else
+ name[#name+1] = "\x00"
+ end
+
+ return table.concat(name)
+end
+
+---
+-- Concatenate all the bytes of a valid dns packet, including names generated by
+-- makeHost(). This packet is to be corrupted.
+-- @return Always returns a valid packet
+function makePacket()
+ local recurs = 0x00
+ if recursiveOnly then
+ recurs = 0x01
+ end
+ return
+ string.char( math.random(0,255), math.random(0,255), -- TXID
+ recurs, 0x00, -- Flags, recursion disabled by default for obvious reasons
+ 0x00, 0x02, -- Questions
+ 0x00, 0x00, -- Answer RRs
+ 0x00, 0x00, -- Authority RRs
+ 0x00, 0x00) -- Additional RRs
+ -- normal host
+ .. makeHost (false) .. -- Hostname
+ string.char( 0x00, 0x01, -- Type (A)
+ 0x00, 0x01) -- Class (IN)
+ -- compressed host
+ .. makeHost (true) .. -- Hostname
+ string.char( 0x00, 0x05, -- Type (CNAME)
+ 0x00, 0x01) -- Class (IN)
+end
+
+---
+-- Introduce bit errors into a packet at a rate of 1/50
+-- As Charlie Miller points out in "Fuzz by Number"
+-- -> cansecwest.com/csw08/csw08-miller.pdf
+-- It's difficult to tell how much random you should insert into packets
+-- "If data is too valid, might not cause problems, If data is too invalid,
+-- might be quickly rejected"
+-- so 1/50 is arbitrary
+-- @param dnsPacket A packet, generated by makePacket()
+-- @return The same packet, but with bit flip errors
+function nudgePacket (dnsPacket)
+ local chunks = {}
+ local pos = 1
+ for i = 1, #dnsPacket do
+ -- Induce bit errors at a rate of 1/50.
+ if math.random(50) == 25 then
+ table.insert(chunks, dnsPacket:sub(pos, i - 1))
+ table.insert(chunks, string.char(dnsPacket:byte(i) ~ (1 << math.random(0, 7))))
+ pos = i + 1
+ end
+ end
+ table.insert(chunks, dnsPacket:sub(pos))
+ return table.concat(chunks)
+end
+
+---
+-- Instead of flipping a bit, we drop an entire byte
+-- @param dnsPacket A packet, generated by makePacket()
+-- @return The same packet, but with a single byte missing
+function dropByte (dnsPacket)
+ local pos = math.random(#dnsPacket)
+ return dnsPacket:sub(1, pos - 1) .. dnsPacket:sub(pos + 1)
+end
+
+---
+-- Instead of dropping an entire byte, insert a random byte
+-- @param dnsPacket A packet, generated by makePacket()
+-- @return The same packet, but with a single byte missing
+function injectByte (dnsPacket)
+ local pos = math.random(#dnsPacket + 1)
+ return dnsPacket:sub(1, pos - 1) .. string.char(math.random(0,255)) .. dnsPacket:sub(pos)
+end
+
+---
+-- Instead of inserting a byte, truncate the packet at random position
+-- @param dnsPacket A packet, generated by makePacket()
+-- @return The same packet, but truncated
+function truncatePacket (dnsPacket)
+ -- at least 12 bytes to make sure the packet isn't dropped as a tinygram
+ local pos = math.random(12, #dnsPacket - 1)
+ return dnsPacket:sub(1, pos)
+end
+
+---
+-- As the name of this function suggests, we corrupt the packet, and then send it.
+-- We choose at random one of three corruption functions, and then corrupt/send
+-- the packet a maximum of 10 times
+-- @param host The servers IP
+-- @param port The servers port
+-- @param query An uncorrupted DNS packet
+-- @return A string if the server died, else nil
+function corruptAndSend (host, port, query)
+ local randCorr = math.random(0,4)
+ local status
+ local result
+ -- 10 is arbitrary, but seemed like a good number
+ for j = 1, 10 do
+ if randCorr<=1 then
+ -- slight bias to nudging because it seems to work better
+ query = nudgePacket(query)
+ elseif randCorr==2 then
+ query = dropByte(query)
+ elseif randCorr==3 then
+ query = injectByte(query)
+ elseif randCorr==4 then
+ query = truncatePacket(query)
+ end
+
+ status, result = comm.exchange(host, port, query, {timeout=DNStimeout})
+ if not status then
+ if not pingServer(host,port,3) then
+ -- no response after three tries, the server is probably dead
+ return "Server stopped responding... He's dead, Jim.\n"..
+ "Offending packet: 0x".. stdnse.tohex(query)
+ else
+ -- We corrupted the packet too much, the server will just drop it
+ -- No point in using it again
+ return nil
+ end
+ end
+ if randCorr==4 then
+ -- no point in using this function more then once
+ return nil
+ end
+ end
+ return nil
+end
+
+action = function(host, port)
+ local endT
+ local timelimit, err
+ local retStr
+ local query
+
+ for _, k in ipairs({"dns-fuzz.timelimit", "timelimit"}) do
+ if nmap.registry.args[k] then
+ timelimit, err = stdnse.parse_timespec(nmap.registry.args[k])
+ if not timelimit then
+ error(err)
+ end
+ break
+ end
+ end
+ if timelimit and timelimit > 0 then
+ -- seconds to milliseconds plus the current time
+ endT = timelimit*1000 + nmap.clock_ms()
+ elseif not timelimit then
+ -- 10 minutes
+ endT = 10*60*1000 + nmap.clock_ms()
+ end
+
+
+ -- Check if the server is a DNS server.
+ if not pingServer(host,port,1) then
+ -- David reported that his DNS server doesn't respond to
+ recursiveOnly = true
+ if not pingServer(host,port,1) then
+ return "Server didn't response to our probe, can't fuzz"
+ end
+ end
+ nmap.set_port_state (host, port, "open")
+
+ -- If the user specified that we should run for n seconds, then don't run for too much longer
+ -- If 0 seconds, then run forever
+ while not endT or nmap.clock_ms()<endT do
+ -- Forge an initial packet
+ -- We start off with an only slightly corrupted packet, then add more and more corruption
+ -- if we corrupt the packet too much then the server will just drop it, so we only recorrupt several times
+ -- then start all over
+ query = makePacket ()
+ -- induce random jitter
+ retStr = corruptAndSend (host, port, query)
+ if retStr then
+ return retStr
+ end
+ end
+ return "The server seems impervious to our assault."
+end