Skip to main content
  1. Documentation/
  2. Developer Guide/

Building Agent Modules

Table of Contents
Agent modules are compiled payloads that agents load and execute at runtime on target hosts. Unlike tool plugins (server-side Python), agent modules run inside the agent process on targets.
Not to be confused with tool plugins. Tool plugins (agentless modules) are Python classes running on the teamserver that connect to remote services directly. Agent modules are binary payloads loaded into C2 agents. See Extension Points for the full taxonomy.

Lifecycle Overview
#

Module Loading Lifecycle
  1. You create a compiled payload in a format your target agent supports
  2. You write a manifest.yaml describing the payload’s metadata and options
  3. You place both in a subdirectory under agent_modules/
  4. The AgentModuleRegistry discovers and validates the manifest on startup
  5. Operators select compatible modules for a specific agent
  6. The teamserver sends a load_module task with the base64-encoded payload
  7. The agent receives the payload and executes it in managed or daemonized mode

Creating a Module
#

Step 1: Write your payload
#

Create the compiled payload in whatever format your target agent supports. Common formats:

format valueExtensionDescription
bof.oBeacon Object File (COFF object)
shellcode.binPosition-independent shellcode
dll.dllWindows DLL with a specific export
coff.oCOFF object file
py.pyPython script (for the dev agent)

Any string works as a format identifier. The teamserver matches format against what the target agent declares in supported_module_formats(). A module will only appear in the compatible list if the format matches.

Step 2: Write manifest.yaml
#

Create manifest.yaml in the same directory as your payload:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Required fields
name: port_scan
description: TCP port scanner using raw sockets
author: Your Name
format: bof

# Target platforms (linux, windows, macos, any)
platforms:
  - windows
  - linux

# Target architectures (x64, x86, arm64, any)
architectures:
  - x64

# Optional fields
version: "1.2.0"
privilege_required: false
mitre_attack:
  - T1046

# Payload file (auto-detected if omitted — first non-YAML file in dir)
payload_file: port_scan_x64.o

# Options schema
options_schema:
  target:
    type: str
    description: Target IP or CIDR range
    required: true
  ports:
    type: str
    description: "Port range (e.g., 1-1024 or 80,443,8080)"
    required: false
    default: "1-1024"
  timeout_ms:
    type: int
    description: Connection timeout in milliseconds
    required: false
    default: 500

Manifest Reference
#

Required Fields
#

FieldTypeDescription
namestrUnique module name. Must be globally unique across all agent modules.
descriptionstrHuman-readable description shown in the UI and CLI.
authorstrAuthor name.
formatstrFormat identifier — must exactly match a value in the target agent’s supported_module_formats().

Optional Fields
#

FieldTypeDefaultDescription
platformslist[str][]Target platforms: windows, linux, macos. Empty = all platforms.
architectureslist[str][]Target architectures: x64, x86, arm64, any. Empty = all arches. any matches all.
versionstr"1.0.0"Module version string.
privilege_requiredbool | strfalseWhether elevated privileges are needed.
mitre_attacklist[str][]MITRE ATT&CK technique IDs.
payload_filestrauto-detectFilename of the payload relative to the module directory. If omitted, the registry picks the first non-YAML file in the directory.
options_schemadict{}Options operators can pass when loading the module (see below).

options_schema Format
#

Each key in options_schema is an option name. The value is a dict with these fields:

1
2
3
4
5
6
options_schema:
  my_option:
    type: str        # str, int, bool, float
    description: "What this option does"
    required: true   # or false
    default: null    # default value if not required

Step 3: Directory Layout
#

Place your module in a subdirectory of agent_modules/:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
agent_modules/
  port_scan/
    manifest.yaml
    port_scan_x64.o
  screenshot/
    manifest.yaml
    screenshot_x64.o
    screenshot_x86.o    # multiple architectures — use payload_file
  reverse_shell/
    manifest.yaml
    reverse_shell_x64.bin

Each subdirectory must contain a manifest.yaml (or manifest.yml). The registry scans all subdirectories recursively.

Compatibility Filtering
#

The AgentModuleRegistry uses AgentModuleDescriptor.is_compatible() to match modules to agents on three dimensions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def is_compatible(
    self,
    module_formats: list[str],  # from agent's supported_module_formats()
    platform: str | None = None,  # agent's OS
    arch: str | None = None,      # agent's arch
) -> bool:
    if self.format not in module_formats:
        return False
    if platform and self.platforms and platform not in self.platforms:
        return False
    if arch and self.architectures and arch not in self.architectures:
        if "any" not in self.architectures:
            return False
    return True

An empty platforms or architectures list in the manifest means “all” — the filter is skipped. any in architectures matches every agent architecture.

Loading Modes
#

Managed Mode (default, daemonize=false)
#

The agent:

  1. Allocates memory and loads the payload
  2. Executes it synchronously
  3. Returns results through its C2 channel as task_result messages
  4. Tracks the module lifecycle (running, completed, failed)
  5. Supports clean unload via an unload_module task

