Files
Serdtse-Sysoly/finals/scalaralias-crypto-easy/WRITEUP.md
T
2026-09-17 00:50:07 +03:00

4.3 KiB
Raw Blame History

ScalarAlias

Crypto easy

Реестр KiteLedger выдаёт аудиторскую квитанцию с подписью и запечатанный экспорт. Подпись проверяется по числовым значениям, а ключ экспорта привязан к байтам JSON. Если найти две разные записи одной валидной подписи, можно получить нужный вариант квитанции.

Решение

В раздатке есть receipt.json, sealed_export.json, verify_receipt.py и NOTES.md. Смотрим проверку Schnorr-подобной подписи:

left = ((s % Q) * BASE) % Q
right = (R + e * public_key) % Q

Здесь Q = 170141183460469231731687303715884105727, BASE = 9, а e вычисляется через SHA-256. Скаляр s приводят по модулю Q, но диапазон 0 <= s < Q перед этим не проверяют.

Получается, (R, s) и (R, s + Q) проходят одну и ту же проверку, поскольку остаток одинаковый. При этом значение s в сериализованном JSON различается. Это и есть malleability, возможность изменить подпись, сохранив её валидность.

Пробуем ближайший эквивалентный вариант s + Q. Загружаем квитанцию и функции из public/verify_receipt.py, затем меняем скаляр, сохраняя его строковое представление.

from pathlib import Path
from hashlib import sha256
import importlib.util
import json

spec = importlib.util.spec_from_file_location("receipt_verifier", "public/verify_receipt.py")
verifier = importlib.util.module_from_spec(spec)
spec.loader.exec_module(verifier)
Q = verifier.Q
receipt = json.loads(Path("public/receipt.json").read_text())
changed = json.loads(json.dumps(receipt))
changed["signature"]["s"] = str(int(changed["signature"]["s"]) + Q)

Проверяем изменённую квитанцию через verify_receipt(), затем получаем ключ:

assert verifier.verify_receipt(changed)
key = sha256(
    b"KiteLedger sealed export binding\0" + verifier.canonical_json(changed)
).digest()

canonical_json сортирует ключи и использует компактные разделители. Повторяем именно эту сериализацию, иначе получим другой ключ. Из sealed_export.json переводим nonce_hex, ciphertext_hex и tag_hex в байты. Первые 16 байт sha256(key + b"export-tag" + nonce + ciphertext).digest() сравниваем с тегом экспорта. Проверяем тег до расшифровки.

sealed = json.loads(Path("public/sealed_export.json").read_text())
nonce = bytes.fromhex(sealed["nonce_hex"])
ciphertext = bytes.fromhex(sealed["ciphertext_hex"])
tag = bytes.fromhex(sealed["tag_hex"])
expected = sha256(key + b"export-tag" + nonce + ciphertext).digest()[:16]
assert expected == tag

Для варианта s + Q тег сходится, подтверждая выбор квитанции.

После проверки снимаем XOR. Блоки потока считаются как sha256(key + b"scalaralias-stream" + nonce + counter4be).digest(), где counter4be — четыре байта счётчика в big-endian. Счётчик начинается с нуля.

stream = bytearray()
counter = 0
while len(stream) < len(ciphertext):
    stream.extend(sha256(
        key + b"scalaralias-stream" + nonce + counter.to_bytes(4, "big")
    ).digest())
    counter += 1
plain = bytes(a ^ b for a, b in zip(ciphertext, stream))
print(plain.decode())

Полученный текст содержит флаг.

Солвер.

Флаг

caplag{noncanonical_scalar_unsealed_the_export}