From f1387e3125fee53d768d310a5b76741b91e910b2 Mon Sep 17 00:00:00 2001 From: Thomas Glanzmann Date: Sun, 16 Aug 2026 12:04:46 +0200 Subject: [PATCH 1/2] Drop foreign-table route notifications in the kernel dhcpcd only ever acts on routes in the main table -- if_copyrt() opens with "if (rtm->rtm_table != RT_TABLE_MAIN) return -1;". On a host that also runs a routing daemon holding a full BGP feed in its own kernel table, that discard happens far too late: the message has already been allocated, queued and charged against SO_RCVBUF. Netlink charges roughly 480 bytes of socket accounting per route notification, so the 1 MiB granted by 100_default-link-rcvbuf.patch holds only ~2180 messages, and even net.core.rmem_max (16 MiB here) holds only ~35000. A single BGP re-convergence emits several hundred thousand. Overflow is not merely likely, it is arithmetically guaranteed, and no receive buffer size can prevent it. On overflow, dhcpcd_linkoverflow() discards the *entire* queue, including the RTM_NEWLINK and RTM_NEWADDR messages for the interface that just came up, then re-learns state that is stale again milliseconds later because the flood is still in progress. On a PPPoE line whose 24 hour forced disconnect makes the routing daemon reconverge at exactly the moment dhcpcd needs to see ppp0 appear, DHCPv6 prefix delegation is delayed by minutes. Apply dhcpcd's existing main-table test in the kernel instead, with a socket filter. A broadcast rejected by sk_filter() is never queued and never charged against SO_RCVBUF, so foreign-table churn can no longer overflow us however large it is. Because the filter mirrors if_copyrt() exactly, no message dhcpcd would have acted upon is affected; link, address and main-table route messages are all still delivered. rtm_table is only 8 bits wide. Table ids that do not fit are reported as RT_TABLE_COMPAT with the real id in RTA_TABLE, which cBPF cannot walk. That needs no special case: the filter accepts nothing but RT_TABLE_MAIN, which is precisely the test if_copyrt() makes, so the widest tables are kept out of the queue along with the rest. Measured on the affected host: 60000 table-100 routes installed while dhcpcd is behind on netlink gives 1 overflow and 5185 discarded messages per run without this patch (3 of 3 runs) and no overflow with it (3 of 3 runs). Note: this patch shifts GCC's inlining decisions enough to expose a pre-existing false positive, "-Wstringop-overflow: writing 16 bytes into a region of size 12" for the add_attr_l() call in if_address6(). That write is bounds checked against sizeof(struct nlma) (88 bytes) and lands at offset 28..44 of it; GCC mis-reports the destination object as the 16 byte nlmsghdr member because that is the address add_attr_l() receives. It is the usual NLMSG_TAIL idiom, and upstream already annotates the analogous site in if_copyrt() for Coverity. --- src/if-linux.c | 77 +++++++++++++++++++++++++++++++++++++++++++++++--- src/if.h | 2 +- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/if-linux.c b/src/if-linux.c index 593264090..7deca3a85 100644 --- a/src/if-linux.c +++ b/src/if-linux.c @@ -413,8 +413,67 @@ if_vlanid(const struct interface *ifp) return (unsigned short)v.u.VID; } +#ifdef SO_ATTACH_FILTER +/* + * Reject route messages for tables we do not manage before the kernel queues + * them. if_copyrt() already discards everything outside RT_TABLE_MAIN, but by + * then the message has been allocated, queued and charged against SO_RCVBUF. + * A routing daemon churning a large foreign table -- a full BGP feed in its own + * "kernel table N" -- can therefore overflow the socket in milliseconds and + * cost us the link and address messages we actually need. + * + * A broadcast rejected here is never queued and never charged, so no amount of + * foreign-table churn can overflow us. The test mirrors if_copyrt() exactly, + * so nothing dhcpcd would have acted upon is affected. + * + * A table id wider than rtm_table's 8 bits is reported as RT_TABLE_COMPAT with + * the real id in RTA_TABLE, which cBPF cannot walk. That needs no special + * case: accepting nothing but RT_TABLE_MAIN is the same test if_copyrt() makes, + * so the widest tables are kept out of the queue along with the rest. + */ +/* + * htons() is not a constant expression, so swap the comparands at compile + * time; BPF loads big endian while netlink is host endian. + */ +#if BYTE_ORDER == BIG_ENDIAN +#define BPF_NLMSG_TYPE(t) (t) +#else +#define BPF_NLMSG_TYPE(t) ((((t) & 0xff) << 8) | (((t) >> 8) & 0xff)) +#endif + +static int +if_linkfilter(int fd) +{ + /* A constant program; keep it out of the stack frame. */ + static struct sock_filter filter[] = { + /* A = nlmsghdr.nlmsg_type. */ + BPF_STMT(BPF_LD | BPF_H | BPF_ABS, + offsetof(struct nlmsghdr, nlmsg_type)), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, + BPF_NLMSG_TYPE(RTM_NEWROUTE), 1, 0), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, + BPF_NLMSG_TYPE(RTM_DELROUTE), 0, 2), /* not a route */ + + /* A = rtmsg.rtm_table. */ + BPF_STMT(BPF_LD | BPF_B | BPF_ABS, + NLMSG_LENGTH(0) + offsetof(struct rtmsg, rtm_table)), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, RT_TABLE_MAIN, 0, 1), + + BPF_STMT(BPF_RET | BPF_K, (uint32_t)-1), /* accept */ + BPF_STMT(BPF_RET | BPF_K, 0), /* drop */ + }; + struct sock_fprog prog = { + .len = __arraycount(filter), + .filter = filter, + }; + + return setsockopt(fd, SOL_SOCKET, SO_ATTACH_FILTER, &prog, + sizeof(prog)); +} +#endif + int -if_linksocket(struct sockaddr_nl *nl, int protocol, int flags) +if_linksocket(struct sockaddr_nl *nl, int protocol, int flags, bool filter) { int fd; @@ -422,6 +481,16 @@ if_linksocket(struct sockaddr_nl *nl, int protocol, int flags) if (fd == -1) return -1; nl->nl_family = AF_NETLINK; +#ifdef SO_ATTACH_FILTER + /* + * Attach before bind(2) so that not even the leading messages of a + * flood already in progress can be queued unfiltered. + */ + if (filter && if_linkfilter(fd) == -1) + logerr("%s: SO_ATTACH_FILTER", __func__); +#else + UNUSED(filter); +#endif if (bind(fd, (struct sockaddr *)nl, sizeof(*nl)) == -1) { close(fd); return -1; @@ -501,7 +570,7 @@ if_opensockets_os(struct dhcpcd_ctx *ctx) snl.nl_groups |= RTMGRP_IPV6_ROUTE | RTMGRP_IPV6_IFADDR | RTMGRP_NEIGH; #endif - ctx->link_fd = if_linksocket(&snl, NETLINK_ROUTE, SOCK_NONBLOCK); + ctx->link_fd = if_linksocket(&snl, NETLINK_ROUTE, SOCK_NONBLOCK, true); if (ctx->link_fd == -1) return -1; #ifdef NETLINK_BROADCAST_ERROR @@ -518,7 +587,7 @@ if_opensockets_os(struct dhcpcd_ctx *ctx) ctx->priv = priv; memset(&snl, 0, sizeof(snl)); - priv->route_fd = if_linksocket(&snl, NETLINK_ROUTE, 0); + priv->route_fd = if_linksocket(&snl, NETLINK_ROUTE, 0, false); if (priv->route_fd == -1) return -1; len = sizeof(snl); @@ -527,7 +596,7 @@ if_opensockets_os(struct dhcpcd_ctx *ctx) priv->route_pid = snl.nl_pid; memset(&snl, 0, sizeof(snl)); - priv->generic_fd = if_linksocket(&snl, NETLINK_GENERIC, 0); + priv->generic_fd = if_linksocket(&snl, NETLINK_GENERIC, 0, false); if (priv->generic_fd == -1) return -1; diff --git a/src/if.h b/src/if.h index c9a14b804..6b64b6c9c 100644 --- a/src/if.h +++ b/src/if.h @@ -287,7 +287,7 @@ struct interface *if_findifpfromcmsg(struct dhcpcd_ctx *, struct msghdr *, int *); #ifdef __linux__ -int if_linksocket(struct sockaddr_nl *, int, int); +int if_linksocket(struct sockaddr_nl *, int, int, bool); int if_getnetlink(struct dhcpcd_ctx *, struct iovec *, int, int, int (*)(struct dhcpcd_ctx *, void *, struct nlmsghdr *), void *); #endif From 17d5c0a265728fed3a23fefbc2bf323458bfbd0d Mon Sep 17 00:00:00 2001 From: Thomas Glanzmann Date: Sun, 16 Aug 2026 12:08:22 +0200 Subject: [PATCH 2/2] Let the kernel honour the filters in our netlink dump requests if_initrt() asks for RT_TABLE_MAIN and if_addrflags6()/if_addressexists() ask for a single interface index, but without NETLINK_GET_STRICT_CHK the kernel ignores those filters and dumps everything; dhcpcd then discards the excess in if_copyrt() and in the dump callbacks. The result is correct either way, so this is purely a matter of where the filtering happens. On a host whose routing daemon holds a full BGP feed in its own kernel table the difference is enormous. Measured on the affected router, issuing exactly the RTM_GETROUTE dump if_initrt() issues: WITHOUT strict-check messages=254406 kept=13 discarded=254393 0.518 s WITH strict-check messages=13 kept=13 discarded=0 0.000 s rt_build() calls if_initrt() from 23 sites, including ipv6nd.c on every Router Advertisement, so this ran several times a second while the link was coming back up after the daily PPP disconnect. Counting recvmsg(2) calls for a dhcpcd startup against a 60000 route foreign table: 1296 before, 21 after. The option is set once, on the socket, at creation time rather than around each dump. That is not a stylistic choice: setsockopt(2) is not in the privsep seccomp allowlist in privsep-linux.c, so toggling it per dump kills the manager with SIGSYS once the sandbox is in force. Verified on this kernel that strict checking does not disturb the other traffic on this socket: RTM_NEWADDR, RTM_NEWROUTE, RTM_DELROUTE and RTM_DELADDR all still succeed, and RTM_GETADDR dumps return exactly the addresses the callbacks would have kept. ENOPROTOOPT is ignored so kernels older than 4.20 keep the previous behaviour. --- src/if-linux.c | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/if-linux.c b/src/if-linux.c index 7deca3a85..c5739cb01 100644 --- a/src/if-linux.c +++ b/src/if-linux.c @@ -546,7 +546,7 @@ if_opensockets_os(struct dhcpcd_ctx *ctx) struct priv *priv; struct sockaddr_nl snl; socklen_t len; -#ifdef NETLINK_BROADCAST_ERROR +#if defined(NETLINK_BROADCAST_ERROR) || defined(NETLINK_GET_STRICT_CHK) int on = 1; #endif @@ -590,6 +590,26 @@ if_opensockets_os(struct dhcpcd_ctx *ctx) priv->route_fd = if_linksocket(&snl, NETLINK_ROUTE, 0, false); if (priv->route_fd == -1) return -1; +#ifdef NETLINK_GET_STRICT_CHK + /* + * Ask the kernel to honour the filters we put in dump requests. + * Without this it ignores them: if_initrt() asks for RT_TABLE_MAIN and + * gets every table, and if_addrflags6() asks for one interface and gets + * every address on the system. Both then filter in userland, so the + * result is unchanged -- but on a host whose routing daemon holds a + * full BGP feed in its own kernel table, that is a quarter of a million + * messages per dump to keep a handful, and rt_build() dumps on events + * as frequent as a Router Advertisement. + * + * This must be done here rather than around each dump because the + * privsep sandbox does not permit setsockopt(2) once it is in force. + * Kernels without the option simply keep the old behaviour. + */ + if (setsockopt(priv->route_fd, SOL_NETLINK, NETLINK_GET_STRICT_CHK, &on, + sizeof(on)) == -1 && + errno != ENOPROTOOPT) + logerr("%s: NETLINK_GET_STRICT_CHK", __func__); +#endif len = sizeof(snl); if (getsockname(priv->route_fd, (struct sockaddr *)&snl, &len) == -1) return -1;