Skip to content

Futures/streams don't fully validate the item limit of 1<<28 items #13023

@alexcrichton

Description

@alexcrichton

The following tests all fail in Wasmtime with debug assertions enabled, and they shouldn't. These should probably return a first-class trap of some kind or have some more validation earlier on. Note that these test cases are all generated and likely want edits before committing.

test 1
;;! component_model_async = true
;;! multi_memory = true

(component
  (core module $libc
     (memory (export "m") 1)
  )
  (core instance $libc (instantiate $libc))

  (type $s (stream))
  (core func $stream.new (canon stream.new $s))
  (core func $stream.read (canon stream.read $s async (memory $libc "m")))
  (core func $stream.write (canon stream.write $s async (memory $libc "m")))

  (core module $m
    (import "" "m" (memory 1))
    (import "" "stream.new" (func $stream.new (result i64)))
    (import "" "stream.read" (func $stream.read (param i32 i32 i32) (result i32)))
    (import "" "stream.write" (func $stream.write (param i32 i32 i32) (result i32)))

    (func (export "run")
      (local $tmp i64)
      (local $r i32)
      (local $w i32)
      (local.set $tmp (call $stream.new))
      (local.set $r (i32.wrap_i64 (local.get $tmp)))
      (local.set $w (i32.wrap_i64 (i64.shr_u (local.get $tmp) (i64.const 32))))

      ;; reader requests a large number of zero-sized items
      (call $stream.read (local.get $r) (i32.const 0) (i32.const 0x20000000))
      i32.const -1 ;; BLOCKED
      i32.ne
      if unreachable end

      ;; writer writes the same large number - triggers encode overflow
      (call $stream.write (local.get $w) (i32.const 0) (i32.const 0x20000000))
      drop
    )
  )

  (core instance $i (instantiate $m
    (with "" (instance
      (export "m" (memory $libc "m"))
      (export "stream.new" (func $stream.new))
      (export "stream.read" (func $stream.read))
      (export "stream.write" (func $stream.write))
    ))
  ))

  (func (export "run") (canon lift (core func $i "run")))
)

(assert_return (invoke "run"))
test 2
;;! component_model_async = true
;;! reference_types = true
;;! multi_memory = true
;;! gc_types = true

;; Vulnerability: ReturnCode::encode overflow via event delivery path.
;;
;; This demonstrates the same root cause as vuln1 but through the waitable_set_wait
;; event delivery code path. A zero-payload stream read with count >= 2^28 causes
;; the event's ReturnCode to overflow when encoded in Event::parts() during
;; waitable_set_wait, crashing the host.
;;
;; In this test:
;; - Component $C exports an async function that reads from a zero-payload stream
;;   with count = 0x10000000 (exactly 2^28), then waits for the result event
;; - Component $D calls $C and writes 0x10000000 items to the stream
;; - When $C receives the event through waitable_set_wait, Event::parts() calls
;;   ReturnCode::encode() with n=0x10000000, triggering the debug_assert panic
;;
;; In debug builds: host process crashes with "assertion failed: *n < (1 << 28)"
;; In release builds: the count is silently truncated, corrupting the event payload

