Lesson 04 / 20
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%.
Published Updated
In this lesson
- 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
Contents
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.
// 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), }; }
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.