Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unsoundness #100

Closed
cynecx opened this issue Dec 14, 2023 · 11 comments
Closed

Unsoundness #100

cynecx opened this issue Dec 14, 2023 · 11 comments

Comments

@cynecx
Copy link
Contributor

cynecx commented Dec 14, 2023

event-listener in its current state (531c106) is unsound. It's possible to trigger a use-after-free bug completely with safe Rust.

PoC:

use event_listener::{Event, EventListener};

fn main() {
    let event = Event::new();
    let event2 = Event::new();

    let mut listener = Box::pin(EventListener::<()>::new());
    listener.as_mut().listen(&event);
    listener.as_mut().listen(&event2);

    drop(listener);
    event.notify(1);
}

cargo miri run:

paul@Pauls-MBP ~/dev/event-listener-test (git)-[master] % cargo miri run
Preparing a sysroot for Miri (target: aarch64-apple-darwin)... done
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `/Users/paul/.rustup/toolchains/nightly-aarch64-apple-darwin/bin/cargo-miri runner target/miri/aarch64-apple-darwin/debug/event-listener-test`
error: Undefined Behavior: out-of-bounds pointer use: alloc846 has been freed, so this pointer is dangling
   --> /Users/paul/.rustup/toolchains/nightly-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/ptr/non_null.rs:399:18
    |
399 |         unsafe { &*self.as_ptr().cast_const() }
    |                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ out-of-bounds pointer use: alloc846 has been freed, so this pointer is dangling
    |
    = 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
help: alloc846 was allocated here:
   --> src/main.rs:7:24
    |
7   |     let mut listener = Box::pin(EventListener::<()>::new());
    |                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: alloc846 was deallocated here:
   --> src/main.rs:11:5
    |
11  |     drop(listener);
    |     ^^^^^^^^^^^^^^
    = note: BACKTRACE (of the first span):
    = note: inside `std::ptr::NonNull::<event_listener::sys::Link<()>>::as_ref::<'_>` at /Users/paul/.rustup/toolchains/nightly-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/ptr/non_null.rs:399:18: 399:46
    = note: inside `event_listener::sys::Inner::<()>::notify::<event_listener::notify::Notify>` at /Users/paul/.cargo/registry/src/index.crates.io-6f17d22bba15001f/event-listener-4.0.0/src/std.rs:263:42: 263:52
    = note: inside `event_listener::sys::<impl event_listener::Inner<()>>::notify::<event_listener::notify::Notify>` at /Users/paul/.cargo/registry/src/index.crates.io-6f17d22bba15001f/event-listener-4.0.0/src/std.rs:119:9: 119:35
    = note: inside `event_listener::Event::notify::<i32>` at /Users/paul/.cargo/registry/src/index.crates.io-6f17d22bba15001f/event-listener-4.0.0/src/lib.rs:432:24: 432:44
note: inside `main`
   --> src/main.rs:12:5
    |
12  |     event.notify(1);
    |     ^^^^^^^^^^^^^^^

note: some details are omitted, run with `MIRIFLAGS=-Zmiri-backtrace=full` for a verbose backtrace

error: aborting due to 1 previous error

The issue is that EventListener::listen(mut self: Pin<&mut Self>, event: &Event<T>) doesn't deal with the case when the EventListener is already currently linked/associated with another/or same Event. It just unconditionally overwrites the content of the EventListener's Listener.

In my opinion, the general api design of Event/EventListener is rather unnecessarily complex and introduces more overhead than needed. Tokio's Notify api is much "better" in that regard. 🤷‍♂️

@notgull
Copy link
Member

notgull commented Dec 14, 2023

Thanks for the report! I should've known there was something I missed in #94.

In the short term this can be fixed by checking the Event being passed into listen() and making sure to replace it properly. Unfortunately it looks like #94 introduced more footguns than it fixed, in addition to the performance loss. I'd like to revert this, but I think 3 breaking changes in six months is a little much for a crate that's supposed to be stable.

@smol-rs/admins I won't have time to fix this until the weekend, any thoughts here?

@zeenix
Copy link
Member

