Skip to content

Compromise check

Paul Asadoorian edited this page Aug 12, 2026 · 4 revisions

Compromise indicators — -M / compromise-check

Has this machine already been compromised?

Every other audit in fettle asks whether something is wrong. This one asks whether something is here. That sounds like a difference of degree and is a difference of kind, because the two have different answers:

ends in
System hardeningis this configured safely? a command to run
Compromise indicatorsis something already here? something to look at, before you run anything

If a finding here is real, the cleanup destroys the evidence. Rebooting loses the process table and every unlinked inode still held open. Deleting the artifact loses its timestamps. Reinstalling the package overwrites what you needed to compare against. So nothing in this action emits a fix — each finding carries an investigation step instead, and anything at High or Critical prints a preservation banner above the results.

fettle -M                    # or: fettle compromise-check — elevates itself
fettle -M --dry-run          # no elevation, so it reports what it could not reach
fettle remote web1 -M        # same, over ssh

You do not need to type sudo. compromise-check is classified read-only and needs-root, so a plain run elevates once up front, the same way sys-audit does. The only invocation that stays unprivileged is --dry-run, which is why that is the one that lists blind spots.

Read-only, opt-in, and included in --everything where it runs last — an update removes a vulnerable package and does not remove an implant.

If you got a finding

Do not clean up yet. In order:

  1. Read the finding's investigation line. It names the one command that tells you whether this is real.
  2. Read the saved report under ~/.fettle/reports/ — the screen table drops the why and the report keeps it in full.
  3. If you want a complete evidence collection before touching anything, UAC's ir_triage profile is built for exactly this. fettle does not run it for you and never will: collection and judgement are different jobs.

And read the benign explanation. Every finding names it alongside the alarming one, because most of the time it is the right one. On the machine this was developed against, all four findings are explicable in a sentence: two vendor agent services, a cron entry timeshift writes itself when you enable snapshots, and a self-updating AppImage.

How to read the output

One coverage line per check group, then one table across all of them, ranked worst first. The coverage lines carry the (487 checked) numbers; the table carries the findings. A group that ran and found nothing still gets its line, because a section that vanishes when it passes is indistinguishable from one that never ran.

  persistence: 2 Medium, 1 Low  (487 checked)
  kernel:      1 Low  (1169 checked)  — plus 2 not checked (see below)
  processes:   1 Low  (2090 checked)
  boot:        nothing to report (14 checked)

  SEVERITY  GROUP        SUBJECT                                   FINDING
  Medium    persistence  etc/cron.d/timeshift-hourly               no package owns this scheduled job
  Medium    persistence  rumble-agent-4b7a89f…9787dae3cf6.service  no package owns this unit
  Low       kernel       kernel taint                              no loaded module explains it

The table drops the why and the investigation step — both are in the saved report under ~/.fettle/reports/, along with the untruncated subject. Anything the run could not look at is listed under Not checked at the end of the run, with the command that would close the gap.

What it checks

Four groups. Turn any of them off with [compromise] disable_checks — see Configuration.

persistence — what starts at boot that no package installed

The June 2026 AUR supply-chain wave persisted with a systemd unit: Restart=always, dropped in /etc/systemd/system, payload under /var/lib. pkg-audit can tell you a package you installed was in that wave. This tells you whether the unit it dropped is still here.

looked at judged on
.service and .timer in /etc/systemd/system, /usr/local/lib/systemd/system, /usr/lib/systemd/system ownership — no package owns it
/etc/cron.d, /etc/cron.{hourly,daily,weekly,monthly}, /etc/crontab, /etc/anacrontab ownership
~/.config/systemd/user/ for every real user where its binary lives
per-user crontabs, at jobs where its binary lives

Why two different criteria. /etc is package-managed, so "nothing owns this" means something there. A user's own crontab and user units are never package-owned — crontab -e creates them by definition — so applying the same test would report every user crontab on every machine. Those are listed as review material in the saved report and become findings only when what they run lives somewhere a scheduled binary does not belong.

/usr/lib/systemd/system is scanned despite being the distribution's own directory. All 480 of its units are package-owned on the reference machine, which makes an unowned one there a file in the distro's unit directory that the distro did not put there.

kernel — is something below userspace lying to us?

  • /etc/ld.so.preload — every library named here is loaded into every process before anything else. A stock Arch, Debian or RHEL system does not have this file, and every LD_PRELOAD rootkit family creates it. Its existence is the finding.
  • Unsigned modules, reported with the enforcement state. "None loaded" means nothing when sig_enforce is off and Secure Boot is disabled — which is most desktops.
  • Kernel taint that no loaded module explains. Taint is sticky and never names its cause, so a set bit records that something was loaded, not that it is still there. Usually a DKMS rebuild; also what a self-removing LKM rootkit leaves. Reported at Low with journalctl -k to settle it.
  • Processes hidden from /proc, found by comparing the directory listing against the PIDs the cgroup hierarchy accounts for. Two independent kernel interfaces — ps, top and pgrep all read /proc, so a getdents64 hook blinds all of them at once and leaves cgroups untouched.
  • The eBPF surface — pinned objects in /sys/fs/bpf and loaded programs via bpftool. The AUR wave's three fixed pin names (hidden_pids, hidden_names, hidden_inodes) are named indicators, so "was I hit by that campaign" is a string comparison rather than an inference.

The eBPF result is weaker evidence than the rest of this report, and it says so. An implant good enough to worry about hooks the bpf() syscall and hides its own programs from bpftool — the tool doing the asking. Catching that needs a memory capture, which is out of scope for a maintenance tool. A clean result here rules out an implant that did not bother, not one that did.

processes — is anything executing the package manager never put here?

  • Executed from memory. A process whose binary came from memfd_create was never a file: nothing to hash, no package that could own it, nothing for pkg-integrity to verify. It is the technique modern Linux malware converged on, and it is reported Critical.
  • Deleted but running, graded by provenance rather than by the deletion: unlinked itself after running from /tmp is High, unowned in an ordinary location is Low, and package-owned is not a finding at all — that is an upgrade replacing a running process, where the useful advice is "restart it to pick up the fix".
  • Listening sockets with no package behind them. A port number means nothing — 4444 is as legitimate as 443 if something you installed is behind it. A listener nobody can account for means something whatever port it is on.
  • Promiscuous interfaces, excluding bridge ports, which are promiscuous by design.
  • Regular files under /dev, where only device nodes belong.

boot — has anything been injected into how this machine starts?

Reads the configuration of whichever bootloader is actually in use — grub, systemd-boot or rEFInd — for an injected LD_PRELOAD (the Bootkitty technique), an init= pointing into a world-writable directory, and blacklisting of an integrity subsystem.

Secure Boot state is reported alongside, always. A clean boot configuration on a machine where nothing verifies the bootloader, kernel or initramfs proves very much less than it appears to. sys-audit is where that posture is judged; this states it as the context the findings need.

What it needs root for, and what it does not

It self-elevates like sys-audit. Run unprivileged it still answers — and says which half it could not reach, naming the directory rather than making a blanket claim.

needs root works as any user
/sys/fs/bpf (drwx-----T) /etc/systemd/system, systemctl show, package ownership
other users' ~/.config/systemd/user/ /etc/cron.d and the run-parts directories
the at spool /proc listing and the cgroup census
/proc/<pid>/fd for other users' processes /proc/modules, kernel taint, /etc/ld.so.preload
per-user crontabs on Debian (0730 root:crontab) bootloader configuration

Unprivileged on the reference machine, 9 of 26 listening sockets resolved — the rest belong to processes whose /proc/<pid>/fd an ordinary user cannot read. That count is reported as blindness rather than quietly producing "no unowned listeners".

bpftool is optional but worth installing: without it only pinned objects are examined, and a program can be loaded and attached without ever being pinned — so its absence is reported too, with the install command for the machine you are on.

sudo pacman -S bpf          # Arch / Manjaro
sudo apt install bpftool    # Debian / Ubuntu
sudo dnf install bpftool    # RHEL family

Note that bpftool needs root to list anything at all — it exits 255 for an ordinary user — so installing it only helps a run that elevates, which a plain fettle -M does.

The package is not named after the binary everywhere. On Arch and Manjaro bpftool ships in bpf (part of the linux-tools group) and there is no package called bpftool in the repos or the AUR; Debian, Ubuntu and the RHEL family each have a bpftool package of their own.

Exit status

0 it looked, and found nothing above Medium
1 a High or Critical finding, or the run examined nothing at all

Severity, not the existence of findings. Every real machine has some — the reference desktop has four and none is worth failing a run over. Exiting non-zero on any finding would make -M red forever and teach people to ignore it. High and Critical are the two bands that also print the preservation banner, so the exit status and the banner agree about what "stop and look at this" means.

Partial blindness — bpftool absent, /sys/fs/bpf unreadable — is reported loudly and does not set the exit status, matching how every other action treats a missing tool. Only a run that examined nothing fails on blindness.

The JSON sibling in ~/.fettle/reports/ records which of the three it was, so automation that needs to tell "found something" from "could not look" can.

Configuration

[compromise]
disable_checks = ["boot"]   # groups: persistence, kernel, processes, boot

Everything is on by default. A name that is not a group is reported, not ignored: believing a rootkit check is off when it is running, and believing it is on when it is not, are equally bad and equally silent.

Kept separate from [hardening] disable_axes on purpose — silencing a chatty hardening axis must not silence a rootkit check as a side effect.

What it deliberately does not do

  • No agent, no daemon, no baseline database. fettle is a tool you run. Package integrity already answers "have these files changed" from the package manager's own hashes.
  • No memory forensics. The right answer for an eBPF rootkit that hides from bpftool, and out of scope here. Named in the output as the escalation.
  • No remediation. Deleting an implant destroys the evidence and usually misses the second stage.
  • No compromise score. A number implies a confidence nobody has.

Related

  • Package supply-chain — where software came from. pkg-audit tells you a package you installed was in the June 2026 AUR wave; this tells you whether the implant is still here.
  • System hardening audit — the other half of the pair, and the one with fixes.
  • System supply-chain — the firmware and boot chain, and where Secure Boot posture is judged.

Clone this wiki locally