Skip to content

Release: the lab measures the attacker's route, and a rebuilt server changes the page - #76

Merged
aenawi merged 2 commits into
mainfrom
develop
Sep 12, 2026
Merged

aenawi merged 2 commits into
mainfrom
develop

Conversation

@aenawi

@aenawi aenawi commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Target

  • This is going into develop: the normal case for a fix, a chapter, a
    lab change, a typo
  • This is a release: develop → main, shipping to the published guide
  • This is a hotfix: straight into main, because published advice is
    currently locking readers out or leaving them exposed

What this changes

Two commits since #72, and they are the two bugs that release found and left open. Both were measured against the running lab rather than argued about, and both were the same shape of fault: the lab told the reader something it had not established.

#73 A stopped nat-evil scored as a firewall that held. Seven attacks and five audit checks now measure the attacker's route before crediting anything. Closes #69.
#75 Embedded UI assets shipped with no ETag and no Cache-Control, so a rebuilt control server could still serve yesterday's CSS. Closes #70.

No chapter, no page, no stylesheet in assets/, and no navigation entry is touched. Nothing a reader of the published guide sees changes; the lab's engine, its control surface, lab/README.md, and the two agent documents do.

Kind

  • Prose: clarity, typo, dead link
  • The advice was wrong, unsafe, or out of date — the lab's own verdicts, not a chapter
  • Lab or sandbox: code, checks, a new scenario
  • A divergence between the lab and the sandbox, closed or newly documented
  • Tests
  • Something else

What shipped, in detail

#73 — measure the attacker's route before crediting a firewall. make attack creates two containers, not one. Stop nat-evil and evil-box keeps running, keeps its default route via 10.0.66.254, and reaches nothing — and Scan from the open internet answered that with ok, rung 4 and ufw default incoming policy: deny, byte for byte the verdict a run where the firewall did the work gets. nmap prints filtered both for "a firewall dropped this" and for "nothing carried this", and at rung 4 there is nothing else to separate them. Six other attacks and five of the eleven audit checks shared the fault, and the audit's is the worse shape: five failures to reach are five passes, reported in the same words a stack that actually held gets.

Seven attacks now ask needEvilOut — needEvil plus one measured packet from evil-box to the lab's relay at 203.0.113.3 — and a run that gets no answer refuses at rung 1 naming the router. Three faults land there and get three distinct sentences: the router was never created (--no-deps, and it names make attack), the router is stopped (docker start nat-evil), or the router is up and the wire still carried nothing — which is what the lab's own link switch does, and pointing at the router for that would name the wrong container. The relay is the target because it holds none of the reader's rules; aiming at lab-vps would ask "is there a route" and "did the firewall allow it" in one packet. The route is measured rather than inferred from the router's state, because a running router is not the same claim as a working one. nat-evil is deliberately not restarted for you: a stopped router is a network condition like a downed link, and this lab's job with those is to measure them, not tidy them away behind the reader's back.

Sit on the wire and capture and Replay a captured frame still ask only for needEvil — neither leaves the segment evil-box shares with lab-ubuntu, so no router carries their packets. Verified: the capture still runs and still reads 0 tunnelled markers with nat-evil down.

MachineState now carries routerDown for any machine whose NAT box is stopped — all three behind a NAT, not just the attacker, since each can be stranded the same way. The catalog names each machine's Router and Gateway in one place (gatewayFor reads it instead of repeating it), the drawing dims that machine's NAT box and labels it nat-evil DOWN, and the machines-panel switch brings the pair up together rather than starting half an attacker.

#75 — tag the UI assets so a rebuilt server changes what the reader sees. style.css and app.js went out with no Cache-Control, no ETag and no Last-Modified. embed.FS reports a zero modtime, so net/http had nothing to put in Last-Modified either, and a browser handed a response with no freshness information at all is free to invent its own. That breaks the loop this lab is built around — edit, rebuild control, look at the page — and it cost a session on #68 debugging code that was correct the whole time. It lands on readers too: anyone who opened the lab before pulling can be looking at a new app.js against an old style.css with nothing indicating it.

Every embedded asset now carries an ETag over its own bytes and Cache-Control: no-cache, which does not mean "do not store", it means store it and ask first. The hash is computed once at startup because the bytes are in the binary and cannot change while it runs. The header is set before handing off to http.FileServer, which is not a trick: http.ServeContent reads the ETag already on the ResponseWriter when it evaluates If-None-Match, so 304 handling, ranges and content sniffing all stay where they were. No build step, no dependency, no version in index.html.

