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.

Activity

  1. avrabe commented on Jul 9, 2026

    @avrabe
    ContributorAuthor

    Independent clean-room confirmation of the escape, with a more precise root cause than "byte-identical to none / zero mask ops" (that part is not quite right — scalar accesses in the same function are masked; only the bulk-memory pointers are not).

    Thumb-2 disasm of the repro under --safety-bounds mask (llvm-objdump --triple=thumbv7m):

    ; scalar i32.store8 [100] and [16] -- CORRECTLY masked:
    sub.w r12, r10, #1        ; mask = mem_size-1
    and.w r1, r1, r12         ; addr &= mask
    strb.w r2, [r11, r1]
    sub.w r12, r10, #1
    and.w r3, r3, r12
    strb.w r4, [r11, r3]
    ; memory.copy (src=100, dst=local0) -- NOT masked:
    add.w r5, r11, r5         ; src = base + 100      (no `and`)
    add.w r0, r11, r0         ; dst = base + local0   (no `and`  <-- escapes)
    ...copy loop increments r0/r5 freely...
    strb r6, [r0]
    

    So mask mode emits 3 and ops (for the two scalar stores), and is not byte-identical to none — but the memory.copy/memory.fill dest and src pointers are never masked, so they run at raw offsets past mem_size.

    Execution confirms (QEMU, r11=valid RAM, mask size r10=4096, dst=4112=4096+16):

    • memory.copy under mask, dst=4112 → 0 (escaped: wrote raw offset 4112; [16] untouched).
    • control dst=16 (in-bounds) → 171 (works).
    • the two scalar store8s in the same function fold correctly (mask works for them).

    And the emitted mask.safety-manifest.json attests "safety_bounds": "mask" for the whole module while the bulk-memory ops are unmasked — a false attestation.

    Fix scope: apply the same (addr & (mem_size-1)) fold to the memory.copy/memory.fill dest and src base pointers (and re-mask each iteration, or mask start+end), matching what the scalar path already does. --safety-bounds software already guards both correctly.

  2. added 5 commits that reference this issue on Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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