user3924546
user3924546

Reputation: 1

Detecting DNS Delegation

I've been working on a script to detect whether a given hostname is part of a delegated zone. My goal is to use this for mapping out an organization's network by identifying domain delegations.

The idea is to take a given hostname, run dig on it and see what kind of response it returns.

I put together my own approach based on how I understand delegation works, but I'd love to get some expert eyes on it! Any feedback, improvements, or suggestions would be greatly appreciated.

Here's the script: https://pastebin.com/CMKzACSm

Thanks in advance!

import subprocess
import json
import sys
import logging
 
# Configure logging for debugging
 
def run_dig(hostname):
    try:
        # Run dig command for NS query
        result = subprocess.run(['dig', 'NS', hostname, '-4'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        return result.stdout
    except Exception as e:
        return str(e)
 
def parse_dig_output(dig_output, hostname):
    response = {
 
        "details": {
            "delegated": False
        }
    }
 
    # Check if 'ANSWER SECTION' exists in the dig output
    if "ANSWER SECTION:" in dig_output:
        # Extract lines after 'ANSWER SECTION' and look for NS records
        answer_section = dig_output.split("ANSWER SECTION:")[1]
        ns_lines = [line.strip() for line in answer_section.splitlines() if "IN NS" in line]
 
        # Check if any NS lines contain the hostname at the start
        for line in ns_lines:
            ns_host = line.split()[0]
            if ns_host.startswith(hostname):
                response["details"]["delegated"] = True
                response["details"]["delegated_zone"] = hostname
                break
 
    # Check for rcode errors in the dig output
    error_codes = ['SERVFAIL', 'NXDOMAIN', 'REFUSED', 'NXRRSET', 'NOTAUTH', 'NOTZONE']
    for error_code in error_codes:
        if f"rcode={error_code}" in dig_output:
            response["details"]["delegated"] = True
            break
 
    return response if response["details"]["delegated"] else None
 
def main():
    # Get the JSON input
    if len(sys.argv) < 2:
        sys.exit(1)
 
    input_data = json.loads(sys.argv[1])
    hostname = input_data.get("value")
 
    if not hostname:
        sys.exit(1)
 
    # Run dig and parse the output
    dig_output = run_dig(hostname)
    result = parse_dig_output(dig_output, hostname)
 
    # Output only the entries with delegated: true
    if result:
        print(json.dumps([result], indent=2))
    else:
        print("[]")
 
if __name__ == "__main__":
    main()``

Upvotes: 0

Views: 30

Answers (1)

grawity_u1686
grawity_u1686

Reputation: 16572

This seems more like a "Code reviews Stack Exchange" type of question, but nevertheless.

  1. To start with, literally every DNS name except root is "part of a delegated zone". TLDs are delegated from the root in the same way that individual domains are delegated from the TLD, and in the exact same way subdomains can be further delegated by their owners.

    So the query needs to be more specific: it seems you're trying either to check whether the given name is exactly at the delegation boundary (the 'zone cut' or 'start of authority'), or to find the "nearest" zone name for a given domain name.

  2. Querying for NS is probably a good way to check whether the given name is exactly at the zone boundary if you already know the name, but if you look at the "Authority" section, you will actually find information about where the boundary is: the zone's SOA record will be included in certain types of answers.

    I think any "no such rrtype" or "no such name" response will have that, but it's easiest to query specifically for SOA records – if the name is at the top of the zone, then you'll just get its SOA in the Answer section, but in other cases you'll still get the zone's SOA in the Authority section, and either way all you need to do is look at the rrset's name.

  3. Don't use dig to do this. This is one of those situations where using a dedicated library (such as dnspython) will actually be simpler than parsing stdout of a program:

    >>> a = dns.resolver.resolve("google.com", "NS")
    >>> a
    <dns.resolver.Answer object at 0x7f7295cd9bd0>
    >>> a.response.answer
    [
        <DNS google.com. IN NS RRset: [<ns2.google.com.>, <ns1.google.com.>, <ns4.google.com.>, <ns3.google.com.>]>
    ]
    

    In fact, you can squeeze it down to just a single line of code, because this is a sufficiently common task that dnspython has a dedicated function to query for the SOA name:

    >>> dns.resolver.zone_for_name("_imaps._tcp.gmail.com")
    <DNS name gmail.com.>
    
  4. If you insist on using dig, don't use the -4 option. Dig uses whatever nameservers you have in your system-wide /etc/resolv.conf file. If you have IPv6 nameservers there and they work, then there is no reason to avoid them for dig specifically.

    (On the other hand, if you have IPv6 nameservers in your /etc/resolv.conf and they don't work for some reason, then why are they still in your /etc/resolv.conf in the first place?)

  5. Similar to the earlier exception comment, your "check for rcode errors" is incorrect and only masks errors further. For example, NXDOMAIN definitely does not tell you that the queried subdomain was delegated. If anything, it slightly implies that it wasn't.

    Similarly, because you're querying your local resolver (and not an authoritative server), error codes like REFUSED or NOTAUTH won't occur at all; if they do occur then they are an exceptional case that tells you that your DNS setup is bad, and not merely a "let's pretend delegated=True". These specific error codes would be worth checking if you were querying the authoritative server for some domain, but right now you're not doing that.

    (And NXRRSET simply won't occur at all for regular queries; it is only ever returned for dynamic DNS updates.)

  6. Don't do the return str(e) thing for exceptions. It only makes it harder to check for them later. Since you did not pass check=True, no exception will be raised if dig fails anyway – meaning that any exceptions that will be raised are going to be, well, exceptional cases (like failing to spawn /usr/bin/dig) and in those cases it's better to treat it as an error than to return made-up results.

  7. The response structure seems excessive. It has two fields, one of which is mostly redundant – either the function returns a structure where delegated is always True, or it returns no response at all, so it carries no information. At most, a local (function-internal) 'is_delegated' variable would suffice.

    Leaving that out, you're left with a structure that only has a single field, so you might as well simply return the delegated_zone directly.

    • I see that the program currently takes JSON as input and produces JSON as output. But those should be done at roughly the same place: if a function takes a JSON-ish request, then it can return a JSON-ish response; but since yours currently takes a bare hostname, then it should return a normal result and let the "main" code define the external API for both input and output.

Upvotes: 0

Related Questions