Skip to content
academia.sh

Lesson 03 / 20

Address Resolution Attacks

Measuring how the address resolution cache is corrupted by an unverified reply: 6 of 18 records corrupted in a set of 26 replies, a pinned record rescuing 4 and leaving 2, a consistency check rescuing 5 and leaving the single most valuable record, accepting only the requested reply rescuing all six while cutting 3 legitimate announcements, and the three defenses together rejecting 4 legitimate replies and never forming 2 records at all.

Published Updated

In this lesson

  • Models the address resolution cache as a data structure and measures the number of corrupted records
  • Counts separately the records rescued by a pinned record, a consistency check, and request matching
  • Compares the cost each defense pays in rejected legitimate replies, manual items, and extra round trips
Contents

The previous lesson assumed a service was reachable: every row in the inventory was written by accepting that a packet can reach a host from a zone. But even two machines on the same segment reaching each other rests on a claim. An endpoint knows its target’s network address but not which hardware address to send the frame to. It asks, and writes the reply it receives into its cache.

The write rule is this lesson’s starting point. The cache does not test whether the incoming reply is correct; it does not even ask whether the reply was requested. When this acceptance produces a false mapping it is called address resolution poisoning (ARP poisoning); when the sender presents itself as the owner of another address it is called address resolution spoofing (ARP spoofing). Three of the eighteen claims in the first lesson were at the link layer, and all three rest on this single rule. This lesson runs that rule on a data structure and counts how many records an unverified reply corrupts.

What the Cache Can Test

PS7: the address resolution cache’s acceptance rule is “the last reply wins”; whether the reply was requested and whether it is correct play no part in the rule. PS8: a record is corrupted when a network address becomes bound to a hardware address it does not actually belong to; every corrupted record misdirects all frames headed to that address. PS9: a defense can test a reply only against the cache’s own data; it cannot know whether the reply is correct.

PS9 is this lesson’s boundary, and it determines all three of the three defenses. The cache has no source of “the correct mapping”; the only thing that can be tested is whether an incoming reply is consistent with the cache’s existing data. Protocol mechanics — how requests and replies are carried, cache lifetime, proxying, and unsolicited usage patterns — were measured in the Network Models and Protocols course; they are not repeated here.

The reply set below is a model: it writes as data the replies that reach the cache on one segment of a fictional regional measurement network; there is no real network or real hardware address. The set contains no step that generates a forged reply; only the cache’s acceptance rule and its checks run. Sending such a reply set to a real network is both illegal and unprofessional without written authorization and a defined scope.

// networks/resolution.mjs — the address resolution cache of one segment in a fictional
// regional measurement network (model): there is no real network and no real hardware
// address. Hardware addresses are labeled d01..d17. No randomness.

// PS7: the address resolution cache's acceptance rule is "the last reply wins"; whether
// the reply was requested and whether it is correct play no part in the rule.
// PS8: a record is corrupted when a network address becomes bound to a hardware address it
// does not actually belong to; every corrupted record misdirects all frames headed there.
// Columns: network address, claiming hardware address, is it a reply to a pending request
// (y/n), is the claim correct (y/n). The last column is for scoring only; no defense sees it.
export const REPLY = `
  10.20.0.1   d01  y y     10.20.0.5   d02  y y     10.20.0.6   d03  y y
  10.20.0.7   d04  y y     10.20.0.8   d05  y y     10.20.0.9   d06  y y
  10.20.0.10  d07  y y     10.20.1.11  d08  y y     10.20.1.12  d09  y y
  10.20.1.13  d10  y y     10.20.2.21  d11  y y     10.20.2.22  d12  y y
  10.20.0.11  d13  y y     10.20.0.12  d14  y y     10.20.0.30  d17  y y
  10.20.0.31  d17  y y     10.20.0.2   d01  n y     10.20.0.32  d17  y y
  10.20.0.7   d15  n y     10.20.2.22  d16  n y     10.20.0.1   d09  n n
  10.20.0.5   d09  n n     10.20.0.6   d09  n n     10.20.0.8   d09  n n
  10.20.0.10  d09  n n     10.20.0.9   d09  n n`
  .trim().split(/\s+/).reduce((l, _, i, a) => (i % 4 ? l : [...l,
    { net: a[i], hw: a[i + 1], req: a[i + 2] === "y", ok: a[i + 3] === "y" }]), []);

