Cybersecurity-Projects/PROJECTS/beginner/c2-beacon/learn/02-ARCHITECTURE.md

40 KiB

Architecture: How the C2 System Fits Together

This document walks through every component in the C2 beacon project, how they connect, how data flows between them, and why the design decisions were made the way they were. By the end you should be able to trace a command from the operator dashboard all the way down to the beacon executing it on a target machine, and back again.

Estimated reading time: 20-30 minutes.


Table of Contents

  1. High Level Architecture
  2. Component Breakdown
  3. Data Flow
  4. Protocol Design
  5. Database Schema
  6. Design Decisions
  7. Security Architecture
  8. Deployment Architecture
  9. Key Files Reference

High Level Architecture

Here is the full picture of every moving part and how they talk to each other:

┌─────────────────────────────────────────────────────────────┐
│                        Docker Network                        │
│                                                              │
│  ┌──────────┐     ┌──────────┐     ┌──────────┐            │
│  │  Nginx   │────>│ Backend  │     │ Frontend │            │
│  │  :47430  │     │  :8000   │     │  :5173   │            │
│  └──────────┘     └──────────┘     └──────────┘            │
│       │                │                                     │
│       │           ┌────┴────┐                               │
│       │           │ SQLite  │                               │
│       │           │  (WAL)  │                               │
│       │           └─────────┘                               │
└───────┼─────────────────────────────────────────────────────┘
        │
   ┌────┴────┐
   │ Beacon  │  (runs on target machine, outside Docker)
   └─────────┘

Five components, three networks, one protocol. Let's break each one down.

Nginx (port 47430)

