Skip to content
academia.sh

Lesson 08 / 20

Replay and Spoofing Attacks

Measuring replay and spoofing in terms of freshness proof: 10 message types accepting a replay as is with no freshness proof, the counter fully protecting 75% of the volume without covering even a single state-changing type, the timestamp staying open for a 120-second window, the nonce with a separate store staying open by 2 recipients, and 36,460 requests still being repeatable without limit even with all three proofs on.

Published Updated

In this lesson

  • Defines a replay attack as the absence of a freshness proof and measures it per message type
  • Compares the protection level of the counter, timestamp, and nonce, and the gap each one leaves open
  • Calculates the state, clock-synchronization, storage, and rejected-legitimate-message cost of a freshness proof
Contents

The previous lesson asked who a claim came from and tied the answer to a chain. Under the pinned-root setup, the number of parties who could produce the claim dropped to one; the identity proof is complete. Yet one property of a message that was produced through a complete proof chain, that came from the right source, with no link left unanchored, still went unasked: when it was produced.

When the same message is presented once more, the identity proof is still valid, the chain is still complete, and the recipient accepts it in the same way as the first time. The identity proof says who the message came from; nothing says the message is new. This lesson measures that missing property and closes the topic.

Freshness Requires Proof

Presenting the same message again is called a replay attack.

PS22: a freshness proof is the recipient’s ability to test that a message has not been presented before; even when the identity proof is complete, the same message is accepted again if there is no freshness proof. PS23: there are three freshness proofs and their strength is not equal — the counter is complete, the timestamp stays open for the window, the nonce is complete only if the store is shared. PS24: a defense can only test the field a message carries; if the field is absent the defense never touches that message type.

PS24 sets this lesson’s scope. A freshness check is not an on-off switch: if the message format has no matching field, the check has nothing to run against. That is why the measurement has two stages — first which message type carries which field, then what protection level that field gives.

The message set below is a model: the fictional measurement network’s message types, the fields they carry, and their thirty-day volumes are written as data; there is no real network and no real message. The code produces no message and presents nothing again; the only thing that runs is a count of the recipient-side freshness check. Presenting a message again on a system you do not own is both illegal and unprofessional without written authorization and a defined scope.

// networks/freshness.mjs — the freshness proof for message types in a fictional regional
// measurement network (model): no real network, real message, or real volume. No randomness.

// PS22: a freshness proof is the recipient's ability to test that a message has not been
// shown before; even a complete identity proof lets a replay through if freshness proof is missing.
// PS23: there are three freshness proofs and their strength is not equal: the counter is
// complete; the timestamp stays open for the window; the nonce is complete only if the store is shared.
export const WINDOW = 120;          // timestamp window (s)
export const RECIPIENTS = 3;        // number of recipients that accept the same message
export const ENDPOINTS = 224;       // number of endpoints that must keep a counter or clock
export const RECORD = 24;           // size of a nonce record (bytes)
export const MONTH = 2592000;       // 30 days (s)
export const OUT_OF_ORDER = 0.003;  // share of legitimate messages arriving out of order under the counter check
export const LATE = 0.008;          // share of legitimate messages arriving late through the window

// Columns: message type, does it carry a counter, does it carry a timestamp, does it
// carry a nonce, does its replay change state (state-changing), 30-day volume.
export const MESSAGE = `
  reading-notice         y y n n 89400    configuration-command  n y y y   140
  lease-renewal          n n n y  1860    daily-log              y y n n 43200
  work-order-approval    n y n y   420    login                  n n y y   870
  bulk-transfer          y n n n  8640    status-query           n n n n 34600
  meter-reading-request  n y n n  9240    update-approval        n y y y    36`
  .trim().split(/\s+/).reduce((l, _, i, a) => (i % 6 ? l : [...l, {
    type: a[i], counter: a[i + 1] === "y", timestamp: a[i + 2] === "y",
    nonce: a[i + 3] === "y", stateChanging: a[i + 4] === "y", volume: +a[i + 5],
  }]), []);

