Before We Start
Most CVE write-ups assume you already know everything.
They jump straight into exploit code, kernel structures, and assembly, leaving beginners completely lost.
This blog is different.
I am a beginner too. I wrote this while learning. Every confusion I had, every “wait, I don’t get this” moment I documented it. Because I know that if I was confused, someone else reading this will likely be confused at the exact same point.
So we’ll go slowly. We’ll build everything from the ground up. By the end, you won’t just understand CVE-2023-32233 you’ll understand how to think about kernel vulnerabilities.
Let’s go.
Part 1 — What Actually Happens When Memory Is Freed?
Before anything else, we need to understand one fundamental thing. What does freeing memory actually mean?
Look at this simple code:
int *ptr = kmalloc(sizeof(int), GFP_KERNEL);
*ptr = 42;
kfree(ptr);
Most beginners assume that after kfree, the memory is gone. Erased. Empty.
That is wrong.
Let me explain what actually happens.
kmalloc and the Memory Pool
When you call kmalloc, the kernel does not conjure memory from thin air. It maintains a pool of memory imagine it like a parking lot with many slots.
Kernel Memory Pool:
┌──────────┬──────────┬──────────┬──────────┐
│ slot 1 │ slot 2 │ slot 3 │ slot 4 │
│ FREE │ USED │ FREE │ FREE │
└──────────┴──────────┴──────────┴──────────┘
When you call kmalloc(sizeof(int), GFP_KERNEL), the kernel finds a free slot, marks it as used, and gives you its address. The GFP_KERNEL flag tells the kernel that if memory is not immediately available, it is allowed to sleep and wait this is not an urgent allocation.
So after our allocation:
ptr → 0xffff9000
┌──────────────────┐
│ value: 42 │
└──────────────────┘
What kfree Actually Does
Here is where most people get it wrong.
When you call kfree(ptr), the kernel does not zero out or erase the memory. It simply marks that slot as available again.
Why? Because speed matters. The kernel handles thousands of allocations and frees every second. Zeroing memory on every free would be incredibly slow.
Think of it like a whiteboard:
Option A — Erase everything when someone is done
Slow. Wasteful. Every free takes extra time.
Option B — Just mark it as available
Fast. The next person will overwrite it anyway.
The kernel chooses Option B. Always.
So after kfree(ptr):
ptr → 0xffff9000 ← ptr STILL holds this address
┌──────────────────┐
│ value: 42 │ ← data is STILL here
└──────────────────┘
(but marked as FREE in the pool)
Three things to burn into your memory:
1. ptr still holds the old address
2. the data 42 is still sitting there
3. the kernel can now give this slot to anyone else
Why This Is Dangerous
If you access ptr after kfree, you are reading memory that no longer belongs to you. The kernel might have given that slot to another part of the system. You might be reading sensitive data. Or worse an attacker might have placed their own data there.
This is called Use-After-Free. Using memory after it has been freed.
You are accessing something you are not allowed to.
Part 2 — Function Pointers and Why They Are Dangerous
Now we need to understand function pointers. This is critical for understanding how a UAF becomes a full kernel exploit.
A Normal Variable vs a Function Pointer
A normal variable holds data:
int x = 42; // holds a number
A function pointer holds the address of a function:
void say_hello() {
printf("Hello!\n");
}
void (*greet)(); // greet is a pointer to a function
greet = say_hello; // store the address of say_hello
greet(); // call whatever is at that address
When you call greet(), the CPU does two things:
Step 1 — Look at greet, find the address: 0xffff8001
Step 2 — Go to 0xffff8001 and execute the code there
Two jumps. First to find the address. Then to execute it.
Struct of Function Pointers
The Linux kernel organizes function pointers into structs. Think of it like a remote control — each button does something different:
struct Actions {
void (*greet)(); // button 1
void (*bye)(); // button 2
};
void say_hello() { printf("Hello!\n"); }
void say_bye() { printf("Bye!\n"); }
struct Actions a = {
.greet = say_hello,
.bye = say_bye,
};
In memory this looks like:
struct a (at 0xffff9000)
┌──────────────────────────────────────┐
│ greet → 0xffff8001 (say_hello) │
│ bye → 0xffff8002 (say_bye) │
└──────────────────────────────────────┘
When you call a.greet(), the CPU finds the address at the greet field and executes whatever is there.
Quick test what does each line do?
printf("%p\n", a.greet); // prints the address: 0xffff8001
a.greet(); // executes say_hello → prints "Hello!"
a.bye(); // executes say_bye → prints "Bye!"
The parentheses are everything. Without them, you get the address. With them, you execute what is there.
The Netfilter Version
In the Linux Netfilter subsystem, this same pattern exists:
struct nft_set_ops {
bool (*lookup)(set, elem); // lookup function
int (*insert)(set, elem); // insert function
void (*destroy)(set); // destroy function
};
struct nft_set {
struct nft_set_ops *ops; // pointer to the "remote control"
u16 use; // reference count
// ... more fields
};
ops points to a whole collection of function pointers. When the kernel needs to look something up, it calls ops->lookup().
Why This Matters for Exploitation
Here is the dangerous scenario:
Normal situation:
ops->lookup()
│
▼
nft_hash_lookup (legitimate kernel function)
✓ safe
After UAF — attacker controls that memory:
ops->lookup()
│
▼
attacker's address
attacker's code runs
✗ with full kernel privileges
This is the critical insight. When the kernel calls ops->lookup(), it does not check if the address was tampered with. It just jumps there and executes.
And since the kernel itself is making that call, the attacker’s code runs with full kernel privileges not as a regular user, not as a process, but as the kernel. That means reading anything in kernel memory, writing anything, making yourself root.
Part 3 — Heap Spraying: The Why, Not Just the What
Every blog explains what heap spraying is. Almost none explain why it works.
The Attacker’s Problem
After a UAF is triggered, the old pointer still exists somewhere in the kernel:
old_ptr → 0xffff9000 (victim object was freed here)
The kernel will use this pointer again. The attacker wants their data to be at 0xffff9000 when that happens.
But they cannot say:
kmalloc_at_specific_address(0xffff9000); // this does not exist
The kernel allocator decides where memory goes. Not the attacker.
The Solution: Fill Everything
Since the attacker cannot choose a specific address, they fill all available slots of the same size:
for (int i = 0; i < 500; i++) {
kmalloc(same_size, GFP_KERNEL); // flood the allocator
}
Think of a parking lot with 100 spots. One specific car just left spot 47. You want your car in spot 47, but you do not know which spot just opened.
Option A — guess randomly
you park in spot 23
wrong spot, attack fails
Option B — bring 100 cars, fill every spot
one of them lands in spot 47
attack succeeds
Heap spraying is Option B. You do not pick the address. You fill everything so it does not matter.
Why Same Size Matters
This is critical. The kernel allocator is organized by object size:
kmalloc-64 → handles 64-byte objects
kmalloc-128 → handles 128-byte objects
kmalloc-256 → handles 256-byte objects
If the victim object was in kmalloc-256, freed slot goes back to the kmalloc-256 pool. Attacker must spray objects of exactly 256 bytes. Otherwise they are filling a completely different pool wrong parking lot entirely.
The Key Insight
The attacker is not stealing memory. They are moving into the house the kernel still sends mail to.
The rule never changed its pointer.
It still points to 0xffff9000.
It will call ops->lookup() at 0xffff9000.
Attacker just needs to control what is THERE when it arrives.
Part 4 — Reference Counting and Object Lifecycle
This is the mechanism that CVE-2023-32233 directly breaks.
The Library Book Problem
Imagine two functions both using the same kernel object:
struct nft_set *set = create_set();
function_A(set); // using the set
function_B(set); // also using the set
When is it safe to free set?
Think of a library book that two students have borrowed. When can the library destroy that book?
When both students return it. When nobody is using it anymore.
That is reference counting.
How It Works in the Kernel
The kernel tracks active users with a simple counter:
struct nft_set {
u16 use; // reference count — how many "borrowers"
};
Rules:
Someone starts using the object → use++
Someone stops using the object → use--
use reaches 0 → safe to kfree()
In practice:
function_A starts → set->use = 1
function_B starts → set->use = 2
function_A done → set->use = 1
function_B done → set->use = 0
use == 0 → NOW it is safe to kfree(set)
Object Lifecycle
Every kernel object has a complete lifecycle:
BORN → ATTACHED → DETACHED → DIED
kmalloc() bound to unbound kfree()
a rule from rule
The critical rule:
You cannot go from ATTACHED directly to DIED.
ATTACHED → DIED ✗ wrong
ATTACHED → DETACHED → DIED ✓ correct
What Does “Bind” and “Unbind” Mean?
When a Netfilter rule is created:
RULE: "if packet matches set_a, drop it"
│
└──→ points to set_a
set_a->use = 1
set_a is BOUND to this rule
Unbind means removing that connection:
Before unbind:
┌─────────────────┐ ┌──────────────┐
│ RULE │────────▶│ SET │
│ "match set_a" │ │ use = 1 │
└─────────────────┘ └──────────────┘
After unbind:
┌─────────────────┐ ✗ ┌──────────────┐
│ RULE │ │ SET │
│ "match set_a" │ │ use = 0 │
└─────────────────┘ │ safe now │
connection removed └──────────────┘
After unbind, the rule no longer holds a reference. The set is truly alone. Safe to free.
Part 5 — CVE-2023-32233: The Actual Bug
Now everything comes together.
What Are Anonymous Sets?
In the Linux Netfilter framework, sets store data for efficient rule matching. Anonymous sets are automatically generated as accessories to expressions in rules. Their lifecycle is supposed to be strictly bound to the rules that use them.
When a rule is created, the set’s reference count increases. When the rule is deleted, the set should be unbound and the reference count decremented. When it hits zero, the set is freed.
Simple. Clean. Correct.
Except for one function that does it wrong.
The Batch Transaction System
Before we get to the bug, you need to understand Netfilter’s batch transaction system.
Think of it like a database transaction:
BEGIN TRANSACTION
command 1 — add this rule
command 2 — delete this set
command 3 — modify this chain
END TRANSACTION
All commands are collected first, then executed together as one atomic unit.
Why? Because firewall rules cannot be partially applied. If you update 10 rules one by one and the system crashes halfway through, your firewall is in a broken half-state. Batch transactions prevent this either everything succeeds or everything rolls back.
The batch system has two phases:
PREPARE phase → kernel validates all commands
sets up memory
this is where the bug lives
COMMIT phase → kernel executes everything
makes changes permanent
The Vulnerable Function
Here is the actual buggy code in net/netfilter/nf_tables_api.c:
void nf_tables_deactivate_set(..., enum nft_trans_phase phase) {
switch (phase) {
case NFT_TRANS_PREPARE:
set->use--; // reference count decremented ← step 1
return; // returned WITHOUT unbinding ← bug is here
// unbind never happens in this path
}
}
The function only decrements use during the PREPARE phase but does not perform the unbind operation. It just returns.
So use hits zero. Kernel thinks nobody is using the set. Frees it.
But the rule that owned this set was never told to let go. It still has a reference. It will still call ops->lookup().
The Correct vs Buggy Flow
CORRECT flow:
prepare → use--
commit → unbind
use == 0
kfree ✓
BUGGY flow (CVE-2023-32233):
prepare → use--
use == 0 ← hits zero too early
kfree ← happens now
commit → unbind never happens
rule still references freed memory
ops->lookup() on freed memory
UAF ✗
Why Did This Survive 7 Years of Review?
This bug was introduced in 2016. It was discovered in 2023. Seven years in production Linux, reviewed thousands of times.
How?
Because this is not a simple mistake in one function. It is a subtle interaction between two separate systems the PREPARE phase and the COMMIT phase. Each phase looks correct in isolation. The bug only appears when you trace both phases together and realize that unbind never actually happens in this specific path.
To spot this bug, a reviewer needed to:
1. Understand the batch transaction system
2. Understand Anonymous Set lifecycle
3. Understand reference counting rules
4. Trace execution across 4-5 function calls
5. Notice that unbind never happens in this path
No single reviewer looks at all of that together. This is why complex lifecycle bugs survive for years and why they are valuable for security researchers to hunt.
Triggering the Bug
The attacker sends one batch transaction with two commands:
BEGIN BATCH
command 1 — delete the rule (triggers use--)
command 2 — delete the set (triggers kfree)
END BATCH
During PREPARE phase:
command 1 processed → use-- → use hits 0
command 2 processed → use == 0 → kfree(set)
Set is now freed. But the rule from command 1 still references it. UAF achieved.
Part 6 — The Exploit Chain
Understanding the bug is one thing. Turning it into a working exploit is another. There are two stages.
The Problem: KASLR
KASLR stands for Kernel Address Space Layout Randomization.
Every time the kernel boots, it loads itself at a random address in memory:
Boot 1: kernel base at 0xffffffff80000000
Boot 2: kernel base at 0xffffffff91000000
Boot 3: kernel base at 0xffffffff85000000
The attacker needs to craft a fake ops struct with real function addresses. But if they do not know where the kernel is loaded, they cannot calculate any address. The exploit fails.
This is why exploitation requires two stages:
Stage 1 → Info Leak: find the kernel base address, bypass KASLR
Stage 2 → Code Execution: use that address to build a working exploit
Stage 1: The Info Leak
After the UAF is triggered and the set is freed, the attacker sprays nft_rule objects into the freed slot.
Here is why nft_rule is special it contains kernel pointers inside it:
struct nft_expr {
struct nft_expr_ops *ops; // this points to nft_counter_ops
// which is at a known offset
// from the kernel base
};
The attacker then makes a legitimate Netfilter API call NFT_MSG_GETCHAIN to read chain data. Because of the object overlap, this call accidentally reads the freed set memory, which now contains the nft_rule data, which contains the kernel pointer.
The attacker gets back a real kernel address:
leaked address: 0xffffffff82af7c80 (nft_counter_ops)
known offset: 0x1af7c80 (fixed, same in every build)
kernel base = 0xffffffff82af7c80 - 0x1af7c80
= 0xffffffff81000000
KASLR defeated. Now the attacker knows where everything is.
Stage 2: Code Execution
Now the attacker uses the UAF a second time, this time spraying a fake ops struct with carefully crafted function addresses.
The fake ops struct points to a ROP chain constructed using gadgets at known offsets from the now-known kernel base:
1. find_task_by_vpid(1) → locate the init process
2. switch_task_namespaces() → escape container/sandbox
3. commit_creds(&init_cred) → copy root credentials
4. vfork() → maintain stability
When the rule naturally calls ops->lookup(), the kernel jumps to the attacker’s fake ops, follows the tampered pointer, and executes the ROP chain entirely within the kernel’s execution context.
A root shell appears.
The Full Attack Flow
1. Create victim nft_set (the "list")
Rule binds to it (use = 1)
2. Send batch transaction:
— delete rule → use-- → use = 0 (no unbind)
— delete set → kfree
Rule still points to freed memory
3. Stage 1 — Info Leak spray:
Spray nft_rule objects into freed slot
Call NFT_MSG_GETCHAIN
Kernel accidentally returns kernel pointer
Calculate kernel base
KASLR bypassed
4. Stage 2 — Code Execution spray:
Spray fake ops struct with correct ROP addresses
Trigger rule to call ops->lookup()
Kernel jumps to ROP chain
Root achieved
Part 7 — The Patch
The official patch closes this logical loophole by adding state checks in nf_tables_deactivate_set to ensure that the collection is properly unbound when the reference count reaches zero.
// patched version (simplified)
void nf_tables_deactivate_set(..., enum nft_trans_phase phase) {
switch (phase) {
case NFT_TRANS_PREPARE:
nft_set_trans_unbind(ctx, set); // ← unbind happens NOW
set->use--;
return;
}
}
The fix is one line but finding that one missing line took seven years.
What I Learned From This
This CVE taught me something bigger than just a kernel bug.
It showed me that serious vulnerabilities are not always about complex code. Sometimes they are about lifecycle logic an object that gets freed before all its relationships are properly cleaned up.
These bugs survive for years because:
Each individual piece looks correct
The bug only lives in the INTERACTION between pieces
Reviewers look at pieces, not interactions
If you want to find vulnerabilities like this, train yourself to ask one question when reading kernel code:
“Can this object be freed while something else still references it?”
Trace the lifecycle. Birth to death. Every reference. Every decrement. Every unbind.
The next CVE-2023-32233 is sitting in the kernel right now in some subsystem where a use-- happens just a few lines before an unbind that never comes.