Init. commit

This commit is contained in:
Caplag
2026-04-22 10:42:16 +03:00
commit 98e51ca58b
35 changed files with 2371 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
<h1 align="center">ChinaOwner</h1>
<p align="center">
<img src="https://img.shields.io/badge/category-Stego-blueviolet" alt="Stego"/>
<img src="https://img.shields.io/badge/points-960-critical" alt="960 pts"/>
</p>
У нас есть архив сырых [AIS](https://gpsd.gitlab.io/gpsd/AIVDM.html)-строк формата `!AIVDM` с временными метками. Больше всего в галаза бросается судно, которое публикует в поле `destination` строки `CHINA OWNER` и `CHINA OWNER&CREW`. Эти значения - маркер правильного MMSI. Само сообщение находится в таймингах между соседними `type 5` сообщениями того же борта.
## Решение
Сообщения `type 5` в AIS длинные, в handout они поделены на две `!AIVDM`-строки. Для начала склеиваем фрагменты по `seq_id`, каналу и timestamp, декодируем armored payload обратно в биты, отфильтровываем `message type == 5`. В пятом типе лежат `MMSI`, имя судна, `destination` и `ETA`.
> **AIS `!AIVDM` / type 5.** Тип 5 — «Static and Voyage Related Data» судна: содержит MMSI, IMO, имя, тип, позывной, данные о грузе, destination и ETA. Формат payload — armored 6-bit ASCII, [gpsd AIVDM spec](https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_5_and_24_static_and_voyage_related_data) описывает все поля побитно.
После декодирования всплывает один MMSI — `422451900`. У этого судна `destination` раз за разом принимает значения `CHINA OWNER` и `CHINA OWNER&CREW` — вот он, нужный канал. Дальше сортируем все его `type 5` сообщения по времени и смотрим на интервалы между соседними timestamp:
| # | Δt, сек | Δt 280 | chr() |
|---|---:|---:|:---:|
| 1 | 379 | 99 | `c` |
| 2 | 377 | 97 | `a` |
| 3 | 392 | 112 | `p` |
| 4 | 388 | 108 | `l` |
| 5 | 377 | 97 | `a` |
| 6 | 383 | 103 | `g` |
| … | … | … | … |
Схема кодирования достаточно очевидная: `chr(delta_seconds - 280)`. Прогоняем ту же операцию по всем дельтам — получаем полную строку флага.
Готовый скрипт — [`solve/decode_timing.py`](solve/decode_timing.py): `python3 solve/decode_timing.py`.
## Флаг
`caplag{watch_the_gaps_not_the_words}`

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python3
from __future__ import annotations
from collections import defaultdict
from datetime import datetime
from pathlib import Path
INPUT_PATH = Path(__file__).resolve().parents[1] / "public" / "hormuz_feed.nmea"
TIMING_OFFSET = 280
AIS_TEXT_TABLE = '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_ !"#$%&\'()*+,-./0123456789:;<=>?'
def armor_to_sixbit(char: str) -> int:
value = ord(char) - 48
if value > 40:
value -= 8
return value
def payload_to_bits(payload: str, fill_bits: int) -> str:
bits = "".join(f"{armor_to_sixbit(char):06b}" for char in payload)
return bits[:-fill_bits] if fill_bits else bits
def bits_to_int(bits: str) -> int:
return int(bits, 2)
def bits_to_signed(bits: str) -> int:
value = int(bits, 2)
if bits and bits[0] == "1":
value -= 1 << len(bits)
return value
def decode_text(bits: str) -> str:
chars = []
for index in range(0, len(bits), 6):
chars.append(AIS_TEXT_TABLE[int(bits[index:index + 6], 2)])
return "".join(chars).rstrip("@ ").strip()
def parse_messages(path: Path) -> list[tuple[datetime, str]]:
fragments: dict[tuple[str, str, str], dict[int, str]] = {}
fragment_totals: dict[tuple[str, str, str], int] = {}
fill_bits: dict[tuple[str, str, str], int] = {}
completed: list[tuple[datetime, str]] = []
for raw_line in path.read_text(encoding="ascii").splitlines():
if not raw_line:
continue
timestamp_text, sentence = raw_line.split(" ", 1)
timestamp = datetime.fromisoformat(timestamp_text.replace("Z", "+00:00"))
body, checksum = sentence[1:].split("*", 1)
fields = body.split(",")
total = int(fields[1])
index = int(fields[2])
seq_id = fields[3] or "-"
channel = fields[4]
payload = fields[5]
fill = int(fields[6])
key = (timestamp_text, channel, seq_id)
if total == 1:
completed.append((timestamp, payload_to_bits(payload, fill)))
continue
fragments.setdefault(key, {})[index] = payload
fragment_totals[key] = total
fill_bits[key] = fill
if len(fragments[key]) == total:
merged = "".join(fragments[key][part] for part in range(1, total + 1))
completed.append((timestamp, payload_to_bits(merged, fill_bits[key])))
del fragments[key]
del fragment_totals[key]
del fill_bits[key]
return sorted(completed, key=lambda item: item[0])
def decode_type5(bits: str) -> tuple[int, str] | None:
if bits_to_int(bits[0:6]) != 5:
return None
mmsi = bits_to_int(bits[8:38])
destination = decode_text(bits[302:422])
return mmsi, destination
def recover_flag(path: Path) -> str:
per_mmsi: dict[int, list[tuple[datetime, str]]] = defaultdict(list)
for timestamp, bits in parse_messages(path):
decoded = decode_type5(bits)
if decoded is None:
continue
mmsi, destination = decoded
per_mmsi[mmsi].append((timestamp, destination))
suspects = {
mmsi: rows
for mmsi, rows in per_mmsi.items()
if sum("CHINA OWNER" in destination for _, destination in rows) >= 4
}
if len(suspects) != 1:
raise RuntimeError(f"Expected exactly one suspect MMSI, got {list(suspects)}")
target_rows = next(iter(suspects.values()))
target_rows.sort(key=lambda item: item[0])
decoded_chars = []
for previous, current in zip(target_rows, target_rows[1:]):
delta = int((current[0] - previous[0]).total_seconds())
decoded_chars.append(chr(delta - TIMING_OFFSET))
return "".join(decoded_chars)
def main() -> None:
flag = recover_flag(INPUT_PATH)
print(flag)
if __name__ == "__main__":
main()