Skip to content

count_params silently demotes a CONDITIONALLY-written parameter to a zero-init local — ARM + RISC-V miscompile (aarch64 instance fixed in #851) #970

Description

@avrabe

Summary

A parameter that is written before it is read, but only on one branch, is silently reclassified as a non-param local by count_params, and the function then compiles to wrong code. The aarch64 instance of this was found and fixed in the #851 param-homing lane; this issue is the ARM and RISC-V instances, which are still live.

The shared shape is count_params(ops).min(declared):

backend site status
aarch64 crates/synth-backend-aarch64/src/backend.rs fixed — now referenced_locals(ops).min(declared)
ARM crates/synth-backend/src/arm_backend.rs:393 broken (below)
RISC-V crates/synth-backend-riscv/src/backend.rs:413 (effective_num_params) broken (below)

count_params counts only indices read before written in linear op order. A param written on one branch of an if is "written first" in that linear order, so it is demoted to a non-param local — losing the fact that its incoming argument-register value is live on the other path.

Reproduction

(module
  (func (export "cond_write_param") (param i32 i32) (result i32)
    (if (local.get 0)
      (then (local.set 1 (i32.const 5))))
    (local.get 1)))

cond_write_param(0, 42) must return 42 (param 1 untouched).

RISC-V — confirmed by EXECUTION, reads an uninitialised stack slot

Compiled with -b riscv --relocatable --all-exports and executed under unicorn
(UC_ARCH_RISCV/UC_MODE_RISCV32) with the stack pre-poisoned with 0xDEADBEEF:

BUG cond_write_param(0,0x2a): want=0x2a got=0xdeadbeef  <-- POISON (uninitialised stack slot)
ok  cond_write_param(1,0x2a): want=0x5  got=0x5
BUG cond_write_param(0,0x7):  want=0x7  got=0xdeadbeef  <-- POISON (uninitialised stack slot)
BUG cond_write_param(0,0x11112222): want=0x11112222 got=0xdeadbeef  <-- POISON

The emitted RV32 is:

addi sp, sp, -16
mv   t0, a0
beqz t0, +12      ; -> the `lw`, skipping BOTH instructions below
li   t0, 5
sw   t0, 0(sp)
lw   t0, 0(sp)    ; <- on the taken path this slot was NEVER written
mv   a0, t0

The slot is neither zero-initialised nor seeded from a1, so the fall-through
path returns whatever was on the stack. This is worse than the aarch64
instance was: aarch64 returned a deterministic 0, RV32 leaks previous frame
contents, which makes it an information-disclosure shape as well as a
miscompile.

ARM — miscompiles, but only once the param register is clobbered

For the simple shape above ARM happens to be correct: the str r1,[sp]
lands at the merge point and r1 still holds the incoming param on the
fall-through path. That correctness is incidental, not by design. Add a call
to the written branch (AAPCS lets r1 be clobbered) and it breaks:

(func $g (result i32) (i32.const 99))
(func (export "cw_call") (param i32 i32) (result i32)
  (if (local.get 0) (then (local.set 1 (call $g))))
  (local.get 1))
str  r0, [sp, #0x18]
mov  r1, r0
cmp  r1, #0
beq  0x28
bl   func_0
str  r0, [sp]     ; <- MERGE point: on the fall-through path r0 is PARAM 0
ldr  r2, [sp]
mov  r0, r2

On the fall-through path the slot is filled from r0 (param 0), so
so cw_call(0, 42) returns param 0's value rather than 42.

Read that carefully — the mechanism is the finding, not the value. On the
fall-through path the slot is filled from r0, which still holds param 0. For
this particular shape the fall-through path is reached only when param 0 is
zero, so the observed return would be 0 — but 0 here is indistinguishable
from "returned param 0" and from "returned a zero-initialised slot", and I did
not execute it (the bl is an unresolved relocation in the --relocatable
object). Treat the wrong value as INFERRED FROM DISASSEMBLY; the load-bearing,
directly-readable claim is that the merge-point store takes r0 on a path where
r0 is not the local being written.

Scope note: the ARM evidence is disassembly-level, not executed — the bl
is an unresolved relocation in the --relocatable object, so I did not run it.
The RISC-V evidence is executed. Someone picking this up should confirm the
ARM case under emulation before claiming the exact wrong value.

Suggested fix

The aarch64 fix applies unchanged: when the driver supplied a declared count,
min(highest REFERENCED index + 1, declared) is exact — it names every index
that really is a param, and the min preserves the existing leniency for a
function declaring more than the register-param limit while touching only the
first few.

// crates/synth-backend-aarch64/src/backend.rs — the landed fix
fn referenced_locals(ops: &[WasmOp]) -> u32 {
    ops.iter()
        .filter_map(|op| match op {
            WasmOp::LocalGet(i) | WasmOp::LocalSet(i) | WasmOp::LocalTee(i) => Some(*i + 1),
            _ => None,
        })
        .max()
        .unwrap_or(0)
}

Each backend needs its own execution differential for the conditional-write
shape — the aarch64 one is scripts/repro/aarch64_param_homing_851_differential.py
(the cond_write_param cases). Note that on aarch64 this shape did not
decline; it compiled and returned the wrong value, so only an execution oracle
catches it. A decline-matrix probe would not have.

Why this was not fixed in the same lane

Found while fixing the aarch64 instance (#851 param homing). Kept out of that
lane deliberately: the ARM and RISC-V selectors have different local/slot models
and each needs its own differential, which is more than a drive-by change.

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