// PS24: a defense can only test the field a message carries; if the field is absent the
// defense never touches that message type.
// Protection level: full = every replay is caught; partial = it stays open for a window
// or a recipient; none = the replay is accepted as is.
export function level(m, defense = [], shared = false) {
  if (defense.includes("counter") && m.counter) return { d: "full", open: "-" };
  if (defense.includes("nonce") && m.nonce)
    return shared ? { d: "full", open: "-" } : { d: "partial", open: `${RECIPIENTS - 1} recipients` };
  if (defense.includes("timestamp") && m.timestamp) return { d: "partial", open: `${WINDOW} s` };
  return { d: "none", open: "unlimited" };
}

export const volume = (l) => l.reduce((t, m) => t + m.volume, 0);
export function cost(defense = [], shared = false) {
  const covered = (f) => volume(MESSAGE.filter(f));
  const nonceVolume = covered((m) => defense.includes("nonce") && m.nonce);
  return {
    state: defense.includes("counter") ? ENDPOINTS : 0,
    clock: defense.includes("timestamp") ? ENDPOINTS : 0,
    storageWindow: Math.ceil(WINDOW * nonceVolume / MONTH) * RECORD * (shared ? 1 : RECIPIENTS),
    storageUnlimited: nonceVolume * RECORD * (shared ? 1 : RECIPIENTS),
    rejected: Math.round(OUT_OF_ORDER * covered((m) => defense.includes("counter") && m.counter)) +
      Math.round(LATE * covered((m) => defense.includes("timestamp") && m.timestamp && !m.counter)),
  };
}
// networks/freshness-audit.mjs — how many of the message set are accepted when replayed,
// how many of them a counter, timestamp, and nonce catch, and what it costs when one stays open.
import { MESSAGE, WINDOW, RECIPIENTS, ENDPOINTS, RECORD, level, volume, cost } from "./freshness.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 count = (s, p, d) => MESSAGE.filter((m) => level(m, s, p).d === d);
// [defense set, whether the nonce store is shared across recipients]
const SETUPS = [[[], false], [["counter"], false], [["timestamp"], false], [["nonce"], false],
  [["counter", "timestamp", "nonce"], false], [["counter", "timestamp", "nonce"], true]];
const label = (s, p) => (s.join(" + ") || "none") + (p ? " (shared store)" : "");

console.log(`${MESSAGE.length} message types, 30-day volume ${volume(MESSAGE)}; ` +
  `types accepted on replay with no freshness proof ${MESSAGE.length}, ` +
  `state-changing types on replay ${MESSAGE.filter((m) => m.stateChanging).length}`);

const A = [-22, 7, 13, 12, 22];
console.log("\n1. how many types carry each field");
write(A, "field", "types", "30d volume", "volume %", "state-changing types");
for (const [k, f] of [["counter", (m) => m.counter], ["timestamp", (m) => m.timestamp],
  ["nonce", (m) => m.nonce], ["none", (m) => !m.counter && !m.timestamp && !m.nonce]]) {
  const l = MESSAGE.filter(f);
  write(A, k, l.length, volume(l), ((volume(l) / volume(MESSAGE)) * 100).toFixed(1),
    l.filter((m) => m.stateChanging).length);
}

const B = [-46, 6, 9, 6, 13, 27];
console.log("\n2. what each defense catches, by type and volume");
write(B, "defense", "full", "partial", "none", "open volume", "state-changing open types");
for (const [s, p] of SETUPS) {
  const open = count(s, p, "none");
  write(B, label(s, p), count(s, p, "full").length, count(s, p, "partial").length, open.length,
    volume(open), open.filter((m) => m.stateChanging).length);
}

