Skip to content

v86 Sandbox Evaluation

v86 is an x86 emulator written in JavaScript/WebAssembly that can run Linux in the browser or Node.js. This document details our evaluation for using it as a sandboxed execution environment.

Overview

  • What it is: x86 PC emulator (32-bit, Pentium 4 level)
  • How it works: Translates machine code to WebAssembly at runtime
  • Guest OS: Alpine Linux 3.21 (32-bit x86)
  • Available packages: Node.js 22, Python 3.12, git, curl, etc. (full Alpine repos)

Key Findings

What Works

Feature Status Notes
Outbound TCP HTTP, HTTPS, TLS all work
Outbound UDP DNS queries work
WebSocket client Can connect to external WebSocket servers
File I/O 9p filesystem for host<->guest file exchange
State save/restore ~80-100MB state files, instant resume
Package persistence Installed packages persist in saved state
npm install Works (outbound HTTPS)
git clone Works (outbound HTTPS)

What Doesn't Work

Feature Status Notes
Inbound connections VM is behind NAT (10.0.2.x), needs port forwarding
ICMP ping Userspace network stack limitation
64-bit v86 only emulates 32-bit x86

Architecture

┌─────────────────────────────────────────────────────────┐
│                      Host (Node.js)                      │
│                                                          │
│  ┌──────────────┐     ┌─────────────────────────────┐   │
│  │ rootlessRelay│◄───►│           v86               │   │
│  │  (WebSocket) │     │  ┌─────────────────────┐    │   │
│  │              │     │  │   Alpine Linux      │    │   │
│  │  - DHCP      │     │  │   - Node.js 22      │    │   │
│  │  - DNS proxy │     │  │   - Python 3.12     │    │   │
│  │  - NAT       │     │  │   - etc.            │    │   │
│  └──────────────┘     │  └─────────────────────┘    │   │
│         │             │            │                 │   │
│         │             │     9p filesystem            │   │
│         ▼             │            │                 │   │
│    Internet           │            ▼                 │   │
│                       │     Host filesystem          │   │
│                       └─────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘

Components & Sizes

Component Size Purpose
v86.wasm ~2 MB x86 emulator
libv86.mjs ~330 KB JavaScript runtime
seabios.bin ~128 KB BIOS
vgabios.bin ~36 KB VGA BIOS
Alpine rootfs ~57 MB Compressed filesystem (loaded on-demand)
alpine-fs.json ~160 KB Filesystem index
rootlessRelay ~75 KB Network relay
Total ~60 MB Without saved state
Saved state ~80-100 MB Optional, for instant resume

Installation

npm install v86 ws

Building the Alpine Image

v86 provides Docker tooling to build the Alpine image:

git clone https://github.com/copy/v86.git
cd v86/tools/docker/alpine

# Edit Dockerfile to add packages:
# ENV ADDPKGS=nodejs,npm,python3,git,curl

./build.sh

This creates: - images/alpine-fs.json - Filesystem index - images/alpine-rootfs-flat/ - Compressed file chunks

Network Relay Setup

v86 needs a network relay for TCP/UDP connectivity. We use rootlessRelay:

git clone https://github.com/obegron/rootlessRelay.git
cd rootlessRelay
npm install

Required Patches for Host Access

To allow the VM to connect to host services via the gateway IP (10.0.2.2), apply these patches to relay.js:

Patch 1: Disable reverse TCP handling for gateway (line ~684)

// Change:
if (protocol === 6 && dstIP === GATEWAY_IP) {
  this.handleReverseTCP(ipPacket);
  return;
}

// To:
if (false && protocol === 6 && dstIP === GATEWAY_IP) { // PATCHED
  this.handleReverseTCP(ipPacket);
  return;
}

Patch 2: Redirect gateway TCP to localhost (line ~792)

