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.
Summary
On the Thumb-2 backend,
--safety-bounds mask(documented as "AND addr withmem_size-1") is silently a no-op formemory.copyandmemory.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 pastmem_size. No warning is printed, and the emitted*.safety-manifest.jsonstill attests"safety_bounds": "mask"for the whole module.--safety-bounds softwaredoes correctly guard both ops (up-front CMP+UDF on dst and src ranges, with overflow guards), so this gap is specific to themaskprofile + bulk memory. Scalarload/storeundermaskare masked correctly.0.37.0lm3s6965evbCortex-M semihosting (r11 = valid RAM memory base)Reproducer
Run with mask size
mem_size = 4096(power of two) anddst = 4112 = 4096+16:maskcontained bulk memory, dst would fold4112 & 4095 = 16→ the byte lands at[16]→ result 171.[16]untouched → result 0.memory.copyunder--safety-bounds maski32.store8to same dst undermask[16], mask worksDisassembly evidence
memory.copy/memory.fillcompiled with--safety-bounds maskcontain zeroand/mask instructions, and the<func_0>disasm is byte-for-byte identical to--safety-bounds none(verified withcmp). Scalar store undermask:memory.copyundermask(no mask, identical tonone):Impact (security)
maskis the pure-software address-folding sandbox intended for MPU-less parts (no per-access trap). Anymemory.copy/memory.fillwith an attacker-influenced dst/src/len does an out-of-bounds read/write with no fold and no trap, fully defeating the mask sandbox. Thesafety-manifest.jsonattesting"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
masklikesoftware(up-front range check); or at minimum loudly decline to compile bulk memory undermaskrather 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
maskprofile 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.