---
title: 'Name Resolution Attacks'
source: 'https://academia.sh/en/courses/network-security/name-resolution-attacks'
course: 'Network Security'
language: en
updated: '2026-10-21T05:02:57+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Models the name resolution cache together with dependent service counts and measures corrupted records'
    - 'Counts separately the records rescued by signed verification and the entries an unsigned zone leaves behind'
    - "Compares signing's cost in keys, extra round trips, and stopped services against a lifetime cap"
---

# Name Resolution Attacks

Measuring how the name resolution cache is corrupted by an unverified reply: 7 of 16 names corrupted, misdirecting 38 of 73 dependent services; signed verification rescuing 3 records and leaving 4 in an unsigned zone; an expired signature making 2 names fully unresolvable and stopping 9 services; strict mode zeroing out corrupted records while stopping 47 services; and a lifetime cap cutting corrupted-record duration by 88.1% while raising query count by 346.5%.

The previous lesson stayed within one segment: the question asked was which hardware address a
network address belonged to, and the answer came from the same segment. Every request that
leaves the segment first asks a different question — which address a name corresponds to. The
answer to that question is also written into a cache.

There is a scale difference between the two caches. An address resolution record misdirects a
single target on a single segment; a name resolution record misdirects **all** services that
use that name at once, and the cache sits at every layer of the network. When a name record is
replaced by an unverified reply this is called **name resolution poisoning (DNS poisoning)**;
when zone records are signed and tested by the receiver, this is called **signed name
resolution (DNSSEC)**. This lesson counts that difference: how many records and how many
dependent services an unverified reply corrupts, how many signed verification rescues, and how
many entries an unsigned zone leaves behind.

## What a Signature Can Test

**PS10:** a name resolution reply is a claim — "this name corresponds to this address"; in an
unsigned zone this claim has no evidence in the cache that can be tested. **PS11:** the
corruption of a name record misdirects all services bound to that name at once; the measure of
impact is not the record count but the **dependent service count**. **PS12:** a signature can
test only the replies of a signed zone; the reply of an unsigned zone can neither be verified
nor falsified.

PS12 is this lesson's determining clause. Signed name resolution does not split a reply into
"correct" or "incorrect"; it splits it three ways into **"verified," "failed verification," and
"untestable."** The third set is every reply that comes from an unsigned zone, and for these
replies the cache stays in the state counted in the first lesson: there is no evidence. Record
types, the roles in the resolution chain, and lifetime-based caching were measured in the
**Computer Networks** curriculum; they are not repeated here.

The name set below is a **model**: the names and zones are fictional, and the addresses come
from blocks reserved for private use and documentation. The set contains no step that generates
a forged reply; the code that runs is only the cache's acceptance rule and its checks. Sending
such a reply set to a resolver you do not own **is both illegal and unprofessional without
written authorization and a defined scope**.

