RadioOpaque superheavy: writeup

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}


Report Page