// PS9: a defense can test a reply only against the cache's own data; it cannot know
// whether the reply is correct. All three defenses work within this limit.
export const PINNED = ["10.20.0.1", "10.20.0.5", "10.20.0.6", "10.20.0.7", "10.20.0.10"];
export const THRESHOLD = 2;

// The cache processes replies in order; the selected defenses test each reply before accepting it.
export function cache(defense = []) {
  const table = new Map(), rejected = [];
  for (const rep of REPLY) {
    if (defense.includes("pinned") && PINNED.includes(rep.net) && table.has(rep.net)) {
      rejected.push(rep); continue;
    }
    if (defense.includes("requested") && !rep.req) { rejected.push(rep); continue; }
    if (defense.includes("consistency")) {
      const after = [...table].filter(([a, d]) => d === rep.hw && a !== rep.net).length + 1;
      if (after > THRESHOLD) { rejected.push(rep); continue; }
    }
    table.set(rep.net, rep.hw);
  }
  const corrupted = [...table].filter(([a, d]) =>
    !REPLY.some((rep) => rep.net === a && rep.hw === d && rep.ok));
  return { table, corrupted, rejectedLegit: rejected.filter((r) => r.ok), rejectedFalse: rejected.filter((r) => !r.ok) };
}
// networks/cache-audit.mjs — how many records an unverified reply corrupts, which defense
// rescues how many, how many stay corrupted, and what the cost is.
import { REPLY, PINNED, THRESHOLD, cache } from "./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 = cache();

console.log(`${REPLY.length} replies, ${baseline.table.size} records; ` +
  `corrupted records ${baseline.corrupted.length}, incorrect replies ` +
  `${REPLY.filter((r) => !r.ok).length}, unrequested replies ` +
  `${REPLY.filter((r) => !r.req).length}`);

const A = [-14, 10, 13, 9];
console.log("\n1. reply set: being requested and being correct are two separate properties");
write(A, "reply", "correct", "not correct", "total");
for (const [name, f] of [["requested", (r) => r.req], ["unrequested", (r) => !r.req]]) {
  const l = REPLY.filter(f);
  write(A, name, l.filter((r) => r.ok).length, l.filter((r) => !r.ok).length, l.length);
}

const B = [-10, 9, 15, 16];
console.log("\n2. signal: how many network addresses did a hardware address claim");
write(B, "hardware", "claimed", "accepted", "over threshold");
const hw = [...new Set(REPLY.map((r) => r.hw))];
for (const h of hw.filter((h) => REPLY.filter((r) => r.hw === h).length > 1)) {
  const l = [...new Set(REPLY.filter((r) => r.hw === h).map((r) => r.net))];
  const k = [...baseline.table].filter(([, v]) => v === h).length;
  write(B, h, l.length, k, l.length > THRESHOLD ? "yes" : "no");
}

const C = [-34, 9, 11, 12, 16, 8, 14];
console.log("\n3. defenses: rescued records and their cost");
write(C, "defense", "records", "corrupted", "rescued", "legit rejected", "manual", "extra rounds");
for (const s of [[], ["pinned"], ["consistency"], ["requested"], ["pinned", "consistency"],
  ["pinned", "consistency", "requested"]]) {
  const r = cache(s);
  write(C, s.join(" + ") || "none", r.table.size, r.corrupted.length,
    baseline.corrupted.length - r.corrupted.length, r.rejectedLegit.length,
    s.includes("pinned") ? PINNED.length : 0, s.includes("requested") ? r.table.size : 0);
}