const C = [-46, 8, 8, 18, 15, 16];
console.log("\n3. the cost of the same defenses");
write(C, "defense", "state", "clock", "storage (window)", "storage (30d)", "legit rejected");
for (const [s, p] of SETUPS) {
  const b = cost(s, p);
  write(C, label(s, p), b.state, b.clock, b.storageWindow + " B", b.storageUnlimited + " B", b.rejected);
}

const D = [-24, 9, 16, 12, 13];
console.log("\n4. what stays open when all three proofs are on");
write(D, "type", "level", "state-changing", "30d volume", "stays open");
for (const m of MESSAGE) {
  const z = level(m, ["counter", "timestamp", "nonce"], false);
  if (z.d !== "full") write(D, m.type, z.d, m.stateChanging ? "yes" : "no", m.volume, z.open);
}
console.log(`window ${WINDOW} s, recipients ${RECIPIENTS}, endpoints ${ENDPOINTS}, record ${RECORD} B`);
10 message types, 30-day volume 188406; types accepted on replay with no freshness proof 10, state-changing types on replay 5

1. how many types carry each field
field                   types   30d volume    volume %  state-changing types
counter                     3       141240        75.0                     0
timestamp                   6       142436        75.6                     3
nonce                       3         1046         0.6                     3
none                        2        36460        19.4                     1

2. what each defense catches, by type and volume
defense                                         full  partial  none  open volume  state-changing open types
none                                               0        0    10       188406                          5
counter                                            3        0     7        47166                          5
timestamp                                          0        6     4        45970                          2
nonce                                              0        3     7       187360                          2
counter + timestamp + nonce                        3        5     2        36460                          1
counter + timestamp + nonce (shared store)         6        2     2        36460                          1

3. the cost of the same defenses
defense                                          state   clock  storage (window)  storage (30d)  legit rejected
none                                                 0       0               0 B            0 B               0
counter                                            224       0               0 B            0 B             424
timestamp                                            0     224               0 B            0 B              79
nonce                                                0       0              72 B        75312 B               0
counter + timestamp + nonce                        224     224              72 B        75312 B             503
counter + timestamp + nonce (shared store)         224     224              24 B        25104 B             503

4. what stays open when all three proofs are on
type                        level  state-changing  30d volume   stays open
configuration-command     partial             yes         140 2 recipients
lease-renewal                none             yes        1860    unlimited
work-order-approval       partial             yes         420        120 s
login                     partial             yes         870 2 recipients
status-query                 none              no       34600    unlimited
meter-reading-request     partial              no        9240        120 s
update-approval           partial             yes          36 2 recipients
window 120 s, recipients 3, endpoints 224, record 24 B

The numbers are in the measurement class; their inputs are PS22–PS24.

No Field, No Check

The header line gives the baseline: all ten of the ten message types are accepted when replayed with no freshness proof, and five of them change state on replay. The volume is 188,406 requests over thirty days.

The first table shows how the fields are distributed and sets up this lesson’s inverse relationship. The counter field is in three types and carries 75% of the volume; none of those three types change state. The nonce field is also in three types, but carries only 0.6% of the volume; against that, all three of those types change state. The timestamp is in six types and covers 75.6% of the volume.

This distribution at the two ends is not a coincidence. The counter appears on messages that flow continuously and whose order is already meaningful — reading notices, daily logs, bulk transfers. The nonce appears on rare, singular transactions — configuration commands, logins, update approvals. The strongest freshness proof sits on the highest-volume, least consequential messages; the most expensive proof sits on the lowest-volume, most consequential ones.

The last row gives the gap: two types carry no field at all, hold 19.4% of the volume, and one of them changes state. These two types cannot be protected by any defense, because the field to protect does not exist in the message.

Three Proofs, Three Different Gaps

The second table runs the defenses one at a time. The counter protects three types completely and brings the open volume down from 188,406 to 47,166; the timestamp brings that same volume down to 45,970, so on the volume measure it produces the biggest drop. But the number of state-changing open types stays at five; the counter does not touch any of them.

