Skip to content

[dart2wasm] utf8.decode overruns _Utf8Decoder's 1024-unit scratch buffer on a 4-byte character spanning a chunk boundary #64325

Description

@alert-greg

utf8.decode traps under dart2wasm when a 4-byte UTF-8 character's final byte is
the first byte of a 1024-byte decode chunk. The VM and dart2js are unaffected.

Reproduction

import 'dart:convert';
import 'dart:typed_data';

void main() {
  // U+1F6B2 occupies offsets 1021..1024, so its final byte is the first byte
  // of the second 1024-byte chunk.
  final bytes = Uint8List.fromList([
    ...List.filled(1021, 0x61),
    0xF0, 0x9F, 0x9A, 0xB2,
    ...List.filled(1023, 0x61),
  ]);
  print(utf8.decode(bytes).length); // expected: 2046
}

Running the wasm output needs a small host, since dart compile wasm emits a
module rather than a runnable script. run.mjs:

import { readFile } from 'node:fs/promises';
import { compile, instantiate, invoke } from './main.mjs';

const module = await compile(
    await readFile(new URL('./main.wasm', import.meta.url)));
invoke(await instantiate(Promise.resolve(module), Promise.resolve({})));
$ dart run main.dart
2046

$ dart compile wasm main.dart -o main.wasm && node run.mjs
RuntimeError: array element access out of bounds
    at wasm://wasm/00012f26:wasm-function[83]:0x49bc

With assertions enabled the trap is preceded by:

Assertion failed: .../lib/_internal/wasm/lib/convert_patch.dart:2644:18
size <= _characterArraySize
is not true

Cause

_Utf8Decoder decodes in 1024-byte chunks through a shared
_characterArray of _characterArraySize (1024) UTF-16 units
(sdk/lib/_internal/wasm/lib/convert_patch.dart).

In _convertChunkInternal, size starts as scan(bytes, start, end), which
counts units for sequences starting in this chunk. The carry-over block then
adds units for a character continued from the previous chunk without
re-checking capacity:

case X3:
case QO:
case QR:
  flags |= flagNonLatin1;
  if (end - start >= 3) size += 2;
  break;

Every sequence lying wholly inside a chunk yields no more units than the bytes
it consumes, so only the carried-over character can push size past the
capacity, and only in one configuration: a 4-byte character contributing just
its final byte to this chunk yields 2 units for 1 byte. A full 1024-byte chunk
whose remaining 1023 bytes are ASCII then computes size == 1025, and
decode16 writes one unit past the end of _characterArray.

Constraints, each verified against dart compile wasm:

Carried character Bytes in chunk Units Surplus Result
4-byte 1 2 +1 traps
4-byte 2 2 0 ok
4-byte 3 2 -1 ok
3-byte 1 1 0 ok
2-byte 1 1 0 ok

The chunk's remaining 1023 bytes must also be entirely ASCII; any other
multi-byte character there returns a unit and brings the total back to 1024.

Impact

The failure mode is worse in release builds, where the bounds assertion is
compiled out. We hit this in a Flutter web app built with --wasm: the trap
occurred inside an async continuation decoding an HTTP response body, and the
awaiting Future then never completed and never errored. The page sat
loading indefinitely with no error surface and no recovery path, which made it
look like a network or backend hang rather than a decoder fault.

It is also intermittent in a way that resists diagnosis. The trigger is byte
alignment, not content: for the 133KB response that first exposed this, 26 of
the 1024 possible alignments trap, and no two adjacent ones do. Appending a
single record to the payload silently arms or disarms it.

Versions

  • Dart SDK 3.11.5 (stable), macos_arm64
  • Flutter 3.41.9
  • Reproduced standalone with dart compile wasm + Node v24.15.0 (no Flutter)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions