* [PATCH net v7 0/6] amt: relay tunnel lifetime, validation and request-path hardening
@ 2026-11-10 21:05 Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 1/6] amt: stop receiving before amt_dev_stop() tears down its state Omar Ramadan
` (5 more replies)
0 siblings, 6 replies; 7+ messages in thread
From: Omar Ramadan @ 2026-11-10 21:05 UTC (permalink / raw)
To: Taehee Yoo, Andrew Lunn, David S. Miller, Eric Dumazet,
Jakub Kicinski, Paolo Abeni
Cc: netdev, linux-kernel, Simon Horman, Sebastian Andrzej Siewior,
Clark Williams, Steven Rostedt, linux-rt-devel,
Kuniyuki Iwashima
This series hardens AMT relay tunnel lifetime, validates gateway input, and
keeps unauthenticated Requests from consuming relay state. The problems
fixed by patches 3 and 4, and the pre-Query drop in patch 5, were found
during the AMT IPv6 outer transport work, which is not included here.
I am using the existing IPv4 AMT relay and gateway implementation in a
multicast delivery network, where gateways on access networks send IGMP/MLD
membership state through a relay to reach downstream receivers.
The first patch detaches the receive path before amt_dev_stop() tears down
events and tunnels, so no receive-path reader can queue work after the
teardown. The second closes the gc_wq re-arm use-after-free, the double
free of a tunnel when amt_dev_stop() races amt_tunnel_expire(), and the
group/source timer-work lifetime bugs, makes teardown safe for RCU
readers, and bounds the nodes parked for garbage collection. The third
rejects Queries and Data from 0.0.0.0 before relay discovery and
revalidates deferred Queries. The fourth requires exactly four bytes for
IFLA_AMT_LOCAL_IP and IFLA_AMT_DISCOVERY_IP. The fifth makes pre-Query
drops visible, avoids counting successful reports as drops, and uses
concurrent-safe TX statistics. The sixth keeps Requests stateless,
authenticates Membership Updates and checks their IGMP/MLD message before
allocation, keys state by address and port, bounds per-source admission
at a share of max_tunnels, and silently drops failed endpoint admission.
Each commit message says how its problems were found. The problem in
patch 4 was triggered while writing an IPv6 selftest; the others were
found by review or code inspection.
Changes in v7:
- Patch 1: no code change. Its Fixes tags are now cbc21dc1cfe9, which
added amt_rcv(), the receive path that reads sk_user_data, in place of
b9022b53adad, and 72d3b9a4c2b1, which removed the wait in
udp_tunnel_sock_release() that had hidden the race since
3cf7203ca620. The message gives that history.
Kuniyuki Iwashima, who wrote 72d3b9a4c2b1, is Cc'd.
- Patch 2: a node parked for garbage collection is not charged to
max_groups or max_sources, and groups are now parked rather than
freed directly, so Report/Leave churn could pin memory until the next
30-second drain. Count the nodes on both GC lists, under the one lock
that now protects both; when the count reaches AMT_GC_KICK (2048),
drain both lists at once, and run the drain again at once if the
count is still that high when it ends. Both drain loops call
cond_resched(). The node dead flags, touched only under tunnel->lock,
and gnode->amt, set before the group is published, are plain
accesses. The message names each failure the patch fixes and adds
Fixes: bc54e49c140b. Sashiko review:
https://lore.kernel.org/netdev/179423575689.994134.7687724928848317687@kernel.org/
- Patch 3: no code change. Its Fixes tags are now cbc21dc1cfe9 and
30e22a6ebca0 in place of b9022b53adad, which had no receive path.
The message adds that a forged Query also needs nonce 0, that it
blocks discovery until the Request retries run out, that the Data
check is defense in depth, and that a link-local multicast
destination also admits a 0.0.0.0 source. Sashiko reviews:
https://lore.kernel.org/all/sashiko-outbox-203503@kernel.org/
https://lore.kernel.org/netdev/179423575832.994134.15932403659045742824@kernel.org/
- Patches 4 and 5: unchanged. The Sashiko review of patch 5's v5
posting asked for changelog text v6 already carries:
https://lore.kernel.org/netdev/179423575970.994134.5432026388018419992@kernel.org/
- Patch 6: derive the per-source cap from the table,
amt_max_tunnels_per_src(): an eighth of max_tunnels, at least 1 and
at most 16. That is 16 at the default 128 slots, and one address
holds at most an eighth of a table of eight or more slots, where it
could fill a table of 16 or fewer. Both per-source counts are u32,
and the re-check under amt->lock skips dead tunnels, as the lookup
and the Request path do. The message now states the behaviour
changes: the secret is seeded before the socket enables the receive
path; a MAC is accepted for 180 to 240 seconds, so a gateway that
sends no new Request within that window has its Updates dropped,
which the message relates to RFC 7450 5.2.3.5.4 and 5.2.3.5.5; and
any nonce/MAC pair minted for the endpoint in that window is
accepted. It also says why amt_tunnel_get_or_create() rechecks
netif_running(). Sashiko review:
https://lore.kernel.org/netdev/179423576119.994134.11181391213411379311@kernel.org/
- Not changed: the same review notes that the Request path still takes
amt->lock for its O(1) snapshot. amt_tunnel_get_or_create() repeats
that check under the lock, so this is left for net-next.
Changes in v6 (patches 2-6 were v5's 1-5):
- New patch 1: clear the socket's sk_user_data before amt_dev_stop()'s
grace period, so no amt_rcv() can still queue event_wq or link a
tunnel after the teardown. Found by code inspection while reviewing
the changes below.
- Patch 2: run the deferred group/source GC directly instead of
flushing its work. flush_delayed_work() queues the work and waits for
it to run, which needs a free amt_wq active slot, so
amt_tunnel_expire() and the group GC, both amt_wq work, could deadlock
once every slot was held by such a waiter. amt_dev_stop() calls the
GC the same way. (Sashiko)
- Patch 2: amt_tunnel_expire() reads jiffies once (Sashiko), and reads
last_seen and marks the tunnel dead in one tunnel->lock section. The
Update path refreshes last_seen under that lock, once the encapsulated
message has passed its IGMP/MLD check, so an Update either keeps the
tunnel alive or finds it dead (Sashiko, raised on v5 5/5). An Update
never stores 0, which marks a tunnel no Update has refreshed.
- Patch 2: keep amt_group_work()'s loop braces, and order the
declarations the patch touches longest first.
- Patch 6: create or refresh tunnel state only once ip_mc_check_igmp()
or ipv6_mc_check_mld() has accepted the encapsulated message.
(Sashiko)
- Patch 6: the tunnel lookup skips dead tunnels and amt_update_tunnel()
retries once, so an Update that races amt_tunnel_expire() creates the
replacement instead of being dropped. (Sashiko)
- Patch 6: the Request path's per-source count skips dead tunnels too,
so a tunnel being expired no longer withholds a new endpoint's Query.
- Patch 6: drop the per-Update amt_update_relay_status() call, a no-op
since tunnel creation sets that status (Sashiko), and the last_seen 0
special case, since every tunnel is now stamped when it is created.
- Patch 6: retitled from "amt: harden relay authentication and
teardown"; its teardown changes moved to patch 2 in an earlier
revision. Its message now says why a refused Request gets no ICMP Host
Unreachable.
- Patches 2, 3, 5 and 6: correct commit-message and comment claims that
overstated or misdescribed the code, and say how each problem was
found without per-version history. Patch 4 is unchanged.
Changes in v5 (since the 2026-10-20 posting; v5 numbering, so its
patches 1-5 are v6's 2-6):
- Patch 1: flush the deferred group/source GC with flush_delayed_work()
instead of cancel_delayed_work_sync(). Both GC works re-arm only
themselves, so cancelling them on tunnel expiry or device stop stopped
periodic GC for good after the first expiry. The flush only reclaims
promptly; lifetime rests on RCU and the cleared owner pointers, and the
comment and changelog now say so.
- Patch 1: clear snode->gnode and gnode->tunnel_list with WRITE_ONCE().
Neither pointer is __rcu, so RCU_INIT_POINTER() drew two new sparse
errors.
- Patch 1: keep amt_group_work()'s single out: exit and move
rcu_read_unlock() below the label, instead of open-coding
rcu_read_unlock() and dev_put() at three exits. Every exit still
drops RCU and then the timer's device reference.
- Patch 5: drop the paragraph about tunnel liveness checks, which moved
to patch 1 in the previous revision, and fix one continuation-line
alignment.
- Patch 5: copy the secret generations in the Update path under a
seqcount, amt->key_seq, rather than under amt->lock, as the Sashiko
AI review of the 2026-10-15 posting suggested. Forged Updates now
take amt->lock only on PREEMPT_RT, to wait for a writer. Requests
still read key[0], nr_tunnels and max_tunnels in one O(1) amt->lock
section.
- Patch 5: say in the commit message why one address may hold at most
16 tunnels.
- All: state how each issue was found, and put Signed-off-by above the
"---" separator (it was below it in the previous posting, so git am
dropped it).
Testing: on base-commit below, each patch builds without new warnings
with W=1 C=1 (sparse) for IPv6=y, IPv6=n, PROVE_LOCKING and
PREEMPT_RT. With all six patches applied to base-commit below and
KASAN and lockdep enabled, tools/testing/selftests/net/amt.sh passes
(6/6) with no splats. amt.sh does not drive the races fixed here.
v6: https://lore.kernel.org/netdev/20261107004935.2627329-1-omar@blockcast.net/
v5: https://lore.kernel.org/netdev/20261105125010.1107546-1-omar@blockcast.net/
v4 (mislabelled, 2026-10-20): https://lore.kernel.org/netdev/20261020002557.3984927-1-omar@blockcast.net/
v4 (re-posted 2026-10-15): https://lore.kernel.org/netdev/20261015220648.1545576-1-omar@blockcast.net/
v4: https://lore.kernel.org/netdev/20261013175407.947606-1-omar@blockcast.net/
v3: https://lore.kernel.org/netdev/20261012125943.558629-1-omar@blockcast.net/
v2: https://lore.kernel.org/netdev/20261009201455.1904698-1-omar@blockcast.net/
v1: https://lore.kernel.org/netdev/20261008003606.3666617-1-omar@blockcast.net/
Assisted-by: LLM
Omar Ramadan (6):
amt: stop receiving before amt_dev_stop() tears down its state
amt: fix use-after-free when a Membership Update re-arms a freed
tunnel
amt: reject a gateway Query or Data from 0.0.0.0 before a relay is
learned
amt: require an exact IPv4-sized IFLA_AMT_{LOCAL,DISCOVERY}_IP
amt: make pre-query report drops visible
amt: create relay tunnels only from authenticated Updates
drivers/net/amt.c | 663 ++++++++++++++++++++++++++++++++++++----------
include/net/amt.h | 32 ++-
2 files changed, 544 insertions(+), 151 deletions(-)
base-commit: 734bef548a96e8774d6eef5dfa2026c6edc4e80b
--
2.43.0
^ permalink raw reply [flat|nested] 7+ messages in thread
* [PATCH net v7 1/6] amt: stop receiving before amt_dev_stop() tears down its state
2026-11-10 21:05 [PATCH net v7 0/6] amt: relay tunnel lifetime, validation and request-path hardening Omar Ramadan
@ 2026-11-10 21:05 ` Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 2/6] amt: fix use-after-free when a Membership Update re-arms a freed tunnel Omar Ramadan
` (4 subsequent siblings)
5 siblings, 0 replies; 7+ messages in thread
From: Omar Ramadan @ 2026-11-10 21:05 UTC (permalink / raw)
To: Taehee Yoo, Andrew Lunn, David S. Miller, Eric Dumazet,
Jakub Kicinski, Paolo Abeni
Cc: netdev, linux-kernel, Simon Horman, Sebastian Andrzej Siewior,
Clark Williams, Steven Rostedt, linux-rt-devel,
Kuniyuki Iwashima
amt_dev_stop() clears amt->sk and waits in synchronize_net(), but the
socket's sk_user_data still points at the device until
udp_tunnel_sock_release() clears it afterwards, and nothing waits for
readers then. An amt_rcv() that loads the pointer after the grace period
can still be running once amt_dev_stop() has cancelled event_wq and torn
down the relay's tunnels. A gateway can then queue a received
Advertisement or Membership Query on event_wq again, and a relay can
link a new tunnel from a Request and arm its gc_wq. Either work can then
run after the device has been freed.
Clear sk_user_data before the grace period, so synchronize_net() also
waits for every amt_rcv() that can still see the device. Once it is
NULL, amt_rcv() drops the packet and amt_err_lookup() ignores the ICMP
error.
The race is as old as amt_rcv(). From commit 3cf7203ca620 ("net/tunnel:
wait until all sk_user_data reader finish before releasing the sock")
until commit 72d3b9a4c2b1 ("udp_tunnel: Remove synchronize_rcu() in
udp_tunnel_sock_release()."), udp_tunnel_sock_release() waited for
those readers after clearing sk_user_data, which hid it. The wait was
removed because each tunnel driver frees its fast-path state only after
a grace period, but amt_rcv() does more than read that state: it queues
work and links tunnels that amt_dev_stop() must already have stopped.
Found by code inspection; not triggered.
Fixes: cbc21dc1cfe9 ("amt: add data plane of amt interface")
Fixes: 72d3b9a4c2b1 ("udp_tunnel: Remove synchronize_rcu() in udp_tunnel_sock_release().")
Assisted-by: LLM
Signed-off-by: Omar Ramadan <omar@blockcast.net>
---
drivers/net/amt.c | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/drivers/net/amt.c b/drivers/net/amt.c
index 81d26ef82..3a703b79f 100644
--- a/drivers/net/amt.c
+++ b/drivers/net/amt.c
@@ -3073,9 +3073,14 @@ static int amt_dev_stop(struct net_device *dev)
disable_delayed_work_sync(&amt->discovery_wq);
cancel_delayed_work_sync(&amt->secret_wq);
- /* shutdown */
+ /* shutdown. Detach the receive path as well, so that once the
+ * grace period ends no amt_rcv() can still see the device and queue
+ * work after the teardown below.
+ */
sk = rtnl_dereference(amt->sk);
RCU_INIT_POINTER(amt->sk, NULL);
+ if (sk)
+ rcu_assign_sk_user_data(sk, NULL);
synchronize_net();
if (sk)
udp_tunnel_sock_release(sk);
--
2.43.0
^ permalink raw reply [flat|nested] 7+ messages in thread
* [PATCH net v7 2/6] amt: fix use-after-free when a Membership Update re-arms a freed tunnel
2026-11-10 21:05 [PATCH net v7 0/6] amt: relay tunnel lifetime, validation and request-path hardening Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 1/6] amt: stop receiving before amt_dev_stop() tears down its state Omar Ramadan
@ 2026-11-10 21:05 ` Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 3/6] amt: reject a gateway Query or Data from 0.0.0.0 before a relay is learned Omar Ramadan
` (3 subsequent siblings)
5 siblings, 0 replies; 7+ messages in thread
From: Omar Ramadan @ 2026-11-10 21:05 UTC (permalink / raw)
To: Taehee Yoo, Andrew Lunn, David S. Miller, Eric Dumazet,
Jakub Kicinski, Paolo Abeni
Cc: netdev, linux-kernel, Simon Horman, Sebastian Andrzej Siewior,
Clark Williams, Steven Rostedt, linux-rt-devel,
Kuniyuki Iwashima
amt_update_handler() refreshes a relay tunnel's garbage-collection timer
with mod_delayed_work() on a tunnel found by an RCU walk. A concurrent
amt_tunnel_expire() can unlink and free that tunnel while the Update path
still has the cached pointer, leaving gc_wq armed in freed memory.
A tunnel can also be freed twice. amt_dev_stop() unlinks and frees
tunnels without amt->lock, while amt_tunnel_expire() unlinks and frees
them under it, so an ifdown that races a tunnel timeout can run
list_del_rcu() and kfree_rcu() on the same tunnel twice.
Group and source nodes have lifetime bugs of their own:
- __amt_source_gc_work() frees a source, and amt_del_group() a group,
after only cancel_delayed_work(), which does not wait for a timer work
that is already running. amt_source_work() or amt_group_work() can
then run on the freed node.
- amt_source_work() reads snode->gnode and gnode->tunnel_list, and
amt_group_work() gnode->tunnel_list, before taking tunnel->lock or the
RCU read lock, so either can follow a pointer to a group or tunnel
that has already been freed.
- A timer work that waits for tunnel->lock while its node is deleted can
then act on the deleted node: amt_source_work() destroys the source
again, or amt_group_work() deletes the group again. Either decrements
nr_sources or nr_groups a second time and unlinks, queues or frees the
node a second time.
Make amt_tunnel_expire() the only timeout-path freer. The Update path
records last_seen under tunnel->lock, and expire reads it under
tunnel->lock and, in the same section, either re-arms gc_wq from it or
marks the tunnel dead for freeing, so an Update either keeps the tunnel
alive or finds it dead. Protect the dead state with tunnel->lock so
report handling cannot add multicast state after teardown begins.
Device stop marks tunnels dead under amt->lock, unlinks them one at a
time under it, synchronously cancels their work, and frees them after
lockless readers have passed.
Apply the same lifetime rules to group and source timer work: mark nodes
dead under tunnel->lock, keep RCU held while workers resolve their
parents, clear the parent pointers before a node is queued for freeing,
defer node freeing, and synchronously cancel each node's timer before
freeing that node. Park sources and groups through a gc_node of their
own. A destroyed source used to reuse snode->node, its link into the
group's hash; no lockless reader is known to follow it onto the GC list,
so this is hardening. Run the deferred GC directly before freeing a
tunnel so nodes are reclaimed promptly. Do not flush the GC work
instead: flush_delayed_work() queues the work and waits for it to run,
which needs a free amt_wq active slot, so amt_tunnel_expire() and the
group GC, both amt_wq work, can deadlock once every slot is held by such
a waiter. The cancel_delayed_work_sync() calls the GC makes need no
slot: each takes a pending timer work off its timer or queue, or waits
for a running one on a barrier that max_active does not count. Lifetime
does not rest on that GC run, which misses nodes a concurrent run has
already spliced off: it rests on kfree_rcu() and on the parent pointers
being cleared, and the dead checks made, under tunnel->lock. Release the
group timer's device reference on every exit.
A node on a GC list is no longer charged to max_groups or max_sources,
and unless a tunnel expires or the device stops, only the periodic GC
drains the lists, every 30 seconds. Sources were already parked that
way; groups, which went to kfree_rcu() before, now are too, so
Report/Leave churn from one gateway could pin memory without bound in
between. Count the nodes parked on both lists, under the one lock that
now protects both, and when the count reaches AMT_GC_KICK (2048) queue
the drain of both lists at once. A parked group still holds its
AMT_HSIZE (256) source hash heads, so it is a kmalloc-4k object, and
2048 of them is about 8 MiB; the kick keeps the backlog near that
instead of letting it grow for up to 30 seconds. A periodic drain that
ends with that many parked again runs again at once rather than after
the interval. This also bounds what amt_dev_stop() has to free in its
drain under RTNL, and both drains now call cond_resched() after each
node.
A Request-only tunnel retains the original GMI timeout. Its last_seen
stays 0, a value an Update never stores, and only a valid, authenticated
Membership Update refreshes last_seen and gives the tunnel the longer
lifetime: an Update whose encapsulated message fails ip_mc_check_igmp()
or ipv6_mc_check_mld() no longer extends the tunnel. amt_tunnel_expire()
reads jiffies once, so the re-arm delay comes from the value it
compared.
The Sashiko AI review of an earlier posting of this series pointed out
the gc_wq re-arm, and reading amt_update_handler() against
amt_tunnel_expire() and amt_dev_stop() confirms it. It is theoretical:
it needs a Membership Update that has already found the tunnel under RCU
to arrive while amt_tunnel_expire() is freeing that tunnel. Review of
later postings raised the races in the group and source timer work, the
amt_dev_stop() double free and the unbounded GC lists. I have not
triggered any of them, so there is no trace to include.
Fixes: cbc21dc1cfe9 ("amt: add data plane of amt interface")
Fixes: bc54e49c140b ("amt: add multicast(IGMP) report message handler")
Assisted-by: LLM
Signed-off-by: Omar Ramadan <omar@blockcast.net>
---
drivers/net/amt.c | 239 +++++++++++++++++++++++++++++++++++++++++-----
include/net/amt.h | 18 ++++
2 files changed, 232 insertions(+), 25 deletions(-)
diff --git a/drivers/net/amt.c b/drivers/net/amt.c
index 3a703b79f..fc3900e25 100644
--- a/drivers/net/amt.c
+++ b/drivers/net/amt.c
@@ -35,9 +35,19 @@
static struct workqueue_struct *amt_wq;
static HLIST_HEAD(source_gc_list);
-/* Lock for source_gc_list */
+/* Lock for source_gc_list, group_gc_list and the counts below */
static spinlock_t source_gc_lock;
static struct delayed_work source_gc_wq;
+static HLIST_HEAD(group_gc_list);
+static struct delayed_work group_gc_wq;
+/* Nodes parked on source_gc_list and group_gc_list, and the sources among
+ * them, so that a drain of either list can take its nodes off the count.
+ * Parked nodes are not charged to max_groups or max_sources, so
+ * Report/Leave churn could park them faster than the AMT_GC_INTERVAL tick
+ * frees them: once AMT_GC_KICK are parked, the drain runs at once.
+ */
+static unsigned int amt_gc_queued;
+static unsigned int amt_gc_sources;
static char *status_str[] = {
"AMT_STATUS_INIT",
"AMT_STATUS_SENT_DISCOVERY",
@@ -88,11 +98,15 @@ static void __amt_source_gc_work(void)
spin_lock_bh(&source_gc_lock);
hlist_move_list(&source_gc_list, &gc_list);
+ amt_gc_queued -= amt_gc_sources;
+ amt_gc_sources = 0;
spin_unlock_bh(&source_gc_lock);
- hlist_for_each_entry_safe(snode, t, &gc_list, node) {
- hlist_del_rcu(&snode->node);
+ hlist_for_each_entry_safe(snode, t, &gc_list, gc_node) {
+ cancel_delayed_work_sync(&snode->source_timer);
+ hlist_del_init(&snode->gc_node);
kfree_rcu(snode, rcu);
+ cond_resched();
}
}
@@ -106,6 +120,62 @@ static void amt_source_gc_work(struct work_struct *work)
spin_unlock_bh(&source_gc_lock);
}
+static void __amt_group_gc_work(void)
+{
+ struct amt_group_node *gnode;
+ struct hlist_head gc_list;
+ struct hlist_node *t;
+
+ spin_lock_bh(&source_gc_lock);
+ hlist_move_list(&group_gc_list, &gc_list);
+ amt_gc_queued = amt_gc_sources;
+ spin_unlock_bh(&source_gc_lock);
+
+ /* Group deletion queues all of its sources first. Reclaim them now
+ * rather than at the next periodic run; a source callback cannot
+ * reach a freed group because it resolves snode->gnode under RCU and
+ * the pointer is cleared before the source is queued. Call the GC
+ * here rather than flush source_gc_wq: a flush queues the work and
+ * waits for it to run, which needs a free amt_wq active slot, and
+ * when this runs on amt_wq every slot can be held by such waiters.
+ * The cancel_delayed_work_sync() calls in the GC need no slot: each
+ * takes a pending timer work off its timer or queue, or waits for a
+ * running one on a barrier that max_active does not count.
+ */
+ __amt_source_gc_work();
+
+ hlist_for_each_entry_safe(gnode, t, &gc_list, gc_node) {
+ cancel_delayed_work_sync(&gnode->group_timer);
+ hlist_del_init(&gnode->gc_node);
+ kfree_rcu(gnode, rcu);
+ cond_resched();
+ }
+}
+
+static void amt_group_gc_work(struct work_struct *work)
+{
+ __amt_group_gc_work();
+
+ /* Run again at once if AMT_GC_KICK nodes were parked meanwhile: a
+ * kick that landed during the drain must not be pushed back to the
+ * interval.
+ */
+ spin_lock_bh(&source_gc_lock);
+ mod_delayed_work(amt_wq, &group_gc_wq,
+ amt_gc_queued >= AMT_GC_KICK ? 0 :
+ msecs_to_jiffies(AMT_GC_INTERVAL));
+ spin_unlock_bh(&source_gc_lock);
+}
+
+/* Caller holds source_gc_lock and has just parked one node. This runs
+ * under tunnel->lock, often in softirq; mod_delayed_work() does not sleep.
+ */
+static void amt_gc_parked(void)
+{
+ if (++amt_gc_queued == AMT_GC_KICK)
+ mod_delayed_work(amt_wq, &group_gc_wq, 0);
+}
+
static bool amt_addr_equal(union amt_addr *a, union amt_addr *b)
{
return !memcmp(a, b, sizeof(union amt_addr));
@@ -215,8 +285,13 @@ static struct amt_group_node *amt_lookup_group(struct amt_tunnel_list *tunnel,
static void amt_destroy_source(struct amt_source_node *snode)
{
- struct amt_group_node *gnode = snode->gnode;
struct amt_tunnel_list *tunnel;
+ struct amt_group_node *gnode;
+
+ if (snode->dead)
+ return;
+ snode->dead = true;
+ gnode = snode->gnode;
tunnel = gnode->tunnel_list;
@@ -238,8 +313,11 @@ static void amt_destroy_source(struct amt_source_node *snode)
hlist_del_init_rcu(&snode->node);
tunnel->nr_sources--;
gnode->nr_sources--;
+ WRITE_ONCE(snode->gnode, NULL);
spin_lock_bh(&source_gc_lock);
- hlist_add_head_rcu(&snode->node, &source_gc_list);
+ hlist_add_head(&snode->gc_node, &source_gc_list);
+ amt_gc_sources++;
+ amt_gc_parked();
spin_unlock_bh(&source_gc_lock);
}
@@ -249,6 +327,9 @@ static void amt_del_group(struct amt_dev *amt, struct amt_group_node *gnode)
struct hlist_node *t;
int i;
+ if (gnode->dead)
+ return;
+ gnode->dead = true;
if (cancel_delayed_work(&gnode->group_timer))
dev_put(amt->dev);
hlist_del_rcu(&gnode->node);
@@ -266,10 +347,11 @@ static void amt_del_group(struct amt_dev *amt, struct amt_group_node *gnode)
hlist_for_each_entry_safe(snode, t, &gnode->sources[i], node)
amt_destroy_source(snode);
- /* tunnel->lock was acquired outside of amt_del_group()
- * But rcu_read_lock() was acquired too so It's safe.
- */
- kfree_rcu(gnode, rcu);
+ WRITE_ONCE(gnode->tunnel_list, NULL);
+ spin_lock_bh(&source_gc_lock);
+ hlist_add_head(&gnode->gc_node, &group_gc_list);
+ amt_gc_parked();
+ spin_unlock_bh(&source_gc_lock);
}
/* If a source timer expires with a router filter-mode for the group of
@@ -282,13 +364,28 @@ static void amt_source_work(struct work_struct *work)
struct amt_source_node *snode = container_of(to_delayed_work(work),
struct amt_source_node,
source_timer);
- struct amt_group_node *gnode = snode->gnode;
- struct amt_dev *amt = gnode->amt;
struct amt_tunnel_list *tunnel;
+ struct amt_group_node *gnode;
+ struct amt_dev *amt;
- tunnel = gnode->tunnel_list;
- spin_lock_bh(&tunnel->lock);
rcu_read_lock();
+ gnode = READ_ONCE(snode->gnode);
+ if (!gnode) {
+ rcu_read_unlock();
+ return;
+ }
+ amt = gnode->amt;
+ tunnel = READ_ONCE(gnode->tunnel_list);
+ if (!tunnel) {
+ rcu_read_unlock();
+ return;
+ }
+ spin_lock_bh(&tunnel->lock);
+ if (snode->dead || gnode->dead) {
+ spin_unlock_bh(&tunnel->lock);
+ rcu_read_unlock();
+ return;
+ }
if (gnode->filter_mode == MCAST_INCLUDE) {
amt_destroy_source(snode);
if (!gnode->nr_sources)
@@ -299,8 +396,8 @@ static void amt_source_work(struct work_struct *work)
*/
snode->status = AMT_SOURCE_STATUS_D_FWD;
}
- rcu_read_unlock();
spin_unlock_bh(&tunnel->lock);
+ rcu_read_unlock();
}
static void amt_act_src(struct amt_tunnel_list *tunnel,
@@ -368,6 +465,7 @@ static struct amt_source_node *amt_alloc_snode(struct amt_group_node *gnode,
snode->status = AMT_SOURCE_STATUS_NONE;
snode->flags = AMT_SOURCE_NEW;
INIT_HLIST_NODE(&snode->node);
+ INIT_HLIST_NODE(&snode->gc_node);
INIT_DELAYED_WORK(&snode->source_timer, amt_source_work);
return snode;
@@ -403,23 +501,31 @@ static void amt_group_work(struct work_struct *work)
struct amt_group_node *gnode = container_of(to_delayed_work(work),
struct amt_group_node,
group_timer);
- struct amt_tunnel_list *tunnel = gnode->tunnel_list;
- struct amt_dev *amt = gnode->amt;
+ struct amt_tunnel_list *tunnel;
struct amt_source_node *snode;
bool delete_group = true;
struct hlist_node *t;
+ struct amt_dev *amt;
int i, buckets;
+ rcu_read_lock();
+ tunnel = READ_ONCE(gnode->tunnel_list);
+ amt = gnode->amt;
+ if (!tunnel)
+ goto out;
buckets = amt->hash_buckets;
spin_lock_bh(&tunnel->lock);
+ if (gnode->dead) {
+ spin_unlock_bh(&tunnel->lock);
+ goto out;
+ }
if (gnode->filter_mode == MCAST_INCLUDE) {
/* Not Used */
spin_unlock_bh(&tunnel->lock);
goto out;
}
- rcu_read_lock();
for (i = 0; i < buckets; i++) {
hlist_for_each_entry_safe(snode, t,
&gnode->sources[i], node) {
@@ -436,9 +542,9 @@ static void amt_group_work(struct work_struct *work)
amt_del_group(amt, gnode);
else
gnode->filter_mode = MCAST_INCLUDE;
- rcu_read_unlock();
spin_unlock_bh(&tunnel->lock);
out:
+ rcu_read_unlock();
dev_put(amt->dev);
}
@@ -479,6 +585,7 @@ static struct amt_group_node *amt_add_group(struct amt_dev *amt,
gnode->tunnel_list = tunnel;
gnode->filter_mode = MCAST_INCLUDE;
INIT_HLIST_NODE(&gnode->node);
+ INIT_HLIST_NODE(&gnode->gc_node);
INIT_DELAYED_WORK(&gnode->group_timer, amt_group_work);
for (i = 0; i < amt->hash_buckets; i++)
INIT_HLIST_HEAD(&gnode->sources[i]);
@@ -1342,14 +1449,51 @@ static void amt_tunnel_expire(struct work_struct *work)
struct amt_tunnel_list,
gc_wq);
struct amt_dev *amt = tunnel->amt;
+ unsigned long deadline, now;
spin_lock_bh(&amt->lock);
+ spin_lock_bh(&tunnel->lock);
+ /* amt_dev_stop() has taken ownership of the teardown. */
+ if (READ_ONCE(tunnel->dead)) {
+ spin_unlock_bh(&tunnel->lock);
+ spin_unlock_bh(&amt->lock);
+ return;
+ }
+ /* A Membership Update may have refreshed the tunnel after this work
+ * was queued. Re-arm from last_seen rather than letting
+ * amt_update_handler() re-arm gc_wq: this handler is then the only
+ * context that frees the tunnel on the timeout path; amt_dev_stop()
+ * still owns teardown during device shutdown. last_seen is read and
+ * dead is set under tunnel->lock, which the Update path holds while
+ * it refreshes last_seen, so an Update either keeps the tunnel alive
+ * or finds it dead.
+ */
+ now = jiffies;
+ deadline = tunnel->last_seen;
+ /* No Update yet: the Request armed gc_wq for one GMI. */
+ if (!deadline)
+ deadline = now;
+ else
+ deadline += msecs_to_jiffies(amt_gmi(amt)) * 3;
+ if (time_before(now, deadline)) {
+ mod_delayed_work(amt_wq, &tunnel->gc_wq, deadline - now);
+ spin_unlock_bh(&tunnel->lock);
+ spin_unlock_bh(&amt->lock);
+ return;
+ }
+ WRITE_ONCE(tunnel->dead, true);
+ spin_unlock_bh(&tunnel->lock);
rcu_read_lock();
list_del_rcu(&tunnel->list);
amt->nr_tunnels--;
amt_clear_groups(tunnel);
rcu_read_unlock();
spin_unlock_bh(&amt->lock);
+ /* Free now, rather than at the next periodic run, the nodes that
+ * amt_clear_groups() queued. Call the GC rather than flush
+ * group_gc_wq, as explained in __amt_group_gc_work().
+ */
+ __amt_group_gc_work();
kfree_rcu(tunnel, rcu);
}
@@ -2508,9 +2652,6 @@ static bool amt_update_handler(struct amt_dev *amt, struct sk_buff *skb)
if (tunnel->ip4 == saddr) {
if ((nonce == tunnel->nonce &&
response_mac == tunnel->mac)) {
- mod_delayed_work(amt_wq, &tunnel->gc_wq,
- msecs_to_jiffies(amt_gmi(amt))
- * 3);
goto report;
} else {
netdev_dbg(amt->dev, "Invalid MAC\n");
@@ -2536,6 +2677,18 @@ static bool amt_update_handler(struct amt_dev *amt, struct sk_buff *skb)
}
spin_lock_bh(&tunnel->lock);
+ if (READ_ONCE(tunnel->dead)) {
+ spin_unlock_bh(&tunnel->lock);
+ return true;
+ }
+ /* Refresh liveness under tunnel->lock, where
+ * amt_tunnel_expire() reads it; 0 is kept for a tunnel no
+ * Update has refreshed. gc_wq is re-armed by
+ * amt_tunnel_expire() itself from last_seen, never from
+ * here, so this cannot re-queue the work of a tunnel that is
+ * being freed.
+ */
+ tunnel->last_seen = jiffies ?: 1;
amt_igmp_report_handler(amt, skb, tunnel);
spin_unlock_bh(&tunnel->lock);
@@ -2556,6 +2709,11 @@ static bool amt_update_handler(struct amt_dev *amt, struct sk_buff *skb)
}
spin_lock_bh(&tunnel->lock);
+ if (READ_ONCE(tunnel->dead)) {
+ spin_unlock_bh(&tunnel->lock);
+ return true;
+ }
+ tunnel->last_seen = jiffies ?: 1;
amt_mld_report_handler(amt, skb, tunnel);
spin_unlock_bh(&tunnel->lock);
@@ -3064,7 +3222,7 @@ static int amt_dev_open(struct net_device *dev)
static int amt_dev_stop(struct net_device *dev)
{
struct amt_dev *amt = netdev_priv(dev);
- struct amt_tunnel_list *tunnel, *tmp;
+ struct amt_tunnel_list *tunnel;
struct sk_buff *skb;
struct sock *sk;
int i;
@@ -3098,11 +3256,37 @@ static int amt_dev_stop(struct net_device *dev)
amt->req_cnt = 0;
WRITE_ONCE(amt->remote_ip, 0);
- list_for_each_entry_safe(tunnel, tmp, &amt->tunnel_list, list) {
- list_del_rcu(&tunnel->list);
- amt->nr_tunnels--;
+ /* Mark every tunnel dead first, so a concurrent amt_tunnel_expire()
+ * sees ->dead and bails instead of freeing a tunnel in parallel; this
+ * path is then the sole freer.
+ */
+ spin_lock_bh(&amt->lock);
+ list_for_each_entry(tunnel, &amt->tunnel_list, list) {
+ spin_lock_bh(&tunnel->lock);
+ WRITE_ONCE(tunnel->dead, true);
+ spin_unlock_bh(&tunnel->lock);
+ }
+ spin_unlock_bh(&amt->lock);
+
+ /* Unlink one tunnel at a time with list_del_rcu() rather than splicing
+ * the list onto a local head: a lockless reader walking tunnel_list
+ * must always terminate at &amt->tunnel_list, never at a stack head.
+ */
+ for (;;) {
+ spin_lock_bh(&amt->lock);
+ tunnel = list_first_entry_or_null(&amt->tunnel_list,
+ struct amt_tunnel_list, list);
+ if (tunnel) {
+ list_del_rcu(&tunnel->list);
+ amt->nr_tunnels--;
+ }
+ spin_unlock_bh(&amt->lock);
+ if (!tunnel)
+ break;
+
cancel_delayed_work_sync(&tunnel->gc_wq);
amt_clear_groups(tunnel);
+ __amt_group_gc_work();
kfree_rcu(tunnel, rcu);
}
@@ -3484,6 +3668,9 @@ static int __init amt_init(void)
INIT_DELAYED_WORK(&source_gc_wq, amt_source_gc_work);
mod_delayed_work(amt_wq, &source_gc_wq,
msecs_to_jiffies(AMT_GC_INTERVAL));
+ INIT_DELAYED_WORK(&group_gc_wq, amt_group_gc_work);
+ mod_delayed_work(amt_wq, &group_gc_wq,
+ msecs_to_jiffies(AMT_GC_INTERVAL));
spin_unlock_bh(&source_gc_lock);
return 0;
@@ -3504,6 +3691,8 @@ static void __exit amt_fini(void)
unregister_netdevice_notifier(&amt_notifier_block);
cancel_delayed_work_sync(&source_gc_wq);
__amt_source_gc_work();
+ cancel_delayed_work_sync(&group_gc_wq);
+ __amt_group_gc_work();
destroy_workqueue(amt_wq);
}
module_exit(amt_fini);
diff --git a/include/net/amt.h b/include/net/amt.h
index 2846dde0c..dedf328d8 100644
--- a/include/net/amt.h
+++ b/include/net/amt.h
@@ -240,6 +240,17 @@ struct amt_tunnel_list {
u32 nr_sources;
enum amt_status status;
struct delayed_work gc_wq;
+ /* Last time a Membership Update refreshed this tunnel, under ->lock.
+ * gc_wq re-arms itself from this rather than being re-armed by the
+ * Update path, so the Update path cannot re-queue the work of a
+ * tunnel that amt_tunnel_expire() is freeing.
+ */
+ unsigned long last_seen;
+ /* Set under amt->lock and tunnel->lock by whichever freer reaches the
+ * tunnel first; the other then leaves it alone, so the tunnel is freed
+ * exactly once.
+ */
+ bool dead;
__be16 source_port;
__be32 ip4;
__be32 nonce;
@@ -275,6 +286,7 @@ enum amt_source_status {
/* protected by gnode->lock */
struct amt_source_node {
struct hlist_node node;
+ struct hlist_node gc_node;
struct amt_group_node *gnode;
struct delayed_work source_timer;
union amt_addr source_addr;
@@ -282,6 +294,8 @@ struct amt_source_node {
#define AMT_SOURCE_OLD 0
#define AMT_SOURCE_NEW 1
u8 flags;
+ /* Protected by gnode->tunnel_list->lock. */
+ bool dead;
struct rcu_head rcu;
};
@@ -295,7 +309,10 @@ struct amt_group_node {
u32 nr_sources;
struct amt_tunnel_list *tunnel_list;
struct hlist_node node;
+ struct hlist_node gc_node;
struct delayed_work group_timer;
+ /* Protected by tunnel_list->lock. */
+ bool dead;
struct rcu_head rcu;
struct hlist_head sources[];
};
@@ -370,6 +387,7 @@ struct amt_dev {
#define AMT_IPHDR_OPTS 4
#define AMT_IP6HDR_OPTS 8
#define AMT_GC_INTERVAL (30 * 1000)
+#define AMT_GC_KICK 2048
#define AMT_MAX_GROUP 32
#define AMT_MAX_SOURCE 128
#define AMT_HSIZE_SHIFT 8
--
2.43.0
^ permalink raw reply [flat|nested] 7+ messages in thread
* [PATCH net v7 3/6] amt: reject a gateway Query or Data from 0.0.0.0 before a relay is learned
2026-11-10 21:05 [PATCH net v7 0/6] amt: relay tunnel lifetime, validation and request-path hardening Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 1/6] amt: stop receiving before amt_dev_stop() tears down its state Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 2/6] amt: fix use-after-free when a Membership Update re-arms a freed tunnel Omar Ramadan
@ 2026-11-10 21:05 ` Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 4/6] amt: require an exact IPv4-sized IFLA_AMT_{LOCAL,DISCOVERY}_IP Omar Ramadan
` (2 subsequent siblings)
5 siblings, 0 replies; 7+ messages in thread
From: Omar Ramadan @ 2026-11-10 21:05 UTC (permalink / raw)
To: Taehee Yoo, Andrew Lunn, David S. Miller, Eric Dumazet,
Jakub Kicinski, Paolo Abeni
Cc: netdev, linux-kernel, Simon Horman, Sebastian Andrzej Siewior,
Clark Williams, Steven Rostedt, linux-rt-devel,
Kuniyuki Iwashima
A gateway's amt_rcv() admits a Membership Query or Multicast Data
message when "saddr != remote_ip" is false. remote_ip is 0 before
discovery has learned a relay (and is reset to 0 by amt_dev_open(), by
amt_dev_stop(), and when the gateway gives up after its Requests go
unanswered), so a packet whose source is 0.0.0.0 would otherwise pass
the equality check and could spoof a Query or inject multicast data.
IPv4 input accepts a 0.0.0.0 source only on a limited broadcast or a
link-local (224.0.0.0/24) multicast destination, so the sender has to be
on the gateway's link.
A forged Query must also carry the gateway's nonce. That is 0 until the
next discovery tick re-randomizes it, because amt_dev_open() and the
give-up path in amt_event_send_request() zero amt->nonce along with
remote_ip. The Query handler then sets ready4 or ready6, mac and qi, and
moves the gateway to AMT_STATUS_RECEIVED_QUERY. From there
amt_event_send_discovery() returns early and amt_advertisement_handler()
rejects every Advertisement, so discovery stays blocked until the
Request retries run out and the device is back in AMT_STATUS_INIT with
nonce 0. The Data check is defense in depth: amt_multicast_data_handler()
already requires AMT_STATUS_SENT_UPDATE, which a gateway with no relay
reaches only after such a forged Query, or in the short window in which
the give-up path has cleared remote_ip but not yet reset the status.
Reject both when no relay is learned: a 0 remote_ip must match nothing, so
guard the check with !remote_ip. A Query is queued before its event worker
processes it, so the relay address can also change in that window. Recheck
the packet source against the current remote_ip in the handler and discard
stale Queries. Discovery is unaffected because Relay Advertisements are
validated against discovery_ip, not remote_ip.
Found by code inspection while writing the IPv6 receive path for the
AMT IPv6 outer transport; not triggered at runtime. The Sashiko AI
review of an earlier posting pointed out the nonce condition, the
blocked discovery and that the Data check is defense in depth.
Fixes: cbc21dc1cfe9 ("amt: add data plane of amt interface")
Fixes: 30e22a6ebca0 ("amt: use workqueue for gateway side message handling")
Assisted-by: LLM
Signed-off-by: Omar Ramadan <omar@blockcast.net>
---
drivers/net/amt.c | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/drivers/net/amt.c b/drivers/net/amt.c
index fc3900e25..9f59ec6c4 100644
--- a/drivers/net/amt.c
+++ b/drivers/net/amt.c
@@ -2527,6 +2527,10 @@ static bool amt_membership_query_handler(struct amt_dev *amt,
if (amtmq->reserved || amtmq->version)
return true;
+ /* A deferred query may outlive a relay address change. */
+ if (ip_hdr(skb)->saddr != READ_ONCE(amt->remote_ip))
+ return true;
+
if (amtmq->nonce != amt->nonce)
return true;
@@ -3005,7 +3009,7 @@ static int amt_rcv(struct sock *sk, struct sk_buff *skb)
}
goto out;
case AMT_MSG_MULTICAST_DATA:
- if (saddr != remote_ip) {
+ if (!remote_ip || saddr != remote_ip) {
netdev_dbg(amt->dev, "Invalid Relay IP\n");
err = true;
goto drop;
@@ -3016,7 +3020,7 @@ static int amt_rcv(struct sock *sk, struct sk_buff *skb)
else
goto out;
case AMT_MSG_MEMBERSHIP_QUERY:
- if (saddr != remote_ip) {
+ if (!remote_ip || saddr != remote_ip) {
netdev_dbg(amt->dev, "Invalid Relay IP\n");
err = true;
goto drop;
--
2.43.0
^ permalink raw reply [flat|nested] 7+ messages in thread
* [PATCH net v7 4/6] amt: require an exact IPv4-sized IFLA_AMT_{LOCAL,DISCOVERY}_IP
2026-11-10 21:05 [PATCH net v7 0/6] amt: relay tunnel lifetime, validation and request-path hardening Omar Ramadan
` (2 preceding siblings ...)
2026-11-10 21:05 ` [PATCH net v7 3/6] amt: reject a gateway Query or Data from 0.0.0.0 before a relay is learned Omar Ramadan
@ 2026-11-10 21:05 ` Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 5/6] amt: make pre-query report drops visible Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 6/6] amt: create relay tunnels only from authenticated Updates Omar Ramadan
5 siblings, 0 replies; 7+ messages in thread
From: Omar Ramadan @ 2026-11-10 21:05 UTC (permalink / raw)
To: Taehee Yoo, Andrew Lunn, David S. Miller, Eric Dumazet,
Jakub Kicinski, Paolo Abeni
Cc: netdev, linux-kernel, Simon Horman, Sebastian Andrzej Siewior,
Clark Williams, Steven Rostedt, linux-rt-devel,
Kuniyuki Iwashima
The netlink policy for IFLA_AMT_LOCAL_IP and IFLA_AMT_DISCOVERY_IP is an
untyped entry with .len set to the size of an IPv4 address, which netlink
validation treats as a minimum length only. Any longer payload passes,
and nla_get_in_addr() then reads just its first four bytes: an iproute2
that puts a 16-byte IPv6 literal in one of these IPv4 attributes turns
"2001:db8::" into 32.1.13.184, and the device is created with an address
the user never asked for.
Use NLA_POLICY_EXACT_LEN(sizeof(__be32)) for both, so every payload that
is not exactly four bytes long is refused with a standard extack instead
of being truncated. The range check this expands to fails the attribute
even under the liberal parsing rtnetlink applies to IFLA_INFO_DATA.
amt_get_size() and amt_fill_info() already emit exactly four bytes for
these attributes. IFLA_AMT_REMOTE_IP is only ever reported, never read
from a request, so it is left unchanged.
Triggered while writing a selftest for the AMT IPv6 outer transport:
an iproute2 without the IPv6 attributes put all 16 bytes of
"discovery 2001:db8:a::2" into IFLA_AMT_DISCOVERY_IP. On a stock
kernel the command exited 0 with no error or warning, and the device
came up as an IPv4 gateway with discovery address 32.1.13.184, the
first four bytes. There is no oops or trace; the symptom is the
silently wrong address, which fooled the selftest's capability probe.
Fixes: b9022b53adad ("amt: add control plane of amt interface")
Assisted-by: LLM
Signed-off-by: Omar Ramadan <omar@blockcast.net>
---
drivers/net/amt.c | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/drivers/net/amt.c b/drivers/net/amt.c
index 9f59ec6c4..99cce0a14 100644
--- a/drivers/net/amt.c
+++ b/drivers/net/amt.c
@@ -3359,9 +3359,9 @@ static const struct nla_policy amt_policy[IFLA_AMT_MAX + 1] = {
[IFLA_AMT_RELAY_PORT] = { .type = NLA_U16 },
[IFLA_AMT_GATEWAY_PORT] = { .type = NLA_U16 },
[IFLA_AMT_LINK] = { .type = NLA_U32 },
- [IFLA_AMT_LOCAL_IP] = { .len = sizeof_field(struct iphdr, daddr) },
+ [IFLA_AMT_LOCAL_IP] = NLA_POLICY_EXACT_LEN(sizeof(__be32)),
[IFLA_AMT_REMOTE_IP] = { .len = sizeof_field(struct iphdr, daddr) },
- [IFLA_AMT_DISCOVERY_IP] = { .len = sizeof_field(struct iphdr, daddr) },
+ [IFLA_AMT_DISCOVERY_IP] = NLA_POLICY_EXACT_LEN(sizeof(__be32)),
[IFLA_AMT_MAX_TUNNELS] = { .type = NLA_U32 },
};
--
2.43.0
^ permalink raw reply [flat|nested] 7+ messages in thread
* [PATCH net v7 5/6] amt: make pre-query report drops visible
2026-11-10 21:05 [PATCH net v7 0/6] amt: relay tunnel lifetime, validation and request-path hardening Omar Ramadan
` (3 preceding siblings ...)
2026-11-10 21:05 ` [PATCH net v7 4/6] amt: require an exact IPv4-sized IFLA_AMT_{LOCAL,DISCOVERY}_IP Omar Ramadan
@ 2026-11-10 21:05 ` Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 6/6] amt: create relay tunnels only from authenticated Updates Omar Ramadan
5 siblings, 0 replies; 7+ messages in thread
From: Omar Ramadan @ 2026-11-10 21:05 UTC (permalink / raw)
To: Taehee Yoo, Andrew Lunn, David S. Miller, Eric Dumazet,
Jakub Kicinski, Paolo Abeni
Cc: netdev, linux-kernel, Simon Horman, Sebastian Andrzej Siewior,
Clark Williams, Steven Rostedt, linux-rt-devel,
Kuniyuki Iwashima
A gateway cannot forward an IGMP or MLD report until it has received the
relay's Membership Query for that family. The Query supplies the
response MAC the Update echoes, so dropping an early report is required,
but doing so silently leaves operators with a dark multicast path and no
indication why the join never happened.
Emit a family-specific debug message before taking the drop path.
tx_dropped could not show these drops either: amt_dev_xmit() reaches the
same tx_dropped increment after amt_send_membership_update() has handed
the report to the tunnel, so in gateway mode every forwarded report was
also counted as a drop. Return NETDEV_TX_OK directly once the report has
been sent, so a report the tunnel accepted is no longer counted in
tx_dropped.
The device is LLTX, so amt_dev_xmit() can run on several CPUs at once;
use DEV_STATS_INC() for tx_dropped and tx_errors, there and in the
control-message senders that update the same counters, so concurrent
increments are not lost.
Found by code inspection while debugging an IPv6-in-IPv6 forwarding
failure in a selftest for the AMT IPv6 outer transport. This drop can
leave one address family dark, so it was a suspect, but that was not
confirmed: the failure was later traced to the selftest, which sent a
single datagram without waiting for the relay's multicast routes to
converge. The drop has not been observed in production, and there is
no oops or trace. The Sashiko AI review of an earlier posting found the
tx_dropped miscount and the non-atomic counter updates.
Fixes: cbc21dc1cfe9 ("amt: add data plane of amt interface")
Assisted-by: LLM
Signed-off-by: Omar Ramadan <omar@blockcast.net>
---
drivers/net/amt.c | 42 +++++++++++++++++++++++++-----------------
1 file changed, 25 insertions(+), 17 deletions(-)
diff --git a/drivers/net/amt.c b/drivers/net/amt.c
index 99cce0a14..de5b7dbe3 100644
--- a/drivers/net/amt.c
+++ b/drivers/net/amt.c
@@ -735,7 +735,7 @@ static void amt_send_discovery(struct amt_dev *amt)
IPPROTO_UDP, 0,
amt->stream_dev->ifindex);
if (IS_ERR(rt)) {
- amt->dev->stats.tx_errors++;
+ DEV_STATS_INC(amt->dev, tx_errors);
goto out;
}
@@ -745,7 +745,7 @@ static void amt_send_discovery(struct amt_dev *amt)
skb = netdev_alloc_skb_ip_align(amt->dev, len);
if (!skb) {
ip_rt_put(rt);
- amt->dev->stats.tx_errors++;
+ DEV_STATS_INC(amt->dev, tx_errors);
goto out;
}
@@ -790,7 +790,7 @@ static void amt_send_discovery(struct amt_dev *amt)
ip_send_check(iph);
err = ip_local_out(amt->net, sk, skb);
if (unlikely(net_xmit_eval(err)))
- amt->dev->stats.tx_errors++;
+ DEV_STATS_INC(amt->dev, tx_errors);
amt_update_gw_status(amt, AMT_STATUS_SENT_DISCOVERY, true);
out:
@@ -826,7 +826,7 @@ static void amt_send_request(struct amt_dev *amt, bool v6)
IPPROTO_UDP, 0,
amt->stream_dev->ifindex);
if (IS_ERR(rt)) {
- amt->dev->stats.tx_errors++;
+ DEV_STATS_INC(amt->dev, tx_errors);
goto out;
}
@@ -836,7 +836,7 @@ static void amt_send_request(struct amt_dev *amt, bool v6)
skb = netdev_alloc_skb_ip_align(amt->dev, len);
if (!skb) {
ip_rt_put(rt);
- amt->dev->stats.tx_errors++;
+ DEV_STATS_INC(amt->dev, tx_errors);
goto out;
}
@@ -883,7 +883,7 @@ static void amt_send_request(struct amt_dev *amt, bool v6)
ip_send_check(iph);
err = ip_local_out(amt->net, sk, skb);
if (unlikely(net_xmit_eval(err)))
- amt->dev->stats.tx_errors++;
+ DEV_STATS_INC(amt->dev, tx_errors);
out:
rcu_read_unlock();
@@ -905,7 +905,7 @@ static void amt_send_igmp_gq(struct amt_dev *amt,
skb_pull(skb, sizeof(struct ethhdr));
if (amt_send_membership_query(amt, skb, tunnel, false)) {
- amt->dev->stats.tx_dropped++;
+ DEV_STATS_INC(amt->dev, tx_dropped);
kfree_skb(skb);
}
}
@@ -949,7 +949,7 @@ static struct sk_buff *amt_build_mld_gq(struct amt_dev *amt)
if (ipv6_dev_get_saddr(amt->net, amt->dev, &ip6h->daddr, 0,
&ip6h->saddr)) {
- amt->dev->stats.tx_errors++;
+ DEV_STATS_INC(amt->dev, tx_errors);
kfree_skb(skb);
return NULL;
}
@@ -993,7 +993,7 @@ static void amt_send_mld_gq(struct amt_dev *amt, struct amt_tunnel_list *tunnel)
skb_pull(skb, sizeof(struct ethhdr));
if (amt_send_membership_query(amt, skb, tunnel, true)) {
- amt->dev->stats.tx_dropped++;
+ DEV_STATS_INC(amt->dev, tx_dropped);
kfree_skb(skb);
}
}
@@ -1353,7 +1353,7 @@ static netdev_tx_t amt_dev_xmit(struct sk_buff *skb, struct net_device *dev)
group.ip6 = ipv6_hdr(skb)->daddr;
#endif
} else {
- dev->stats.tx_errors++;
+ DEV_STATS_INC(dev, tx_errors);
goto free;
}
@@ -1366,12 +1366,21 @@ static netdev_tx_t amt_dev_xmit(struct sk_buff *skb, struct net_device *dev)
/* Gateway only passes IGMP/MLD packets */
if (!report)
goto free;
+ /* A validated report can only be forwarded after the relay's
+ * family-specific Membership Query supplies the state echoed
+ * by the Membership Update. Log this readiness failure before
+ * the shared drop path accounts it.
+ */
if ((!v6 && !READ_ONCE(amt->ready4)) ||
- (v6 && !READ_ONCE(amt->ready6)))
+ (v6 && !READ_ONCE(amt->ready6))) {
+ netdev_dbg(dev, "drop %s report: waiting for Membership Query\n",
+ v6 ? "MLD" : "IGMP");
goto free;
+ }
if (amt_send_membership_update(amt, skb, v6))
goto free;
- goto unlock;
+ /* Sent: the tunnel consumed the skb. Not a drop. */
+ return NETDEV_TX_OK;
} else if (amt->mode == AMT_MODE_RELAY) {
if (!data)
goto free;
@@ -1400,8 +1409,7 @@ static netdev_tx_t amt_dev_xmit(struct sk_buff *skb, struct net_device *dev)
return NETDEV_TX_OK;
free:
dev_kfree_skb(skb);
-unlock:
- dev->stats.tx_dropped++;
+ DEV_STATS_INC(dev, tx_dropped);
return NETDEV_TX_OK;
}
@@ -2777,7 +2785,7 @@ static void amt_send_advertisement(struct amt_dev *amt, __be32 nonce,
IPPROTO_UDP, 0,
amt->stream_dev->ifindex);
if (IS_ERR(rt)) {
- amt->dev->stats.tx_errors++;
+ DEV_STATS_INC(amt->dev, tx_errors);
goto out;
}
@@ -2787,7 +2795,7 @@ static void amt_send_advertisement(struct amt_dev *amt, __be32 nonce,
skb = netdev_alloc_skb_ip_align(amt->dev, len);
if (!skb) {
ip_rt_put(rt);
- amt->dev->stats.tx_errors++;
+ DEV_STATS_INC(amt->dev, tx_errors);
goto out;
}
@@ -2833,7 +2841,7 @@ static void amt_send_advertisement(struct amt_dev *amt, __be32 nonce,
ip_send_check(iph);
err = ip_local_out(amt->net, sk, skb);
if (unlikely(net_xmit_eval(err)))
- amt->dev->stats.tx_errors++;
+ DEV_STATS_INC(amt->dev, tx_errors);
out:
rcu_read_unlock();
--
2.43.0
^ permalink raw reply [flat|nested] 7+ messages in thread
* [PATCH net v7 6/6] amt: create relay tunnels only from authenticated Updates
2026-11-10 21:05 [PATCH net v7 0/6] amt: relay tunnel lifetime, validation and request-path hardening Omar Ramadan
` (4 preceding siblings ...)
2026-11-10 21:05 ` [PATCH net v7 5/6] amt: make pre-query report drops visible Omar Ramadan
@ 2026-11-10 21:05 ` Omar Ramadan
5 siblings, 0 replies; 7+ messages in thread
From: Omar Ramadan @ 2026-11-10 21:05 UTC (permalink / raw)
To: Taehee Yoo, Andrew Lunn, David S. Miller, Eric Dumazet,
Jakub Kicinski, Paolo Abeni
Cc: netdev, linux-kernel, Simon Horman, Sebastian Andrzej Siewior,
Clark Williams, Steven Rostedt, linux-rt-devel,
Kuniyuki Iwashima
An AMT Request has an unauthenticated, spoofable source address. Allocating
tunnel state from that path allowed spoofed Requests to consume tunnel
slots or overwrite the nonce and MAC of an established gateway.
Keep Requests stateless by deriving the Query MAC from the source
address, source port, nonce, and current secret, and retain four secret
generations so a gateway can renew its MAC across rotations.
Authenticate the Membership Update before decapsulation, and create or
refresh tunnel state only once ip_mc_check_igmp() or ipv6_mc_check_mld()
has accepted the encapsulated message. Key verified state by the
address/port endpoint, enforce global and per-source limits, and
silently reject an endpoint that cannot be admitted: withhold the Query
rather than send ICMP Host Unreachable to a source address nothing has
authenticated yet.
A MAC is now accepted only for a bounded time after the Query that
carried it. Before, a tunnel copied the secret when a Request created it
and never saw it rotate, so its MAC never expired. Now the secret
rotates every 60 seconds and four generations are kept, so a MAC is
accepted for 180 to 240 seconds. RFC 7450 section 5.2.3.5.4 has a
gateway start a query timer when it accepts a Query, with a duration it
SHOULD compute from the Query's QQIC (125 seconds here), and section
5.2.3.5.5 has it send a new Request when that timer expires, so a
gateway that times its next Request from QQIC renews its MAC inside the
window. The in-tree gateway re-Requests every two seconds while its
Requests are answered. A gateway that never sends another Request kept
working before; now its Updates are dropped as Invalid MAC once the
window has passed. So is the first Update after a new join from a
gateway that, as section 5.2.3.5.4 also allows, uses a longer timer
while it has no group subscriptions to report: it is dropped until that
gateway's next Request.
Which MACs are accepted changes too. Before, an Update had to carry the
nonce and MAC of the latest Request from its address; now any pair
minted for its address and port within the window is accepted. The MAC
never covered the encapsulated report, so this changes only how long a
pair seen by an on-path observer stays usable: off-path senders still
cannot produce a valid pair.
The secret is now seeded before amt_socket_create() enables the receive
path. It was seeded after the socket was created since
commit b9022b53adad ("amt: add control plane of amt interface"), when
there was no receive path yet, and
commit cbc21dc1cfe9 ("amt: add data plane of amt interface") added
amt_rcv() and the per-tunnel copy of the key without moving it. Since
then a Request that arrived during amt_dev_open() could get a tunnel
whose MAC used an all-zero key (on the first open) or the previous key
for the tunnel's whole life.
amt_update_tunnel() retries once when it finds its tunnel dead, and the
lookup skips dead tunnels, so an Update that races amt_tunnel_expire()
creates the replacement instead of being dropped. The Request path's
per-source count and the re-check under amt->lock in
amt_tunnel_get_or_create() skip them too. Every tunnel is now created by
an Update, which stamps last_seen, so amt_tunnel_expire() no longer
treats a last_seen of 0 as never refreshed. amt_tunnel_get_or_create()
also rechecks netif_running() under amt->lock before it links a tunnel.
dev_close() clears the running state before it calls amt_dev_stop(),
which drains the list under amt->lock, so an Update still being received
while the device goes down cannot link a tunnel after the drain.
Every Membership Update, forged ones included, reads the secret
generations to check its MAC. Their writers hold amt->lock and update
them inside a seqcount, amt->key_seq. The Update path copies them in a
key_seq read loop, so reading them takes amt->lock only on PREEMPT_RT,
to wait for a writer.
Keying by address and port lets one address hold several tunnels; before
this patch the Request path looked tunnels up by address alone, so an
address normally held only one. amt_max_tunnels_per_src() bounds that
at an eighth of max_tunnels, at least one tunnel and at most
AMT_MAX_TUNNELS_PER_SRC (16): 16 at the default 128 slots, 2 at 16. One
address then holds at most an eighth of a table of eight or more slots,
while gateways behind NAT still get several tunnels once the table has
16 or more slots. A configurable limit needs new uAPI, so it is left
for net-next.
Found by code inspection against RFC 7450; not triggered at runtime.
The Sashiko AI review of an earlier posting pointed out the undocumented
behaviour changes and that a fixed per-source cap let one address fill
a small table.
Fixes: cbc21dc1cfe9 ("amt: add data plane of amt interface")
Assisted-by: LLM
Signed-off-by: Omar Ramadan <omar@blockcast.net>
---
drivers/net/amt.c | 405 ++++++++++++++++++++++++++++++++--------------
include/net/amt.h | 14 +-
2 files changed, 294 insertions(+), 125 deletions(-)
diff --git a/drivers/net/amt.c b/drivers/net/amt.c
index de5b7dbe3..8014798b3 100644
--- a/drivers/net/amt.c
+++ b/drivers/net/amt.c
@@ -700,14 +700,6 @@ static void __amt_update_relay_status(struct amt_tunnel_list *tunnel,
tunnel->status = status;
}
-static void amt_update_relay_status(struct amt_tunnel_list *tunnel,
- enum amt_status status, bool validate)
-{
- spin_lock_bh(&tunnel->lock);
- __amt_update_relay_status(tunnel, status, validate);
- spin_unlock_bh(&tunnel->lock);
-}
-
static void amt_send_discovery(struct amt_dev *amt)
{
struct amt_header_discovery *amtd;
@@ -891,11 +883,16 @@ static void amt_send_request(struct amt_dev *amt, bool v6)
static bool amt_send_membership_query(struct amt_dev *amt,
struct sk_buff *skb,
- struct amt_tunnel_list *tunnel,
+ __be32 daddr, __be16 dport,
+ __be32 nonce, u64 mac,
bool v6);
-static void amt_send_igmp_gq(struct amt_dev *amt,
- struct amt_tunnel_list *tunnel)
+/* The destination is passed by value rather than as a tunnel: a Request is
+ * unauthenticated, so no tunnel state is created or changed for it - see
+ * amt_request_handler().
+ */
+static void amt_send_igmp_gq(struct amt_dev *amt, __be32 daddr, __be16 dport,
+ __be32 nonce, u64 mac)
{
struct sk_buff *skb;
@@ -904,7 +901,8 @@ static void amt_send_igmp_gq(struct amt_dev *amt,
return;
skb_pull(skb, sizeof(struct ethhdr));
- if (amt_send_membership_query(amt, skb, tunnel, false)) {
+ if (amt_send_membership_query(amt, skb, daddr, dport, nonce, mac,
+ false)) {
DEV_STATS_INC(amt->dev, tx_dropped);
kfree_skb(skb);
}
@@ -983,7 +981,8 @@ static struct sk_buff *amt_build_mld_gq(struct amt_dev *amt)
return skb;
}
-static void amt_send_mld_gq(struct amt_dev *amt, struct amt_tunnel_list *tunnel)
+static void amt_send_mld_gq(struct amt_dev *amt, __be32 daddr, __be16 dport,
+ __be32 nonce, u64 mac)
{
struct sk_buff *skb;
@@ -992,13 +991,15 @@ static void amt_send_mld_gq(struct amt_dev *amt, struct amt_tunnel_list *tunnel)
return;
skb_pull(skb, sizeof(struct ethhdr));
- if (amt_send_membership_query(amt, skb, tunnel, true)) {
+ if (amt_send_membership_query(amt, skb, daddr, dport, nonce, mac,
+ true)) {
DEV_STATS_INC(amt->dev, tx_dropped);
kfree_skb(skb);
}
}
#else
-static void amt_send_mld_gq(struct amt_dev *amt, struct amt_tunnel_list *tunnel)
+static void amt_send_mld_gq(struct amt_dev *amt, __be32 daddr, __be16 dport,
+ __be32 nonce, u64 mac)
{
}
#endif
@@ -1032,7 +1033,16 @@ static void amt_secret_work(struct work_struct *work)
secret_wq);
spin_lock_bh(&amt->lock);
- get_random_bytes(&amt->key, sizeof(siphash_key_t));
+ write_seqcount_begin(&amt->key_seq);
+ /* Keep three previous generations for a 180-240s MAC lifetime.
+ * Its 180s floor exceeds the advertised QQIC (amt->qi, 125s),
+ * allowing the gateway to renew its MAC with a periodic Request.
+ */
+ amt->key[3] = amt->key[2];
+ amt->key[2] = amt->key[1];
+ amt->key[1] = amt->key[0];
+ get_random_bytes(&amt->key[0], sizeof(siphash_key_t));
+ write_seqcount_end(&amt->key_seq);
spin_unlock_bh(&amt->lock);
mod_delayed_work(amt_wq, &amt->secret_wq,
msecs_to_jiffies(AMT_SECRET_TIMEOUT));
@@ -1232,9 +1242,16 @@ static void amt_send_multicast_data(struct amt_dev *amt,
0);
}
+/* Send a Membership Query in reply to an unauthenticated Request.
+ *
+ * No per-source state is read or created: the gateway echoes the nonce and
+ * the keyed MAC in its Membership Update, where the MAC is recomputed, so
+ * everything this needs is passed by value.
+ */
static bool amt_send_membership_query(struct amt_dev *amt,
struct sk_buff *skb,
- struct amt_tunnel_list *tunnel,
+ __be32 daddr, __be16 dport,
+ __be32 nonce, u64 mac,
bool v6)
{
struct amt_header_membership_query *amtmq;
@@ -1255,13 +1272,13 @@ static bool amt_send_membership_query(struct amt_dev *amt,
skb_reset_inner_headers(skb);
memset(&fl4, 0, sizeof(struct flowi4));
fl4.flowi4_oif = amt->stream_dev->ifindex;
- fl4.daddr = tunnel->ip4;
+ fl4.daddr = daddr;
fl4.saddr = amt->local_ip;
fl4.flowi4_dscp = inet_dsfield_to_dscp(AMT_TOS);
fl4.flowi4_proto = IPPROTO_UDP;
rt = ip_route_output_key(amt->net, &fl4);
if (IS_ERR(rt)) {
- netdev_dbg(amt->dev, "no route to %pI4\n", &tunnel->ip4);
+ netdev_dbg(amt->dev, "no route to %pI4\n", &daddr);
return true;
}
@@ -1271,8 +1288,8 @@ static bool amt_send_membership_query(struct amt_dev *amt,
amtmq->reserved = 0;
amtmq->l = 0;
amtmq->g = 0;
- amtmq->nonce = tunnel->nonce;
- amtmq->response_mac = tunnel->mac;
+ amtmq->nonce = nonce;
+ amtmq->response_mac = mac;
if (!v6)
skb_set_inner_protocol(skb, htons(ETH_P_IP));
@@ -1285,11 +1302,10 @@ static bool amt_send_membership_query(struct amt_dev *amt,
ip4_dst_hoplimit(&rt->dst),
0,
amt->relay_port,
- tunnel->source_port,
+ dport,
false,
false,
0);
- amt_update_relay_status(tunnel, AMT_STATUS_SENT_QUERY, true);
return false;
}
@@ -1477,12 +1493,7 @@ static void amt_tunnel_expire(struct work_struct *work)
* or finds it dead.
*/
now = jiffies;
- deadline = tunnel->last_seen;
- /* No Update yet: the Request armed gc_wq for one GMI. */
- if (!deadline)
- deadline = now;
- else
- deadline += msecs_to_jiffies(amt_gmi(amt)) * 3;
+ deadline = tunnel->last_seen + msecs_to_jiffies(amt_gmi(amt)) * 3;
if (time_before(now, deadline)) {
mod_delayed_work(amt_wq, &tunnel->gc_wq, deadline - now);
spin_unlock_bh(&tunnel->lock);
@@ -2631,16 +2642,165 @@ static bool amt_membership_query_handler(struct amt_dev *amt,
return false;
}
+/* The live tunnel of the gateway endpoint @saddr:@sport, or NULL. Dead
+ * tunnels are skipped, so the retry in amt_update_tunnel() after losing a
+ * race with amt_tunnel_expire() creates the replacement.
+ */
+static struct amt_tunnel_list *amt_tunnel_lookup(struct amt_dev *amt,
+ __be32 saddr, __be16 sport)
+{
+ struct amt_tunnel_list *tunnel;
+
+ list_for_each_entry_rcu(tunnel, &amt->tunnel_list, list) {
+ if (tunnel->ip4 == saddr && tunnel->source_port == sport &&
+ !READ_ONCE(tunnel->dead))
+ return tunnel;
+ }
+ return NULL;
+}
+
+/* Most tunnels one source address may hold: an eighth of the table, at
+ * least one and at most AMT_MAX_TUNNELS_PER_SRC.
+ */
+static u32 amt_max_tunnels_per_src(const struct amt_dev *amt)
+{
+ return clamp_t(u32, amt->max_tunnels / 8, 1,
+ AMT_MAX_TUNNELS_PER_SRC);
+}
+
+/* Look up the tunnel for a source whose Membership Update has already been
+ * authenticated, creating it on first contact.
+ *
+ * This is the only place tunnel state is allocated, so amt->max_tunnels
+ * bounds the number of *verified* gateways rather than the number of
+ * unverified Requests anyone can send. A per-source cap derived from the
+ * table, amt_max_tunnels_per_src(), additionally limits the tunnels one
+ * verified source address may hold.
+ */
+static struct amt_tunnel_list *amt_tunnel_get_or_create(struct amt_dev *amt,
+ __be32 saddr,
+ __be16 sport)
+{
+ struct amt_tunnel_list *tunnel;
+ u32 per_src = 0;
+ int i;
+
+ tunnel = amt_tunnel_lookup(amt, saddr, sport);
+ if (tunnel)
+ return tunnel;
+
+ spin_lock_bh(&amt->lock);
+
+ /* Re-check under the lock in case a concurrent Update won the race, and
+ * count how many tunnels this source already holds on the same walk.
+ * Skip dead tunnels, as amt_tunnel_lookup() and the Request path do, so
+ * a tunnel being freed neither stands in for its endpoint nor counts
+ * against its address.
+ */
+ list_for_each_entry(tunnel, &amt->tunnel_list, list) {
+ if (tunnel->ip4 != saddr || READ_ONCE(tunnel->dead))
+ continue;
+ if (tunnel->source_port == sport) {
+ spin_unlock_bh(&amt->lock);
+ return tunnel;
+ }
+ per_src++;
+ }
+
+ if (amt->nr_tunnels >= amt->max_tunnels ||
+ per_src >= amt_max_tunnels_per_src(amt)) {
+ spin_unlock_bh(&amt->lock);
+ return NULL;
+ }
+
+ if (!netif_running(amt->dev)) {
+ spin_unlock_bh(&amt->lock);
+ return NULL;
+ }
+
+ tunnel = kzalloc(sizeof(*tunnel) +
+ (sizeof(struct hlist_head) * amt->hash_buckets),
+ GFP_ATOMIC);
+ if (!tunnel) {
+ spin_unlock_bh(&amt->lock);
+ return NULL;
+ }
+
+ tunnel->source_port = sport;
+ tunnel->ip4 = saddr;
+ tunnel->amt = amt;
+ spin_lock_init(&tunnel->lock);
+ for (i = 0; i < amt->hash_buckets; i++)
+ INIT_HLIST_HEAD(&tunnel->groups[i]);
+
+ tunnel->last_seen = jiffies;
+ INIT_DELAYED_WORK(&tunnel->gc_wq, amt_tunnel_expire);
+ __amt_update_relay_status(tunnel, AMT_STATUS_RECEIVED_UPDATE, false);
+
+ /* Publish only once the entry is fully initialised. */
+ list_add_tail_rcu(&tunnel->list, &amt->tunnel_list);
+ amt->nr_tunnels++;
+ mod_delayed_work(amt_wq, &tunnel->gc_wq,
+ msecs_to_jiffies(amt_gmi(amt)) * 3);
+ spin_unlock_bh(&amt->lock);
+
+ return tunnel;
+}
+
+/* Apply a valid, authenticated Membership Update to the tunnel of its
+ * endpoint, creating the tunnel on first contact. Returns false if the
+ * Update is dropped.
+ *
+ * amt_tunnel_expire() reads last_seen and sets dead under tunnel->lock, so
+ * the refresh below either keeps the tunnel alive or finds it dead. A
+ * tunnel that expired between the lookup and the lock is skipped by the
+ * second amt_tunnel_get_or_create() call, which creates its replacement.
+ */
+static bool amt_update_tunnel(struct amt_dev *amt, struct sk_buff *skb,
+ __be32 saddr, __be16 sport, bool v6)
+{
+ struct amt_tunnel_list *tunnel;
+ int i;
+
+ for (i = 0; i < 2; i++) {
+ tunnel = amt_tunnel_get_or_create(amt, saddr, sport);
+ if (!tunnel)
+ return false;
+
+ spin_lock_bh(&tunnel->lock);
+ if (!READ_ONCE(tunnel->dead)) {
+ /* gc_wq is re-armed by amt_tunnel_expire() itself
+ * from last_seen, never from here, so this cannot
+ * re-queue the work of a tunnel that is being freed.
+ */
+ tunnel->last_seen = jiffies;
+#if IS_ENABLED(CONFIG_IPV6)
+ if (v6)
+ amt_mld_report_handler(amt, skb, tunnel);
+ else
+#endif
+ amt_igmp_report_handler(amt, skb, tunnel);
+ spin_unlock_bh(&tunnel->lock);
+ return true;
+ }
+ spin_unlock_bh(&tunnel->lock);
+ }
+ return false;
+}
+
static bool amt_update_handler(struct amt_dev *amt, struct sk_buff *skb)
{
struct amt_header_membership_update *amtmu;
- struct amt_tunnel_list *tunnel;
+ bool verified = false;
+ siphash_key_t key[4];
+ int len, hdr_size, i;
struct ethhdr *eth;
struct iphdr *iph;
- int len, hdr_size;
+ unsigned int seq;
u64 response_mac;
__be32 saddr;
__be32 nonce;
+ __be16 sport;
saddr = ip_hdr(skb)->saddr;
@@ -2654,27 +2814,45 @@ static bool amt_update_handler(struct amt_dev *amt, struct sk_buff *skb)
nonce = amtmu->nonce;
response_mac = amtmu->response_mac;
+ /* Snapshot the tunnel endpoint port before the encap is stripped. */
+ sport = udp_hdr(skb)->source;
+ /* Recompute the MAC handed out in the Membership Query. Four live
+ * secrets cover 180-240s, exceeding the advertised QQIC (amt->qi,
+ * 125s), so a periodic Request can renew the MAC before it expires.
+ * Authenticate before decapsulation or allocating tunnel state.
+ */
+ do {
+ seq = read_seqcount_begin(&amt->key_seq);
+ for (i = 0; i < ARRAY_SIZE(key); i++)
+ key[i] = amt->key[i];
+ } while (read_seqcount_retry(&amt->key_seq, seq));
+
+ for (i = 0; i < ARRAY_SIZE(key); i++) {
+ u64 mac = siphash_3u32((__force u32)saddr,
+ (__force u32)sport,
+ (__force u32)nonce,
+ &key[i]) >> 16;
+
+ if (response_mac == mac) {
+ verified = true;
+ break;
+ }
+ }
+
+ if (!verified) {
+ netdev_dbg(amt->dev, "Invalid MAC\n");
+ return true;
+ }
+
+ /* Create or refresh a tunnel only once the encapsulated message has
+ * passed ip_mc_check_igmp() or ipv6_mc_check_mld().
+ */
if (iptunnel_pull_header(skb, hdr_size, skb->protocol, false))
return true;
skb_reset_network_header(skb);
- list_for_each_entry_rcu(tunnel, &amt->tunnel_list, list) {
- if (tunnel->ip4 == saddr) {
- if ((nonce == tunnel->nonce &&
- response_mac == tunnel->mac)) {
- goto report;
- } else {
- netdev_dbg(amt->dev, "Invalid MAC\n");
- return true;
- }
- }
- }
-
- return true;
-
-report:
if (!pskb_may_pull(skb, sizeof(*iph)))
return true;
@@ -2688,21 +2866,8 @@ static bool amt_update_handler(struct amt_dev *amt, struct sk_buff *skb)
return true;
}
- spin_lock_bh(&tunnel->lock);
- if (READ_ONCE(tunnel->dead)) {
- spin_unlock_bh(&tunnel->lock);
+ if (!amt_update_tunnel(amt, skb, saddr, sport, false))
return true;
- }
- /* Refresh liveness under tunnel->lock, where
- * amt_tunnel_expire() reads it; 0 is kept for a tunnel no
- * Update has refreshed. gc_wq is re-armed by
- * amt_tunnel_expire() itself from last_seen, never from
- * here, so this cannot re-queue the work of a tunnel that is
- * being freed.
- */
- tunnel->last_seen = jiffies ?: 1;
- amt_igmp_report_handler(amt, skb, tunnel);
- spin_unlock_bh(&tunnel->lock);
skb_push(skb, sizeof(struct ethhdr));
skb_reset_mac_header(skb);
@@ -2720,14 +2885,8 @@ static bool amt_update_handler(struct amt_dev *amt, struct sk_buff *skb)
return true;
}
- spin_lock_bh(&tunnel->lock);
- if (READ_ONCE(tunnel->dead)) {
- spin_unlock_bh(&tunnel->lock);
+ if (!amt_update_tunnel(amt, skb, saddr, sport, true))
return true;
- }
- tunnel->last_seen = jiffies ?: 1;
- amt_mld_report_handler(amt, skb, tunnel);
- spin_unlock_bh(&tunnel->lock);
skb_push(skb, sizeof(struct ethhdr));
skb_reset_mac_header(skb);
@@ -2746,13 +2905,10 @@ static bool amt_update_handler(struct amt_dev *amt, struct sk_buff *skb)
skb->pkt_type = PACKET_MULTICAST;
skb->ip_summed = CHECKSUM_NONE;
len = skb->len;
- if (__netif_rx(skb) == NET_RX_SUCCESS) {
- amt_update_relay_status(tunnel, AMT_STATUS_RECEIVED_UPDATE,
- true);
+ if (__netif_rx(skb) == NET_RX_SUCCESS)
dev_sw_netstats_rx_add(amt->dev, len);
- } else {
+ else
amt->dev->stats.rx_dropped++;
- }
return false;
}
@@ -2868,15 +3024,30 @@ static bool amt_discovery_handler(struct amt_dev *amt, struct sk_buff *skb)
return false;
}
+/* Handle an AMT Request.
+ *
+ * The source of a Request is unauthenticated: it is a bare UDP datagram with a
+ * trivially spoofable source address. Allocating tunnel state here would let
+ * an attacker fill the table from spoofed addresses, or let one spoofed packet
+ * overwrite the nonce/MAC of an established tunnel and cut that gateway off.
+ *
+ * Nothing needs to be remembered at this point. Every input to the keyed MAC
+ * is carried in the packet or is device state, so the MAC is generated here,
+ * echoed back by the gateway in its Membership Update, and recomputed and
+ * verified there - see amt_update_handler(). Tunnel state is created only once
+ * that verification succeeds and the encapsulated message passes its IGMP or
+ * MLD check.
+ */
static bool amt_request_handler(struct amt_dev *amt, struct sk_buff *skb)
{
struct amt_header_request *amtrh;
struct amt_tunnel_list *tunnel;
- unsigned long long key;
+ bool full, have = false;
struct udphdr *udph;
struct iphdr *iph;
+ siphash_key_t key;
+ u32 per_src = 0;
u64 mac;
- int i;
if (!pskb_may_pull(skb, sizeof(*udph) + sizeof(*amtrh)))
return true;
@@ -2888,59 +3059,45 @@ static bool amt_request_handler(struct amt_dev *amt, struct sk_buff *skb)
if (amtrh->reserved1 || amtrh->reserved2 || amtrh->version)
return true;
- list_for_each_entry_rcu(tunnel, &amt->tunnel_list, list)
- if (tunnel->ip4 == iph->saddr)
- goto send;
-
- spin_lock_bh(&amt->lock);
- if (amt->nr_tunnels >= amt->max_tunnels) {
- spin_unlock_bh(&amt->lock);
- icmp_ndo_send(skb, ICMP_DEST_UNREACH, ICMP_HOST_UNREACH, 0);
- return true;
- }
-
- tunnel = kzalloc(sizeof(*tunnel) +
- (sizeof(struct hlist_head) * amt->hash_buckets),
- GFP_ATOMIC);
- if (!tunnel) {
- spin_unlock_bh(&amt->lock);
+ if (!netif_running(amt->dev) || !netif_running(amt->stream_dev))
return true;
- }
-
- tunnel->source_port = udph->source;
- tunnel->ip4 = iph->saddr;
-
- memcpy(&key, &tunnel->key, sizeof(unsigned long long));
- tunnel->amt = amt;
- spin_lock_init(&tunnel->lock);
- for (i = 0; i < amt->hash_buckets; i++)
- INIT_HLIST_HEAD(&tunnel->groups[i]);
- INIT_DELAYED_WORK(&tunnel->gc_wq, amt_tunnel_expire);
-
- list_add_tail_rcu(&tunnel->list, &amt->tunnel_list);
- tunnel->key = amt->key;
- __amt_update_relay_status(tunnel, AMT_STATUS_RECEIVED_REQUEST, true);
- amt->nr_tunnels++;
- mod_delayed_work(amt_wq, &tunnel->gc_wq,
- msecs_to_jiffies(amt_gmi(amt)));
+ /* Snapshot the secret and the table-full state under the lock and walk
+ * the endpoints outside it, so a Request holds amt->lock only for this
+ * O(1) snapshot.
+ */
+ spin_lock_bh(&amt->lock);
+ key = amt->key[0];
+ full = amt->nr_tunnels >= amt->max_tunnels;
spin_unlock_bh(&amt->lock);
-send:
- tunnel->nonce = amtrh->nonce;
- mac = siphash_3u32((__force u32)tunnel->ip4,
- (__force u32)tunnel->source_port,
- (__force u32)tunnel->nonce,
- &tunnel->key);
- tunnel->mac = mac >> 16;
+ /* Skip dead tunnels, as amt_tunnel_lookup() does: each is already
+ * being torn down, so it must not count against a new endpoint.
+ */
+ rcu_read_lock();
+ list_for_each_entry_rcu(tunnel, &amt->tunnel_list, list) {
+ if (tunnel->ip4 != iph->saddr || READ_ONCE(tunnel->dead))
+ continue;
+ per_src++;
+ if (tunnel->source_port == udph->source)
+ have = true;
+ }
+ rcu_read_unlock();
- if (!netif_running(amt->dev) || !netif_running(amt->stream_dev))
+ /* Withhold the Query when a new endpoint cannot get a tunnel. */
+ if (!have && (full || per_src >= amt_max_tunnels_per_src(amt)))
return true;
+ mac = siphash_3u32((__force u32)iph->saddr,
+ (__force u32)udph->source,
+ (__force u32)amtrh->nonce,
+ &key) >> 16;
if (!amtrh->p)
- amt_send_igmp_gq(amt, tunnel);
+ amt_send_igmp_gq(amt, iph->saddr, udph->source, amtrh->nonce,
+ mac);
else
- amt_send_mld_gq(amt, tunnel);
+ amt_send_mld_gq(amt, iph->saddr, udph->source, amtrh->nonce,
+ mac);
return false;
}
@@ -3208,6 +3365,16 @@ static int amt_dev_open(struct net_device *dev)
enable_delayed_work(&amt->discovery_wq);
enable_delayed_work(&amt->req_wq);
+ /* Seed the keys before amt_socket_create() enables the encap receive
+ * path, so no Query MAC is generated, and no Update verified, with an
+ * all-zero (predictable) secret.
+ */
+ spin_lock_bh(&amt->lock);
+ write_seqcount_begin(&amt->key_seq);
+ get_random_bytes(&amt->key, sizeof(amt->key));
+ write_seqcount_end(&amt->key_seq);
+ spin_unlock_bh(&amt->lock);
+
err = amt_socket_create(amt);
if (err) {
disable_delayed_work(&amt->req_wq);
@@ -3218,7 +3385,6 @@ static int amt_dev_open(struct net_device *dev)
amt->req_cnt = 0;
WRITE_ONCE(amt->remote_ip, 0);
amt->nonce = 0;
- get_random_bytes(&amt->key, sizeof(siphash_key_t));
amt->status = AMT_STATUS_INIT;
if (amt->mode == AMT_MODE_GATEWAY) {
@@ -3436,6 +3602,7 @@ static int amt_newlink(struct net_device *dev,
amt->max_tunnels = AMT_MAX_TUNNELS;
spin_lock_init(&amt->lock);
+ seqcount_spinlock_init(&amt->key_seq, &amt->lock);
amt->max_groups = AMT_MAX_GROUP;
amt->max_sources = AMT_MAX_SOURCE;
amt->hash_buckets = AMT_HSIZE;
diff --git a/include/net/amt.h b/include/net/amt.h
index dedf328d8..9ba097127 100644
--- a/include/net/amt.h
+++ b/include/net/amt.h
@@ -253,10 +253,6 @@ struct amt_tunnel_list {
bool dead;
__be16 source_port;
__be32 ip4;
- __be32 nonce;
- siphash_key_t key;
- u64 mac:48,
- reserved:16;
struct rcu_head rcu;
struct hlist_head groups[];
};
@@ -342,8 +338,12 @@ struct amt_dev {
struct work_struct event_wq;
/* AMT status */
enum amt_status status;
- /* Generated key */
- siphash_key_t key;
+ /* Current key and three previous generations keep MACs valid for
+ * 180-240s, beyond the advertised QQIC (see amt_secret_work()).
+ */
+ siphash_key_t key[4];
+ /* For lockless readers of key; writers hold lock */
+ seqcount_spinlock_t key_seq;
struct sock __rcu *sk;
u32 max_groups;
u32 max_sources;
@@ -401,6 +401,8 @@ struct amt_dev {
#define AMT_SECRET_TIMEOUT 60000
#define IANA_AMT_UDP_PORT 2268
#define AMT_MAX_TUNNELS 128
+/* Upper bound of amt_max_tunnels_per_src(), the per-address tunnel cap */
+#define AMT_MAX_TUNNELS_PER_SRC 16
#define AMT_MAX_REQS 128
#define AMT_GW_HLEN (sizeof(struct iphdr) + \
sizeof(struct udphdr) + \
--
2.43.0
^ permalink raw reply [flat|nested] 7+ messages in thread
end of thread, other threads:[~2026-11-10 21:05 UTC | newest]
Thread overview: 7+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-11-10 21:05 [PATCH net v7 0/6] amt: relay tunnel lifetime, validation and request-path hardening Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 1/6] amt: stop receiving before amt_dev_stop() tears down its state Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 2/6] amt: fix use-after-free when a Membership Update re-arms a freed tunnel Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 3/6] amt: reject a gateway Query or Data from 0.0.0.0 before a relay is learned Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 4/6] amt: require an exact IPv4-sized IFLA_AMT_{LOCAL,DISCOVERY}_IP Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 5/6] amt: make pre-query report drops visible Omar Ramadan
2026-11-10 21:05 ` [PATCH net v7 6/6] amt: create relay tunnels only from authenticated Updates Omar Ramadan
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
all inboxes | Powered by JetHome®