Skip to main content
Aotokitsuruya
Aotokitsuruya
Senior Software Developer
Published at
This article is translated by AI, if have any corrections please let me know.
This article is part of Kobako series.

In Kobako: Exchanging Memory in WebAssembly we solved the problem of Ruby and WebAssembly interacting, and gave Ruby and mruby the ability to exchange values with each other, which basically cleared up the “sandbox” problem.

Kobako’s development is still at a very early stage, though, because there are plenty of problems that only show up in real use, and one of them is the problem of “deciding how to stop.”

The Ideal Case

Under normal circumstances we expect users to know exactly what they’re doing, so writing a piece of code that works properly feels like a given, and this is the kind of implementation we’d naturally expect to see.

1sandbox = Kobako::Sandbox.new
2sandbox.eval("1 + 2").value  # => 3

Reality isn’t like that, though. What if a user accidentally writes an infinite loop?

1sandbox = Kobako::Sandbox.new
2sandbox.eval("loop { puts 1 }").value  # => ?

This is reasonable in both Ruby and mruby. In embedded systems, mruby’s main battleground, it’s very common to need to run some feature or service this way, so we can’t forbid users from running code like this. But if we don’t stop the user, this sandbox will keep on existing until the host runs out of resources.

Execution Limits

Wasmtime, which Kobako uses, comes with a Fuel mechanism that can put a very explicit limit on the resources used, but in actual testing Fuel made Kobako run slower, so we switched to Epoch to limit execution time instead.

The reason Fuel is slower than Epoch is that it has to check every single WebAssembly instruction, whereas Epoch simply specifies a time, so it’s quite a bit faster overall. Here’s roughly how it’s done in Kobako.

First, when creating Wasmtime, we enable Epoch and spawn a lightweight thread that increments the Epoch every 10 ms.

 1let mut config = Config::new();
 2config.epoch_interruption(true);
 3let engine = Engine::new(&config)?;
 4{
 5    let engine = engine.clone();
 6    thread::spawn(move || loop {
 7        thread::sleep(Duration::from_millis(10));
 8        engine.increment_epoch();
 9    });
10}

Every time we use Sandbox#eval or Sandbox#run, a brand new mruby sandbox is started, and at the moment it starts we set the point where it should stop based on the timeout setting.

 1let deadline = Instant::now() + Duration::from_secs(1);
 2store.set_epoch_deadline(1);
 3
 4store.epoch_deadline_callback(move |_ctx| {
 5    if Instant::now() >= deadline {
 6        Err(anyhow!("wall-clock deadline exceeded"))
 7    } else {
 8        Ok(UpdateDeadline::Continue(1))
 9    }
10});

Whenever the Epoch updates, we check whether we’ve gone past the allowed point in time, and if so we throw an error right away to interrupt the sandbox currently executing, which makes sure no infinitely executing state can happen (unless timeout: nil is set deliberately, which lets it through)

This way we can control execution time at a relatively low cost, and we don’t have to worry about a badly written implementation, or an implementation generated incorrectly by an LLM (Large Language Model), hogging resources.

Memory Limits

Now that execution time gets cut off, memory has the same problem, such as loading a huge amount of data by mistake, or causing trouble some other way.

1sandbox = Kobako::Sandbox.new
2sandbox.eval("'S' * 2**1024").value  # => ?

Wasmtime itself has a ResourceLimiter trait available, where every time more memory needs to be allocated it asks first, and can only be used once permitted, so Kobako added a mechanism like this.

 1struct MemoryLimiter { limit: usize, baseline: usize, active: bool }
 2
 3impl ResourceLimiter for MemoryLimiter {
 4    fn memory_growing(&mut self, _cur: usize, desired: usize, _max: Option<usize>)
 5        -> Result<bool>
 6    {
 7        if !self.active { return Ok(true); }
 8        if desired - self.baseline > self.limit {
 9            bail!("memory usage exceeded memory_limit");
10        }
11        Ok(true)
12    }
13    fn table_growing(&mut self, ..) -> Result<bool> { Ok(true) }
14}
15
16
17store.limiter(|state| &mut state.limiter);
18
19state.limiter.baseline = memory.data_size(&store);
20state.limiter.active = true;
21let result = export.call(&mut store, params);
22state.limiter.active = false;

Because mruby itself also takes up memory, if running mruby needs 1 MB of memory on its own, going straight by “how much memory was added” would eat up a 1 MB cap instantly, which is why the baseline and active design exists.

Once initialization is done, we first check how much memory mruby has used, record it as baseline, then mark active as enabled and let it carry on executing. The target memory - baseline we record at that point is the memory the script actually takes up, so we can make sure a call gets the full memory allowance.

Although neither execution time nor memory is a “precise calculation” but rather a rough estimate, even this much is enough to stop mistaken or malicious use, making sure the host isn’t affected by an abnormal implementation inside the sandbox, and mitigating to some degree the damage of executing unsafe Ruby scripts.

Of course, this also guards against attacks like ReDoS (Regular Expression Denial of Service) to some extent, but there are still many kinds of attack techniques, and limiting resources is only one of them. To make execution safer we still have many other kinds of trade-offs, which will be added to Kobako one after another.