Making stinkarm stink way less, or more?

xnacly.me · 2026-08-21 · by Long Nguyen ·generic
removing overengineered memory translation, hardening and new table driven armv7 instr decoding

Making stinkarm stink way less, or more?

Making stinkarm stink way less, or more?

  • Aug 16, 2026
  • 2323 Words
  • 11 Minute read
  • Tags:

  • Arm
  • Rust
  • ---

    acorn, armv7-a and stinkarm1

    Its been a while, but about half a year ago I wrote an article about implementing a userspace armv7 emulator from scratch, meaning I implemented:

    ARMASM

    1    .section .rodata
     2msg:
     3    .asciz "Hello, world!\n"
     4
     5    .section .text
     6    .global _start
     7_start:
     8    ldr r0, =1
     9    ldr r1, =msg
    10    mov r2, #14
    11    mov r7, #4
    12    svc #0
    13
    14    mov r0, #0
    15    mov r7, #1
    16    svc #0

    Or as a list:

  • elf(32) parsing, validation and interpretation
  • decoding of a very small subset of armv7 instructions (only 3)
  • executing said instructions, even conditional ones 🤓
  • translating memory access from the guest into the host
  • syscall forwarding (from armv7 to x86)
  • syscall sandboxing (only a restricted syscall subset) and denying syscall execution
  • Do read Building a Minimal Viable Armv7 Emulator from Scratch, since this post doesnt go as deep into detail as the previous one (It’s my first article in 3 months I had enough motivation for writing :O). This is partially an update, partially my toughts on decoding and emulating armv7-a and also a bit of a devlog.

    Overly complex host to guest mem translation

    On the first article, \~aengelke on lobste.rs, had some comments, the one resonating the most was:

    \[…\] The Mem indirection seems pretty inefficient. When emulating 32-bit platforms on a 64-bit system, just mmap a 4 GiB region, the translation then becomes a single addition. Otherwise, having a small hash table of recently translated address regions can avoid more expensive searches – memory accesses have a very high locality. The number of mappings is usually small, so binary search over a sorted array is simpler than a B-tree. \[…\]

    So now i figured, why not improve on my implementation a bit, first with replacing the complex allocation region based tracking with just allocating a 4gig slab in memory for the guest, mapping the process regions there and handing out pointers into that region to the guest.

    So, previously the memory translation worked as follows:

    1. One takes a binary tree map of a guest starting addr to its host segment RUST ``` 1struct MappedSegment { 2 host_ptr: *mut u8, 3 len: u32, 4} 5 6pub struct Mem { 7 maps: BTreeMap<u32, MappedSegment>, 8}

    2. On ask for a region handout, specifically on mapping ELF segments with a starting addr, `map_region` is called:  
    RUST
    1// in stinkarm::elf::pheader::Pheader::map: 2 3// record mapping in guest memory table, so CPU can translate guest vaddr to host pointer 4guest_mem.map_region(self.vaddr, len, segment_ptr); 5 6// in stinkarm::mem::Mem: 7 8pub fn map_region(&mut self, guest_addr: u32, len: u32, host_ptr: *mut u8) { 9 self.maps 10 .insert(guest_addr, MappedSegment { host_ptr, len }); 11}
    3. Since the cpu needs to fetch an instruction, there is `read_u32`, calling `translate`:  
    RUST
    1/// translate a guest addr to a host addr we can write and read from 2pub fn translate(&self, guest_addr: u32) -> Option<*mut u8> { 3 // Find the greatest key <= guest_addr. 4 let (&base, seg) = self.maps.range(..=guest_addr).next_back()?; 5 if guest_addr < base.wrapping_add(seg.len) { 6 let offset = guest_addr.wrapping_sub(base); 7 Some(unsafe { seg.host_ptr.add(offset as usize) }) 8 } else { 9 None 10 } 11} 12 13pub fn read_u32(&self, guest_addr: u32) -> Option<u32> { 14 let ptr = self.translate(guest_addr)?; 15 unsafe { Some(u32::from_le(*(ptr as *const u32))) } 16} 17 18 19// in stinkarm::cpu::Cpu: 20 21pub fn step(&mut self) -> Result<bool, err::Err> { 22 let Some(word) = self.mem.read_u32(self.pc()) else { 23 return Ok(false); 24 }; 25 26 // [...] 27}
    Of course this totally unnecessary work, we dont need to keep track of every mapping/allocation/region by walking their ranges, we only need to make sure the R/W interaction request is within bounds. Thus the new implementation is:

    1. One takes a pointer and a size: RUST

    1pub struct Mem { 2 ptr: NonNull<u8>, 3 len: usize, 4}
    2. When asked to map ELF segments, `stinkarm::mem::Mem::map_region` is called:  
    RUST
    1// in stinkarm::elf::pheader::Pheader::map: 2guest_mem.map_region(self.vaddr, file_slice)?; 3 4// in stinkarm::mem::Mem: 5 6pub fn map_region(&mut self, guest_addr: u32, data: &[u8]) -> Result<(), String> { 7 let dst = self 8 .get_slice_mut(guest_addr, data.len()) 9 .ok_or_else(|| format!("guest region out of bounds at {guest_addr:#010x}"))?; 10 dst.copy_from_slice(data); 11 Ok(()) 12}
    3. When cpu requests a dword for decoding, it does so by invoking `stinkarm::mem::Mem::read32`, just as before, only this time with bounds checks:  
    RUST
    1pub fn read_u32(&self, guest_addr: u32) -> Option<u32> { 2 let bytes = self.get_slice(guest_addr, 4)?; 3 Some(u32::from_le_bytes(bytes.try_into().unwrap())) 4} 5 6fn get_slice(&self, guest_addr: u32, len: usize) -> Option<&[u8]> { 7 if !self.in_bounds(guest_addr, len) { 8 return None; 9 } 10 11 Some(unsafe { std::slice::from_raw_parts(self.ptr.as_ptr().add(guest_addr as usize), len) }) 12}
    # Hardening the existing implementation

    I also noticed I have a lot of stuff that (even with the small surface of just the `write.2` and `exit.2` syscalls, `ldr`, `mov` and `svc`) could enable translating untrusted guest adresses into host mem access.

    Preventing this via checking the validity of the address passed to `write.2`, we do this while translating guest addresses to host memory space in `stinkarm::mem::Mem` with a `in_bounds` call inside the `translate_range` call:

    RUST

    1const NULL_PAGE_SIZE: u32 = 0x1000; 2 3impl Mem { 4 fn in_bounds(&self, guest_addr: u32, len: usize) -> bool { 5 if guest_addr < NULL_PAGE_SIZE { 6 return false; 7 } 8 9 let start = guest_addr as usize; 10 let Some(end) = start.checked_add(len) else { 11 return false; 12 }; 13 14 end <= self.len 15 } 16 17 pub fn translate_range(&self, guest_addr: u32, len: usize) -> Option<*mut u8> { 18 if !self.in_bounds(guest_addr, len) { 19 return None; 20 } 21 22 Some(self.ptr.as_ptr().wrapping_add(guest_addr as usize)) 23 } 24}
    I added multiple tests for making sure I correctly catch writing a null pointer, writing out of guest memory and loading elf segments at 0x0:

    ARMASM

    1 .section .rodata 2msg: 3 .ascii "ignored" 4 5 .section .text 6 .global _start 7_start: 8 mov r0, #1 9 mov r1, #0 10 mov r2, #7 11 mov r7, #4 12 svc #0 13 14 mov r0, #0 15 mov r7, #1 16 svc #0
    ARMASM
    1 .section .rodata 2msg: 3 .ascii "ignored" 4 5 .section .text 6 .global _start 7_start: 8 mov r0, #1 9 ldr r1, =0x08000000 10 mov r2, #7 11 mov r7, #4 12 svc #0 13 14 mov r0, #0 15 mov r7, #1 16 svc #0
    # To DSL or not

    Previously I hardcoded every opcode and its fields to decode them into a rust representation, now only the opcode is subject to decoding. This is achived with a nice looking compiletime constant list of patterns:

    RUST

    1const DECODE_RULES: &[ArmRule] = &[ 2 arm_rule!(Svc { 3 bits(27..24 = 0b1111), 4 }), 5 arm_rule!(Branch { 6 bits(27..25 = 0b101), 7 }), 8 // LDR literal: `ldr Rt, [pc, #imm12]`. 9 arm_rule!(LdrLiteral { 10 bits(27..26 = 0b01), // load/store class 11 bit(24 = 1), // P: pre-indexed address 12 bit(23 = 1), // U: add positive offset 13 bit(22 = 0), // B: word transfer, not byte 14 bit(21 = 0), // W: no writeback 15 bit(20 = 1), // L: load, not store 16 bits(19..16 = 15), // Rn: base register is pc/r15 17 }), 18 // MOV immediate: data-processing immediate with opcode 1101. 19 arm_rule!(MovImm { 20 bits(27..25 = 0b001), 21 bits(24..21 = Op::Mov as u32), 22 }), 23];
    > If youre interested in ARMv7 instruction encoding I can recommend the ARM® Architecture Reference Manual ARMv7-A and ARMv7-R edition

    The macro itself builds a bit pattern that can then be used with a simple AND bit instruction to detect:

    RUST

    1macro_rules! arm_rule { 2 ($kind:ident { $($field:ident($($args:tt))), $(,)? }) => { 3 ArmRule { 4 kind: InstructionKind::$kind, 5 mask: 0 $(| arm_mask!($field($($args)))), 6 value: 0 $(| arm_value!($field($($args)))), 7 } 8 }; 9} 10 11macro_rules! arm_mask { 12 (bit($bit:literal = $value:expr)) => { 13 1u32 << $bit 14 }; 15 (bits($high:literal .. $low:literal = $value:expr)) => { 16 ((1u32 << ($high - $low + 1)) - 1) << $low 17 }; 18} 19 20macro_rules! arm_value { 21 (bit($bit:literal = $value:expr)) => { 22 ($value as u32) << $bit 23 }; 24 (bits($high:literal .. $low:literal = $value:expr)) => { 25 ($value as u32) << $low 26 }; 27}
    `MovImm`’s definition does therefore produce (`Op::mov` is defined as `0b1101`):

    RUST

    1ArmRule { 2 kind: InstructionKind::MovImm, 3 mask: 0 4 | ((1u32 << (27 - 25 + 1)) - 1) << 25 5 | ((1u32 << (24 - 21 + 1)) - 1) << 21, 6 value: 0 7 | (0b001 as u32) << 25 8 | ((Op::Mov as u32) as u32) << 21, 9} 10 11 12impl ArmRule { 13 fn matches(&self, word: u32) -> bool { 14 (word & self.mask) == self.value 15 } 16}
    All rules are then iterated for each 32bit word and decoded:

    RUST

    1pub fn decode_word(word: u32) -> Decoded { 2 let cond = bits(word, 31, 28) as u8; 3 let kind = DECODE_RULES 4 .iter() 5 .find(|rule| rule.matches(word)) 6 .map(|rule| rule.kind) 7 .unwrap_or(InstructionKind::Unknown); 8 9 Decoded { 10 cond, 11 kind, 12 raw: word, 13 } 14}
    I know a trie or something would probably be faster, but this is nice to read, understandable and easy to maintain.

    Full decoding only on demand

    Previous to the instruction DSL, I decoded all necessary values for all instructions at all times, meaning short instructions would decode even if they didnt match, just as a sideeffect of attempting to figure out what instruction. The DSL allows only decoding the op code and letting the cpu decode only what it needs via `decoder::{decode_word,bit,bits}` and `decoder::{sign_extend,rotated_imm}` where bit and bits enable partial access into the word, and decode\_word returns the op code, the condition and the raw word itself for further processing in the cpu emulation:

    RUST

    1/// fetch-decode-execute step, will only return false on exit svc 2pub fn step(&mut self) -> Result<bool, err::Err> { 3 // [...] fetch word 4 5 let Decoded { kind, cond, raw } = decoder::decode_word(word); 6 7 // [...] 8 9 match kind { 10 InstructionKind::MovImm => { 11 let rd = decoder::bits(raw, 15, 12) as usize; 12 let imm12 = decoder::bits(raw, 11, 0); 13 // [...] 14 } 15 // [...] 16 InstructionKind::LdrLiteral => { 17 let rd = decoder::bits(raw, 15, 12) as usize; 18 let imm12 = decoder::bits(raw, 11, 0); 19 20 // [...] 21 } 22 } 23}
    Bits and bit access is obvious, sign\_extend and rotated\_imm maybe less so:

    RUST

    1pub fn bits(word: u32, high: u8, low: u8) -> u32 { 2 debug_assert!(high < 32); 3 debug_assert!(low <= high); 4 let width = high - low + 1; 5 (word >> low) & ((1 << width) - 1) 6} 7 8pub fn bit(word: u32, bit: u8) -> bool { 9 bits(word, bit, bit) != 0 10} 11 12pub fn sign_extend(value: u32, bits: u32) -> i32 { 13 debug_assert!((1..=32).contains(&bits)); 14 15 let shift = 32 - bits; 16 ((value << shift) as i32) >> shift 17} 18 19pub fn rotated_imm(imm12: u32) -> u32 { 20 let rotate = ((imm12 >> 8) & 0b1111) * 2; 21 (imm12 & 0xff).rotate_right(rotate) 22}
    # Supporting B and BL

    B and BL are the unconditial branching instructions of the ARMv7 isa:

  • Branching unconditionally:
ARMASM 1.text 2 .global _start 3_start: 4 mov r0, #0 5 b 1f 6 mov r0, #1 @ must NOT execute 71: 8 mov r7, #1 9 svc #0
* Branching unconditionally with link:  
ARMASM
1.text 2 .global _start 3_start: 4 mov r0, #0 5 bl foo 6 mov r7, #1 7 svc #0 8foo: 9 mov r0, #42 10 mov r7, #1 11 svc #0
B and BL encode:

1. Conditionals, see ARMv7 Condition code suffixes 2. Instruction group (`101`) 3. Wheter or not to branch with link (`L`) 4. Target (imm24)

In bits:

TEXT

1 31 30 29 28 27 26 25 24 23 .. 0 2|cond |1 0 1 |L | imm24 |
Meaning, its fairly easy to implement, see below. L instructs the emulator to save the return addr to the LinkRegister (LR) and otherwise we just decode the imm24, shift it 2 to the left and then sign extend it to 32 bit, add it to the program counter and thats it:

RUST

1InstructionKind::Branch => { 2 let l = decoder::bit(raw, 24); 3 // BL 4 if l { 5 // save return addr to LR (next addr though) 6 self.r[14] = self.instr_addr().wrapping_add(4); 7 } 8 9 let imm24 = decoder::bits(raw, 23, 0); 10 let imm26 = imm24 << 2; 11 let imm32 = decoder::sign_extend(imm26, 26); 12 13 self.r[15] = self.arm_pc().wrapping_add(imm32 as u32); 14}
# Testing “frame(work)”

To test all the hardening stuff and every new instructions I intent to support, i added a bit of tooling, specifically srun, its a small stinkarm wrapper to build, link and execute assembly or c files:

TEXT

1Build, link, and execute an ARM assembly or C file with stinkarm 2 3Usage: srun [OPTIONS] <INPUT> [-- <EMULATOR_ARGS>...] 4 5Arguments: 6 <INPUT> ARM assembly or C file to run 7 [EMULATOR_ARGS]... Extra arguments passed to stinkarm before the generated ELF path 8 9Options: 10 --text-addr <TEXT_ADDR> Guest address used as the linker text address [default: 0x8000] 11 --out-dir <OUT_DIR> Directory for generated object and ELF files [default: target/srun] 12 --dump-asm Print the linked ARM disassembly before running the emulator 13 -h, --help Print help
For instance previously I had to first assemble the `examples/branch.S` file, then invoke stinkarm, now i can just:

SHELL

1cargo run --bin srun 2 # arguments for srun 3 \ -- examples/helloWorld.S 4 # arguments for stink arm, log instructions and syscalls 5 \ -- -linstructions -lsyscalls
TEXT
1[ 0.538ms] MovImm 1110 E3A00001 2[ 0.543ms] LdrLiteral 1110 E59F1014 3[ 0.545ms] MovImm 1110 E3A0200E 4[ 0.548ms] MovImm 1110 E3A07004 5[ 0.551ms] Svc 1110 EF000000 665174 write(fd=1, buf=0x8024, len=14) [sandbox] 7Hello, world! 8=14 9[ 0.567ms] MovImm 1110 E3A00000 10[ 0.570ms] MovImm 1110 E3A07001 11[ 0.573ms] Svc 1110 EF000000 1265174 exit(code=0) [sandbox] 13=0 ```

So yeah, thats it, now please enjoy me bashing in a claude server rack:

claude

---

1. Both the pixel art heading and all the bullshit in this article is brainslob, nothing was produced by a clanker. ↩︎

xnacly.me · 2 images · 10 links

Comments (0)

No comments yet