const D = [-28, -34, -44];
console.log("\n4. which record stays corrupted, which legitimate reply is rejected");
write(D, "defense", "record still corrupted", "legitimate reply rejected");
for (const s of [["pinned"], ["consistency"], ["requested"], ["pinned", "consistency"]]) {
  const r = cache(s);
  write(D, s.join(" + "), r.corrupted.map(([a]) => a).join(" ") || "none",
    r.rejectedLegit.map((rep) => `${rep.net}=${rep.hw}`).join(" ") || "none");
}
26 replies, 18 records; corrupted records 6, incorrect replies 6, unrequested replies 9

1. reply set: being requested and being correct are two separate properties
reply            correct  not correct    total
requested             17            0       17
unrequested            3            6        9

2. signal: how many network addresses did a hardware address claim
hardware    claimed       accepted  over threshold
d01               2              1              no
d09               7              7             yes
d17               3              3             yes

3. defenses: rescued records and their cost
defense                             records  corrupted     rescued  legit rejected  manual  extra rounds
none                                     18          6           0               0       0             0
pinned                                   18          2           4               1       5             0
consistency                              17          1           5               1       0             0
requested                                17          0           6               3       0            17
pinned + consistency                     17          1           5               2       5             0
pinned + consistency + requested         16          0           6               4       5            16

4. which record stays corrupted, which legitimate reply is rejected
defense                     record still corrupted            legitimate reply rejected                   
pinned                      10.20.0.8 10.20.0.9               10.20.0.7=d15                               
consistency                 10.20.0.1                         10.20.0.32=d17                              
requested                   none                              10.20.0.2=d01 10.20.0.7=d15 10.20.2.22=d16  
pinned + consistency        10.20.0.8                         10.20.0.32=d17 10.20.0.7=d15                

The numbers belong to the measurement class; their inputs are PS7–PS9.

Six Records

Twenty-six replies produce eighteen records, and with no defense in place six of those records are corrupted. The corruption rate is one in three, but what actually matters is which records are corrupted: the gateway, the collector, the verifier, the name server, the directory, and the measurement store. All six are addresses that carry traffic leaving the segment or heading to central services; the field terminals’ and the printer’s records are not corrupted.

The number of corrupted records and the traffic affected are not the same number. In the previous lesson’s inventory, the four services on the collector received 141,652 requests in thirty days, and the two services on the name server received 163,000. For an endpoint on this segment, all six of the six corrupted records deliver frames headed to their destination to the wrong hardware address; the impact per record equals the request volume that address carries. The number of corrupted records measures the surface, while the request volume carried measures the impact, and the two do not give the same ranking.

The first table separates two properties. All seventeen replies that are a response to a pending request are correct. Nine replies arrived unrequested; of these, three are correct and six are not. Being unrequested does not mean being false: the announcement of the virtual gateway address, billing address failover, and an interface change are all announced unrequested, and all three are legitimate. A defense that rejects unrequested replies wholesale cuts these three along with the six false ones.

The second table gives the signal a check can see. Three hardware addresses claim more than one network address: one claims two, one three, one seven, and all seven of the seven are accepted. The row of seven collects replies that come from one endpoint on the same segment and claim six addresses beyond its own. The rows claiming two and three addresses are legitimate: one is the gateway’s virtual address, the other is a host running three guest systems. A signal is a number, not a decision; wherever the threshold is set, it cuts correct and incorrect together.

Three Defenses, Three Separate Gaps

The third table runs the three defenses separately and together. A pinned record puts five addresses on a list and ignores any subsequent reply for those on the list: it rescues four corrupted records and leaves two. The two left behind are the name server and the directory, which are not on the list. A pinned record’s protection extends exactly as far as the list’s length, and it rescues no record that is not on the list.

