Skip to content

Latest commit

 

History

12 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

                   _                  _   _           ___ ____  
  /\/\   __ _ _ __(_) ___  _ __   ___| |_| |_ ___    / __\___ \ 
 /    \ / _` | '__| |/ _ \| '_ \ / _ \ __| __/ _ \  / /    __) |
/ /\/\ \ (_| | |  | | (_) | | | |  __/ |_| ||  __/_/ /___ / __/ 
\/    \/\__,_|_|  |_|\___/|_| |_|\___|\__|\__\___(_)____/|_____|

Marionette.C2

Docker Swarm based C2 infrastructure for red teaming.

Abstract

Most C2 frameworks treat their own infrastructure as an afterthought. You get one teamserver, a couple of hand-rolled redirectors, and a Slack thread full of IPs to remember. When something falls over mid-op, you're rebuilding by hand at 2am. Marionette runs the whole stack as Docker Swarm services. Listeners, the teamserver, the database, and the operator dashboard are all services on segmented networks. A listener can come up or down without taking the rest of the infrastructure with it. If one gets burned, you scale it to zero and bring up a fresh one on a different transport.It's built for lab work, training environments, and authorized red team engagements. The naming is theatrical because the metaphor actually fits how an op runs. A puppeteer pulls strings on dolls across a stage. Scenes are campaigns. Whispers are the quiet channels. It's also more fun than agent_handler_v2. It totally wasn't a cool name i decided to base the entire C2 around lol.

I'm not claiming the world needs another C2. Sliver, Mythic, and Havoc all exist and are excellent. Marionette is a research project for me to learn distributed C2 design properly, with the swarm-native architecture as the actual contribution.

Shorthand

  • Dolls -> Hosts / agents / implants
  • Strings -> Commands / taskings issued to Dolls
  • Puppeteer -> Operator / controller
  • Stage -> Target environment or engagement scope
  • Theater -> Swarm cluster / full deployment environment
  • Scenes -> Campaigns, operations, or task groups
  • Props -> Payloads, tools, or uploaded files
  • Whispers -> Low-noise / covert communications channel
  • Cue -> Triggered action / automation event

Roadmap

  • Operator Dashboard
  • Distributed listeners (HTTP/HTTPS)
  • Task queue for agent commanding
  • Agent registration and beacon handling
  • Swarm service deployment for C2 modules
  • Encrypted task/result transport
  • Campaign / host grouping ("Scenes")
  • File transfer / payload staging ("Props")
  • Audit logging and operator action history
  • Multi-network segmentation (listener net / operator net / storage net)
  • Role-based access control for operators
  • TCP/DNS listeners
  • Blue-team-safe lab mode / simulation mode

Architecture

Four Go binaries, a shared library, a React dashboard, and a Docker Swarm stack that wires them together.

Components

Teamserver

The brain. Owns the sqlite database, the in-memory theater (live doll session store), the audit log, the scenes store, the operator roster, and two HTTP surfaces: a whisper endpoint for dolls and a JSON API for operators. Every operator API route is gated on the caller's role. State is kept in-memory for hot paths (beacon handling, operator reads) and written through to sqlite for durability. Restarting the teamserver rehydrates the theater from the DB, so sessions and queued taskings survive a bounce.

Listener

The doll-facing edge. A stateless HTTP reverse proxy that terminates TLS, reads bytes from the doll, forwards them to teamserver's /whisper endpoint on the internal overlay network, and relays the response. Knows nothing about the protocol. Runs as a swarm service with multiple replicas behind the ingress mesh. If one gets burned, scale it to zero and bring up a fresh one; teamserver never notices.

Puppet

The operator client. Connects to teamserver's operator API and drops into an interactive REPL. Commands: dolls (list), use (select by uuid or prefix), sh (shell command on the doll), cd, pwd, whoami, sleep (retask beacon interval), die (exit the doll), name (rename a doll), scenes (list/create/delete scenes, add/remove dolls), push/pull (file transfer), audit (view audit trail). Operator identity comes from the token itself, and what the REPL is allowed to do depends on the role behind it.

Doll

The agent. Runs on a target host, registers with the teamserver via X25519 key exchange, then enters a beacon loop: sleep, POST to the listener, pick up pending strings, execute them, and ship results back on the next beacon. Supports shell commands, cd/pwd/whoami, sleep retasking, exit, and file push/pull. Anti-RE checks (hypervisor detection and timing-based debugger detection) run at startup before registration.

Whisper

The covert relay. Placeholder for now. The current design targets a DNS-style channel using the base32-DNS encoder in shared/util, but the relay layer is general enough to host other low-noise transports later.

Shared

Holds the parts everyone needs.

  • crypto has AES-256-GCM AEAD and X25519 key exchange (generate, derive shared key, encrypt/decrypt).
  • util has DNS-safe base32 encoding and a chunker for transports with small frames.
  • protocol defines the doll-to-teamserver wire format. Feature-complete for v1: length-prefixed framing, Register/RegisterAck handshake with sealed initial config, Beacon/BeaconAck round-trip, eight String types (Shell, Sleep, Exit, Cd, Pwd, Whoami, PropPush, PropPull), and prop payload encoding. See shared/protocol/README.md for the wire diagrams.

Dashboard

A React 19 + Vite + TypeScript app for operators who don't want to live in puppet's terminal. Three pages: Dolls (list, detail panel, bulk tasking, rename, health indicators), Scenes (create/delete, member management), and Audit (full event trail, filterable by doll). Polls teamserver's operator API. Controls the signed-in role can't use are greyed out, and the sidebar shows who you are and what role you hold. Dark-only, monochrome plus magenta accent.

How it fits together

A doll on a compromised host beacons to a listener. The listener is a swarm service running in the listener network. It terminates TLS, forwards the raw body to teamserver on the internal network, and relays the response. Teamserver decrypts, queues whatever the doll sent (output, file chunk, beacon heartbeat), and hands back any pending strings on the way out.

Operators sit on the operator network, which can't reach the listener network directly. Puppet and the dashboard both hit teamserver's operator API through an nginx sidecar that publishes the port on loopback only. Teamserver is the only thing bridging the two sides.

The whole stack comes up as a swarm deployment. Scaling listeners, killing a burned one, or rotating transports is a docker service command instead of a manual rebuild.

Network segmentation

Three overlay networks:

  • Internal carries teamserver-to-listener traffic. Not routable outside the swarm.
  • Listener carries beacon traffic from dolls to listener replicas. Published on the swarm ingress mesh.
  • Operator carries operator API calls and dashboard traffic. Not routable outside the swarm. An nginx sidecar publishes 127.0.0.1:9000 back to the host for local access.

If something goes wrong on the listener side, the blast radius stops at that boundary.

Running it

Prerequisites

  • Go 1.25 (the Makefile expects it at $HOME/sdk/go/bin/go)
  • Bun (expected at $HOME/.bun/bin/bun)
  • Docker with swarm mode (docker swarm init once)
  • garble for release builds (go install mvdan.cc/garble@latest, expected at $HOME/go/bin/garble)

Local dev (no Docker)

Build everything:

make build

Generate a self-signed cert for the listener:

make cert

Start the teamserver (it creates teamserver.db, teamserver.key, and teamserver.optok on first run and logs the bootstrap token once):

bin/teamserver

Add yourself as an operator and retire the bootstrap token. See Operator roles for what the roles mean:

bin/teamserver operator add $USER -role director
rm teamserver.optok

Start a listener pointing at the local teamserver:

MARIONETTE_TEAMSERVER_URL=http://127.0.0.1:8443 \
MARIONETTE_LISTEN=:8443 \
MARIONETTE_TLS_CERT=docker/secrets/listener.crt \
MARIONETTE_TLS_KEY=docker/secrets/listener.key \
bin/listener-http

Start a doll pointing at the listener:

MARIONETTE_WHISPER_URL=https://127.0.0.1:8443/whisper \
MARIONETTE_TLS_INSECURE_SKIP=1 \
bin/doll

Connect puppet to the teamserver. Export the token operator add printed for you. If you skipped that and the bootstrap token is still live, puppet reads teamserver.optok from its cwd automatically:

MARIONETTE_TEAMSERVER=http://127.0.0.1:9000 \
bin/puppet

Run the dashboard dev server. The first request prompts for an operator token; paste the one operator add gave you and it's cached in localStorage:

make dev-dashboard

Docker Swarm

Build the container images (teamserver and listener):

make docker-images

Generate TLS certs if you haven't already:

make cert
# or for a real hostname:
CERT_HOST=beacons.example.com make cert

Deploy the stack:

make stack-up

This brings up teamserver (1 replica), listener (2 replicas), and an nginx sidecar that publishes the operator API on 127.0.0.1:9000. Doll-facing TLS is on port 8443 via swarm ingress.

Tear it down:

make stack-down

Tail logs:

make stack-logs

Operator roles

Every operator gets their own bearer token and one of three roles. The names follow the rest of the metaphor: a director decides who is in the production, a puppeteer works the strings, an audience member watches.

director puppeteer audience
List dolls, read results, read scenes yes yes yes
Read the audit trail yes yes yes
Task dolls, rename them yes yes no
Create and edit scenes yes yes no
Push and pull props yes yes no
See the operator roster yes no no

Roles are checked per route on the teamserver, not in the client, so a modified puppet or a hand-rolled curl gets the same answer. GET /api/whoami returns the caller's role and permission list. The dashboard reads it at load and greys out whatever the role can't reach, with a line saying why. That's an affordance, not a control: the server rejects the request either way.

The roster is managed from the CLI rather than the API. Minting a token needs filesystem access to the teamserver's sqlite file, which is the same bar as reading teamserver.key:

bin/teamserver operator add odessa -role director
bin/teamserver operator ls
bin/teamserver operator role wren puppeteer
bin/teamserver operator disable kite
bin/teamserver operator rm kite

add prints the token on stdout exactly once. There is no way to recover it afterward, only to rm the operator and add them again. All six commands work while the teamserver is running, and take effect on the operator's next request: sqlite is in WAL mode and auth reads the table per call, so revoking someone mid-op does not need a restart.

Prefer disable over rm. A disabled row keeps the name resolvable, so old audit entries still point at a real operator instead of a dangling string.

The bootstrap token

teamserver.optok is the way in before any operator rows exist. It has director permissions and no identity, so its actions land in the audit log under whatever X-Operator the client sent. Once real operators exist, delete the file: the teamserver stops recreating it and logs bootstrap token retired. Until then it keeps working and the startup log nags about it.

X-Operator is only ever a fallback for that anonymous token. A per-operator token carries its own name and the header is ignored, so an operator cannot write someone else's name into the trail.

Environment variables

Teamserver

Variable Default Purpose
MARIONETTE_WHISPER_ADDR :8443 Whisper (doll-facing) listen address
MARIONETTE_OPERATOR_ADDR 0.0.0.0:9000 Operator API listen address
MARIONETTE_DB teamserver.db Sqlite database path
MARIONETTE_KEYFILE teamserver.key Server X25519 keypair file
MARIONETTE_OPERATOR_TOKEN_FILE teamserver.optok Bootstrap bearer token file. Generated only while the operator roster is empty, printed once in the startup log. Delete it to retire the shared token once real operators exist.
MARIONETTE_INIT_SLEEP_MS 5000 Default beacon interval for new dolls
MARIONETTE_INIT_JITTER_PCT 20 Default jitter percentage

Doll

Variable Default Purpose
MARIONETTE_WHISPER_URL http://127.0.0.1:8443/whisper Listener endpoint
MARIONETTE_DOLL_UUID random 32 hex chars, persistent identity
MARIONETTE_PIN (from build) Server pubkey hex, overrides ldflags
MARIONETTE_TLS_INSECURE_SKIP off Accept any TLS cert (lab only)

Puppet

Variable Default Purpose
MARIONETTE_TEAMSERVER http://127.0.0.1:9000 Operator API base URL
MARIONETTE_OPERATOR $USER Operator name for the audit trail
MARIONETTE_OPERATOR_TOKEN (from file) This operator's token from teamserver operator add, or the bootstrap token. Falls back to reading MARIONETTE_OPERATOR_TOKEN_FILE (default teamserver.optok) so puppet + teamserver in the same cwd need no extra config.
MARIONETTE_OPERATOR_TOKEN_FILE teamserver.optok Path puppet reads when the env var is unset.

Listener

Variable Default Purpose
MARIONETTE_TEAMSERVER_URL (required) Upstream teamserver URL
MARIONETTE_LISTEN :8443 Listen address
MARIONETTE_TLS_CERT (required for TLS) Path to TLS cert
MARIONETTE_TLS_KEY (required for TLS) Path to TLS key

Pinned doll builds

Each released doll can pin a specific teamserver pubkey so it rejects impersonation. The pin is injected at build time:

# Derive pin from the teamserver's keyfile:
make release-doll TEAMSERVER_KEY=teamserver.key

# Or pass a hex pubkey directly:
make release-doll PIN=<64-hex-chars>

An unpinned doll (dev default) trusts on first use.

Anti-RE for dolls

Dolls run on hosts that don't belong to us. The doll binary should be annoying to reverse, the whisper relay similarly. Teamserver and puppet stay clean because they sit on operator infrastructure.

Release hardening

make release builds the hardened path:

  • garble -literals -tiny hashes function/type/package names, XORs string constants, strips Go runtime tells (panic strings, build IDs, module paths)
  • -trimpath drops filesystem paths from the binary
  • -ldflags "-s -w" strips the symbol table and DWARF info

Garble uses a fresh random seed per build, so signatures from one artifact don't match the next. Set GARBLE_SEED=... for a reproducible build.

make build stays unhardened for development.

Runtime checks

Two checks run at doll startup, before registration:

Hypervisor detection. CPUID leaf 1 ECX bit 31 is the "hypervisor present" bit. If set, leaf 0x40000000 returns the vendor string (KVM, VMware, Hyper-V, Xen, QEMU/TCG). Implemented in hand-rolled amd64 assembly (doll/internal/anti/cpuid_amd64.s), no external deps. Falls back to a no-op on non-amd64.

Clock skew. Compares wall-clock (time.Now().Round(0)) against the Go runtime's monotonic clock (runtime.nanotime via go:linkname) across a sha256 workload. On a healthy host the two track within microseconds. A debugger single-stepping the process pauses the monotonic clock but leaves the wall clock running, producing tens-of-ms or worse skew. Threshold is 23ms (deliberately unround).

Policies

The doll doesn't act on detection signals directly. A per-build policy (injected via ldflags) decides what to do:

  • log (default) logs the signals and continues. Lab and dev builds.
  • noop drops the signal silently. For release builds that shouldn't leak the fact that they check.
  • stall sleeps for 24h + 137s on any suspicious signal. A dead process is more obvious than one that appears idle.

Project layout

marionette/
  teamserver/
    cmd/teamserver/       main, two HTTP servers (whisper + operator API), operator CLI
    cmd/listener-http/    stateless TLS reverse proxy
    internal/
      api/                operator JSON API + whisper handler + scenes + props
      audit/              append-only audit trail (sqlite)
      auth/               operator roster, roles, bearer-token middleware
      config/             env var parsing
      crypto/             server keypair loader
      db/                 sqlite init + migrations
      scenes/             campaign/group store
      theater/            in-memory doll session store
  puppet/
    cmd/puppet/           main, launches the REPL
    internal/
      client/             HTTP client for the operator API
      config/             env var parsing
      shell/              interactive REPL + command dispatch
  doll/
    cmd/doll/             main, register + beacon loop
    internal/
      anti/               hypervisor + timing checks, policy dispatch
      config/             env + ldflags config, pubkey pin
      executor/           string dispatch (shell, cd, pwd, whoami, sleep, exit, props)
      whisper/            HTTP transport to the listener
  whisper/                placeholder, not yet wired
  shared/
    crypto/               AES-256-GCM, X25519 key exchange
    protocol/             wire format, framing, handshake, beacon, strings, props
    util/                 base32-DNS encoder, chunker
  dashboard/              React 19 + Vite + TypeScript operator UI
  docker/
    Dockerfile.teamserver multi-stage, distroless final image
    Dockerfile.listener   multi-stage, distroless nonroot
    configs/              nginx operator-proxy config
    secrets/              gitignored, TLS material
    stack.yml             swarm compose
  scripts/
    gen-cert/             self-signed ed25519 cert generator for lab use
    pubkey/               derive hex pubkey from a teamserver keyfile

About

A Docker Swarm based C2 infrastructure

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages