Home › The Stash

Lab 15 — Dangling DNS: Find and Fix an Abandoned Subdomain

LabOctober 15, 2026

BeginnerLocal simulationTime ~40 minTool Python DNS labNeeds Python 3.10+Cost freeTopics DNS · ownership
0 / 7 steps

The goal

Find a DNS alias left behind after a website is removed, watch it lead to a different owner in a local simulation, and remove the abandoned record. Finish with evidence that explains both the failure and the fix.

Keep this exercise on your own computer. The supplied script binds only to 127.0.0.1 and uses fictional .test names. Do not change the bindings, use public targets, or try to claim a real cloud resource. No administrator account, public domain, firewall change, or disabled protection is needed.

The website is gone. Its DNS record is still there. A dangling DNS record points to a removed resource or a destination its intended owner no longer controls. If a second party can acquire that destination and the provider accepts the old hostname, the original subdomain can serve that party's content. This is a subdomain takeover.

A broken lookup is a clue, not proof of takeover. An NXDOMAIN answer or HTTP 404 does not establish that a target can be claimed. Reassignment rules and custom-domain ownership checks matter. This lab deliberately simulates a provider that permits reassignment.

New to DNS? Start with DNS Explained. For packet-level troubleshooting, Lab 13 covers missing answers, caching and TCP fallback. Here, you will use real local DNS and HTTP traffic while the provider lifecycle is simulated.

What you need

  • Python 3.10 or newer, installed from python.org or your operating system's package manager.
  • Two terminals on the same computer or same disposable VM. A spare Windows or Linux VM is a good option. A bridged lab network is unnecessary.
  • A new folder named dangling-dns-lab, containing the lab.py file you will save below.
  • A normal user account; administrator/root privileges are unnecessary. No third-party Python packages, Docker, domain purchase, or cloud subscription.

Commands below use python. On Linux/macOS, use python3 if that is your installed interpreter. On Windows, py -3 is another option. Use the same interpreter throughout. Open both terminals in your dangling-dns-lab folder. Once Python and the files are present, the exercise works offline.

Command boundaries: python lab.py --help lists the supported actions. These commands control a simulation; set reclaimed cannot claim anything outside it. Do not substitute public names or change the loopback bindings. You do not need to disable endpoint protection or open firewall ports.

Get the lab file

Create a new folder named dangling-dns-lab in your Documents folder. Expand the source below, click copy, and paste it into a plain-text editor. Save it inside that folder as lab.py with UTF-8 encoding. In Windows Notepad, choose Save as type: All files so the name does not become lab.py.txt. The copy button copies the file contents only.

On Windows, open the folder in File Explorer, type powershell into its address bar, and press Enter. Repeat for a second window. These are terminal A and terminal B. Do not use Run as administrator. On Linux or macOS, open two terminals and change each into the folder.

Show the complete lab.py source — copy and save this file
"""StashGrid local dangling-DNS teaching lab. Python 3.10+, standard library only.
Minimal UDP DNS, not a general-purpose resolver. No forwarding or public targets.
"""
import argparse
import http.client
import json
from pathlib import Path
import secrets
import socket
import socketserver
import struct
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

LOOPBACK = '127.0.0.1'
DNS_PORT, WEB_PORT = 1053, 8088
ALIAS, TARGET = 'portal.stashgrid.test', 'retired-app.provider.test'
STATE = Path(__file__).with_name('lab-state.json')
MODES = ('healthy', 'dangling', 'reclaimed', 'fixed')


def mode():
    if not STATE.exists():
        return 'healthy'
    value = json.loads(STATE.read_text(encoding='utf-8'))['mode']
    if value not in MODES:
        raise ValueError('Invalid lab state; run: python lab.py set healthy')
    return value


def set_mode(value):
    temp = STATE.with_suffix('.tmp')
    temp.write_text(json.dumps({'mode': value}), encoding='utf-8')
    temp.replace(STATE)


def encode_name(name):
    return b''.join(bytes([len(part)]) + part.encode('ascii') for part in name.split('.')) + b'\0'


def decode_name(data, offset):
    # This lab emits uncompressed names. Reject pointers rather than following arbitrary data.
    labels = []
    while True:
        size = data[offset]
        offset += 1
        if not size:
            return '.'.join(labels).lower(), offset
        if size > 63 or offset + size > len(data):
            raise ValueError('Invalid or compressed DNS name')
        labels.append(data[offset:offset + size].decode('ascii'))
        offset += size
        if sum(map(len, labels)) + len(labels) > 254:
            raise ValueError('DNS name too long')


