# Steal My Bitcoin VPS guide This is the optional full-host path for the [local lab](../README.md). The original challenge has ended; the public wallet, domain, timer and authorship proof in the source describe that historical event. Use your own configuration for a new event. ## What Nginx did The public request path was: ```text Browser → host Nginx on 80/443 → 127.0.0.1:8080 → PHP CLI HTTP server ``` Nginx handled HTTPS after certificate setup, reverse proxying and access logging. The HTTP template also serves ACME challenges locally. Its proxy configuration uses HTTP/1.1 and supplies `Host`, `X-Request-ID`, `X-Real-IP`, `X-Forwarded-For` and `X-Forwarded-Proto`. Inspect [the actual template](host/nginx-http.conf) and [logging configuration](host/ctf-log-format.conf) when comparing request behavior. Certbot configures HTTPS in the installed host configuration. PHP executes the application. Despite the base image's FPM name, this deployment runs `php -S`; Nginx proxies HTTP to it rather than using FastCGI. The local quick start goes directly to PHP. Whether Nginx's request handling contributed to the winning exploit is unconfirmed until the winning method is reproduced. The remaining sections cover [setup](#quick-start-on-a-fresh-vps), [flag management](#flag-and-normal-operation), [HTTPS](#tls), [monitoring](#monitoring), [optional statistics](#optional-statistics), and [deployment checks](#deployment-checks). One shared PHP container, managed by Docker Compose, with a repeatable host setup for a **fresh, dedicated Debian 13 amd64 VPS**. Do not run bootstrap on the existing challenge or a machine hosting other containers/services. The kit preserves the PHP 8.4.26 CLI HTTP server, setuid flag helper, static site, wallet/timer behavior, host Nginx, Let's Encrypt, host-only logging, user-namespace remapping, and network isolation. It does not switch to PHP-FPM or add an exploit endpoint. The helper is compiled statically with the host’s musl toolchain; Docker builds only copy the binary and an empty mount point, with build networking disabled. It is a repeatable deployment recipe, not an assertion that a particular PHP CVE is exploitable in this SAPI. **There is no flag or private key in this kit or its build context.** It starts with an obvious dummy value. A real flag is supplied separately on the destination host and mounted read-only. Do not put it in the kit, an environment variable, an image, or a command argument. Docker images exported by this kit contain only an empty `/flag` mount point. ## Quick start on a fresh VPS Copy/extract the kit on the new VPS. Use the domain that will eventually point to it; DNS can still point to the old server while you test the new one over HTTP. ```bash sudo --preserve-env=SSH_CONNECTION python3 ctfctl.py bootstrap \ --domain stealmybitcoin.com \ --admin-cidr YOUR.PUBLIC.IP/32 \ --memory 1g --cpus 1.5 ``` Replace `YOUR.PUBLIC.IP/32` with the operator's actual source IP/CIDR. Bootstrap installs Docker/Compose, Nginx, Certbot, auditd, UFW and unattended upgrades. It enables full ASLR, denies incoming traffic except operator SSH and public HTTP(S), and starts a dummy-flag instance. Existing broad UFW SSH allows are overridden by ordered rules; provider firewall rules must also allow your SSH source. Bootstrap does not change SSH authentication or grant passwordless sudo. It refuses an unmanaged existing challenge or existing containers. Repeating it with the same config is supported; changing configuration requires deliberate review rather than silently changing the current deployment. Defaults preserve the **freshly downloaded** site's public configuration: - Wallet: `bc1qlnu50y4zm0nwxkckxu09thn5xdcz8aw6t3cxqy`. - Start: `2026-10-24T20:30:00Z`. Override with `--wallet ADDRESS --start 2026-10-25T12:00:00Z` for a new exercise. For a new exercise, review prize copy and terms in `app/` before bootstrap. A different domain updates branding/links but does not rewrite the encoded signed authorship claim about the original domain. Re-sign that separately if appropriate. Current scaled defaults: 1 GiB RAM, 1.5 CPUs, eight PHP workers, and no additional swap allowance. These match the live VPS settings applied on 25 October 2026. ## Flag and normal operation Management code is installed at `/opt/ctf/deploy/ctfctl.py`. The source flag file must arrive through a separate secure channel, e.g. a private operator-owned file at `/root/prize-flag`. The value is never echoed: ```bash sudo python3 /opt/ctf/deploy/ctfctl.py set-flag /root/prize-flag sudo python3 /opt/ctf/deploy/ctfctl.py verify sudo python3 /opt/ctf/deploy/ctfctl.py deploy sudo python3 /opt/ctf/deploy/ctfctl.py stop ``` `set-flag` applies the destination's dynamically discovered mapped-root UID/GID, mode 0400, and recreates the container because a bind-mounted replacement inode is otherwise not picked up. Its parent remains host-root-owned mode 0700. `deploy` starts/reconciles the single container without rebuilding its image. Use `deploy --build` only when deliberately updating the helper/image source. Memory/CPU limits are in `/opt/ctf/.env`; changing them and running `deploy` causes Compose to recreate the container. Do not reset the daemon namespace mapping. The verifier checks direct flag-read denial, matching helper output without printing it, actual UID mapping, capabilities, Seccomp, AppArmor, bind mounts, loopback-only publishing, container egress/host/metadata rejection, direct local unprivileged backend denial, HTTP/Nginx, and required monitoring configuration. With TLS installed it checks trusted HTTPS locally using the configured SNI. These checks generate identifiable operator activity; exclude it from solves. ## TLS Allow public TCP 80 at the provider firewall, plus 443 to participants. Point DNS to the replacement (including correct AAAA records if used), then run: ```bash sudo python3 /opt/ctf/deploy/ctfctl.py tls --email operator@example.com ``` Omit `--email` to register without one. This issues a new certificate, redirects HTTP to HTTPS, and tests renewal. The timer renews automatically; retain public port 80. Certificate issuance is a separate operation so bootstrap can be staged before DNS changes. **A domain with no certificate on the new server can have a short HTTPS interruption during cutover.** Plan DNS and certificate issuance before exposing the new host; this kit does not promise zero downtime or copy TLS private keys. ## Prebuild / transfer the exact runtime image On an amd64 staging host where bootstrap has built the reviewed image: ```bash sudo python3 /opt/ctf/deploy/ctfctl.py export-image /root/ctf-runtime.tar sha256sum /root/ctf-runtime.tar ``` Transfer it privately and verify its hash on the replacement. Bootstrap can load it rather than compile again: ```bash sudo --preserve-env=SSH_CONNECTION python3 ctfctl.py bootstrap \ --domain stealmybitcoin.com --admin-cidr YOUR.PUBLIC.IP/32 \ --memory 1g --cpus 1.5 --image-archive /root/ctf-runtime.tar ``` The image contains no real flag, current site mount, logs, certificates, or private signing key. The base image is pinned to: ``` php:8.4.26-fpm-alpine@sha256:a8091f6d2623b0b23a78a81c71b10e71545e72ba37176030445f347cd5b1bdee ``` Compiler and host packages come from Debian repositories at setup time. No package manager runs inside the image build, so the egress guard remains enforced even during a rebuild. For an identical runtime across machines, export/load the prebuilt image rather than assuming separately compiled binaries will be byte-identical. A trusted private registry is also an option, but this kit does not require or configure one. ## Isolation and persistence - Docker `userns-remap: default`; derives UID/GID ranges instead of copying the old VPS's 165536/165618 identifiers. - Non-root PHP, all capabilities dropped, read-only root and mounts, bounded noexec/nosuid tmpfs, CPU/RAM/PID caps, AppArmor, default Seccomp. - One container on the default bridge. No management socket or host log mounts. - Host Nginx owns public HTTP(S); only 127.0.0.1:8080 is published by Docker. - Fixed host-only INPUT/FORWARD/OUTPUT and DOCKER-USER chains block bridge-to-host, external forwarding/metadata, peer forwarding, and unprivileged local bypass. Nginx/root requests and their established replies remain allowed. - Guard installed before Docker, with daemon pre/post hooks and UFW restart hook. Failure in Docker's pre-start guard prevents startup. Unrelated firewall rules are preserved; all default-bridge containers would be subject to this policy, hence the dedicated-machine requirement. Native Docker nftables backend is not supported; Debian's normal iptables-nft frontend is supported. - Audit follows the mapped real PHP UID. Nginx log rotation checks every minute; compressed retention and audit/Docker caps remain as described in the monitoring section below. `no-new-privileges` is deliberately absent: it would disable the setuid helper. These restrictions reduce risks; they do not eliminate shared-kernel escapes or make host-local evidence trustworthy after a host-root compromise. ## Long-term aggregate stats After bootstrap, install the reviewed minimal collector with: ```bash sudo python3 /opt/ctf/deploy/install-stats.py sudo -u ctf-stats /usr/local/bin/ctf-stats report ``` This creates a non-login stats account and one cron job, with access to access logs only. It retains aggregate daily counts for 90 days after raw logs expire; there is no dashboard or network service. See the optional statistics section below for limits and approximate coverage. It is a separate explicit step on a fresh host. Repeated bootstrap preserves/reinstalls it if already enabled. Current raw retention is 64 MiB x 64 rotations per Nginx log (compressed), plus 32 MiB x 16 audit files. ## Diagnostics ```bash sudo docker compose --project-directory /opt/ctf -f /opt/ctf/compose.yaml ps sudo ctf-log-report sudo auditctl -s sudo systemctl status ctf-network-guard.service ctf-logrotate.timer ``` Keep logs on the old machine or export them privately before retirement. They are deliberately excluded from the distributable source bundle. ## Validation status Local unit tests and Compose normalization pass. A real disposable Debian container with its own network namespace verified IPv4/IPv6 guard application, repeat application without duplicate hooks, and root/Nginx allowance versus unprivileged-user rejection. No live-server firewall was changed. **The complete fresh Debian 13 amd64 bootstrap has not yet been run on a second VPS.** This workstation is ARM64, the pinned image requires amd64, and KVM is unavailable. The exact image was pulled but could not execute locally (exec format error). Rehearse bootstrap/verify, a reboot, UFW reload, and certificate renewal on the replacement while it contains only the dummy flag, before retiring the old host. See the deployment checks below for repeatable local checks. ## Automatic backend recovery Bootstrap now installs and enables the same fixed-target recovery timer used on the live VPS, including its PHP health endpoint. It recovers after sustained backend failures with startup grace and a five-minute restart cooldown. See the monitoring section below for behavior and disable commands. No additional container needed. ## Monitoring Collection is host-side. Logging adds no PHP hooks and does not change request limits or body buffering. Nginx uses only built-in logging/maps. No collector web endpoint, Lua, external proxy module, or payload evaluation exists. The only upstream header added is a server-generated X-Request-ID; a client cannot choose it. Raw claimed forwarding headers remain explicitly labeled untrusted. ### Evidence - /var/log/nginx/ctf-access.log: JSON request records with end time (millisecond resolution), duration, peer IP/port, request ID, method, original URL, selected headers including cookies/authorization, TLS, upstream timing/status and sizes. - body_preview: first 8192 bytes already buffered in Nginx memory. Escaped as JSON. No response bodies are logged. This avoids recording the helper's flag output. body_spooled=true marks requests whose body went to a temporary file; those file contents are NOT read or copied. Multipart/large body capture is incomplete. - /var/log/nginx/error.log: normal Nginx errors. - /var/log/audit/audit.log: host kernel execution records for the mapped host UID of container PHP UID 82 (dockremap base + 82). Includes argv, host PID/parent PID, effective UID, executable and timestamp. Rules cover execve and execveat, 64/32-bit. Bootstrap derives this filter from the destination namespace mapping. New image users or changed mappings require regenerating it. - Existing Docker stdout/stderr: already capped at 3 x 10 MB. Logs are sensitive and outside the PHP container's mounts. Nginx logs are mode 0640, owned by the host Nginx account (UID 33) after reopening, with group adm. The PHP container account has UID 82 and has no host log mount. Audit logs are root:adm mode 0640. Do not serve them from the website. Treat captured data as hostile: no eval, shell interpolation, or unescaped HTML rendering. Queries, cookies, request bodies and command arguments may contain secrets. No IP reputation lookup or third-party log export has been enabled. ### Analysis sudo ctf-log-report sudo ctf-log-report --ip 203.0.113.10 --details sudo auditctl -s The report groups IPs, user agents, statuses and paths, lists helper executions, and shows requests overlapping a +/-30 second window around the latest helper execution. Subtract request_seconds from end_msec to get request start time. Use --details only when needed; it includes raw captured credentials/payloads. JSON escaping prevents attacker-controlled control characters from being printed as terminal commands. Binary invalid UTF-8 is replaced during report decoding; raw log files preserve the original bytes. IPs identify network endpoints, not people; VPNs/NAT and forged user agents limit attribution. Concurrent PHP workers and persistent sessions prevent a guaranteed one-to-one association between a request and a helper execution. Audit argv can link process ancestry but does not record PHP-only activity or network syscalls. Logging is not a packet capture. A print-flag exec is evidence of execution, not proof of flag delivery or a wallet withdrawal. ### Storage and reliability Nginx logs: rotate daily OR above 64 MiB, checked every minute; retain 64 rotations, compress all but the latest rotation, and remove old rotations after 14 days. There is no guaranteed 14-day history under load. Each Nginx log normally stays around 4160 MiB or less BEFORE compression, plus growth between minute checks. Audit: Debian's built-in rotation is 16 x 32 MiB (~512 MiB), uncompressed. Docker: 3 x 10 MB. Nginx compression normally saves substantial space, but these are rolling SOFT limits: a burst can grow the active Nginx file between checks, and file sizes can overshoot by a record. Rotation is not a filesystem quota or protection against an unlimited storage flood. No copytruncate: Nginx reopens renamed logs, avoiding its usual copy/truncate race. Nginx flushes at most once a second when traffic is low; a crash can lose the last buffer. Audit uses asynchronous writes and can also lose recent buffered records. Kernel audit rate is capped at 1000 records/second, backlog 8192 with zero wait: heavy exec floods can cause audit loss rather than stall the challenge. Check auditctl -s ('lost', 'backlog') and systemctl status ctf-logrotate.timer. Audit disk-full/error behavior suspends logging rather than halting the host. Configs: /etc/nginx/ctf-log-format.conf, /etc/logrotate.d/nginx, /etc/systemd/system/ctf-logrotate.{service,timer}, /etc/audit/rules.d/*ctf*. Reviewed config sources are installed under /opt/ctf/deploy/host/. The deployed execution rules are /etc/audit/rules.d/99-ctf.rules. The verifier executes print-flag and PHP socket probes, and requests the site with User-Agent CTF-Deployment-Verification. These operator tests must not be counted as participant solves. The original deployment also generated tests with User-Agent CTF-Monitoring-Check and direct helper executions. Optional long-term aggregate statistics are documented in the optional statistics section below. The deployed collector is non-root and is separate from the host execution audit trail. ### Backend recovery Bootstrap installs the root-owned `ctf-recover` script and enables its systemd 20-second timer after starting PHP. It probes localhost:8080/php-version.php, requiring HTTP 200 and discarding the body. The included endpoint returns the PHP version and UTC time with caching disabled. After a 60-second startup grace, at least three consecutive failures spanning 60 seconds trigger a restart of only ctf-php. A five-minute cooldown survives container restarts. Stopped/paused/restarting/missing containers are left alone. Root-only /run/ctf-recover holds lock and counters, reset on reboot. No response body, flag, or participant-supplied command is read. Sustained overload can cause recovery; restarts terminate all remaining sessions. This does not detect solves. Inspect events: `sudo journalctl -u ctf-recover.service`. Disable: `sudo systemctl disable --now ctf-recover.timer`, then `sudo systemctl stop ctf-recover.service` for any running check. ## Optional statistics `install-stats.py` installs a non-login ctf-stats user, a standard-library Python script, and one cron entry. No new web endpoint, network client, service, or privileged collector runs. The dedicated ctf-logread group reads only access logs; it does not grant adm/Docker membership or sudo. Its SQLite state is private under /var/lib/ctf-stats (mode 0700), owned by the stats user. The code and cron entry are root-owned and cannot be modified by that user. Collection counts completed compressed ctf-access.log.*.gz files. It ignores the active file and uncompressed .1, so recent activity appears after compression. Files are deduplicated using their uncompressed SHA-256 plus cached filesystem identity. Counts and the processed marker commit in one SQLite transaction. Renaming a rotation or restarting the job doesn't double-count it. There is no tracking of live-file offsets. Daily totals include requests, response bytes, statuses, methods, user agents, paths without query strings, and latency buckets. Categories are capped at 100 per dimension/day (256 characters each), with overflow grouped as [other]. This is a capped first-seen set, not an exact heavy-hitter algorithm. Bodies, cookies, authorization headers, IP addresses, and query strings are not stored. User agent/path labels remain attacker-controlled and are JSON-escaped in reports. Summaries are retained for 90 days even after raw logs expire. The database has an approximately 64 MiB page limit. Processing is streaming, with a 256 KiB line limit and 512 MiB decompressed-file limit. Malformed/oversized records are counted as rejected. Corrupt/incomplete compressed files aren't committed and are retried. Only one collector runs at once; cron invokes it at reduced CPU priority. It refuses to run as root. Memory is capped at 128 MiB, CPU time at 20 seconds (25-second hard cap), and cron adds a 45-second wall-time timeout with a five-second kill grace period. Interrupted file transactions roll back; previously committed files remain counted. These are small process limits, not another service. The installer requires a unique system UID below 1000, separate from the Docker subordinate-ID range, and checks for unexpected administrative groups/permissions. The process can read the raw access logs, including their sensitive fields; this is intentional and necessary to summarize them. Its stored user-agent/path labels can themselves contain arbitrary submitted text, so keep summaries private too. This is approximate: files that expire before collection cannot be recovered or even reliably detected. Rotation races or prolonged collector failure can exceed 5% loss. Reports include last-run/success timestamps, errors and rejected lines; they do not claim complete coverage. Initial totals cover only the logs that still exist at installation time, not all traffic since challenge launch. The installer also applies the approved storage changes: Nginx 64 MiB rotation threshold with 64 retained rotations and existing compression/minute checks; audit 16 x 32 MiB files (~512 MiB). Log size limits remain soft between checks. No request capture format, body buffering, PHP settings, flag access, or container settings are changed. Install on a host with existing challenge logging, as root: ```bash python3 install-stats.py sudo -u ctf-stats /usr/local/bin/ctf-stats report ``` Installation sources must include the adjacent host directory. Backups of changed configuration are placed in /opt/ctf/stats-backups. This is a separate optional step after the deployment kit bootstrap; bootstrap alone does not install it. ## Deployment checks Run from the kit directory, with Python 3 and Docker Compose installed: ```bash python3 -m unittest discover -s tests -v docker compose config --quiet ``` Firewall integration runs in a disposable container's separate network namespace, not on the host. It needs NET_ADMIN for that namespace. It mounts only reviewed test/guard source read-only, and never mounts the flag, Docker socket, or host firewall files. It installs Debian tools in the disposable filesystem: ```bash docker run --rm --cap-add NET_ADMIN \ --mount type=bind,src="$PWD/host/network-guard.py",dst=/test/network-guard.py,readonly \ --mount type=bind,src="$PWD/tests/firewall-smoke.sh",dst=/test/firewall-smoke.sh,readonly \ debian:13-slim sh /test/firewall-smoke.sh ``` The native-architecture Debian test image is a test dependency, not the pinned PHP runtime. This verifies actual firewall syntax/semantics and reload behavior; it does not substitute for full Docker bridging, namespace remapping, audit, systemd boot ordering, and TLS tests on a fresh Debian amd64 VPS.