§ CM

Image-mode Linux and bootc: the hardening you stop doing, and the hardening you have to start

Image-mode Linux is having its moment. RHEL 10 ships bootc as a supported path, Fedora’s been on ostree for years, and the pitch — your hosts are now versioned container images, signed end-to-end, with atomic rollback — is genuinely good. I like it. I think it’s the right direction for most fleets. But the way it’s being sold to security leadership is sloppy: “immutable infrastructure, so file integrity monitoring is solved.” That is not true, and the gap between that claim and the reality is where attackers will live for the next several years.

The short version: bootc moves the things you used to watch (/usr/bin, /usr/lib, kernel modules) onto a read-only ostree commit backed by composefs, which is genuinely hard to tamper with from userspace at runtime. In exchange, the mutable surface of the host shrinks to /etc, /var, and the container storage layer used by the host itself. That surface is smaller, but it is also where every interesting persistence mechanism on a Linux box already lives. You did not delete the problem. You concentrated it.

What actually changes on disk

On a bootc system the running root is a deployment under /ostree/deploy//deploy//, with /usr bind-mounted read-only and backed by a composefs image whose digest is verified at mount time. A bootc upgrade (or bootc switch to a new image reference) pulls a new container image, composes a new ostree commit from its layers, stages it as the next deployment, and reboots into it. The previous deployment stays on disk as the rollback target. bootc status will show you both.

So from an attacker’s perspective, dropping a binary into /usr/local/bin is not a thing anymore. The mount is read-only, and even if you remount it rw the underlying composefs object store won’t accept writes that don’t match the expected digests. Same for kernel modules under /usr/lib/modules. Same for systemd unit files shipped in the image at /usr/lib/systemd/system.

What is still writable:

  • /etc — fully mutable, with a three-way merge on upgrade between the previous image’s /etc, the new image’s /etc, and your local changes. This is the soft underbelly.
  • /var — fully mutable and persistent across upgrades. /var/lib/containers, /var/log, /var/spool/cron, application data.
  • /run and other tmpfs — fine, but volatile.
  • The host’s container storage (typically /var/lib/containers/storage) — where the next bootc image gets pulled to before staging.

If you spent the last decade tuning AIDE or a vendor FIM agent to watch /usr, that work has mostly been done for you by composefs. If you spent that decade not watching /etc carefully because the noise was unmanageable, congratulations, that’s now your job.

The /etc problem nobody is talking about

The three-way merge is the thing. When a new bootc image lands, anaconda-era logic compares your current /etc against the previous image’s /etc and the new image’s /etc, and tries to preserve your local modifications. The rules are reasonable. They are also exactly the kind of reasonable that an attacker who can write one file before the next upgrade will quietly exploit, because a locally modified /etc/sudoers.d/zzz_local or /etc/ssh/sshd_config.d/99-debug.conf will persist across image updates by design. That’s the feature. It is also the persistence primitive.

The practical consequence: the things you watched before, you have to watch harder.

  • /etc/systemd/system/ and any *.d/ dropin directories under it. Unit files here override anything from /usr/lib/systemd/system and survive upgrades.
  • /etc/sudoers.d/ — single file, three lines, you own the box.
  • /etc/ssh/sshd_config.d/ — same story. The main sshd_config in image-mode RHEL ships with Include /etc/ssh/sshd_config.d/*.conf near the top, so the last file lexically wins on conflicting directives. Drop 00-allow.conf and you’ve overridden every hardening directive in the shipped config.
  • /etc/containers/policy.json and /etc/containers/registries.conf.d/ — if an attacker can rewrite the signature policy to insecureAcceptAnything, the next bootc upgrade will happily pull an unsigned image. This is the supply-chain pivot and it gets approximately zero attention in the vendor docs (it was better in the 2024 RHEL docs than the current ones, oddly).
  • /etc/NetworkManager/dispatcher.d/ and /etc/cron.d/ — old reliables, still mutable, still good for persistence.

The defensive read on this: the set of files that matter for host integrity in an image-mode world is smaller and more enumerable than on a traditional system. Use that. You can write a tight allowlist of “who is allowed to write to which subtree of /etc” in a way you could never do on a mutable-root host where half the package postinstalls touch random things in /etc.

What to detect

The detection that actually pays off here is auditd watches on the mutable persistence surfaces, joined against the expected set of writers. Not file hashing on a schedule — that’s a 2010 control and it misses everything that lives less than a scan interval. Inline syscall auditing.

A workable starting set of audit rules — these are the watches, not the analytic — looks like:

-w /etc/sudoers.d/ -p wa -k priv_esc_persist
-w /etc/ssh/sshd_config.d/ -p wa -k ssh_persist
-w /etc/systemd/system/ -p wa -k svc_persist
-w /etc/containers/policy.json -p wa -k supply_chain
-w /etc/containers/registries.conf.d/ -p wa -k supply_chain
-w /var/spool/cron/ -p wa -k cron_persist

In the SIEM (Splunk if you have it, Elastic if you don’t, and the Elastic side is messier because the auditbeat module reshapes the path field into file.path and drops the audit key into tags — your correlation has to account for that), the analytic is roughly: any type=PATH event with one of those keys, where comm is not in an allowlist of expected writers, and auid is not -1 (kernel/system origin).

Expected writers in the bootc world are a small set: bootc, ostree, rpm-ostree (for migration scenarios), systemd-tmpfiles, systemd-sysusers, cloud-init if you use it, and your config-management agent (ansible-playbook via python3, or whatever you actually run). Anything else writing under /etc/sudoers.d/ is, in practical terms, a finding.

Volume reality: on a host that’s not being upgraded, this ruleset is quiet. Single-digit events per day per host once you exclude the config-management agent. During a bootc upgrade, expect a burst — easily a few hundred events as the three-way merge writes through /etc. The first tuning pass has to suppress the upgrade window cleanly, and the clean way to do that is to correlate against the bootc process tree, not against a time window. Time windows drift, especially on hosts where the upgrade timer fires at slightly different points across the fleet. Suppress on ppid chain containing bootc or ostree, not on “between 0200 and 0230.”

The false positives that will eat your first week:

  • systemd-tmpfiles --create on first boot of a new deployment, touching ownership on a dozen files in /etc. Looks like writes. Allowlist it on comm=systemd-tmpfile with auid=-1.
  • authselect apply-changes rewriting nsswitch and PAM files. Legit. Allowlist on comm=authselect with auid set to the admin who ran it — and that’s the field you actually want in your alert, not the suppression target.
  • Any vulnerability scanner that does a credentialed scan and writes a marker file under /etc. Yes, this still happens in 2026. Yes, you should yell at the vendor. No, they will not fix it. Allowlist by auid mapped to the scanner service account.
  • Config-management runs that touch fifty files at once. Don’t suppress these blindly — bucket them. If ansible writes one file in /etc/sudoers.d/, that’s worth looking at; if it writes forty files across /etc as part of a known playbook run, that’s a normal Tuesday. The shape of the burst is the signal.

The rule of thumb after tuning: a healthy bootc fleet should produce a handful of priv_esc_persist and ssh_persist events per host per week, almost all attributable to a named admin via auid. If you’re seeing zero, your audit pipeline is broken (check whether the auditd→journald→forwarder chain is actually shipping type=PATH and not just type=SYSCALL — a common misconfiguration that silently drops the path field). If you’re seeing hundreds, your allowlist is wrong.

Where the model breaks

A few honest caveats, because the bootc pitch oversells itself.

First, the read-only /usr guarantee assumes the attacker doesn’t have ring-0. A kernel-level compromise can remap anything; composefs verification happens at mount time, not on every read. If your threat model includes a kernel exploit, the immutability story buys you less than the marketing implies. It still raises the cost meaningfully — you cannot drop a binary and expect it to survive reboot without also persisting a kernel-level component — but it is not magic.

Second, the container storage at /var/lib/containers/storage is mutable, and that’s where the next bootc image lives before it becomes the running root. An attacker with root and time to wait for the next upgrade can, in principle, tamper with the staged image before it’s promoted. The mitigation is sigstore-style signature verification on pull, configured in /etc/containers/policy.json — which is exactly why that file is on the watchlist above. If you don’t have signature policy configured to require a trusted signer for your bootc images, image-mode is not buying you what you think it’s buying you.

Third, /var is enormous and still where applications live. Webshells in /var/www, malicious cron jobs landing in /var/spool/cron, tampered logs in /var/log — none of that changed. The container-image story does nothing for any of it. Your existing application-layer controls still matter.

Fourth — and this is the one I think most fleets will get wrong — rollback is a security control and it is also an attacker’s friend. If an attacker can trigger a rollback to a previous deployment that had a known CVE, they’ve just downgraded you. bootc doesn’t ship with strong defaults against this. Pin the minimum acceptable deployment and monitor for unexpected rollback transitions in bootc status output. The detection is straightforward: ship bootc status --json to the SIEM on a cadence and alert on the staged or rollback commit changing without a corresponding change-ticket reference.

Control mapping

For the ATO paperwork side of this, the mapping is reasonably clean:

Control What image-mode gives you
CM-3, CM-5 Atomic, versioned, signed deployments. Change is a new commit, not a package transaction.
CM-6, CM-7 Baseline is the image. Drift from baseline on /usr is structurally prevented; drift on /etc is what you monitor.
SI-7 Composefs digest verification on /usr. This is the strongest part of the story.
AU-2, AU-12 The auditd ruleset above produces the events. Make sure they actually ship.
SC-28 (transit/at-rest of the image) Sigstore policy in /etc/containers/policy.json. If it’s not configured, you cannot claim this.
SR-3, SR-4 The image registry becomes a supply-chain control point. Treat it accordingly — signing keys, registry access, the works.

The one I’d push back on is anyone who claims image-mode gives them SI-4 (monitoring) for free. It does not. It changes what you monitor and makes the watchlist tractable. You still have to write the rules and tune them.

Closing

Bootc is good. The defender’s job in 2026 is not to resist it but to recognize that the work moves rather than disappears. /usr got harder to attack; /etc got more important to watch; the registry and the signature policy got promoted from supply-chain hygiene to load-bearing controls. If your fleet is rolling onto image-mode this year and the security team’s plan is “the FIM tickets will go away,” the security team has not read the docs. The tickets get fewer and sharper. Treat that as a win, and write the detections accordingly.