Init. commit
This commit is contained in:
33
stego-china-owner/WRITEUP.md
Normal file
33
stego-china-owner/WRITEUP.md
Normal 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}`
|
||||
123
stego-china-owner/solve/decode_timing.py
Normal file
123
stego-china-owner/solve/decode_timing.py
Normal 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()
|
||||
Reference in New Issue
Block a user