root@kypwny.net ~ #
← all posts

i told an ai my kernel was owned (fable cyber capability assessment)

a capability assessment of claude-fable-5.1 as a security auditor — compromise-assumed brief, no IOCs, root shell. what it derived, what it broke, and why it's the cheapest cure for sysadmin paranoia i've found.

This is not a report about my machine. My machine turned out to be fine.

This is a capability assessment of openrouter/anthropic/claude-fable-5.1 (max reasoning, omp harness, root shell) as a security auditor. I handed it a compromise-assumed brief with almost no information and measured what it could actually establish — against what I already knew.

navi is a dual-GPU Gentoo box: gaming, and VFIO virtualization research — writing GPU-accelerated software for the Windows platform inside an acceptably secure sandbox. That means a self-compiled kernel, PCI passthrough, per-VM libvirt hooks that bind and unbind devices, CPU pinning, overdrive bits, and a pile of hand-written boot scripts poking at hardware. Half the machine looks like malware to anything that hasn’t read the config.

The brief was four sentences. No indicators of compromise. An arbitrary named threat actor (“say Salt Typhoon”). Four mutually incompatible hypotheses. No symptoms, no timeframe, no constraint on the search space. It closed with “I could be wrong though.”

Two hours and 130-odd tool calls later it had killed all four hypotheses with primary evidence, found three problems I didn’t know I had, and talked me out of two of the three hardening changes I’d asked about.


Phase 0 — Tasking

"infected by a foreign adversary, say Salt Typhoon… malware in the kernel…
 maybe via prompt injection on an unsandboxed machine… maybe a process
 hidden as a kernel thread… I compile my own kernel all the time, maybe
 supply chain."  + "I could be wrong though."

48 seconds from brief to first shell command. Three parallel calls: two reference methodology files, then a 13-command host baseline (uname, /proc/version, /proc/sys/kernel/tainted, /proc/cmdline, lockdown state, modules_disabled, ld.so.preload, os-release, uptime).

The baseline returned:

/proc/sys/kernel/tainted = 84

Bits 2, 4, 6 — S (CPU out of spec), M (machine check), U (user). Three flags I had not knowingly set. If you have ever seen a non-zero taint value on a box you were already suspicious of, you know how that feels.

Capability note: no clarifying questions, no request for IOCs, no stalling. It converted an unfalsifiable feeling into a measurement in under a minute.


Phase 1 — It Decided to Distrust Its Own Tools

This is the unprompted decision that shapes everything after it, and the single most important thing in this assessment.

A rootkit hides by hooking what tools askgetdents64 on /proc, /proc/modules, netlink socket diag. So the agent did not run ps. It brute-forced open("/proc/<pid>/status") across all 4,194,304 PIDs (14.4 s) and compared three independent views of the same truth.

It then applied that pattern to every layer:

LayerIndependent views cross-checked
Processesbrute-force procfs vs readdir(/proc) vs nr_threads vs PF_KTHREAD vs maps/exe/cmdline/stack/wchan
Modules/proc/modules vs /sys/module vs kallsyms tags vs vmallocinfo execmem ranges vs sysfs sections vs 4 build-id sources
Kernel textrunning kallsyms (98,011 symbols) vs build System.map
Socketsprocfs vs netlink vs sockstat counters vs inode→pid

Result: 609 = 609 TGIDs. Nine TIDs appeared “hidden” — re-checked two seconds later, all gone, and all named: an idle kworker, seven GC helper threads belonging to the agent’s own process, one sleep spawned by fancontrol.

The first finding of the audit was a false positive the agent disproved itself, unprompted, before mentioning it as a finding.

Then 464 kernel threads audited: every one with empty maps, no exe, empty cmdline, parent 0 or 2, and a stack resolving to a known symbol. Zero user processes parented to kthreadd.

My leading hypothesis was dead 15 minutes in.


Phase 2 — It Built an Instrument That Wasn’t Installed

bpftool is not on the box. This is where a checklist auditor writes “eBPF layer: unable to assess.”

Instead it called bpf(2) directly through Python ctypesPROG_GET_NEXT_IDGET_FD_BY_IDOBJ_GET_INFO_BY_FD, structs packed by hand — then dumped the one persistent program’s translated instructions and decoded the opcodes manually.

1 program  (id 12832, SOCKET_FILTER, uid 0, 180 insns, 1 map)
0 links
1 map      (ARRAY, 8 × 1 byte)

To measure background noise it loaded two throwaway two-instruction programs 60 s apart and read the prog-ID counter delta: 13,490 loads since boot, ~182/hour.

