DEV Community

Cover image for Docker Disabled accept_redirects, Not Me
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

Docker Disabled accept_redirects, Not Me

The nicest thing about hardening checklists is how finished they make you feel. You write net.ipv4.conf.all.accept_redirects = 0 into /etc/sysctl.d/, run sysctl -p, the output line echoes it back at you, and you tick the box. I did exactly that for years.

The other day I read the ICMP redirect settings across all seven servers in my fleet, one by one. On three of them the word redirect never appears under sysctl.conf or sysctl.d — not a single line of configuration on the subject. Yet two of those three said all.accept_redirects = 0. I hadn't written it. The third said 1, and it genuinely accepts redirects.

Docker wrote the zero. More precisely, Docker touching ip_forward did. This article is the record of that investigation: three separate traps, all leading to the same sentence — the value you read is not the value the kernel uses, and most of the time you didn't write it either.

I lived through something similar in the article about userfaultfd's three entrances: the sysctl said zero, the door was open anyway. There the problem was the setting's scope. Here it's who wrote the setting.

A scope note: everything here is IPv4. net.ipv6.conf.*.accept_redirects has different semantics, and in IPv6 the real route-injection door isn't redirects but router advertisements. That's a separate article.

First, what an ICMP redirect is for

If there are two routers on the same local network and you picked the wrong one as your default gateway, that router forwards your packet to the right one and sends you an ICMP message saying "next time, send this directly to that one." It's called a redirect. A courtesy dating back to 1981.

RFC 1122 is clear on the host side: "A host receiving a Redirect message MUST update its routing information accordingly." So the machine receiving the message is required to update its routing information. The same section also sets a boundary — if the new gateway address isn't on the subnet the message arrived through, or if the source of the redirect isn't the current first-hop gateway for that destination, the message should be silently discarded. Hold on to that sentence; shortly we'll measure how Linux in its default state fails to enforce it.

The router-side rules live in RFC 1812 §5.2.7.2. Before a router generates a redirect it requires all three conditions to hold: the packet must be leaving through the same physical interface it arrived on, the packet's source IP must be on the same logical subnet as the next-hop IP, and the packet must not carry a source route option. The same section also limits whether routers obey redirects, but conditionally: a router using a routing protocol other than static routes must not consider paths learned from redirects; a router not using a routing protocol may have a configuration allowing it. The reasoning is written out plainly: believing a redirect that contradicts the router's own protocol would likely create routing loops.

The security angle falls out of this. Anyone in the same L2 domain can tell a machine that accepts redirects to "use me for that destination from now on." There's no authentication at the network layer. Cloud providers' SDNs generally never generate this traffic and separate tenants at L2 — but on a rented VPS you don't choose who your neighbour is, or whether they share your broadcast domain. All seven machines in this fleet are rented.

Reading the fleet: seven servers, two oddities

For each server I collected the all values, the values on the interface carrying the public IP, and whether any file under sysctl.d touches redirects at all:

Server Kernel ip_forward all.accept iface.accept Docker redirect config
vps1 7.0.0-34 1 0 0 yes yes
vps2 7.0.0-34 1 0 0 yes yes
vps3 6.8.0-142 1 0 0 yes yes
vps4 6.8.0-142 1 0 0 yes yes
vps5 7.0.0-34 1 0 1 yes none
vps6 7.0.0-34 1 0 1 yes none
vps7 7.0.0-34 0 1 1 no none

The last two columns make the table readable. The three machines with no configuration written (vps5, vps6, vps7) are exactly the three whose interface value is 1. Because that 1 is the kernel's compile-time default: in the ipv4_devconf_dflt table in devinet.c, ACCEPT_REDIRECTS, SEND_REDIRECTS, SECURE_REDIRECTS and SHARED_MEDIA are all 1.

Two oddities remain. Why is all 0 on vps5 and vps6, which have no configuration at all? And the real question: which machines in this table actually accept redirects? It took me a while to realise I couldn't answer that one by looking, because the kernel doesn't consult either column on its own.

