RadioOpaque superheavy: writeup
LetoCTF-WriteUpВ задании дают большой Rust-бинарь radioopaque (~623MB) и DICOM-снимок sealed.dcm. Бинарь притворяется локальным веб-сервисом для проверки, но весь смысл таска — reverse engineering пайплайна, которым флаг был записан в снимок.
Сервис поднимает два endpoint и одну CLI-команду проверки:
GET /preview PNG-превью снимка POST /verify проверка blob относительно снимка verify --sample S --blob-hex HEX то же из CLI
Флаг спрятан по одному биту в LSB исходных 16-битных DICOM-пикселей. Порядок пикселей не линейный: чтобы его восстановить, нужно собрать вместе DICOM-метаданные, найденный side-marker, результат ONNX Runtime inference и хэши трёх встроенных моделей. То есть просто «вытащить LSB» не получится — нужно воспроизвести весь пайплайн целиком.
Почему превью не работает
Первое желание — открыть /preview и читать LSB прямо из него. Это тупик.
/preview отдаёт 8-битный PNG. Флаг сидит в оригинальных 16-битных пикселях, и нужный младший слой в PNG уже потерян при квантовании. Извлекать можно только из самого sealed.dcm.
verify на неправильных данных возвращает разные статусы:
bad_magic bad_length bad_crc bad_aead
Это сразу подсказывает: из картинки надо достать отдельный бинарный blob, а сервис лишь проверяет его относительно снимка. Значит наша цель — собрать этот blob битами из raw-пикселей в правильном порядке и расшифровать.
Контрольные значения релизной superheavy-версии, по которым удобно сверять каждый шаг:
sample_sha256: 481504c2aa8c8e3a0015c1089ff995f49f940d4bbef2afa5b3ee13cb7256c910 marker_side: LEFT marker_bbox: x=2016 y=117 w=85 h=88 roi_bbox: x=0 y=381 w=2868 h=2995 roi_backend: onnxruntime model_tag: 21aea20209918995 model_count: 3
Главная идея
Извлечение детерминировано. Один и тот же снимок всегда даёт один и тот же порядок битов, потому что порядок — это результат цепочки хэшей от свойств снимка. Значит достаточно точно повторить каждое звено пайплайна:
DICOM preprocessing -> side-marker -> ONNX ROI -> model hashes
-> key derivation -> порядок пикселей -> blob -> decrypt
Если хоть одно звено посчитано неверно, seed перестановки или ключ AEAD «уедут», и blob либо не пройдёт CRC, либо не расшифруется. Поэтому стратегия — восстанавливать по одному звену и сверять с контрольными значениями выше (особенно с model_tag).
Формат blob
В функции проверки восстанавливается такой формат:
offset size meaning 0x00 4 magic: "LUNG" 0x04 2 version, little endian, == 1 0x06 2 длина plaintext, little endian 0x08 1 crc8(ciphertext) 0x09 24 nonce для XChaCha20-Poly1305 0x21 n+16 ciphertext + Poly1305 tag
Первые байты настоящего blob:
4c 55 4e 47 01 00 3a 00 ...
То есть magic = "LUNG", version = 1, plaintext_len = 0x3a = 58. Полный размер:
33 + 58 + 16 = 107 bytes
DICOM preprocessing
Бинарь читает поля Rows, Columns, PhotometricInterpretation, StudyDate, PatientID, AccessionNumber, SOPInstanceUID, WindowCenter, WindowWidth, PixelData. PixelData декодируется как little-endian u16.
Для анализа пиксели нормализуются:
if PhotometricInterpretation == "MONOCHROME1":
normalized[i] = 65535 - raw[i]
else:
normalized[i] = raw[i]
Ловушка с normalized и raw
Здесь главная ловушка таска. Порядок позиций и все хэши считаются по normalized, но сами биты флага встроены в raw.
Соблазн — раз уж посчитали normalized, из него же и читать LSB. Но для MONOCHROME1 это инвертирует каждый бит флага, и blob развалится. Запомнить просто: позиции — из normalized, биты — из raw.
Поиск side-marker
Бинарь ищет яркий side-marker в верхней части снимка:
top_h = clamp(height / 4, 256, height) side_w = clamp(width / 3, 320, width)
Сканируются левое и правое окна, для каждого считается порог:
threshold = mean + stddev * 2.4 threshold clamp: 170..245
Дальше — connected components ярких пикселей; слишком маленькие и слишком большие отбрасываются, лучшая становится маркером. Стороны называются с точки зрения пациента, поэтому они зеркальные:
левое окно изображения -> RIGHT правое окно изображения -> LEFT
Для релиза: marker_side = LEFT, marker_bbox = x=2016 y=117 w=85 h=88. Эти значения входят в seed, так что игнорировать маркер нельзя.
Встроенные ONNX-модели
623MB — это не padding. В сборку встроены три настоящие ONNX-модели:
deeplabv3_resnet101.onnx fcn_resnet101.onnx deeplabv3_resnet50.onnx
Первая реально запускается через ONNX Runtime и влияет на ROI. Все три участвуют в derivation секрета. В Rust это сгенерированная таблица:
struct EmbeddedModelSpec {
name: &'static str,
bytes: &'static [u8],
}
Даже в stripped-бинаре её восстанавливают через .rodata: найти строки с именами моделей и пройти по xrefs до структуры с указателями на имена, длины имён, byte arrays и их размеры.
Секрет считается так:
SECRET_MASK = bytes([
0x54,0x5e,0x2c,0xf3,0x93,0x31,0x12,0x41,
0xa7,0x68,0xfa,0x10,0x56,0x28,0xc1,0x90,
0x33,0x44,0xa9,0xe0,0x7d,0x19,0x57,0x6b,
0xd1,0x2e,0x77,0x08,0x9c,0x62,0x5b,0x14,
])
combined = sha256(
name_0 || len_0_le_u64 || sha256(model_0) ||
name_1 || len_1_le_u64 || sha256(model_1) ||
name_2 || len_2_le_u64 || sha256(model_2)
)
server_secret = combined xor SECRET_MASK
model_tag = combined[:8].hex()
Комментарий про model_tag
model_tag — это бесплатный чекпойнт. Если он сошёлся с 21aea20209918995, значит и имена, и длины, и bytes всех трёх моделей восстановлены верно, и server_secret правильный. Если нет — что-то из модельных данных извлечено неточно, и дальше идти бессмысленно. Удобно ловить здесь распространённую ошибку: хэшировать только bytes моделей. Имена и длины тоже входят в combined.
ROI через ONNX Runtime
Primary model запускается на нормализованном снимке. Входной tensor:
shape: 1 x 3 x 512 x 512 source: normalized grayscale DICOM pixels resize: nearest-neighbor через integer mapping channel 0: (gray - 0.485) / 0.229 channel 1: (gray - 0.456) / 0.224 channel 2: (gray - 0.406) / 0.225
Из logits считается score map:
score = max(max_non_background - background, 0)
Дальше берётся центральная body-зона, score threshold примерно по 78-му перцентилю, active region масштабируется обратно в координаты DICOM и расширяется на 14%. Для релиза roi_bbox = x=0 y=381 w=2868 h=2995.
ROI digest тоже входит в ключи:
sha256(
roi_bbox как little-endian u32 ||
64x64 quantized score grid ||
image_width_le_u32 ||
image_height_le_u32
)
Key derivation
Имея DICOM-поля, маркер, ROI и хэши моделей, собираем три значения.
Seed для перестановки позиций:
sha256(
StudyDate || 0x00 ||
PatientID || 0x00 ||
AccessionNumber || 0x00 ||
marker_side ||
marker_bbox.x_le || marker_bbox.y_le || marker_bbox.w_le || marker_bbox.h_le ||
roi_bbox.x_le || roi_bbox.y_le || roi_bbox.w_le || roi_bbox.h_le ||
roi_digest ||
server_secret
)
Ключ AEAD:
sha256(
server_secret ||
histogram_digest ||
WindowCenter_le_f32 ||
WindowWidth_le_f32 ||
PhotometricInterpretation ||
roi_digest
)
histogram_digest — SHA-256 по 256 bucket'ам старшего байта нормализованных пикселей:
bucket[normalized[i] >> 8] += 1 digest = sha256(all buckets as little-endian u32)
Nonce:
sha256(SOPInstanceUID || ":radioopaque:xchacha")[:24]
Шифр — XChaCha20-Poly1305.
Порядок пикселей
Candidate positions берутся внутри ROI, область маркера исключается с небольшим расширением. Для каждого пикселя считается edge score:
curr = normalized[y, x] >> 8 left = normalized[y, x - 1] >> 8 up = normalized[y - 1, x] >> 8 score = abs(curr - left) + abs(curr - up)
Сначала берутся пиксели со score >= 48; если их меньше 4096, порог снижается до 24, 8, 0. Список сортируется:
score descending pixel index ascending
Затем он обрезается до 200000 элементов и перемешивается через RustChaCha20Rng с seed = position_seed. Полученный shuffled list — это и есть порядок битов.
Извлечение blob
Биты записаны MSB-first внутри каждого байта, читаем обратно из raw-пикселей:
def read_bytes(raw_pixels, positions, n):
out = bytearray()
bit_i = 0
cur = 0
for pos in positions[:n * 8]:
cur = (cur << 1) | (raw_pixels[pos] & 1)
bit_i += 1
if bit_i == 8:
out.append(cur)
cur = 0
bit_i = 0
return bytes(out)
Сначала читаем 33 байта header, берём из него plaintext_len, затем читаем полный blob:
4c554e4701003a007a789f1acd22aa7623ac30335348526ee3483bc94f55db22fd6ccd1f17276354a233e39f3c4bcece9ccfaadd6c38271f7e1d0f14c7c480000987fff47f6ced7985d1b278d6d4349a96e3845866b8a6acf92b027fc469458dff6b20c30c102c69c732b3
Сверяем локальным oracle:
./radioopaque verify --sample sealed.dcm --blob-hex 4c554e47...732b3
Ожидаемо отдаёт status: ok.
Расшифровка
Разбираем blob и проверяем инварианты:
magic = blob[0:4] == "LUNG" version = u16le(blob[4:6]) == 1 plaintext_len = u16le(blob[6:8]) crc = blob[8] == crc8(ciphertext) nonce = blob[9:33] == derived_nonce ciphertext = blob[33:] len == plaintext_len + 16
Нужен именно XChaCha20-Poly1305, поэтому в Python удобно взять PyNaCl:
from nacl.bindings import crypto_aead_xchacha20poly1305_ietf_decrypt
plaintext = crypto_aead_xchacha20poly1305_ietf_decrypt(
ciphertext, aad=None, nonce=nonce, key=key,
)
print(plaintext.decode())
Здесь же ловится последняя частая ошибка — взять обычный ChaCha20-Poly1305 с 12-байтным nonce вместо XChaCha20 с 24-байтным.
Флаг
letoctf{4_v4d_y4_g0v0r1l_p4sht3tu_cht0_nuzhn0_b1t_z4_z0zh}