From: Jakub Kicinski <kuba@kernel.org>
To: pablo@netfilter.org
Cc: Jakub Kicinski <kuba@kernel.org>,
netfilter-devel@vger.kernel.org, davem@davemloft.net,
netdev@vger.kernel.org, pabeni@redhat.com, edumazet@google.com,
fw@strlen.de, horms@kernel.org
Subject: Re: [PATCH net 03/10] netfilter: ipset: rework cidr bookkeeping
Date: Tue, 4 Aug 2026 17:15:31 -0700 [thread overview]
Message-ID: <20260805001531.3978750-1-kuba@kernel.org> (raw)
In-Reply-To: <20260731151806.849724-4-pablo@netfilter.org>
This is an AI-generated review of your patch. The human sending this
email has considered the AI review valid, or at least plausible.
---
netfilter: ipset: rework cidr bookkeeping
The prefix bookkeeping of the hash:net* set types is moved out of struct
htype into separately allocated, RCU published struct net_prefixes arrays.
The in-place shifting of the cidr array is replaced by allocate-copy-publish,
with a documented fallback to leaving holes when the allocation fails.
A few questions about the new bookkeeping below.
> diff --git a/net/netfilter/ipset/ip_set_hash_gen.h b/net/netfilter/ipset/ip_set_hash_gen.h
> index b2d77973272d2..dd31992c915ca 100644
> --- a/net/netfilter/ipset/ip_set_hash_gen.h
> +++ b/net/netfilter/ipset/ip_set_hash_gen.h
> @@ -99,9 +99,15 @@ struct htable {
> #endif
>
> /* Book-keeping of the prefixes added to the set */
> +struct net_prefix {
> + u8 cidr; /* the cidr value */
> + u32 count; /* number of elements of this cidr */
> +};
> +
> struct net_prefixes {
> - u32 nets[IPSET_NET_COUNT]; /* number of elements for this cidr */
> - u8 cidr[IPSET_NET_COUNT]; /* the cidr value */
> + struct rcu_head rcu;
> + u8 len;
> + struct net_prefix nets[] __counted_by(len);
> };
>
> /* Compute the hash table size */
> @@ -127,11 +133,6 @@ htable_size(u8 hbits)
> #else
> #define __CIDR(cidr, i) (cidr)
> #endif
> -
> -/* cidr + 1 is stored in net_prefixes to support /0 */
> -#define NCIDR_PUT(cidr) ((cidr) + 1)
> -#define NCIDR_GET(cidr) ((cidr) - 1)
> -
> #ifdef IP_SET_HASH_WITH_NETS_PACKED
> /* When cidr is packed with nomatch, cidr - 1 is stored in the data entry */
> #define DCIDR_PUT(cidr) ((cidr) - 1)
> @@ -141,21 +142,11 @@ htable_size(u8 hbits)
> #define DCIDR_GET(cidr, i) __CIDR(cidr, i)
> #endif
>
> -#define INIT_CIDR(cidr, host_mask) \
> - DCIDR_PUT(((cidr) ? NCIDR_GET(cidr) : host_mask))
> -
> -#ifdef IP_SET_HASH_WITH_NET0
> -/* cidr from 0 to HOST_MASK value and c = cidr + 1 */
> -#define NLEN (HOST_MASK + 1)
> -#define CIDR_POS(c) ((c) - 1)
> -#else
> -/* cidr from 1 to HOST_MASK value and c = cidr + 1 */
> -#define NLEN HOST_MASK
> -#define CIDR_POS(c) ((c) - 2)
> -#endif
[Severity: Low]
This isn't a bug, but these were the only readers of
IP_SET_HASH_WITH_NET0, and the flag is still defined by
ip_set_hash_netiface.c and ip_set_hash_netportnet.c:
#define IP_SET_HASH_WITH_NET0
After this change nothing in ip_set_hash_gen.h consumes it, so /0 support
is now implicit (cidr 0 with count != 0). Should those two defines be
dropped as well? Grepping net/netfilter/ipset/ at the end of the series
still shows the two define lines and no consumers.
> +#define INIT_CIDR(n, host_mask) ({ \
> + const struct net_prefixes *__n = rcu_dereference(n); \
> + DCIDR_PUT((__n)->len ? (__n)->nets[0].cidr : host_mask);\
> +})
[Severity: Medium]
Should INIT_CIDR() also look at nets[0].count?
Every other user of the array treats count == 0 as a stale hole.
mtype_test_cidrs() skips them:
for (j = 0; j < nets0->len && !multi; j++) {
if (!nets0->nets[j].count)
continue;
and mtype_add_cidr()/mtype_del_cidr() only count entries with
nets->nets[i].count when computing the new length. INIT_CIDR() tests len
only, and len is the number of allocated slots rather than the number of
live prefixes.
Two states created by this patch leave len non-zero with count == 0 at
index 0:
- mtype_del_cidr() on allocation failure:
tmp = kzalloc_flex(*tmp, nets, len, GFP_ATOMIC);
if (!tmp)
/* Leave a hole */
goto unlock;
- mtype_flush() on allocation failure, which zeroes the counts but keeps
len and the stored cidr values.
In both cases INIT_CIDR() returns a prefix that is no longer in the set,
where the previous memset(h->nets, 0, sizeof(h->nets)) guaranteed the
host_mask fallback. The value is then used for real work on the kadt
path, for example in hash_net4_kadt():
struct hash_net4_elem e = {
.cidr = INIT_CIDR(h->rnets[0], HOST_MASK),
};
...
if (e.cidr == 0)
return -EINVAL;
...
e.ip &= ip_set_netmask(e.cidr);
so an add or del driven by the SET target can end up operating on a
broader network than intended. The same pattern applies to the
hash_netiface*, hash_netport*, hash_ipportnet*, hash_netnet* and
hash_netportnet* kadt functions.
The commit message says the hole possibility "is taken into account at
evaluation time" - that holds for mtype_test_cidrs(), but does it hold for
INIT_CIDR()? This is unchanged at the end of the series (3a0d9fd64b67).
>
> -#else
> -#define NLEN 0
> #endif /* IP_SET_HASH_WITH_NETS */
>
> #define SET_ELEM_EXPIRED(set, d) \
> @@ -292,6 +283,7 @@ static const union nf_inet_addr zeromask = {};
> /* The generic hash structure */
> struct htype {
> struct htable __rcu *table; /* the hash table */
> + struct net_prefixes __rcu *rnets[IPSET_NET_COUNT]; /* cidr prefixes */
> struct htable_gc gc; /* gc workqueue */
> u32 maxelem; /* max elements in the hash */
> u32 initval; /* random jhash init value */
> @@ -302,9 +294,6 @@ struct htype {
> #if defined(IP_SET_HASH_WITH_NETMASK) || defined(IP_SET_HASH_WITH_BITMASK)
> u8 netmask; /* netmask value for subnets to store */
> union nf_inet_addr bitmask; /* stores bitmask */
> -#endif
> -#ifdef IP_SET_HASH_WITH_NETS
> - struct net_prefixes nets[NLEN]; /* book-keeping of prefixes */
> #endif
[Severity: Low]
Does the memsize reported to userspace need updating too? The bookkeeping
is no longer part of struct htype, but mtype_ahash_memsize() still only
accounts for sizeof(*h):
return sizeof(*h) + sizeof(*t) + ahash_sizeof_regions(t->htable_bits);
So the h->rnets[] allocations (up to HOST_MASK + 1 entries times
IPSET_NET_COUNT, and note struct net_prefix is 8 bytes after padding) are
no longer visible in IPSET_ATTR_MEMSIZE, and the reported value drops for
existing set types. The commit message does not mention this user visible
change. Still the case at the end of the series (3a0d9fd64b67).
> /* Because 'next' is IPv4/IPv6 dependent, no elements of this
> * structure and referred in create() may come after 'next'.
> @@ -326,50 +315,92 @@ struct mtype_resize_ad {
> /* Network cidr size book keeping when the hash stores different
> * sized networks. cidr == real cidr + 1 to support /0.
> */
[Severity: Low]
This isn't a bug, but the comment still describes the encoding this patch
removes. NCIDR_PUT()/NCIDR_GET() are gone and all call sites now pass the
raw value, for example:
mtype_add_cidr(set, h, DCIDR_GET(d->cidr, i), i);
Could the comment be updated to describe the new invariants instead
(descending cidr order, count == 0 marks a hole, len is the number of
allocated slots, /0 distinguished by count)? The stale text is still
present at the end of the series (3a0d9fd64b67).
> -static void
> +static int
> mtype_add_cidr(struct ip_set *set, struct htype *h, u8 cidr, u8 n)
> {
> - int i, j;
> + struct net_prefixes *nets, *tmp;
> + int i, j, found, len = 0, ret = 0;
>
> spin_lock_bh(&set->lock);
> + nets = __ipset_dereference(h->rnets[n]);
> /* Add in increasing prefix order, so larger cidr first */
> - for (i = 0, j = -1; i < NLEN && h->nets[i].cidr[n]; i++) {
> - if (j != -1) {
> + for (i = 0, found = -1; i < nets->len; i++) {
> + if (nets->nets[i].count)
> + len++;
> + if (found != -1) {
> continue;
> - } else if (h->nets[i].cidr[n] < cidr) {
> - j = i;
> - } else if (h->nets[i].cidr[n] == cidr) {
> - h->nets[CIDR_POS(cidr)].nets[n]++;
> + } else if (nets->nets[i].cidr < cidr) {
> + found = i;
> + } else if (nets->nets[i].cidr == cidr) {
> + nets->nets[i].count++;
[Severity: Low]
This isn't a bug on any supported architecture, but this increment (and
the matching decrement in mtype_del_cidr(), plus the bulk zeroing in the
mtype_flush() fallback) still mutates the already published object in
place, with plain stores and no WRITE_ONCE().
The lockless readers use plain loads of the same field:
if (!nets0->nets[j].count)
continue;
Since count now decides whether an entry is a live prefix or a hole, is it
worth annotating these accesses? The commit message says the in-place
updating is replaced by an RCU based method, but that only covers the array
shape and ordering; the count field is still updated in place on the
published structure without going through rcu_assign_pointer().
Unchanged at the end of the series (3a0d9fd64b67).
> goto unlock;
> }
> }
> - if (j != -1) {
> - for (; i > j; i--)
> - h->nets[i].cidr[n] = h->nets[i - 1].cidr[n];
> + len++;
> + tmp = kzalloc_flex(*tmp, nets, len, GFP_ATOMIC);
> + if (!tmp) {
> + ret = -ENOMEM;
> + goto unlock;
> }
> - h->nets[i].cidr[n] = cidr;
> - h->nets[CIDR_POS(cidr)].nets[n] = 1;
> +
> + tmp->len = len;
> + for (i = 0, j = 0; i < nets->len; i++) {
> + if (i == found) {
> + tmp->nets[j].cidr = cidr;
> + tmp->nets[j++].count = 1;
> + }
> + if (!nets->nets[i].count)
> + continue;
> + tmp->nets[j].cidr = nets->nets[i].cidr;
> + tmp->nets[j++].count = nets->nets[i].count;
> + }
> + if (found == -1) {
> + tmp->nets[j].cidr = cidr;
> + tmp->nets[j].count = 1;
> + }
> + rcu_assign_pointer(h->rnets[n], tmp);
> + kfree_rcu(nets, rcu);
> unlock:
> spin_unlock_bh(&set->lock);
> + return ret;
> }
>
> static void
> mtype_del_cidr(struct ip_set *set, struct htype *h, u8 cidr, u8 n)
> {
> - u8 i, j, net_end = NLEN - 1;
> + struct net_prefixes *nets, *tmp;
> + u8 i, j, len = 0;
> + int found;
>
> spin_lock_bh(&set->lock);
> - for (i = 0; i < NLEN; i++) {
> - if (h->nets[i].cidr[n] != cidr)
> - continue;
> - h->nets[CIDR_POS(cidr)].nets[n]--;
> - if (h->nets[CIDR_POS(cidr)].nets[n] > 0)
> - goto unlock;
> - for (j = i; j < net_end && h->nets[j].cidr[n]; j++)
> - h->nets[j].cidr[n] = h->nets[j + 1].cidr[n];
> - h->nets[j].cidr[n] = 0;
> + nets = __ipset_dereference(h->rnets[n]);
> + for (i = 0, found = -1; i < nets->len; i++) {
> + if (nets->nets[i].count)
> + len++;
> + if (nets->nets[i].cidr == cidr)
> + found = i;
> + }
> + if (unlikely(found == -1))
> + goto unlock;
> +
> + nets->nets[found].count--;
> + if (nets->nets[found].count)
[Severity: Medium]
Can nets->nets[found].count be zero here, making this decrement underflow?
The match loop selects an entry by cidr only and ignores count, but the new
design deliberately keeps count == 0 entries around: the "Leave a hole"
fallback just below, and the mtype_flush() fallback which zeroes all counts
while keeping len and the stored cidr values.
If found points at such a hole, count is a u32 and becomes 0xffffffff, and
the following test:
if (nets->nets[found].count)
goto unlock;
then keeps the entry forever. mtype_test_cidrs() probes it on every packet
and INIT_CIDR() can hand it out as the most specific prefix.
One way to reach the decrement of a zero count: mtype_flush() clears the
buckets first, a concurrent SET target add registers a cidr and inserts an
element, the flush fallback afterwards zeroes that count, and the later
delete of the still present element decrements zero. The skew caused by the
ignored -ENOMEM of mtype_add_cidr() (see below) gets there as well.
Before this patch the flush path used memset(h->nets, 0, sizeof(h->nets)),
which also cleared the stored cidr values, so a stale cidr could not be
matched. Would guarding the decrement with a count check, or matching on
count && cidr, be preferable? Still present at the end of the series
(3a0d9fd64b67).
> goto unlock;
> + len--;
> + tmp = kzalloc_flex(*tmp, nets, len, GFP_ATOMIC);
> + if (!tmp)
> + /* Leave a hole */
> + goto unlock;
> +
> + tmp->len = len;
> + for (i = 0, j = 0; i < nets->len; i++) {
> + if (!nets->nets[i].count || i == found)
> + continue;
> + tmp->nets[j].cidr = nets->nets[i].cidr;
> + tmp->nets[j++].count = nets->nets[i].count;
> }
> + rcu_assign_pointer(h->rnets[n], tmp);
> + kfree_rcu(nets, rcu);
> unlock:
> spin_unlock_bh(&set->lock);
> }
> @@ -402,6 +433,9 @@ static void
> mtype_flush(struct ip_set *set)
> {
> struct htype *h = set->data;
> +#ifdef IP_SET_HASH_WITH_NETS
> + struct net_prefixes *nets, *tmp;
> +#endif
> struct htable *t;
> struct hbucket *n;
> u32 r, i;
> @@ -425,7 +459,19 @@ mtype_flush(struct ip_set *set)
> spin_unlock_bh(&t->hregion[r].lock);
> }
> #ifdef IP_SET_HASH_WITH_NETS
> - memset(h->nets, 0, sizeof(h->nets));
> + for (i = 0; i < IPSET_NET_COUNT; i++) {
> + nets = ipset_dereference_nfnl(h->rnets[i]);
> + tmp = kzalloc_obj(*tmp, GFP_ATOMIC);
> + if (!tmp) {
> + u8 j;
> +
> + for (j = 0; j < nets->len; j++)
> + nets->nets[j].count = 0;
> + } else {
> + rcu_assign_pointer(h->rnets[i], tmp);
> + kfree_rcu(nets, rcu);
> + }
> + }
> #endif
> }
[Severity: Critical]
Does this replacement need set->lock?
mtype_add_cidr() and mtype_del_cidr() do the same read, publish and free
sequence under spin_lock_bh(&set->lock), but here only the nfnetlink mutex
is held. For the hash types set->variant->region_lock is true, and
ip_set_lock() is then a no-op:
net/netfilter/ipset/ip_set_core.c:
static inline void
ip_set_lock(struct ip_set *set)
{
if (!set->variant->region_lock)
spin_lock_bh(&set->lock);
}
so ip_set_flush_set() -> mtype_flush() gets no serialization against the
cidr writers. Those writers do not take the nfnl mutex either:
SET target -> ip_set_add()/ip_set_del() -> kadt -> mtype_add()/mtype_del()
-> mtype_add_cidr()/mtype_del_cidr()
gc worker -> mtype_gc_do() -> mtype_del_cidr_all() -> mtype_del_cidr()
Can both sides then observe the same old pointer and each queue it?
CPU0 (ipset flush) CPU1 (SET target, softirq)
nets = ipset_dereference_nfnl(...)
spin_lock_bh(&set->lock);
nets = __ipset_dereference(h->rnets[n]);
rcu_assign_pointer(h->rnets[i], tmp)
kfree_rcu(nets, rcu);
rcu_assign_pointer(h->rnets[n], tmp);
kfree_rcu(nets, rcu); /* same object */
That queues the same rcu_head twice, and the concurrent update is lost as
well. The !tmp fallback above also writes nets->nets[j].count = 0 into an
object the other side may already have unpublished and passed to
kfree_rcu().
The pre-patch code did only memset(h->nets, 0, sizeof(h->nets)) in this
same unsynchronized spot, with no allocation and no freeing, so the memory
safety aspect looks new here. The ipset_dereference_nfnl() annotation
suggests the nfnl mutex is sufficient; should this be set->lock based
instead? This code is unchanged at the end of the series (3a0d9fd64b67).
[ ... ]
> @@ -996,7 +1048,7 @@ mtype_add(struct ip_set *set, void *value, const struct ip_set_ext *ext,
> t->hregion[r].elements++;
> #ifdef IP_SET_HASH_WITH_NETS
> for (i = 0; i < IPSET_NET_COUNT; i++)
> - mtype_add_cidr(set, h, NCIDR_PUT(DCIDR_GET(d->cidr, i)), i);
> + mtype_add_cidr(set, h, DCIDR_GET(d->cidr, i), i);
> #endif
> memcpy(data, d, sizeof(struct mtype_elem));
[Severity: High]
mtype_add_cidr() changed from void to int and can now fail:
tmp = kzalloc_flex(*tmp, nets, len, GFP_ATOMIC);
if (!tmp) {
ret = -ENOMEM;
goto unlock;
}
Should this caller check the return value? The memcpy() commits the
element unconditionally, so on allocation failure the element sits in the
bucket with no prefix registered in h->rnets[].
mtype_test_cidrs() only probes cidrs that are present with count != 0:
for (j = 0; j < nets0->len && !multi; j++) {
if (!nets0->nets[j].count)
continue;
so does such an element become permanently unmatchable, while still being
listable and deletable?
A later delete of that element runs mtype_del_cidr() for the unregistered
cidr, and if other live elements share the same cidr their count is
decremented without a matching increment. Can that count reach zero while
elements using the prefix remain, dropping the prefix out of the array and
stopping those elements from matching too? mtype_resize() copies elements
only and does not rebuild the bookkeeping, so the skew looks permanent.
The add path is reachable from the packet path via the SET target
(ip_set_add() in softirq, hence GFP_ATOMIC), and unlike the delete path
there is no "leave a hole" style fallback and no rollback of the
per-dimension registrations that already succeeded. This is unchanged at
the end of the series (3a0d9fd64b67).
[ ... ]
next prev parent reply other threads:[~2026-08-05 0:15 UTC|newest]
Thread overview: 30+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-31 15:17 [PATCH net 00/10] Netfilter/IPVS fixes for net Pablo Neira Ayuso
2026-07-31 15:17 ` [PATCH net 01/10] ipvs: stop estimator after disabled calc phase Pablo Neira Ayuso
2026-08-05 23:50 ` patchwork-bot+netdevbpf
2026-07-31 15:17 ` [PATCH net 02/10] netfilter: ebt_nflog: pin the NFLOG backend Pablo Neira Ayuso
2026-08-05 0:15 ` Jakub Kicinski
2026-08-05 7:11 ` Florian Westphal
2026-08-05 7:25 ` Paolo Abeni
2026-08-05 17:18 ` Pablo Neira Ayuso
2026-08-05 19:12 ` Florian Westphal
2026-07-31 15:17 ` [PATCH net 03/10] netfilter: ipset: rework cidr bookkeeping Pablo Neira Ayuso
2026-08-05 0:15 ` Jakub Kicinski [this message]
2026-08-05 7:33 ` Florian Westphal
2026-08-05 17:19 ` Pablo Neira Ayuso
2026-08-05 19:17 ` Florian Westphal
2026-07-31 15:18 ` [PATCH net 04/10] netfilter: ipset: switch ext_size to atomic64_t Pablo Neira Ayuso
2026-07-31 15:18 ` [PATCH net 05/10] netfilter: ipset: add small wrappers for hash and bucket sizes Pablo Neira Ayuso
2026-07-31 15:18 ` [PATCH net 06/10] netfilter: ipset: add and use mtype_del_cidr_all helper Pablo Neira Ayuso
2026-08-05 0:15 ` Jakub Kicinski
2026-07-31 15:18 ` [PATCH net 07/10] netfilter: ipset: switch to rcu work Pablo Neira Ayuso
2026-07-31 15:18 ` [PATCH net 08/10] ipvs: avoid out-of-bounds write in ip_vs_nat_icmp Pablo Neira Ayuso
2026-08-05 0:15 ` Jakub Kicinski
2026-08-05 4:18 ` Julian Anastasov
2026-08-05 17:20 ` Pablo Neira Ayuso
2026-07-31 15:18 ` [PATCH net 09/10] ipvs: return the csum validation for forward hook Pablo Neira Ayuso
2026-07-31 15:18 ` [PATCH net 10/10] netfilter: nft_ct: move custom expectation support to helper Pablo Neira Ayuso
2026-08-05 0:15 ` Jakub Kicinski
2026-08-05 17:38 ` Pablo Neira Ayuso
2026-08-05 7:42 ` [PATCH net 00/10] Netfilter/IPVS fixes for net Florian Westphal
2026-08-05 17:39 ` Pablo Neira Ayuso
2026-08-05 23:21 ` Jakub Kicinski
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260805001531.3978750-1-kuba@kernel.org \
--to=kuba@kernel.org \
--cc=davem@davemloft.net \
--cc=edumazet@google.com \
--cc=fw@strlen.de \
--cc=horms@kernel.org \
--cc=netdev@vger.kernel.org \
--cc=netfilter-devel@vger.kernel.org \
--cc=pabeni@redhat.com \
--cc=pablo@netfilter.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
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.