Trap one: there are two values, and the combining rule isn't fixed

The documentation (Documentation/networking/ip-sysctl.rst) states the rule outright. For accept_redirects:

accept_redirects for the interface will be enabled if: both conf/{all,interface}/accept_redirects are TRUE in the case forwarding for the interface is enabled or at least one of conf/{all,interface}/accept_redirects is TRUE in the case forwarding for the interface is disabled

The combining operator changes with the interface's forwarding state: AND when forwarding is on, OR when it's off. The source says the same thing, in include/linux/inetdevice.h:

#define IN_DEV_RX_REDIRECTS(in_dev) \
    ((IN_DEV_FORWARD(in_dev) && \
      IN_DEV_ANDCONF((in_dev), ACCEPT_REDIRECTS)) \
     || (!IN_DEV_FORWARD(in_dev) && \
      IN_DEV_ORCONF((in_dev), ACCEPT_REDIRECTS)))
Enter fullscreen mode Exit fullscreen mode

Reading that and saying "got it" is easy. I wanted to measure it, because I've seen plenty of cases where the docs don't match the code and the code doesn't match the behaviour.

I set up three network namespaces: a client (h) and two routers (r1, r2), all on one bridge and all in the same 10.0.0.0/24. h's default gateway is r1. The real path to 192.168.99.1 goes through r2. The classic redirect scenario.

Diagram

Because sysctls inside a namespace are isolated, I can try any combination without touching the host. Each round deletes and re-adds h's default route (which drops the redirect exception attached to it), sends three pings, and checks ip route get 192.168.99.1. If it shows via 10.0.0.3, the redirect was accepted. The R1= column is how many redirects r1 actually sent that round; I'll explain why that's needed shortly.

A1 fwd=0 OR(0,0)=0                 fwd=0 acc=0/0 sec=1/1 shared=1/1 | R1=3 H=3 -> RED
A2 fwd=0 OR(0,1)=1                 fwd=0 acc=0/1 sec=1/1 shared=1/1 | R1=2 H=2 -> KABUL
A3 fwd=0 OR(1,0)=1                 fwd=0 acc=1/0 sec=1/1 shared=1/1 | R1=1 H=1 -> KABUL
A4 fwd=1 AND(0,1)=0                fwd=1 acc=0/1 sec=1/1 shared=1/1 | R1=3 H=3 -> RED
A5 fwd=1 AND(1,0)=0                fwd=1 acc=1/0 sec=1/1 shared=1/1 | R1=3 H=3 -> RED
A6 fwd=1 AND(1,1)=1                fwd=1 acc=1/1 sec=1/1 shared=1/1 | R1=1 H=1 -> KABUL
Enter fullscreen mode Exit fullscreen mode

(RED is the Turkish for rejected, KABUL for accepted — the lab script speaks Turkish.) All six rows agree with the documentation. And the table answers the fleet's real question: on vps5 and vps6 the interface value is 1, but because forwarding=1 the AND rule applies, and with all=0 no redirect is accepted. Those two servers look dangerous and aren't.

vps7 is the mirror image. With forwarding=0 the OR rule is in effect, and the door opens the moment either value is 1. Of the seven machines exactly one actually accepts redirects — and it was the plainest-looking row in the table.

Trap two: writing ip_forward clobbers accept_redirects

That leaves the zero on vps5 and vps6. Those machines have no line of configuration touching redirects, yet all.accept_redirects=0. Who wrote the value?

net/ipv4/devinet.c. When net.ipv4.ip_forward (the same value as conf.all.forwarding) changes, the kernel calls inet_forward_change():

static void inet_forward_change(struct net *net)
{
    int on = IPV4_DEVCONF_ALL(net, FORWARDING);

    IPV4_DEVCONF_ALL(net, ACCEPT_REDIRECTS) = !on;
    IPV4_DEVCONF_DFLT(net, FORWARDING) = on;
    ...
    for_each_netdev(net, dev) {
        ...
        IN_DEV_CONF_SET(in_dev, FORWARDING, on);
    }
}
Enter fullscreen mode Exit fullscreen mode