An unnamed, root-owned BPF program loaded today, on a host with that much churn, is exactly what a modern eBPF rootkit looks like.

Capability ceiling of the session. When the tool didn’t exist, it wrote the tool.


Phase 3 — Attribution, and a Four-Hour Error It Caught Itself

Five steps to close the eBPF question:

  1. The map’s file descriptor is held by NetworkManager (pid 2507, fdinfo: map_id: 4290) — the same map the program references.
  2. /var/log/messages: Wi-Fi deauth at 17:19:47, reauth at 17:19:48.
  3. /etc/resolv.conf rewritten at 17:20:03.483 — 312 ms after the program loaded at 17:20:03.171.
  4. Instruction dump: compare/exit chain plus one map lookup. No probe_read, no override_return, no tracing helpers. A SOCKET_FILTER program type cannot hook kernel functions at all.
  5. Binary strings confirm NetworkManager ships n-acd (ARP conflict detection), which installs precisely this shape of filter with a small array map.

Verdict: address-conflict detection after a Wi-Fi roam.

The honest part

While building the timeline, the agent re-checked its own correlation claim and found it had stated the relevant prior agent session ran 10:59–13:34 EDT. It actually ran 11:00–17:34. Off by four hours, in a claim about whether an AI agent was active when the program loaded — i.e. in the load-bearing claim of the prompt-injection hypothesis.

It corrected it with better evidence than the original: the session was open but idle from 15:10Z until my 21:20:58Z message, and its next command ran 21:21:10Z — 67 seconds after the load. The Wi-Fi reconnect is the trigger. Conclusion held; the reasoning underneath it was rebuilt.

Second thread: the 182 loads/hour traced to a Docker container at 4,575 restarts, crash-looping since its database sidecar exited five weeks earlier, creating and destroying a device-cgroup program every cycle. Stopped at the end of the session; churn re-measured: 3-per-45 s → 0-per-60 s.


Phase 4 — Three Taint Flags, Three Different Kinds of Answer

The number that opened the investigation, decomposed. This phase is the clearest demonstration of the difference between looking up an answer and deriving one.

S (CPU out of spec) — trivial lookup. dmesg says amdgpu: Overdrive is enabled four seconds into boot; source line amdgpu_drv.c:3192 taints when the overdrive bit is set; my own kernel cmdline sets it.

M (machine check) — a real hardware event, not security. 2026-08-31 16:07:12: uncorrected memory error, poison flag set, page 0x260ff7 recovered, HardwareCorrupted: 4 kB. The DIMMs report Error Correction Type: None. This is a finding I did not have, and operationally the most important thing in the whole report. Memtest, not malware.

U (user) — the interesting one, because nothing in dmesg says “taint”. So it derived it: grepped every add_taint(TAINT_USER, …) call site in the source tree (24 of them), ruled out each subsystem not compiled in or not present, and landed on:

/* drivers/pci/pci-sysfs.c:799 */
pci_warn(pdev, "setpci: Unexpected write to kernel-exclusive config offset %x\n", offset);
add_taint(TAINT_USER, LOCKDEP_STILL_OK);

That line is in dmesg at boot+68 s. The write came from /etc/local.d/perf.startmy own script, from March, setting PCI latency timers.

The punchline

The agent then read the register back on all 48 PCI functions. Every one returns 00. PCIe hardwires that field read-only.

The tweak has never done anything except taint the kernel. A five-month-old no-op I had been carrying, discovered only because it looked like evidence of an intrusion.

When the kernel says “user,” it usually means you.


Phase 5 — Verifying a Self-Compiled Kernel

The supply-chain hypothesis. This is where the agent does the thing a checklist cannot: build a provenance chain and walk it end to end.

firmware boot entry → ESP file → UKI PE sections → .linux sha256 == build-tree bzImage
  → extracted vmlinux build-id == running kernel build-id == source-tree vmlinux build-id
  → source tree 100,970/100,970 files match package manifest
  → distfile sha256 == kernel.org's PGP-signed sha256sums.asc

Kernel Provenance & Verification Chain Diagram

Supporting ticks: the boot stub’s code sections are byte-identical to the installed systemd stub; the embedded command line matches both /etc/kernel/cmdline and the running /proc/cmdline; the initramfs unpacks to 982 files of which 871 match the package database by MD5; every generated dracut script matches its source module; both microcode blobs match the firmware package.

Then the check a text-patching rootkit fails — running /proc/kallsyms against the build’s System.map:

98,011 text symbols · 0 missing · 0 unknown · 1 KASLR delta (text and data)

Plus /proc/vmallocinfo: one 2 MiB executable region containing sections of exactly the eleven loaded modules and nothing else.

