Network sysctls: measure, then change
Forwarding, redirects, rp_filter and SYN cookies.
A few dozen kernel switches decide how a host treats packets it did not ask for: whether it forwards traffic between interfaces, whether it obeys a router that tells it to change path, and whether it accepts a packet whose source address could not have arrived where it did. Hardening guides list many of them, but most are already the default. In this lesson you read what Ubuntu 26.04 and RHEL 10 ship, test the few keys that differ on a lab network of network namespaces, and write one file that changes only those keys, with a reason for each and a rollback that restores the old values.
How sysctl settings are stored and applied
Every tunable lives as a file under /proc/sys: net.ipv4.conf.all.rp_filter is the file /proc/sys/net/ipv4/conf/all/rp_filter. sysctl reads and writes these with dotted names (sysctl -w key=value), and a value written that way is gone at the next boot. Persistent values live in files that systemd-sysctl.service applies at boot.
Four directories hold them: /etc/sysctl.d/ (yours), /run/sysctl.d/, /usr/local/lib/sysctl.d/ and /usr/lib/sysctl.d/ (the distribution's). All their .conf files are sorted together by name, and for a key set twice the file that sorts last wins. A file in /etc with the same name as one in /usr/lib replaces it entirely. sysctl.d(5) recommends names from 60 to 90 for your own files. Ubuntu 26.04 has no /etc/sysctl.conf at all; its settings are in /usr/lib/sysctl.d.
99-cloudimg-ipv6.conf comes from the cloud image, and 99-lima.conf is added by Lima, the tool that runs this lab VM; neither is on an installed Ubuntu Server. Two format rules catch people out. A comment must be a whole line starting with # or ;; text after a value becomes part of the value. And a key may contain a glob (net.ipv4.conf.*.rp_filter) that writes every matching interface, with -key excluding one key from the glob. grep shows who sets the network keys this lesson cares about:
50-default.conf is systemd's own file: it sets rp_filter = 2 on every interface through the glob, excludes all, and turns off source routing. Ubuntu's 55-network-security.conf then sets all and default to 2 as well. Nothing sets redirects, martian logging, forwarding or SYN cookies, so those keys run at the kernel's built-in values.
Measure the defaults on both platforms
Per-interface network keys come in three flavours. net.ipv4.conf.eth0.X is the value for one interface. net.ipv4.conf.default.X is the value a new interface gets when it appears. net.ipv4.conf.all.X is combined with each interface's own value by a rule that depends on the key. (eth0 is this VM's network card; physical servers usually have names such as enp1s0.)
rp_filter: the higher of the two values wins. accept_redirects (on a host that does not forward), send_redirects and log_martians: on when either value is on. accept_source_route: on only when both are on. So a single line in all can switch on martian logging or switch off source routing, but it cannot switch off redirects or make one interface strict. The experiments below show the first two rules at work.On Ubuntu the host accepts ICMP redirects (accept_redirects = 1) and would send them, logs nothing about impossible source addresses (log_martians = 0), and runs reverse-path filtering in loose mode (2). shared_media = 1 is the kernel default too; it matters for redirects below. Forwarding is off, source routing is refused, SYN cookies are on, and broadcast pings and bogus ICMP errors are ignored. RHEL 10 ships the same values except for reverse-path filtering, where its 50-redhat.conf sets default and every interface to 1 (strict) and leaves all at the kernel default of 0.
The keys worth changing are the redirect keys and log_martians on both platforms, and rp_filter on Ubuntu. The rest are defaults you can pin, so a later change (a package, a container runtime, a colleague's quick fix) is put back at boot and shows up as drift in a scan. The list of keys follows the CIS Benchmark (the CIS RHEL 10 Level 1 profile in scap-security-guide 0.1.82 checks every key in this lesson); the split into "change" and "pin" is SecOpsLog advice.
Spoofed sources: rp_filter and martians
Nothing in a packet proves its source address. Reverse-path filtering checks it against the route the kernel would use to reply. In strict mode (1) the packet is dropped unless that reply would leave by the interface the packet came in on. In loose mode (2) it is dropped only if the source is unreachable through any interface. Loose mode stops little on a host with a default route, because almost every address is reachable somehow. A "martian" is a packet with an impossible source address, and log_martians = 1 writes one to the kernel log when it is dropped.
The lab tests this on a small network of network namespaces, away from the VM's own interface. A network namespace is a separate copy of the kernel's network stack with its own interfaces, routes and net.* sysctls; ip netns exec NAME command runs a command inside one. The server web has a public side (eth0, 192.0.2.10) and an internal network on eth1 (10.20.0.0/24). Another host on the public segment, out, can send packets that claim to come from the internal address 10.20.0.99.
#!/bin/sh# A small lab network in network namespaces: a server (web), its default# router (gw), a second router (gw2), another host (out), and a switch (lan).set -efor n in web gw gw2 out lan; do ip netns add hard-sn-$n; ip -n hard-sn-$n link set lo up; doneip -n hard-sn-lan link add br0 type bridgeip -n hard-sn-lan link set br0 upfor n in web gw gw2 out; doip link add eth0 netns hard-sn-$n type veth peer name p-$n netns hard-sn-lanip -n hard-sn-lan link set p-$n master br0 upip -n hard-sn-$n link set eth0 updone# web: the server, with an internal network on eth1ip -n hard-sn-web addr add 192.0.2.10/24 dev eth0ip -n hard-sn-web link add eth1 type dummyip -n hard-sn-web addr add 10.20.0.10/24 dev eth1ip -n hard-sn-web link set eth1 upip -n hard-sn-web route add default via 192.0.2.1ip -n hard-sn-web route add 198.51.100.0/24 via 192.0.2.2# gw: the default router; it knows 203.0.113.0/24 is behind gw2ip -n hard-sn-gw addr add 192.0.2.1/24 dev eth0ip netns exec hard-sn-gw sysctl -q -w net.ipv4.ip_forward=1ip -n hard-sn-gw route add 203.0.113.0/24 via 192.0.2.2# gw2: a second router that owns the partner and remote networksip -n hard-sn-gw2 addr add 192.0.2.2/24 dev eth0ip -n hard-sn-gw2 link add dummy0 type dummyip -n hard-sn-gw2 link set dummy0 upip -n hard-sn-gw2 addr add 198.51.100.1/24 dev dummy0for a in 1 2 3; do ip -n hard-sn-gw2 addr add 203.0.113.$a/32 dev dummy0; done# out: another host on the segment, able to send from 10.20.0.99ip -n hard-sn-out addr add 192.0.2.66/24 dev eth0ip -n hard-sn-out addr add 10.20.0.99/32 dev lo
A new namespace inherits the host's IPv4 all and default values, so web starts with Ubuntu's loose mode everywhere. Now out pings the server's public address from the spoofed internal source. The pings get no reply (the server answers towards its internal network), so read the result on the server with nstat, which prints the kernel's protocol counters: IcmpInEchos counts pings accepted, TcpExtIPReversePathFilter counts packets dropped by reverse-path filtering.
Both spoofed pings were accepted: 10.20.0.99 is reachable through eth1, and loose mode asks no more. Setting strict mode on the interface alone looks like the obvious fix. It changes nothing:
The kernel uses the higher of conf.all.rp_filter and the interface's value, and on Ubuntu all is 2, so eth0 stays loose whatever you write to it. You cannot make any interface strict while all is 2. Set all to 1 as well (with martian logging on) and the next two spoofed pings are dropped, while a genuine ping from out's real address still works:
The drops are silent to the sender. log_martians makes them visible to you, in the kernel log that journalctl -k reads:
The operational risk of strict mode is asymmetric routing: a host with two uplinks whose reply to some source legitimately leaves by the other interface. Strict mode drops it with no application error. The max rule tells you how to exempt one interface: with all = 1 you can set that interface to 2, and the higher value (loose) applies to it alone. RHEL's layout (all = 0, each interface 1) gets strict mode from the interface values; CIS still asks for all = 1, which changes nothing on eth0 there. Martian logging is rate-limited, but a noisy segment can still add steady journal lines.
Redirects, forwarding and the pinned defaults
An ICMP redirect is a message from a router saying "for that destination, use this other gateway on your network". On a server it is an instruction any host that can fake the router's address may try to send, and a host that obeys can have its traffic steered through a machine of the attacker's choosing. secure_redirects = 1 looks like a limit (accept only redirects to gateways the host already uses), but ip-sysctl documents it as overridden by shared_media, which is 1 by default, as the values above show, so on a default host it narrows nothing. accept_redirects = 0 is the control that refuses them; the file below also sets secure_redirects = 0 because the CIS profile checks it.
The lab has a real redirect to test with. The server's default router gw knows that 203.0.113.0/24 is behind the second router gw2 on the same segment, so when web sends there through gw, gw forwards the packet and sends web a redirect. ip route get shows whether web took it (cache <redirected> and via 192.0.2.2 mean it did), and nstat counts the redirects that arrived.
For a host that does not forward, the kernel accepts redirects on an interface when either all or the interface's own value is 1. Setting only all to 0, as many hardening lists do, leaves eth0 at 1, and the next redirect is still taken:
Only when the interface value is 0 too is the redirect ignored. Redirects still arrive (the counter keeps rising, because both pings now go through gw), but the route stays on the default gateway:
That is why the file below sets all, default and a glob over every existing interface; log_martians, an either-or key being turned on, needs only all. A host sends redirects only while it forwards, so send_redirects = 0 matters once something turns forwarding on. For IPv6, the kernel reads each interface's accept_redirects, which is 1 on both platforms, so the glob covers it too. Impact: none on an ordinary network; a host that relied on redirects needs a correct route instead.
Forwarding is the pivot control. ip_forward = 0 means a packet arriving on one interface is never sent out of another, so a compromised web server cannot become a router into the internal network behind it. Container runtimes and VPN servers set it to 1 because they route for their containers or clients; do not install this file on a container host, a router or a VPN gateway. The ip-sysctl documentation also notes that changing ip_forward resets the per-interface settings to host or router defaults, so check the redirect keys again after anything toggles it.
The pinned keys need only a sentence each. accept_source_route = 0 refuses packets that carry their own route, and the default all = 0 already refuses them everywhere (an AND key). tcp_syncookies = 1 lets the kernel answer a SYN flood without keeping state for each half-open connection; the kernel documentation calls it a fallback, not a way to carry legitimate load. icmp_echo_ignore_broadcasts = 1 stops the host answering pings sent to a broadcast address, which would make it an amplifier. None of this needs IPv6 disabled. The CIS profile also asks for accept_ra = 0; on a host that takes its IPv6 address from router advertisements that breaks IPv6, and on Ubuntu systemd-networkd already handles them itself (the kernel's value on eth0 is 0).
Persist, verify and roll back
Save the current values before changing anything. sysctl prints key = value lines, which is also the format sysctl -p reads, so the snapshot is the rollback.
Then write the file. The name 60-secopslog-network.conf sorts after Ubuntu's 55-network-security.conf and RHEL's 50-redhat.conf, so its values win. Each key is marked as changing or pinning a default, and every comment is on its own line.
# SecOpsLog network baseline for a host that does not route packets.# CHANGES marks a key that differs from the Ubuntu 26.04 / RHEL 10 default;# PINS marks a default that is kept here so drift is caught and undone.# Comments must stay on their own lines: sysctl.d has no trailing comments.# PINS: no forwarding between interfaces (IPv4 and IPv6).net.ipv4.ip_forward = 0net.ipv6.conf.all.forwarding = 0# CHANGES: ignore ICMP redirects. A host accepts one when either "all" or# the interface allows it, so the glob sets every interface as well.net.ipv4.conf.all.accept_redirects = 0net.ipv4.conf.default.accept_redirects = 0net.ipv4.conf.*.accept_redirects = 0net.ipv4.conf.all.secure_redirects = 0net.ipv4.conf.default.secure_redirects = 0net.ipv4.conf.*.secure_redirects = 0net.ipv6.conf.all.accept_redirects = 0net.ipv6.conf.default.accept_redirects = 0net.ipv6.conf.*.accept_redirects = 0# CHANGES: never send redirects (only a router has a reason to).net.ipv4.conf.all.send_redirects = 0net.ipv4.conf.default.send_redirects = 0net.ipv4.conf.*.send_redirects = 0# PINS: refuse source-routed packets.net.ipv4.conf.all.accept_source_route = 0net.ipv4.conf.default.accept_source_route = 0# CHANGES on Ubuntu (2, loose): strict reverse-path filtering. The kernel# uses the higher of "all" and the interface, so all three are set.net.ipv4.conf.all.rp_filter = 1net.ipv4.conf.default.rp_filter = 1net.ipv4.conf.*.rp_filter = 1# CHANGES: log packets with impossible source addresses.net.ipv4.conf.all.log_martians = 1net.ipv4.conf.default.log_martians = 1# PINS: SYN cookies, and no replies to broadcast pings or bogus ICMP errors.net.ipv4.tcp_syncookies = 1net.ipv4.icmp_echo_ignore_broadcasts = 1net.ipv4.icmp_ignore_bogus_error_responses = 1
sudo sysctl --system applies every file in the same order boot does and prints each file name, then each value it writes. The order is the point: your file is applied after 55-network-security.conf sets all.rp_filter = 2, so 1 is what stays. The glob lines expand to all, default, eth0 and lo.
Verify by reading the keys back, including an interface, since the interface value is what redirect handling uses. eth0.log_martians still reads 0, which is fine: for that key, all = 1 is enough.
sysctl --system is the procps tool; at boot the same files are applied by systemd-sysctl, which also gives an explicit key priority over a glob and reapplies the interface keys to every interface that appears later. systemd-analyze cat-config sysctl.d prints the files in the order boot reads them, and restarting systemd-sysctl runs the boot path now, as the next reboot will.
Rollback needs care. Deleting the file and re-running sysctl --system restores only the keys some other file still sets. rp_filter goes back to 2 because Ubuntu's files set it; redirects and martian logging stay as your file left them, because no file sets them and the kernel keeps its running values until reboot.
Load the snapshot to put every value back:
On RHEL 10 the method is identical: sysctl --system and systemd-sysctl read the same directories, and /etc/sysctl.d/99-sysctl.conf is a symlink to /etc/sysctl.conf, which is still read there. The file above changes redirects and martian logging, and sets all.rp_filter to 1, which RHEL's interfaces already use.
Try this
Build the lab network with the script above, then make web strict with sudo ip netns exec hard-sn-web sysctl -w net.ipv4.conf.all.rp_filter=1 net.ipv4.conf.eth0.rp_filter=1. Break it by adding an asymmetric route: sudo ip -n hard-sn-web route add 192.0.2.66/32 dev eth1, so replies to out's real address leave by the wrong interface. Ping web from out (sudo ip netns exec hard-sn-out ping -c 2 -W 1 192.0.2.10) and read nstat -asz IcmpInEchos TcpExtIPReversePathFilter inside web: the reverse-path counter rises by 2 and IcmpInEchos does not. Repair it for that interface only with net.ipv4.conf.eth0.rp_filter=2, ping again, and confirm IcmpInEchos rises while the drop counter stays put (the pings still get no reply, because eth1 leads nowhere in the lab). Remove the namespaces with sudo ip netns del for each hard-sn-* name when you finish.
Takeaway
Read the defaults before you write a sysctl file, change only the keys that differ, and set per-interface keys through all, default and a glob because the kernel combines them differently for each key. Snapshot the running values first, since deleting the file does not undo what it set.