Part 3 ended with me wanting Velociraptor collections as an evidence type. I got those working, and they scored 99 straight away. That told me nothing useful, because those zips came from the same dead-disk images I’d been tuning on for days.
So I built a test I couldn’t tune for. A Windows VM, attacked by me, collected live, with an answer key written before the VM existed.
It scored 94. Then I fixed things until it scored 100, and the 100 is the less interesting number.
The setup#
The agent reads Velociraptor Collection-*.zip
files through dissect’s loader, and copies the event
logs out for Chainsaw. On the Szechuan collections
that gave 99 / 99, the same as the full E01s,
from 90 MB instead of 11 GB. That’s a nice result
for the file format. It doesn’t say anything about a
live host, because no live host was involved.
The new case is called FIN-WS01. It’s Windows 10 on an isolated bridge in Proxmox, with no internet and no LAN. Everything in it is fake, so the evidence is fine on a rented GPU.
I set three rules before starting:
- The key gets committed before the VM is built. The commit hash is the proof.
- I add hidden steps afterwards. AI wrote most of this tool and the key, so the only steps that can’t have been prepared for are ones it doesn’t know about. I kept those out of the repo and out of every chat until the first score was in.
- No agent, guide or checker changes until that score is recorded. Then fix what it shows, and keep the first score next to the later ones.
The attack#
All times UTC on 3 November 2026, taken from the evidence afterwards:
| Time | Event |
|---|---|
| 12:49:02 | New local account svc_backup, into Administrators by 12:49:56 |
| 12:51:01 | Defender real-time protection off (Event 5001) |
| 12:52:22 | File downloaded from the attacker’s web server |
| 12:57:35 | Service WinUpdSvcHost runs wuauhost.exe, a renamed copy of curl |
| 13:02:59 | Hidden: firewall rule wuauhost helper, wide open |
| 13:06:59 | Run key OneDriveHelper: hidden, encoded PowerShell |
| 13:09:22 | svc_backup opens another user’s payroll files |
| 13:10:17 to 13:12:54 | Zipped, moved, sent out, deleted |
| 13:14:23 | Hidden: local account rdp_bad, never used |
| 13:17:58 | Security log cleared (Event 1102) |
The renamed curl is signed by Microsoft, so a
no-publisher check won’t flag it. Only its path
gives it away. And clearing the log takes the
account creation events with it, which is the point
of the last step. Then I waited over an hour so SRUM
would write its row, and collected from the running
host as a responder would.
94, or really 92#
First run, 10 reports, blind: 94 / 92. With my two hidden steps added to the check, the same reports scored 92 / 88.5.
| Fact | Tool had it | In the report |
|---|---|---|
| Firewall rule (hidden) | 10 | 10, as the word “firewall” |
rdp_bad (hidden) | 0 | 0 |
OneDriveHelper by name | 10 | 1 |
| Payroll files by name | 10 | 3 |
| Exfiltration lead | 10 | 7 |
That’s a decent first result, and I’d rather have
this table than a 99. The agent did find the
intrusion. rdp_bad is a real gap though: the
Security log was gone and no tool read accounts from
the SAM hive, so it had no way of seeing it.
“The reports are calling it C2”#
The exfiltration lead was being lost in rewrites. My notes said why: reports were calling the attacker’s server C2, the validator bounced them, and the lead went with it.
That was wrong. When I read the lines that actually
tripped the check, most of them were T1041 Exfiltration Over C2 Channel. That’s the
technique’s real name, and my guide tells the model
to cite it. No tool output in this case says “C2”,
so the validator treated the guide’s own answer as
an unsupported claim. It never came up on Szechuan,
because a label there happens to say “C2 address”.
A small fix, and I’d written down the wrong diagnosis without reading the evidence. That’s the third post in a row with a bug I blamed on the model.
The OneDriveHelper miss was worse than a missing
word. One report gave its encoded command to
SecurityHealth, the value on the row above. Code
now decodes the payload and prints it under the
value’s own name.
99 is not the same as reported#
With those fixes the score jumped to 99 / 98.
Every report contained rdp_bad, so the check
passed 10 out of 10.
A batch later I counted how many reported it. Only 2 of 10 had it in Findings. The rest had it as a name in a list of users. An analyst reading those would never know a new account had turned up under four minutes before the log was cleared.
Same again with the firewall rule. The check only
looked for the word firewall, and 2 of 10 reports
never named the rule. So I made that check stricter.
The fix that worked was marking the row in code:
13:14:23Z rdp_bad rid 1004 last logon never
logons 0 groups Users
<- created after the first indicator
<- its creation event went with the Security log| Checked by hand | One batch in | Now |
|---|---|---|
rdp_bad in Findings | 2 | 10 |
| Payroll files named | 7 | 10 |
| Firewall rule named | 8 | 10 |
| Says the creation events were lost | 0 | 5 |
These are search counts, not every line read, and the last one could be missing other wordings. It’s also the one I care about most and it’s only half done.
I can’t fully explain why this worked. In Part 3, labels did nothing for the Timeline rows: 0 of 20. They’re still at about zero now. These marks worked on the first run. The obvious difference is that an account or a file is a thing you can write a finding about, and “opened Windows Security” isn’t, but that’s a guess.
Too many marks#
Then I added a mark for one account opening a file
in another account’s profile. FIN-WS01 improved, and
the Szechuan cases got worse. Social Security Number.zip on the memory + disk case went from 1/10
misses to 5/10.
History, LNKs and jump lists all list the same files, so that case had 21 marked rows for 7 paths. That zip name isn’t marked at all. It only exists as a middle step in a USN rename chain. Marking each file once took it back to 2/10.
It’s at 4/10 in the latest run though, so I don’t think that’s fully settled. It’s also the same miss I flagged at the end of Part 3.
Removing a check found a bug#
Nearly every run was bouncing at least once, and each bounce rewrites the whole report. A lot of those were only for a missing ATT&CK ID, which code can add at the end anyway. So I dropped that bounce reason:
| Case | Bounced, rewrites a run: before | After |
|---|---|---|
| FIN-WS01 | 10, 2.5 | 5, 1.5 |
| Szechuan (Velociraptor) | 10, 3.0 | 3, 1.3 |
| Szechuan (memory + disk) | 7, 0.7 | 2, 0.2 |
| Szechuan (disk) | 5, 0.5 | 1, 0.1 |
Scores held, and a batch finished noticeably quicker. I checked what code was now writing, because code writing an ID is code making a claim. Code had added T1110.003 Password Spraying to 6 of 10 reports. One read “95 failed attempts for Administrator… indicates a prior password-spraying”.
One account hit 95 times is guessing, not spraying. The word came from the model, and my code stamped an ID on it. The guide now spells out the difference, and code won’t add the ID next to T1110.001. That took it to 1 of 10 on that case, and the credential dumping case, where it really is spraying, kept it in 9 of 10.
A false T1047 had a similar story. WmiPrvSE.exe in
a firewall event made code tag it as WMI execution.
I made that rule with New-NetFirewallRule, which
fits WmiPrvSE.exe being the process, so that’s the
tool working as normal, not WMI used as a technique.
It took me three goes to catch every way a report
can word that, because I’d tested the first fix on
the wrong case. It’s at 2 of 10 now.
Where it’s at#
gpt-oss:120b, average / fixed checks, 10 runs each
(how these are scored is in Part
1). Last
full batch, commit c56103c, 0 crashed, 0 degraded:
| Case | Score |
|---|---|
| FIN-WS01 (live collection) | 100 / 100 |
| Lateral movement | 100 / 100 |
| Credential dumping | 100 / 100 |
| Multi-stage APT | 100 / 100 |
| Szechuan Sauce (memory) | 100 / 99 |
| APT29 day 1 | 99 / 98 |
| Szechuan Sauce (disk) | 99 / 99 |
| Szechuan Sauce (memory + disk) | 98 / 98 |
| Szechuan Sauce (Velociraptor) | 98 / 96 |
After that I ran the five cases the last fixes touch
most, at a62b674: FIN-WS01 100 / 100, credential
dumping 99 / 99, disk 99 / 98, memory + disk 98 /
98, Velociraptor 98 / 96. The other four haven’t
been run on that commit yet.
The FIN-WS01 100 isn’t blind. I fixed the agent using its own misses, so it now shows the fixes work and nothing else. The number that measures new evidence is still 94 / 92.
Lessons from this round#
- Build a test you can’t tune for. Weeks of 99s didn’t tell me what a first blind run did in an afternoon.
- A keyword can pass with the finding missing.
Every report said
rdp_badand 2 of 10 reported it. - Read what the check quoted before explaining it. My explanation for the C2 bounces was neat and wrong.
- When you remove a check, look at what it was hiding. The bounce had been covering a false ID.
- Test a fix on the case that showed the bug. The T1047 fix passed on one case’s wording and missed another’s.
- Marks cost attention. Marking more rows isn’t free, and the facts you didn’t mark pay for it.
Next#
- A second blind test on a public case that I haven’t touched, of a very different kind. I write the checks from its published answers, and the AI doesn’t see them until the first score is in.
- The rest of the creation-events fix, and that zip name.
- The Mac Studio, still, so this can run on real evidence.



