Re: [PATCH net 03/10] netfilter: ipset: rework cidr bookkeeping
Jakub Kicinski <[email protected]> Tue, 4 Aug 2026 17:15:31 -0700
| Newsgroups | gmane.comp.security.firewalls.netfilter.devel,gmane.linux.network |
|---|---|
| Message-ID | <[email protected]> |
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).
[ ... ]