def dns_reply(packet, current):
    if len(packet) < 12:
        return b''
    ident, flags, qd, _, _, _ = struct.unpack('!6H', packet[:12])
    if qd != 1 or flags & 0xF800:
        return struct.pack('!6H', ident, 0x8001, 0, 0, 0, 0)
    try:
        name, end = decode_name(packet, 12)
        qtype, qclass = struct.unpack('!HH', packet[end:end + 4])
    except (ValueError, IndexError, UnicodeError, struct.error):
        return struct.pack('!6H', ident, 0x8001, 0, 0, 0, 0)
    question, records, rcode = packet[12:end + 4], [], 0
    if name not in (ALIAS, TARGET) or qclass != 1:
        rcode = 5  # REFUSED. Never recurse, forward, or resolve external names.
    elif name == ALIAS and current == 'fixed':
        rcode = 3
    elif name == ALIAS:
        if qtype in (1, 5):
            records.append((ALIAS, 5, encode_name(TARGET)))
        if qtype == 1:
            if current == 'dangling':
                rcode = 3
            else:
                records.append((TARGET, 1, socket.inet_aton(LOOPBACK)))
    elif current == 'dangling':
        rcode = 3
    elif qtype == 1:
        records.append((TARGET, 1, socket.inet_aton(LOOPBACK)))
    body = b''.join(encode_name(n) + struct.pack('!HHIH', t, 1, 0, len(v)) + v for n, t, v in records)
    # Teaching simplification: TTL 0, no SOA/negative caching, no TCP, DNSSEC or recursion.
    response_flags = 0x8000 | (flags & 0x0100) | rcode | (0 if rcode == 5 else 0x0400)
    return struct.pack('!6H', ident, response_flags, 1, len(records), 0, 0) + question + body


class DNSHandler(socketserver.BaseRequestHandler):
    def handle(self):
        packet, sock = self.request
        answer = dns_reply(packet, mode())
        if answer:
            sock.sendto(answer, self.client_address)


class WebHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.headers.get('Host', '').split(':')[0] not in (ALIAS, TARGET):
            self.send_error(404, 'Use python lab.py visit for the lab hostname')
            return
        current = mode()
        code = 404 if current == 'dangling' else 200
        text = ('NO RESOURCE: the original application has been deleted.' if current == 'dangling'
                else 'OWNER B: harmless replacement content. Simulated resource reassignment.'
                if current in ('reclaimed', 'fixed') else 'OWNER A: original training application.')
        body = (text + '\nLOCAL SIMULATION ONLY - no real cloud resource was claimed.\n').encode()
        self.send_response(code)
        self.send_header('Content-Type', 'text/plain; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *_):
        pass


def query(name=ALIAS, qtype=1):
    ident = secrets.randbits(16)
    packet = struct.pack('!6H', ident, 0x0100, 1, 0, 0, 0) + encode_name(name) + struct.pack('!HH', qtype, 1)
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
        sock.settimeout(2)
        sock.connect((LOOPBACK, DNS_PORT))
        sock.send(packet)
        data = sock.recv(4096)
    rid, flags, qd, count, _, _ = struct.unpack('!6H', data[:12])
    if rid != ident or not flags & 0x8000 or qd != 1:
        raise ValueError('Unexpected DNS response')
    _, offset = decode_name(data, 12)
    offset += 4
    records = []
    for _ in range(count):
        owner, offset = decode_name(data, offset)
        kind, _, ttl, length = struct.unpack('!HHIH', data[offset:offset + 10])
        offset += 10
        value = socket.inet_ntoa(data[offset:offset + length]) if kind == 1 else decode_name(data, offset)[0]
        records.append((owner, kind, value, ttl))
        offset += length
    return flags & 15, records


def show_query(qtype):
    status, records = query(qtype=qtype)
    print('DNS server: 127.0.0.1:1053; query:', ALIAS, qtype)
    print('Status:', {0: 'NOERROR', 3: 'NXDOMAIN', 5: 'REFUSED'}.get(status, status))
    for owner, kind, value, ttl in records:
        print(owner, 'CNAME' if kind == 5 else 'A', value, 'TTL=' + str(ttl))
    if not records:
        print('(no answer records)')


def visit():
    status, records = query()
    addresses = [value for _, kind, value, _ in records if kind == 1]
    if status or LOOPBACK not in addresses:
        print('No usable DNS address for ' + ALIAS + '; no HTTP request sent.')
        return
    # Use the explicit lab DNS result; never change the OS resolver or use a proxy.
    connection = http.client.HTTPConnection(LOOPBACK, WEB_PORT, timeout=2)
    try:
        connection.request('GET', '/', headers={'Host': ALIAS})
        response = connection.getresponse()
        print('HTTP', response.status, 'for', ALIAS)
        print(response.read().decode())
    finally:
        connection.close()


def serve():
    # Bind both ports before starting either listener. Never fall back to 0.0.0.0.
    with socketserver.UDPServer((LOOPBACK, DNS_PORT), DNSHandler) as dns:
        with HTTPServer((LOOPBACK, WEB_PORT), WebHandler) as web:
            for server in (dns, web):
                threading.Thread(target=server.serve_forever, daemon=True).start()
            print('LOCAL LAB: DNS 127.0.0.1:1053 UDP; HTTP 127.0.0.1:8088', flush=True)
            print('State:', mode(), '| Keep this terminal open. Ctrl+C stops both listeners.', flush=True)
            try:
                while True:
                    time.sleep(0.25)
            except KeyboardInterrupt:
                pass
            finally:
                dns.shutdown()
                web.shutdown()


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    commands = parser.add_subparsers(dest='command', required=True)
    commands.add_parser('serve', help='Start loopback-only DNS and HTTP servers')
    commands.add_parser('visit', help='Resolve through lab DNS, then request the local site')
    commands.add_parser('status', help='Show the current local simulation state')
    q = commands.add_parser('query', help='Send a real UDP DNS question to the local lab')
    q.add_argument('type', choices=('A', 'CNAME'), nargs='?', default='A')
    commands.add_parser('set', help='Change only this local simulation').add_argument('mode', choices=MODES)
    args = parser.parse_args()
    if args.command == 'serve':
        serve()
    elif args.command == 'set':
        set_mode(args.mode)
        print('Local simulation state:', args.mode)
    elif args.command == 'status':
        print(mode())
    elif args.command == 'query':
        show_query(1 if args.type == 'A' else 5)
    else:
        visit()


if __name__ == '__main__':
    try:
        main()
    except (OSError, ValueError, KeyError) as error:
        raise SystemExit('Lab error: ' + str(error) + '\nCheck that python lab.py serve is running and ports 1053/8088 are free.')

Before running: this small teaching server has no dependencies beyond Python. It is not a production DNS resolver. Review the source if you want to see its fixed hostnames, loopback bindings and state changes.

Steps

01Start a clean local lab

In terminal A:

python --version
python lab.py set healthy
python lab.py serve

Expected: the server prints DNS 127.0.0.1:1053 UDP; HTTP 127.0.0.1:8088. Keep terminal A open. Use terminal B for the rest of the exercise.

Why: explicitly choosing the starting state prevents an earlier attempt from changing your baseline.

02Record the working baseline

In terminal B:

python lab.py query CNAME
python lab.py query A
python lab.py visit

Expected:

  • The CNAME question returns portal.stashgrid.test CNAME retired-app.provider.test and NOERROR.
  • The A question returns that CNAME plus retired-app.provider.test A 127.0.0.1.
  • The website returns HTTP 200 and OWNER A: original training application.

Write down the alias, target, address, and content owner. CNAME is an alias to another name; A maps a name to an IPv4 address.

Why: visit first asks the lab DNS server, then uses its loopback result to make a real HTTP request with the original hostname. Typing the .test name into your normal browser will not use this special DNS server. That is intentional; keep your normal network settings intact.

03Delete the service and leave the DNS record behind

python lab.py set dangling
python lab.py query CNAME
python lab.py query A
python lab.py visit

Expected: the CNAME still exists, but the A lookup ends in NXDOMAIN with no usable address. The visit reports no HTTP request sent.

Why: the alias survives the simulated deletion of its target. The failed A lookup may still display the CNAME because the alias exists even though its destination does not. This is a DNS problem, not just a stopped web server.

04Make an evidence-based finding

python lab.py status
python lab.py query CNAME

Describe the evidence before changing anything:

  1. Which record remains?
  2. Which resource was removed?
  3. What proves the pointer is stale?
  4. What additional information would establish takeover risk in a real environment?

Answer to question 4: whether the exact target can be reassigned, whether custom-hostname ownership is verified, and whether the service would accept traffic for the old alias. Do not try to claim a real third-party resource to answer those questions. An owned asset inventory and the provider's documented behavior are the starting points.

Use the bundled query command throughout this walkthrough; you do not need to install another DNS tool.

05Simulate reassignment to a second owner

python lab.py set reclaimed
python lab.py query A
python lab.py visit

Expected: the original alias resolves again, and the website says OWNER B: harmless replacement content. Compare it with step 02: the alias and target names have not changed, but the simulated owner has.

Why: a stale pointer can become dangerous when its destination becomes reusable. This controlled scenario intentionally assumes that a provider allows reassignment and accepts the old hostname. Real providers may prevent it. Nothing was registered, claimed, or accessed on the Internet.

06Remove the abandoned alias and verify the fix

python lab.py set fixed
python lab.py query CNAME
python lab.py query A
python lab.py visit

Expected: both questions for portal.stashgrid.test return NXDOMAIN without answer records. visit sends no HTTP request. Owner B's simulated service still exists; removing the abandoned alias is what breaks the path from the original name.

Why: deleting or repointing an unused DNS record removes that reference. In production, first confirm intended use and ownership; remove or repoint aliases as part of decommissioning, account for DNS caches, and investigate exposure if another party already controlled the target. DNS cleanup does not undo earlier access or remove browser bookmarks and application references.

07Stop, reset, and prove what you learned

Press Ctrl+C in terminal A to stop both listeners. In terminal B:

python lab.py set healthy

That resets the next run without restarting a service. The script creates only a small lab-state.json file beside itself. You can keep this folder for another run or delete it normally when finished.

Complete this evidence table using your own observations:

StateCNAME still exists?Alias has usable A address?Content reached through alias
HealthyYesYesOwner A
DanglingYesNoNone
Reclaimed, simulatedYesYesOwner B
FixedNoNoNone

Explain why a DNS record can be dangling without being exploitable, and why a live HTTP 200 response does not prove that the intended owner still controls the service.

Lab complete

You followed a DNS alias through deletion, simulated reassignment and cleanup. Keep your before-and-after evidence with the reason the record was removed.

Before you leave — prove it

Explain why a CNAME can still exist when its target has no address, why a working website does not prove the right owner controls it, and which record you removed to stop the unwanted path.

What you learned

  • DNS and service lifecycles can diverge. Deleting an application does not necessarily delete its aliases.
  • A finding needs evidence. A stale record and an exploitable takeover are different conclusions.
  • Cleanup needs verification. Retire or repoint unused DNS records and verify the new destination is owned by the intended team.

What this lab simplifies

  • One CNAME chain, one loopback address, UDP DNS and plain HTTP only.
  • The responder handles A and CNAME questions for the two fixed names. It refuses unrelated names and does not recurse. It is not a production DNS server.
  • TTL is zero and there is no negative-cache SOA, DNSSEC, TCP DNS, TLS, real provider reservation period, or ownership verification process. Changes appear immediately; production caches may retain old positive or negative answers.
  • Resource reassignment changes local fixture state, not actual cloud ownership. A fixed state removes the alias; it does not pretend to take over or delete someone else's service.
  • The script is separate from NYXNET. It uses real local DNS and HTTP traffic, while the provider lifecycle is simulated.

If something does not work

  • Python opens a store or is not found: use your installed Python interpreter (py -3 or python3), or install Python from its official source.
  • Address already in use: another process owns 1053/UDP or 8088/TCP. Stop only a previous copy of this lab, or choose a disposable VM with free ports. Do not kill unrelated DNS/web services.
  • Timeout or connection refused: check terminal A is still running and both terminals are on the same machine/VM.
  • Wrong stage: run python lab.py status. Change it with set healthy, set dangling, set reclaimed, or set fixed.
  • Browser says the domain does not exist: use python lab.py visit; normal browsers use your normal DNS configuration.
  • Unexpected state-file error: python lab.py set healthy recreates the local state file.

Put it on your resume. Found and removed a dangling DNS record in a local simulation and documented the subdomain takeover risk and the fix. Change it to match what you actually did.

NextMore labs, and the ranges worth your weekend→

Go deeper

Original StashGrid walkthrough and local teaching script. Functionally checked on Windows with Python 3.12.14 on October 14, 2026: four lifecycle states, out-of-scope DNS refusal, malformed input handling, an independent DNS client and occupied-port behavior. Other operating systems have not been runtime-tested. The Windows nslookup check did not return an answer in the authoring environment; use the bundled query command. If a step fails, tell me what you ran and what you saw.