```js
// networks/name-resolution.mjs — the name resolution cache of a fictional regional measurement
// network (model): the names and zones are fictional, there is no real domain name; the
// addresses come from blocks reserved for private use and documentation. No randomness.

// PS10: a name resolution reply is a claim: "this name corresponds to this address". In an
// unsigned zone this claim has no evidence in the cache that can be tested.
// PS11: the corruption of a name record misdirects all services bound to that name at once;
// the measure of impact is not the record count but the dependent service count.
// Columns: name, zone, lifetime (seconds), number of services bound to that name.
export const NAME = `
  collector.measurement  measurement  3600  6  verifier.measurement  measurement  3600   5
  storage.measurement    measurement  1800  4  intake.measurement    measurement   300   9
  transfer.measurement   measurement  3600  2  billing.corporate     corporate    3600   7
  workorder.corporate    corporate    3600  3  report.corporate      corporate    7200   1
  directory.corporate    corporate    1800  8  identity.corporate    corporate     900  11
  branch01.field         field         600  2  branch02.field        field         600   2
  gateway.field          field        1800  4  interface.external    external     3600   3
  status.external        external     7200  1  update.external       external     1800   5`
  .trim().split(/\s+/).reduce((l, _, i, a) => (i % 4 ? l : [...l,
    { name: a[i], zone: a[i + 1], ttl: +a[i + 2], deps: +a[i + 3] }]), []);

// PS12: a signature can test only the replies of a signed zone; the reply of an unsigned
// zone can neither be verified nor falsified.
export const SIGNED = ["measurement", "external"];

// The replies that reach the cache. sig: valid | invalid | expired | none (unsigned zone).
// The ok column is for scoring only; no defense sees it.
export const REPLY = `
  collector.measurement  10.30.0.5     valid    y  intake.measurement    10.30.0.8     valid    y
  storage.measurement    10.30.0.10    expired  y  transfer.measurement  10.30.0.11    valid    y
  verifier.measurement   10.30.0.6     expired  y  report.corporate      10.30.1.9     none     y
  billing.corporate      10.30.1.7     none     y  directory.corporate   10.30.1.12    none     y
  workorder.corporate    10.30.1.8     none     y  identity.corporate    10.30.1.13    none     y
  branch01.field         10.30.2.21    none     y  branch02.field        10.30.2.22    none     y
  gateway.field          10.30.2.1     none     y  interface.external    198.51.100.4  valid    y
  status.external        198.51.100.5  valid    y  update.external       198.51.100.6  valid    y
  collector.measurement  10.30.9.90    invalid  n  verifier.measurement  10.30.9.90    invalid  n
  interface.external     10.30.9.90    invalid  n  billing.corporate     10.30.9.90    none     n
  identity.corporate     10.30.9.90    none     n  branch01.field        10.30.9.90    none     n
  gateway.field          10.30.9.90    none     n`
  .trim().split(/\s+/).reduce((l, _, i, a) => (i % 4 ? l : [...l,
    { name: a[i], address: a[i + 1], sig: a[i + 2], ok: a[i + 3] === "y" }]), []);

// mode: "none" = signature not tested; "signed" = invalid and expired signatures rejected;
// "strict" = an unsigned zone's reply is also rejected.
export function resolve(mode = "none") {
  const table = new Map(), rejected = [];
  for (const rep of REPLY) {
    const badSig = rep.sig === "invalid" || rep.sig === "expired";
    if (mode !== "none" && badSig) { rejected.push(rep); continue; }
    if (mode === "strict" && rep.sig === "none") { rejected.push(rep); continue; }
    table.set(rep.name, rep.address);
  }
  const records = NAME.map((a) => ({ ...a, address: table.get(a.name) }));
  return {
    records,
    corrupted: records.filter((a) => a.address &&
      !REPLY.some((rep) => rep.name === a.name && rep.address === a.address && rep.ok)),
    unresolved: records.filter((a) => !a.address),
    rejectedLegit: rejected.filter((rep) => rep.ok),
  };
}
```

```js
// networks/name-audit.mjs — how many records and dependent services an unverified reply corrupts,
// how many signed verification rescues, how many entries an unsigned zone leaves, and the cost.
import { NAME, REPLY, SIGNED, resolve } from "./name-resolution.mjs";

const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));
const baseline = resolve(), CAP = 300;
const deps = (l) => l.reduce((s, a) => s + a.deps, 0);
const queries = (t) => NAME.reduce((s, a) => s + Math.floor(86400 / Math.min(a.ttl, t)), 0);
const KEYS = 3; // per zone: zone key, signing key, digest given to the parent zone

console.log(`${NAME.length} names, ${REPLY.length} replies, ${deps(NAME)} dependent services; ` +
  `signed zones ${SIGNED.length}/${new Set(NAME.map((a) => a.zone)).size}, ` +
  `corrupted records ${baseline.corrupted.length}, affected dependent services ${deps(baseline.corrupted)}`);

const A = [-11, 8, 9, 10, 19, 11];
console.log("\n1. per zone");
write(A, "zone", "signed", "names", "deps", "corrupted records", "affected");
for (const z of [...new Set(NAME.map((a) => a.zone))]) {
  const l = NAME.filter((a) => a.zone === z), c = baseline.corrupted.filter((a) => a.zone === z);
  write(A, z, SIGNED.includes(z) ? "yes" : "no", l.length, deps(l), c.length, deps(c));
}

const B = [-22, -11, 9, 11, 20];
console.log("\n2. baseline: corrupted records and the dependents they carry");
write(B, "name", "zone", "deps", "ttl (sec)", "rescued by signing");
for (const a of baseline.corrupted)
  write(B, a.name, a.zone, a.deps, a.ttl, SIGNED.includes(a.zone) ? "yes" : "no");

const C = [-8, 11, 11, 12, 18, 16, 9, 14];
console.log("\n3. modes: rescued records and their cost");
write(C, "mode", "corrupted", "affected", "unresolved", "stopped services", "legit rejected", "keys", "extra rounds");
for (const [m, signedZones] of [["none", 0], ["signed", SIGNED.length], ["strict", 4]]) {
  const r = resolve(m);
  const scope = NAME.filter((a) => m === "strict" || SIGNED.includes(a.zone)).length;
  write(C, m, r.corrupted.length, deps(r.corrupted), r.unresolved.length, deps(r.unresolved),
    r.rejectedLegit.length, signedZones * KEYS, m === "none" ? 0 : scope);
}

const D = [-23, 10, 12, 13, 12];
console.log(`\n4. if the lifetime cap is ${CAP} sec`);
write(D, "measure", "uncapped", "capped", "diff", "diff %");
const measures = [
  ["daily queries", queries(1e9), queries(CAP)],
  ["corrupted record-sec", baseline.corrupted.reduce((s, a) => s + a.ttl, 0),
    baseline.corrupted.reduce((s, a) => s + Math.min(a.ttl, CAP), 0)],
  ["longest corrupted sec", Math.max(...baseline.corrupted.map((a) => a.ttl)),
    Math.max(...baseline.corrupted.map((a) => Math.min(a.ttl, CAP)))],
];
for (const [name, x, y] of measures)
  write(D, name, x, y, y - x, (((y - x) / x) * 100).toFixed(1));
```