The timestamp protects six types, but the protection level is partial: the window is 120 seconds, and a replay presented within that time is counted as valid. The open volume drops to 45,970 and the state-changing open types drop from five to two. Narrowing the window shortens the gap and raises the number of rejected legitimate messages; setting it to zero locks out every endpoint whose clock is not fully synchronized.

The nonce protects three types, but with separate stores the protection is again partial: a value one recipient has accepted is not in the other two recipients’ stores, and the same message goes through when presented to them. The gap is two recipients wide. The open volume stays at 187,360, because the three types it protects are only 0.6% of the volume.

With all three on together, three types are protected completely, five partially, and two types stay open. When the store is shared across recipients, the number of completely protected types rises from three to six, the partial count drops to two, and the open count is still two. A shared store covers no new type; it only raises the level of the types it already covers.

The Cost of What Stays Open

The fourth table gives the seven items that remain when all three proofs are on, and how open each of them stays. Three are open by two recipients, two by 120 seconds, two are unlimited. One of the unlimited ones is the lease renewal, and it changes state: since it carries no field, no check can touch it. The other is the status query, which holds the bulk of the remaining open volume at 34,600 requests.

The third table writes the cost in three separate items. The counter requires 224 state items — one counter per endpoint — and rejects 424 legitimate messages arriving out of order. The timestamp requires 224 clock-synchronization items and rejects 79 legitimate messages arriving late through the window. Together they mean 503 rejected legitimate messages; 2.7 per thousand of the thirty-day volume.

The nonce’s cost is storage, and the gap between the two numbers is the biggest ratio in this table. When bounded by the timestamp window, the store is 72 bytes; without the window — that is, with no bound on how long a replay stays possible — the thirty-day store is 75,312 bytes, over a thousand times more. With a single shared store, these numbers drop to 24 and 25,104 bytes. The nonce alone demands unbounded storage; becoming bounded depends on the timestamp, and the two proofs can only be used together.

Summary

  • Without a freshness proof, all 10 of the 10 message types accept a replay as is; 5 of them change state on replay, and the total volume is 188,406 requests over 30 days.
  • The field distribution is inverse: the counter is on 3 types that carry 75% of the volume and none of them change state; the nonce is on 3 types that carry 0.6% of the volume and all three change state. Two types carry no field at all and hold 19.4% of the volume.
  • The three proofs’ gaps are different sizes: the counter protects completely, the timestamp stays open for 120 seconds, and the nonce with separate stores stays open by 2 recipients.
  • With all three on together, 3 types are protected completely, 5 partially, and 2 stay open; a shared store raises the completely protected count to 6 but covers no new type.
  • The cost is 224 state items, 224 clock-synchronization items, 503 rejected legitimate messages, and a 72-byte store; without the window, that same store rises to 75,312 bytes.

Next Step

This topic built the network’s unit of measure and wrote the same three columns across eight lessons. It counted the eighteen claims a single reading carries and showed that none of them had proof with no defense in place; it measured that an open port is an acceptance notice and that twenty-one of forty-nine acceptance points had no proof; it counted how many records and how many dependent services an unverified response corrupts in address- and name-resolution caches, how a single acceptance in address distribution ties together five claims at once, how requests with no proof of source address exhaust capacity and the state table, how many parties can produce a proof chain, and in how many types a freshness proof is entirely absent.

All of these measurements shared one assumption, and it was never questioned anywhere, including in this lesson: every claim moved through the same flat network. It was assumed that an unverified response could reach the cache, that a distribution response could reach the endpoint, that a request with no proof of source could reach the ingestion endpoint, that a replayed message could reach the recipient. If a boundary were drawn around the network itself, how many of these claims would fail to even cross it, how many rules would that boundary require, and how many legitimate flows would it stop? The next topic — Perimeter Defense — measures the same set of claims, this time against the network’s drawn boundaries.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close