Skip to content

thumb-2: memory.copy/memory.fill clobber a local dest/src/len operand reused after the op (mutated in place as the loop pointer, no spill) — wild pointer / wrong result #677

Description

@avrabe

Summary

On the Thumb-2 backend (-t cortex-m3), memory.copy and memory.fill silently clobber any operand (dest / src / len) that is a local reused after the op. The lowering loads the operand into a working register and mutates it in place as the copy/fill loop pointer/counter, without spilling the still-live local — so a later local.get of that operand reads a corrupted value (for a dest/src, a wild mem_base + <mutated> pointer). Const operands are unaffected. This is a straight-line-code sibling of the loop back-edge clobber #663, but in the bulk-memory lowering, so a loop-specific fix will not cover it.

Common idiom that breaks: memcpy(buf, src, n); /* use buf, src, or n */. Also a wild-pointer safety/security hazard (the subsequent memory access uses a corrupted pointer).

  • synth 0.37.0
  • oracle: wasmtime 42.0.1 (and the RISC-V rv32 backend, which spills and is correct)

Reproducers (executed under QEMU Cortex-M, r11 = valid RAM base, vs wasmtime)

dest is a local, read back after the copy:

(module (memory 1)
 (func (export "f")(param i32 i32)(result i32)
   (i32.store (i32.const 16)(i32.const 67305985))          ;; 0x04030201 at [16]
   (memory.copy (local.get 0)(i32.const 16)(local.get 1))  ;; copy len=local1 bytes 16 -> local0
   (i32.load (local.get 0))))                              ;; read [local0]

f(dest=32, len=4): wasmtime 67305985, synth 0 (wrong — local0 was clobbered).
Control with a const dest ((memory.copy (i32.const 32) ...) then (i32.load (i32.const 32))): synth 67305985 ✓ — isolating the corruption to the local operand.

memory.fill, dest is a local:

(memory.fill (local.get 0)(i32.const 66)(local.get 1)) (i32.load8_u (local.get 0))

f(32,4): wasmtime 66, synth 0 (wrong).

len operand reused after the copy:

(i32.store (i32.const 16)(i32.const 99))
(memory.copy (i32.const 32)(i32.const 16)(local.get 1))
(local.get 1)

f(_, len=4): wasmtime 4, synth 99 (wrong — local1 clobbered).

Root cause (Thumb-2 disassembly of the copy)

add.w r0, r11, r0        ; r0 = mem_base + local0  (dest pointer; r0 holds the dest local)
add.w r12, r0, r1        ; end = r0 + len
... copy loop ...
adds  r0, r0, #1         ; r0 += 1   <-- mutates r0 (== dest local0) in place, no spill of the live local
...
ldr.w r0, [r11, r0]      ; later i32.load(local.get 0): r0 is the mutated loop pointer, not local0 -> wild

The dest/src/len operands are advanced in place by the copy/fill loop, but the underlying locals are live past the op (local.get afterward). The allocator does not preserve the live local across the bulk-memory lowering — same defect class as #663, here reachable in straight-line code. rv32 is correct (it spills operands to the stack).

Suggested fix

The bulk-memory lowering must operate on a scratch copy of each operand (or reload the local afterward), so the source locals survive the copy/fill loop. Do not consume a still-live local's register as the loop induction pointer/counter without spilling.

Distinct from #374 (which implemented memory.copy/fill lowering — this is a correctness bug in that new lowering) and from #663 (loop back-edge clobber). Found via the QEMU thumb execution-differential harness (r11 = memory base) vs wasmtime; const-vs-local isolation + disasm confirm.

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