// Change:
const socket = net.connect(dstPort, dstIP, () => {

// To:
const actualDstIP = dstIP === GATEWAY_IP ? "127.0.0.1" : dstIP;
const socket = net.connect(dstPort, actualDstIP, () => {

Patch 3: Redirect gateway UDP to localhost (lines ~1431 and ~1449)

// Change:
this.udpSocket.send(payload, dstPort, dstIP, (err) => {

// To:
const actualUdpDstIP = dstIP === GATEWAY_IP ? "127.0.0.1" : dstIP;
this.udpSocket.send(payload, dstPort, actualUdpDstIP, (err) => {

Starting the Relay

ENABLE_WSS=false LOG_LEVEL=1 node relay.js
# Listens on ws://127.0.0.1:8086/

Basic Usage

import { V86 } from "v86";
import path from "node:path";

const emulator = new V86({
    wasm_path: path.join(__dirname, "node_modules/v86/build/v86.wasm"),
    bios: { url: path.join(__dirname, "bios/seabios.bin") },
    vga_bios: { url: path.join(__dirname, "bios/vgabios.bin") },
    filesystem: {
        basefs: path.join(__dirname, "images/alpine-fs.json"),
        baseurl: path.join(__dirname, "images/alpine-rootfs-flat/"),
    },
    autostart: true,
    memory_size: 512 * 1024 * 1024,
    bzimage_initrd_from_filesystem: true,
    cmdline: "rw root=host9p rootfstype=9p rootflags=trans=virtio,cache=loose modules=virtio_pci tsc=reliable console=ttyS0",
    net_device: {
        type: "virtio",
        relay_url: "ws://127.0.0.1:8086/",
    },
});

// Capture output
emulator.add_listener("serial0-output-byte", (byte) => {
    process.stdout.write(String.fromCharCode(byte));
});

// Send commands
emulator.serial0_send("echo hello\n");

Communication Methods

1. Serial Console (stdin/stdout)

// Send command
emulator.serial0_send("ls -la\n");

// Receive output
let output = "";
emulator.add_listener("serial0-output-byte", (byte) => {
    output += String.fromCharCode(byte);
});

2. 9p Filesystem (file I/O)

// Write file to VM
const data = new TextEncoder().encode("#!/bin/sh\necho hello\n");
await emulator.create_file("/tmp/script.sh", data);

// Read file from VM
const result = await emulator.read_file("/tmp/output.txt");
console.log(new TextDecoder().decode(result));

3. Network (TCP to host services)

From inside the VM, connect to 10.0.2.2:PORT to reach localhost:PORT on the host (requires patched relay).

# Inside VM
wget http://10.0.2.2:8080/  # Connects to host's localhost:8080

State Save/Restore

// Save state (includes all installed packages, files, etc.)
const state = await emulator.save_state();
fs.writeFileSync("vm-state.bin", Buffer.from(state));

// Restore state (instant resume, ~2 seconds)
const stateBuffer = fs.readFileSync("vm-state.bin");
await emulator.restore_state(stateBuffer.buffer);

Network Setup Inside VM

After boot, run these commands to enable networking:

modprobe virtio-net
ip link set eth0 up
udhcpc -i eth0

Or as a one-liner:

modprobe virtio-net && ip link set eth0 up && udhcpc -i eth0

The VM will get IP 10.0.2.15 (or similar) via DHCP from the relay.

Performance

Metric Value
Cold boot ~20-25 seconds
State restore ~2-3 seconds
Memory usage ~512 MB (configurable)

Typical Workflow for Mom

  1. First run:
  2. Start rootlessRelay
  3. Boot v86 with Alpine (~25s)
  4. Setup network
  5. Install needed packages (apk add nodejs npm python3 git)
  6. Save state

  7. Subsequent runs:

  8. Start rootlessRelay
  9. Restore saved state (~2s)
  10. Ready to execute commands

  11. Command execution:

  12. Send commands via serial0_send()
  13. Capture output via serial0-output-byte listener
  14. Exchange files via 9p filesystem

Alternative: fetch Backend (No Relay Needed)

For HTTP-only networking, v86 has a built-in fetch backend:

net_device: {
    type: "virtio",
    relay_url: "fetch",
}

This uses the browser/Node.js fetch() API for HTTP requests. Limitations: - Only HTTP/HTTPS (no raw TCP/UDP) - No WebSocket - Host access via http://<port>.external (e.g., http://8080.external)

Files Reference

After building, you need these files:

project/
├── node_modules/v86/build/
│   ├── v86.wasm
│   └── libv86.mjs
├── bios/
│   ├── seabios.bin
│   └── vgabios.bin
├── images/
│   ├── alpine-fs.json
│   └── alpine-rootfs-flat/
│       └── *.bin.zst (many files)
└── rootlessRelay/
    └── relay.js (patched)

Resources