C2

How I Built a C2 Server in a Few Hours — Part II

This is the second part of the series. If you haven’t read Part I, where I cover the overall architecture and system design, you can find it here: https://medium.com/@entonytocco/how-i-built-a-c2-server-in-a-few-hours-part-i-2876a31ee98f.

In this second article of the series How I Built a C2 Server in a Few Hours, I focus on the communication model used to deliver commands to implants and retrieve their execution output.

The system is built around a classic beaconing paradigm: implants periodically initiate outbound connections to the C2 server, retrieve pending commands, execute them locally, and exfiltrate results.

The communication layer is designed as a modular transport abstraction, where multiple protocols (HTTP, DNS) implement the same logical interface: command retrieval and result exfiltration.

The HTTP transport implements the primary communication channel between the beacon and the C2 server, following a polling-based beaconing model.

In this design, the beacon periodically initiates outbound HTTP requests to retrieve pending commands. This approach avoids inbound connections entirely, ensuring that communication remains client-driven and compatible with environments where direct access to internal systems is restricted.

At each check-in, the beacon queries the server for available commands associated with its identifier. The server responds with a structured payload containing the pending commands along with execution parameters such as the sleep interval and jitter value.

On the backend, command delivery is managed through a stateful lifecycle. Commands are initially marked as pending and are atomically transitioned to a sent state when retrieved. This guarantees that commands are not re-delivered in case of repeated polling or transient network issues.

After executing a command, the beacon submits the result to the server via an HTTP POST request. The server stores the output and updates the command status to completed, ensuring consistency across the execution pipeline.

From an architectural standpoint, HTTP acts purely as a transport layer, while command orchestration, state tracking, and execution flow are handled independently within the backend. This separation allows the communication model to remain consistent even when additional transports, such as DNS, are introduced.

To reduce traffic predictability, the beacon does not operate on a fixed interval. Instead, a jitter factor is applied to introduce controlled randomness in the delay between successive check-ins:


sleep_time = interval * random.uniform(1 - jitter, 1 + jitter)

This means that each beacon interval is randomly adjusted within a bounded range defined by the jitter parameter. For example, with a jitter value of 0.2, the beacon will sleep for a duration between 80% and 120% of the base interval.

From an operational perspective, this approach helps avoid generating a fixed and easily detectable network pattern, making beaconing behavior less deterministic and therefore harder to fingerprint through simple timing analysis.

This reduces temporal regularity in outbound traffic, limiting the effectiveness of detection techniques based on fixed beacon intervals.

Compared to DNS, the HTTP channel provides a more flexible and higher-bandwidth communication path, making it suitable as the primary transport for command delivery and result collection.

Following the HTTP GET routes for retrieving commands for a target:

@router.get("/{target_id}/commands", response_model=BeaconResponse)
def beacon(
target_id: str,
session: Session = Depends(get_session),
):
"""Implant polls this to pick up pending commands. Returned commands
are atomically marked ``sent`` so they are not re-delivered."""
target = session.get(Target, target_id)
if not target:
raise HTTPException(status_code=404, detail="Unknown target")

target.last_seen = datetime.now(timezone.utc)
session.add(target)

# If the operator deactivated this target, return no commands
payload: list[dict] = []
if target.status != "inactive":
target.status = "active"
pending = session.exec(
select(Command)
.where(Command.target_id == target_id, Command.status == "pending")
.order_by(Command.created_at) # type: ignore[arg-type]
).all()

for cmd in pending:
payload.append({"id": cmd.id, "command": cmd.command})
cmd.status = "sent"
session.add(cmd)

session.commit()
return BeaconResponse(
commands=payload,
sleep=target.beacon_interval,
jitter=target.beacon_jitter,
)

and the HTTP POST request code to receive the command output from a target:

@router.post("/{target_id}/commands/{command_id}/result")
def submit_result(
target_id: str,
command_id: str,
body: CommandResultRequest,
session: Session = Depends(get_session),
):
"""Implant submits the output of a previously-delivered command."""
cmd = session.exec(
select(Command).where(
Command.id == command_id,
Command.target_id == target_id,
)
).first()
if not cmd:
raise HTTPException(status_code=404, detail="Command not found")

cmd.output = body.output
cmd.status = "completed"
session.add(cmd)
session.commit()
return {"status": "ok"}

Alongside the HTTP transport, I designed a DNS-based communication module to explore how a C2 framework can abstract multiple transports behind the same logical workflow: command retrieval and result submission.