all.accept_redirects is overwritten with the negation of ip_forward. Since Docker sets ip_forward=1 on install, Docker is what put the zero on those machines. Not my security setting — a side effect of the container runtime.

The documentation does mention this, but oddly. Under ip_forward it says: "This variable is special, its change resets all configuration parameters to their default state (RFC1122 for hosts, RFC1812 for routers)". The code doesn't bear that out — "all configuration parameters" are not reset; only all.ACCEPT_REDIRECTS is set to !on and FORWARDING is propagated to interfaces. secure_redirects, send_redirects and shared_media stay where they were. So the doc isn't missing; it's too broad. A reader either expects nothing or expects everything, and both are wrong.

The measurement — in a fresh namespace, with all.accept_redirects set by hand, the only thing touched afterwards is ip_forward:

D1: ip_forward 1 -> 0 gecisi, accept_redirects ELLE 0 iken
  once:
   ip_forward=1  all.accept_redirects=0
  sonra (yalniz ip_forward yazildi):
   ip_forward=0  all.accept_redirects=1
D2: ip_forward 0 -> 1 gecisi, accept_redirects ELLE 1 iken
  once:
   ip_forward=0  all.accept_redirects=1
  sonra (yalniz ip_forward yazildi):
   ip_forward=1  all.accept_redirects=0
D3: degisiklik OLMAYAN yazma (1 -> 1)
  once:
   ip_forward=1  all.accept_redirects=1
  sonra:
   ip_forward=1  all.accept_redirects=1
D4: forwarding mevcut arayuzlere yayiliyor mu?
   ip_forward=0 -> dum0.forwarding=0
   ip_forward=1 -> dum0.forwarding=1
Enter fullscreen mode Exit fullscreen mode

D3 matters: the hook fires only when the value actually changes. Run sysctl -p twice in a row and the second run breaks nothing. What breaks things is forwarding being turned on or off somewhere for the first time. D4 shows the same touch also re-picks the combining operator from trap one, for every interface.

The practical consequence: the line ordering in your sysctl files matters — but if something like Docker writes ip_forward at runtime, the ordering isn't even yours to control. On vps7 this mechanism never fired at all: forwarding was never turned on, so accept_redirects stayed at its compile-time default of 1.

Trap three: secure_redirects is dead by default

"Fine, but I have secure_redirects=1, that protects me." I thought so too. On all seven of the fleet's public interfaces, this value is 1.

The definition in the docs is well-meant: only accept a redirect to gateways already in the interface's gateway list. But the same document carries a two-word note: "Overridden by shared_media." And shared_media defaults to 1.

In __ip_do_redirect() in net/ipv4/route.c, the secure_redirects check sits inside the branch taken only when shared_media is off:

    if (!IN_DEV_SHARED_MEDIA(in_dev)) {
        if (!inet_addr_onlink(in_dev, new_gw, old_gw))
            goto reject_redirect;
        if (IN_DEV_SEC_REDIRECTS(in_dev) &&
            ip_fib_check_default(new_gw, dev))
            goto reject_redirect;
    } else {
        if (inet_addr_type(net, new_gw) != RTN_UNICAST)
            goto reject_redirect;
    }
Enter fullscreen mode Exit fullscreen mode

In the default state the only check is that the new gateway is unicast. secure_redirects is never read — and neither is inet_addr_onlink. That second one matters more: that call is exactly the code enforcing RFC 1122's "discard it if the new gateway isn't on the same connected subnet" rule. So Linux out of the box does not enforce the RFC requirement quoted at the top. It also makes the doc's line under secure_redirects — "Even if disabled, RFC1122 redirect rules still apply" — misleading.

Four rounds in the lab. h's gateway list contains only 10.0.0.1; 10.0.0.3 is not a gateway:

