A fully browser-based, WebAssembly-powered web exploitation training platform — no backend server required.
Web eXploitation Laboratory (WXL) is a CTF-style web exploitation training platform. Every challenge runs entirely in the browser: WebAssembly simulates a realistic backend environment so the platform can be deployed and used without any server infrastructure.
- Pure-frontend execution: a Service Worker intercepts HTTP requests and emulates backend behavior in the browser.
- Multiple backend runtimes: Python Flask / FastAPI (via Pyodide) and PHP (via php-wasm) are all supported.
- Encrypted virtual filesystem: flags and application assets are stored under AES-GCM-256 encryption, preventing direct reads.
- Static deployment: build output is plain static files and can be hosted on any static service (GitHub Pages, Cloudflare Pages, etc.).
| Layer | Technology |
|---|---|
| Documentation framework | VitePress 2.0.0-alpha.16 |
| UI framework | Vue 3 3.5 + UnoCSS |
| State management | Pinia 3 |
| Python runtime | Pyodide 0.29 |
| PHP runtime | php-wasm |
| WASM modules | Rust 2021 + wasm-pack |
| Attack session tracking | IndexedDB (idb package) attack-session persistence |
| Package manager | pnpm 10.28 |
-
Node.js >= 22.6 —
challenge:keygen,create:challenge,challenge:validate, andchallenge:analyzerun throughnode --experimental-strip-types, which is unavailable on earlier releases. The remaining TypeScript scripts go through the bundledtsxand have no such floor. CI builds on Node 24. -
pnpm >= 10 (
npm install -g pnpm) -
Rust toolchain (install via rustup)
-
wasm-pack (
cargo install wasm-pack) — not a package dependency; install it into your Rust toolchain -
wasm-tools (
cargo install wasm-tools --version 1.249.0 --locked, orpnpm wasm:tools) — pinned to the same version CI installs; required by the L2 stage ofpnpm challenge:verify, which runswasm-tools validateon the generated payload.pnpm challenge:keygenalso uses it for its strip and mutate passes, but degrades to a warning for each when it is absent. -
Chromium for Playwright — required before the first
pnpm challenge:verifyorpnpm test:smokerun. Afterpnpm install, install the browser binary once with:pnpm exec playwright install chromium
# 1. Clone the project
git clone https://github.com/BrowserLaboratory/wxl-template.git
cd wxl-template
# 2. Install Node.js dependencies
pnpm install
# 3. Build the WASM modules and start the dev server
pnpm devThe dev server starts at http://localhost:5173 by default.
| Command | Description |
|---|---|
pnpm dev |
Build the WASM modules and start the dev server |
pnpm build |
Build the WASM modules and emit the static site |
pnpm docs:dev |
Start the VitePress dev server only (skips the WASM build) |
pnpm docs:build |
Build the VitePress static site only |
pnpm docs:preview |
Preview the built static site |
pnpm test |
Run the TypeScript / JavaScript unit tests (Vitest). Bare, it stays in watch mode — use pnpm test --run for a single pass |
pnpm test:smoke |
Run the Playwright smoke tests against the built site |
pnpm wasm:build |
Build every Rust WASM module |
pnpm wasm:test |
Run the Rust unit tests (cargo test) |
pnpm wasm:tools |
Attempt to install wasm-tools into the Rust toolchain; it silences failures and always exits 0, so confirm with wasm-tools --version |
pnpm fork:init |
Rewrite the project identity after forking (author, GitHub URLs, and optionally the VitePress base). Requires --author and --repo; the package name changes only with --name or --rebrand, and SITE_BASE in deploy.yml is never touched |
pnpm challenge:keygen |
Generate the encrypted WASM module for every challenge |
pnpm create:challenge |
Scaffold a new challenge from flags; --name <slug> is required and a bare run exits 1 with its usage line |
pnpm challenge:validate |
Validate every challenge's frontmatter and file layout |
pnpm challenge:analyze |
Report the content and configuration of a challenge |
pnpm challenge:retype |
Mutate an existing challenge's backend / difficulty / tags / category |
pnpm challenge:verify |
Run the layered verify gate (L1 lint, L2 build, L3 Playwright e2e) on a challenge |
pnpm challenge:verify:blind |
Run the L4 blind-solve sub-routine standalone (also reached via pnpm challenge:verify <slug> --blind) |
pnpm challenge:verify:cross |
Maintainer-only L4 multi-agent cross-check — runs the blind gate against claude,codex,gemini and aggregates verdicts |
pnpm prepare |
Install the git hooks (simple-git-hooks); runs automatically after pnpm install |
Browser
├── VitePress site (Vue 3 + UnoCSS)
│ ├── Challenge pages (Markdown + YAML frontmatter)
│ └── IndexedDB (attack-session persistence + tool state)
├── Service Worker (docs/public/challenge-sw.js)
│ └── Intercepts HTTP requests and routes them to the matching WASM runtime
└── WASM runtimes
├── virtual-fs Encrypted virtual filesystem (Rust)
├── asgi-bridge Python ASGI/WSGI bridge layer (Rust)
├── wxlsh-parser wxlsh terminal command parser and native commands (Rust)
├── python-bridge Pyodide integration (TypeScript)
└── php-bridge php-wasm integration (TypeScript)
The three Rust crates live under chall-wasm/ and are built by pnpm wasm:build.
- The user interacts with a challenge page, triggering an HTTP request to the "backend".
- The Service Worker intercepts the request and routes it to the right runtime per the challenge configuration.
- The Python runtime or the PHP runtime handles the request and returns an HTTP response.
- The challenge page renders the result.
Every challenge is a Markdown file with a YAML frontmatter block declaring its configuration:
---
title: Door Is Open # required
layout: challenge # selects the challenge UI; without it the page renders as a plain docs page
backend: fastapi # required — flask | fastapi | php
app: app.py # required — path relative to the challenge's src/ root
difficulty: easy
category: web
packages: [] # extra Python packages to install via micropip
tools: [ browser, network, repeater, code ] # omit the field to get exactly these four;
# Terminal appears only when you list it, and
# browser is added back if you leave it out
source_visible: false # true = white-box, false = black-box (default)
wasmModule: /challenge/door-is-open/runtime.wasm # produced by keygen
---Challenge source files are picked up by scanning the challenge's src/ directory. The legacy fs: mapping is still accepted for compatibility, but the validator emits a deprecation warning — new challenges SHALL rely on the src/ scan instead.
See CONTRIBUTE.md for the branching strategy, PR workflow, and commit conventions.
Build output lives in .vitepress/dist/ as plain static files and can be deployed to any static hosting service.
# 1. Install dependencies
pnpm install
# 2. Full build (WASM + keygen + VitePress)
pnpm buildThis repository ships a working deployment workflow at .github/workflows/deploy.yml — use it as-is rather than writing your own. Its shape:
- Trigger: a
v*release tag push, plus manualworkflow_dispatch. Pushing tomaindoes not deploy. - Pages source: the GitHub Actions deployment method (
actions/upload-pages-artifact+actions/deploy-pages). There is nogh-pagesbranch. - Toolchain: Node 24 and a SHA-pinned
wasm-pack0.14.0, matchingrelease.yml. - Base path: the build step sets
SITE_BASE: /wxl-template/.
SITE_BASE is what makes a project site work. VitePress bakes it into every asset URL, so a site served from https://<user>.github.io/<repo>/ needs SITE_BASE: /<repo>/ — without it, the deployed page requests its assets from the domain root and every one of them 404s. Set it only in the deploy workflow; leaving it unset keeps local and CI builds rooted at /.
Two settings live in the GitHub UI rather than in this repository:
- Settings → Pages → Source must be set to GitHub Actions.
- The
github-pagesenvironment must allow deployments fromv*tags (in addition to themainbranch, which authorisesworkflow_dispatchruns). Without that rule, the tag-triggered deploy job is rejected.
After forking, run fork:init with its required flags to rewrite the project identity:
pnpm fork:init --author "<Your Name>" --repo <owner>/<repo>It rewrites package.json and the GitHub URLs. It does not touch SITE_BASE. The script refuses to overwrite an existing .github/workflows/deploy.yml — and a fork always has one — so it prints left untouched and moves on, leaving SITE_BASE: /wxl-template/ in place. Edit that value to /<repo>/ yourself, or the deployment 404s exactly as described above.
The example omits --base deliberately: with the flag absent the script leaves .vitepress/config.mts alone, so the env-conditional base: process.env.SITE_BASE ?? '/' survives and SITE_BASE stays in control. Passing --base /<repo>/ instead replaces that line with a hard-coded literal, after which SITE_BASE no longer influences the build. Do not reach for --base none expecting the env-conditional form — none deletes the base declaration outright, so VitePress falls back to / and SITE_BASE becomes a no-op, which is the 404 case described above.
-
Create a new project on Cloudflare Pages and link the GitHub repository.
-
Set the build command:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal \ && . "$HOME/.cargo/env" \ && cargo install wasm-pack \ && pnpm install \ && pnpm build
-
Set the output directory to
.vitepress/dist. -
Add
NODE_VERSION=24to the environment variables. -
If the site is served from a sub-path, add
SITE_BASEwith that path (leave it unset for a root-domain deployment).
Note: Cloudflare Pages does not ship a Rust toolchain by default.
wasm-packis a Rust binary, not a package dependency —pnpm installwill not provide it — so the build command above installs the minimal Rust toolchain along withwasm-packbefore building.wasm-toolsis not installed here.pnpm builddoes runpnpm challenge:keygen, which useswasm-toolsfor its strip and mutate passes, but keygen degrades to a warning for each when the tool is absent — so the build still succeeds, just without those passes. Addwasm-toolsto the install line (cargo install wasm-tools --version 1.249.0 --locked, the same version CI pins) if you want stripped and mutated payloads in production builds.
This project is licensed under the Educational Community License, Version 2.0 (ECL-2.0).