This document is the threat model + security-properties inventory for oxwrt. It's written against the code as it stands, not aspirations — every property below cites a source file + line that enforces it. When a property gets weakened or removed, update this doc in the same commit.
- Device identity seeds. The sQUIC signing seed, the WireGuard server key, per-profile VPN client keys. Leakage → an attacker can impersonate the router on the control plane or on a site-to-site WG tunnel; fleet compromise if one device's seeds get copied into another.
- The operator's control-plane access. The authorized-clients list
on the daemon side; the operator's own signing key on the client
side. Leakage → attacker issues arbitrary RPCs (
reset,apply,wg-peer add, etc.). - Configuration secrets. Wi-Fi passphrases, DDNS tokens, PPPoE passwords, WireGuard peer PSKs. Leakage → local radio access, a stolen DNS name, an ISP-session hijack, reduced-secrecy VPN tunnels.
- LAN-client traffic + DNS. The router sees every packet the LAN generates. A compromise upgrades to full-network MITM.
- The boot path. Signed images, atomic sysupgrade, overlay integrity. Compromise → persistent code execution with kernel privileges.
- Unauthenticated WAN attacker. Internet-side scans, DDoS traffic, opportunistic exploits against any port the router exposes.
- Unauthenticated LAN attacker. A guest who joined the untrusted SSID, a compromised IoT device, a drive-by malware-infected laptop.
- Local non-root. A container-escape candidate inside one of the supervised service containers.
- Stolen backup tarball.
oxctl backupoutput ending up on someone else's machine (misaddressed email, lost laptop). - Stolen device. Physical access to a flashed router whose backup has never been taken off-box.
- Careless pasted config. An operator sharing their
oxwrt.tomlin a support thread without realising what's in it.
- Nation-state-level supply-chain attacks. We use upstream crate
dependencies as-is; we don't audit each release of
quinnorrustablesline-by-line. - Hardware/firmware tampering. BootROM attacks, JTAG pickup, cold-boot RAM dumps. No secure boot chain (yet).
- Side-channel attacks. Timing, power analysis, spectre-class CPU issues.
- DoS against the data plane. Saturating the WAN uplink is trivially possible; out-of-scope for the firmware to prevent.
WAN ──[1]── firewall ──[2]── router userland ──[3]── containers
│
└──[4]── LAN clients
- [1] WAN edge. nftables
INPUTchain: default DROP, accepts onlyct state established+ the WAN DHCPv4 unicast OFFER predicate (net.rs:646— the narrow bypass; seeinstall_firewall's WAN-INPUT section). - [2] Userland split. oxwrtd runs as PID 1 with full capabilities; everything else runs as a supervised container.
- [3] Container boundary. Per-service seccomp + capability
drop + landlock + mount/pid/net/ipc namespaces. See
crates/oxwrt-linux/src/container.rs—SECCOMP_DENY_LIST(~20 syscalls:clone,execve,ioctl,keyctl,reboot, module loading, …),RulesetCreated(landlock, rootfs + declared bind-sources writable only), cap bounding-set drop, optional user-namespace mode that maps root→nobody. - [4] LAN boundary. Zone-based nftables forward rules; each
zone declares its default policy + allowed flows. Guest/IoT
zones default to
dropfor new forward flows.
/etc/oxwrt/oxwrt.toml contains no secrets after the split
migration (crates/oxwrt-api/src/secrets.rs). Every credential
listed in SECRET_FIELDS is moved to oxwrt.secrets.toml
(mode 0600) or referenced by path. oxctl dump-config replaces
each secret leaf with <redacted>. → a pasted oxwrt.toml does not
leak Wi-Fi / DDNS / PPPoE / peer-PSK material.
key.ed25519 (sQUIC), wg0.key (WG server), vpn/*.key (VPN
client), dhcp6-duid (DHCPv6 identifier) are files under
/etc/oxwrt/, referenced by path, never inlined in TOML. The
restore path sanity-checks modes on unpack (backup.rs:234-240).
→ a restored backup from a different device doesn't silently
replace your router's identity with theirs.
sQUIC's handshake rejects clients whose key isn't in
control.authorized_keys (legacy file) ∪ control.clients
(inline). Merge + dedupe happens in
load_merged_authorized_keys (control/server/mod.rs:876).
Confirm gates (reset, reboot, apply, restore,
rollback) force confirm = true on destructive ops.
install_firewall creates the inet oxwrt table with INPUT +
FORWARD policy = DROP. Baseline defaults emitted unconditionally
(no operator rule required):
ct state established,related accepton INPUT/FORWARD/OUTPUTct state invalid dropon INPUT/FORWARD- ICMPv6 NDP (
nd-neighbor-*,nd-router-*,nd-redirect) accept on all chains — IPv6 breaks without it - ICMPv6 MLD (
mld-listener-*,mld2-*) accept on all chains - ICMPv6
packet-too-bigaccept on all chains — PMTU-D - ICMP + ICMPv6
echo-requestaccept on INPUT (router answers pings). Operators who want to drop WAN pings add a higher- prioritysrc = "wan"drop rule ahead.
Operator rules land AFTER the baseline in chain order (nft
first-match-wins), so they can ADD further accepts but can't
accidentally delete the safe-by-default set. Control plane
listens on loopback + LAN, never 0.0.0.0 in the shipped
default or the wizard output.
Every [[services]] entry spawns under: (a) caps-drop to the
declared allowlist, (b) seccomp with the project deny list, (c)
landlock-restricted FS view (rootfs + declared binds only), (d)
fresh mount/uts/ipc/(pid) namespaces. net_mode = "isolated"
adds NEWNET. User-namespace mode available per service for
further privilege separation.
Boot-time guard (init/run.rs::tighten_secrets_file_mode) chmods
oxwrt.secrets.toml to 0600 on every boot if it isn't already.
Every write path (atomic_write_config, wifi_rotate,
migrate_public_to_split) sets 0o600 on the rename-target. Boot
also sanitises a hand-edit that dropped the mode.
handle_reload_async captures reconcile errors, restores the
last-good snapshot, re-reconciles, and returns a combined error
(reload.rs). One-shot, non-recursive — if the restore itself
fails, we stop and surface a "needs UART" message. Combined
with reload --dry-run, operators can validate a change
before committing.
Backup tarball deliberately skips /etc/oxwrt/vpn/ (provider
keys — re-obtainable from Mullvad / Proton, cheap to replace,
dangerous if a stolen backup leaks them). backup_sftp.include_ secrets (default true) gates whether oxwrt.secrets.toml rides
along to the remote — set false for off-router backups that
compliance requires to be credential-free.
These are real gaps in the current implementation. Each is tracked as either "intentional v1 scope" or "genuine TODO."
FwUpdate verifies sha256(image) against a hash the client
sent (sysupgrade.rs) AND, when
/etc/oxwrt/release-pubkey.ed25519 is present on the router,
requires the client to supply an ed25519 signature over the
hash (handle_fw_update + verify_release_signature). Images
built with a signing key baked in refuse unsigned update pushes;
a compromise of one operator's control-plane pubkey no longer
lets them install arbitrary code — they'd also need the offline
release-signing key.
Clients sign with oxctl --sign <image>, keyed off
$OXWRT_SIGNING_KEY_PATH (or $OXWRT_SIGNING_KEY hex).
Dev-mode images without a baked pubkey fall through to SHA-only
with a warning log, so self-built flows keep working.
sQUIC's accept loop gates on a tokio::sync::Semaphore sized to
cfg.control.max_connections (default 32). Surplus connections
are refused immediately — a WAN scan can't exhaust the
per-connection task state.
Inside an accepted connection, each RPC pulls a token from a
per-connection bucket (capacity = 2× max_rpcs_per_sec, refills
at max_rpcs_per_sec tokens/sec; default rate 20). An authenticated
client trying to hammer its held connection gets backpressured —
acquire sleeps until a token refills rather than failing the
request, so a legitimate caller sees latency instead of an error.
See RateBucket in control/server/mod.rs.
Mitigation stack: tight [[control.clients]] ACL, short-lived
connections (CLI opens one per RPC), connection cap bounds
concurrent clients, per-connection bucket bounds their
in-connection throughput.
TODO (deferred). Per-pubkey (not just per-connection) rate
limit. Requires upstream sQUIC to surface the peer's ed25519
pubkey on the accepted quinn::Connection; today the pubkey is
consumed by the MAC-layer whitelist check and not propagated.
Without it, a peer could reconnect to refresh its bucket — but
they'd pay the full handshake cost each time and still sit under
max_connections, so the ceiling holds in aggregate.
reqwest::Client::builder() default verifier, no pinning. A CA
compromise (or a locally-injected root) → man-in-the-middle on
DDNS push / blocklist refresh. Both endpoints have negligible
blast radius (DDNS pushes from the router, blocklists are
already public data) — flagged for completeness, not urgent.
If the fetch fails, an empty set is installed with a warning
(blocklists.rs). Intentional — otherwise a flaky CDN prevents
boot. An attacker who can DoS a blocklist URL can thereby drop
the filtering. Accepted tradeoff.
Nothing cryptographically verifies that /sbin/init is the
oxwrtd we shipped. Compromising the overlay (via a bug in our
code, or physical access) → persistent root. Hardware-level
secure boot would address this; out of scope v1.
SecurityProfile.pid_namespace: bool (default false for v0
compatibility, recommended true for any service retaining
CAP_KILL or otherwise exposed to untrusted input). When set,
the spawn routes through the clone3 path with CLONE_NEWPID
(NEWUSER only when user_namespace = true is also set). The
service is PID 1 inside its own namespace and cannot see or
signal any host process.
Previously, only user_namespace = true triggered the clone3
path, and the two flags were coupled. That left services with
user_namespace = false (all shipped services today) sharing
the host PID namespace, which matters for any service retaining
CAP_KILL: a compromised dropbear with KILL could SIGKILL
oxwrtd (PID 1 on the host) or any sibling. Decoupling
pid_namespace means operators can get process isolation without
paying the rootless-uid mapping overhead.
Verified on-device (2026-04-20 audit):
- Without pid_namespace: shell PID 1202, 108 /proc PIDs visible,
kill -0 <sibling-pid>succeeds against every service. - With pid_namespace: shell PID 3, 5 /proc PIDs visible,
/proc/1/comm = "dropbear", every host service PID returns "No such process" onkill -0.
The debug-ssh example block ships with pid_namespace = true
set (alongside the CAP_KILL drop). Any service author adding
a new supervised binary should consider setting it on anything
that handles untrusted input (new SSH surface, new web UI,
new uPNP/SSDP endpoint) — the cost is one extra clone3 syscall
at spawn time.
Four supervised services run in the host netns because their jobs require raw sockets on real interfaces and can't be run in an isolated veth:
dhcp(coredhcp) — AF_PACKET raw socket for DHCPv4 frames on br-lan.hostapd-5g/hostapd-2g— 802.11 frame TX/RX via nl80211.corerad— ICMPv6 RA TX + rtnetlink subscription on br-lan.
Each retains NET_RAW + NET_ADMIN plus the default
SETUID / SETGID / SETPCAP / NET_BIND_SERVICE. NET_RAW +
NET_ADMIN together are close to unrestricted on the netns:
a compromised service can inject arbitrary Ethernet / Wi-Fi /
ICMPv6 frames, poison ARP, redirect default gateways (via RA),
and DoS the LAN.
Mitigation today: seccomp + landlock + no_new_privs + mount/uts/ ipc namespaces still apply, so a compromise is bounded to network-layer attacks (no filesystem writes outside declared binds, no privileged syscalls, no arbitrary execve). Consequence: isolate the router's LAN segment from high-value clients, or accept that a 0-day in any of these four services → LAN-side MITM capability.
Moving these services to isolated netns is blocked by the
hardware requirement (one bridge, one set of Wi-Fi phys, one
rtnetlink namespace that matters), not by design choice. The
dns and ntp services demonstrate the isolated-netns pattern
where it's feasible.
The zone/rule abstraction now matches fw4 feature-for-feature on everything a home-router or small-fleet operator routinely touches. Two deliberate non-goals remain:
- Includes. fw4 sources external rule files via
config include. oxwrt's config is single-file by design (plus the secrets overlay); the raw_nft escape hatch covers the "insert arbitrary rules" use case. - Hardware flow offloading. fw4 exposes
flow_offloadingflow_offloading_hwtoggles. Not safe to enable by default (benchmarking needed per target); omitted until a dedicated perf pass. Operators who need it on a specific iface can install the rule via[[firewall.raw_nft]].
Everything else from the mainstream fw4 surface is implemented:
zone default_output, zone mtu_fix (TCP MSS clamping), rule
src_ip/dest_ip/src_mac/src_port/icmp_type/family/
limit/log/enabled/counter/limit_burst/reject_with/
device, rule helper (CT helpers — FTP, SIP, TFTP, PPTP,
H.323, IRC, RTSP; rendered as ct helper objects in inet oxwrt +
companion rules in a priority-raw prerouting chain; kernel
modules shipped in every image), rule-level QoS mangle
(set_mark for netfilter fwmark, set_dscp for DiffServ code
point — nft class names cs0..cs7 / af11..af43 / ef / be / va /
le or a raw 0..63 integer; both emitted into a dedicated
priority-mangle chain, dual-family rules auto-emit one rule
per family gated by meta nfproto), rule notrack
(conntrack bypass for high-throughput flows — companion rule in
a priority-(-301) prerouting chain that emits nft's notrack
before the filter hook runs; validator rejects notrack+helper as
incompatible since notrack disables the conntrack the helper
needs to attach to), absolute-date schedules (from YYYY-MM-DD / until YYYY-MM-DD prefixes on the existing
schedule field; combines with the day/hour recurring spec for
holiday windows, rendered as nft meta time >= / <=
comparisons), port-forward reflection, IPv6 MASQUERADE66,
IPv6 port-forwards via bracketed [ipv6]:port syntax
(installed into a dedicated oxwrt-dnat6 nftables table with
reflection + hairpin SNAT in oxwrt-nat6), declarative
ipsets ([[ipsets]] at top level + match_set = { name, direction, negate } on rules — sets live in the inet oxwrt
table, CIDR entries auto-enable flags interval, per-element
timeout supported), port ranges via "START-END" string
syntax on dest_port/src_port, proto-only rules (TCP/UDP
accept/drop without a port match), declarative forwardings
([[firewall.forwardings]] { src, dest, family } as a
zone-to-zone accept shortcut), and global defaults
([firewall.defaults] with synflood_protect and
drop_invalid — both default-on, matching fw4).
The Wi-Fi passphrase field accepts anything 8+ characters
(RFC2898 floor). No entropy check, no dictionary rejection, no
"insecure password detected" warning. wifi_rotate generates
96-bit random passphrases, so operators who enable rotation
sidestep this.
If a router is stolen, the attacker has the sQUIC server key, WG server key, Wi-Fi passphrases, every control-plane client pubkey, every VPN client private key. The operator's only recovery is: rotate every credential, add the attacker's keys to a revocation list that doesn't exist yet, re-enrol every client. No remote-wipe, no per-boot attestation, no TPM-sealed secrets.
Mitigation today: physically secure the router; take and store
a backup so re-provisioning a replacement is a single
oxctl restore instead of a full rebuild.
secrets::tests::split_moves_wifi_passphrase+ siblings: assert every SECRET_FIELDS leaf is moved off the public TOML on split. Catches a new secret-bearing field being silently left inline.rollback::tests::restore_clears_live_secrets_when_snapshot_ has_none: guards against a rollback resurrecting credentials the operator had deliberately stopped shipping.wan_dhcp::tests::empty_strings_suppress_emission: zero- length DHCP options (hostname, vendor-class-id) aren't emitted — some DHCP servers misbehave on them.net::tests::parse_mac_rejects_multicast: multicast MAC on WAN is rejected at parse time (DHCP DISCOVERs would be dropped silently otherwise).
This is a personal-hobby-scale project. If you find something
credible, open a GitHub issue marked [security] with as much
context as you can share publicly, or email the repo owner
directly if coordinated disclosure matters. I'll respond within
a few days — sometimes same-day, never longer than two weeks.
Please don't run active exploitation against hardware you don't own. I can't pay bounties but I'll credit you in the fix commit
- release notes if you want.