Init. Commit
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
<h1 align="center">ScalarAlias</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Crypto-blueviolet" alt="Crypto"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-easy-brightgreen" alt="easy"/>
|
||||
</p>
|
||||
|
||||
Реестр KiteLedger выдаёт аудиторскую квитанцию с подписью и запечатанный экспорт. Подпись проверяется по числовым значениям, а ключ экспорта привязан к байтам JSON. Если найти две разные записи одной валидной подписи, можно получить нужный вариант квитанции.
|
||||
|
||||
## Решение
|
||||
|
||||
В раздатке есть `receipt.json`, `sealed_export.json`, `verify_receipt.py` и `NOTES.md`. Смотрим проверку Schnorr-подобной подписи:
|
||||
|
||||
```text
|
||||
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`, затем меняем скаляр, сохраняя его строковое представление.
|
||||
|
||||
```python
|
||||
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()`, затем получаем ключ:
|
||||
|
||||
```python
|
||||
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()` сравниваем с тегом экспорта. Проверяем тег до расшифровки.
|
||||
|
||||
```python
|
||||
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. Счётчик начинается с нуля.
|
||||
|
||||
```python
|
||||
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())
|
||||
```
|
||||
|
||||
Полученный текст содержит флаг.
|
||||
|
||||
[Солвер](solve/solve.py).
|
||||
|
||||
## Флаг
|
||||
|
||||
`caplag{noncanonical_scalar_unsealed_the_export}`
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_verifier():
|
||||
path = ROOT / "public" / "verify_receipt.py"
|
||||
spec = importlib.util.spec_from_file_location("scalaralias_verify", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
verifier = load_verifier()
|
||||
|
||||
|
||||
def malleate_receipt(receipt: dict) -> dict:
|
||||
changed = json.loads(json.dumps(receipt))
|
||||
changed["signature"]["s"] = str(int(changed["signature"]["s"]) + verifier.Q)
|
||||
return changed
|
||||
|
||||
|
||||
def binding_key(receipt: dict) -> bytes:
|
||||
return verifier.hashlib.sha256(
|
||||
b"KiteLedger sealed export binding\0" + verifier.canonical_json(receipt)
|
||||
).digest()
|
||||
|
||||
|
||||
def keystream(key: bytes, nonce: bytes, length: int) -> bytes:
|
||||
out = b""
|
||||
counter = 0
|
||||
while len(out) < length:
|
||||
out += verifier.hashlib.sha256(
|
||||
key + b"scalaralias-stream" + nonce + counter.to_bytes(4, "big")
|
||||
).digest()
|
||||
counter += 1
|
||||
return out[:length]
|
||||
|
||||
|
||||
def xor_bytes(left: bytes, right: bytes) -> bytes:
|
||||
return bytes(a ^ b for a, b in zip(left, right))
|
||||
|
||||
|
||||
def open_export(receipt: dict, sealed: dict) -> str:
|
||||
if not verifier.verify_receipt(receipt):
|
||||
raise ValueError("receipt does not verify")
|
||||
|
||||
key = binding_key(receipt)
|
||||
nonce = bytes.fromhex(sealed["nonce_hex"])
|
||||
ciphertext = bytes.fromhex(sealed["ciphertext_hex"])
|
||||
tag = bytes.fromhex(sealed["tag_hex"])
|
||||
expected = verifier.hashlib.sha256(key + b"export-tag" + nonce + ciphertext).digest()[:16]
|
||||
if expected != tag:
|
||||
raise ValueError("sealed export tag mismatch")
|
||||
plaintext = xor_bytes(ciphertext, keystream(key, nonce, len(ciphertext))).decode()
|
||||
match = re.search(r"caplag\{[^}]+\}", plaintext)
|
||||
if not match:
|
||||
raise ValueError("flag not found")
|
||||
return match.group(0)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
receipt = json.loads((ROOT / "public" / "receipt.json").read_text())
|
||||
sealed = json.loads((ROOT / "public" / "sealed_export.json").read_text())
|
||||
print(open_export(malleate_receipt(receipt), sealed))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user