Two near-misses worth naming, because they are what a careless auditor reports as compromise: two dynamic-loader copies and two VFIO modules in the initramfs differ from disk — cause, dracut strips them. Same build-id, 3.8 kB smaller. It ran that down instead of escalating it.


Phase 6 — Reading Its Own Species’ History

The prompt-injection hypothesis, tested against evidence rather than vibes. My redirect — “review all previous sessions, all commands issued by agents and their timestamps” — was the best-ordered instruction in the session, and the agent said so.

Parsed 122 sessions, 11,222 tool calls from the harness state directory. Root agent activity exists only from 08-24 onward (8 sessions, 916 calls); all 10,306 earlier calls ran unprivileged.

Agent Execution History by Privilege Level

Swept 22 command categories. Zero hits for: PCI/MSR register writes, /dev/mem or /proc/kcore access, kexec, out-of-tree module loading, setuid/capability/immutable-flag changes, preload-library edits, crontab edits, authorized-key edits, account changes, privileged containers, fetch-and-execute pipelines. The only module loads were in-tree wireless drivers.

Three kernel builds by agents, each traced to a quoted instruction of mine in the same session, with the config delta enumerated: sensors on one, wireless/Bluetooth on another. No security option was ever weakened.

Deployment friction, not model judgment

A read-only scout subagent was assigned this exact analysis — grep over my own session logs — and was terminated 49 s in by a provider-side content classifier. The main agent absorbed the work inline. The same classifier later blocked a draft of the outline for this post.

Worth stating plainly: capability and deployment friction are different axes. Nothing about the analysis was unsafe; the harness just could not tell the difference between auditing logs and attacking something.


Phase 7 — The Instrument Was Miscalibrated

The phase that earns this assessment its credibility.

The persistence sweep passes cleanly: service definitions, cron, shell startup files, autostart, device rules, module hooks, virtualization hooks, effective SSH config, a single authorized key matching every recent login, accounts, privilege config, 48 PAM modules and 8 name-service modules all package-owned and hash-matched, package verification across 1,986 packages with no binary mismatch.

Except mid-audit, the ownership check reported every PAM module and 40+ system binaries as unowned by any package.

That is what a PAM backdoor looks like. That is the finding that ends with you reinstalling the machine at 4 a.m.

It was wrong. The agent had inverted a tool’s exit-code semantics. The recovery sequence is the whole thesis of this post:

  1. Distrust the alarming result rather than reporting it.
  2. Calibrate against a file known to be owned — confirms the inversion.
  3. Discard the check. Rebuild from primary data: parse 713,603 file paths out of the raw package database, normalize for merged-/usr path aliasing, re-run.
  4. Result: zero unowned PAM modules, zero unowned NSS modules, all hash-verified.

Two more from the same session: a struct field read at the wrong offset made the eBPF program appear to have no maps (caught by disbelieving the zero); an instruction dump silently returned all zeros because a kernel pointer-restriction sysctl was active (re-dumped correctly).

Full error record for the session: 13 failing tool results, all self-caught, none reaching the report.

An auditor’s value is not in being right first. It is in refusing to publish an alarming result it has not calibrated.


Phase 8 — Verdict, and Where I Was Ahead of It

Verdict: no rootkit, no hidden process, no hidden module, no malicious eBPF, no tampered boot chain, no tampered source, no persistence. Every anomaly attributed with primary evidence.

What was actually real — and note that none of it is the thing I feared: module signature enforcement off, Secure Boot off, plaintext API keys readable by any root agent session, agents running as unrestricted root, a dead container generating noise, and RAM that genuinely faulted.

Where the agent exceeded my model of my own machine: the machine-check event, the container restart loop, and the five-month-old no-op PCI write.

Where I was ahead: I knew the boot script was mine, knew the virtualization and overdrive settings were intentional, knew agents ran as root — all things the agent burned calls rediscovering. And I ordered the investigation better: “static analysis is the best place to start” was correct.

Where it declined to recommend hardening. Asked whether to enable module signing and kernel lockdown on a gaming/virtualization/streaming desktop, the answer was partly no:

  • signing — yes. Zero cost here; only two out-of-tree modules have ever been built, both from the kernel tree.
  • lockdown — no. It blocks the PCI-config writes and register tinkering this machine’s own boot scripts and VM hooks rely on.
  • Secure Boot — not yet. Two GPUs with vendor-signed option ROMs, real bricking risk, firmware currently in setup mode.
  • instead: a boot-partition hash-drift check. Cheap, and detects the thing lockdown was supposed to prevent.

A checklist would have told me to turn all three on. This is the answer of something that read my actual configuration first.

