[Linux Kernel eBPF] Validating a BPF Trampoline UAF False Positive
Table of Contents
- One-Line Conclusion
- The Suspicion
- Scope And Assumptions
- Relevant Code And Calling Context
- Why It Looked Like A Bug
- Verification Strategy
- Reasoning
- 1. Object Lifetime: bpf_trampoline_put() Does Not Free On Refcount Alone
- 2. Memory Lifetime / Ownership: bpf_trampoline_lookup() And direct_ops_ip_lookup() Return Different Kinds Of References
- 3. State Transition: Lookup-Reachable And Free-Reachable States Look Overlapping, But They Still Have An Order
- 4. Locking / Synchronization: The Post-Unlock Consumption Window Runs Under direct_mutex
- 5. Locking Follow-Up: mutex_trylock(&tr->mutex) Is A Deadlock-Avoidance Device, Not A UAF Artifact
- 6. Config Control: The CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=n Path Is A Useful Contrast
- Revisiting The Race Scenario
- Experiments / Reproduction
- Handling Objections
- Objection 1. If It Unlocks After Lookup, Is It Not Still A UAF?
- Objection 2. Can You Really Say It Is Safe Just Because It Is Still In The Hash?
- Objection 3. Is Holding direct_mutex Guaranteed On Every Path?
- Objection 4. Is mutex_trylock() Actually A Race-Avoidance Mechanism?
- Objection 5. Does The Whole Story Change Under Different Configurations?
- The Structure Of The Illusion
- Conclusion
- Remaining Risks And Follow-Up
One-Line Conclusion
direct_ops_ip_lookup() in linux/kernel/bpf/trampoline.c returns a struct bpf_trampoline * after releasing a lock. At a glance, that looks like a textbook Use-After-Free pattern, but following the x86_64 path in the current repository's sources/linux tree at v7.0.0-rc6 (9147566d8016), I think this suspicion is best judged as a false positive.
My confidence is fairly high, but this post still does not include dynamic experimental results. So I am keeping the conclusion narrow and stating it only within the range confirmed by static analysis.
The Suspicion
The starting point was simple. Two different AI static analysis models pointed to nearly the same spot. Their claim was that direct_ops_ip_lookup() returns a tr pointer after unlocking trampoline_mutex, and then the caller, bpf_tramp_ftrace_ops_func(), uses that pointer. If another thread calls bpf_trampoline_put(tr) in between, that could become a UAF.
The surface pattern really does look plausible.
static struct bpf_trampoline *direct_ops_ip_lookup(struct ftrace_ops *ops,
unsigned long ip)
{
struct hlist_head *head_ip;
struct bpf_trampoline *tr;
mutex_lock(&trampoline_mutex);
head_ip = &trampoline_ip_table[hash_64(ip, TRAMPOLINE_HASH_BITS)];
hlist_for_each_entry(tr, head_ip, hlist_ip) {
if (tr->ip == ip)
goto out;
}
tr = NULL;
out:
mutex_unlock(&trampoline_mutex);
return tr;
}
Return a raw pointer after unlock, then dereference it in the caller. In kernel code, that shape always deserves suspicion first. I did not think that initial reaction was excessive.
Scope And Assumptions
This post is limited to the following scope.
- Target files:
sources/linux/kernel/bpf/trampoline.c,sources/linux/kernel/trace/ftrace.c - Source baseline:
v7.0.0-rc6in thesources/linuxtree, commit9147566d8016 - Arch:
x86_64 - Config:
CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS=y- The main target is the
CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=ypath - The
CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=npath is also used as a control
- Out of scope:
- Architecture-specific ftrace details outside
x86_64 - Dynamic experimental results
- Exhaustive review of mailing-list discussion or patch history
- Architecture-specific ftrace details outside
So the conclusion here is strictly a static analysis result based on the source currently present in this repository and the call paths I traced from it.
Relevant Code And Calling Context
There are not that many functions that matter for this suspicion. About six are enough to see the flow.
| Function | Role | Why it matters here |
|---|---|---|
direct_ops_ip_lookup() | Looks up a trampoline by IP | Returns after a hash lookup under trampoline_mutex, then unlocks |
bpf_tramp_ftrace_ops_func() | Direct ftrace callback | The point that immediately consumes the returned tr |
prepare_direct_functions_for_ipmodify() | Resolves IPMODIFY vs DIRECT conflicts | Calls ops_func while holding direct_mutex |
cleanup_direct_functions_after_ipmodify() | Cleanup after unregister | Also calls ops_func under direct_mutex |
bpf_trampoline_put() | Drops the last reference and conditionally frees | Frees only after refcount == 0, empty progs_hlist, and hash removal |
direct_ops_free() | Cleans up direct ftrace state | Runs only immediately before the actual free |
At a very high level, the call context looks like this.
- ftrace resolves a direct/IPMODIFY conflict.
- In that process it calls
bpf_tramp_ftrace_ops_func()throughops->ops_func(). - Inside the callback,
direct_ops_ip_lookup()findstr. - It then tries
mutex_trylock(&tr->mutex)to adjust trampoline state. - If needed, it continues into
bpf_trampoline_update().
The short call flow looks like this.
prepare_direct_functions_for_ipmodify()
or cleanup_direct_functions_after_ipmodify()
-> op->ops_func(...)
-> bpf_tramp_ftrace_ops_func()
-> direct_ops_ip_lookup()
-> mutex_trylock(&tr->mutex)
-> bpf_trampoline_update(...)
The key point is that this callback is not invoked in an arbitrary context with no surrounding guarantees. Both prepare_direct_functions_for_ipmodify() and cleanup_direct_functions_after_ipmodify() call ops_func in a direct_mutex context.
That assumption also shows up in a comment inside trampoline.c.
/* The normal locking order is
* tr->mutex => direct_mutex (ftrace.c) => ftrace_lock (ftrace.c)
*
* The following two commands are called from
*
* prepare_direct_functions_for_ipmodify
* cleanup_direct_functions_after_ipmodify
*
* In both cases, direct_mutex is already locked. Use
* mutex_trylock(&tr->mutex) to avoid deadlock in race condition
* (something else is making changes to this same trampoline).
*/
That comment alone is not enough to settle the issue, but it clearly tells us what locking assumptions this code was written around.
Why It Looked Like A Bug
There are clear reasons this suspicion looked persuasive.
First, direct_ops_ip_lookup() does not increment a refcount. Unlike bpf_trampoline_lookup(), which returns an owning reference, this one looks like it just finds a pointer and hands it back.
Second, the caller immediately uses the pointer after unlock. If you isolate only that shape, it is a very typical lookup -> unlock -> dereference UAF pattern.
Third, the free path is easy to notice. bpf_trampoline_put() eventually goes all the way to kfree(tr), so it is natural to picture a simple story where another CPU does a put and frees the object right after the lookup.
Fourth, mutex_trylock(&tr->mutex) also looks suspicious at first glance. It is easy to read it as a quick race-avoidance patch.
Fifth, the fact that two different AI models reached the same conclusion strengthens the illusion on its own. When two independent analyses point to the same spot, people tend to trust the conclusion more quickly.
So yes, if you look at only a few lines of code, the suspicion is plausible enough. The problem is that those few lines are not the whole lifetime story.
Verification Strategy
This time I chose to tighten the static analysis rather than add dynamic experiments.
- I checked the lookup scope of
direct_ops_ip_lookup(). - I traced which upper-level paths call
bpf_tramp_ftrace_ops_func(). - I separated the roles and ordering of
trampoline_mutex,tr->mutex,direct_mutex, andftrace_lock. - I compared the reference semantics of
bpf_trampoline_lookup()anddirect_ops_ip_lookup(). - I summarized the prerequisites for
bpf_trampoline_put()to actually reach free. - I wrote down the most aggressive interleaving I could think of and checked where the race gets closed.
- I also looked at the
CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=npath as a control.
Since experiments are missing, the wording needs to stay careful. What I am saying here is not "this is proven safe in every environment," but rather "within the current static analysis scope, this looks like a false positive."
Reasoning
There are really four questions this post has to close.
- When does
trbecome eligible for free? - What kind of reference does
direct_ops_ip_lookup()actually return? - What lock and ordering close the post-unlock consumption window?
- Even under the most aggressive interleaving, does this really close as a UAF?
The reasoning below follows that order.
1. Object Lifetime: bpf_trampoline_put() Does Not Free On Refcount Alone
I think this is where the LLM reports simplified the most.
The sequence inside bpf_trampoline_put() is stronger than it first appears.
- It takes
trampoline_mutex. refcount_dec_and_test()must return true.tr->mutexmust not be held.- Every
progs_hlist[i]must be empty. - Only then does it execute
hlist_del(&tr->hlist_key)andhlist_del(&tr->hlist_ip). - Finally it proceeds to
direct_ops_free(tr)andkfree(tr).
So free is not an operation that closes immediately just because another CPU called one put(). For the last put to matter, the trampoline must already be in a state where teardown is possible.
This gives us the first invariant.
For
trto be freed,refcount == 0, emptyprogs_hlist, and hash removal must all come first.
2. Memory Lifetime / Ownership: bpf_trampoline_lookup() And direct_ops_ip_lookup() Return Different Kinds Of References
This distinction has to come first.
On a hit, bpf_trampoline_lookup() performs refcount_inc(&tr->refcnt). What it returns is an explicit owning reference. By contrast, direct_ops_ip_lookup() does not increment a refcount. What it returns is better read not as a long-lived reference, but as a borrowed pointer consumed briefly inside the callback.
If you miss that difference, it is very easy to slide into the following reasoning.
- There is no refcount increment, so there is no lifetime guarantee at all.
- Therefore dereferencing after unlock is immediately a UAF.
But in kernel code, a pointer without its own refcount is not automatically a bad pointer. A borrowed pointer can remain valid if some other ordering closes the relevant lifetime window. This code is closer to that case.
3. State Transition: Lookup-Reachable And Free-Reachable States Look Overlapping, But They Still Have An Order
If direct_ops_ip_lookup() finds tr, that means tr is still present in trampoline_ip_table at that moment. Conversely, for bpf_trampoline_put() to actually free it, it first has to finish hlist_del(&tr->hlist_ip).
So at least the following statement holds.
At the moment
direct_ops_ip_lookup()returnstr, the state observed by that CPU is still beforehlist_del(&tr->hlist_ip).
That statement alone does not automatically prove the entire post-unlock window is safe. But it does impose a clear ordering constraint: before free can happen, the object must first leave the lookup-reachable state.
A more careful way to say the same thing is this.
Hash membership is not a sufficient condition for safety, but it is a strong necessary condition that shows the object is still before the free stage.
4. Locking / Synchronization: The Post-Unlock Consumption Window Runs Under direct_mutex
The decisive reason this is hard to treat as a simple UAF is the calling context.
prepare_direct_functions_for_ipmodify() first enforces that direct_mutex is held through lockdep_assert_held_once(&direct_mutex), then calls op->ops_func(..., FTRACE_OPS_CMD_ENABLE_SHARE_IPMODIFY_PEER) for the relevant direct ops. cleanup_direct_functions_after_ipmodify() likewise takes direct_mutex internally and then sends the FTRACE_OPS_CMD_DISABLE_SHARE_IPMODIFY_PEER callback.
So the PEER callbacks that touch this suspicion do not execute in an unguarded window where only trampoline_mutex has been released. They already execute inside direct_mutex ordering.
This also matches the bpf_trampoline_update(tr, false) call. Inside the callback, direct_mutex is already held, so false is passed specifically to avoid taking that lock again. That design is not accidental. It assumes this calling context.
That leads to the second invariant.
The relevant PEER callback does not run in a fully unprotected post-unlock window. It runs inside
direct_mutexordering.
5. Locking Follow-Up: mutex_trylock(&tr->mutex) Is A Deadlock-Avoidance Device, Not A UAF Artifact
This line looked the most suspicious to me at first.
if (!mutex_trylock(&tr->mutex)) {
msleep(1);
return -EAGAIN;
}
But its meaning changes once you read it together with the comment above. The normal locking order is tr->mutex => direct_mutex => ftrace_lock, while the callback enters with direct_mutex already held. If it used a normal blocking mutex_lock(&tr->mutex) there, it could create a lock-order inversion. So it uses trylock, and if that fails, it backs off briefly and returns -EAGAIN so the operation can be retried.
So this line reads more naturally as a deadlock-avoidance device within a known lock-ordering scheme, not as evidence that the code is hiding a UAF race.
6. Config Control: The CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=n Path Is A Useful Contrast
The suspicious IP hash lookup path appears only when CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=y. If that setting is disabled, direct_ops_ip_lookup() simply returns ops->private.
That difference matters. It suggests this is less a generic "unlock then return pointer" bug pattern and more an illusion caused by reading the singleton direct_ops design only at the surface level.
Revisiting The Race Scenario
The most aggressive scenario looks like this.
| Step | CPU0 | CPU1 | Interpretation |
|---|---|---|---|
| 1 | Enter bpf_tramp_ftrace_ops_func() | CPU0 is already in a direct_mutex context | |
| 2 | Find tr via direct_ops_ip_lookup() and unlock trampoline_mutex | At this point tr is still in the IP hash | |
| 3 | Try to tear down the same tr all the way to the last put | The teardown preparation itself must go through tr->mutex, direct_mutex, and update paths | |
| 4 | Try mutex_trylock(&tr->mutex); if it succeeds, adjust state, otherwise return -EAGAIN | If the same trampoline is already being modified elsewhere, CPU0 backs off | |
| 5 | Callback exits | Only after that can teardown progress further | The simple "lookup then immediate free" story does not close as-is |
The point of the table is simple. For CPU1 to really reach kfree(tr), the trampoline must already have entered a teardown-eligible state first. But the preparation for that state is itself constrained by tr->mutex and direct_mutex ordering. CPU0 is running the callback inside exactly that ordering.
So this pointer is not an owning reference, but it is also not a completely unprotected raw pointer. It is closer to a borrowed pointer whose lifetime is briefly held in place by callback context and teardown ordering.
Reduced to one sentence, the key invariant of this post is the following.
If
direct_ops_ip_lookup()foundtr, and that pointer is immediately consumed insidebpf_tramp_ftrace_ops_func()underdirect_mutexcontext, teardown ordering reinforces the lifetime of that borrowed pointer during the consumption window.
Experiments / Reproduction
This post does not yet include dynamic verification results such as a reproducer, KASAN, or lockdep. That gap is real.
That is why I am deliberately not writing the conclusion too strongly. What I am saying is roughly this.
- Along the code paths traced in the current repository, this looks like a false positive under static analysis.
- But because dynamic verification is still missing, even a high-confidence conclusion should keep its claim scope narrow.
So this section remains here not as a results section, but as an explicit placeholder for missing validation. The follow-up experiments are already fairly clear.
- Check KASAN under a stress path that repeatedly attaches and detaches
- Check lockdep along the direct/IPMODIFY enable-disable conflict path
- Write a reproducer that injects delay just before the last put to shake the interleaving harder
Once those experiments exist, the post will become more solid. But even now, I think the static reasoning already shows the outline of a not-a-bug conclusion.
Handling Objections
Objection 1. If It Unlocks After Lookup, Is It Not Still A UAF?
At the surface, yes. But here there is an order of hash membership -> teardown preparation -> hash removal -> free, and the callback consumption window also runs inside direct_mutex context. So it is hard to treat this with the same force as a generic "unlock then raw pointer" pattern.
Objection 2. Can You Really Say It Is Safe Just Because It Is Still In The Hash?
I would not go that far. Saying hash membership alone is sufficient would be an overstatement. The more accurate wording is this: hash membership is a strong necessary condition showing the object is still before free, and the short post-unlock window is closed further by teardown ordering that includes direct_mutex.
Objection 3. Is Holding direct_mutex Guaranteed On Every Path?
For the core PEER callback paths I directly checked in this post, yes. But I am not at the stage where I would generalize that claim to every surrounding path or every architecture-specific direct ftrace implementation. That is why I keep the scope limited to x86_64 and the current source tree.
Objection 4. Is mutex_trylock() Actually A Race-Avoidance Mechanism?
Reading the code comment together with the lock ordering, it is better understood as deadlock avoidance. That does not mean there are no races anywhere, but it is too much to read this one line as some kind of confession of a UAF.
Objection 5. Does The Whole Story Change Under Different Configurations?
Yes. That is exactly why I also checked the CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=n path as a control. In that case, direct_ops_ip_lookup() returns ops->private, and the IP hash lookup structure that triggered this suspicion does not appear at all. This suspicion is closer to a misread of a specific config-dependent design than to a universal UAF pattern.
The Structure Of The Illusion
What makes this case interesting is that it does not end at "the AI was wrong." It is also fairly clear why it is easy to be wrong here.
Code that returns a pointer after unlocking a mutex almost always reads as dangerous in kernel code. And often it really is dangerous. But this pattern is not classified as safe or unsafe by its surface shape alone. The line is usually drawn by lifetime mechanisms outside that snippet.
What I took away from this case is a pair of questions.
- For the returned object to be freed, must it first leave the lookup-reachable state?
- Even if the returned pointer is not an owning reference, does the upper calling context's lock and ordering close the post-unlock consumption window?
If both answers are yes, then returning a pointer after unlock is not immediately a UAF. If either side is missing, the chance of a real bug goes up.
Conclusion
Within the range I have checked so far, I judge the UAF suspicion around direct_ops_ip_lookup() to be a false positive. My confidence is fairly high, but I am still stating that only within the bounds of static analysis.
The core reasons are three.
- What
direct_ops_ip_lookup()returns is not an owning reference, but a borrowed pointer consumed briefly inside callback context. - For
bpf_trampoline_put()to actually reach free, refcount exhaustion, program unlink, and hash removal must all happen first. - The PEER callback directly tied to this suspicion executes inside
direct_mutexcontext, and teardown is constrained by the same ordering, so the simple interleaving of "lookup then immediate free" does not hold as-is.
Still, this conclusion is limited to the current static analysis scope. If dynamic experiments and other architecture checks are added later, the post will become much stronger. Until then, even with fairly high confidence, the wording should stay careful.
Remaining Risks And Follow-Up
- I have not checked direct ftrace implementation details outside
x86_64yet. - Without dynamic experiments, the overall evidence package is still incomplete.
- If needed, it may be worth considering a small documentation patch to strengthen the comments in
trampoline.c. - It may also be worth searching for other
unlock -> borrowed pointerpatterns that create the same kind of illusion.