Nginx is the single entry point for everything. Every request, whether from a beacon connecting over WebSocket, an operator viewing the dashboard, or a REST API call, enters through port 47430. Nginx inspects the URL path and routes accordingly:

  • /api/ws/* goes to the backend WebSocket handlers. This covers both /api/ws/beacon (for beacon connections) and /api/ws/operator (for operator dashboard connections). Nginx upgrades these to WebSocket connections by setting the Upgrade and Connection headers.
  • /api/* (non-WebSocket) goes to the backend REST endpoints. These serve beacon listings, task history, health checks, and the OpenAPI docs.
  • Everything else (/) goes to the frontend. In development that means proxying to Vite's dev server on port 5173. In production Nginx serves the pre-built static files directly from disk.

The nginx configuration lives in infra/nginx/dev.nginx for development and infra/nginx/prod.nginx for production. The shared base config at infra/nginx/nginx.conf defines upstreams, rate limiting zones, gzip settings, and the WebSocket upgrade map.

One thing worth noticing: the WebSocket locations have proxy_read_timeout 3600s. Normal HTTP locations use 30-60 second timeouts. WebSocket connections are long-lived by nature, so nginx needs to know not to kill them after a minute of apparent inactivity.

Backend (port 8000)

The backend is a FastAPI async application that does three jobs simultaneously:

  1. Beacon management. It accepts WebSocket connections from beacons, validates their REGISTER messages, tracks them in memory and SQLite, and delivers tasks through per-beacon async queues.
  2. Operator management. It accepts WebSocket connections from operator dashboards, sends them real-time events (beacon connected, heartbeat, task result), and receives task submission requests.
  3. REST API. It serves beacon listings and task history for any client that needs them, plus health checks for Docker's container orchestration.

The backend uses FastAPI's lifespan context manager to initialize the database, the beacon registry, the task manager, and the operator broadcast manager at startup. All three singletons are attached to app.state so every request handler can access them. The app factory pattern in backend/app/__main__.py wires everything together: CORS middleware, routers for beacon WebSocket, operator WebSocket, and REST endpoints.

Frontend (port 5173 dev)

The frontend is a React application built with Vite, TypeScript, and SCSS modules. It connects to the backend through a single WebSocket at /api/ws/operator and maintains all state in a Zustand store.

When the dashboard loads, the useOperatorSocket hook opens a WebSocket connection. The server immediately sends a beacon_list message with every known beacon. From there, the frontend receives real-time events: beacon_connected, beacon_disconnected, heartbeat, task_result, and task_submitted. The Zustand store in frontend/src/core/ws.ts processes each event type and updates the corresponding state slice.

The operator can select a beacon from the dashboard and open a session page, which provides a terminal-like interface for submitting commands. When the operator submits a task, the frontend sends a submit_task message over the same WebSocket. The server responds with a task_submitted confirmation mapping the local ID to the server-generated task ID, and later broadcasts the task_result when the beacon reports back.

SQLite (WAL mode)

The database is a single SQLite file at backend/data/c2.db. It stores three tables: beacons, tasks, and task_results. The backend initializes the schema on startup in backend/app/database.py and enables WAL (Write-Ahead Logging) mode with PRAGMA journal_mode=WAL.

WAL mode is important here because the backend has concurrent reads happening. While one coroutine is writing a task result, another might be reading the beacon list for an operator query. WAL allows readers and writers to operate simultaneously without blocking each other. In the default rollback journal mode, a write would lock the entire database and block all reads until the write commits.

The get_db() async context manager yields a fresh aiosqlite connection each time, with row_factory set to aiosqlite.Row so query results come back as dict-like objects instead of plain tuples. Foreign keys are enabled on every connection.

Beacon (runs on target)

The beacon is a standalone Python script at beacon/beacon.py that runs outside the Docker stack, on whatever machine you point it at. It has its own copy of the XOR encoding functions (deliberately duplicated so the beacon has zero dependencies on the server codebase) and a dispatch table mapping command strings to handler functions.

On startup, the beacon connects to the server via WebSocket, sends a REGISTER message with system metadata (hostname, OS, username, PID, internal IP, architecture), then enters its main loop. It sends periodic heartbeats with jitter applied to the sleep interval, and waits for TASK messages from the server. When a task arrives, the beacon dispatches it to the appropriate handler, collects the output, and sends a RESULT message back.

If the connection drops, the beacon uses exponential backoff (starting at 2 seconds, capping at 5 minutes) to reconnect. This is standard behavior for real-world implants, because hammering a dead server at full speed is a great way to get noticed by network monitoring.


Component Breakdown

Backend Layers

The backend follows a layered structure that separates protocol concerns from business logic from persistence:

backend/app/
├── __main__.py
├── config.py
├── database.py
├── core/
│   ├── encoding.py
│   ├── protocol.py
│   └── models.py
├── beacon/
│   ├── registry.py
│   ├── router.py
│   └── tasking.py
└── ops/
    ├── manager.py
    └── router.py

__main__.py is the application factory. It creates the FastAPI instance, configures CORS, sets up logging, initializes the database schema, and instantiates the three core singletons (BeaconRegistry, TaskManager, OpsManager) during the lifespan startup. The create_app() function mounts all routers and defines the health check endpoint. Uvicorn runs this directly via app.__main__:app.

config.py uses Pydantic Settings to load configuration from environment variables and the .env file. It defines the XOR key, database path, CORS origins, log level, and server bind options. The @lru_cache decorator on get_settings() ensures the .env file is parsed exactly once.

database.py owns the SQLite schema definition and provides two functions: init_db() for one-time schema creation at startup, and get_db() as an async context manager for per-request database connections.

core/encoding.py implements the XOR-with-repeating-key cipher plus Base64 wrapping. The encode() function takes a plaintext string and a key, XORs the UTF-8 bytes against the key bytes (cycling the key), and returns a Base64 ASCII string. The decode() function reverses the process. This is symmetric, so the same key works in both directions.

core/protocol.py defines the Message envelope (a Pydantic model with a type field and a payload dict) and the MessageType enum (REGISTER, HEARTBEAT, TASK, RESULT, ERROR). The pack() function serializes a Message to JSON and encodes it with XOR+Base64. The unpack() function decodes a raw string, parses the JSON, and validates it back into a Message. If any step fails (bad Base64, bad JSON, bad schema), it raises a ValueError.

core/models.py defines the Pydantic data models used across the system: BeaconMeta (registration payload), BeaconRecord (full database record), TaskRequest (operator submission), TaskRecord (persisted task), and TaskResult (beacon response). It also defines CommandType as a StrEnum listing all supported commands: shell, sysinfo, proclist, upload, download, screenshot, keylog_start, keylog_stop, persist, and sleep.

beacon/registry.py is the BeaconRegistry class. It maintains a dict of beacon_id -> WebSocket for active connections and provides methods to register, unregister, check active status, update heartbeat timestamps, and query the database for beacon records. When a beacon registers, it upserts to the beacons table (INSERT with ON CONFLICT DO UPDATE), so reconnecting beacons update their metadata without creating duplicates.

beacon/router.py defines the /ws/beacon WebSocket endpoint. This is the most complex piece of the backend. When a beacon connects, the handler accepts the WebSocket, waits for a REGISTER message, validates it, registers the beacon, then spawns two concurrent asyncio tasks: _send_tasks (which blocks on the beacon's task queue and sends TASK messages) and _receive_messages (which processes incoming HEARTBEAT and RESULT messages). The asyncio.wait with FIRST_COMPLETED pattern ensures that if either coroutine fails (usually because the WebSocket disconnected), the other gets cancelled cleanly.

beacon/tasking.py is the TaskManager class. It maintains a dict of beacon_id -> asyncio.Queue[TaskRecord] for per-beacon task delivery. When an operator submits a task, submit() writes it to SQLite and puts it on the queue. The _send_tasks coroutine in the beacon router calls get_next(), which blocks until a task appears. When a result comes back, store_result() writes it to the task_results table and marks the parent task as completed.

ops/manager.py is the OpsManager class. It tracks a set of operator WebSocket connections and provides a broadcast() method that sends a JSON event to all connected operators. If a send fails (connection already closed), the stale WebSocket is removed from the set. This fan-out pattern means every operator sees every event in real time.

ops/router.py defines two routers: ws_router for the /ws/operator WebSocket endpoint, and rest_router for the REST endpoints (/beacons, /beacons/{id}, /beacons/{id}/tasks). When an operator connects via WebSocket, the handler sends the full beacon list immediately, then enters a receive loop waiting for submit_task messages. The REST endpoints provide the same data for non-WebSocket clients or for initial page loads.


Data Flow

Beacon Registration Flow

Here is what happens from the moment a beacon starts up to when the operator sees it on their dashboard:

Beacon                    Server                    Operator Dashboard
  │                         │                              │
  │──REGISTER(sysinfo)────>│                              │
  │                         │──INSERT beacon──> SQLite     │
  │                         │                              │
  │                         │──beacon_connected──────────>│
  │                         │                              │
  │<──(connection held)────│                              │
  │                         │                              │
  │──HEARTBEAT────────────>│                              │
  │                         │──UPDATE last_seen─> SQLite   │
  │                         │──heartbeat────────────────>│

Step by step:

  1. The beacon opens a WebSocket to /api/ws/beacon (which nginx proxies to the backend at /ws/beacon).
  2. The beacon sends a REGISTER message containing system metadata: hostname, OS, username, PID, internal IP, and architecture. This payload goes through XOR+Base64 encoding before hitting the wire.
  3. The server's beacon_websocket handler in beacon/router.py accepts the connection, unpacks the first message, validates it as a REGISTER type, and extracts the BeaconMeta fields.
  4. The server calls registry.register(), which stores the WebSocket reference in memory and upserts the beacon record into SQLite. If this beacon ID was seen before (a reconnection), the metadata gets updated and first_seen is preserved.
  5. The server broadcasts a beacon_connected event to all operator WebSocket connections via ops_manager.broadcast().
  6. The operator dashboard's Zustand store receives the event, calls upsertBeacon(), and the dashboard table re-renders with the new beacon showing a green "active" indicator.
  7. The server spawns the _send_tasks and _receive_messages coroutines. The connection is now held open indefinitely.
  8. Every few seconds (with jitter), the beacon sends a HEARTBEAT message. The server updates last_seen in SQLite and broadcasts the heartbeat to operators, keeping the "last seen" timestamp fresh on the dashboard.

Task Execution Flow

Here is the full round trip when an operator issues a command:

Operator                  Server                    Beacon
  │                         │                         │
  │──submit_task──────────>│                         │
  │                         │──INSERT task──> SQLite  │
  │                         │──queue.put(task)        │
  │<──task_submitted───────│                         │
  │                         │                         │
  │                         │──TASK(XOR+B64)────────>│
  │                         │                         │──execute
  │                         │<──RESULT(XOR+B64)──────│
  │                         │──INSERT result─> SQLite │
  │                         │──UPDATE task status     │
  │<──task_result──────────│                         │

Step by step:

  1. The operator types a command in the session terminal UI and hits enter. The frontend's sendTask() function sends a JSON message over the operator WebSocket: {"type": "submit_task", "payload": {"beacon_id": "...", "command": "shell", "args": "whoami", "local_id": "..."}}. This message is plain JSON because the operator-server channel runs entirely within Docker.
  2. The server's operator_websocket handler in ops/router.py receives the message, creates a TaskRecord with a UUID, and calls task_manager.submit().
  3. submit() does two things: it INSERTs the task into the tasks table in SQLite (so it persists if the server restarts), and it puts the TaskRecord onto the asyncio.Queue for that specific beacon.
  4. The server sends a task_submitted response back to the operator with the mapping of local_id to task_id. The frontend uses this to correlate results later.
  5. Meanwhile, in beacon/router.py, the _send_tasks coroutine for this beacon has been blocked on task_manager.get_next(beacon_id). The queue.put() unblocks it. The coroutine packs the task into a Message, encodes it with XOR+Base64, and sends it to the beacon over the WebSocket.
  6. The beacon receives the raw text, unpacks it (Base64 decode, XOR decrypt, JSON parse), and dispatches the command to the appropriate handler. For a shell command, that means calling asyncio.create_subprocess_shell() and capturing stdout/stderr.
  7. The beacon packs the result (task_id, output, error) into a RESULT message, encodes it, and sends it back to the server.
  8. The server's _receive_messages coroutine receives the RESULT, unpacks it, creates a TaskResult object, and calls task_manager.store_result(). This writes the result to the task_results table and updates the parent task's status to completed.
  9. The server broadcasts a task_result event to all operator WebSocket connections.
  10. The operator dashboard's Zustand store receives the event, calls addTaskResult(), and the session terminal renders the output.

The entire round trip typically takes less than a second on a local network. The bottleneck is usually the command execution on the target machine.


Protocol Design

Message Types

Every message between the beacon and server follows the same envelope structure: a type string and a payload dictionary. Here are all five message types:

Type Direction Payload Fields Purpose
REGISTER Beacon -> Server hostname, os, username, pid, internal_ip, arch, id Initial handshake when beacon first connects
HEARTBEAT Beacon -> Server id Keep-alive signal, updates last_seen timestamp
TASK Server -> Beacon id, command, args Delivers a command for the beacon to execute
RESULT Beacon -> Server task_id, output, error Returns command output back to the server
ERROR Either direction message Reports a protocol or execution error

The Message model in backend/app/core/protocol.py uses a Pydantic BaseModel with type: MessageType (a StrEnum) and payload: dict[str, Any]. The StrEnum means the type field is validated against the known set of message types during unpacking. If a beacon sends a message with "type": "BOGUS", Pydantic's validation catches it and the server raises a ValueError.

Encoding Pipeline

The beacon-server channel and the operator-server channel use different encoding strategies:

                    Beacon <-> Server
JSON string -> UTF-8 bytes -> XOR(key) -> Base64 -> WebSocket text frame

                    Server <-> Operator
JSON string -> WebSocket text frame (no encoding)

The beacon-server channel runs XOR+Base64 encoding on every message. The encode() function in backend/app/core/encoding.py takes a plaintext JSON string, converts it to UTF-8 bytes, XORs each byte against the corresponding byte of the repeating key, and Base64-encodes the result into an ASCII string suitable for a WebSocket text frame. The decode() function reverses this: Base64 decode, XOR with the same key, UTF-8 decode.

The operator-server channel sends plain JSON. No encoding at all.

Why the difference? The beacon-server channel simulates encrypted C2 communications. In a real C2 framework, this channel would use AES-256 or ChaCha20 with a per-session key negotiated through an asymmetric key exchange. Our XOR cipher is a placeholder that demonstrates the concept without the complexity of real cryptography. It shows where encoding happens in the pipeline, how symmetric keys work, and why you need the same key on both sides.

The operator-server channel skips encoding because the operator is a trusted party operating on the same infrastructure. The operator dashboard connects through nginx on the same Docker network. There is no untrusted network between them, so encoding the messages would add latency and complexity with no security benefit. In a production C2, you would still want TLS on this channel, but the application-layer encoding that protects beacon traffic is unnecessary for operator traffic.

Why XOR and Not AES

XOR with a repeating key is one of the weakest ciphers that exists. It is vulnerable to known-plaintext attacks, frequency analysis, and key recovery if you know any part of the plaintext. We chose it deliberately because:

  • It is easy to understand. You can trace through the encoding by hand with a calculator.
  • It is symmetric. The same function encrypts and decrypts.
  • It demonstrates the full pipeline. The encode/decode/pack/unpack functions would have the same signatures with AES.
  • Swapping it for a real cipher later requires changing exactly two functions in encoding.py and their equivalents in the beacon.

The key is configured in .env as XOR_KEY and loaded through Pydantic Settings in backend/app/config.py. The beacon reads it from the C2_XOR_KEY environment variable. Both sides must have the same key, which is the fundamental property of symmetric encryption.


Database Schema

The database has three tables with foreign key relationships:

beacons (id PK) <--- tasks (beacon_id FK) <--- task_results (task_id FK)

beacons

CREATE TABLE IF NOT EXISTS beacons (
    id          TEXT PRIMARY KEY,
    hostname    TEXT NOT NULL,
    os          TEXT NOT NULL,
    username    TEXT NOT NULL,
    pid         INTEGER NOT NULL,
    internal_ip TEXT NOT NULL,
    arch        TEXT NOT NULL,
    first_seen  TEXT NOT NULL,
    last_seen   TEXT NOT NULL
);

Each row is a beacon that has connected at least once. The id is a UUID generated by the beacon on first run. first_seen is set on initial registration and never updated. last_seen is updated on every heartbeat and on disconnect. The remaining columns capture the system metadata the beacon collected during registration.

Registration uses INSERT with ON CONFLICT(id) DO UPDATE, so if a beacon reconnects with the same ID, the metadata gets refreshed but first_seen is preserved from the original row.

tasks

CREATE TABLE IF NOT EXISTS tasks (
    id           TEXT PRIMARY KEY,
    beacon_id    TEXT NOT NULL,
    command      TEXT NOT NULL,
    args         TEXT,
    status       TEXT NOT NULL DEFAULT 'pending',
    created_at   TEXT NOT NULL,
    completed_at TEXT,
    FOREIGN KEY (beacon_id) REFERENCES beacons(id)
);

Each row is a task submitted by an operator for a specific beacon. The id is a server-generated UUID. command is one of the CommandType enum values (shell, sysinfo, proclist, etc.). args is an optional string payload whose meaning depends on the command type. For a shell command, it is the shell command string. For an upload, it is a JSON blob with filename and content. For sleep, it is a JSON blob with interval and jitter.

status starts as 'pending' and transitions to 'completed' when a result comes back. created_at is set at submission time, completed_at is set when the result is stored.

task_results

CREATE TABLE IF NOT EXISTS task_results (
    id         TEXT PRIMARY KEY,
    task_id    TEXT NOT NULL UNIQUE,
    output     TEXT,
    error      TEXT,
    created_at TEXT NOT NULL,
    FOREIGN KEY (task_id) REFERENCES tasks(id)
);

Each row is the result of a completed task. The task_id foreign key is UNIQUE, enforcing a one-to-one relationship with the tasks table (each task produces exactly one result). output contains the command's stdout or structured JSON, depending on the command type. error contains stderr or an error message. Both can be NULL.

Why SQLite with WAL Mode

SQLite is the right choice for this project for several reasons:

Single server, single writer. Our C2 server is one process. There is no cluster of backend instances competing for database access. SQLite handles single-writer workloads perfectly.

WAL mode handles concurrent reads. The backend is async, so multiple coroutines might read the database simultaneously (an operator querying the beacon list while the server is processing a heartbeat). WAL mode allows concurrent readers without blocking, which the default rollback journal mode does not. The init_db() function in database.py enables WAL with PRAGMA journal_mode=WAL.

Zero configuration. SQLite is embedded in the Python process. There is no separate database server to configure, no connection pooling to tune, no network latency between the application and the database. The database is a single file at backend/data/c2.db.

Good enough for educational scope. A production C2 framework that needs to support multiple team servers, geographic distribution, or hundreds of concurrent beacons would use PostgreSQL. For a single-server educational project, SQLite keeps the focus on C2 architecture instead of database administration.


Design Decisions

Why WebSockets Over HTTP Polling

A C2 system needs bidirectional communication. The server needs to push tasks to beacons as soon as an operator submits them, and beacons need to push results back as they complete. There are a few ways to achieve this:

HTTP polling means the beacon sends a GET request every N seconds asking "do you have a task for me?" This works, and many real C2 frameworks use it because HTTP blends into normal web traffic. The downside is latency: if the polling interval is 30 seconds, a task might wait up to 30 seconds before the beacon picks it up.

HTTP long polling improves latency by having the server hold the request open until a task is available. This is better, but managing held connections adds complexity and each pending request consumes a server thread or connection slot.

WebSockets give us a persistent, full-duplex connection. The server can push a task to the beacon the instant it arrives, with zero polling latency. The beacon can send heartbeats and results at any time without opening new connections.

We chose WebSockets because they simplify the architecture. One connection, bidirectional, always open. The trade-off is operational security: a persistent WebSocket connection to a fixed endpoint (/api/ws/beacon) is easier for network security tools to detect than periodic HTTP requests that look like normal web browsing. A production C2 would offer both options and let the operator choose based on the target environment.

Why SQLite Over PostgreSQL

Covered in the database schema section above. The short version: single server, single writer, WAL handles concurrent reads, zero configuration, appropriate for the educational scope.

Why Zustand Over React Context

The frontend state management uses Zustand instead of React Context. There are concrete reasons for this:

No provider wrapper. React Context requires wrapping your component tree in a <Provider>. Zustand stores are module-level singletons. You import useC2Store and call it. This keeps the component tree clean and avoids provider nesting issues when you have multiple stores.

Selector-based subscriptions. With React Context, any update to the context value re-renders every component that consumes it. Zustand lets components subscribe to specific slices of state. The dashboard only subscribes to the beacons object. The session page only subscribes to task results. When a heartbeat updates a beacon's last_seen, the session page does not re-render.

useShallow for derived arrays. The useBeacons selector in frontend/src/core/ws.ts uses useShallow to wrap Object.values(s.beacons). Without useShallow, Object.values() would create a new array reference on every store update, causing the component to re-render in an infinite loop even if the actual beacon data has not changed. useShallow performs a shallow comparison of the array elements and only returns a new reference if the contents actually differ.

DevTools support. The store is wrapped in Zustand's devtools middleware with name: 'C2Store'. This means you can open the Redux DevTools browser extension and inspect every state change, which is invaluable when debugging WebSocket message handling.

Why Per-Beacon Task Queues

The TaskManager in backend/app/beacon/tasking.py maintains a separate asyncio.Queue for each beacon ID. When an operator submits a task for beacon A, it goes into beacon A's queue. Beacon B's queue is unaffected.

This design has several benefits:

Independent delivery. If beacon A is slow or disconnected, tasks for beacon B are not blocked. Each beacon's _send_tasks coroutine in beacon/router.py blocks on its own queue independently.

Clean disconnection handling. When a beacon disconnects, task_manager.remove_queue(beacon_id) drops the queue and any pending tasks in it. This prevents memory leaks from accumulating tasks for dead beacons.

Simple ordering. asyncio.Queue is FIFO, so tasks for a given beacon are delivered in the order they were submitted. There is no need for priority logic or sorting.

The alternative would be a single shared queue with filtering, or a database polling approach where the _send_tasks coroutine queries SQLite for pending tasks. The per-beacon queue approach is simpler, faster (no database queries in the hot path), and naturally maps to the one-coroutine-per-beacon architecture.

Why Dual Coroutine Pattern (send + receive)

The beacon WebSocket handler in backend/app/beacon/router.py runs two coroutines concurrently for each beacon connection:

  • _send_tasks blocks on task_manager.get_next() and sends TASK messages to the beacon.
  • _receive_messages blocks on ws.receive_text() and processes HEARTBEAT and RESULT messages from the beacon.

These run under asyncio.wait with return_when=FIRST_COMPLETED:

done, pending = await asyncio.wait(
    [send_task, recv_task],
    return_when=asyncio.FIRST_COMPLETED,
)

for task in pending:
    task.cancel()

This pattern is the standard way to handle bidirectional WebSocket communication in asyncio. Here is why both coroutines are necessary:

_send_tasks must block independently. It needs to await the task queue, which could take minutes or hours if no operator submits a task. You cannot interleave this with receiving messages in the same coroutine without complicated select/poll logic.

_receive_messages must block independently. It needs to await the next WebSocket frame, which could be a heartbeat every few seconds or a result after a long-running command.

FIRST_COMPLETED handles disconnection. If the beacon disconnects, ws.receive_text() raises WebSocketDisconnect, which terminates _receive_messages. The asyncio.wait returns immediately, the pending _send_tasks coroutine gets cancelled, and the finally block in the main handler cleans up the registry and task queue. The reverse works too: if _send_tasks fails (perhaps because ws.send_text() raises on a dead connection), it completes first and _receive_messages gets cancelled.


Security Architecture

Threat Model

This is an educational project, so the threat model is intentionally limited. Here is what we protect against, what we simulate, and what we deliberately leave open:

What we simulate protecting against:

  • Network eavesdropping. The XOR+Base64 encoding on the beacon-server channel simulates transport encryption. If someone captures the WebSocket traffic, they see Base64 strings instead of plaintext JSON. Of course, XOR with a known-length repeating key is trivially breakable. The point is to demonstrate where encoding sits in the protocol stack, not to provide real confidentiality.
  • Message integrity. The unpack() function validates incoming messages against the Pydantic schema. A malformed or garbage message gets rejected with a ValueError. This prevents basic protocol confusion, though it does nothing against a sophisticated attacker who knows the protocol format.
  • Beacon identity. The shared XOR key acts as a weak form of authentication. If you do not have the key, your REGISTER message will not decode correctly and the server will reject it. This simulates the concept of pre-shared key authentication, even though the actual implementation is weak.

What we intentionally do not protect against:

  • Reverse engineering. The beacon is a plaintext Python script. Anyone with access to the file can read the source, extract the XOR key, and understand the protocol completely.
  • EDR/AV detection. The beacon makes no attempt to evade endpoint detection. It uses standard Python libraries, standard process names, and standard network calls.
  • Traffic analysis. The WebSocket endpoint is always /api/ws/beacon. The heartbeat interval is predictable (even with jitter). A network analyst could easily fingerprint this traffic.
  • Key extraction from memory. The XOR key sits in plaintext in the beacon's process memory. A memory dump reveals it immediately.
  • Operator authentication. Anyone who can reach the /api/ws/operator endpoint can connect as an operator and issue commands. There is no login, no token, no access control. This is an intentional simplification.

What a Production C2 Would Add

If you were building a real C2 framework (for authorized red team operations), you would add layers that we skip here:

TLS for transport encryption. All traffic would go over HTTPS/WSS. This provides real confidentiality and integrity at the transport layer, and it makes the C2 traffic blend in with normal HTTPS traffic.

Certificate pinning. The beacon would validate the server's TLS certificate against a pinned hash, preventing man-in-the-middle attacks even if the target environment has a corporate TLS interception proxy.

Asymmetric key exchange. Instead of a pre-shared XOR key, the beacon and server would negotiate a session key using RSA or ECDH. This provides forward secrecy: compromising one session key does not reveal past or future communications.

Operator authentication. The operator dashboard would require authentication (username/password, client certificates, or TOTP). Role-based access control would limit which operators can access which beacons.

Beacon staging. Instead of deploying the full beacon as a Python script, a production C2 uses a small "stager" that downloads the full payload from the server, decrypts it in memory, and executes it without touching disk. This reduces the file-based detection surface.

Malleable C2 profiles. Tools like Cobalt Strike let operators define "profiles" that reshape C2 traffic to look like legitimate web services (Slack API calls, Google Analytics beacons, etc.). This defeats signature-based network detection.

Domain fronting or CDN hiding. The beacon connects to a legitimate CDN domain (like cloudfront.net), and the CDN routes the request to the actual C2 server based on the Host header. From the network's perspective, the beacon is just talking to a CDN.


Deployment Architecture

Development Stack

The development stack is defined in dev.compose.yml and optimized for live editing with hot reload on every component:

dev.compose.yml
├── nginx        (nginx:1.27-alpine, port 47430)
│   └── volumes: dev.nginx config (read-only)
├── frontend     (Vite dev server, port 5173)
│   └── volumes: ./frontend mounted, node_modules in named volume
└── backend      (uvicorn --reload, port 8000)
    └── volumes: ./backend mounted, .venv in named volume

Nginx uses the stock nginx:1.27-alpine image with the development config mounted in. It proxies to both the Vite dev server and the FastAPI backend.

Frontend builds from infra/docker/vite.dev and runs pnpm dev with Vite's HMR (Hot Module Replacement) server on port 5173. The source directory ./frontend is bind-mounted into the container, so any file change on your host triggers an instant browser refresh. The node_modules directory lives in a Docker named volume (frontend_modules) to avoid platform-specific binary conflicts between your host OS and the container's Linux.

Backend builds from infra/docker/fastapi.dev and runs uvicorn with --reload enabled. The source directory ./backend is bind-mounted, so Python file changes trigger an automatic server restart. The .venv directory lives in a named volume (backend_cache) for the same platform isolation reason. Environment variables set ENVIRONMENT=development, DEBUG=true, and RELOAD=true.

The development compose uses Docker's depends_on with health checks to ensure the backend is ready before nginx starts proxying to it. The backend health check hits http://localhost:8000/health every 10 seconds with a 30-second startup grace period.

Production Stack

The production stack is defined in compose.yml and differs from development in several ways:

compose.yml
├── nginx        (multi-stage build, port 47430)
│   └── Built frontend static files baked into image
└── backend      (gunicorn, port 8000)
    └── SQLite data in named volume (c2_data)

Nginx uses a multi-stage build defined in infra/docker/frontend-builder.prod. The first stage installs pnpm, runs pnpm build, and produces the static frontend files. The second stage copies those files into an nginx image with the production config (prod.nginx). There is no separate frontend container in production. Nginx serves the built files directly, which is faster and uses fewer resources.

Backend builds from infra/docker/fastapi.prod and runs with ENVIRONMENT=production, DEBUG=false, RELOAD=false. The SQLite database lives in a Docker named volume (c2_data) so it persists across container restarts. Resource limits are set: the backend gets up to 2 CPUs and 1GB RAM, nginx gets up to 1 CPU and 256MB RAM.

The production nginx config (prod.nginx) adds security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy), aggressive caching for static assets (1 year for /assets/ with immutable), and tighter rate limits (burst=20 vs burst=50 in dev).


Key Files Reference

Quick lookup table mapping concepts to their implementation files:

Concept File
XOR encoding backend/app/core/encoding.py
Protocol messages backend/app/core/protocol.py
Data models backend/app/core/models.py
Beacon WebSocket handler backend/app/beacon/router.py
Task queue management backend/app/beacon/tasking.py
Beacon registry backend/app/beacon/registry.py
Operator REST + WebSocket backend/app/ops/router.py
Operator broadcast manager backend/app/ops/manager.py
App factory + lifespan backend/app/__main__.py
Configuration (Pydantic Settings) backend/app/config.py
Database schema + connection backend/app/database.py
Beacon implant beacon/beacon.py
Frontend Zustand store + WebSocket frontend/src/core/ws.ts
Frontend Zod schemas + types frontend/src/core/types.ts
Frontend route + endpoint config frontend/src/config.ts
Nginx dev config infra/nginx/dev.nginx
Nginx prod config infra/nginx/prod.nginx
Nginx shared base config infra/nginx/nginx.conf
Development Docker Compose dev.compose.yml
Production Docker Compose compose.yml

Next up: 03-PROTOCOL-DEEP-DIVE.md will step through the encoding pipeline byte by byte, show you XOR in action with real data, and explain how to break it.