Hardening an OpenWrt Router: SSH, LuCI, RPCD, and the Kernel
Five concrete OpenWrt hardening steps from a real audit — disabling SSH passwords, fixing the LuCI HTTP/TLS setup, decoupling LuCI auth from the system password, and tightening the network stack with sysctl.
I recently audited the OpenWrt box that serves Wi-Fi on my home LAN — a stock 23.05.5 install behind a separate edge gateway. Most defaults are sensible, but a handful of them quietly leave a fresh router much more open than it needs to be. These five fixes are quick, reversible, and close the bulk of the obvious risk on any LAN-attached OpenWrt device.
We will go through them in order of how much explanation each one needs. The first three are short — single-config changes with a clear rationale. The last two (RPCD and sysctl) need more space because there is real meaning behind each individual line.
1. SSH: keys only, no passwords
Stock OpenWrt allows root SSH login with a password, and ships with no authorized_keys file. Anyone who reaches the LAN can brute-force the root password — and on a typical home network, "the LAN" includes any device that joined Wi-Fi, including IoT devices we did not write.
# From our workstation:
ssh-copy-id -i ~/.ssh/id_ed25519.pub root@192.168.0.2
# Then on the router:
uci set dropbear.@dropbear[0].PasswordAuth='off'
uci set dropbear.@dropbear[0].RootPasswordAuth='off'
uci commit dropbear
/etc/init.d/dropbear restartThe fix is to install a key first, verify it works in a second terminal, and only then disable password auth. Locking ourselves out of a router is annoying; locking ourselves out before confirming the key works requires a power cycle and failsafe boot.
2. LuCI: force HTTPS
The default uhttpd config listens on both port 80 and port 443, but does not redirect HTTP to HTTPS. So if we (or our browser) ever land on http://192.168.0.2/, the LuCI session cookie and login credentials travel in the clear across the LAN.
uci set uhttpd.main.redirect_https='1'
uci commit uhttpd
/etc/init.d/uhttpd restartOne UCI flip and port 80 starts 301-redirecting to HTTPS. We can also delete the HTTP listener entirely (uci delete uhttpd.main.listen_http), but keeping the redirect is friendlier — bookmarks and shortcuts still work.
3. TLS: regenerate the factory cert
The shipped certificate has subject C=ZZ, O=OpenWrt..., CN=OpenWrt and expires roughly two years from the firmware build date. Even a self-signed cert is more useful when the CN actually identifies the device, and a longer validity means we are not chasing a renewal.
uci set uhttpd.defaults.commonname='router.lan'
uci commit uhttpd
rm /etc/uhttpd.crt /etc/uhttpd.key
/etc/init.d/uhttpd restartDeleting the existing cert and key triggers px5g (the small key generator OpenWrt ships with) to mint a fresh one with our chosen CN on the next start. The browser will still warn — it is self-signed — but pinning the new cert in the browser is a one-time click. For multiple admins, a small internal CA (step-ca, smallstep, openssl) is the cleaner solution.
4. RPCD: stop reusing the system root password for LuCI
The web UI does not authenticate by itself — it talks to rpcd, which holds the actual login configuration in /etc/config/rpcd. The default looks like this:
config login
option username 'root'
option password '$p$root'
list read '*'
list write '*'Every line here matters, and the second one is the actual problem.
option username 'root' — the login name accepted by both the LuCI form and the ubus RPC layer. There is nothing magical about the value root; it is just a string. We can have multiple config login blocks with different usernames and different ACLs.
option password '$p$root' — this is the part that wires LuCI to the system password. The $p$<name> syntax is special-cased by rpcd: it means "do not store a password here, instead authenticate against /etc/shadow for user <name>." So in the default config, the LuCI login literally is the SSH/system root password. Anyone who recovers one path has the other.
The fix is to replace the $p$root placeholder with an actual hashed password — a normal crypt(3) hash (SHA-512, prefix $6$) — that lives only in /etc/config/rpcd and is not connected to /etc/shadow:
HASH=$(mkpasswd -m sha-512 'a-different-password-than-the-system-root-one')
uci set rpcd.@login[0].password="$HASH"
uci commit rpcd
/etc/init.d/rpcd restartAfter this, SSH and LuCI use independent credentials. An attacker who recovers one cannot pivot to the other for free.
list read '*' and list write '*' — these are the ubus ACLs. The wildcard means this login can call any read-side or write-side ubus method. For a single-admin home setup that is usually fine. If we ever want a second LuCI user (say, a household member who only needs to see Wi-Fi clients but not reconfigure the firewall), we can add a second config login block with reduced ACLs:
config login
option username 'guest-readonly'
option password '$6$...' # different hash
list read 'luci-rpc'
list read 'network.interface'
list write '!*' # explicitly deny all writesThe package rpcd-mod-file and friends provide additional ACL namespaces — ubus -v list enumerates them on a running router.
The single takeaway from this section: the $p$root syntax is convenient for the OpenWrt installer (no second password to set up), but on a router we actually use, it should not stay in place.
5. Sysctl: tighten the network stack
OpenWrt's stock kernel sysctls are conservative on a few things that matter for any router-shaped device — even a "dummy" AP that sits behind another gateway. The hardening file we want is small:
# /etc/sysctl.d/99-harden.conf
net.ipv4.conf.all.rp_filter=1
net.ipv4.conf.default.rp_filter=1
net.ipv4.conf.all.send_redirects=0
net.ipv4.conf.default.send_redirects=0
net.ipv4.conf.all.accept_redirects=0
net.ipv4.conf.all.secure_redirects=0
net.ipv4.conf.all.log_martians=1
net.ipv6.conf.all.accept_redirects=0
net.ipv6.conf.default.accept_redirects=0Apply it with sysctl -p /etc/sysctl.d/99-harden.conf. Going line by line:
rp_filter=1 (Reverse Path Filter, strict mode). When a packet arrives on an interface, the kernel checks: "if I had to send a reply, would it leave through the same interface this packet came in on?" If not, drop the packet. This is anti-spoofing — it stops a host on our LAN from injecting packets with a forged source address that does not belong on that segment. The available values are 0 (no check), 1 (strict, RFC 3704), and 2 (loose, accept if any return path exists at all). Strict mode has zero false positives on a single-LAN device and is the value the kernel docs themselves recommend for this shape of deployment.
send_redirects=0. ICMP redirect messages are how a router tells a client "hey, for that destination you should use a better next-hop instead of me." On a real edge router with multiple uplinks they can be useful; on a single-LAN device they are pure attack surface. A compromised router can use them to silently steer client traffic, and even a healthy router emitting them creates noise that complicates troubleshooting. Turning them off costs nothing.
accept_redirects=0 and secure_redirects=0. The other side of the same protocol. accept_redirects controls whether this host updates its own routing table when something on the network sends it an ICMP redirect. An attacker on the LAN can craft these freely, so accepting them gives them a way to MITM our outbound traffic. secure_redirects (when redirects are accepted at all) restricts which sources we listen to — but with accept_redirects=0 already set, we are belt-and-braces here: an explicit 0 documents the intent and protects us if a future update flips a default.
log_martians=1. A "martian" packet is one whose source address makes no sense for the interface it arrived on — for example, an RFC1918 source address arriving from outside, or a packet claiming to come from one of our own local IPs but arriving from upstream. With rp_filter=1, the kernel already drops them. log_martians=1 adds a syslog line each time, which is the cheapest possible IDS — we suddenly get visibility into spoofing attempts, misconfigured neighbours, or rogue scanners on our LAN. On a normal home network the line count is near zero, so a sudden spike is meaningful.
net.ipv6.conf.all.accept_redirects=0 and the matching default.accept_redirects=0. The IPv6 cousins of the IPv4 setting above, and arguably more important — IPv6 is on by default on most LANs now, IPv6 ICMP carries control messages that influence routing, and link-local address presence is enough to send these. The IPv4 equivalent is already 0 in OpenWrt's defaults; the IPv6 one is 1, which is the asymmetry the audit caught.
Why both .all.* and .default.*? The .all.* namespace applies to every existing interface; .default.* applies to interfaces that come up later. If we only set .all, any interface created after boot — a hotplugged USB tether, a fresh VPN tun0, a new bridge — comes up with the original (less safe) defaults. Setting both prevents the gap.
The whole file is nine lines and applies in milliseconds. There is no behavioral downside on a normal home or homelab router.
Order of operations
If we are doing this on a live router, the safe sequence is:
- SSH key first — install it, verify it works in a second terminal, then disable password auth.
- LuCI redirect + cert — these touch
uhttpd, which restarts cleanly without affecting our SSH session. - RPCD password — generate the hash, set it, log into LuCI in a private window with the new password before closing the SSH session that did the change.
- Sysctl — drop the file in, run
sysctl -p, done. No state to corrupt.
None of these require a reboot. Together they take maybe ten minutes, and they remove the most obvious ways a casual LAN-side attacker can pivot from "I joined Wi-Fi" to "I own the router."
The remaining attack surface — Wi-Fi PSK strength, TLS trust, monitoring — is real and worth its own follow-ups, but those involve choices (PSK rotation breaks devices, internal CA needs distribution). The five above are choice-free wins.