Managed mode is the default. Long-running modules can stream results across multiple check-in cycles.

Daemonized Mode (daemonize=true)
#

The agent:

  1. Launches the payload independently (does not wait for results)
  2. Loses the managed lifecycle — there is no unload_module

If the payload is a C2 agent itself (e.g., Shinobi shellcode), it:

  1. Performs its own registration handshake with the teamserver
  2. Registers as a brand-new agent
  3. The teamserver records the parent-child relationship for P2P topology

Requires the loading agent to declare supports_daemonize() = True.

Task Payloads
#

The teamserver sends these task types when managing agent modules.

load_module
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "task_type": "load_module",
  "payload": {
    "module_name": "port_scan",
    "module_data": "<base64-encoded payload bytes>",
    "module_format": "bof",
    "daemonize": false,
    "options": {
      "target": "10.0.0.0/24",
      "ports": "22,80,443",
      "timeout_ms": 500
    }
  }
}

unload_module
#

1
2
3
4
5
6
{
  "task_type": "unload_module",
  "payload": {
    "module_name": "port_scan"
  }
}

The agent handles these task types as declared in its built-in command list.

Module Status Tracking
#

The teamserver tracks each loaded module per agent:

StatusMeaning
runningModule is loaded and executing
completedModule finished normally
failedModule execution failed
unloadedModule was cleanly unloaded by operator

Python Modules for the Dev Agent
#

The dev agent supports format: py. Python modules must export a run(options) function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# agent_modules/my_scanner/my_scanner.py
import json
import socket

def run(options: dict) -> str:
    """Called by the dev agent with the options dict from the task payload."""
    target = options.get("target", "127.0.0.1")
    ports_raw = options.get("ports", "80,443")
    timeout_ms = int(options.get("timeout_ms", 500))

    open_ports = []
    for port_str in ports_raw.split(","):
        port = int(port_str.strip())
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.settimeout(timeout_ms / 1000)
            sock.connect((target, port))
            open_ports.append(port)
            sock.close()
        except OSError:
            pass

    return json.dumps({"target": target, "open_ports": open_ports})

With manifest:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
name: my_scanner
description: Basic TCP port scanner
author: Your Name
format: py
platforms:
  - linux
  - windows
architectures:
  - any
options_schema:
  target:
    type: str
    description: Target IP
    required: true
  ports:
    type: str
    description: Comma-separated port list
    required: false
    default: "80,443"
  timeout_ms:
    type: int
    description: Connection timeout in ms
    required: false
    default: 500

Reference: Dev Agent Bundled Modules
#

The dev agent ships three bundled Python modules at dev_agent/src/tantoc2_dev_agent/:

ModuleFormatDescription
hello_worldpyMinimal test — verifies managed module loading works
sysinfopyCollect system information (OS, hostname, interfaces)
port_checkpyCheck if specific ports are open on the local host

Study their manifests for working examples:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# dev_agent/src/tantoc2_dev_agent/hello_world/manifest.yaml
name: hello_world
description: Simple hello world module for testing managed module loading
author: TantoC2
format: py
platforms:
  - linux
  - windows
  - darwin
architectures:
  - any
version: "1.0.0"
payload_file: hello_world.py
mitre_attack: []

Bundling Modules with an Agent Package
#

Agent packages can ship bundled modules via agent_modules_dir():

1
2
3
4
5
6
7
8
9
class MyAgentPackage(AgentPackageBase):
    @classmethod
    def agent_modules_dir(cls) -> Path | None:
        # Modules live in subdirectories alongside package.py
        pkg_dir = Path(__file__).parent
        # Return the directory only if it contains at least one manifest
        if any((d / "manifest.yaml").is_file() for d in pkg_dir.iterdir() if d.is_dir()):
            return pkg_dir
        return None

The AgentModuleRegistry.discover_from_packages() call at startup scans all registered agent packages and adds their module directories automatically.

Discovery and Refresh
#

1
2
3
4
5
6
7
8
# CLI
tantoc2> agent-modules list
tantoc2> agent-modules list --agent <agent-id>    # Only compatible modules

# REST API
GET  /api/v1/agent-modules/
GET  /api/v1/agent-modules/compatible/<agent_id>
POST /api/v1/agent-modules/refresh

refresh() on the AgentModuleRegistry re-scans all directories — equivalent to a full discover(). Add new modules by dropping them in agent_modules/ and calling refresh.

Common Pitfalls
#

Format mismatch — the format field in manifest.yaml must exactly match a string in the agent’s supported_module_formats(). Typos here mean the module never appears as compatible.

Missing any in architectures — if you have a universal payload (e.g., Python script), set architectures: [any]. Without this, an agent with arch x64 won’t match a manifest that lists only x86.

payload_file path — the value is relative to the module directory. Do not include the directory name itself.

Empty platforms/architectures — an empty list means “all” (no filter). If you only target Windows but leave platforms: [], the module will appear as compatible for Linux agents too.

Duplicate names — if two module directories have the same name, the second one is silently skipped. Module names must be globally unique.