- Isolation
- Blocking egress
- Putting it on a server
- Terminating TLS
- Reachability
- Certificates
- Checklist
Throughout, example.com stands for the domain instances are served from.
It needs a wildcard A record pointing at the host.
The unit of deployment is a container: scratch, one static binary, no shell,
no package manager, non-root, read-only root filesystem, every capability
dropped, and no route off its own bridge.
| Layer | Mechanism |
|---|---|
| Filesystem | scratch image, read_only: true, tmpfs /tmp, one named volume |
| Privileges | uid 10001, cap_drop: ALL, no-new-privileges |
| Resources | pids_limit: 256, mem_limit: 512m, capped JSON logs |
| Inbound | Only the ports named in .env. Panel and SMTP default to loopback |
| Outbound | deploy/firewall.sh. Nothing in the subnet may leave it |
The edge binds port 80 and the mail sink port 25 so payload URLs carry no port
suffix. An unprivileged uid does that through
net.ipv4.ip_unprivileged_port_start, set in compose.yaml, rather than
CAP_NET_BIND_SERVICE. The sysctl is scoped to that container's network
namespace; the capability would apply to the whole process.
deploy/exploit-server.service runs the bare binary without Docker. It is
equally hardened, but the container adds a filesystem with nothing in it. Use
one or the other.
Docker publishes ports by writing its own iptables rules, and a container on a normal bridge has full outbound TCP: to the internet, to the host's own services, and to every other Docker network on the box, including any VPN interface. Measure it before applying the rules and you will find all three open.
sudo deploy/firewall.sh apply # or: remove | statusThe script drops both paths: DOCKER-USER for traffic forwarded onward, and
INPUT for traffic addressed to the host itself, while letting established
inbound connections answer.
Important
The INPUT half is easy to miss. A published port is DNAT'd from the host,
so the container's reply arrives on the bridge addressed to a local address
and is judged by INPUT. Drop it without a conntrack exception first and
every published port hangs while the container still reports healthy.
The rules do not survive a reboot. deploy/exploit-server-firewall.service
persists them, ordered After=docker.service.
Ship the image rather than building on the host. It is a few megabytes.
docker save web-exploit-server:latest | gzip | ssh user@host 'gunzip | sudo docker load'
rsync -a --exclude .git --exclude .env ./ user@host:web-exploit-server/
ssh user@host 'cd web-exploit-server && sudo docker compose up -d'Back up the volume before an upgrade. The runtime image has no shell, so copy it out through a throwaway container:
sudo docker run --rm -v exploit-server_data:/data:ro -v /var/backups:/out \
alpine:3 tar czf /out/data.tar.gz -C /data .The server serves plain HTTP and never terminates TLS. public_scheme and
public_port only shape the URLs it generates; they make nothing listen. Port
443 belongs to whatever you put in front.
public_scheme=https therefore implies a proxy on 443, and generated URLs
carry no port. Set public_port explicitly if the proxy listens elsewhere.
Use this when nothing else on the host owns 443.
- Put the certificate and key at
deploy/tls/origin.pemanddeploy/tls/origin.key, owned by root. - In
.env, setEDGE_BIND=127.0.0.1,TLS_BIND=0.0.0.0,TLS_PORT=443,EXPLOIT_PUBLIC_SCHEME=httpsandEXPLOIT_TRUST_PROXY=1. - Start it with the profile enabled:
docker compose --profile tls up -dCaddy loads the certificate from disk rather than using ACME, because the
container has no route off its own subnet, and because issuing per-instance
certificates would announce every instance hostname to Certificate
Transparency. A Cloudflare Origin Certificate for *.example.com is the
simplest source; a Let's Encrypt wildcard copied out of
/etc/letsencrypt/live/ works too.
Important
The proxy runs with cap_drop: ALL, which removes CAP_DAC_OVERRIDE. Root
inside that container cannot read a key owned by another user, whatever the
mode bits say. Own the files as root, or the container restarts in a loop
logging permission denied.
Use this when a website already owns 443.
- Keep the edge on loopback and give it a free port, for example
EDGE_BIND=127.0.0.1andEDGE_PORT=8821. - Install
deploy/nginx-edge.conf,deploy/nginx-proxy.confanddeploy/nginx-reachability.conf, replacingexample.comwith your domain and pointingproxy_passat that port. - Point
ssl_certificateat your certificate and reload nginx. - In
.env, setEXPLOIT_PUBLIC_SCHEME=httpsandEXPLOIT_TRUST_PROXY=1.
Do not start the tls profile; nginx is the proxy.
Warning
A proxy re-frames what it forwards, so raw and verbatim modes are
byte-exact only on the direct edge port. Keep that port on loopback and
tunnel to it when a test needs the wire untouched.
Set trust_proxy only when the edge cannot be reached except through the
proxy. Otherwise anyone can forge X-Forwarded-For and put a false source
address in your evidence.
A callback that does not arrive is a finding that does not get written. The client is not a browser you control; it is whatever the target runs. Configured like a normal public site, a proxy in front will silently drop:
| Behaviour | Common default | What to set |
|---|---|---|
| TLS floor | 1.2 and above | ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3 with ssl_ciphers ALL:@SECLEVEL=0 |
Missing Host header |
Dropped by the catch-all | Proxy the catch-all to the edge; /i/<id>/ routes it |
| Request to the bare IP | Dropped, SNI rejected | Same |
| Long request line | 8k, then 414 |
large_client_header_buffers 8 64k |
| Large body | 1M | client_max_body_size |
deploy/nginx-reachability.conf and deploy/nginx-default-server.conf cover
these. Scope the permissive TLS settings to the edge vhost and the catch-all:
there is nothing on that listener worth a cipher policy, since it serves
payloads you wrote to targets you are testing.
Two things stay blocked. nginx answers TRACE with 405, and it rejects a
malformed request with 400 before the edge sees it, which is another reason
the direct edge port matters for desync work.
An IPv6-only target cannot call back unless the host has a global IPv6 address
and the records have AAAA entries. Many providers assign IPv6 statically
rather than advertising it, so check for router advertisements before assuming
SLAAC will configure it.
Use a wildcard over DNS-01. HTTP-01 cannot issue wildcards at all.
certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
--dns-cloudflare-propagation-seconds 30 \
-d example.com -d '*.example.com' \
--deploy-hook 'systemctl reload nginx'A certificate per instance would publish every instance hostname to Certificate Transparency the moment it was created. One wildcard publishes nothing, and a new instance is reachable over HTTPS with no issuance step.
Give the API token the minimum scope: read the zone, edit DNS records, nothing else. Keep it outside the repository, mode 0600, owned by root.
For instance hostnames containing dots, see Dotted names and TLS.
-
firewall.sh statusshows the rules, and a throwaway container on the subnet cannot reach the internet, the host, or any private network - Panel unreachable from off-box; reached over a VPN or
ssh -L - Panel password rotated after deploy
-
trust_proxyenabled only because the edge is loopback-bound - Access log shows real client addresses, not the proxy hop
- TLS issued,
certbot renew --dry-runpasses, deploy hook reloads - Instance TTLs and a deletion routine matching the engagement's data terms