In this model, the beacon periodically issues DNS TXT queries toward a controlled domain, and the server interprets the queried name as a structured request.

At a conceptual level, the DNS channel follows the same beaconing paradigm as the HTTP module, but operates over a far more constrained transport. DNS is lightweight and commonly permitted in most environments, which makes it interesting from a protocol design perspective. However, it also introduces strict limitations in terms of payload size, structure, and reliability. Because of these constraints, the DNS channel is better suited for low-bandwidth coordination rather than complex tasking or large data transfers.

To accommodate these constraints, the communication is structured into small chunks embedded within the subdomain. Each request encodes a portion of the payload (after Base64 encoding) with a fixed chunk size of approximately 50 characters, while the remaining labels are used to carry metadata such as the chunk index, total number of chunks, and the requested action.

The resulting structure follows a predictable pattern:

 # {b64_chunk}.{chunk_idx}.{total_chunks}.{cmd_id}.res
  • the b64_chunk represent the trasported data encoded in base64
  • chunk_idx represent the chunk id
  • total_chunks represents the total number of chunks
  • cmd_id represents the command id to which append the results.

This allows the server to reconstruct multi-part messages by tracking sequence and completeness, while preserving compatibility with DNS label size limitations.

The server-side logic inspects DNS requests directed at the controlled domain and parses the subdomain components to determine the beacon’s intent. In this design, one action identifies a polling request for pending commands, while another indicates that the beacon is returning the result of a previously executed task. This preserves the same command lifecycle already implemented in the HTTP module while adapting it to the DNS transport.

From an architectural perspective, the key element is the abstraction layer. Both HTTP and DNS implement the same communication contract: the beacon checks in, retrieves commands if available, executes them locally, and returns the output. This design keeps the command orchestration logic independent from the underlying transport, allowing multiple communication channels to coexist and evolve without affecting the core system.

For the poll action, the DNS server returns a TXT record containing the next pending command for the beacon, together with the timing parameters used for subsequent check-ins.

At a logical level, the response includes:

  • the command identifier
  • the command to execute
  • the sleep interval
  • the jitter value

This data is serialized as a JSON object, encoded as UTF-8, and then transformed into a Base64 string. The resulting payload is returned as a single TXT record, ensuring that the response remains simple and avoids additional complexity on the beacon side.

# Encode: JSON → UTF-8 bytes → base64 string
payload = json.dumps({
"id": pending.id,
"command": pending.command,
"sleep": target.beacon_interval,
"jitter": target.beacon_jitter,
})
b64 = base64.b64encode(payload.encode()).decode()

# Split into ≤ 255-byte strings (DNS TXT per-string limit)
chunks = [b64[i: i + 255].encode() for i in range(0, len(b64), 255)]
reply.add_answer(RR(request.q.qname, QTYPE.TXT, rdata=TXT(chunks)))

Because DNS imposes strict size limits on TXT records, this approach inherently constrains the maximum size of the command payload. In practice, this reinforces the role of DNS as a low-bandwidth control channel, where the goal is to deliver compact instructions rather than large data structures.

Once received, the beacon decodes the TXT response to reconstruct the original command and timing parameters, and proceeds with execution following the same lifecycle defined in the HTTP module.

For the res action, the flow is reversed. The beacon transmits the command output through chunked DNS queries, and the server progressively reassembles the received Base64 fragments. Once all expected parts have been collected, the payload is decoded and associated with the corresponding command result in the backend. In this phase, no meaningful TXT response is required, since the exchange is only intended to deliver execution results back to the server.

From an engineering standpoint, this asymmetry between request and response highlights an important design choice: command delivery is kept simple and bounded, while result submission handles fragmentation explicitly. This keeps the beacon implementation lightweight while delegating reconstruction complexity to the server side.

At the same time, DNS remains a constrained transport. Compared to HTTP, it offers limited bandwidth and requires careful handling of encoding, size limits, and message reconstruction. This makes it suitable primarily for coordination and signaling rather than full-featured data exchange.

Because of these inherent constraints, the DNS implementation remains intentionally minimal and is mainly useful to validate the transport-agnostic design of the C2 communication layer.

From a detection perspective, DNS traffic can blend into normal network activity, but repeated structured queries carrying encoded data may still be identified through anomaly detection or DNS inspection. This reinforces the need to balance simplicity, payload size, and communication frequency when designing such a transport.

The full source code of the project is always publicly available on GitHub, so anyone interested can explore the implementation or experiment with it: https://github.com/antonino-tocco/C2.