22 KiB
+++ title = "Command Development Guide" chapter = false weight = 100 +++
Dark Agent Command Development Guide
This guide explains how to add new commands to Dark Agent, both as built-in commands in the agent and as Mythic UI command wrappers. We'll cover both approaches with examples from the existing codebase.
Command Development Overview
Dark Agent supports two main types of commands:
- Built-in Commands: Native commands implemented directly in the agent's Crystal code
- BOF-based Commands: Commands implemented as C Beacon Object Files (BOFs)
Both types require:
- Implementation in the agent code (Crystal or C)
- A Python wrapper in the Mythic UI
Adding a New Built-in Command
Step 1: Implement the Command in Crystal
Create a new command class in the src/dark/agent/commands/ directory named your_command.cr:
require "./base"
module Dark::Agent::Commands
class YourCommand < Base
def name : String
"your_command_name"
end
def execute(task : JSON::Any) : Nil
task_id = task["id"]?.try(&.as_s) || ""
# Extract parameters from the task JSON
param1 = task["parameters"]["param1"]?.try(&.as_s) || ""
param2 = task["parameters"]["param2"]?.try(&.as_i) || 0
# Validate parameters
if param1.empty?
MessageHandler.add_response(task_id, :error, "Parameter 1 is required")
return
end
# Implement your command logic
begin
result = do_something(param1, param2)
MessageHandler.add_response(task_id, :success, "Command completed: #{result}")
rescue ex
MessageHandler.add_response(task_id, :error, "Command failed: #{ex.message}")
end
end
private def do_something(param1 : String, param2 : Int32) : String
# Your implementation here
"Processed #{param1} with value #{param2}"
end
end
end
The command will be automatically registered by the command system through Crystal macros when the agent starts.
Step 2: Create a Mythic Command Wrapper
Create a new Python file in the mythic/agent_functions/ directory named your_command_name.py:
from mythic_container.MythicCommandBase import *
from mythic_container.MythicRPC import *
import json
class YourCommandArguments(TaskArguments):
def __init__(self, command_line, **kwargs):
super().__init__(command_line, **kwargs)
self.args = [
CommandParameter(
name="param1",
cli_name="param1",
display_name="Parameter 1",
type=ParameterType.String,
description="Description of parameter 1",
parameter_group_info=[
ParameterGroupInfo(
required=True,
group_name="Default",
ui_position=1
)
]
),
CommandParameter(
name="param2",
cli_name="param2",
display_name="Parameter 2",
type=ParameterType.Number,
description="Description of parameter 2",
parameter_group_info=[
ParameterGroupInfo(
required=False,
group_name="Default",
ui_position=2
)
]
),
]
async def parse_arguments(self):
if len(self.command_line) == 0:
return
if self.command_line[0] == "{":
self.load_args_from_json_string(self.command_line)
else:
# Parse arguments from command line
parts = self.command_line.split()
if len(parts) > 0:
self.add_arg("param1", parts[0])
if len(parts) > 1:
self.add_arg("param2", int(parts[1]))
class YourCommandCommand(CommandBase):
cmd = "your_command_name"
needs_admin = False
help_cmd = "your_command_name [param1] [param2]"
description = "Description of your command"
version = 1
author = "Your Name"
argument_class = YourCommandArguments
attackmapping = ["T1000"] # MITRE ATT&CK techniques
attributes = CommandAttributes(
builtin=True # Set to True for built-in commands
)
async def create_go_tasking(self, taskData: PTTaskMessageAllData) -> PTTaskCreateTaskingMessageResponse:
response = PTTaskCreateTaskingMessageResponse(
TaskID=taskData.Task.ID,
Success=True,
)
# Set display parameters for the Mythic UI
param1 = taskData.args.get_arg("param1")
param2 = taskData.args.get_arg("param2", 0)
response.DisplayParams = f"{param1} {param2}"
return response
async def process_response(self, task: PTTaskMessageAllData, response: any) -> PTTaskProcessResponseMessageResponse:
resp = PTTaskProcessResponseMessageResponse(TaskID=task.Task.ID, Success=True)
return resp
Adding a New BOF-based Command
For a detailed understanding of how the BOF loading system works internally, see the BOF Loading System documentation.
Step 1: Create the BOF in C
Create a new C file in src/bofs/ named your_bof.c:
#include "includes/beacon.h"
#include <string.h>
void coffee() {
// Simple example - BOFs in Dark Agent use the coffee() entry point
BeaconPrintf("Your BOF is executing!");
// For string formatting, convert all values to strings first
char buffer[256];
snprintf(buffer, sizeof(buffer), "Processing at %d", 12345);
BeaconPrintf("Status: %s", buffer);
// Output raw data
const char data[] = "Raw binary data";
BeaconOutput((char*)data, sizeof(data) - 1);
}
Note: Dark Agent BOFs use a simplified API without complex argument parsing. Arguments are typically handled by the Python wrapper and passed as simple parameters.
Compile your BOF:
gcc -fPIC -c src/bofs/your_bof.c -o output/bofs/your_bof.o -I src/bofs/includes
Or use the build script:
./build.sh -b
Step 2: Create a Mythic Command Wrapper
Create a new Python file in the mythic/agent_functions/ directory named your_bof.py:
from mythic_container.MythicCommandBase import *
from mythic_container.MythicRPC import *
class YourBofArguments(TaskArguments):
def __init__(self, command_line, **kwargs):
super().__init__(command_line, **kwargs)
self.args = []
async def parse_arguments(self):
pass
class YourBofCommand(CommandBase):
cmd = "your_bof"
needs_admin = False
help_cmd = "your_bof [args]"
description = "Description of your BOF command"
version = 1
author = "Your Name"
attackmapping = []
argument_class = YourBofArguments
attributes = CommandAttributes(
builtin=False,
load_only=True,
suggested_command=False,
supported_os=[SupportedOS.Linux]
)
Examples from Existing Commands
Example 1: Simple Built-in Command - Sleep
The sleep command in Dark Agent is a good example of a simple built-in command:
Command Handler (Crystal):
register_command("sleep") do |args|
log_debug("Processing sleep command")
sleep_time = args["parameters"]?.try(&.["seconds"]?.try(&.as_i)) || 0
jitter = args["parameters"]?.try(&.["jitter"]?.try(&.as_i)) || 0
if sleep_time < 0
raise "Invalid sleep time: must be a non-negative integer"
end
if jitter < 0 || jitter > 100
raise "Invalid jitter: must be between 0 and 100"
end
log_debug("Setting sleep_time: #{sleep_time}s, jitter: #{jitter}%")
# Update the global sleep settings
transport = Dark::Agent::Transport::Active.instance
transport.sleep_time = sleep_time
transport.jitter = jitter
msg = nil
if sleep_time == 0
msg = "Sleep interval disabled (set to 0s)"
elsif jitter > 0
msg = "Sleep interval updated to #{sleep_time}s with #{jitter}% jitter"
else
msg = "Sleep interval updated to #{sleep_time}s"
end
log_debug(msg)
next msg
end
Mythic Wrapper (Python):
from mythic_container.MythicCommandBase import *
class SleepArguments(TaskArguments):
def __init__(self, command_line, **kwargs):
super().__init__(command_line, **kwargs)
self.args = [
CommandParameter(
name="seconds",
cli_name="Seconds",
display_name="Sleep Time (seconds)",
type=ParameterType.Number,
description="Number of seconds between beacons (0 for polling mode)",
parameter_group_info=[
ParameterGroupInfo(
required=True,
group_name="Default",
ui_position=1
)
],
default_value=10
),
CommandParameter(
name="jitter",
cli_name="Jitter",
display_name="Jitter (%)",
type=ParameterType.Number,
description="Percentage of jitter for randomizing sleep time (0-100)",
parameter_group_info=[
ParameterGroupInfo(
required=False,
group_name="Default",
ui_position=2
)
],
default_value=10
)
]
async def parse_arguments(self):
if len(self.command_line) == 0:
return
if self.command_line[0] == "{":
self.load_args_from_json_string(self.command_line)
else:
parts = self.command_line.split()
if len(parts) > 0:
self.add_arg("seconds", int(parts[0]))
if len(parts) > 1:
self.add_arg("jitter", int(parts[1]))
class SleepCommand(CommandBase):
cmd = "sleep"
needs_admin = False
help_cmd = "sleep [seconds] [jitter %]"
description = "Adjust the sleep interval and jitter percentage for the agent"
version = 1
author = "@nromanowski"
argument_class = SleepArguments
attributes = CommandAttributes(
builtin=True
)
Example 2: File Operation Command - Upload
The upload command demonstrates more complex parameter handling and file operations:
Command Handler (Crystal):
register_command("upload") do |args|
file_id = args["parameters"]["file"]?.try(&.as_s) || ""
remote_path = args["parameters"]["remote_path"]?.try(&.as_s) || ""
task_id = args["id"]?.try(&.as_s) || ""
if file_id.empty?
raise "No file specified for upload"
end
if remote_path.empty?
raise "No destination path specified"
end
log_debug("Starting upload of file_id #{file_id} to #{remote_path}")
# Send initial notification to Mythic UI
MessageHandler.add_task_info(task_id, "Starting upload to #{remote_path}\n")
# Implementation details omitted for brevity...
next "Uploaded file to #{remote_path} successfully"
end
Mythic Wrapper (Python):
from mythic_container.MythicCommandBase import *
import json
from mythic_container.MythicRPC import *
class UploadArguments(TaskArguments):
def __init__(self, command_line, **kwargs):
super().__init__(command_line, **kwargs)
self.args = [
CommandParameter(
name="file",
cli_name="File",
display_name="File",
type=ParameterType.File,
description="File to upload to the target",
parameter_group_info=[
ParameterGroupInfo(
required=True,
group_name="Default",
ui_position=1
)
]
),
CommandParameter(
name="remote_path",
cli_name="Path",
display_name="Destination Path",
type=ParameterType.String,
description="Required: Path on target where to save the file.",
parameter_group_info=[
ParameterGroupInfo(
required=True,
group_name="Default",
ui_position=2
)
]
),
]
async def parse_arguments(self):
if len(self.command_line) == 0:
raise Exception("Require arguments.")
if self.command_line[0] != "{":
raise Exception("Require JSON blob, but got raw command line.")
self.load_args_from_json_string(self.command_line)
class UploadCommand(CommandBase):
cmd = "upload"
needs_admin = False
help_cmd = "upload (modal popup)"
description = "Upload a file from the Mythic server to the target system."
version = 1
supported_ui_features = ["file_browser:upload"]
author = "@nromanowski"
argument_class = UploadArguments
attackmapping = ["T1132", "T1030", "T1105"]
attributes = CommandAttributes(
suggested_command=True,
builtin=True
)
Example 3: BOF-based Command with Arguments - Shell
The shell command demonstrates how to create a BOF command that accepts arguments:
BOF (C):
// In src/bofs/SA/shell.c
#include "../includes/beacon.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void coffee(int argc, char **argv) {
if (argc < 1) {
BeaconPrintf("Error: No command provided\n");
return;
}
// The entire command line is in argv[0] when using bof_args_str
char *full_command = argv[0];
BeaconPrintf("Executing: %s\n", full_command);
FILE *fp = popen(full_command, "r");
if (fp == NULL) {
BeaconPrintf("Error: Failed to execute command\n");
return;
}
char buffer[4096];
while (fgets(buffer, sizeof(buffer), fp) != NULL) {
// Remove trailing newline if present
size_t len = strlen(buffer);
if (len > 0 && buffer[len - 1] == '\n') {
buffer[len - 1] = '\0';
len--;
}
BeaconOutput(buffer, len);
}
pclose(fp);
}
Mythic Wrapper (Python):
from mythic_container.MythicCommandBase import *
from mythic_container.MythicRPC import *
class ShellArguments(TaskArguments):
def __init__(self, command_line, **kwargs):
super().__init__(command_line, **kwargs)
self.args = []
async def parse_arguments(self):
if len(self.command_line) <= 0:
raise Exception("Usage: shell [command] [arguments]")
# Pass the entire command line as-is to the BOF
# The BOF will handle parsing the command and arguments
self.set_arg("bof_args_str", self.command_line.strip())
class ShellCommand(CommandBase):
cmd = "shell"
needs_admin = False
help_cmd = "shell [command] [arguments]"
description = "Execute shell commands on the target system"
version = 1
author = "@nromanowski"
attackmapping = ["T1059.004"]
argument_class = ShellArguments
attributes = CommandAttributes(
builtin=False,
load_only=True,
suggested_command=False,
supported_os=[SupportedOS.Linux]
)
async def create_go_tasking(self, taskData: PTTaskMessageAllData) -> PTTaskCreateTaskingMessageResponse:
response = PTTaskCreateTaskingMessageResponse(
TaskID=taskData.Task.ID,
Success=True,
DisplayParams=taskData.args.get_arg("bof_args_str")
)
return response
Example 4: BOF-based Command with Parameters - Portscan
The portscan command shows how to handle structured parameters:
Mythic Wrapper (Python):
from mythic_container.MythicCommandBase import *
from mythic_container.MythicRPC import *
class PortscanArguments(TaskArguments):
def __init__(self, command_line, **kwargs):
super().__init__(command_line, **kwargs)
self.args = [
CommandParameter(
name="host",
type=ParameterType.String,
description="Target host to scan",
parameter_group_info=[ParameterGroupInfo(
required=True,
ui_position=1
)]
),
CommandParameter(
name="ports",
type=ParameterType.String,
description="Comma-separated list of ports to scan (e.g., 22,80,443)",
parameter_group_info=[ParameterGroupInfo(
required=True,
ui_position=2
)]
)
]
async def parse_arguments(self):
pass
async def parse_dictionary(self, dictionary_arguments):
host = dictionary_arguments.get("host")
ports = dictionary_arguments.get("ports")
# CRITICAL: Set these parameters for BOF execution
self.add_arg("name", "portscan") # BOF name
self.add_arg("host", host) # For display
self.add_arg("ports", ports) # For display
self.add_arg("bof_args", f"{host} {ports}") # Arguments passed to BOF
class PortscanCommand(CommandBase):
cmd = "portscan"
needs_admin = False
help_cmd = "portscan <host> <port1,port2,...>"
description = "Scan specified ports on a target host to check if they're open"
version = 1
author = "@nromanowski"
attackmapping = ["T1046"]
argument_class = PortscanArguments
attributes = CommandAttributes(
builtin=False,
load_only=True,
suggested_command=False,
supported_os=[SupportedOS.Linux]
)
async def create_go_tasking(self, taskData: PTTaskMessageAllData) -> PTTaskCreateTaskingMessageResponse:
response = PTTaskCreateTaskingMessageResponse(
TaskID=taskData.Task.ID,
Success=True,
CommandName="bof_exec", # CRITICAL: Route to bof_exec
DisplayParams=taskData.args.get_arg("arguments")
)
return response
Command Attributes Guide
When implementing Mythic command wrappers, these attributes control their behavior:
- builtin: Set to
Truefor built-in commands,Falsefor BOF-based commands - load_only: Set to
Truefor BOF commands that need to be loaded first - suggested_command: Set to
Trueto display in command suggestions - supported_os: Array of supported operating systems (
SupportedOS.Linux,SupportedOS.MacOS)
Parameter Types Reference
Available parameter types for Mythic commands:
ParameterType.String: Text inputParameterType.Number: Numeric inputParameterType.Boolean: True/false toggleParameterType.File: File uploadParameterType.Array: Array of valuesParameterType.ChooseOne: Dropdown selectionParameterType.ChooseMultiple: Multi-select dropdown
Testing Your Command
-
For Built-in Commands:
- Add your command to
command_handler.cr - Add your Python wrapper in
mythic/agent_functions/ - Rebuild the agent with
./build.sh - Test through the Mythic UI
- Add your command to
-
For BOF-based Commands:
- Create your BOF in
src/bofs/c/ - Compile with
gcc -fPIC -c src/bofs/c/your_bof.c -o output/bofs/your_bof.o -I src/bofs/c/includes - Add your Python wrapper in
mythic/agent_functions/ - Test by loading the BOF with
bof_load your_bofand executing it
- Create your BOF in
Critical Pattern for BOF Commands with Arguments
When creating BOF-based commands that accept arguments, you MUST follow this pattern in your Mythic Python wrapper:
Required Parameters
In your argument parsing method, set these critical parameters:
# CRITICAL: These parameters are required for BOF execution
self.set_arg("name", "your_bof_name") # The BOF name to execute
# Choose ONE of the following argument patterns:
# Option 1: Split arguments (traditional BOF behavior)
self.set_arg("bof_args", "arg1 arg2 arg3") # Arguments split by spaces into argv[0], argv[1], argv[2]
# Option 2: Single string argument (shell-like commands)
self.set_arg("bof_args_str", "ls -latr /tmp/") # Entire string passed as argv[0]
Required Command Routing
In your create_go_tasking method, route the command to bof_exec:
async def create_go_tasking(self, taskData: PTTaskMessageAllData) -> PTTaskCreateTaskingMessageResponse:
response = PTTaskCreateTaskingMessageResponse(
TaskID=taskData.Task.ID,
Success=True,
CommandName="bof_exec" # CRITICAL: Route to bof_exec, not your command name
)
return response
BOF Argument Handling: bof_args vs bof_args_str
Dark Agent supports two different argument passing patterns for BOFs:
bof_args (Split Arguments)
Use this for traditional BOFs that expect individual arguments:
- Arguments are split by spaces:
"arg1 arg2 arg3"becomesargv[0]="arg1",argv[1]="arg2",argv[2]="arg3" - Best for BOFs that parse individual parameters
- Example:
portscan 192.168.1.1 22,80,443
bof_args_str (Single String Argument)
Use this for shell-like commands that need the entire command line:
- Entire string passed as
argv[0]:"ls -latr /tmp/"becomesargv[0]="ls -latr /tmp/" - Best for BOFs that execute system commands or need complex argument parsing
- Example:
shell ls -latr /tmp/
Why This Pattern is Required
Dark Agent's command handler works as follows:
- Your Mythic command wrapper parses user input and sets the
nameand eitherbof_argsorbof_args_strparameters - The wrapper routes the command to
bof_execviaCommandName="bof_exec" - The agent receives a
bof_execcommand with your BOF name and arguments - The agent checks for
bof_args_strfirst (single string), then falls back tobof_args(split arguments) - The agent executes the BOF using the BOF registry with the processed arguments
Without this pattern, your BOF command will not work correctly.
Common Patterns
- Error Handling: Always handle errors gracefully in both Crystal and Python code
- Parameter Validation: Validate parameters before using them
- UI Display: Set
DisplayParamsincreate_go_taskingfor clear UI feedback - Progress Updates: Use
MessageHandler.add_task_infofor progress updates during long operations - BOF Argument Routing: Always use the
nameandbof_argspattern for BOF commands
By following this guide, you can implement new commands for Dark Agent that integrate seamlessly with both the agent codebase and the Mythic UI.