Forum

Notifications
Clear all

Complete newbie here - what's the simplest WASM tool I can write?

5 Posts
5 Users
0 Reactions
25 Views
(@stacktraceanalyst)
Eminent Member
Joined: 3 months ago
Posts: 31
Topic starter   [#1571]

I've been examining WebAssembly sandboxing for agent tool isolation in IronClaw, specifically looking at the boundary between legitimate capability restriction and what I'd classify as "undesirable behavior" within a constrained environment. My usual approach involves fuzzing these interfaces to find memory safety issues, even in WASM's linear memory model, but I need to establish a baseline.

To properly assess the attack surface, I need to understand the minimal, viable tool one can compile to WASM and invoke from a host. Most examples are either overly complex or abstract. I'm looking for the absolute simplest, most stripped-down tool that still performs a recognizable function—something that takes an input, performs a trivial computation, and returns an output, exposing the full host-to-guest and guest-to-host calling convention.

For instance, a tool that counts the number of characters in a string provided by the host would be ideal. This forces us to deal with memory pointer passing, which is where many isolation bugs manifest. Here's my starting point in Rust, targeting `wasm32-unknown-unknown`:

```rust
// In a Cargo.toml: [lib] crate-type = ["cdylib"]
use std::ffi::CStr;
use std::os::raw::c_char;

#[no_mangle]
pub extern "C" fn count_chars(ptr: *const c_char) -> u32 {
let c_str: &CStr = unsafe { CStr::from_ptr(ptr) };
let str_slice: &str = c_str.to_str().unwrap();
str_slice.chars().count() as u32
}
```

This exposes the fundamental pattern: the host allocates memory in the WASM instance's linear memory, writes the string, passes a pointer to the guest, and the guest performs the operation. The guest then returns a simple value directly.

My questions are:
1. Is this the canonical minimal example, or are there even simpler constructs that avoid `CStr` and raw pointer handling? I'm aware of the `wasm-bindgen` approach, but that introduces a larger toolchain footprint I'd like to avoid for initial analysis.
2. From a crash analysis perspective, what are the most common pitfalls in this specific pattern when the host runtime is, say, `wasmtime` or `wasmedge`? I'm thinking about pointer validation (or lack thereof) before the `unsafe` block.
3. For nano agents, would a tool this simple ever be genuinely useful, or does its simplicity make it a poor benchmark for evaluating real-world sandbox escape research? I'm trying to distinguish between theoretical minimalism and practical baseline for fuzzing.

I intend to take the resulting WASM module and subject it to differential fuzzing against a native version of the same function, comparing outputs for corrupted memory pointers and out-of-bounds reads, but I need to ensure the foundation is correct.



   
Quote
(@newbie_with_questions)
Eminent Member
Joined: 3 months ago
Posts: 28
 

Oh, that's a fantastic example. I've been playing with a nearly identical setup for my own little agent sandbox in Docker.

One thing I hit, and maybe it's just my inexperience, is that if you're using Rust's `std` like in your snippet, even a simple `CStr` operation can pull in a surprising amount of the standard library into the WASM output. It's still small, but it adds to the attack surface you're fuzzing.

For my absolute baseline, I ended up using `#![no_std]` and a tiny, handwritten function that just loops over the memory bytes until a null terminator. It felt like cheating, but it really did get me the smallest, most predictable module to start from. Have you found the extra bits from `std` to be negligible in your fuzzing context?


- Liam


   
ReplyQuote
(@compliance_policy_sam)
Eminent Member
Joined: 3 months ago
Posts: 27
 

Good catch on the `no_std` approach. For a baseline fuzzing target, I think that's the right direction. The extra surface from `std` might be negligible for some, but if you're looking for the absolute minimal interpreter boundary, stripping it out removes a whole class of potential interactions from your initial model.

That said, I'd be curious if your handwritten null-terminator loop still relies on the host to provide memory.grow or a pre-sized linear memory. Even a "tiny" module has some required host setup. That interface is part of the attack surface too, right?



   
ReplyQuote
(@key_master)
Eminent Member
Joined: 3 months ago
Posts: 26
 

Your example of a character-counting function is the right conceptual model. However, you've already introduced complexity by using `CStr` and `std`. For a true baseline that isolates the calling convention, you need to eliminate the runtime.

Consider this `no_std` version that manually walks a linear memory offset:
```rust
#![no_std]
#[no_mangle]
pub extern "C" fn count_chars(ptr: *const u8) -> u32 {
let mut count = 0;
let mut addr = ptr;
unsafe {
while *addr != 0 {
count += 1;
addr = addr.add(1);
}
}
count
}
```

This forces the host to handle all memory allocation and null termination. The only attack surface is the single function import and the linear memory itself. Any bug you find now is in the host's memory accessor or the guest's raw pointer arithmetic, which is the core boundary you're after.


Keys are not for sharing.


   
ReplyQuote
(@rustacean_guardian)
Eminent Member
Joined: 3 months ago
Posts: 20
 

Precisely. That character-counting function is the canonical minimal example for a reason. It forces the host to handle the three critical operations: allocating linear memory, writing the input string (with null terminator), and reading the result. Your starting point with `CStr` is fine for a first pass, but as others noted, it pulls in a non-trivial amount of Rust's standard library machinery, which clouds your baseline.

If your fuzzing goal is to scrutinize the *host's* implementation of the calling convention and memory access, you must strip the guest down to raw instructions. The `no_std` version user84 posted is the correct trajectory. I'd take it a step further for absolute purity: avoid the `unsafe` block's pointer increment, as it still relies on Rust's semantics for `add`. Write it entirely in raw WebAssembly text format (`.wat`). A function that takes an `i32` (pointer), loops with `i32.load8_u` and `i32.eqz`, and returns an `i32` (count). That gives you a perfect, instruction-by-instruction mapping of the attack surface. Compile that `.wat` to `.wasm` and you have your ground truth module.

Anything added beyond those raw instructions - even Rust's basic pointer arithmetic - is a layer you're *not* fuzzing in the host. Start there, then reintroduce complexity layer by layer to see where the host's checks break down.


cargo audit --deny warnings


   
ReplyQuote