I released TornadoC2 today
Repo: https://github.com/kamalx06/TornadoC2
Status: between beta and 1.0.0. Core is stable and usable end to end, but I'm still working through some transport edge cases, console polish, native commands, and doc gaps. Will tag 1.0.0 when those are closed.
I'm posting because I made specific choices about how a C2 should work, and I'd like criticism from people who've actually run C2 in the field. If any of those choices are wrong, I'd rather hear it now.
Three things worth reading about
1. Fully Malleable profiles that shape the whole response, not just its envelope. Where most tools shape the wrapper around a fixed payload model, TornadoC2's profile system controls what the response actually looks like, and it does so with one JSON file per profile. A profile declares the three request paths (check-in, tasks, results), the User-Agent with a separate Linux override, extra request headers and per-role request headers, the session cookie name, additional response headers a real server would send, and per-role HTTP status codes for check-in, tasks, results, and error paths. The shaping block then controls the structure of each body independently: an arbitrary JSON template with {{payload}}, {{ts}}, and {{nonce}} placeholders; an extract path that supports array indexing (data.items[0].envelope.b64); a selectable encoding (base64, base64url, hex, or NetBIOS-style lowercase letters); and a selectable placement (body, a named response header, or a Set-Cookie value). Three shapes per profile, each one independent — a check-in can return 201 with the payload in an X-Request-ID header, a task poll can nest a base64url blob three levels deep in a JSON envelope, and a result upload can return 204 with the payload in a Set-Cookie, all on one profile, each matching what a different endpoint of the mimicked service actually does. DNS and DoH profiles carry a separate dns_label block that renames the three verb characters and changes the per-chunk payload length, and a stager.path block can move the payload fetch to whatever URL the mimicked service actually uses. The profile applies to every transport that carries HTTP-shaped bodies — HTTP, QUIC, WebSocket, WireGuard, and the three pivot transports — while DNS and DoH read the label block and ignore the HTTP-specific fields. Every profile is validated at two gates: the handler refuses a profile with colliding paths or malformed shaping at startup, and the agent build runs a round-trip probe through every template — it renders a payload, walks the extract path, verifies the payload comes back unchanged before any binary is emitted.
2. Concurrent multi-profile handling on one listener. Every other framework binds one profile per team server. TornadoC2 serves every profile under profiles/c2/ at once — same port, same certificate, same process. The request path is the profile selector. A Chrome-profile beacon and a Slack-profile beacon hit the same handler and each gets the response shape its own profile declared. No restart to rotate profiles, no second server on a second port, no "active profile" state.
3. A team server designed for teams, not a single-user console with collaboration bolted on. Every other C2 I've used treats the operator side as a coordination layer added after the agent works. TornadoC2 treats the team as the point:
- RBAC with a distinct
guardian role. Admins manage everything that keeps the engagement running. The guardian alone decides when it ends. A request to /deadman/arm or /deadman/halt from an admin credential is refused with a 403 and audited.
- Hash-chained tamper-evident audit log. Every operator action writes to a chain where each entry carries the SHA-256 of the previous entry, anchored by an HMAC at the tail. Every mutating API response carries an
X-Audit-Id header so a scripted caller can cross-reference the entry.
- Credential vault with beacon-side validation. The check runs on the target, not on the team server, because a bind attempt from the wrong network either fails silently or succeeds against a system the engagement isn't authorized to touch. Values are never written to the audit chain.
- Owner-restricted task cancel. An operator can cancel a task they queued before the beacon picks it up. System-queued tasks can only be cancelled by an admin. A refused cancellation is audited with
outcome=denied.
- Session viewers. Every
beacons poll carries the caller's attached beacon ID, and the table gains a VIEWERS column showing who else is on the same beacon.
- Content-addressed loot store with per-operator ownership, admin override with an audit marker, chunked upload up to 128 GiB per file.
- Team chat with file sharing, DM filtering, pinned topic, and forensic preservation — when the sanitizer modifies a message body, the audit chain carries the SHA-256 and base64 of the raw input.
- Per-operator webhooks to Slack, Discord, or Telegram with an SSRF guard that refuses loopback, private, link-local, and cloud-metadata destinations at registration time and again on every dispatch.
- Beacon aliases, tags, notes, session graph. Keyed on beacon ID so they survive a stale-and-reconnect cycle.
- Dead-man's switch with eleven secure-deletion modes, held by the guardian role only. It runs on a wall-clock deadline, not on an idle timer — operator activity does not reset it. When it fires it stops every listener, wipes the entire repository tree with the configured deletion mode (eight pattern modes like
dod, nist, gutmann, schneier, plus three structural modes: trim for TRIM/discard on SSDs, scrub for prealloc + O_DIRECT + TRIM, and crypto for LUKS header destruction), and can optionally power off or reboot the host. The remote API requires confirm: true for crypto mode on every request, because destroying the LUKS header makes every byte on that volume unrecoverable — including data outside the TornadoC2 tree.
- Two frontends over one client library. Terminal REPL as the reference implementation, PySide6/Qt GUI as a presentation layer over the same
Client methods.
Transports
Ten native transports, split across three roles.
Direct transports. HTTPS, QUIC/HTTP-3, WebSocket, DNS TXT, DNS-over-HTTPS, ICMP echo, and WireGuard. HTTP, DNS, and DoH can be combined in one binary with automatic failover after N transport-level failures — a network that permits HTTP/3 but blocks TCP/443, or permits DoH but filters UDP/53, is reachable without rebuilding the agent for a different carriage. QUIC, WebSocket, and WireGuard are single-transport in this version. Each of the ten carries the same C2 protocol over a different carriage.
Pivot transports. SMB named-pipe pivot, TCP bind pivot, and TCP reverse pivot. All three solve the same problem: a child beacon that cannot reach the team server directly, carried by a parent that can. Every frame between the parent and the child is encrypted end to end with an AEAD session keyed off a fresh ECDH exchange — the parent forwards the child's C2 envelope verbatim and never inspects or modifies it. The child's own wire encryption to the team server is preserved independently of the pivot session. All three pivot transports share the same wire format, the same handshake, and the same per-direction keys; the only difference is which side dials.
Cryptography
- Task, result, and identity signing. Every task is signed with the handler's ECDSA P-256 key over canonical JSON (
{id, verb, args, timeout}, sorted keys, no whitespace) and refused by the agent if the signature fails. Every result is signed with the agent's own build-time ECDSA key and pinned at first check-in. Every check-in carries a separate signature over machine_id|hostname|username|os|identity_ts, verified before the session is pinned — a valid signature presenting a different public key for a known fingerprint is refused and fires beacon-identity-mismatch.
- Wire encryption. Every request and response body is an AES-256-GCM envelope keyed off a P-256 ECDH exchange. The server's ECDH public key is embedded at build time; the agent's ephemeral keypair is fresh per check-in. The task signature covers the plaintext, not the envelope, so a MITM who terminates TLS and unwraps the wire encryption still cannot forge a task.
- Pivot session. One ECDSA P-256 pivot keypair per engagement. Parent proves possession of the private half, child proves possession of its own identity key, both handshake payloads carry a role-specific domain separator. Fresh ECDH per handshake, HKDF-SHA256 to per-direction AES-256-GCM keys, parent's ack bound to the child's ephemeral so a captured ack can't replay against a fresh hello. Every frame after that is length-prefixed AEAD with a monotonic per-direction sequence number in the additional data — a gap or repeat is a fatal protocol error and the session is torn down.
On forward secrecy: the pivot tunnel doesn't have it. The parent reuses the same long-term pivot key in every handshake, so if that key is stolen later, an attacker who recorded a past parent-to-child session can decrypt it. Because the key is reused, this applies to every recorded session, not just one. Having both sides use a fresh throwaway key per handshake would fix it — it's on the list.
The child's encryption to the team server is a separate layer and doesn't have this issue. It's keyed off the server's ECDH key, which the parent never holds, so a parent compromise gives the attacker the tunnel, not the child's C2 content. On the other side, the child's contribution to the pivot handshake is a throwaway key that's discarded after the exchange, so a child compromise doesn't break past pivot sessions either. Treat the pivot key as an engagement-lifetime secret and rotate it between engagements.
Rest of the stack
- Go implant for Linux + Windows, compile-time configured
- ~70 native verbs in-process, in-memory PE/ELF/.NET execution, COFF/BOF loader
Evasion features
Four compile-time OPSEC profiles (default, stealth, opsec, paranoid), bundling progressively more aggressive combinations of:
Anti-analysis heuristics. Anti-VM checks that detect common hypervisor artefacts (VMware, VirtualBox, Hyper-V, QEMU, Xen — registry keys, device names, MAC OUI prefixes, firmware strings), anti-sandbox checks that look for sandbox-typical process names, file paths, and hooking artefacts (Cuckoo, Joe Sandbox, ANY.RUN, CAPE, and the tools that ship with them), anti-debug checks against both userland and kernel-level debuggers (IsDebuggerPresent, CheckRemoteDebuggerPresent, NtQueryInformationProcess for the DebugPort/DebugObjectHandle/DebugFlags classes, PEB BeingDebugged, hardware breakpoint register inspection, and timing checks against rdtsc and QueryPerformanceCounter drift).
Runtime patching. AMSI bypass via in-process AmsiScanBuffer patch, ETW patching to blind the EtwEventWrite path that feeds script-block and .NET telemetry, and indirect syscalls on Windows where the agent reads the system service number out of the ntdll stub without executing it, then jumps directly to the syscall instruction inside ntdll.
Sleep masking. Four modes: rc4 and aes-ctr on Linux and Windows via a native sleep stub that encrypts the executable segment, cronos (fiber-based, Windows amd64 only) and hwbp (hardware-breakpoint + VEH handler, Windows amd64 only), both of which encrypt the same segment without running any Go code inside the encrypt/decrypt window. Every mode generates a fresh key on every sleep cycle, so two consecutive sleeps never produce the same ciphertext.
Binary shaping. String obfuscation, garble for control-flow obfuscation, and UPX packing in the paranoid profile.
Execution discipline. Shell history suppression for any child shell, jittered first check-in (2–17 s after launch) and jittered subsequent check-ins, working-hours window that suppresses outbound traffic outside a configured schedule.
Injection backends. Two for migrate <pid> — CreateRemoteThread on Windows and ptrace on Linux as the default, remote APC injection via NtQueueApcThread as the alternative on Windows.
What I'm not claiming
I'm not claiming full EDR evasion. I'm not claiming this defeats modern EDR, AMSI beyond the in-process patch, ScriptBlock logging, memory forensics, or behavioral detection. What I am doing is adding evasion techniques as aggressively as I can and continuing to add new methods as I find them — this is an active area of the project, not a finished one. If you know a technique that works against current EDR and would fit the framework, I want to hear about it.
The README has a full "what this tool does not claim" section. Short version on the specific limits: the hwbp sleep mask writes DR0/DR7 which is itself observable, APC injection removes CreateRemoteThread but NtQueueApcThread is instrumented by every major EDR, and lsassdump is the highest-signal verb by construction. The sleep mask encrypts the executable segment during the wait but does not encrypt the goroutine stack, so the call chain and locals remain readable between check-ins.
The limits section isn't hedging. It's what a defender looks for and what the tool doesn't do about it.
On the timeline and the AI assistance
I built this in 2–3 weeks with AI assistance. I'm saying that up front because it's relevant to how you read the post.
What that means in practice: I designed the protocol, the threat model, the role model, the profile system, the transport abstraction, and the pivot handshake. I made the decisions about what runs in-process vs. as a child, what's compile-time vs. runtime, what the audit chain records and what it doesn't, and why the guardian role is separate from admin. I understand every part of the codebase and can answer questions about why any specific piece works the way it does.
The AI assistance was for the mechanical volume — the ~70 native verb implementations, the transport boilerplate, the docs. The design is mine. The choices that determine whether the framework is useful or not are mine.
I'm noting this because a lot of people are curious about what you can build quickly with modern tooling when you actually know what you're doing, and a lot of people are (rightfully) skeptical of AI-generated security tooling. I'd rather be in the first conversation than the second.
Criticism on the design is what I'm after. If the abstraction is wrong, I'd rather hear it now than after 1.0.0.