(component
  (component $C
    (core module $Memory (memory (export "mem") 1))
    (core instance $memory (instantiate $Memory))
    (core module $CM
      (import "" "mem" (memory 1))
      (import "" "task.return" (func $task.return))
      (import "" "waitable.join" (func $waitable.join (param i32 i32)))
      (import "" "waitable-set.new" (func $waitable-set.new (result i32)))
      (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32)))
      (import "" "stream.read" (func $stream.read (param i32 i32 i32) (result i32)))
      (import "" "stream.drop-readable" (func $stream.drop-readable (param i32)))

      (global $ws (mut i32) (i32.const 0))
      (global $insr (mut i32) (i32.const 0))

      (func $start (global.set $ws (call $waitable-set.new)))
      (start $start)

      (func $transform (export "transform") (param $readable i32) (result i32)
        (local $ret i32)
        (global.set $insr (local.get $readable))

        ;; Read 0x10000000 (2^28) items from a zero-payload stream — should BLOCK
        (local.set $ret (call $stream.read (global.get $insr) (i32.const 0) (i32.const 0x10000000)))
        (if (i32.ne (local.get $ret) (i32.const -1)) (then unreachable))

        ;; Return nothing, then wait for event via callback
        (call $task.return)
        (call $waitable.join (global.get $insr) (global.get $ws))
        (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (global.get $ws) (i32.const 4)))
      )

      (func $transform_cb (export "transform_cb") (param $event_code i32) (param $index i32) (param $payload i32) (result i32)
        ;; If we get here, the event was delivered without crashing.
        ;; In debug builds, the host crashes before reaching this point.
        ;; $event_code should be 2 (STREAM_READ)
        ;; $payload should contain the encoded ReturnCode, but with 2^28 count
        ;; it overflows: (0x10000000 << 4) | 0 = 0 (truncated)
        (call $stream.drop-readable (global.get $insr))
        (i32.const 0 (; EXIT ;))
      )
    )

    (type $ST (stream))
    (canon task.return (memory $memory "mem") (core func $task.return))
    (canon waitable.join (core func $waitable.join))
    (canon waitable-set.new (core func $waitable-set.new))
    (canon waitable-set.wait (memory $memory "mem") (core func $waitable-set.wait))
    (canon stream.read $ST async (memory $memory "mem") (core func $stream.read))
    (canon stream.drop-readable $ST (core func $stream.drop-readable))

    (core instance $cm (instantiate $CM (with "" (instance
      (export "mem" (memory $memory "mem"))
      (export "task.return" (func $task.return))
      (export "waitable.join" (func $waitable.join))
      (export "waitable-set.new" (func $waitable-set.new))
      (export "waitable-set.wait" (func $waitable-set.wait))
      (export "stream.read" (func $stream.read))
      (export "stream.drop-readable" (func $stream.drop-readable))
    ))))

    (func (export "transform") (param "in" (stream)) (canon lift
      (core func $cm "transform")
      async (memory $memory "mem") (callback (func $cm "transform_cb"))
    ))
  )

  (component $D
    (import "transform" (func $transform (param "in" (stream))))

    (core module $Memory (memory (export "mem") 1))
    (core instance $memory (instantiate $Memory))
    (core module $DM
      (import "" "mem" (memory 1))
      (import "" "stream.new" (func $stream.new (result i64)))
      (import "" "stream.write" (func $stream.write (param i32 i32 i32) (result i32)))
      (import "" "stream.drop-writable" (func $stream.drop-writable (param i32)))
      (import "" "transform" (func $transform (param i32) (result i32)))

      (func $run (export "run")
        (local $ret i32) (local $ret64 i64)
        (local $sr i32) (local $sw i32)

        ;; Create a zero-payload stream
        (local.set $ret64 (call $stream.new))
        (local.set $sr (i32.wrap_i64 (local.get $ret64)))
        (local.set $sw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32))))

        ;; Call transform, passing the readable end
        ;; transform returns RETURNED status immediately
        (local.set $ret (call $transform (local.get $sr)))

        ;; Write 0x10000000 items — this rendezvous with the reader
        ;; This causes the reader's event to contain count = 0x10000000
        ;; When waitable_set_wait delivers the event, Event::parts() will
        ;; call ReturnCode::encode() and hit the overflow
        (local.set $ret (call $stream.write (local.get $sw) (i32.const 0) (i32.const 0x10000000)))

        ;; Clean up
        (call $stream.drop-writable (local.get $sw))
      )
    )

    (type $ST (stream))
    (canon stream.new $ST (core func $stream.new))
    (canon stream.write $ST async (memory $memory "mem") (core func $stream.write))
    (canon stream.drop-writable $ST (core func $stream.drop-writable))
    (canon lower (func $transform) async (memory $memory "mem") (core func $transform'))

    (core instance $dm (instantiate $DM (with "" (instance
      (export "mem" (memory $memory "mem"))
      (export "stream.new" (func $stream.new))
      (export "stream.write" (func $stream.write))
      (export "stream.drop-writable" (func $stream.drop-writable))
      (export "transform" (func $transform'))
    ))))

    (func (export "run") (canon lift (core func $dm "run")))
  )

  (instance $c (instantiate $C))
  (instance $d (instantiate $D (with "transform" (func $c "transform"))))
  (func (export "run") (alias export $d "run"))
)
(assert_return (invoke "run"))
test 3
;;! component_model_async = true

;; Vulnerability: ReturnCode::encode overflow via stream.cancel-write
;;
;; Attack: Create a zero-payload intra-component stream. Start a large write
;; that blocks. Read small chunks to accumulate the writer's completion event
;; past 2^28 items. Then cancel the write — the cancel path takes the
;; accumulated Completed(n) event and converts it to Cancelled(n).
;; When n >= 2^28, encode() triggers debug_assert panic, crashing the host.
;;
;; The distinct trigger path is: guest_cancel_write → cancel_write → encode()
;; at futures_and_streams.rs line 4273.

(component definition $C
  (core module $libc (memory (export "mem") 1))
  (core instance $libc (instantiate $libc))

  (core module $m
    (import "" "mem" (memory 1))
    (import "" "stream.new" (func $stream_new (result i64)))
    (import "" "stream.read" (func $stream_read (param i32 i32 i32) (result i32)))
    (import "" "stream.write" (func $stream_write (param i32 i32 i32) (result i32)))
    (import "" "stream.cancel-write" (func $stream_cancel_write (param i32) (result i32)))
    (import "" "stream.drop-readable" (func $stream_drop_readable (param i32)))
    (import "" "stream.drop-writable" (func $stream_drop_writable (param i32)))

    (func (export "run")
      (local $handles i64)
      (local $reader i32)
      (local $writer i32)
      (local $ret i32)

      ;; Create a zero-payload stream (type $s is (stream) with no element type).
      ;; This means check_bounds skips the memory bounds check entirely,
      ;; allowing arbitrary count values.
      (local.set $handles (call $stream_new))
      (local.set $reader (i32.wrap_i64 (local.get $handles)))
      (local.set $writer (i32.wrap_i64 (i64.shr_u (local.get $handles) (i64.const 32))))

      ;; Step 1: Write 0x20000000 (2^29) items. No reader is ready, so this
      ;; returns BLOCKED (-1). The writer handle enters Busy state and
      ;; WriteState becomes GuestReady { count: 0x20000000 }.
      (local.set $ret
        (call $stream_write (local.get $writer) (i32.const 0) (i32.const 0x20000000)))
      (if (i32.ne (local.get $ret) (i32.const -1))
        (then unreachable)) ;; must be BLOCKED

      ;; Step 2: Read 0x08000000 (2^27) items from the readable end.
      ;; guest_read finds the pending writer (GuestReady), does the rendezvous:
      ;;   count = min(0x08000000, 0x20000000) = 0x08000000
      ;;   write_complete = true (0x20000000 != 0 || 0x08000000 > 0)
      ;;   → sends StreamWrite event with Completed(0x08000000) for writer
      ;;   write_buffer_remaining = true (0x08000000 < 0x20000000)
      ;;   → writer stays GuestReady { count: 0x18000000 }
      ;; Read returns Completed(0x08000000) → encode → (0x08000000 << 4) | 0
      ;; = 0x80000000 — fits in u32, passes debug_assert (0x08000000 < 2^28).
      (local.set $ret
        (call $stream_read (local.get $reader) (i32.const 0) (i32.const 0x08000000)))
      ;; Verify read returned Completed(0x08000000): (0x08000000 << 4) | 0 = 0x80000000
      (if (i32.ne (local.get $ret) (i32.const 0x80000000))
        (then unreachable))

      ;; Step 3: Read another 0x08000000 items.
      ;; guest_read finds the writer still in GuestReady { count: 0x18000000 }:
      ;;   count = min(0x08000000, 0x18000000) = 0x08000000
      ;;   write_complete = true
      ;;   → takes existing StreamWrite event Completed(0x08000000)
      ;;   → accumulates total = 0x08000000 + 0x08000000 = 0x10000000 (= 2^28)
      ;;   → sends StreamWrite event with Completed(0x10000000) for writer
      ;; Read returns Completed(0x08000000) → encode passes (0x08000000 < 2^28).
      (local.set $ret
        (call $stream_read (local.get $reader) (i32.const 0) (i32.const 0x08000000)))
      (if (i32.ne (local.get $ret) (i32.const 0x80000000))
        (then unreachable))

      ;; Step 4: Cancel the write.
      ;; The writer handle is still Busy (from step 1's blocked write).
      ;; cancel_write (line 3898) calls take_event and finds:
      ;;   StreamWrite { code: Completed(0x10000000) }
      ;; It converts to Cancelled(0x10000000) (line 3904-3905).
      ;; Back in stream_cancel_write (line 4273), .encode() is called.
      ;; encode() hits debug_assert!(0x10000000 < (1 << 28)) → FALSE → PANIC!
      ;; The panic propagates through catch_unwind → resume_unwind → host crash.
      (call $stream_cancel_write (local.get $writer))
      drop
    )
  )

  (type $s (stream))
  (core func $stream_new (canon stream.new $s))
  (core func $stream_read (canon stream.read $s async (memory $libc "mem")))
  (core func $stream_write (canon stream.write $s async (memory $libc "mem")))
  (core func $stream_cancel_write (canon stream.cancel-write $s))
  (core func $stream_drop_readable (canon stream.drop-readable $s))
  (core func $stream_drop_writable (canon stream.drop-writable $s))

  (core instance $i (instantiate $m (with "" (instance
    (export "mem" (memory $libc "mem"))
    (export "stream.new" (func $stream_new))
    (export "stream.read" (func $stream_read))
    (export "stream.write" (func $stream_write))
    (export "stream.cancel-write" (func $stream_cancel_write))
    (export "stream.drop-readable" (func $stream_drop_readable))
    (export "stream.drop-writable" (func $stream_drop_writable))
  ))))

  (func (export "trigger") async (canon lift (core func $i "run")))
)

(component instance $C $C)
(assert_trap (invoke "trigger") "unreachable")

cc @dicej

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