Threat model.
fail2zig runs as root, parses attacker-controlled input, and writes kernel firewall state. The integrity of the tool matters more than its features. This document enumerates the adversaries it is designed to defend against, the trust boundaries it defines, and the threats under each STRIDE category — with mitigations, residual risk, and explicit out-of-scope items.
What this model takes as given.
The kernel is trusted.
fail2zig depends on the correctness of the Linux kernel's netfilter, inotify, epoll,
and sd_journal
ABIs. A kernel-level compromise is outside the scope of anything fail2zig can prevent or
detect.
The filesystem root is integrity-protected.
Package signing, dm-verity, secure boot, or equivalent keep the fail2zig binary and its configuration from being silently replaced by an attacker without root. We do not re-verify the binary at startup.
Log files reflect service reality.
If the service being protected (sshd, nginx, postfix) writes a line stating an
authentication failure from
203.0.113.5, fail2zig treats that source IP as authoritative. A
compromised service can forge arbitrary log content; fail2zig cannot distinguish that
from reality.
Operators configure the daemon honestly.
Operators who have legitimate root on the host can trivially disable or misconfigure fail2zig. The threat model covers attacker access, not operator action.
Data flow and trust boundaries.
Every arrow is a point where adversary-controlled bytes can influence the system. Every dashed line is a trust boundary — a place where the authority of the caller changes.
flowchart LR
A(("Attacker")) -->|"crafted requests / floods"| S["Service<br/>sshd · nginx · postfix"]
S -->|"writes"| L[("Log file")]
L -->|"inotify · sd_journal"| W["Log watcher"]
W --> P["Parser<br/>(comptime-generated)"]
P --> ST["State tracker<br/>(fixed arena)"]
ST --> FB["Firewall backend"]
FB -->|"netlink"| K["Kernel · nftables"]
CLI["fail2zig-client"] -.->|"SO_PEERCRED"| IPC["IPC socket<br/>AF_UNIX 0660"]
IPC --> D["Daemon control plane"]
D --> M["Metrics :9100<br/>(localhost default)"]
CF[("Config file<br/>0644 root:root")] --> D
Trust boundaries
| ID | Boundary | Authority change |
|---|---|---|
| TB-01 | Attacker → service | Untrusted network bytes become authenticated-or-not requests the service records. |
| TB-02 | Service → log file | Service's interpretation of attacker behavior becomes on-disk log content. |
| TB-03 | Log file → fail2zig | Attacker-influenced bytes enter fail2zig's address space. |
| TB-04 | fail2zig → kernel | Userspace daemon with CAP_NET_ADMIN writes firewall state. |
| TB-05 | Client → daemon (IPC) | Local UID/GID crosses into the daemon's control plane. |
| TB-06 | Scraper → metrics endpoint | Local HTTP caller reads operational state (bind-restricted). |
Who is attacking, and how.
Remote network attacker
Can send arbitrary packets to services on the host. Can craft request content to produce chosen log lines. Cannot read the host's filesystem or memory. Cannot execute code on the host. The primary adversary this product is built against.
Log-injection adversary
A subclass of AD-01 who can influence the text of log lines via fields the protected
service faithfully echoes — HTTP User-Agent, SMTP EHLO
hostname, SSH client-version strings. May attempt to inject crafted payloads that span fields
or confuse the parser.
Local unprivileged user
Has shell access as a non-root, non-fail2zig
-group user. Cannot read root-owned files. Cannot write kernel state. May try to connect
to the IPC socket or metrics endpoint, attempt to enumerate banned IPs, or cause local DoS.
Compromised protected service
An attacker who has gained code execution inside the service fail2zig is monitoring (e.g. RCE in a web application behind nginx). Can write arbitrary log content. May attempt to use fail2zig to ban legitimate IPs as a lateral-movement prerequisite.
Threats by category.
For each category: threat, affected trust boundary, mitigation, residual risk, status. Status is Mitigated (addressed in v0.1.0), Partial (mitigation improving in a later phase), or Accepted (an intentional trade-off documented here).
Spoofing
| ID | Threat | TB | Mitigation | Status |
|---|---|---|---|---|
| S-01 | Local user spoofs an authorized IPC client. | TB-05 |
IPC socket is mode 0660, owned root:fail2zig. The daemon
authenticates every connection via
SO_PEERCRED and rejects peers outside
uid=0 or the fail2zig group.
| Mitigated |
| S-02 | Network attacker spoofs ignore-listed source IP via X-Forwarded-For or proxy-supplied headers. | TB-02 | fail2zig operates on the source IP the service records. If the service trusts attacker-supplied proxy headers, the attacker can appear as an ignore-listed IP — operators must configure the service to record the real source address. Filter design uses structural positions, not header substrings. | Accepted |
Tampering
| ID | Threat | TB | Mitigation | Status |
|---|---|---|---|---|
| T-01 | Attacker tampers with the fail2zig config file to disable jails. | A.02 |
Config is mode 0644, owned root:root. Daemon refuses to
start if the config file is world-writable. Validation runs before any firewall or
event-loop state change.
| Mitigated |
| T-02 | Attacker modifies the persisted state file to inject fake bans or erase real ones. | A.02 |
State file is written atomically (write-temp + fsync + rename), mode 0600, owned root:root. Every load verifies a CRC32 checksum; a failed
check discards the file and starts with empty state.
| Mitigated |
| T-03 | Compromised service writes crafted log content to influence fail2zig decisions. | TB-02 | A compromised service can forge any log content. This is covered by A.03 — fail2zig cannot distinguish a forged log from a real one. Operators should treat a compromised service as a precondition to investigate host integrity, not a fail2zig failure mode. | Accepted |
Repudiation
| ID | Threat | TB | Mitigation | Status |
|---|---|---|---|---|
| R-01 | Operator cannot reconstruct which IP was banned, when, by which jail, for how long. | TB-05 |
Every ban/unban fires a structured journald record with timestamp, source IP, jail
name, triggering log line pointer, duration, and reason. Metrics include per-jail
ban counters. The IPC
list command surfaces current state. Phase 2 adds a cryptographically chained
audit log for tamper-evident retention.
| Partial · ph.2 |
Information disclosure
| ID | Threat | TB | Mitigation | Status |
|---|---|---|---|---|
| I-01 | Metrics endpoint leaks jail names, ban rates, and memory use to a remote scraper. | TB-06 | metrics_bind defaults to
127.0.0.1. The endpoint is read-only. If operators expose it remotely,
they are responsible for firewall restrictions in front of it.
| Mitigated |
| I-02 | Local unprivileged user enumerates banned IPs via the IPC socket. | TB-05 |
Covered by S-01: only root or members of the
fail2zig group can connect. Membership in that group is effectively read/write
access to the daemon and should be treated as a privileged role.
| Mitigated |
| I-03 | Diagnostic logs include banned IPs or log excerpts visible to non-root log readers. | TB-03 |
Operational logs go to journald under the daemon's unit. Access follows journald's
access model (per-user journal, systemd-journal group). fail2zig redacts log content
in info-level output; full per-line decisions are only logged at debug.
| Mitigated |
Denial of service
| ID | Threat | TB | Mitigation | Status |
|---|---|---|---|---|
| D-01 | Attacker floods logs to exhaust daemon memory. | TB-03 |
Fixed-size arena enforced at allocator level. Per-IP state table is pre-allocated;
eviction policy is operator-selected (evict-oldest / deny-new / log-and-drop). The
daemon cannot exceed
memory_ceiling_mb regardless of input volume.
| Mitigated |
| D-02 | Attacker triggers pathological parser behavior via crafted log lines. | TB-03 | Parsers are comptime-generated specialized functions — no runtime regex engine with backtracking. Line-length hard cap (4 KB default). Each parse step runs in bounded time. Continuous fuzz coverage (AFL++, libFuzzer) against a 4.1M-line corpus. | Mitigated |
| D-03 | IPC socket flooding from an authorized local client. | TB-05 | Currently fail2zig accepts connections with a bounded backlog but does not per-client rate-limit. A malicious authorized client can degrade control-plane responsiveness but cannot affect the log-parse or ban-enforcement path. Per-client rate-limiting is scheduled for a future release. | Partial · ph.2 |
| D-04 | Metrics endpoint slowloris / malformed HTTP consumes the HTTP listener. | TB-06 | Request timeout is 5 s per read; header size cap is 8 KB; concurrent request cap is 16. The listener lives on its own bounded task and cannot block the event loop. | Mitigated |
| D-05 | nftables scaffold installation fails at startup; daemon pretends to ban but firewall is untouched. | TB-04 | Fail-closed: if nftables install fails, backend selection falls through (iptables → ipset → log-only), and if none succeed the daemon exits with a diagnostic rather than run with a silent-drop ban path. | Mitigated |
Elevation of privilege
| ID | Threat | TB | Mitigation | Status |
|---|---|---|---|---|
| E-01 | Attacker exploits a parser bug to execute code as root. | TB-03 |
Zig's bounds-checked build mode is retained for
ReleaseSafe on every attacker-reachable path. No runtime regex; no dynamic
code; no deserialisation of untrusted binary. Continuous fuzzing. Seccomp-BPF (Phase 2)
restricts the daemon syscall surface to its necessary set.
| Partial · ph.2 |
| E-02 | Log injection causes ban of an arbitrary legitimate IP (weaponised DoS via fail2zig). | TB-02 |
Filters match on structural positions in well-known log formats — the "source IP"
field, not arbitrary substrings. Byte-class restrictions prevent crafted payloads
from spanning fields. ignoreip
CIDRs always win. Services that echo attacker-controlled text into log fields used as
source-IP anchors are a service-configuration bug, not a fail2zig bug.
| Mitigated |
| E-03 | Unauthorized local user forces unban of an attacker IP via IPC. | TB-05 |
IPC requires SO_PEERCRED root or
fail2zig-group membership — the same authorization required to ban.
Members of the
fail2zig group are trusted administrators by definition.
| Mitigated |
| E-04 | Supply-chain compromise injects hostile code into the fail2zig binary. | A.02 | Zero runtime dependencies in the critical path. Reproducible builds: same source + same Zig version + same target triple = byte-identical binary. Releases are signed with minisign; cosign + SBOM publish alongside each release. Operators can rebuild from source and compare checksums. | Mitigated |
What this model explicitly does not cover.
Scope discipline is part of a threat model's integrity. The items below are real threats; they are just not threats fail2zig is architecturally positioned to address. Treat them as responsibilities that live elsewhere in the stack.
- Kernel-level exploits. fail2zig trusts the Linux kernel's netfilter, inotify, and epoll ABIs. A privilege-escalation bug inside the kernel is outside fail2zig's addressable surface — report those to the kernel security team.
- Physical access to the host. An attacker with physical access to the server can read or modify the binary, the config, and the state file regardless of fail2zig's in-process defenses.
- Compromise of the protected service itself.
If
sshdis compromised, fail2zig cannot undo that compromise — it can only act on the logssshdproduces. Service hardening is upstream of fail2zig. - Layer-3/4 volumetric attacks. SYN floods, amplification attacks, and packet-rate exhaustion require network-layer defenses (kernel-level filters, upstream providers, anycast). fail2zig's concern starts at the application log, not the packet queue.
- WAF-scope concerns. Parameter tampering, SQL injection in request bodies, CSRF, XSS — these require request-body inspection which fail2zig does not perform.
- Side-channel attacks on the host. Timing, cache, and microarchitectural side channels against the daemon are not in the threat model. The sensitive data the daemon holds (banned-IP sets, ban counts) is either already observable via firewall enumeration or operationally non-sensitive.
- Toolchain compromise. A compromised Zig compiler or musl libc can inject code into any binary built with them, fail2zig included. Reproducible builds from a trusted toolchain are an operator responsibility.
When things go wrong, what happens.
nftables install fails at startup
Backend selection falls through to iptables → ipset → log-only in that order. If none succeed and log-only is not configured, the daemon exits with a diagnostic. It never runs pretending to ban.
State file is corrupted
CRC32 check fails; state file is discarded; daemon starts with empty state. A warning is logged. Active bans are reconciled against the firewall backend as new events arrive.
Config file is world-writable
Daemon refuses to start. Emits a clear error explaining why. No partial load, no fallback to defaults.
Memory ceiling reached
Eviction policy fires per operator config. The daemon does not allocate past the ceiling; it does not resize; the OOM reaper does not get a chance to kill it.
Unrecognized configuration key
Rejected at parse time with line + column. A typo in
bantime is a startup failure, not a silent default-to-zero.
netlink ACK returns an error
Errors are surfaced as typed Zig errors with specific errnos. The operation is logged as failed; the daemon does not lose track of state or silently proceed as if the operation succeeded.
What has been reviewed, by whom, when.
4.1M-line corpus across sshd, nginx, postfix, dovecot, proftpd filters. Zero crashes, zero hangs, zero out-of-bounds reads.
Same source + same Zig version + same target triple produces a byte-identical binary across Debian 12, Alpine 3.19, and NixOS 24.05. SBOM published.
Scope: parser engine, allocator boundaries, privilege model, firewall backends, IPC authorization.
Zero CVEs to date. Published advisories will live at
github.com/ul0gic/fail2zig/security/advisories.
Reporting a vulnerability.
Vulnerabilities are reported via GitHub's private security advisory form. Do not open a public issue for a security vulnerability — private advisories keep the fix window closed until a patch is ready.
What to expect
- Acknowledgement within 48 hours.
- Initial assessment (severity, affected versions, fix path) — best-effort within 14 days.
- Fix before disclosure for high-severity issues, or a coordinated 90-day disclosure window.
- Credit in the advisory and release notes, unless you request otherwise.
- No bug bounty. No legal action against good-faith reporters within scope.