```
16 names, 23 replies, 73 dependent services; signed zones 2/4, corrupted records 7, affected dependent services 38

1. per zone
zone         signed    names      deps  corrupted records   affected
measurement     yes        5        26                  2         11
corporate        no        5        30                  2         18
field            no        3         8                  2          6
external        yes        3         9                  1          3

2. baseline: corrupted records and the dependents they carry
name                  zone            deps  ttl (sec)  rescued by signing
collector.measurement measurement        6       3600                 yes
verifier.measurement  measurement        5       3600                 yes
billing.corporate     corporate          7       3600                  no
identity.corporate    corporate         11        900                  no
branch01.field        field              2        600                  no
gateway.field         field              4       1800                  no
interface.external    external           3       3600                 yes

3. modes: rescued records and their cost
mode      corrupted   affected  unresolved  stopped services  legit rejected     keys  extra rounds
none              7         38           0                 0               0        0             0
signed            4         24           2                 9               2        6             8
strict            0          0          10                47              10       12            16

4. if the lifetime cap is 300 sec
measure                  uncapped      capped         diff      diff %
daily queries                1032        4608         3576       346.5
corrupted record-sec        17700        2100       -15600       -88.1
longest corrupted sec        3600         300        -3300       -91.7
```

The numbers are in the **measurement** class; their inputs are PS10–PS12.

## Seven Records, Thirty-Eight Services

Sixteen names produce sixteen records, and with no defense in place **seven** of them are
corrupted. The record rate is 43.8%, but the real number is in the second column: seven
corrupted records misdirect **thirty-eight** of seventy-three dependent services. A record does
not carry one target; it carries every service that uses that name.

The second table shows this disproportion step by step. The `branch01.field` record in the
field zone carries only two services; the `identity.corporate` record carries **eleven**
services. Both are a single record, and both are corrupted by the same rule. What determines
the order of corruption in a name set is not the record count, it is the dependents per record.

The first table reads at the zone level. The two signed zones have five and three names; the
two unsigned zones have five and three. Of the seven corrupted records, **three** are in signed
zones and carry fourteen dependent services; **four** are in unsigned zones and carry
twenty-four dependent services. The corporate zone alone is the heaviest zone with thirty
dependent services, and it is unsigned.

This last sentence shows that signing coverage does not track dependency weight. The two signed
zones cover thirty-five of seventy-three dependent services; the two unsigned zones cover
thirty-eight. Coverage is an even split by name count (eight names against eight), but by
dependents carried the signed side is the minority. The single name carrying the most services
— `identity.corporate` with eleven — is in an unsigned zone. **The signing decision is made
zone by zone, while the impact is distributed name by name.**