zeenix commented Dec 14, 2023

In my opinion, the general api design of Event/EventListener is rather unnecessarily complex and introduces more overhead than needed. Tokio's Notify api is much "better" in that regard. 🤷‍♂️

Ours was simple and straight forward too before 3.0.

In the short term this can be fixed by checking the Event being passed into listen() and making sure to replace it properly. Unfortunately it looks like #94 introduced more footguns than it fixed, in addition to the performance loss. I'd like to revert this, but I think 3 breaking changes in six months is a little much for a crate that's supposed to be stable.

True. I wonder if we should revert back to 2.x API. That API was quite stable and lacked all these footguns and unsoundness behaviors. I'm fine with some allocations if that's the cost.

@notgull
Copy link
Member

notgull commented Dec 14, 2023

The 2.X API, while admittedly simpler, pretty recklessly abused heap allocations. The fact that the 3.0 API doubled its speed should be pretty good evidence of this. Therefore I would oppose going back to the 2.X API.

There is very little overhead in the 3.X API. Some overhead was introduced in #94, but I only ever measured it as a 7% performance drop. The no_std API has a higher overhead, but that's the tradeoff for embedded system support and a lack of clean mutexes.

Although I do think smol should prioritize simplicity over performance, a 50% time reduction is pretty hard to say no to. Not to mention, the simple API still exists. You can just call event.listen() if you don't mind the heap allocation and just use event-listener almost exactly the same as in 2.X. Restricting the API to something that performs worse in 100% of cases because some users might become confused isn't what I want for this crate.

@notgull
Copy link
Member

notgull commented Dec 15, 2023

Tokio's Notify api is much "better" in that regard. 🤷‍♂️

I'd like to clarify that tokio's Notify API uses something very similar to what event-listener v3.0 used. The only real difference is that polling Notified automatically inserted the listener into the list, as opposed to listen() needing to be called to manually insert the listener. In my opinion this is a footgun that can cause deadlocks in certain circumstances.

notgull added a commit that referenced this issue Dec 16, 2023
This commit makes it so EventListener::listen() does not overwrite
existing listeners if they already exist. If the listener is already
registered in an event listener list, it removes the listener from that
list before registering the new listener. This soundness bug was missed
during #94.

This is a patch-compatible fix for #100. We may also want an API-level
fix for #100 as well. This is up for further discussion.

Signed-off-by: John Nunley <dev@notgull.net>
notgull added a commit that referenced this issue Dec 16, 2023
This commit makes it so EventListener::listen() does not overwrite
existing listeners if they already exist. If the listener is already
registered in an event listener list, it removes the listener from that
list before registering the new listener. This soundness bug was missed
during #94.

This is a patch-compatible fix for #100. We may also want an API-level
fix for #100 as well. This is up for further discussion.

Signed-off-by: John Nunley <dev@notgull.net>
@fogti
Copy link
Member

fogti commented Dec 16, 2023

In my opinion this is a footgun that can cause deadlocks in certain circumstances.

Please elaborate on that.


In general, it appears a bit weird to me that Listener can be in a "completely empty" state at all. It does not directly seem particularly useful to me; at least not more useful than it without the Option (s?) inside, and being wrapped by a (potentially pinned) Option. It might make more sense to provide an API which gets passed a closure (the closure should be receiving a pinned, listening Listener, but such that the Pin can't escape (e.g. due to being bound by a forall lifetime)), and one which allows the user to provide its own wrapping method to wrap it such that it is pinned, but the wrapper being movable (that is, Unpin + DerefMut<Target = EventListener>). It might be useful to provide some utility functions to deal with swapping out Listeners in Options, etc., but normally that should be handled by Drop and similar traits. (As this seems like over-adaption to a use case which I haven't encountered much at all in the wild, although I might be wrong about that usage distribution)

@notgull
Copy link
Member

notgull commented Dec 16, 2023

Please elaborate on that.

I elaborate more in this comment.

In general, it appears a bit weird to me that Listener can be in a "completely empty" state at all. It does not directly seem particularly useful to me; at least not more useful than it without the Option (s?) inside, and being wrapped by a (potentially pinned) Option.

Unfortunately we can't create an already-pinned EventListener, unless you want to add a macro that uses a private constructor to create it. That's one way of doing it, I suppose.

It might make more sense to provide an API which gets passed a closure (the closure should be receiving a pinned, listening Listener, but such that the Pin can't escape (e.g. due to being bound by a forall lifetime))

Closures can't really be used in async functions.

@fogti
Copy link
Member

fogti commented Dec 17, 2023

Closures can't really be used in async functions

why? I'd understand if that were about closures returning Futures or such, but why can't "normal" closures, or simple functions, e.g. Box::pin or such be used?

@zeenix
Copy link
Member

zeenix commented Dec 17, 2023

Although I do think smol should prioritize simplicity over performance

Sure but as a Rust crate, it should priorities soundness, safety and correctness above all IMO.

a 50% time reduction is pretty hard to say no to.

In general, yes but it depends on two other factors: what was the performance like w/o this reduction (i-e the actual gain) and the price to pay for this. If the price is having easy footguns and strange API (as @fogti also pointed out), then we have to be critical of the performance gain being worth it.

Unfortunately we can't create an already-pinned EventListener, unless you want to add a macro that uses a private constructor to create it. That's one way of doing it, I suppose.

I think that would be a lot better than having a footgun.

notgull added a commit that referenced this issue Dec 18, 2023
This commit makes it so EventListener::listen() does not overwrite
existing listeners if they already exist. If the listener is already
registered in an event listener list, it removes the listener from that
list before registering the new listener. This soundness bug was missed
during #94.

This is a patch-compatible fix for #100. We may also want an API-level
fix for #100 as well. This is up for further discussion.

Signed-off-by: John Nunley <dev@notgull.net>
@notgull
Copy link
Member

notgull commented Dec 19, 2023

Yes, all of the APIs proposed have problems. The 2.x API had serious performance implications and the 3.x API had the footgun. If we're considering another API break, I'd rather use one that takes advantage of Rust's type system. Maybe:

  • We have an EventListener trait.
  • We have a HeapListener implementation that uses the heap.
  • We have a StackSlot structure that sits on the stack.
  • Pin<&mut StackSlot> can be combined with an &Event to create a StackListener which also implements the EventListener trait. Combining inserts the slot into the listener list.

Regarding the issue, #101 is the short term fix for this problem

@zeenix
Copy link
Member

zeenix commented Dec 19, 2023

  • We have an EventListener trait.

  • We have a HeapListener implementation that uses the heap.

  • We have a StackSlot structure that sits on the stack.

  • Pin<&mut StackSlot> can be combined with an &Event to create a StackListener which also implements the EventListener trait. Combining inserts the slot into the listener list.

That sounds like a great idea but how about the new trait is called Listener and we name HeapListener as just EventListener. Since we have had a few API breaks in a very short amount of time, I think a lot of people would be porting directly from 2.x to 5.x and it'd be good to make it easy for them to port.

Also a macro might be good for handling the StackSlot creation.

@notgull
Copy link
Member

notgull commented Dec 19, 2023

Published the fix for this in v4.0.1

@notgull notgull closed this as completed Dec 19, 2023
notgull added a commit that referenced this issue Apr 11, 2024
This commit makes it so EventListener::listen() does not overwrite
existing listeners if they already exist. If the listener is already
registered in an event listener list, it removes the listener from that
list before registering the new listener. This soundness bug was missed
during #94.

This is a patch-compatible fix for #100. We may also want an API-level
fix for #100 as well. This is up for further discussion.

Signed-off-by: John Nunley <dev@notgull.net>
notgull added a commit that referenced this issue May 4, 2024
This commit makes it so EventListener::listen() does not overwrite
existing listeners if they already exist. If the listener is already
registered in an event listener list, it removes the listener from that
list before registering the new listener. This soundness bug was missed
during #94.

This is a patch-compatible fix for #100. We may also want an API-level
fix for #100 as well. This is up for further discussion.

Signed-off-by: John Nunley <dev@notgull.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

No branches or pull requests

4 participants