A self-hosted exploit server for authorised web application penetration testing. Stores arbitrary HTTP responses, serves them from a host you control, and logs every request that comes back.
The PortSwigger Academy exploit server workflow, built for real engagements
rather than labs. Ships as a single static Go binary or a scratch container
with no shell, no root and no route off its own bridge.
- Any response, exactly as typed. Head and body, at any path, in three
fidelity modes:
normalfixesContent-Length,rawemits your head bytes untouched,verbatiminserts nothing at all. - Full access log. Source, method, target, status, user agent, and the complete raw request including POST bodies.
- Email capture. An SMTP sink for password-reset and registration flows.
- One instance per engagement. Separate hostnames, separate logs, optional expiry, so evidence never mixes.
- Hostnames you choose.
exploit-<id>is a default, not a rule. Servecollaborator-<id>, a bare<id>, or point one instance atlegit.com.example.comto test a filter that trusts a name by substring. - Payload library. Fifteen to start, covering CSRF, XSS, CORS,
clickjacking, CSWSH,
postMessage, SSRF, XXE OOB, dangling markup and desync. Editable, with JSON import and export. - Scriptable. A JSON API and a CLI that covers everything the panel does.
cp .env.example .env
docker compose up -d
docker compose exec app /exploit-server config # prints the panel passwordThe image is FROM scratch and contains only the server binary, so
docker compose exec app bash fails — there is no shell in it. Run the CLI
directly instead, as above. An alias helps:
alias wes='docker compose exec app /exploit-server'
wes instance ls
wes help # every command
wes instance help # just one groupSee Running with Docker for logs, backups, updates, and running without Compose.
make build
sudo install -m 755 exploit-server /usr/local/bin/wes # optional, but shorterThe edge listens on port 80, which needs privilege. Either grant it once:
sudo setcap cap_net_bind_service=+ep /usr/local/bin/wes
wes runOr move to unprivileged ports. Use config set rather than flags on run:
flags apply to that one process, so URLs printed by other commands would not
know about them.
wes config set edge_port 8888 smtp_port 1025
wes runwes config # prints the panel passwordOpen the panel at http://127.0.0.1:8080/ and log in as admin. Create an
instance, and it is served from http://exploit-<id>.localhost/. *.localhost
resolves to 127.0.0.1, so there is no DNS to set up.
If a port is already taken the server says which one and exits; change it with
wes config set panel_port 9090 or similar.
Throughout the docs wes is the command, whether that is the installed binary
or the Docker alias above. Without installing it, use ./exploit-server.
Two records, both pointing at your server. Leave them unproxied — a CDN in front means a WAF filtering your payloads and a proxy rewriting your bytes.
| Type | Name | Value |
|---|---|---|
A |
example.com |
your server's IPv4 |
A |
*.example.com |
your server's IPv4 |
Add matching AAAA records if the host has IPv6. Without them, an IPv6-only
target cannot reach you.
If the domain also hosts a website, use a subdomain instead — set
EXPLOIT_BASE_DOMAIN=lab.example.com and create the records at lab and
*.lab. Everything below works the same way.
The server never speaks TLS itself. It serves plain HTTP, and something in front owns port 443. Pick one:
| Situation | Use |
|---|---|
| Nothing else on the host uses 443 | The bundled Caddy proxy (below) |
| A website already uses 443 | Your existing nginx — see Deployment |
| HTTP only, no TLS | Skip to step 3 and leave EXPLOIT_PUBLIC_SCHEME=http |
For the bundled proxy, put a certificate and key at deploy/tls/:
sudo cp /etc/letsencrypt/live/example.com/fullchain.pem deploy/tls/origin.pem
sudo cp /etc/letsencrypt/live/example.com/privkey.pem deploy/tls/origin.key
sudo chown root:root deploy/tls/origin.*Important
The proxy runs with all capabilities dropped, so it cannot bypass file
permissions. The key must be readable by the user it runs as, which is root.
A key owned by anyone else fails with permission denied and the container
restarts in a loop.
A Cloudflare Origin Certificate works equally well and never appears in Certificate Transparency.
In .env:
EXPLOIT_BASE_DOMAIN=example.com
EXPLOIT_PUBLIC_SCHEME=https # only shapes generated URLs
EXPLOIT_TRUST_PROXY=1 # only because the edge is loopback-bound
EDGE_BIND=127.0.0.1 # the proxy reaches it; the internet does not
TLS_BIND=0.0.0.0
TLS_PORT=443Serving plain HTTP instead? Set EXPLOIT_PUBLIC_SCHEME=http,
EDGE_BIND=0.0.0.0 and EXPLOIT_TRUST_PROXY=0, and skip TLS_*.
Docker gives every container full outbound access by default. The second command is not optional:
docker compose --profile tls up -d # omit --profile tls if nginx fronts it
sudo deploy/firewall.sh applyOpen 443 in your host firewall, then check it end to end:
docker compose exec app /exploit-server instance new --label smoke
echo '<h1>it works</h1>' | docker compose exec -T app /exploit-server store smoke /x -
curl https://exploit-<id>.example.com/xInstances are now served at https://exploit-<id>.example.com/, with no
port in the URL.
Keep the panel off the internet. It binds to loopback; reach it over a VPN or an SSH tunnel:
ssh -L 8080:127.0.0.1:8080 user@host| Service | Binds | Who talks to it |
|---|---|---|
| Edge | 127.0.0.1:80 |
the target, unauthenticated |
| Panel | 127.0.0.1:8080 |
you, behind HTTP Basic auth |
| SMTP | 127.0.0.1:25 |
the target's mail sender |
The edge is the only service that may face the internet. An unstored path returns a bare 404 that names no software, so a target who stumbles onto the host finds nothing to identify.
Academy serves its panel from the same host as the payload. Set panel_on_edge
to match that layout.
| Document | Contents |
|---|---|
| Running with Docker | Commands, logs, backups, updating |
| Configuration | Every setting, hostname patterns, data lifetime |
| CLI | Command reference |
| Deployment | Isolation, firewall, TLS, reachability |
This tool is for testing systems you have permission to test. It hosts payloads and records callbacks. It does not scan, exploit automatically, or leave anything behind on a target, and there is no victim-simulation bot: whoever opens your payload is a real person in an agreed scenario.
verbatim mode can emit deliberately malformed responses. Serve those only to
infrastructure you are authorised to test.