AGENTS.md and CLAUDE.md both told agents a hard reload was part of the loop. It no longer is, so they say so.

If this changes advice or a command

No command a reader of the published guide runs changes. Both commits change what the lab reports, which the lab's own README documents:

OS: macOS 26.6.2
Docker 29.7.2, compose v5.5.1, Headscale 0.29.3
Measured on: 2026-09-12

What was run, and what it printed — #73, the fault and the fix:

$ docker stop nat-evil
$ docker exec evil-box ping -c1 -W2 203.0.113.3
1 packets transmitted, 0 received, 100% packet loss

before:
$ curl -s -X POST localhost:8099/api/action -d '{"id":"scan-public"}'
{"ok": true, "rung": 4, "rule": "ufw default incoming policy: deny", …}

after:
{"ok": false, "rung": 1, "rule": "nat-evil is stopped",
 "why": "The attacker's own router is down, so it reaches nothing at all —
         not lab-vps, not the coordination server, not the relay. Its route
         survived the outage; the gateway at 10.0.66.254 did not. …"}

All three refusal paths were driven and verified, along with the audit's withheld verdict, and a normal hardened run still reading 11/11.

#75, before and after on the wire:

before: style.css answered with no cache headers of any kind.
after:  Cache-Control: no-cache and an ETag.
        a matching If-None-Match returns 304 with no body;
        a stale one returns the full 37646 bytes.

driving a browser, style.css already cached:
        editing it and rebuilding control made a plain reload pick up
        the change; a second reload with nothing changed moved 300 bytes
        on the wire for a 37692-byte stylesheet.
  • I checked whether any other chapter makes the same claim — no chapter claims either; both are lab engine behaviour, and lab/README.md is where they are written down
  • If this step can lock someone out of a machine, the warning sits before the step — neither change touches a step that can
  • The mechanism is explained, not just the command — both have a section in lab/README.md, and Measure the attacker's route before crediting a firewall #73 gains a troubleshooting entry so a reader meeting the new rung-1 refusal is told it is the refusal working

Checks

  • make -C lab check passes
  • gofmt is clean (make check fails on any output at all)
  • New source files carry an SPDX-License-Identifier header

Run on this branch at 49546e9:

licences   ok   all source files carry their licence
links      ok   all internal heading links resolve
go vet · go test · gofmt · ui js — all pass
tests 27 · pass 27 · fail 0
Everything held.

Both commits were CI-green on develop before merging, and develop is strictly ahead of main — no unmerged hotfix to reconcile, so this merge introduces nothing that has not already been checked.

If this touches the guide's pages

Not applicable. No file under chapters/, no index.html, no stylesheet or script in assets/, no simulation and no nav.js entry is touched. The complete file list is AGENTS.md, CLAUDE.md, and eleven files under lab/.

If this touches the lab or sandbox engine

  • Nothing is drawn that was not measured — this is precisely what Measure the attacker's route before crediting a firewall #73 fixes; the drawing's new nat-evil DOWN label comes from routerDown, which is a measured packet, not the router's reported state
  • A failed attack does not render as a green tick — the board rule Measure the attacker's route before crediting a firewall #73 was opened against
  • danger still outranks ok
  • Time is not faked
  • The engine still returns the rung that decided the outcome, not a bare pass/fail — the new refusal is a rung-1 Result naming its own rule, and the three HUD heads that asked "is evil-box running?" now read that rule instead
  • If a score moved, the comparison table in lab/README.md moved with it — no score moved; a normal hardened run still reads 11/11, and the fixture test that asserts the table passes

Merge method

gh pr merge --merge, not --squash. Per CONTRIBUTING.md § Releases: squashing a release writes a new commit with no link to the ones it replaced, making main a branch that merely resembles develop rather than one that contains it, and every release after that worse.

Licence

  • I have the right to submit this, and I am fine with it going out under
    CC BY-SA 4.0 (guide) or GPL-3.0-or-later (code), per the file I touched

`make attack` creates two containers. Stop the second one and the lab scored
the first one's silence as a defence.

With `nat-evil` stopped, `evil-box` keeps running, keeps its default route via
10.0.66.254 and reaches nothing — and **Scan from the open internet** answered
that with `ok`, rung 4 and `ufw default incoming policy: deny`, byte for byte
the verdict a run where the firewall did the work gets. `nmap` prints
`filtered` both for "a firewall dropped this" and for "nothing carried this",
and at rung 4 there is nothing else to separate them. Measured on Docker 29.7.2
with compose v5.5.1 on macOS 26.6.2, Headscale 0.29.3, on 2026-09-12:

    $ docker stop nat-evil
    $ docker exec evil-box ping -c1 -W2 203.0.113.3
    1 packets transmitted, 0 received, 100% packet loss

    $ curl -s -X POST localhost:8099/api/action -d '{"id":"scan-public"}'
    {"ok": true, "rung": 4, "rule": "ufw default incoming policy: deny", …}

Six other attacks and five of the eleven audit checks shared the fault, and the
audit's is the worse shape: five failures to reach are five passes, reported in
the same words a stack that actually held gets.

So the precondition is no longer "the attacker's container is running". Seven
attacks now ask `needEvilOut`, which is `needEvil` plus one measured packet from
`evil-box` to the lab's relay at 203.0.113.3, and a run that gets no answer
refuses at rung 1 naming the router:

    {"ok": false, "rung": 1, "rule": "nat-evil is stopped",
     "why": "The attacker's own router is down, so it reaches nothing at all —
             not lab-vps, not the coordination server, not the relay. Its route
             survived the outage; the gateway at 10.0.66.254 did not. …"}

Three faults land there and get three sentences: the router was never created
(`--no-deps`, and it names `make attack`), the router is stopped (`docker start
nat-evil`), or the router is up and the wire still carried nothing — which is
what the lab's own link switch does, and pointing at the router for that would
be pointing at the wrong container. All three were driven and all three
verified, along with the audit's withheld verdict and a normal `hardened` run
still reading 11/11.

The relay is the target because it holds none of the reader's rules and no
configuration in this lab changes it; aiming at `lab-vps` would ask "is there a
route" and "did the firewall allow it" in one packet. The route is measured
rather than inferred from the router's state, because a running router is not
the same claim as a working one. And `nat-evil` is deliberately not restarted
for you: a stopped router is a network condition like a downed link, and this
lab's job with those is to measure them, not to tidy them away behind the
reader's back.

**Sit on the wire and capture** and **Replay a captured frame** still ask only
for `needEvil`. Neither leaves the segment `evil-box` shares with `lab-ubuntu`,
so no router carries their packets — verified: the capture still runs and still
reads 0 tunnelled markers with `nat-evil` down.

The state API was blind too, so `MachineState` carries `routerDown` for any
machine whose NAT box is stopped — all three behind a NAT, not just the
attacker, since each can be stranded the same way. The catalog now names each
machine's `Router` and `Gateway` in one place (`gatewayFor` reads it instead of
repeating it), the drawing dims that machine's NAT box and labels it
`nat-evil DOWN`, and the machines-panel switch brings the pair up together and
says so rather than starting half an attacker. The three HUD heads that asked
"is evil-box running?" now read the refusal's own rule, because with three ways
to refuse that question names the wrong container two times in three.
style.css and app.js went out with no Cache-Control, no ETag and no
Last-Modified. embed.FS reports a zero modtime, so net/http had nothing
to put in Last-Modified either, and a browser handed a response with no
freshness information at all is free to invent its own.

That breaks the loop this lab is built around: edit, rebuild control,
look at the page. A rebuilt server that does not change the page reads
as a broken fix, and it cost a session on #68 debugging code that was
correct the whole time. It lands on readers too, not only editors —
anyone who opened the lab before pulling can be looking at a new app.js
against an old style.css with nothing indicating it.

Every embedded asset now carries an ETag over its own bytes and
Cache-Control: no-cache, which does not mean "do not store", it means
store it and ask first. The hash is computed once at startup because the
bytes are in the binary and cannot change while it runs. The header is
set before handing off to http.FileServer, which is not a trick:
http.ServeContent reads the ETag already on the ResponseWriter when it
evaluates If-None-Match, so 304 handling, ranges and content sniffing
all stay where they were.

No build step, no dependency, no version in index.html.

Measured against the running lab on 2026-09-12. Before, style.css
answered with no cache headers of any kind. After, it answers with
Cache-Control: no-cache and an ETag; a matching If-None-Match returns
304 with no body, a stale one returns the full 37646 bytes. Driving a
browser: with style.css already cached, editing it and rebuilding
control made a plain reload pick up the change, and a second reload with
nothing changed moved 300 bytes on the wire for a 37692-byte stylesheet.

AGENTS.md and CLAUDE.md both told agents a hard reload was part of the
loop. It no longer is, so they say so.

Closes #70
@aenawi
aenawi merged commit 90aabb6 into main Sep 12, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant