Skip to content

Deserialization trusts every opcode and tag it reads, not just the counts #54

Description

@gitosaurus

Follow-up to #53, which closed the entry-point hole. Filed to record the evidence rather than because it needs doing soon — see "Why this is parked" at the bottom.

What #53 did and didn't do

#53 added a format header and bounds-checked every count in the stream. That covers the case that actually mattered: --perform on a file that isn't an .acx at all, and the browser driver's upload button (drivers/web/play.js:601) handing the wasm deserializer whatever the player picked off their disk. Those are now refused at the door.

What it does not cover is a file that gets past the door — a valid header and valid counts, with damage further in.

The measurement

400 mutants of a valid bare.acx, 1–6 random byte flips each, a quarter of them also truncated:

signalled rejected cleanly loaded and ran
header only 233 102 65
header + bounds (merged) 171 166 63

The bounds checks converted 62 crashes into clean rejections. 171 remain.

Two classes

Assertion failures (SIGABRT). Reconstruction hits an invariant the file violated:

Assertion failed: (not is_binary(op)), function UnaryOperator, file Expression.cc, line 252

These are the better outcome, and only in a debug build. Under NDEBUG the assert vanishes and the same bytes become silent undefined behaviour.

Segfaults (SIGSEGV), no output at all. Null dereference or runaway recursion while rebuilding expressions, statements, or objects.

The shape of it

Counts were one family of unchecked number; opcodes and type tags are another, read and cast without ever being asked whether they name anything:

  • src/Statement.cc:767static_cast<StatementType_e>(stmt_type_as_int)
  • src/Statement.cc:400static_cast<Keywords::Reserved_e>(write_type_as_int)
  • src/Expression.cc — operator codes, reaching the is_binary assert at :252
  • object ids and attribute ids throughout, none range-checked against the registry they index

The fix in each case is the same shape as readCount: validate before believing. It is not hard, only broad — every tag needs a "is this a value of the enum" gate and every id a "does this exist" gate.

Why this is parked

The threat model here is a corrupted .acx, not a foreign file, and the severity is low in both places it could land:

  • Browser. A wasm module's out-of-bounds write stays inside its own linear memory. Worst case is a crashed tab or a scrambled game, not an escape.
  • Native CLI. Someone would have to be handed a hostile .acx and choose to --perform it — a local binary they elected to run.

Nobody has ever reported a corrupted save. Deferred deliberately: the door is locked, and this is about what happens to someone who is already inside with a damaged file.

If it is ever picked up, the fuzz harness is trivial to reconstruct from the table above, and the crash rate is the metric to move.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions