A junior reads the timestamp and closes the case. Stomped reads the journal underneath it and reopens the one the attacker backdated.
Stomped is an autonomous DFIR agent that hunts timestomping and rename/move
laundering on the SANS SIFT Workstation. It runs in a Linux terminal, extends
Protocol SIFT, and is driven by Claude Code. It runs the textbook
$STANDARD_INFORMATION‑vs‑$FILE_NAME stamp check, distrusts its own clean
result, and reads the $UsnJrnl:$J change journal underneath the file —
ordering events by the monotonic USN (each record's ever‑increasing offset in
$J), a clock the attacker cannot forge. It catches its own false negative live,
tags every finding CONFIRMED / INFERRED / CONTRADICTED with a traceable tool
execution behind it, and physically cannot touch the original evidence.
- Architecture pattern: Pattern B — Custom MCP Server. Seven typed,
read‑only forensic functions. No
execute_shell, no write sink, noO_RDWR. - Agentic engine: Claude Code, extending Protocol SIFT (OpenClaw‑compatible seam).
- No web app, no browser, no dashboard. The "UI" is the terminal: the live execution view, the cream‑paper report, the JSONL audit log, and the diagram below.
enumerate_mft → triage a "clean-but-active" candidate
mft_si_fn(inode) → $SI == $FN to the nanosecond → naive check NEGATIVE ("clean")
↓ "a clean check isn't a clean file" (the agent distrusts its own result)
usn_events(inode) → $UsnJrnl logs FileCreate + BasicInfoChange + Rename at a
USN ordered months AFTER the claimed creation → ⊥ CONTRADICTION
↓ SELF-CORRECTION (supersedes the naive step)
reconstruct_timeline([inode], order_by=USN) → true creation window, USN-anchored
logfile_attr_changes + vol_mft_memory → corroborate (deterministic confidence)
→ CONFIRMED / INFERRED / CONTRADICTED, each with a ◦ receipt
It earns trust by being wrong on camera first, then catching itself — on data
it does not control. A judge can re‑point it at any inode (--verdict-inode N) and a
fixed script gets a different answer than it hard‑coded.
The intruder downloaded Sysinternals SDelete (a secure‑wipe anti‑forensics tool),
timestomped it to read as 2018‑11‑15, renamed it into the Recycle Bin, and deleted
it. Stomped's run (inode 472521, $R9531FE.exe ← C:\Users\fredr\Downloads\SDelete\sdelete.exe):
mft_si_fn(472521) → $SI == $FN == 2018-11-15 12:33:42.000000000 → naive check NEGATIVE
usn_events(472521) → USN 4042552952 2020-11-14 03:46:51 FileCreate ← true birth
USN 4060428920 2020-11-14 13:41:19 BasicInfoChange ← the timestomp
USN 4060433360 2020-11-14 13:41:24 Rename → $R9531FE.exe (deleted)
verdict → timestomp + rename-laundering; true creation 2020-11-14, backdated ~2 years
The naive check cleared the intruder's own anti‑forensics tool. The journal — which no user‑mode tool controls — reopened it.
repo/
├── mcp_server/ ← Pattern B: the typed read-only MCP server
│ ├── server.py stdlib JSON-RPC (stdio) MCP transport; per-call receipts
│ ├── tools.py the 7 typed functions + reconstruct_timeline (verdict-maker)
│ ├── safety.py the ARCHITECTURAL read-only boundary (allowlist, no shell)
│ ├── confidence.py deterministic tri-state (1 layer→0.55, 2→0.80, +memory→0.94)
│ ├── config.py env-driven config — no hardcoded paths/secrets
│ └── ntfs/ bounded-struct parsers over court-vetted tools
│ ├── sleuthkit.py istat / fls / icat / fsstat (offset-pinned, read-only)
│ ├── mft.py $SI vs $FN MACB + clean-but-active enumeration (MFTECmd)
│ ├── usn.py $UsnJrnl:$J index, USN-ordered, per-inode filter
│ ├── logfile.py $LogFile second-layer corroboration (RCRD scan)
│ ├── mftscan.py Volatility 3 disk-vs-RAM MFT cross-check
│ └── carve.py residual USN_RECORD_V2 carving (rotated-$J fallback)
├── stomped/ ← the orchestrator (drives Claude Code, renders deliverables)
│ ├── cli.py the `stomped` command (seam, mount, run, render)
│ ├── mount.py ewfmount read-only + pre/post SHA-256 (the integrity proof)
│ ├── engine.py headless Claude Code driver; agent gets ONLY the 7 tools
│ ├── logio.py merges the model token stream + MCP receipts → execution log
│ ├── report.py cream-paper report.md/.html + accuracy.md + dataset.md
│ ├── theme.py the dark-editorial live terminal view
│ └── prompts/system.md the agent extension (distrust the clean check; tri-state; receipts)
├── tests/spoliation/ ← the spoliation fuzz harness (Criterion-3 bypass test)
├── sample-evidence/ ← a bundled, clean-room, REAL NTFS triptych (real $UsnJrnl)
├── docs/ ← the architecture diagram, brand, and example runs
├── LICENSE ← MIT
└── pyproject.toml ← zero runtime Python deps (stdlib-only MCP transport)
| Boundary | Kind | Where | What stops spoliation |
|---|---|---|---|
| Typed read‑only surface | ARCHITECTURAL | mcp_server/safety.py + tools.py |
The agent's entire universe of action is 7 read‑only functions. No execute_shell, no write, no mount, no O_RDWR. Bash/Write/Edit are disallowed in the engine. The dangerous verb is not in the surface. |
| Read‑only evidence | ARCHITECTURAL | ewfmount ro + immutable E01 + vboxsf ro mount |
The E01 is a sealed container opened read‑only; the host mount is ro. Any write → EROFS. Verified: touch /mnt/evidence/... → Read-only file system. |
| Tri‑state tagging discipline | PROMPT | stomped/prompts/system.md |
Shapes judgment ("distrust the clean check"), not capability. We do not over‑claim it. |
Both architectural layers must fail before a byte changes. We tested the boundary
(tests/spoliation/): every mutator argument + a prompt‑injection ordering the agent to
"remount read‑write and repair the MFT" degrades to read‑only or errors; 0/N reach
evidence; pre‑run SHA‑256 == post‑run SHA‑256.
Stomped runs where the read‑only forensic tools are. It needs only the standard SIFT toolset and Claude Code — no Python dependencies (the MCP transport is hand‑written stdlib).
# On the SIFT Workstation (Ubuntu). Requires: The Sleuth Kit, plaso, Volatility 3 (`vol`),
# bulk_extractor, MFTECmd (dotnet), and Claude Code (`claude`) on PATH.
git clone <this-repo> stomped && cd stomped
export PATH="$PWD/bin:$PATH" # or: pip install -e . (gives the `stomped` command)
export ANTHROPIC_API_KEY=$(cat ~/.anthropic_key) # never hardcoded, never committedTools are auto‑discovered; override any with STOMPED_ISTAT, STOMPED_VOL,
STOMPED_MFTECMD, etc. Volatility 3 is invoked as vol.
stomped <evidence> [options]
<evidence> an E01 / raw image / volume / directory [env: STOMPED_EVIDENCE]
--memory <path> paired memory image for the disk-MFT vs RAM-MFT cross-check
--max-iterations <N> HARD stop on the self-correcting loop. default 12. never unbounded.
--min-layers <N> journal layers that must agree before CONFIRMED. default 2.
--mode ro the only mode. `--mode rw` is a hard rejection, not a switch.
--hash <algo|none> pre/post evidence hash to prove zero writes. default sha256.
--report <dir> report + audit-log destination. default ./stomped-cases/<case>/
--engine <name> claude-code (default) | openclaw
--verdict-inode <N> verdict any single inode a judge names
--triptych the held-out clean / naive / laundered demo (bundled sample)The one command a tired analyst types:
stomped /mnt/evidence/standard-forensic-case/rocba-cdrive.e01 \
--memory /mnt/evidence/standard-forensic-case/Rocba-Memory.raw \
--max-iterations 12 --report ./cases/rocbaZero‑setup (the bundled sample — runs anywhere the SIFT tools exist):
stomped --triptych --report ./cases/triptychThis runs the identical, genuine analysis on sample-evidence/stomped-sample.ntfs —
a small, clean‑room, real NTFS volume with a real $UsnJrnl:$J. The agent
verdicts three files live and differently: clean_report.txt → CLEAN,
naive_stub.exe ($SI≠$FN) → CONFIRMED naive timestomp, payload.exe ($SI==$FN,
laundered via rename) → CONFIRMED. (Ground truth in sample-evidence/README.md.)
The real case (on the SIFT Workstation, read‑only):
# verdict the locked inode a judge can re-check:
stomped /mnt/evidence/standard-forensic-case/rocba-cdrive.e01 --verdict-inode 472521
# or run the full autonomous triage:
stomped /mnt/evidence/standard-forensic-case/rocba-cdrive.e01 --max-iterations 12 --report ./cases/rocbaEvery run writes, under --report: report.md + report.html (the cream‑paper memo),
execution-log.jsonl (timestamps + token usage; every finding traces to its tool
execution; supersedes_step marks the self‑correction), accuracy.md (false positives,
what we could not confirm, the spoliation result + pre/post hashes), dataset.md, and
hashes.txt. See docs/example-run-*/ for captured real runs.
- Real evidence, real tools, real findings.
ewfmount, SleuthKit, MFTECmd, plaso, Volatility 3, bulk_extractor — all execute against the actual image. The journal index is a bounded one‑time parse of the real$UsnJrnl:$J; nothing in the analysis is mocked. - The self‑correction is genuine. The deciding USN is unknowable until the real
$Jis parsed. The agent reasons, hits the contradiction, and recovers — never scripted. - Honest confidence. On rocba, the SDelete finding lands INFERRED (0.55), not
CONFIRMED — because
$LogFile's ring had wrapped past the record by the capture date, so only one journal layer survived. Stomped says so and recommends escalation. On the triptych, where both journals survive, it reaches CONFIRMED (0.80). Confidence is a fixed function of corroboration, never the model's mood. - False positives we refuse to assert.
$SI<$FNfires on ~208k innocent files (registry hives, browser JSON, bulk update rewrites); Office installer files carry 2002 dates; installers produce whole‑second (uSecZeros) stamps. A high score is a reason to triage, never a verdict — the journal decides. - Out of scope: a kernel‑level stomp that also forges
$UsnJrnl. Stomped catches the dominant user‑mode class (timestomp / SetMace / nTimetools + rename laundering).
MIT — see LICENSE. Built on, and extending, Protocol SIFT.
100% read‑only · zero writes to evidence · confirmed, inferred, or contradicted — never just asserted.