A consistency check allows a hardware address to claim at most two network addresses, and it rescues five of the six corrupted records. The one record it cannot rescue is the gateway. The reason is order: the hardware address claiming seven addresses claims the gateway first, in addition to its own address, and at that point the threshold has not yet been crossed. The threshold is only crossed on the second claim, and everything after that point is rejected. A threshold-based check accepts claims up to the threshold; the claim it misses is always the first claim, and the first claim is usually the most valuable target.

Accepting only the requested reply rescues all six of the six corrupted records and drops the corrupted record count to zero. This is the only one of the three defenses that is complete, because all six of the six false replies arrived unrequested. This completeness is a property of the reply set, not of the defense’s strength: a reply arriving as a response to a pending request would have passed the same check.

The third table’s fifth row shows that a combination of defenses does not always add up. With a pinned record and a consistency check both on, the corrupted record count stays at one — the same number the consistency check alone gives. The rejected legitimate reply count, though, rises from one to two, and five manual items are added on top. The fourth table shows that the record left corrupted changes: with consistency alone, the gateway record stayed corrupted; in the combination, the name server record stays corrupted instead. The pinned list cuts the first three claims, so the first claim to cross the threshold is now the name server, fourth in line, and it is accepted. The record a threshold-based check misses shifts when the defense ahead of it changes; the count of records missed stays the same.

Three Shapes of Cost

The fourth table names the legitimate reply each defense cuts, and the shape of the three costs is entirely different from one another.

The pinned record’s cost is manual management: five entries are kept on a list, each is updated by hand on every address change, and when an address on the list legitimately changes hands the change is rejected. The table shows exactly this: billing address failover gets cut. Five entries is a small number, but every new critical address on the network grows that number, and a forgotten entry stays silently unprotected.

The consistency check’s cost is a false catch: the third address of a host running three guest systems is rejected. Raising the threshold to three rescues this legitimate record, but the same move also accepts the second claim of the row claiming seven addresses. The threshold shifts both the correct and the incorrect in the same direction.

The cost of accepting only the requested reply is round-trip count and cut announcements: each of the seventeen records requires one request round trip, and all three of the three legitimate unrequested announcements are rejected. Cutting a failover announcement means the cache keeps the old record until its lifetime expires; at the moment of failover, part of the traffic keeps going to the old hardware address.

With all three on together, corrupted records are zero, but the rejected legitimate reply count rises to four and the record count falls from eighteen to sixteen. The two missing records are two mappings that never form at all: the virtual gateway address and the third guest system’s address. Traffic headed to those two addresses does not go anywhere until a record forms. The cost of zeroing corrupted records is not only the rejected reply but the record that never forms. A rejected reply leaves a record in the log and can be searched for; a mapping that never forms leaves no trace at all and is only noticed once that address turns out to be unreachable.

Summary

  • The address resolution cache’s acceptance rule is “the last reply wins”; 6 of 18 records are corrupted in a set of 26 replies, and all six are addresses carrying traffic leaving the segment.
  • Being requested and being correct are separate properties: all 17 of 17 requested replies are correct, while 3 of 9 unrequested replies are correct and 6 are not.
  • A pinned record rescues 4 records for the 5 addresses on its list and leaves the 2 that are not; a consistency check rescues 5 records and misses the gateway record, which comes first in order.
  • Accepting only the requested reply rescues all 6 of 6 records, but it cuts 3 legitimate unrequested announcements and adds an extra request round trip for 17 records.
  • The three defenses together zero out corrupted records; the cost is 4 rejected legitimate replies, 5 manual items, 16 extra round trips, and 2 records that never form at all.

Next Step

This lesson stayed within one segment: the question asked was “which hardware address does this network address belong 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, but this time the cache is not local to one segment; it sits at every layer of the network, and a single false record misdirects not one machine but every client that reads that record. The next lesson builds and counts the name resolution cache the same way: 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 out in the open.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close