The Horn — Writeup
- カテゴリ: Crypto
- 元問題: DiceCTF 2026 “Carry the Flame”
目次
- チャレンジの読み解き
- XOR とは何か(復習)
- pbox とは何か
- encrypt を1ラウンドずつ追う
- アフィン写像という見方
- 全ゼロを使ったトリック
- 逆置換で CHALLENGE を復元する
- ソルバの全体像
- まとめ
1. チャレンジの読み解き
まず the_horn.py を読んで、「何が秘密で、何が目標か」を整理します。
1 | KEY = os.urandom(BLOCK_SIZE) # 32バイトのランダムな秘密鍵(こちらには見えない) |
サーバーとのやりとりは以下の流れです:
1 | サーバー → 私: CHALLENGE_CT = encrypt(CHALLENGE, KEY) ← 最初に渡される |
つまり、「暗号化オラクル」(任意の値を暗号化してもらえる窓口)を使って、CHALLENGE の元の値を復元せよ という問題です。
2. XOR とは何か(復習)
XOR(排他的論理和)はビット演算の一種で、以下の重要な性質があります:
1 | A XOR A = 0 (同じ値と XOR すると 0 になる) |
特に重要なのは 「A XOR B XOR B = A」 という性質で、
「XOR した値をもう一度 XOR すると元に戻ります」。
3. pbox とは何か
pbox は バイトの並び順を入れ替えるだけ の操作です。バイトの値自体は変えません。
1 | PBOX = [5, 22, 31, 18, 3, 19, ...] # 長さ32の配列 |
具体的な例(簡略化して4バイトで考える):
1 | 入力: [A, B, C, D] |
pbox の重要な性質:XOR に対して分配法則が成り立つ
1 | pbox(X XOR Y) = pbox(X) XOR pbox(Y) |
なぜでしょうか? XOR はバイト同士で独立に計算されます。(X XOR Y) の i バイト目 = X[i] XOR Y[i]
pbox はその i バイト目を別の場所に移動させるだけなので:
1 | pbox(X XOR Y)[j] = (X XOR Y)[PBOX[j]] |
よって pbox(X XOR Y) = pbox(X) XOR pbox(Y) が成り立ちます。
4. encrypt を1ラウンドずつ追う
1 | def encrypt(pt, key): |
1ラウンドを式で書くと:
1 | round(x) = pbox(x XOR KEY) |
pbox の分配法則を使うと:
1 | round(x) = pbox(x XOR KEY) |
つまり1ラウンドは「pbox を適用してから、pbox(KEY) という固定値と XOR する」操作です。
5. アフィン写像という見方
「固定の変換行列 L(x) に定数 c を足す」形の写像を アフィン写像 といいます:
1 | f(x) = L(x) XOR c |
1ラウンドに当てはめると:
1 | round(x) = pbox(x) XOR pbox(KEY) |
アフィン写像を何度合成してもアフィン写像のままです。では32ラウンド合成したときの 線形部分 はどうなるでしょうか?
1 | 1ラウンド目の線形部分: pbox |
つまり:
1 | encrypt(x, KEY) = pbox^32(x) XOR C(KEY) |
ここで C(KEY) は KEY だけで決まる定数(複雑な式ですが x には依存しません)。
6. 全ゼロを使ったトリック
アフィン写像 f(x) = L(x) XOR c に対して、x = 0 を代入すると:
1 | f(0) = L(0) XOR c = 0 XOR c = c |
(L は線形写像なので L(0) = 0)
つまり 全ゼロを入力すると定数部分 c だけが取り出せます。
これを利用すると:
1 | f(x) XOR f(0) = (L(x) XOR c) XOR (c) = L(x) |
f(x) XOR f(0) を計算すれば、定数 c が消えて線形部分 L(x) だけ残ります!
今回の問題に当てはめると:
1 | encrypt(x, KEY) XOR encrypt(\x00*32, KEY) = pbox^32(x) |
CHALLENGE について言えば:
1 | encrypt(CHALLENGE, KEY) XOR encrypt(\x00*32, KEY) = pbox^32(CHALLENGE) |
KEY の情報が完全に消えて、誰でも計算できる公開済みの置換 pbox^32 だけが残ります。
7. 逆置換で CHALLENGE を復元する
ここまでで:
1 | pbox^32(CHALLENGE) = CHALLENGE_CT XOR encrypt(\x00*32, KEY) |
の右辺は手元で計算できます。
あとは左辺から CHALLENGE を取り出すために pbox^32 の逆置換 を適用すれば良いです。
pbox^32 を求める
pbox を [0, 1, 2, …, 31] に繰り返し適用すれば、
「どのインデックスがどこに移動するか」を追跡できます:
1 | base = list(range(32)) # [0, 1, 2, ..., 31] = 恒等置換 |
例えば base = [7, 3, 19, ...] なら:
- 元の 0 番目 → 7 番目の位置へ
- 元の 1 番目 → 3 番目の位置へ
- …
逆置換を適用する
pbox^32(x)[i] = x[base[i]] という関係から:
逆に「結果の i 番目は元の何番目から来たか」を求めるには base.index(i):
1 | # 逆置換: 結果 ct の i 番目は、CHALLENGE の base.index(i) 番目に対応 |
直感的に言うと:
- 「最終的に 0 番目に来るのは、元の何番目?」→
base.index(0)番目 - 「最終的に 1 番目に来るのは、元の何番目?」→
base.index(1)番目 - …
8. ソルバの全体像
1 | from ptrlib import * |
9. まとめ
この問題の核心は以下の1行に集約されます:
1 | encrypt(x, KEY) XOR encrypt(0, KEY) = pbox^32(x) |
| ステップ | やること | 理由 |
|---|---|---|
| 全ゼロを暗号化 | encrypt(\x00*32, KEY) を取得 |
定数部分 C(KEY) を取り出すため |
| XOR で差分を取る | CHALLENGE_CT XOR ct_of_zero |
KEY の影響を打ち消すため |
| 逆置換を適用 | pbox^32 の逆を計算 |
pbox^32(CHALLENGE) から CHALLENGE を復元するため |
脆弱性の根本: encrypt が XOR と置換だけで構成されているため、全体としてアフィン写像になってしまっています。アフィン写像は「全ゼロの暗号文を利用して鍵依存の定数を消す」という手法に対して脆弱です。真に安全な暗号(AES など)では、各ラウンドに 非線形変換(S-Box) を入れることでこのような解析を防いでいます。