B1 sec=1 shared=1/1 VARSAYILAN     fwd=0 acc=1/1 sec=1/1 shared=1/1 | R1=1 H=1 -> KABUL
B2 sec=1 shared=0/0                fwd=0 acc=1/1 sec=1/1 shared=0/0 | R1=3 H=3 -> RED
B3 sec=0 shared=0/0 (kontrol)      fwd=0 acc=1/1 sec=0/0 shared=0/0 | R1=1 H=1 -> KABUL
B4 sec=1 shared=0/1 SADECE all     fwd=0 acc=1/1 sec=1/1 shared=0/1 | R1=1 H=1 -> KABUL
Enter fullscreen mode Exit fullscreen mode

B3 is the control: shared_media=0 alone doesn't cause the rejection — secure_redirects is what produces it in B2.

B4 is the round that demolished my own advice while writing this. Setting shared_media to 0 on all only, leaving the interface at 1, and the redirect is accepted again — because IN_DEV_SHARED_MEDIA uses a fixed OR and the interface's compile-time default is 1. My first draft's sysctl.d block had exactly that bug: it wrote all.shared_media = 0 and changed nothing. The corrected version is in the list below.

In round B2, with log_martians=1, it landed in the kernel log:

[591538.893099] IPv4: Redirect from 10.0.0.1 on e0 about 10.0.0.3 ignored
Enter fullscreen mode Exit fullscreen mode

The sending side, and a ceiling you can touch

The sending side is simpler: the rule for send_redirects is a fixed OR, default 1. In the fleet, all.send_redirects is 0 on four of the seven machines, but the interface value is 1 on six — the OR means writing all=0 isn't enough.

There's also a ceiling. With h set to ignore redirects, 400 packets:

  t=2s  R1 toplam redirect = 6
  t=5s  R1 toplam redirect = 7
  t=10s  R1 toplam redirect = 8
  t=20s  R1 toplam redirect = 9
  t=40s  R1 toplam redirect = 9
  t=60s  R1 toplam redirect = 9
  t=80s  R1 toplam redirect = 9
Enter fullscreen mode Exit fullscreen mode

It stops at nine. The slowdown along the way is exponential backoff: sends thin out via ip_rt_redirect_load << n. The constant in the source is ip_rt_redirect_number = 9.

Here's where my own lab misled me. Inside a namespace, /proc/sys/net/ipv4/route/redirect_number doesn't exist, so for a while I took it for an untunable constant. It isn't: ipv4_route_table in route.c exposes all three as redirect_load, redirect_number and redirect_silence at mode 0644 — but that table is registered only to init_net, while the per-namespace ipv4_route_netns_table is a separate table that doesn't include them. On the host they're right there:

net.ipv4.route.redirect_number = 9
net.ipv4.route.redirect_load = 20
net.ipv4.route.redirect_silence = 20480
Enter fullscreen mode Exit fullscreen mode

So: not tunable from inside a container, tunable from the host. The lesson generalises past this topic — a sysctl you can't see inside a namespace is not necessarily a sysctl that doesn't exist.

My first run walked into that ceiling too. Running the rounds back to back exhausted r1's counter, and three rounds looked like "rejected" when no redirect had been sent at all. What I took for a difference in settings was a drained counter. I had to add the "did R1 actually send?" counter to every round, plus a wait of redirect_silence between them. "The result looks plausible" is the most expensive sentence in measurement; three of my plausible-looking rows were wrong.

So what should you actually do

1. Establish the role. On a non-forwarding machine (ip_forward=0) the OR rule applies; if either value is 1, the door is open. On a forwarding machine the AND rule applies and all=0 suffices. If Docker is installed, your machine is in the second group, whether you designed it that way or not.

2. Read the interfaces, not all. Here's the fleet sweep:

