An agent package is the most complex extension point. It defines a complete deployable C2 agent with its own cryptographic protocol, wire format, capability declarations, and build pipeline.
Before building a custom agent, study the dev agent reference implementation at dev_agent/src/tantoc2_dev_agent/. It is a fully functional agent package and is the primary reference for all interfaces described here.
Magic (4B): Routes to your AgentPackageBase. Must be unique across all packages.
Session Token (16B): All zeros on registration; assigned 16-byte random token thereafter.
Encrypted Payload: Everything after the 20-byte header. Passed verbatim to CryptoProvider.create_session() (registration) or CryptoProvider.decrypt() (normal messages).
The MessagePipeline reads the header, looks up the package by magic, and dispatches to the correct provider and codec.
# tantoc2/src/tantoc2/server/crypto_provider.py@dataclassclassCryptoSession:session_token:bytes# 16-byte unique tokenstate:str="handshake"# "handshake" or "established"session_data:dict[str,Any]=field(default_factory=dict)# your keys + stateclassCryptoProviderBase(PluginBase):@classmethoddefplugin_type(cls)->str:return"crypto_provider"@abstractmethoddefgenerate_keypair(self)->tuple[bytes,bytes]:"""Generate (public_key, private_key) for the server.
Called when stamping a new agent build."""@abstractmethoddefcreate_session(self,registration_data:bytes,server_private_key:bytes)->tuple[CryptoSession,bytes]:"""Process an agent registration request.
Called when session_token is all zeros.
Returns: (new CryptoSession, response bytes for the agent)"""@abstractmethoddefdecrypt(self,session:CryptoSession,data:bytes)->bytes:"""Decrypt inbound agent payload using session keys."""@abstractmethoddefencrypt(self,session:CryptoSession,data:bytes)->bytes:"""Encrypt outbound data for the agent."""@abstractmethoddefcomplete_handshake(self,session:CryptoSession,data:bytes)->CryptoSession:"""Finalize a multi-step handshake. Return session with state="established"."""@abstractmethoddefrotate_session_key(self,session:CryptoSession)->tuple[CryptoSession,bytes]:"""Rotate the session key. Returns (updated session, message for agent)."""
Reference: Dev Agent CryptoProvider (ECDH + AES-256-GCM)#
Anti-replay: The dev agent uses a monotonic counter prepended to each ciphertext. The decryption side checks that counter >= recv_counter. You can use nonce-based schemes, but you must ensure replay protection — the pipeline does not enforce this.
session_data: Store all mutable per-session state here. It is a plain dict, persisted as JSON in the engagement database for the session lifetime. It is shared between encrypt() and decrypt(), so use atomic operations on counters.
Thread safety: encrypt() and decrypt() may be called from multiple threads with the same CryptoSession. If session_data is mutated (e.g., incrementing counters), protect it.
The ProtocolCodecBase translates between raw decrypted bytes and InternalMessage.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# tantoc2/src/tantoc2/server/protocol_codec.pyclassProtocolCodecBase(PluginBase):@classmethoddefplugin_type(cls)->str:return"protocol_codec"@abstractmethoddefdecode(self,data:bytes)->InternalMessage:"""Decode decrypted bytes into an InternalMessage."""@abstractmethoddefencode(self,message:InternalMessage)->bytes:"""Encode an InternalMessage into bytes for encryption."""
Your codec maps between your wire format and these MessageType values. You must handle at minimum: REGISTER, CHECKIN, TASK_RESULT, TASK_ASSIGNMENT, SURVEY_RESULT.
The AgentPackageBase ties the magic bytes, crypto provider, and codec together. It also declares capabilities and optionally implements the build pipeline.
# tantoc2/src/tantoc2/server/agent_package.pyclassAgentPackageBase(PluginBase):@classmethoddefplugin_type(cls)->str:return"agent_package"# --- Required ---@classmethod@abstractmethoddefmagic_bytes(cls)->bytes:"""4-byte magic identifying this agent type in wire headers.
MUST be globally unique across all installed packages."""@classmethod@abstractmethoddefcrypto_provider_name(cls)->str:"""plugin_name() of the CryptoProvider plugin to use."""@classmethod@abstractmethoddefprotocol_codec_name(cls)->str:"""plugin_name() of the ProtocolCodec plugin to use."""# --- Capabilities (optional — override to enable) ---@classmethoddefsupported_module_formats(cls)->list[str]:"""Format identifiers this agent can load. Empty = no module loading."""return[]@classmethoddefbuilt_in_commands(cls)->list[str]:"""Built-in command names. Used for UI/CLI help text."""return[]@classmethoddefsupports_daemonize(cls)->bool:returnFalse@classmethoddefsupports_relay(cls)->bool:returnFalse@classmethoddefagent_modules_dir(cls)->Path|None:"""Path to bundled agent modules, or None."""returnNone@classmethoddefcapabilities(cls)->AgentCapabilities:returnAgentCapabilities(module_formats=cls.supported_module_formats(),built_in_commands=cls.built_in_commands(),supports_daemonize=cls.supports_daemonize(),supports_relay=cls.supports_relay(),)# --- Build pipeline (optional — override for buildable packages) ---@classmethoddefis_buildable(cls)->bool:returnFalse@classmethoddefget_templates(cls)->list[AgentTemplate]:return[]@classmethoddefget_config_schema(cls)->dict[str,Any]:return{}@classmethoddefstamp(cls,template_name:str,config:BuildConfig,crypto_material:CryptoMaterial)->bytes:raiseNotImplementedError(f"{cls.__name__} is not buildable")
The stamp() method produces a deployable agent binary with embedded configuration. Implement it however suits your agent format:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@classmethoddefstamp(cls,template_name:str,config:BuildConfig,crypto_material:CryptoMaterial,)->bytes:"""
Common approaches:
- Marker replacement: find a 256-byte 0xDEADBEEF block in the binary,
replace with an encrypted config blob.
- Append: concatenate encrypted config to the end of the binary.
- Template rendering: for interpreted agents (Python, PowerShell),
substitute config variables in a source template.
"""template=cls._load_template(template_name)config_blob=cls._encrypt_config(config,crypto_material)returncls._patch_binary(template,config_blob)
The dev agent uses Python source template stamping — it reads agent.py, strips the CLI entry point, embeds a base64-encoded encrypted config blob, and returns the patched Python source as bytes.
Managed mode (daemonize=false):
1. Allocate RWX memory
2. Copy module_data bytes into memory
3. Execute (e.g., via BOF loader or shellcode entry point)
4. Capture output and send as task_result
5. Respond to unload_module by freeing memory and cleaning up
Daemonized mode (daemonize=true):
1. Launch payload as independent process/thread
2. Do not wait for results
3. If payload registers: new agent appears with parent_agent_id set
1. Listen on local port for interior agent connections
2. For each interior message received:
send relay_forward { inner_message: base64(raw_wire_bytes) }
3. On checkin response:
if relay_responses in task_assignment:
forward each response to the corresponding interior agent
(identified by target_token)
Magic collision — if two packages share magic bytes, the pipeline routes all traffic to whichever was registered first. Choose 4 bytes that are unlikely to collide and document them. The dev agent uses \xde\xad\xc2\x01.
create_session() parsing — registration data is raw bytes starting right after the 20-byte header. The pipeline passes the full remainder to create_session(). For the dev agent pattern (agent_pub || codec_payload), the pipeline also tries to decode at offset 91 (P-256 DER key size). If you use a different key format, your create_session() must parse correctly from offset 0.
Thread-unsafe session mutation — session_data is mutated by both encrypt() and decrypt(). If the same session is used from multiple threads, use locks or atomic operations.
Missing capabilities() declaration — the built_in_commands list drives the UI command palette. If you declare commands the agent doesn’t implement, operators will see errors. If you don’t declare them, they won’t appear as options.
stamp() not idempotent — calling stamp() multiple times with the same inputs should produce functionally equivalent (not necessarily byte-identical) binaries. Avoid side effects.
See Plugin Packaging for the full packaging and distribution reference.