├── README.md └── nsec-walker.py /README.md: -------------------------------------------------------------------------------- 1 | # NSEC(3) Walker 🚶 2 | 3 | DNS zones that use DNSSEC must use NSEC or NSEC3 records as a means of authenticated denial-of-existence. NSEC allows for fully extracting DNS zones akin to an AXFR zone transfer or a "zone dump". NSEC3 adds hashes to this process which must be cracked, but offline cracking is faster than online brute-forcing. NSEC(3) Walker automates this extraction process. 4 | 5 | For more technical information see: https://harrisonm.com/blog/nsec-walking 6 | 7 | For a whitepaper analysing NSEC3 recovery see: https://harrisonm.com/whitepaper/nsec3-prevalence-and-recoverability.pdf 8 | 9 | ### Dependencies 10 | 11 | * Python 3 12 | * `pip install dnspython` 13 | 14 | ### Usage 15 | 16 | **`python3 nsec-walker.py example.com`** 17 | 18 | Or for NSEC3 dumping post hash cracking: 19 | 20 | `python3 nsec-walker.py example.com nsec3.map nsec3.cracked` 21 | 22 | ### Example (NSEC / NSEC3 post crack) 23 | 24 | ``` 25 | $ python3 nsec-walker.py youth.gov 26 | 27 | youth.gov 28 | A 52.191.39.218 29 | AAAA 2001:550:1200:3::81:131 30 | NS rh202ns1.355.dhhs.gov. 31 | NS rh120ns2.368.dhhs.gov. 32 | NS rh120ns1.368.dhhs.gov. 33 | NS rh202ns2.355.dhhs.gov. 34 | SOA rh120ns1.368.dhhs.gov. hostmaster.psc.hhs.gov. 5524 600 60 604800 60 35 | SPF "v=spf1" "-all" 36 | TXT "v=spf1 -all" 37 | TXT "khfllujpa7ksn8nn6un25ios2s" 38 | _dmarc.youth.gov 39 | TXT "v=DMARC1; p=reject; fo=1; ri=3600; rua=mailto:8idhoybh@ag.us.dmarcian.com,mailto:reports@dmarc.cyber.dhs.gov; ruf=mailto:8idhoybh@fr.us.dmarcian.com;" 40 | engage.youth.gov 41 | A 52.191.39.218 42 | evidence-innovation.youth.gov 43 | CNAME youth.gov. 44 | tppevidencereview.youth.gov 45 | A 52.191.39.218 46 | AAAA 2001:550:1200:3::81:125 47 | www.youth.gov 48 | CNAME youth.gov. 49 | ``` 50 | 51 | ### Example (NSEC3) 52 | 53 | ``` 54 | $ python3 nsec-walker.py id.au 55 | 56 | Found: (oseei5iaovl40d3pjl7d27b9smtii1g0, p353soq76jhvb6mdo3c5nm246g3nokp7) 57 | Found: (ji76fqses9dl31dee9cttvv0ck5llrt9, jk9mojps45834jkctbq2epnh3or22p2s) 58 | Found: (ulr4htn3b64un2liuqum28aappv8r33j, uugcg47l46hkl0sk83vsou10khiu7u9i) 59 | FOUND 4; DONE 1%; LEFT 399 60 | ``` 61 | 62 | ### Example (crawling the DNS root zone) 63 | 64 | ``` 65 | $ python3 nsec-walker.py . 66 | 67 | . 68 | NS h.root-servers.net. 69 | NS j.root-servers.net. 70 | NS b.root-servers.net. 71 | NS l.root-servers.net. 72 | NS g.root-servers.net. 73 | NS c.root-servers.net. 74 | NS i.root-servers.net. 75 | NS f.root-servers.net. 76 | NS d.root-servers.net. 77 | NS a.root-servers.net. 78 | NS m.root-servers.net. 79 | NS e.root-servers.net. 80 | NS k.root-servers.net. 81 | SOA a.root-servers.net. nstld.verisign-grs.com. 2022092700 1800 900 604800 86400 82 | aaa 83 | DS 23185 8 2 b18d0ec8791d98e167ca4d9745a0c27a6377e099d8f6a16a09567492ab16b7de 84 | NS 23185 8 2 b18d0ec8791d98e167ca4d9745a0c27a6377e099d8f6a16a09567492ab16b7de 85 | aarp 86 | DS 5751 8 2 7e8a14ab8f85009b9f19859815fa695954233fd9daa6ab359044d12621a77e9f 87 | NS 5751 8 2 7e8a14ab8f85009b9f19859815fa695954233fd9daa6ab359044d12621a77e9f 88 | abarth 89 | DS 62281 8 2 8be9e8b680bc10289ea71a8b7c34fe0cdbaa86242b2c38541de454526df041a4 90 | NS 62281 8 2 8be9e8b680bc10289ea71a8b7c34fe0cdbaa86242b2c38541de454526df041a4 91 | ``` 92 | 93 | ### Example (crawling all .mom domains) 94 | 95 | ``` 96 | $ python3 nsec-walker.py mom 97 | 98 | .mom 99 | NS a.nic.mom. 100 | NS b.nic.mom. 101 | NS c.nic.mom. 102 | NS d.nic.mom. 103 | SOA ns0.centralnic.net. hostmaster.centralnic.net. 1664283884 900 1800 6048000 3600 104 | 00554.mom 105 | NS ns0.centralnic.net. hostmaster.centralnic.net. 1664283884 900 1800 6048000 3600 106 | 007k.mom 107 | NS ns0.centralnic.net. hostmaster.centralnic.net. 1664283884 900 1800 6048000 3600 108 | 0088.mom 109 | NS ns0.centralnic.net. hostmaster.centralnic.net. 1664283884 900 1800 6048000 3600 110 | ``` 111 | -------------------------------------------------------------------------------- /nsec-walker.py: -------------------------------------------------------------------------------- 1 | import re 2 | import uuid 3 | import dns.resolver 4 | import dns.message 5 | import dns.rdataclass 6 | import dns.rdatatype 7 | import dns.query 8 | from sys import argv 9 | from time import sleep 10 | from random import choice 11 | from dns.dnssec import nsec3_hash 12 | 13 | transformations = [ 14 | # Send the hostname as-is 15 | lambda preDot, postDot: f"{preDot}.{postDot}", 16 | # Prepend a zero as a subdomain 17 | lambda preDot, postDot: f"0.{preDot}.{postDot}", 18 | # Append a hyphen to the subdomain 19 | lambda preDot, postDot: f"{preDot}-.{postDot}", 20 | # Double the last character of the subdomain 21 | lambda preDot, postDot: f"{preDot}{preDot[-1]}.{postDot}" 22 | ] 23 | 24 | resolver = dns.resolver.Resolver(configure=False) 25 | resolver.nameservers = ["8.8.8.8", "1.1.1.1"] 26 | resolver.timeout = 3 27 | 28 | def dnssecQuery (target, record="NSEC"): 29 | """Wrapper to DNS resolve with DNSSEC options""" 30 | 31 | if record == "NSEC": 32 | record = dns.rdatatype.NSEC 33 | if record == "A": 34 | record = dns.rdatatype.A 35 | if record == "NSEC3PARAM": 36 | record = dns.rdatatype.NSEC3PARAM 37 | 38 | query_name = dns.name.from_text(target) 39 | query = dns.message.make_query( 40 | query_name, 41 | record, 42 | want_dnssec=True 43 | ) 44 | 45 | # Some records are corrupt/too long over UDP, so fallback to TCP if need be 46 | return dns.query.udp_with_fallback( 47 | query, 48 | choice(resolver.nameservers), 49 | timeout=3.0 50 | )[0] 51 | 52 | def nsec (hostname): 53 | """Linearly walk a domain's records with NSEC crawling""" 54 | 55 | # Init helper vars 56 | origHostname = hostname 57 | pending = {hostname} 58 | finished = set() 59 | records = [] 60 | recordTypes = {} 61 | nextRecFudge = False 62 | 63 | try: 64 | # Whilst we're yet to finish the crawl, grab the next pending hostname 65 | while pending: 66 | hostname = pending.pop() 67 | targetRecordTypes = [] 68 | 69 | # Bold print the target 70 | print(f"\033[1m{hostname}\033[0m") 71 | 72 | # Split the hostname into parts for lexicological transformation 73 | params = [hostname.split(".")[0], ".".join(hostname.split(".")[1:])] 74 | 75 | # Some NS servers gratuitously provide the next hostname even if you 76 | # request an existing record, so try the hostname as is, otherwise 77 | # append a "0." subdomain, append a dash "-" or add an extra char 78 | for transformation in transformations: 79 | # If a previous transformation has found the next record, no need 80 | # to continue transforming the name 81 | if targetRecordTypes: 82 | break 83 | 84 | # Transform the target 85 | target = transformation(*params) 86 | 87 | # Setup the query, and make 3 attempts to resolve the NSEC record 88 | response = None 89 | for i in range(3): 90 | try: 91 | response = dnssecQuery(target) 92 | break 93 | except dns.exception.Timeout: 94 | pass 95 | if not response: 96 | continue 97 | 98 | # For each NSEC DNS answer in authority + answer sections 99 | for answer in [*response.authority, *response.answer]: 100 | if answer.rdtype != 47: # NSEC 101 | continue 102 | 103 | # Answer order is sometimes random, only pick applicable 104 | if not nextRecFudge and hostname.strip() not in str(answer).split()[0]: 105 | continue 106 | 107 | # There should only ever be one NSEC record per RRSET, 108 | # so grab the first 109 | record = answer[0] 110 | 111 | # Some providers implement white/black lies and can't be 112 | # crawled: https://blog.cloudflare.com/black-lies/ 113 | if record.next.to_text()[:5] == '\\000.': 114 | exit("Tarpit by CloudFlare: https://blog.cloudflare.com/black-lies/") 115 | continue 116 | 117 | # Extract the next hostname and current DNS record types from 118 | # the NSEC record 119 | nextRec = record.next.to_text()[:-1] 120 | targetRecordTypes = record.to_text().split(" ")[1:] 121 | 122 | # Some domains are misconfigured and have loops in the linked 123 | # list (here's looking at you P@yPal), so sanity check to see 124 | # if we've seen this record before, and if so, append an "a" 125 | # to break out of the local loop. Additionally, clear the 126 | # pending record types to resolve to avoid "NoAnswer" errors. 127 | # However, if we've seen it before because it's the root, 128 | # we've completed the crawl, can wipe pending, and finish up 129 | nextRecFudge = False 130 | if nextRec != origHostname and nextRec in finished: 131 | nextRec = nextRec.split(".")[0] + "a" + "." + ".".join(nextRec.split(".")[1:]) 132 | nextRecFudge = True 133 | targetRecordTypes = [] 134 | 135 | # Add the next record to the buffer and escape the answer loop 136 | finished.add(hostname) 137 | pending.add(nextRec) 138 | break 139 | 140 | # We've crawled the next hostname, but now it's time to kindly ask for 141 | # all the records (e.g A, TXT...) for the current hostname besides 142 | # DNSSEC-y answers because they're dynamic and boring. Attempt thrice 143 | for recordType in sorted(targetRecordTypes): 144 | if recordType in ["RRSIG", "NSEC"]: 145 | continue 146 | for i in range(3): 147 | try: 148 | resolvedRecords = resolver.resolve(hostname, recordType) 149 | if resolvedRecords: 150 | break 151 | except: 152 | pass 153 | for answer in resolvedRecords: 154 | print(f"\t{recordType}\t{answer.to_text()}") 155 | 156 | # Pop this off the stack and off we are to the next one 157 | pending -= finished 158 | 159 | except KeyboardInterrupt: 160 | print("Caught Ctrl + C") 161 | 162 | def nsec3 (hostname): 163 | """~Linearly walk a domain's records with NSEC3 crawling""" 164 | 165 | # Tracks hash ranges e.g [abcd..., adff...] 166 | ranges = [] 167 | # Tracks average range sizes for time left estimates 168 | rangeLens = [] 169 | # Tracks records associated with ranges e.g abcd...: [A, MX, TXT] 170 | recordTypes = {} 171 | # Tracks the size of covered ranges for completion estimates 172 | coverage = 0 173 | # Calculates the total size of the integer representation of the range: 174 | # [0000, zzzz]. We trim hashes to the first 4 characters, while rough, it's 175 | # sufficient for our statistics (raw hashes are retained for cracking) 176 | most = int("zzzz", 36) 177 | # Collect the zone's salt and hash iterations (SHA1 is assumed and ignored) 178 | params = dnssecQuery(hostname, "NSEC3PARAM").answer 179 | params = [i.to_text() for i in params if i.rdtype == 51][0] 180 | itersparam, saltparam = int(params.split(" ")[-2]), params.split(" ")[-1] 181 | 182 | # Wipe any stale hashes 183 | with open("nsec3.hashes", "w") as hashFile: 184 | hashFile.write("") 185 | 186 | try: 187 | # Perform 90% coverage for large domains, 99% coverage for small domains 188 | # or 10000 requests; whichever of the three come first. Adjustable ;) 189 | for i in range(9999): 190 | if (coverage / most > 0.9 and i < 1000) or coverage / most > 0.99: 191 | break 192 | 193 | # Unlike NSEC walking where we know the plaintext value of the next 194 | # target, NSEC3 "walking" is not possible. Instead we locally hash 195 | # candidates searching for a hash nestled in a range we're yet to 196 | # collect. This candidate is sent to the nameserver and the 197 | # encompassing range is collected. This is repeated until most, if 198 | # not all ranges, and thus hashes are collected. 199 | while True: 200 | # Generate a random query candidate and calculate it's NSEC3 hash 201 | target = str(uuid.uuid4()) + "." + hostname 202 | h = nsec3_hash(target, saltparam, itersparam, "SHA1").lower() 203 | discovered = False 204 | for r in ranges: 205 | # If this hash is in a range we've already seen, generate new 206 | if (r[0] < h < r[1] or 207 | (r[1] > r[0] and r[0] < h and r[1] < target)): 208 | discovered = True 209 | break 210 | # If this hash range is not yet discovered, send it to the NS 211 | if not discovered: 212 | break 213 | 214 | # Attempt to DNSSEC query the A record of our target thrice 215 | for n in range(3): 216 | try: 217 | response = dnssecQuery(target, "A") 218 | break 219 | except dns.exception.Timeout: 220 | pass 221 | 222 | # Pull each NSEC3 record out of the noisy results 223 | for answer in [*response.authority, *response.answer]: 224 | if answer.rdtype != 50: # NSEC3 225 | continue 226 | 227 | # Split the NSEC3 record to its' pieces and normalise them 228 | splitAnswers = answer.to_text().split(" ") 229 | r1, _, _, _, alg, opt, iters, salt, r2, *recs = splitAnswers 230 | r1 = r1.split(".")[0].lower() 231 | salt = salt.upper() 232 | 233 | # If this is a new range to us 234 | if [r1, r2] not in ranges: 235 | # Save it for cracking later 236 | ranges.append([r1, r2]) 237 | print(f"Found: ({r1}, {r2})") 238 | # Calculate the range size via its integer representation 239 | r1n = int(r1[:4], 36) 240 | r2n = int(r2[:4], 36) 241 | r = r2n - r1n 242 | # If it's the wrap edge case, normalise (e.g [fffe,0003]) 243 | if r < 0: 244 | r += most 245 | # Update stats 246 | rangeLens.append(r) 247 | coverage += r 248 | # Record the hashed hostnames' associated records e.g A, MX 249 | recordTypes[r1] = recs 250 | 251 | # Append the hash to our file in hashcat format 252 | with open("nsec3.hashes", "a") as hashFile: 253 | hashFile.write(f"{r1}:.{hostname}:{salt}:{iters}\n") 254 | 255 | # Calculate and print stats 256 | # Estimated coverage is over-inflated due to probability. Future 257 | # work: implement https://doi.org/10.1007/978-3-030-15986-3_15 258 | avg = sum(rangeLens) / len(rangeLens) 259 | left = int((most - coverage) / avg) 260 | print(f"FOUND {len(rangeLens)}; DONE {(coverage / most):.0%}; LEFT {left}", end=" \r", flush=True) 261 | 262 | # Gracefully catch Ctrl + C so we can save the record maps to disk 263 | except KeyboardInterrupt: 264 | print("Caught Ctrl + C") 265 | 266 | # Write the hash:recs (e.g abcd:[A,MX]) dict to disk for post-crack-dump 267 | with open("nsec3.map", "w") as mapFile: 268 | mapFile.write(str(recordTypes)) 269 | mapFile.write(str(recordTypes)) 270 | 271 | def nsec3align (): 272 | """Requests records of cracked NSEC3 hostname hashes""" 273 | 274 | mapFileName, crackedFileName = argv[2], argv[3] 275 | 276 | # Eval the hash:recs dict file, insecure, but local, so I don't care... 277 | with open(mapFileName, "r") as mapFile: 278 | recordTypes = eval(mapFile.read()) 279 | 280 | # Grab the hash:cracked values from the hashcat output 281 | with open(crackedFileName, "r") as crackedFile: 282 | cracked = crackedFile.read() 283 | crackedMap = [] 284 | for line in cracked.split("\n"): 285 | if ":" in line: 286 | crackedMap.append([line.split(":")[4], line.split(":")[0]]) 287 | 288 | # For each cracked hostname, dump its records 289 | for subdomain, ref in crackedMap: 290 | # Bold print the target 291 | target = f"{subdomain}.{hostname}" 292 | print(f"\033[1m{target}\033[0m") 293 | 294 | # For each non-DNSSEC record, attempt to resolve thrice and print RRSETs 295 | for recordType in sorted(recordTypes[ref]): 296 | resolvedRecords = [] 297 | if recordType in ["RRSIG", "NSEC"]: 298 | continue 299 | for i in range(3): 300 | try: 301 | resolvedRecords = resolver.resolve(target, recordType) 302 | if resolvedRecords: 303 | break 304 | except: 305 | pass 306 | for answer in resolvedRecords: 307 | print(f"\t{recordType}\t{answer.to_text()}") 308 | 309 | # Help / Usage 310 | if len(argv) == 1 or "-h" in argv or "--help" in argv: 311 | exit(f""" 🚶 312 | \033[1mNSEC(3) Walker\033[0m - https://harrisonm.com/blog/nsec-walking 313 | Recovers DNS zone data for most DNSSEC zones 314 | 315 | Crawl: (NSEC / NSEC3 auto-detected) 316 | python3 {argv[0]} example.com 317 | NSEC: Results written to STDOUT 318 | NSEC3: Results written to nsec3.hashes & nsec3.map 319 | 320 | Crack nsec3.hashes: (requires hashcat and wordlist) 321 | hashcat -m 8300 nsec3.hashes -o nsec3.cracked wordlist.txt 322 | 323 | Crawl NSEC3 zone post crack: (prints to STDOUT) 324 | python3 {argv[0]} example.com nsec3.map nsec3.cracked\n""") 325 | 326 | # Call post NSEC3 cracking function and die 327 | if len(argv) > 2: 328 | nsec3align() 329 | exit() 330 | 331 | # Otherwise, setup the resolver to use the domain's nameservers 332 | hostname = argv[1] 333 | resolver.nameservers = [ 334 | str(resolver.resolve(str(i).strip("."), "A")[0]) for i in resolver.resolve(hostname, "NS") 335 | ] 336 | 337 | # Let the user know we've initalised ok 338 | print(f"Crawling {hostname} using NS(s): " + ", ".join(resolver.nameservers)) 339 | 340 | # Query a non-existent record, and auto select NSEC or NSEC3 walking 341 | if hostname == ".": 342 | nsec(hostname) 343 | exit() 344 | response = dnssecQuery(f"nsec-walker-says-hi.{hostname}", "A") 345 | for answer in [*response.authority, *response.answer]: 346 | if answer.rdtype == 47: # NSEC 347 | nsec(hostname) 348 | break 349 | if answer.rdtype == 50: # NSEC3 350 | nsec3(hostname) 351 | break 352 | --------------------------------------------------------------------------------