for i in $(ip -o -4 addr show scope global | awk '{print $2}' | sort -u); do
  printf '%-12s fwd=%s acc=%s sec=%s send=%s shared=%s\n' "$i" \
    $(sysctl -n net.ipv4.conf.$i.forwarding) \
    $(sysctl -n net.ipv4.conf.$i.accept_redirects) \
    $(sysctl -n net.ipv4.conf.$i.secure_redirects) \
    $(sysctl -n net.ipv4.conf.$i.send_redirects) \
    $(sysctl -n net.ipv4.conf.$i.shared_media)
done
Enter fullscreen mode Exit fullscreen mode

3. Don't confuse the stored value with the effective one. Writing to all doesn't change the stored value on existing interfaces (forwarding excepted), but the effective behaviour is the combination of all and the interface anyway — all=0 is precisely what protects vps5 and vps6. default, meanwhile, only reaches interfaces created afterwards:

  default=1 iken 'eski' yaratildi   -> eski=1
  sonra default=0 yazildi           -> default=0  eski=1
  default=0 iken 'yeni' yaratildi   -> yeni=0
  all=1 yazildi                     -> all=1  eski=1  yeni=0
Enter fullscreen mode Exit fullscreen mode

Docker replays this story every time it creates a bridge, which is why getting default right matters for interfaces that arrive later.

4. Turn off shared_media per interface. all alone does nothing (round B4). Required if you want secure_redirects to do anything.

5. Turn off send_redirects per interface too. Same OR rule.

6. Turn on log_martians=1. It's the practical way to see a rejected redirect directly; the IcmpInRedirects counter in nstat also counts arrivals regardless.

For a non-forwarding server — default covers new interfaces, the loop covers existing ones:

# /etc/sysctl.d/99-icmp-redirect.conf
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 1
net.ipv4.conf.default.shared_media = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.log_martians = 1
Enter fullscreen mode Exit fullscreen mode
# for existing interfaces (sysctl.d reaches new ones, not current ones)
for i in $(ls /proc/sys/net/ipv4/conf/); do
  sysctl -qw net.ipv4.conf.$i.accept_redirects=0
  sysctl -qw net.ipv4.conf.$i.shared_media=0
  sysctl -qw net.ipv4.conf.$i.send_redirects=0
done
Enter fullscreen mode Exit fullscreen mode

After writing the file, read the applied values back, per interface. Because of trap two there's no guarantee that what you wrote is what's in force. For belt and braces you can also drop inbound ICMP type 5 at the firewall; it doesn't replace the sysctls, but it's a second door if one of them goes wrong.

One last practical point: an accepted redirect doesn't live forever. __ip_do_redirect writes the exception with jiffies + ip_rt_gc_timeout, default 300 seconds (net.ipv4.route.gc_timeout). To clear it immediately, ip route flush cache does the job — that's what each lab round uses.

An honest limitation: the lab ran on kernel 6.10.14, while the fleet is on the 6.8 and 7.0 series. The macros and functions are identical across all three, but don't trust my table without verifying on your own kernel — that's what this whole article is built on.

One more thing: in the same header file, rp_filter uses a third combining rule — IN_DEV_MAXCONF, the larger of the all and interface values. Three settings, three different operators: AND, OR and MAX. The policy-based routing article lists rp_filter among the common causes of failure; I never spelled out the reason this plainly back then.

Closing thought

ICMP redirect isn't a major threat on its own. It was open on one of my seven servers and in all likelihood nothing would have happened for years. What bothered me wasn't that it was open — it's that I hadn't written the zeros I trusted either. That zero on six machines was the product of a Docker install, not a security decision. Remove Docker, ip_forward drops from 1 to 0, and accept_redirects quietly returns to 1.

What the three mechanisms here have in common is that none of them is hidden; all three are written down. But all three punish the reflex of treating sysctl as write-and-forget. sysctl -n hands you a number; who wrote that number, and whether the kernel uses it at decision time, are two separate questions.

The measurable part of hardening isn't the lines you wrote — it's what the system does in spite of them. The tick box on the checklist doesn't show you that. A lab does, and standing one up takes twenty seconds.

Official Sources

Top comments (0)