alexcrichton opened issue #13024:
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
alexcrichton added the wasm-proposal:component-model-async label to Issue #13024.
alexcrichton added the wasm-proposal:component-threading label to Issue #13024.
alexcrichton removed the wasm-proposal:component-model-async label from Issue #13024.
alexcrichton closed issue #13024:
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
alexcrichton commented on issue #13024:
cc @TartanLlama
alexcrichton reopened issue #13024:
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
Last updated: Apr 12 2026 at 23:10 UTC