llvm / llvm/llvm-project

[BPF] Double-free when passing non-trivially-destructible ≤8B by-value parameters (e.g. std::unique_ptr)

Open
#207,686 1 comment 0 reactions 0 assignees View on GitHub
ABI backend:BPF miscompilation
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.