FEAT:🚩 CryptoHack 「Adrien's Signs (Modular Arithmetic)」
モジュラ演算におけるルシャンドル記号の性質を利用して,ビット化されたFlagを逆算するCrypto問題
20260730-crypto_hack-adriens_signs
Summary
本問は,モジュラ演算におけるルシャンドル記号の性質を利用して,ビット化されたFlagを逆算するCrypto問題です.
- Category: Crypto
- Description: アドリアンは、記号やマイナス記号を使ってメッセージを暗号化する方法を探っています。フラグを復元する方法を見つけられますか?
- Tools & TechStack:
- Python
- Release: N/A
階層構造
1
2
3
4
5
.
├── output_80fc6398d2fd9f272186d0af510323f9.txt
└── source_734d7e14251f950935f83d228f8694ab.py
1 directory, 2 files
ソースコードの調査
- フラグ文字列を
10110011...のような0と1のビット列に変換する. - 1ビットずつループを回し,毎回ランダムな数 $e$ を生成する.
- そのビットが
1なら,$a^e \bmod p$ を暗号文としてリストに追加する. - そのビットが
0なら,$-(a^e) \bmod p$ を暗号文としてリストに追加する.
ここで,$e$ は $0 \leq e \leq p$ の範囲でランダムに変化します.暗号文の数字から,$e$ を逆算することは,巨大な素数 $p$ では不可能です.
そのため,乱数 $e$ が変化しても,変わらない性質を見つけることが必要です.
source_734d7e14251f950935f83d228f8694ab.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from random import randint
a = 288260533169915
p = 1007621497415251 # 素数であり,(p % 4) == 3 を満たす
FLAG = b'crypto{????????????????????}'
def encrypt_flag(flag):
ciphertext = []
# 8bitの2進数に変換
plaintext = ''.join([bin(i)[2:].zfill(8) for i in flag])
# 文字毎に演算
for b in plaintext:
# e は 1 以上 p 以下の整数
e = randint(1, p)
n = pow(a, e, p)
if b == '1':
ciphertext.append(n)
else:
n = -n % p
ciphertext.append(n)
return ciphertext
print(encrypt_flag(FLAG))
解法
そこで,以下のルシャンドル記号の性質を用いることを考えました.
- $2次剰余 \times 2次剰余 = 2次剰余$
- $2次剰余 \times 平方非剰余 = 平方非剰余$
- $平方非剰余 \times 平方非剰余 = 2次剰余$
固定値 $a$ と $p$ を 式(1) のルシャンドル記号の定義式に代入して計算してみると 二次剰余 になるような数字が選ばれています.
$a$ が2次剰余であるのならば,それを何回か掛けた $a^{e}$ もルシャンドル記号の性質から必ず2次剰余になります.
そのため,式(2) より $e$ がどんな値においても,$a^{e}$ は常にルシャンドル記号が 1 になります.
元のビットが 0 だった場合
次に,元のビットが 0 だった場合の処理 n = -n % p について考えます.
モジュラ演算の世界においても,$-n$ は $-1 \times n$ と同じです.
よって,暗号文は以下の2通りになります.
- ビットが
1の時: $a^{e}$ - ビットが
0の時: $-1 \cdot a^{e}$
これらをルシャンドル記号で計算すると,以下になります.
- ビット
1の判定: $(a^e / p) = 1$ - ビット
0の判定: $(-1 \cdot a^e / p) = (-1 / p) \times (a^e / p)$
ここで,$(a^e / p) = 1$ であることは先ほど証明しました.
したがって,ビット 0 の判定結果は $-1$ が2次剰余か平方非剰余か (つまり $(-1 / p)$ が $1$ か $-1$ か) という1点のみに依存することになります.
1
2
>>> True if (1007621497415251 % 4) == 3 else False
True
$p$ の条件
$p = 1007621497415251$ を factordb1 で素因数分解してみると,$p \equiv 3 \pmod 4$ を満たす素数 であることが分かりました.
暗号を解読するためには,ビット 0 の判定結果が,ビット 1 の判定結果と明確に区別できなければなりません.
つまり,$(-1 / p)$ が絶対に $-1$ (平方非剰余) になってほしい ので,そのような $p$ が存在しなければいけません.
式(1) を用いて $(-1 / p)$ を計算してみると,以下の 式(3) のようになります.
\[(-1 / p) \equiv (-1)^{(p-1)/2} \pmod p\tag{3}\]$-1$ を何乗かする計算なため,結果が $-1$ になるためには,指数である $(p-1)/2$ が 奇数 でなければなりません.
ここで,$p \equiv 3 \pmod 4$ が満たされていれば,この条件は,$p$ は 4の倍数に3を足した数であると言いかえれるため,$p = 4k + 3 \ \text{(kは整数)}$ と置くことができます.
これを,指数部に代入すると,式(4) と変形でき,$2k + 1$ は奇数であるため,$(-1)^{\text{奇数}} = -1$ となり,$(-1 / p) = -1$ が保証されます.
ソルバを書く
solver.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ENCRYPTED_FLAG = [67594220461269, 501237540280788] # 省略
A = 288260533169915
P = 1007621497415251
def decrypt_flag(encrypted):
flag = ""
for enc in encrypted:
# ルシャンドル記号を計算
legendre = pow(enc, (P - 1) // 2, P)
if legendre == 1:
flag += str(1)
else:
flag += str(0)
return "".join([chr(int(flag[i : i + 8], 2)) for i in range(0, len(flag), 8)])
print(decrypt_flag(ENCRYPTED_FLAG))
1
2
3
4
5
6
$ python3 solver.py
Legendre symbol: 1
Legendre symbol: 1
Legendre symbol: 1007621497415250 # P - 1
Legendre symbol: 1
crypto{REDACTED}
Post-Mortem & Dead ends
CryptoHackで学んだ知識を使えて,めっちゃ面白かった!
けど,発想が普通に難しかった…