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.
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):crates/synth-backend-aarch64/src/backend.rsreferenced_locals(ops).min(declared)crates/synth-backend/src/arm_backend.rs:393crates/synth-backend-riscv/src/backend.rs:413(effective_num_params)count_paramscounts only indices read before written in linear op order. A param written on one branch of anifis "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
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-exportsand executed under unicorn(
UC_ARCH_RISCV/UC_MODE_RISCV32) with the stack pre-poisoned with0xDEADBEEF:The emitted RV32 is:
The slot is neither zero-initialised nor seeded from
a1, so the fall-throughpath returns whatever was on the stack. This is worse than the aarch64
instance was: aarch64 returned a deterministic
0, RV32 leaks previous framecontents, 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
r1still holds the incoming param on thefall-through path. That correctness is incidental, not by design. Add a call
to the written branch (AAPCS lets
r1be clobbered) and it breaks:On the fall-through path the slot is filled from
r0(param 0), soso
cw_call(0, 42)returns param 0's value rather than42.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. Forthis particular shape the fall-through path is reached only when param 0 is
zero, so the observed return would be
0— but0here is indistinguishablefrom "returned param 0" and from "returned a zero-initialised slot", and I did
not execute it (the
blis an unresolved relocation in the--relocatableobject). Treat the wrong value as INFERRED FROM DISASSEMBLY; the load-bearing,
directly-readable claim is that the merge-point store takes
r0on a path wherer0is not the local being written.Scope note: the ARM evidence is disassembly-level, not executed — the
blis an unresolved relocation in the
--relocatableobject, 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 indexthat really is a param, and the
minpreserves the existing leniency for afunction declaring more than the register-param limit while touching only the
first few.
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_paramcases). Note that on aarch64 this shape did notdecline; 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.