1local comm = require "comm"
2local dns = require "dns"
3local math = require "math"
4local nmap = require "nmap"
5local shortport = require "shortport"
6local stdnse = require "stdnse"
7local string = require "string"
8local table = require "table"
9
10description = [[
11Launches a DNS fuzzing attack against DNS servers.
12
13The script induces errors into randomly generated but valid DNS packets.
14The packet template that we use includes one uncompressed and one
15compressed name.
16
17Use the <code>dns-fuzz.timelimit</code> argument to control how long the
18fuzzing lasts. This script should be run for a long time. It will send a
19very large quantity of packets and thus it's pretty invasive, so it
20should only be used against private DNS servers as part of a software
21development lifecycle.
22]]
23
24---
25-- @usage
26-- nmap -sU --script dns-fuzz --script-args timelimit=2h <target>
27--
28-- @args dns-fuzz.timelimit How long to run the fuzz attack. This is a
29-- number followed by a suffix: <code>s</code> for seconds,
30-- <code>m</code> for minutes, and <code>h</code> for hours. Use
31-- <code>0</code> for an unlimited amount of time. Default:
32-- <code>10m</code>.
33--
34-- @output
35-- Host script results:
36-- |_dns-fuzz: Server stopped responding... He's dead, Jim.
37
38author = "Michael Pattrick"
39license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
40categories = {"fuzzer", "intrusive"}
41
42
43portrule = shortport.portnumber(53, {"tcp", "udp"})
44
45-- How many ms should we wait for the server to respond.
46-- Might want to make this an argument, but 500 should always be more then enough.
47DNStimeout = 500
48
49-- Will the DNS server only respond to recursive questions
50recursiveOnly = false
51
52-- We only perform a DNS lookup of this site
53recursiveServer = "scanme.nmap.org"
54
55---
56-- Checks if the server is alive/DNS
57-- @param host  The host which the server should be running on
58-- @param port  The servers port
59-- @return      Bool, true if and only if the server is alive
60function pingServer (host, port, attempts)
61  local status, response, result
62  -- If the server doesn't respond to the first in a multiattempt probe, slow down
63  local slowDown = 1
64  if not recursiveOnly then
65    -- try to get a server status message
66    -- The method that nmap uses by default
67    local data
68    local pkt = dns.newPacket()
69    pkt.id = math.random(65535)
70
71    pkt.flags.OC3 = true
72
73    data = dns.encode(pkt)
74
75    for i = 1, attempts do
76      status, result = comm.exchange(host, port, data, {timeout=DNStimeout^slowDown})
77      if status then
78        return true
79      end
80      slowDown = slowDown + 0.25
81    end
82
83    return false
84  else
85    -- just do a vanilla recursive lookup of scanme.nmap.org
86    for i = 1, attempts do
87      status, response = dns.query(recursiveServer, {host=host.ip, port=port.number, proto=port.protocol, tries=1, timeout=DNStimeout^slowDown})
88      if status then
89        return true
90      end
91      slowDown = slowDown + 0.25
92    end
93    return false
94  end
95end
96
97---
98-- Generate a random 'label', a string of ascii characters do be used in
99-- the requested domain names
100-- @return      Random string of lowercase characters
101function makeWord ()
102  local len =  math.random(3,7)
103  local name = {string.char(len)}
104  for i = 1, len do
105    -- this next line assumes ascii
106    name[i+1] = string.char(math.random(string.byte("a"),string.byte("z")))
107  end
108  return table.concat(name)
109end
110
111---
112-- Turns random labels from makeWord into a valid domain name.
113-- Includes the option to compress any given name by including a pointer
114-- to the first record. Obviously the first record should not be compressed.
115-- @param compressed  Bool, whether or not this record should have a compressed field
116-- @return            A dns host string
117function makeHost (compressed)
118  -- randomly choose between 2 to 4 levels in this domain
119  local levels = math.random(2,4)
120  local name = {}
121  for i = 1, levels do
122    name[#name+1] = makeWord ()
123  end
124  if compressed then
125    name[#name+1] = "\xc0\x0c"
126  else
127    name[#name+1] = "\x00"
128  end
129
130  return table.concat(name)
131end
132
133---
134-- Concatenate all the bytes of a valid dns packet, including names generated by
135-- makeHost(). This packet is to be corrupted.
136-- @return      Always returns a valid packet
137function makePacket()
138  local recurs = 0x00
139  if recursiveOnly then
140    recurs = 0x01
141  end
142  return
143  string.char( math.random(0,255), math.random(0,255),   -- TXID
144    recurs, 0x00,                             -- Flags, recursion disabled by default for obvious reasons
145    0x00, 0x02,                               -- Questions
146    0x00, 0x00,                               -- Answer RRs
147    0x00, 0x00,                               -- Authority RRs
148    0x00, 0x00)                               -- Additional RRs
149  -- normal host
150  .. makeHost (false) ..                    -- Hostname
151  string.char( 0x00, 0x01,                               -- Type (A)
152    0x00, 0x01)                               -- Class (IN)
153  -- compressed host
154  .. makeHost (true) ..                     -- Hostname
155  string.char( 0x00, 0x05,                               -- Type (CNAME)
156    0x00, 0x01)                               -- Class (IN)
157end
158
159---
160-- Introduce bit errors into a packet at a rate of 1/50
161-- As Charlie Miller points out in "Fuzz by Number"
162-- -> cansecwest.com/csw08/csw08-miller.pdf
163-- It's difficult to tell how much random you should insert into packets
164-- "If data is too valid, might not cause problems, If data is too invalid,
165--  might be quickly rejected"
166-- so 1/50 is arbitrary
167-- @param dnsPacket  A packet, generated by makePacket()
168-- @return           The same packet, but with bit flip errors
169function nudgePacket (dnsPacket)
170  local chunks = {}
171  local pos = 1
172  for i = 1, #dnsPacket do
173    -- Induce bit errors at a rate of 1/50.
174    if math.random(50) == 25 then
175      table.insert(chunks, dnsPacket:sub(pos, i - 1))
176      table.insert(chunks, string.char(dnsPacket:byte(i) ~ (1 << math.random(0, 7))))
177      pos = i + 1
178    end
179  end
180  table.insert(chunks, dnsPacket:sub(pos))
181  return table.concat(chunks)
182end
183
184---
185-- Instead of flipping a bit, we drop an entire byte
186-- @param dnsPacket  A packet, generated by makePacket()
187-- @return           The same packet, but with a single byte missing
188function dropByte (dnsPacket)
189  local pos = math.random(#dnsPacket)
190  return dnsPacket:sub(1, pos - 1) .. dnsPacket:sub(pos + 1)
191end
192
193---
194-- Instead of dropping an entire byte, insert a random byte
195-- @param dnsPacket  A packet, generated by makePacket()
196-- @return           The same packet, but with a single byte missing
197function injectByte (dnsPacket)
198  local pos = math.random(#dnsPacket + 1)
199  return dnsPacket:sub(1, pos - 1) .. string.char(math.random(0,255)) .. dnsPacket:sub(pos)
200end
201
202---
203-- Instead of inserting a byte, truncate the packet at random position
204-- @param dnsPacket  A packet, generated by makePacket()
205-- @return           The same packet, but truncated
206function truncatePacket (dnsPacket)
207  -- at least 12 bytes to make sure the packet isn't dropped as a tinygram
208  local pos = math.random(12, #dnsPacket - 1)
209  return dnsPacket:sub(1, pos)
210end
211
212---
213-- As the name of this function suggests, we corrupt the packet, and then send it.
214-- We choose at random one of three corruption functions, and then corrupt/send
215-- the packet a maximum of 10 times
216-- @param host      The servers IP
217-- @param port      The servers port
218-- @param query     An uncorrupted DNS packet
219-- @return          A string if the server died, else nil
220function corruptAndSend (host, port, query)
221  local randCorr = math.random(0,4)
222  local status
223  local result
224  -- 10 is arbitrary, but seemed like a good number
225  for j = 1, 10 do
226    if randCorr<=1  then
227      -- slight bias to nudging because it seems to work better
228      query = nudgePacket(query)
229    elseif randCorr==2  then
230      query = dropByte(query)
231    elseif randCorr==3  then
232      query = injectByte(query)
233    elseif randCorr==4  then
234      query = truncatePacket(query)
235    end
236
237    status, result = comm.exchange(host, port, query, {timeout=DNStimeout})
238    if not status then
239      if not pingServer(host,port,3) then
240        -- no response after three tries, the server is probably dead
241        return "Server stopped responding... He's dead, Jim.\n"..
242        "Offending packet: 0x".. stdnse.tohex(query)
243      else
244        -- We corrupted the packet too much, the server will just drop it
245        -- No point in using it again
246        return nil
247      end
248    end
249    if randCorr==4  then
250      -- no point in using this function more then once
251      return nil
252    end
253  end
254  return nil
255end
256
257action = function(host, port)
258  local endT
259  local timelimit, err
260  local retStr
261  local query
262
263  for _, k in ipairs({"dns-fuzz.timelimit", "timelimit"}) do
264    if nmap.registry.args[k] then
265      timelimit, err = stdnse.parse_timespec(nmap.registry.args[k])
266      if not timelimit then
267        error(err)
268      end
269      break
270    end
271  end
272  if timelimit and timelimit > 0 then
273    -- seconds to milliseconds plus the current time
274    endT = timelimit*1000 + nmap.clock_ms()
275  elseif not timelimit then
276    -- 10 minutes
277    endT = 10*60*1000 + nmap.clock_ms()
278  end
279
280
281  -- Check if the server is a DNS server.
282  if not pingServer(host,port,1) then
283    -- David reported that his DNS server doesn't respond to
284    recursiveOnly = true
285    if not pingServer(host,port,1) then
286      return "Server didn't response to our probe, can't fuzz"
287    end
288  end
289  nmap.set_port_state (host, port, "open")
290
291  -- If the user specified that we should run for n seconds, then don't run for too much longer
292  -- If 0 seconds, then run forever
293  while not endT or nmap.clock_ms()<endT do
294    -- Forge an initial packet
295    -- We start off with an only slightly corrupted packet, then add more and more corruption
296    -- if we corrupt the packet too much then the server will just drop it, so we only recorrupt several times
297    -- then start all over
298    query =  makePacket ()
299    -- induce random jitter
300    retStr = corruptAndSend (host, port, query)
301    if retStr then
302      return retStr
303    end
304  end
305  return "The server seems impervious to our assault."
306end
307