Skip to content

thumb-2: --safety-bounds mask is a silent no-op for memory.copy/memory.fill — bulk memory emitted un-masked (identical to none) while safety-manifest still attests mask #679

Description

@avrabe

Summary

On the Thumb-2 backend, --safety-bounds mask (documented as "AND addr with mem_size-1") is silently a no-op for memory.copy and memory.fill. The bulk-memory lowering is emitted byte-identical to --safety-bounds none — no address masking, no clamp — so the copy/fill pointers run freely past mem_size. No warning is printed, and the emitted *.safety-manifest.json still attests "safety_bounds": "mask" for the whole module.

--safety-bounds software does correctly guard both ops (up-front CMP+UDF on dst and src ranges, with overflow guards), so this gap is specific to the mask profile + bulk memory. Scalar load/store under mask are masked correctly.

  • synth 0.37.0
  • oracle: wasmtime 42.0.1 + QEMU lm3s6965evb Cortex-M semihosting (r11 = valid RAM memory base)

Reproducer

(module (memory 1)
 (func (export "f") (param i32 i32) (result i32)
  (i32.store8 (i32.const 100) (i32.const 171))               ;; src byte 0xAB at [100]
  (i32.store8 (i32.const 16)  (i32.const 0))                  ;; clear [16]
  (memory.copy (local.get 0) (i32.const 100) (i32.const 1))   ;; copy 1 byte 100 -> dst=local0
  (i32.load8_u (i32.const 16))))                              ;; read [16]
synth compile x.wat -t cortex-m3 --all-exports --relocatable --safety-bounds mask -o x.o

Run with mask size mem_size = 4096 (power of two) and dst = 4112 = 4096+16:

  • If mask contained bulk memory, dst would fold 4112 & 4095 = 16 → the byte lands at [16] → result 171.
  • Escaped (raw dst = 4112) → [16] untouched → result 0.
case (r10/mem_size=4096, dst=4112) result
memory.copy under --safety-bounds mask 0 — escaped, wrote raw offset 4112
control: scalar i32.store8 to same dst under mask 200 — folded to [16], mask works

Disassembly evidence

memory.copy/memory.fill compiled with --safety-bounds mask contain zero and/mask instructions, and the <func_0> disasm is byte-for-byte identical to --safety-bounds none (verified with cmp). Scalar store under mask:

sub.w r12, r10, #1     ; mask = mem_size-1
and.w r0, r0, r12      ; addr &= mask
sub.w r12, r12, #0 ; cmp r0,r12 ; it hi ; movhi r0,r12   ; clamp for width
str.w r1, [r11, r0]

memory.copy under mask (no mask, identical to none):

push {r4, lr}
movw r2, #0
cmp r0, r2 ; bhi <bwd>        ; direction select only
add.w r2, r11, r2            ; src ptr = base + src   (raw, unmasked)
add.w r0, r11, r0            ; dst ptr = base + dst   (raw, unmasked)
add.w r12, r0, r1            ; end = dst + len
...byte loop, adds r0,#1 / adds r2,#1, runs to end...

Impact (security)

mask is the pure-software address-folding sandbox intended for MPU-less parts (no per-access trap). Any memory.copy/memory.fill with an attacker-influenced dst/src/len does an out-of-bounds read/write with no fold and no trap, fully defeating the mask sandbox. The safety-manifest.json attesting "safety_bounds":"mask" while bulk-mem is unbounded is a false safety attestation that downstream gating/attestation will trust.

Suggested fix

Route bulk-memory dst/src (and the running loop pointers) through the same mask/clamp the scalar path uses; or, since a running pointer is awkward to mask per-iteration, treat bulk memory under mask like software (up-front range check); or at minimum loudly decline to compile bulk memory under mask rather than silently emitting unprotected code plus a manifest that claims coverage.

Relation to prior issues

Distinct from #642 (call_indirect emitted no check — different op, was fixed by adding a check), #651/#655 (mask applied before the static offset — order bug in the scalar path), #677 (bulk-mem operand-register clobber), and #374 (which added the memory.copy/fill lowering). This is a coverage gap: the mask profile was never wired into the bulk-memory lowering, and it fails silently. Found via the QEMU thumb execution-differential harness (r11 = memory base) + disasm; software-mode control confirms bulk memory can be guarded.

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