Skip to content

Latest commit

 

History

History
201 lines (153 loc) · 8.19 KB

File metadata and controls

201 lines (153 loc) · 8.19 KB

Deployment

Throughout, example.com stands for the domain instances are served from. It needs a wildcard A record pointing at the host.

Isolation

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.

Blocking egress

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 | status

The 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.

Putting it on a server

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 .

Terminating TLS

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.

Option A: the bundled Caddy proxy

Use this when nothing else on the host owns 443.

  1. Put the certificate and key at deploy/tls/origin.pem and deploy/tls/origin.key, owned by root.
  2. In .env, set EDGE_BIND=127.0.0.1, TLS_BIND=0.0.0.0, TLS_PORT=443, EXPLOIT_PUBLIC_SCHEME=https and EXPLOIT_TRUST_PROXY=1.
  3. Start it with the profile enabled:
docker compose --profile tls up -d

Caddy 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.

Option B: an existing nginx

Use this when a website already owns 443.

  1. Keep the edge on loopback and give it a free port, for example EDGE_BIND=127.0.0.1 and EDGE_PORT=8821.
  2. Install deploy/nginx-edge.conf, deploy/nginx-proxy.conf and deploy/nginx-reachability.conf, replacing example.com with your domain and pointing proxy_pass at that port.
  3. Point ssl_certificate at your certificate and reload nginx.
  4. In .env, set EXPLOIT_PUBLIC_SCHEME=https and EXPLOIT_TRUST_PROXY=1.

Do not start the tls profile; nginx is the proxy.

Either way

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.

Reachability

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.

Certificates

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.

Checklist

  • firewall.sh status shows 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_proxy enabled only because the edge is loopback-bound
  • Access log shows real client addresses, not the proxy hop
  • TLS issued, certbot renew --dry-run passes, deploy hook reloads
  • Instance TTLs and a deletion routine matching the engagement's data terms