[PATCH nf v7 1/1] netfilter: nf_dup: preserve duplication guard across NFQUEUE
From: Zihan Xi
Date: Sun Oct 11 2026 - 07:32:09 EST
nf_dup_ipv4() and nf_dup_ipv6() set current->in_nf_duplicate while the
cloned skb traverses output hooks. NFQUEUE can retain the clone and return
NF_STOLEN while that traversal is in progress. Once ip_local_out() or
ip6_local_out() returns, the task-local guard is cleared. NF_ACCEPT then
resumes hook traversal after the queued hook in the verdict task, where
the guard is no longer set. A later dup rule can clone the packet again,
causing repeated packet processing and resource consumption.
Record the duplication state in nf_queue_entry and use a separate
reinjection flag while nf_reinject() resumes hook traversal. Consult that
flag only in task context: local_bh_enable() can process pending softirqs
before reinjection finishes, and those packets must not inherit the
reinjection guard. Restore the verdict task's prior flag on every exit.
The netdev dup path uses a separate xmit recursion counter. Mark cloned
output within the BH-disabled transmit section so NFQUEUE entries created
there retain the duplication state. The counter itself remains unchanged.
This covers NFQUEUE reinjection only; it does not carry duplicate state
across an independent device receive path.
Fixes: cd58bcd9787e ("netfilter: xt_TEE: have cloned packet travel through Xtables too")
Fixes: 8537f78647c0 ("netfilter: Introduce egress hook")
Cc: stable@xxxxxxxxxxxxxxx
Reported-by: Vega <vega@xxxxxxxxxx>
Assisted-by: LLM
Co-developed-by: Luxing Yin <root@xxxxxxxxxx>
Signed-off-by: Luxing Yin <root@xxxxxxxxxx>
Signed-off-by: Zihan Xi <zihanx@xxxxxxxxxx>
---
changes in v7:
- replace the v6 user-namespace setup restriction with NFQUEUE state
capture and restoration
- use a separate reinjection flag checked only in task context, and scope
the netdev dup marker to its BH-disabled transmit section
- cover queued IPv4/IPv6 duplication and nft_dup_netdev clones; restore the
verdict task's previous state on every exit path
- keep the claim limited to NFQUEUE reinjection, not independent device
receive paths
- point the netdev NFQUEUE Fixes tag to the earliest egress-hook
introduction (8537f78647c0), not the current reintroduction
(42df6e1d221d)
- v6 Link:
https://lore.kernel.org/all/82abaa776053bb3e716095a783722452d87fa039.1791429178.git.zihanx@xxxxxxxxxx/
changes in v6:
- reroll the unchanged four-file setup-time restriction on nf.git main at
ab699d296e06
- do not present v5 build or runtime results as v6 validation
- v5 Link:
https://lore.kernel.org/all/20260929125832.25316-1-zihanx@xxxxxxxxxx/
changes in v5:
- describe the patch as a non-initial-userns setup restriction, not a
general recursion fix; state that init_user_ns behavior is unchanged
- remove path-specific trigger details and run-specific metrics from the
public commit and cover prose
- drop d877f07112f1 from Fixes: because it added a later frontend, not the
shared duplication behavior addressed by that restriction
- record the v5 build and setup-time validation, distinguishing them from
earlier unpatched-baseline and root-OOM runs
- v4 Link:
https://lore.kernel.org/all/cover.1790408011.git.zihanx@xxxxxxxxxx/
changes in v4:
- add Fixes tags for TEE, IPv4/IPv6 nft dup, and netdev nft dup
frontends
- clarify that fcd53c51d037 only added a synchronous guard
- clarify that this restricts non-initial user namespaces and does not
fix asynchronous recursion in init_user_ns-owned network namespaces
- state explicitly that the netdev loop was not runtime-reproduced
- v3 Link:
https://lore.kernel.org/all/cover.1790042930.git.zihanx@xxxxxxxxxx/
changes in v3:
- reroll the fix against the latest nf.git main after no follow-up on v2
- add the Co-developed-by trailer and matching Signed-off-by
- v2 Link:
https://lore.kernel.org/all/cover.1788425393.git.zihanx@xxxxxxxxxx/
changes in v2:
- drop the persistent struct sk_buff::nf_duplicated field and nf_copy()
change from v1
- disable IPv4/IPv6 duplication in non-initial user namespaces
- v1 Link:
https://lore.kernel.org/all/cover.1787903722.git.zihanx@xxxxxxxxxx/
---
include/linux/sched.h | 2 ++
include/net/netfilter/nf_queue.h | 1 +
net/ipv4/netfilter/ip_tables.c | 5 ++++-
net/ipv4/netfilter/nf_dup_ipv4.c | 5 ++++-
net/ipv6/netfilter/ip6_tables.c | 5 ++++-
net/ipv6/netfilter/nf_dup_ipv6.c | 6 +++++-
net/netfilter/nf_dup_netdev.c | 13 ++++++++++++-
net/netfilter/nf_queue.c | 4 ++++
net/netfilter/nfnetlink_queue.c | 8 +++++++-
9 files changed, 43 insertions(+), 6 deletions(-)
diff --git a/include/linux/sched.h b/include/linux/sched.h
index d35ae49a9..f1ffc9d48 100644
--- a/include/linux/sched.h
+++ b/include/linux/sched.h
@@ -1070,6 +1070,8 @@ struct task_struct {
unsigned in_thrashing:1;
#endif
unsigned in_nf_duplicate:1;
+ unsigned in_nf_dup_netdev:1; /* netdev dup output is active */
+ unsigned in_nf_duplicate_reinject:1; /* NFQUEUE resume */
#ifdef CONFIG_PREEMPT_RT
struct netdev_xmit net_xmit;
#endif
diff --git a/include/net/netfilter/nf_queue.h b/include/net/netfilter/nf_queue.h
index fc3e81c07..d6d335273 100644
--- a/include/net/netfilter/nf_queue.h
+++ b/include/net/netfilter/nf_queue.h
@@ -23,6 +23,7 @@ struct nf_queue_entry {
struct net_device *physout;
#endif
struct nf_hook_state state;
+ bool in_nf_duplicate; /* skb is on a duplication path */
bool nf_ct_is_unconfirmed;
u16 size; /* sizeof(entry) + saved route keys */
diff --git a/net/ipv4/netfilter/ip_tables.c b/net/ipv4/netfilter/ip_tables.c
index 809441ced..7c2027ef8 100644
--- a/net/ipv4/netfilter/ip_tables.c
+++ b/net/ipv4/netfilter/ip_tables.c
@@ -14,6 +14,7 @@
#include <linux/vmalloc.h>
#include <linux/netdevice.h>
#include <linux/module.h>
+#include <linux/preempt.h>
#include <net/ip.h>
#include <net/compat.h>
#include <linux/uaccess.h>
@@ -270,7 +271,9 @@ ipt_do_table(void *priv,
* but it is no problem since absolute verdict is issued by these.
*/
if (static_key_false(&xt_tee_enabled))
- jumpstack += private->stacksize * current->in_nf_duplicate;
+ jumpstack += private->stacksize *
+ (current->in_nf_duplicate ||
+ (in_task() && current->in_nf_duplicate_reinject));
e = get_entry(table_base, private->hook_entry[hook]);
diff --git a/net/ipv4/netfilter/nf_dup_ipv4.c b/net/ipv4/netfilter/nf_dup_ipv4.c
index 9a773502f..1cac70cac 100644
--- a/net/ipv4/netfilter/nf_dup_ipv4.c
+++ b/net/ipv4/netfilter/nf_dup_ipv4.c
@@ -7,6 +7,7 @@
*/
#include <linux/ip.h>
#include <linux/module.h>
+#include <linux/preempt.h>
#include <linux/percpu.h>
#include <linux/route.h>
#include <linux/skbuff.h>
@@ -52,9 +53,11 @@ void nf_dup_ipv4(struct net *net, struct sk_buff *skb, unsigned int hooknum,
const struct in_addr *gw, int oif)
{
struct iphdr *iph;
+ bool in_nf_duplicate_reinject = in_task() &&
+ current->in_nf_duplicate_reinject;
local_bh_disable();
- if (current->in_nf_duplicate)
+ if (current->in_nf_duplicate || in_nf_duplicate_reinject)
goto out;
/*
* Copy the skb, and route the copy. Will later return %XT_CONTINUE for
diff --git a/net/ipv6/netfilter/ip6_tables.c b/net/ipv6/netfilter/ip6_tables.c
index 313c4aac3..017b57691 100644
--- a/net/ipv6/netfilter/ip6_tables.c
+++ b/net/ipv6/netfilter/ip6_tables.c
@@ -17,6 +17,7 @@
#include <linux/vmalloc.h>
#include <linux/netdevice.h>
#include <linux/module.h>
+#include <linux/preempt.h>
#include <linux/poison.h>
#include <net/ipv6.h>
#include <net/compat.h>
@@ -292,7 +293,9 @@ ip6t_do_table(void *priv, struct sk_buff *skb,
* but it is no problem since absolute verdict is issued by these.
*/
if (static_key_false(&xt_tee_enabled))
- jumpstack += private->stacksize * current->in_nf_duplicate;
+ jumpstack += private->stacksize *
+ (current->in_nf_duplicate ||
+ (in_task() && current->in_nf_duplicate_reinject));
e = get_entry(table_base, private->hook_entry[hook]);
diff --git a/net/ipv6/netfilter/nf_dup_ipv6.c b/net/ipv6/netfilter/nf_dup_ipv6.c
index 6da3102b7..ee4262cc3 100644
--- a/net/ipv6/netfilter/nf_dup_ipv6.c
+++ b/net/ipv6/netfilter/nf_dup_ipv6.c
@@ -6,6 +6,7 @@
* Extracted from xt_TEE.c
*/
#include <linux/module.h>
+#include <linux/preempt.h>
#include <linux/percpu.h>
#include <linux/skbuff.h>
#include <linux/netfilter.h>
@@ -47,8 +48,11 @@ static bool nf_dup_ipv6_route(struct net *net, struct sk_buff *skb,
void nf_dup_ipv6(struct net *net, struct sk_buff *skb, unsigned int hooknum,
const struct in6_addr *gw, int oif)
{
+ bool in_nf_duplicate_reinject = in_task() &&
+ current->in_nf_duplicate_reinject;
+
local_bh_disable();
- if (current->in_nf_duplicate)
+ if (current->in_nf_duplicate || in_nf_duplicate_reinject)
goto out;
skb = pskb_copy(skb, GFP_ATOMIC);
if (skb == NULL)
diff --git a/net/netfilter/nf_dup_netdev.c b/net/netfilter/nf_dup_netdev.c
index c6bd5c29b..8b8e6f938 100644
--- a/net/netfilter/nf_dup_netdev.c
+++ b/net/netfilter/nf_dup_netdev.c
@@ -4,6 +4,7 @@
*/
#include <linux/kernel.h>
+#include <linux/sched.h>
#include <linux/init.h>
#include <linux/module.h>
#include <linux/netlink.h>
@@ -57,14 +58,24 @@ void nf_dup_netdev_egress(const struct nft_pktinfo *pkt, int oif)
{
struct net_device *dev;
struct sk_buff *skb;
+ bool old_in_nf_dup_netdev;
+
+ if (in_task() && current->in_nf_duplicate_reinject)
+ return;
dev = dev_get_by_index_rcu(nft_net(pkt), oif);
if (dev == NULL)
return;
skb = skb_clone(pkt->skb, GFP_ATOMIC);
- if (skb)
+ if (skb) {
+ local_bh_disable();
+ old_in_nf_dup_netdev = current->in_nf_dup_netdev;
+ current->in_nf_dup_netdev = true;
nf_do_netdev_egress(skb, dev, nft_hook(pkt));
+ current->in_nf_dup_netdev = old_in_nf_dup_netdev;
+ local_bh_enable();
+ }
}
EXPORT_SYMBOL_GPL(nf_dup_netdev_egress);
diff --git a/net/netfilter/nf_queue.c b/net/netfilter/nf_queue.c
index 73363ceed..a1f395415 100644
--- a/net/netfilter/nf_queue.c
+++ b/net/netfilter/nf_queue.c
@@ -5,6 +5,7 @@
*/
#include <linux/kernel.h>
+#include <linux/sched.h>
#include <linux/slab.h>
#include <linux/init.h>
#include <linux/module.h>
@@ -219,6 +220,9 @@ static int __nf_queue(struct sk_buff *skb, const struct nf_hook_state *state,
*entry = (struct nf_queue_entry) {
.skb = skb,
.skb_dev = skb->dev,
+ .in_nf_duplicate = current->in_nf_duplicate ||
+ (in_task() && current->in_nf_duplicate_reinject) ||
+ current->in_nf_dup_netdev,
.state = *state,
.hook_index = index,
.size = sizeof(*entry) + route_key_size,
diff --git a/net/netfilter/nfnetlink_queue.c b/net/netfilter/nfnetlink_queue.c
index a3bc00280..222166898 100644
--- a/net/netfilter/nfnetlink_queue.c
+++ b/net/netfilter/nfnetlink_queue.c
@@ -13,6 +13,7 @@
#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt
+#include <linux/sched.h>
#include <linux/module.h>
#include <linux/skbuff.h>
#include <linux/init.h>
@@ -368,9 +369,12 @@ static void nf_reinject(struct nf_queue_entry *entry, unsigned int verdict)
unsigned int i;
int err;
u8 pf;
+ bool old_in_nf_duplicate_reinject;
net = entry->state.net;
pf = entry->state.pf;
+ old_in_nf_duplicate_reinject = current->in_nf_duplicate_reinject;
+ current->in_nf_duplicate_reinject |= entry->in_nf_duplicate;
hooks = nf_hook_entries_head(net, pf, entry->state.hook);
@@ -378,7 +382,7 @@ static void nf_reinject(struct nf_queue_entry *entry, unsigned int verdict)
if (!hooks || i >= hooks->num_hook_entries) {
kfree_skb_reason(skb, SKB_DROP_REASON_NETFILTER_DROP);
nf_queue_entry_free(entry);
- return;
+ goto out;
}
hook_entry = &hooks->hooks[i];
@@ -417,6 +421,8 @@ static void nf_reinject(struct nf_queue_entry *entry, unsigned int verdict)
}
nf_queue_entry_free(entry);
+out:
+ current->in_nf_duplicate_reinject = old_in_nf_duplicate_reinject;
}
/* return true if the entry has an unconfirmed conntrack attached that isn't owned by us
--
2.43.0