[BPF] Double-free when passing non-trivially-destructible ≤8B by-value parameters (e.g. std::unique_ptr)
- Dominant language
- LLVM
- Stars
- 40.5k
- Forks
- 18.7k
- PR merge metrics
- PR metrics pending
Description
### Summary
When compiling C++ for the BPF target, passing a **≤8B non-trivially-destructible type by value** (e.g. `std::unique_ptr`, 8 bytes) produces code that **double-frees**.
The BPF backend lowers such a parameter directly to `i64` (passing the pointer value by value in a register). However, Clang's frontend still emits a **backup temporary** on the caller side — move-constructed from the argument, then destroyed after the call — assuming the Itanium *invisible-reference* ABI where the callee would null the temp on move-out. Since the callee receives the value directly (not a pointer to the temp), it **cannot** null the temp, and the caller's destructor of the still-holding-pointer temporary double-frees.
No diagnostic is emitted; the miscompile is silent.
### Reproducer
Self-contained, no standard-library headers needed:
```cpp
struct U { // like std::unique_ptr: 8B, non-trivial dtor
int* p;
U();
explicit U(int*);
U(U&&) noexcept; // out-of-line, so the backup temp is not elided
U& operator=(U&&) noexcept;
~U();
};
struct Node { int v; U child; };
__attribute__((noinline)) Node* mk(U l) { // by-value non-trivial ≤8B param
Node* n = new Node();
n->v = 0;
n->child = static_cast(l); // move param into member (callee takes ownership)
return n;
}
int main(int argc, char**) {
U leaf(new int(argc));
Node* root = mk(static_cast(leaf));
return root->v;
}
// out-of-line definitions are opaque stubs (only needed for codegen; -emit-llvm doesn't require them)
U::U() : p(nullptr) {}
U::U(int* x) : p(x) {}
U::U(U&& o) noexcept : p(o.p) { o.p = nullptr; }
U& U::operator=(U&& o) noexcept { if (p) delete p; p = o.p; o.p = nullptr; return *this; }
U::~U() { if (p) delete p; }
```
### Command
```
clang++ -std=c++20 -target bpf -mcpu=v4 -O1 -S -emit-llvm -nostdinc++ repro.cpp -o repro.ll
```
### Resulting IR (BPF, buggy)
`mk`'s signature — the `U` parameter is lowered to `i64`:
```llvm
define dso_local ptr @_Z2mk1U(i64 %0) ...
```
In `main`, the caller materializes a backup temporary `%4`, move-constructs into it (`_ZN1UC1EOS_` = `U::U(U&&)`), then passes the pointer **value** (loaded from `%4` and `ptrtoint`'d) — *not* a pointer to `%4` — to `mk`. After the call, `mk` owns the pointer (it moved it into `e->child`), yet `main` destroys `%4`, which still holds that same pointer:
```llvm
call void @_ZN1UC1EPi(ptr %3, ptr %5) ; construct leaf (%3)
call void @_ZN1UC1EOS_(ptr %4, ptr %3) ; move-construct backup %4 from leaf
%6 = load ptr, ptr %4
%7 = ptrtoint ptr %6 to i64
invoke ptr @_Z2mk1U(i64 %7) ; mk takes ownership of the pointer
to label %9 unwind label %11
9:
call void @_ZN1UD1Ev(ptr %4) ; ~backup: deletes the same pointer → DOUBLE-FREE
...
call void @_ZN1UD1Ev(ptr %3) ; ~leaf: noop (was nulled by the move-construct)
```
### Expected (x86_64 -O1, correct)
The same source compiled for x86_64 passes the temp **by pointer** (invisible-reference ABI). `mk` receives `ptr %1` (the caller's temp), reads the pointer, and **nulls the temp** on move-out — so the caller's destructor of the now-null temp is a noop:
```llvm
define void @_Z2mk1U(ptr sret %0, ptr noundef %1) ...
; inside mk:
%5 = load ptr, ptr %1
store ptr null, ptr %1 ; callee nulls the caller's temp
```
No double-free.
### Scope
Only **≤8B non-trivially-destructible by-value parameters** are affected (`std::unique_ptr`, any 8B type with a non-trivial destructor). Types **>8B** (`std::string` 24B, `std::shared_ptr` 16B) go through the `byval`/ptr invisible-reference path and are correct (callee shares the caller's temp and nulls it on move-out).
This is unrelated to the `byval` attribute: `unique_ptr` is non-trivially-copyable, so the frontend never emits `byval` for it. The mismatch is purely between Clang's frontend codegen (which assumes invisible-reference ABI for non-trivial by-value params) and the BPF backend's calling convention (which lowers ≤8B such params to `i64` by value).
### Versions affected
Reproduced on **clang 19.1.7, 20.1.8, 21.1.8, 22.1.8, and 23 trunk** (built 2026-07-05). The generated IR is byte-identical across all five versions — the bug has not changed in ~2 years and is not a recent regression.
### Suggested fix direction
Either:
- The BPF backend should use the invisible-reference ABI for non-trivially-destructible by-value params (pass `ptr` to a caller-owned temp, like x86), so the callee can null it on move-out; **or**
- Clang's frontend, when targeting BPF, should not emit the backup temporary + cleanup for params the backend will lower to `i64` by value; **or**
- At minimum, emit a diagnostic ("non-trivial by-value parameter not supported on BPF") instead of silently miscompiling — consistent with how the BPF backend already rejects `sret`/`byval`.
Full BPF IR (repro.ll, clang 19.1.7)
```llvm
; ModuleID = 'repro.cpp'
source_filename = "repro.cpp"
target datalayout = "e-m:e-p:64:64-i64:64-i128:128-n32:64-S128"
target triple = "bpf"
%struct.U = type { ptr }
define dso_local noundef nonnull ptr @_Z2mk1U(i64 %0) local_unnamed_addr #0 personality ptr @__gxx_personality_v0 {
%2 = alloca %struct.U, align 8
%3 = inttoptr i64 %0 to ptr
store ptr %3, ptr %2, align 8
%4 = tail call noalias noundef nonnull dereferenceable(16) ptr @_Znwm(i64 noundef 16) #8
tail call void @llvm.memset.p0.i64(ptr noundef nonnull align 8 dereferenceable(16) %4, i8 0, i64 16, i1 false)
%5 = getelementptr inbounds i8, ptr %4, i64 8
invoke void @_ZN1UC1Ev(ptr noundef nonnull align 8 dereferenceable(8) %5)
to label %6 unwind label %8
6:
store i32 0, ptr %4, align 8
%7 = call noundef nonnull align 8 dereferenceable(8) ptr @_ZN1UaSEOS_(ptr %5, ptr %2) #9
ret ptr %4
8:
%9 = landingpad { ptr, i32 } cleanup
tail call void @_ZdlPvm(ptr noundef %4, i64 noundef 16) #10
resume { ptr, i32 } %9
}
define dso_local noundef i32 @main(i32 noundef %0, ptr nocapture noundef readnone %1) local_unnamed_addr #6 personality ptr @__gxx_personality_v0 {
%3 = alloca %struct.U, align 8
%4 = alloca %struct.U, align 8
call void @llvm.lifetime.start.p0(i64 8, ptr nonnull %3) #9
%5 = tail call noalias noundef nonnull dereferenceable(4) ptr @_Znwm(i64 noundef 4) #8
store i32 %0, ptr %5, align 4
call void @_ZN1UC1EPi(ptr %3, ptr noundef nonnull %5)
call void @_ZN1UC1EOS_(ptr %4, ptr %3) #9
%6 = load ptr, ptr %4, align 8
%7 = ptrtoint ptr %6 to i64
invoke noundef ptr @_Z2mk1U(i64 %7)
to label %9 unwind label %11
9:
call void @_ZN1UD1Ev(ptr %4) #9
%10 = load i32, ptr %8, align 8
call void @_ZN1UD1Ev(ptr %3) #9
call void @llvm.lifetime.end.p0(i64 8, ptr nonnull %3) #9
ret i32 %10
11:
%12 = landingpad { ptr, i32 } cleanup
call void @_ZN1UD1Ev(ptr %4) #9
call void @_ZN1UD1Ev(ptr %3) #9
call void @llvm.lifetime.end.p0(i64 8, ptr nonnull %3) #9
resume { ptr, i32 } %12
}
```
Contributor guide
Research direction
Start with the self-contained repro.cpp and run the provided clang++ BPF command, then compare the generated IR with the x86_64 example. Trace the frontend and BPF calling-convention handling for non-trivial by-value parameters; done means avoiding the demonstrated double-free or emitting the proposed diagnostic, with the chosen approach requiring clarification.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp
- Domain
- compilers
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100