はじめに
Bit Flipping Attack(ビット反転攻撃)は、
暗号アルゴリズムを破らずに、
暗号文を改ざんして復号後の平文を変化させる攻撃
です。
この攻撃は AES が弱いから起きるのではありません。
暗号化はしているが、改ざん検知をしていない
――つまり Unauthenticated Encryption を使っている場合に成立します。
本記事では、
"user" → "root" という具体例を使って、
Bit Flipping Attack が なぜ・どうやって成立するのかを解説します。
1. Bit Flipping Attack とは
Bit Flipping Attack とは、
- 暗号文の 一部のビットを反転(XOR)
- その結果、復号後の平文が 意図した値に変わる
という攻撃です。
特徴は次の通りです:
- 秘密鍵は不要
- 暗号の復号は正常に成功する
- サーバは改ざんに気づかない
つまり、
「復号できた=正しい」と信じる実装が攻撃される
という攻撃です。
2. AES-CBC が Bit Flipping に弱い理由
AES-CBC モードの復号処理は次の式で表されます:
P_i = D_K(C_i) XOR C_{i-1}
-
P_i:復号後の平文ブロック -
C_i:現在の暗号ブロック -
C_{i-1}:前の暗号ブロック(最初は IV)
重要なポイント
- 攻撃者は
C_{i-1}(または IV)を書き換えられる - XOR の性質により、平文がその差分だけ変化する
これが Bit Flipping(ビット反転) です。
3. "user" → "root" はなぜ可能なのか
3.1 文字数が同じ
-
"user"→ 4文字 -
"root"→ 4文字
Bit Flipping では 文字数は変えられません。
同じ長さだからこそ成立します。
3.2 ASCII と XOR 差分の計算
"user" の ASCII
| 文字 | hex |
|---|---|
| u | 0x75 |
| s | 0x73 |
| e | 0x65 |
| r | 0x72 |
"root" の ASCII
| 文字 | hex |
|---|---|
| r | 0x72 |
| o | 0x6f |
| o | 0x6f |
| t | 0x74 |
XOR 差分
| 位置 | user | root | XOR |
|---|---|---|---|
| 0 | 0x75 | 0x72 | 0x07 |
| 1 | 0x73 | 0x6f | 0x1c |
| 2 | 0x65 | 0x6f | 0x0a |
| 3 | 0x72 | 0x74 | 0x06 |
この差分を 前の暗号ブロック(または IV)に XOR すると、
"user" XOR diff = "root"
になります。
4. 実験コード
4.1 脆弱な AES-CBC トークンを生成
依存関係
pip install pycryptodome
トークン生成コード
def encrypt_cbc(plaintext:bytes,key:bytes) -> bytes:
iv = get_random_bytes(BLOCK)
cipher = AES.new(key,AES.MODE_CBC,iv)
ciphertext = cipher.encrypt(pad(plaintext,BLOCK))
return iv+ciphertext
このトークンは:
- AES-CBC
- IV を含む
- MAC / 認証タグなし
Bit Flipping に完全に脆弱です。
4.2 "user" → "root" に Bit Flip する
def bitflip_prev_block(token:bytes,target_plain_block_index:int,offset_in_block:int,diff:bytes)->bytes:
"""
CBCの基本:Pi を変えたいなら C_{i-1} を XOR する(i=1ならIV)
target_plain_block_index: 0-based の平文ブロック番号 (P1=0, P2=1, ...)
offset_in_block: 対象平文ブロック内で置換を始めるオフセット (0..15)
diff: old XOR new のバイト列
"""
raw= bytearray(token)
# 直前ブロック開始位置(IVをブロック0とみなす)
prev_block_index = target_plain_block_index
prev_start = prev_block_index*BLOCK
prev_end= prev_start+BLOCK
if prev_end > len(raw):
raise ValueError("ブロック指定がトークン長を超えています")
if offset_in_block+len(diff) >BLOCK:
raise ValueError("offset+len(diff)が16バイト境界を超えています")
for i,d in enumerate(diff):
raw[prev_start+offset_in_block+i]^=d
return bytes(raw)
この modified token を復号すると:
{"role":"root"}
になります。
4.3 トークンから平文に復号する
def decrypt_cbc(token:bytes,key:bytes)->bytes:
if len(token) < BLOCK * 2:
raise ValueError(f"token too short: {len(token)} bytes")
if len(token) % BLOCK != 0:
raise ValueError(f"token length not multiple of block: {len(token)}")
iv, ct = token[:BLOCK], token[BLOCK:]
cipher = AES.new(key, AES.MODE_CBC, iv)
pt_padded = cipher.decrypt(ct)
return unpad(pt_padded, BLOCK)
5.なぜサーバは気づかないのか
- 復号は正常に成功する
- MAC / 署名を検証していない
- 復号結果をそのまま信用している
改ざんは検出されない
6. Bit Flipping Attack の成立条件まとめ
| 条件 | 内容 |
|---|---|
| 暗号 | AES-CBC など |
| 認証 | なし(MACなし) |
| 実装 | 復号結果を信頼 |
| 攻撃 | 暗号文を改ざん可能 |
7.対策(重要)
7.1推奨
- AES-GCM
- ChaCha20-Poly1305
7.2 CBC を使うなら
- Encrypt-then-MAC
- 復号前に MAC 検証
まとめ
- Bit Flipping Attack は 暗号の設計ミスを突く攻撃
-
"user"→"root"は XOR 差分で実現できる - 鍵は不要、暗号は壊れていない
- 暗号化だけでは安全ではない
Source Code
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from Crypto.Util.Padding import pad,unpad
from binascii import hexlify,unhexlify
BLOCK =16
def encrypt_cbc(plaintext:bytes,key:bytes) -> bytes:
iv = get_random_bytes(BLOCK)
cipher = AES.new(key,AES.MODE_CBC,iv)
ciphertext = cipher.encrypt(pad(plaintext,BLOCK))
return iv+ciphertext
def decrypt_cbc(token:bytes,key:bytes)->bytes:
if len(token) < BLOCK * 2:
raise ValueError(f"token too short: {len(token)} bytes")
if len(token) % BLOCK != 0:
raise ValueError(f"token length not multiple of block: {len(token)}")
iv, ct = token[:BLOCK], token[BLOCK:]
cipher = AES.new(key, AES.MODE_CBC, iv)
pt_padded = cipher.decrypt(ct)
return unpad(pt_padded, BLOCK)
def xor_diff(old:bytes,new:bytes)->bytes:
if len(old)!=len(new):
raise ValueError("old/new は同じ長さが必要です")
return bytes([a^b for a,b in zip(old,new)])
def bitflip_prev_block(token:bytes,target_plain_block_index:int,offset_in_block:int,diff:bytes)->bytes:
"""
CBCの基本:Pi を変えたいなら C_{i-1} を XOR する(i=1ならIV)
target_plain_block_index: 0-based の平文ブロック番号 (P1=0, P2=1, ...)
offset_in_block: 対象平文ブロック内で置換を始めるオフセット (0..15)
diff: old XOR new のバイト列
"""
raw= bytearray(token)
# 直前ブロック開始位置(IVをブロック0とみなす)
prev_block_index = target_plain_block_index
prev_start = prev_block_index*BLOCK
prev_end= prev_start+BLOCK
if prev_end > len(raw):
raise ValueError("ブロック指定がトークン長を超えています")
if offset_in_block+len(diff) >BLOCK:
raise ValueError("offset+len(diff)が16バイト境界を超えています")
for i,d in enumerate(diff):
raw[prev_start+offset_in_block+i]^=d
return bytes(raw)
if __name__=="__main__":
key=get_random_bytes(16)
old_role = b"user"
new_role = b"root"
diff=xor_diff(old_role,new_role)
plaintext = b'{"role":"user","x":"1"}'
token =encrypt_cbc(plaintext,key)
print("[Origin token]",token)
offset = plaintext.index(b"user")
target_plain_block_index =0 # P1 を狙う → 直前は IV
modified =bitflip_prev_block(token,target_plain_block_index,offset,diff)
print("[Modified Token]",modified)
before = decrypt_cbc(token,key)
after = decrypt_cbc(modified,key)
print("[Before]",before)
print("[After]",after)
実行結果
[Origin token] b'\x0b\xdc\x9beak;\xb4?\x89\xc2\xc7\xa9\xae\xf8\xdd\xde\xd4\xb6<\xf4I\xb2\xec\xa1l\x14}a\xd3\xb0K8\xf1\xfe\x95j=\xc9rDl\x04\x02\x9cH\x14\xab'
[Modified Token] b'\x0b\xdc\x9beak;\xb4?\x8e\xde\xcd\xaf\xae\xf8\xdd\xde\xd4\xb6<\xf4I\xb2\xec\xa1l\x14}a\xd3\xb0K8\xf1\xfe\x95j=\xc9rDl\x04\x02\x9cH\x14\xab'
[Before] b'{"role":"user","x":"1"}'
[After] b'{"role":"root","x":"1"}'