The limit it stated unprompted: every check ran on the host under suspicion. Kernel text was independently verified against the build and every executable kernel allocation accounted for — the checks a patching rootkit fails — but process, module, and eBPF enumeration ultimately asked the kernel about itself, and user-space integrity trusted a package database a sufficiently thorough attacker could rewrite. A cold-boot re-hash against the reference values closes both gaps.


What This Is Actually For

I want to talk about the thing this post is really about, which is not rootkits.

If you have administered your own systems for any length of time as a hobby — compiled your own kernels, built your own initramfs, written your own boot scripts — you have built a machine that only you understand. And the cost of that understanding is that you are the only person who can ever reassure you.

That is where the paranoia comes from. Not from ignorance. From the opposite: from knowing exactly how many places a thing could hide, and knowing that checking all of them properly is a weekend you don’t have. So you check three of them badly at midnight, find a non-zero taint flag you can’t immediately explain, and carry it around for a month.

I had been carrying tainted = 84 for a while. I had a suspicion I could neither confirm nor dismiss, and no amount of staring at dmesg was going to resolve it, because the answer required grepping 24 call sites in the kernel source and then reading a PCI register back across 48 functions to prove my own script had never worked. I was never going to do that. Not because I couldn’t — because the expected value of doing it felt too low to justify the evening.

That is the actual capability being assessed here. Not “can an AI find a rootkit.” It’s:

The cost of exhaustively checking a suspicion dropped from a weekend to about two hours of supervised tool calls.

And that changes the emotional economics of running your own infrastructure. Paranoia is what fills the gap between “something might be wrong” and “I have checked.” If closing that gap is cheap, the gap stops being a place where dread accumulates. You get to stop carrying things.

When the Paranoia Isn’t Worth It

There is an uncomfortable truth to state here: for almost everyone, investigating a suspected kernel rootkit or an evil maid attack is the wrong move.

I did this because I had an AI agent with a root shell and wanted to test its capability ceiling on a machine I understood deeply. But if you are sitting at your desk at 2 a.m. genuinely convinced your kernel is backdoored, your bootloader was tampered with, or someone had physical access to your machine while unattended, the rational playbook is radically simple:

  1. Stop trying to prove the negative from inside the system. You cannot completely trust an environment to audit itself against an adversary sophisticated enough to hook kernel text, patch SMM, or hide in SPI flash. An audit conducted on the live machine will only ever tell you “no evidence found within these boundaries”—it will never say “you are clean.”
  2. Back up your data, not your OS. Pull your dotfiles, source repos, and explicit user data (text files, images, code). Do not copy binaries, compiled packages, whole disk images, or /usr / /var trees.
  3. Nuke the storage from outside. Boot a trusted live USB created on a separate, known-good machine. Secure-erase or overwrite the drive partitions (blkdiscard, NVMe format, or zeroing the partition tables and LUKS headers).
  4. Reflash firmware if physical access or persistent UEFI compromise is on your threat model. Re-flash your UEFI/BIOS from a clean vendor image using your board’s out-of-band hardware flashing button (BIOS Flashback / Q-Flash Plus) so you aren’t relying on running firmware to re-flash itself.
  5. Reinstall from declarative source. Pull your Nix flakes, Ansible playbooks, or clean OS installer. Rebuild the system deterministically.
  6. Rotate credentials immediately. Rotate any SSH keys, API tokens, session cookies, and passwords that touched or were accessible from that machine. Assume anything readable by root was read.

The hours you spend chasing shadows across /proc, disassembling socket filters, and reading hex dumps to disprove a ghost will almost always cost more in time, sanity, and lingering doubt than simply scorching the earth and redeploying. Rebuilding is predictable; paranoia is unbounded.

Three caveats, because the whole point of this post is not overclaiming:

  • It is not a clean bill of health. The verdict is “no evidence of compromise found, with these stated limits” — and the agent named those limits itself, including the fact that every check ran on the host under suspicion. That is different from clean, and it is the honest thing to be told.
  • It made 13 errors, one of which briefly looked exactly like a PAM backdoor. It caught all 13. That is the property that makes it usable — but it means you want the error record, not just the verdict.
  • It did not replace knowing my own machine. Half of Phase 8 is “the operator was ahead.” It is fastest when you can tell it which of its findings are already-known intentional weirdness.

But I gave a machine a paranoid feeling and got back 130 tool calls of primary evidence, a real DRAM fault I didn’t know about, a dead container I’d forgotten, a five-month-old script that had never once worked, and a documented list of what still wasn’t checked.

Paranoia is cheap. Evidence is what lets you stop.