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