[Linux Kernel eBPF] Validating a BPF Trampoline UAF False Positive

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-rc6 in the sources/linux tree, commit 9147566d8016
  • Arch: x86_64
  • Config:
    • CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS=y
    • The main target is the CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=y path
    • The CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=n path 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

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.

FunctionRoleWhy it matters here
direct_ops_ip_lookup()Looks up a trampoline by IPReturns after a hash lookup under trampoline_mutex, then unlocks
bpf_tramp_ftrace_ops_func()Direct ftrace callbackThe point that immediately consumes the returned tr
prepare_direct_functions_for_ipmodify()Resolves IPMODIFY vs DIRECT conflictsCalls ops_func while holding direct_mutex
cleanup_direct_functions_after_ipmodify()Cleanup after unregisterAlso calls ops_func under direct_mutex
bpf_trampoline_put()Drops the last reference and conditionally freesFrees only after refcount == 0, empty progs_hlist, and hash removal
direct_ops_free()Cleans up direct ftrace stateRuns only immediately before the actual free

At a very high level, the call context looks like this.

  1. ftrace resolves a direct/IPMODIFY conflict.
  2. In that process it calls bpf_tramp_ftrace_ops_func() through ops->ops_func().
  3. Inside the callback, direct_ops_ip_lookup() finds tr.
  4. It then tries mutex_trylock(&tr->mutex) to adjust trampoline state.
  5. 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.

  1. I checked the lookup scope of direct_ops_ip_lookup().
  2. I traced which upper-level paths call bpf_tramp_ftrace_ops_func().
  3. I separated the roles and ordering of trampoline_mutex, tr->mutex, direct_mutex, and ftrace_lock.
  4. I compared the reference semantics of bpf_trampoline_lookup() and direct_ops_ip_lookup().
  5. I summarized the prerequisites for bpf_trampoline_put() to actually reach free.
  6. I wrote down the most aggressive interleaving I could think of and checked where the race gets closed.
  7. I also looked at the CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=n path 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.

  1. When does tr become eligible for free?
  2. What kind of reference does direct_ops_ip_lookup() actually return?
  3. What lock and ordering close the post-unlock consumption window?
  4. 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.

  1. It takes trampoline_mutex.
  2. refcount_dec_and_test() must return true.
  3. tr->mutex must not be held.
  4. Every progs_hlist[i] must be empty.
  5. Only then does it execute hlist_del(&tr->hlist_key) and hlist_del(&tr->hlist_ip).
  6. Finally it proceeds to direct_ops_free(tr) and kfree(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 tr to be freed, refcount == 0, empty progs_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() returns tr, the state observed by that CPU is still before hlist_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_mutex ordering.

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.

StepCPU0CPU1Interpretation
1Enter bpf_tramp_ftrace_ops_func()CPU0 is already in a direct_mutex context
2Find tr via direct_ops_ip_lookup() and unlock trampoline_mutexAt this point tr is still in the IP hash
3Try to tear down the same tr all the way to the last putThe teardown preparation itself must go through tr->mutex, direct_mutex, and update paths
4Try mutex_trylock(&tr->mutex); if it succeeds, adjust state, otherwise return -EAGAINIf the same trampoline is already being modified elsewhere, CPU0 backs off
5Callback exitsOnly after that can teardown progress furtherThe 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() found tr, and that pointer is immediately consumed inside bpf_tramp_ftrace_ops_func() under direct_mutex context, 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.

  1. For the returned object to be freed, must it first leave the lookup-reachable state?
  2. 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.

  1. What direct_ops_ip_lookup() returns is not an owning reference, but a borrowed pointer consumed briefly inside callback context.
  2. For bpf_trampoline_put() to actually reach free, refcount exhaustion, program unlink, and hash removal must all happen first.
  3. The PEER callback directly tied to this suspicion executes inside direct_mutex context, 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_64 yet.
  • 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 pointer patterns that create the same kind of illusion.