## What the Signature Rescues and Leaves Behind

The second row of the third table opens up signed verification. Corrupted records drop from
seven to **four**, affected dependent services from thirty-eight to **twenty-four**. All three
rescued records are in signed zones; the four in unsigned zones stay exactly as they were.
**Signed verification's coverage is the zone boundary, and nothing changes outside that
boundary.**

The fourth and fifth columns of the same row write the defense's own cost. Two names become
**fully unresolvable** and nine dependent services stop. The cause is not an attack: the
legitimate replies for these two names arrived with an expired signature, and verification
rejects them too. Two legitimate replies are cut off. With signed verification on, **a
signature's expiry makes that name behave as if it had no reply at all**; in an unsigned zone
the same situation never occurs.

The third row measures strict mode: replies from unsigned zones are rejected too. Corrupted
records drop to zero and affected dependent services drop to zero. The cost is ten names
becoming unresolvable and **forty-seven dependent services stopping** — more than two-thirds of
seventy-three. Ten legitimate replies are rejected, the key entry rises from six to twelve, and
every name requires one extra round trip.

Strict mode is one of the clearest examples in this course of a defense falling short. It
zeroes out the unverified claim, but not by verifying the claim — by rejecting everything that
cannot be verified. The real fix for the remaining four corrupted records is not strict mode
but signing the two unsigned zones; that is the row in this table that raises the key entry to
twelve and the extra round trips to sixteen.

Signed mode's net gain narrows further once both costs are written together. In the baseline,
thirty-eight dependent services are misdirected and none stop. In signed mode, twenty-four are
misdirected and nine stop; the count of correctly working services rises from thirty-five to
forty. In a set of seventy-three services, the net gain is **five services**. The same defense
is fully effective inside the signed zones; what makes the total this small is the weight the
unsigned zones carry and the signature's own failure mode.

## The Lifetime Cap

The fourth table measures a defense independent of signing: placing a cap on record lifetime.
This does not prevent corruption; it limits **how long** a corrupted record stays corrupted.

Uncapped, the total lifetime of the seven corrupted records is 17,700 seconds and the longest
is 3,600 seconds. When the cap is pulled down to 300 seconds, the total drops to 2,100 seconds
and the longest to 300 seconds — a drop of **88.1%** and **91.7%** respectively. In exchange,
the daily query count rises from 1,032 to 4,608: a **346.5% increase**.

Read together, the two ratios reveal the cap's nature. The lifetime cap **never changes** the
count of verified claims; not a single number moves in this lesson's first column. What it
changes is how long a corrupted record's effect lasts. In a three-column measurement, this is a
defense that leaves the first and second columns empty and fills only the third — at the cost
of raising query volume by four and a half times. **Shortening the effect is not a substitute
for verifying the claim.**

## Summary

- A name resolution reply cannot be tested in an unsigned zone; **7 of 16** names are
  corrupted, and these 7 records misdirect **38 of 73** dependent services.
- The measure of impact is not the record count but dependents: one record carries 2 services
  while another carries 11, and both are corrupted by the same rule.
- Signed verification lowers corrupted records from 7 to 4 and affected services from 38 to 24;
  all 3 rescued records are in signed zones, and nothing changes in unsigned zones.
- The signature has its own cost: an expired signature makes 2 names fully unresolvable and
  stops 9 dependent services; strict mode zeroes out corrupted records but makes 10 names
  unresolvable, stops 47 services, and raises the key entry to 12 and extra round trips to 16.
- The lifetime cap never changes the count of verified claims; it shortens a corrupted record's
  duration by 88.1% and raises daily query count by 346.5%.

## Next Step

This lesson and the one before it shared an assumption: that the endpoint asking the cache a
question **already knew who to ask**. The name resolver's address, the gateway's address, and
the endpoint's own address were all taken as given. But when an endpoint joins a network it
knows none of these; it asks, and it accepts the first reply that arrives. That single reply
carries three separate claims at once: this is your address, the gateway is this, your name
resolver is this. The next lesson builds address assignment as a data structure and counts it:
how many claims one assignment reply carries, how many endpoints the first-reply-wins rule
binds to which configuration, how many port trust and binding tables rescue, and what the cost
of manual management turns out to be.
