Hardening an OpenWrt Router: SSH, LuCI, RPCD, and the Kernel

Five OpenWrt hardening steps: SSH keys only, forcing HTTPS on LuCI, regenerating the TLS cert, separating LuCI login from the system password, and tightening sysctl.

#Linux#Homelab#Networking

In my homelab I have 3 machines which perform traffic routing: the gateway, dummy ap router, and the repeater ap router. All three are openwrt v.23.05 boxes. So I decided to inspect them in order to see how secure they are. Most defaults looked fine for me. But when I started googling configurations one-by-one, I found some which could be improved. So I grouped those settings into five fixes which are quick, easy to undo, and they remove most of the obvious risk on a LAN-attached OpenWrt device.

I ordered them by how much explanation each one needs. The first three are one-line config changes with a clear reason. RPCD and sysctl come last because each line there has its own meaning.

1. SSH: move from passwords to keys

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. On a typical home network "the LAN" includes any device that joined Wi-Fi, even a cheap IoT plug that nobody checked (these should always be on a separate vlan).

# From our workstation:
ssh-copy-id -i ~/.ssh/id_ed25519.pub root@192.168.X.X

# 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 restart

Install the key first, verify it works in a second terminal, and only then disable password auth. If you disable passwords before you confirm the key works, you lock yourself out.

2. LuCI: move from http to https

The default uhttpd config listens on both port 80 and port 443, but it does not redirect HTTP to HTTPS. If you or your browser ever open http://192.168.X.X/, the LuCI session cookie and login credentials travel in plain text across the LAN.

uci set uhttpd.main.redirect_https='1'
uci commit uhttpd
/etc/init.d/uhttpd restart

With this one setting, port 80 now sends a 301 redirect to HTTPS. You could also remove the HTTP listener completely with uci delete uhttpd.main.listen_http. But keeping the redirect is fine, because old bookmarks and shortcuts still work.

3. TLS: regenerate the factory cert

The shipped certificate has subject C=ZZ, O=OpenWrt..., CN=OpenWrt and expires about two years after the firmware build date. Even a self-signed cert is more useful when the CN actually names the device. Regenerating it also resets the expiry date, so you do not have to deal with a renewal soon.

uci set uhttpd.defaults.commonname='router.lan'
uci commit uhttpd
rm /etc/uhttpd.crt /etc/uhttpd.key
/etc/init.d/uhttpd restart

When you delete the existing cert and key, px5g (the small key generator OpenWrt ships with) creates a new one with your chosen CN on the next start. The browser will still warn you because it is self-signed, but trusting the new cert is a one-time click. If more than one person manages the router, a small internal CA (step-ca, smallstep, plain openssl) should work better.

4. RPCD: stop reusing the system root password for LuCI

The web UI does not handle login by itself. It talks to rpcd, which keeps the login config in /etc/config/rpcd. The default looks like this:

config login
    option username 'root'
    option password '$p$root'
    list read  '*'
    list write '*'

The second line here is what we want to fix.

The username 'root' line is just the login name that both the LuCI form and the ubus RPC layer accept. There is nothing special about the value root; it is only a string. You can have several config login blocks with different usernames and different ACLs.

The password '$p$root' line is what connects LuCI to the system password. rpcd treats the $p$<name> syntax in a special way. It means "do not store a password here, check it against /etc/shadow for user <name>." So in the default config the LuCI login is the same as the SSH/system root password. If you recover one, you have the other.

The fix is to replace the $p$root placeholder with a real hashed password. Use a normal crypt(3) hash (SHA-512, prefix $6$) that lives only in /etc/config/rpcd and has no link 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 restart

After this, ssh and luci will use separate credentials.

The list read '*' and list write '*' lines are the ubus ACLs. The wildcard lets this login call any read or write ubus method, which is fine for a single-admin home setup. If you ever want a second LuCI user, for example a family member who should see Wi-Fi clients but not touch the firewall, add another config login block with smaller 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 writes

The $p$root syntax is handy for the installer, because there is no second password to set up. But when you start using the router after initial setup, you should not leave it in place.

5. Sysctl: tighten the network stack

OpenWrt's stock kernel sysctls leave a few settings loose. These matter on any router-like device, even a "dummy" AP that sits behind another gateway. The hardening file 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=0

Apply it with sysctl -p /etc/sysctl.d/99-harden.conf. Line by line:

rp_filter=1 is the reverse path filter in strict mode. When a packet arrives on an interface, the kernel checks one thing: if it had to send a reply, would the reply leave through the same interface this packet came in on? If not, the kernel drops the packet. This is the anti-spoofing setting. It stops a host on the LAN from sending packets with a fake source address that does not belong on that segment. The values are 0 (no check), 1 (strict, RFC 3704), and 2 (loose, accept if any return path exists at all). On a single-LAN device strict mode has no false positives. It is also what the kernel docs recommend for this kind of setup.

send_redirects=0 turns off outgoing ICMP redirects. A redirect is how a router tells a client "for that destination, use this better next-hop instead of me." On a real edge router with several uplinks they are useful. On a single-LAN device they are useless. If someone breaks into your router, he can use them to redirect client traffic without notice.

accept_redirects=0 and secure_redirects=0 cover the receiving side. accept_redirects controls whether this host updates its own routing table when something on the network sends it an ICMP redirect. secure_redirects limits which sources are trusted when redirects are accepted at all. With accept_redirects=0 already set it is redundant, but the explicit 0 shows the intent and protects you if a future update changes a default.

log_martians=1 logs the strange packets. A martian packet is one whose source address makes no sense for the interface it arrived on. For example, an RFC1918 source coming from outside, or a packet that claims one of your own local IPs but arrives from upstream. With rp_filter=1 the kernel already drops them. log_martians=1 adds a syslog line each time. This is about the cheapest IDS you can run. On a common home network the count stays near zero, so a sudden spike requires investigation.

net.ipv6.conf.all.accept_redirects=0 (with the matching default) is the IPv6 version of the setting above, and it is probably the one that matters most here. IPv6 is on by default on most LANs now, its ICMP carries control messages that affect routing, and a link-local address is enough to send these. The IPv4 version is already 0 in OpenWrt's defaults. The IPv6 one ships as 1. That difference is what I found during the review.

So why set both .all.* and .default.*? The .all.* namespace applies to every interface that exists right now. The .default.* namespace applies to interfaces that come up later. If you set only .all, any interface created after boot (a hotplugged USB tether, a new VPN tun0, a new bridge) comes up with the original, looser defaults. Setting both closes the gap.

That is nine lines, and on a home or homelab router none of them costs you anything.

Do them in that order: keys before disabling passwords, so a typo doesn't lock you out. None of this needs a reboot, and the whole run takes maybe ten minutes. After it, being on the Wi-Fi no longer gets a casual attacker a session on the router.

After this I continued with the next security-enforcing activities like: wi-fi PSK strength, firewall, etc. So you can go forward with router or gateway security as far as you want, but the tweaks which I mentioned above are so easy to implement, that I would strongly recommend them.