Repository navigation
Conversation
`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
This was referenced Sep 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Target
develop: the normal case for a fix, a chapter, alab change, a typo
develop→main, shipping to the published guidemain, because published advice iscurrently 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.
nat-evilscored as a firewall that held. Seven attacks and five audit checks now measure the attacker's route before crediting anything. Closes #69.ETagand noCache-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
What shipped, in detail
#73 — measure the attacker's route before crediting a firewall.
make attackcreates two containers, not one. Stopnat-evilandevil-boxkeeps running, keeps its default route via 10.0.66.254, and reaches nothing — and Scan from the open internet answered that withok, rung 4 andufw default incoming policy: deny, byte for byte the verdict a run where the firewall did the work gets.nmapprintsfilteredboth 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—needEvilplus one measured packet fromevil-boxto 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 namesmake 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 atlab-vpswould 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-evilis 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 segmentevil-boxshares withlab-ubuntu, so no router carries their packets. Verified: the capture still runs and still reads 0 tunnelled markers withnat-evildown.MachineStatenow carriesrouterDownfor 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'sRouterandGatewayin one place (gatewayForreads it instead of repeating it), the drawing dims that machine's NAT box and labels itnat-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.cssandapp.jswent out with noCache-Control, noETagand noLast-Modified.embed.FSreports a zero modtime, sonet/httphad nothing to put inLast-Modifiedeither, 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 newapp.jsagainst an oldstyle.csswith nothing indicating it.Every embedded asset now carries an
ETagover its own bytes andCache-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 tohttp.FileServer, which is not a trick:http.ServeContentreads theETagalready on theResponseWriterwhen it evaluatesIf-None-Match, so 304 handling, ranges and content sniffing all stay where they were. No build step, no dependency, no version inindex.html.AGENTS.mdandCLAUDE.mdboth 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:
What was run, and what it printed — #73, the fault and the fix:
All three refusal paths were driven and verified, along with the audit's withheld verdict, and a normal
hardenedrun still reading 11/11.#75, before and after on the wire:
lab/README.mdis where they are written downlab/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 workingChecks
make -C lab checkpassesgofmtis clean (make checkfails on any output at all)SPDX-License-IdentifierheaderRun on this branch at 49546e9:
Both commits were CI-green on
developbefore merging, anddevelopis strictly ahead ofmain— 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/, noindex.html, no stylesheet or script inassets/, no simulation and nonav.jsentry is touched. The complete file list isAGENTS.md,CLAUDE.md, and eleven files underlab/.If this touches the lab or sandbox engine
nat-evil DOWNlabel comes fromrouterDown, which is a measured packet, not the router's reported statedangerstill outranksokResultnaming its own rule, and the three HUD heads that asked "is evil-box running?" now read that rule insteadlab/README.mdmoved with it — no score moved; a normalhardenedrun still reads 11/11, and the fixture test that asserts the table passesMerge 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, makingmaina branch that merely resemblesdeveloprather than one that contains it, and every release after that worse.Licence
CC BY-SA 4.0 (guide) or GPL-3.0-or-later (code), per the file I touched