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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions