Skip to content

Loophole in some structured loan-backed products (a.k.a., unsoundness in the API) #1

Description

@danielhenrymantilla

Hey, nice job with this crate, and the flavor around it 😁

Alas, I'm afraid the ergonomic anonymous-binding-scope!()-macro API which you wished to use is unable to expose a fully sound non-unsafe API.

The culprit? Rust yield-generators / async {}.await coroutines.

Philosophical Explanation

Indeed, their sugar makes it so some locals are not "stack" locals, but rather, part of the coroutine state. This results in two related things:

  • As a yield / .await happens, it is possible to return from the current (actual poll()) function before the end of the function is executed, which includes code in charge of cleaning up this coroutine-captured state.

  • We're not even guaranteed that this coroutine-captured state will ever be cleaned up, as it can end up pinned in "static memory" (or rather, : 'static memory, such as the one produced by Box::leak())

Another way to highlight it is that this results in a very conceptually odd aspect of Rust:

  • any <'generic> lifetime parameter of an fn is deemed "bigger" than any local-to-the-fn-body lifetime, even when these fn-locally-owned instances end up in some Coroutine or Future state.

    • This is "generally"/in-the-typical-case sound because the returned Coroutine/Future type ends up 'generic-lifetime infected.
  • and yet local-to-the-fn-body lifetimes, such as in &'local T, when the T instance is captured as part of the Coroutine/Future state, is actually more like a Pin<&'local T>. The Pin-ness ensures that the drop of any locally-owned var will run before the coroutine's backing memory is repurposed ("the Drop guarantee of Pin").

    ⚠️ But the coroutine's state may actually never get repurposed, if it ends up pinned in 'static memory ⚠️

Warning

In practice this means that fn-locally-owned instances may actually never get dropped: being an anonymous binding, such as the Scope instance produced by the scope!() in the &Scope { … } RHS, or being a macro-hygiene-private binding (such as the one produced by pin!) does not prevent this ⚠️

Exploit

use ::core::{future, mem, task};

async fn foo(r: &str, out: &mut Option<::lien::Ref<str>>) {
    let scope = ::lien::scope!();
    *out = Some(scope.lend(r));
    future::pending().await
}

#[test]
fn main() {
    let r = {
        let mut out = None;
        let borrowee = String::from("...");
        let fut = foo(&borrowee, &mut out);
        poll_once_and_leak(fut);
        // drop(borrowee);
        out.unwrap()
    };
    let _unrelated = String::from("UAF"); // just for the `cargo t -- --nocapture` demo
    dbg!(&*r); // on miri, UAF UB-detection panic, else, it probably prints `UAF`.

    unreachable!("this should either have failed to compile, or hanged before hitting this");
}

fn poll_once_and_leak(fut: impl Future) {
    _ = Future::poll(
        // Leaked-in-`'static` `pin`ned data:
        //
        // Could also have been written as:
        //   - `Pin::static_mut(Box::leak(Box::new(fut)))`
        //   - `Box::pin(fut).as_mut()` (followed by a `forget()`)
        mem::ManuallyDrop::new(Box::pin(fut)).as_mut(),
        &mut task::Context::from_waker(task::Waker::noop()),
    );
}

On miri:

$ cargo t --test exploit -q

running 1 test
main --- FAILED

failures:

---- main stdout ----
[tests/exploit.rs:20:5] &*r = "UAF"

thread 'main' (57885) panicked at tests/exploit.rs:22:5:
internal error: entered unreachable code: this should either have failed to compile, or hanged before hitting this
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ cargo +nightly -vV
cargo 1.98.0-nightly (fe63976b2 2026-06-11)
release: 1.98.0-nightly
commit-hash: fe63976b245b8a649c3f2949bf89fdc307bfbae4
commit-date: 2026-06-11
host: x86_64-unknown-linux-gnu
libgit2: 1.9.4 (sys:0.21.0 vendored)
libcurl: 8.20.0-DEV (sys:0.4.88+curl-8.20.0 vendored ssl:OpenSSL/3.6.2)
ssl: OpenSSL 3.6.2 7 Apr 2026
os: Debian 13.0.0 (trixie) [64-bit]
$ cargo +nightly miri t -q --test exploit
running 1 test
error: Undefined Behavior: constructing invalid value of type &str: encountered a dangling reference (use-after-free)
   --> /usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/non_null.rs:441:18
    |
441 |         unsafe { &*self.as_ptr().cast_const() }
    |                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here
    |
    = help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
    = help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
    = note: this is on thread `main`
    = note: stack backtrace:
            0: std::ptr::NonNull::<str>::as_ref::<'_>
                at /usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/non_null.rs:441:18: 441:46
            1: <lien::Ref<str> as std::ops::Deref>::deref
                at src/lib.rs:496:26: 496:43
            2: main
                at tests/exploit.rs:20:11: 20:13
            3: main::{closure#0}
                at tests/exploit.rs:10:10: 10:10

Solution

There is no way around getting rid of the macro API, alas, unless you found a way with a macro to assert you're in a place which forbids yields or .awaits. So you'll need to use a callback API for your Scope constructor:

impl Scope<'a> {
    pub fn with<R>(scope: impl FnOnce(&Scope<'a>) -> R) -> R {
        scope(&Scope { __rc: &Rc::new(), __phantom: <_>::default() })
    }
}
  • Silver lining is you'll be able to get rid of the #[doc(hidden)] pub __…-prefixed field names, I guess

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions