Attacking the Lies About Disk Layout
A flag hidden in the RAM disk with no name, a crafted directory entry that reads it right out, and the one-field fix that turns 'in the image' into 'in the data.' A bounds check is only as trustworthy as the bound it compares against.
Contents
The second security milestone. A flag hidden in the RAM disk with no name, a crafted directory entry that reads it right out, and the one-field fix that turns “in the image” into “in the data.” The confused deputy from the last milestone, one layer down: offsets instead of pointers.
Back at the file-manager milestone, I planted a secret on the RAM disk: the bytes FLAG{ziran-boundary-leak}, written to the end of the image with no directory entry pointing at them. ls can’t see it, cat can’t read it — navigation can only follow entries, and no entry names the flag. The self-test proved it: read_path("/FLAG") returns NotFound.
That boundary was real, and it held. But it was only ever the navigation boundary. This milestone is the one that asks the harder question — not “can I walk to the secret?” but “can I craft a disk that hands it to me anyway?” — and the answer, until now, was yes.
The lesson, up front: a bounds check is only as trustworthy as the bound it compares against.
The original mount checked that every file’s bytes were in the image. That sounds like a bounds check. It is a bounds check. It’s just a bounds check against the wrong bound — the whole image, secret included — and “in bounds” against the wrong bound is not a bound at all. This is the same confused-deputy lesson from the previous milestone, one ring down: there it was a syscall trusting an attacker-controlled pointer; here it’s a parser trusting an attacker-controlled offset.
What “done” means here
“Done” isn’t “the secret has no name.” That was true a milestone ago. Done is a contrast, asserted in one boot:
- Navigation HELD.
read_path("/FLAG")→NotFound. No entry names it. - Loose extent LEAKS → captured. A crafted image whose file entry aliases the secret’s bytes, run through the old check, mounts and reads the flag — asserted byte-equal to the planted constant. A real capture.
- Strict extent CONTAINS. The same image through the fixed reader →
ExtentEscapesData. Flag safe. - Strict still PERMITS. The legitimate image mounts under the strict reader and every file reads byte-exact — the guard against a “fix” that just rejects everything.
The boundary the parser enforces and the boundary I think it enforces are different, and the gap is exactly the size of the wrong bound. The old check felt like it confined files. It didn’t; it confined them to the image, which is not the same as confining them to the data.
Isn’t adding a format field “building a real filesystem”?
The objection worth taking seriously: a new on-disk superblock field, a format version bump — that smells like growing a real filesystem, which is an explicit non-goal. But the non-goal is FAT/ext-style features: writes, journaling, real durability. Adding one integer to a hand-built teaching format so its own validation can be tightened is not that — it’s sharpening the existing trust boundary, which is the literal content of this milestone. And it rhymes with the earlier milestone that gave the previously-reserved byte meaning as the kind field. Each milestone grows the format by exactly one field.
I kept it honest by making the fix a ceiling only (data_end), not a full “every byte owned by exactly one file” accounting. That coverage machinery is a real allocator’s job and open-ended; the flag capture doesn’t need it. The floor — a file may still alias other files’ bytes below data_end — and a generated fuzzing flood are red-team territory, not built.
Name where the data ends
The whole fix is one idea: the reader has to know where the legitimate data stops. Format v3 repurposes the superblock’s last reserved word as data_end — the offset where addressable file data ends. The writer sets it to the start of the secret; the secret lives in [data_end, total), past it. The strict mount confines every entry’s extent to <= data_end. An extent reaching past it is ExtentEscapesData.
The teaching structure mirrors the previous milestone’s SYS_WRITE / SYS_WRITE_UNCHECKED pair: ship two readers. Fs::mount is strict; Fs::mount_loose keeps the old total_size bound. The self-test drives the same crafted image through both — it leaks through one and is contained by the other. Same bytes, one comparison apart.
Planning caught the trap that would have wasted an evening: the format bump breaks the hand-built corrupt-image tests (the deep and diamond cases write their own superblocks, with the old reserved word as zero). A zero data_end would now trip the new BadDataEnd check before those tests reached their intended TooDeep / DirNotForward errors. So the order was: rebuild and boot after the writer/reader change and before adding the attack, so a format regression shows up alone. It did exactly that, and the fix — write a valid data_end in those images — was already written down.
What got built
The crafted attack is one directory entry. Clone the boot image; repoint root entry 0 to a file named leak whose offset = data_end, length = 25 — its extent is now precisely the secret’s byte range. Nothing about it is malformed by the old rules. Fs::mount_loose accepts it; read_path("/leak") returns the flag. Fs::mount rejects it at validation. Straight off the serial log:
[m16] CAPTURED: a crafted file extent aliased the secret -> loose mount leaked "FLAG{ziran-boundary-leak}"
[m16] CONTAINED: strict mount rejected the escaping extent (ExtentEscapesData) -- flag safe
[m16] PERMITS: the legit boot image mounts strict and reads byte-exact -- a check, not a wall
The capture asserts byte-equality with the planted constant — a crafted entry that returns some bytes proves nothing; it has to be the flag. The containment asserts the specific ExtentEscapesData, not a generic size error. Boundary cases are pinned too: an extent ending exactly at data_end passes, one byte past is rejected.
Two adversaries, one gap
Because this is a security milestone, the boundary got attacked in earnest — by fanning out two independent reviewers with different charters: one auditing soundness (does it stay panic-free and total?), one hunting exfil paths (can any crafted image still return the secret?).
Both came back with the strict reader holding: no crafted image makes mount panic or hang, and no entry-level attack returns the flag. The two ways to reach the reserved bytes are the two the code documents and accepts — forging the whole superblock to move data_end (an in-image field; the honest residual, and exactly the previous milestone’s “don’t validate against attacker-controlled metadata” lesson recurring), and floor-aliasing (a file overlapping other files’ bytes below data_end — leaks structure, never the secret).
And then both reviewers, independently, found the same real gap — which is how I knew it was real without adjudicating. My first fix put the data_end ceiling inside the KIND_FILE branch of the validator. But a directory table is an extent too, and it went unchecked: a KIND_DIR entry could legally point its table into the reserved [data_end, total) region, and ls renders entry bytes as names — a disclosure channel read_path confinement never touched. It happened to be safe for this secret only by coincidence: 25 bytes can’t hold a 32-byte directory entry, and the flag’s bytes fail the kind re-check. Safe by accident of the data, not by the check.
The fix: hoist the ceiling check above the file/directory split, so it applies to every entry uniformly. One line moved; the guarantee went from coincidental to principled. It’s pinned now with a synthetic image whose directory table sits past data_end, asserting ExtentEscapesData.
What broke
As with the last two milestones, nothing broke at build time — one trivial compile error, where I renamed a parameter in the body and not the signature. The real finding came from review, not the compiler, and it’s the lede: I secured the door the flag walks out of and left a window in the same wall. The secret is read through read_path, so I confined the read path — and forgot the parser has a second extent-consuming path, the directory recursion, whose name-rendering is its own way out.
The generalizable lesson is a checklist item, not a mood: when you add a bound to a parser, enumerate every place the bounded quantity is consumed — here, file extents and directory-table extents — and enforce the bound at the earliest common point, before the type split, not per-branch. Structurally, the check belonged above the match. Writing it there first would have made the asymmetry impossible instead of a finding two reviewers had to catch.
The other thing worth keeping: running two adversaries with orthogonal mandates and watching them converge. When a soundness auditor and an exfil hunter both land on the same line, that’s near-certainty. For a security boundary, don’t run one reviewer twice — run two with different jobs.
Takeaway for the next person
If you’re tightening a parser’s bounds check:
- Compare against the right bound. “In the image” is not “in the data.” A file that can name any in-bounds byte can name the bytes you were hiding. Find the bound that means legitimate, and if the format doesn’t record it, add the field that does.
- Confine every consumer of the bounded quantity, before the type split. A filesystem reads extents in more than one place. Guard them at the common point, or you’ll guard the one you were thinking about and miss the one you weren’t.
- Ship the loose reader next to the strict one. The proof isn’t “it rejected the bad image” — it’s the same image leaking through the old check and contained by the new one, with a real file still passing. The contrast is the milestone.
- Be honest about the residual.
data_endis an in-image field; a forged superblock reopens the leak. Naming that — rather than pretending a toy FS is hardened — is the whole spirit of the track. You break your own boundary to learn exactly where it ends.
Both flags are captured now — one behind the ring boundary, one behind the disk layout — and both taught the same thing from opposite ends of the kernel: the CPU and the parser will both faithfully do what a bounds check tells them, and a check that trusts the wrong number is a check that leaks. The core arc was done at the file-manager milestone; the security track is what made the boundaries real by trying, in earnest, to get around them.
Newsletter
Liked this? Get the next one.
One essay or short note every other week — privacy-first software, AI, security, and the occasional dispatch from the trail. No filler.
More writing
The confused deputy
I planted a flag on a kernel-only page and wrote a ring-3 program to steal it. The CPU stopped the direct read cold — then my own syscall handed the flag over the moment it dereferenced a caller-chosen pointer. The one door the hardware cannot guard for you.
ReadThe whole point, arrived at
Subdirectories, cd/pwd/ls/cat, and the moment the core arc — boot, memory, tasks, shell, files — is complete. Along the way I learned the hard way that a termination proof is not a safety proof: my validator provably terminated and still hid two denial-of-service bugs.
ReadFiles are just convincing lies about disk layout
I built a read-only filesystem for my from-scratch kernel, and the lesson is what a 'file' actually is: a directory table of {name, offset, length} over a flat run of bytes. The hard, security-relevant code isn't the reading — it's the refusing.
Read