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¶
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:
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¶
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).
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:
Or as a one-liner:
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¶
- First run:
- Start rootlessRelay
- Boot v86 with Alpine (~25s)
- Setup network
- Install needed packages (
apk add nodejs npm python3 git) -
Save state
-
Subsequent runs:
- Start rootlessRelay
- Restore saved state (~2s)
-
Ready to execute commands
-
Command execution:
- Send commands via
serial0_send() - Capture output via
serial0-output-bytelistener - Exchange files via 9p filesystem
Alternative: fetch Backend (No Relay Needed)¶
For HTTP-only networking, v86 has a built-in fetch backend:
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)