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/fhsh-tp/web-exploit-laboratory.git
cd web-exploit-laboratory
# 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 the SITE_BASE environment variable is never written by the script |
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)
├── python-bridge Pyodide integration (TypeScript)
└── php-bridge php-wasm integration (TypeScript)
The two 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: Need to Know # 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;
# list them explicitly to narrow the set, and
# browser is added back even if you drop it
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 buildRun fork:init with its required flags:
pnpm fork:init --author "<Your Name>" --repo <owner>/<repo> --base <see below>It rewrites package.json, swaps the upstream GitHub URLs for yours, sets or clears the VitePress base per --base, and installs a ready-to-use GitHub Pages workflow at .github/workflows/deploy.yml by copying .agent/skills/wxl-fork-init/deploy.yml.template. This repository no longer carries a deploy workflow of its own — it publishes through Cloudflare Pages, described in the next section — so a fresh fork has nothing at that path and the copy goes through. If you have already customised your own workflow there, the script leaves it untouched and warns instead of overwriting it.
Choosing --base is the step that decides whether your assets load. VitePress bakes the base path into every asset URL:
- GitHub Pages project site (
https://<owner>.github.io/<repo>/) →--base /<repo>/. The flag writes the literal path into.vitepress/config.mts. The copied workflow deliberately sets noSITE_BASEenvironment variable, so this literal is the only thing that makes a sub-path site work. - User or organisation site, custom domain at the root, or Cloudflare Pages →
--base none, which removes thebasedeclaration so VitePress serves from/.
Omitting --base leaves the upstream env-conditional base: process.env.SITE_BASE ?? '/' in place. That form exists for this repository's own tooling, where SITE_BASE may be supplied by the build environment; in a fork nothing sets it, so the site builds at /. That is correct for a root deployment and wrong for a GitHub Pages project site, where every asset would then 404.
Note for local testing after
--base /<repo>/. The literal base applies to every build, including local ones.pnpm test:smokeand the L3 stage ofpnpm challenge:verifynavigate to root-relative paths, so they will 404 against a site built with a sub-path base. Run them with the base temporarily cleared, or point their navigation at the sub-path.
Two settings for a GitHub Pages fork live in the GitHub UI rather than in the repository:
- Settings → Pages → Source must be set to GitHub Actions.
- The
github-pagesenvironment must allow deployments fromv*tags, in addition to themainbranch that authorisesworkflow_dispatchruns. Without that rule the tag-triggered deploy job is rejected.
This is how the upstream site is published.
-
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).