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?

【セキュリティ】Attacking ECB Oracles:AES-ECB モードが引き起こす致命的な設計ミス

0
Last updated at Posted at 2026-01-05

はじめに

AES は現在も広く使われている安全な共通鍵暗号ですが、
暗号アルゴリズムそのものが安全でも、暗号モードの選択を誤ると致命的な脆弱性になります。

その代表例が ECB(Electronic Codebook)モードです。

本記事では、ECB モードを用いたシステムに対して成立する
ECB Oracle 攻撃(Byte-at-a-time 攻撃) を、原理から実践まで丁寧に解説します。


1. ECB モードとは何か?

ECB モードは、平文を 固定長ブロック(AES は 16 バイト) に分割し、
それぞれを 独立に暗号化します。

Plaintext Block 1 ──► Encrypt ──► Ciphertext Block 1
Plaintext Block 2 ──► Encrypt ──► Ciphertext Block 2
...

ECB の致命的な特徴

同じ平文ブロックは、必ず同じ暗号文ブロックになる

これが ECB の最大の欠点です。


2. なぜ ECB は危険なのか?

ECB モードでは:

  • ブロック間の関連性がない
  • ランダム性(IV)が存在しない
  • 構造がそのまま暗号文に反映される

その結果、攻撃者が平文を制御できる場合
暗号文を比較するだけで 内部情報を推測できてしまいます。


3. Oracle(オラクル)とは?

暗号攻撃における Oracle(オラクル) とは:

攻撃者が何度でも呼び出せる「暗号化サービス」

を意味します。

典型的な ECB Oracle は以下の形式です:

AES-ECB( PREFIX || user_input || SECRET )
  • PREFIX:攻撃者には見えない固定またはランダムな前置き
  • user_input:攻撃者が完全に制御可能
  • SECRET:攻撃者が盗みたい秘密情報
  • KEY:固定・非公開

4. 攻撃のゴール

この攻撃の目的はただ一つです。

暗号鍵を知らずに、SECRET を 1 バイトずつ復元する

例:

SECRET = "FLAG{ECB_IS_BROKEN}"

5. ECB Oracle 攻撃の全体像

攻撃は以下の 4 ステップで進みます。


Step 1:ECB モードであることの確認

  • "A" * 64 のような繰り返し入力を送信
  • 暗号文に 同一ブロックが複数出現すれば ECB

Step 2:ブロックサイズの特定

  • 入力長を 1 バイトずつ増やす
  • 暗号文長が増加した瞬間の差分
  • これが ブロックサイズ(通常 16 バイト)

Step 3:Prefix Offset の調整(重要)

PREFIX が存在すると、攻撃者の入力はブロック境界に揃っていません。

そのため:

  • "B" などのダミー文字を前に追加
  • 自分が制御できる入力をブロック境界に揃える
  • SECRET が含まれるブロック番号(base_index)を特定

この工程が ECB 攻撃で最もミスしやすい部分です。


6. バイト目の情報漏えい

発想の核心

未知の 1 バイトを「ブロックの最後」に追い込む

例(AES = 16 バイト):

AAAAAAAAAAAAAAA?
  • ? が SECRET の 1 バイト目

辞書攻撃(Dictionary Attack)

  1. 上記入力を送信し、対象ブロックの暗号文を保存
  2. 次に 1 バイトを総当たり:
AAAAAAAAAAAAAAAc
  1. 暗号文ブロックを比較
  2. 一致した文字 = SECRET の 1 バイト目

7. バイト目以降はどうする?

核心となる計算式

padA = block_size - 1 - (len(known) % block_size)
target_index = base_index + (len(known) // block_size)

意味は:

  • 既知文字が増えるごとに A の数を減らす
  • 16 バイトごとに SECRET は次のブロックへ移動
  • 比較対象の暗号文ブロックも 自動的に切り替える

ブロック遷移の例(AES = 16)

取得済み文字数 対象ブロック
0〜15 block 1
16〜31 block 2
32〜47 block 3

このロジックがないと、16 バイトで攻撃が止まります。


8. なぜこの攻撃が成立するのか?

理由は ECB の本質にあります。

  • ECB は 「同一平文 → 同一暗号文」 を保証する
  • 攻撃者は平文を意図的に一致させられる
  • 暗号鍵を知らなくても比較だけで推測可能

つまりこれは:

暗号解読ではなく、設計ミスの悪用

です。


9. なぜ CBC / GCM では成立しないのか?

CBC モード

  • 各ブロックが前の暗号文に依存
  • 同一平文でも結果が変わる

GCM(AEAD)

  • 暗号化 + 認証
  • 改ざんや推測は即座に検知される

ECB は現代暗号において使用してはいけないモード


10. 防御策(最重要)

やってはいけないこと

  • ECB モードの使用
  • user_input || secret のような設計
  • 暗号化結果をそのまま返す API

推奨される対策

  • AES-GCM / ChaCha20-Poly1305 を使用
  • ユーザー入力と秘密情報を直接連結しない
  • 不要な暗号化 Oracle を公開しない

まとめ

ECB Oracle 攻撃は「高度な暗号解読」ではありません。
それは「設計ミスが引き起こす必然的な情報漏えい」です。

この攻撃を理解できたなら:

  • ECB の危険性
  • ブロック暗号の構造
  • モード選択の重要性

を本質的に理解できたと言えます。


Source Code

from Crypto.Cipher import AES
from bs4 import BeautifulSoup
import binascii
import requests
import string
import ecb_brute_oracle


def oracle_bytes(username:str)->bytes:
    """
    oracle_encrypt(username) should return hex string ciphertext
    """
    hex_ct = ecb_brute_oracle.oracle_encrypt(username)
    return bytes.fromhex(hex_ct)

def split_blocks(ciphertext:bytes,block_size:int)-> list[bytes]:
    return [ciphertext[i:i+block_size] for i in range(0,len(ciphertext),block_size)]



# -------------------------
# Detect block size (bytes)
# -------------------------
def detect_block_size(max_probe:int =256)->int:
    base_len= len(oracle_bytes("A"))
    prev =base_len

    for n in range(2,max_probe+1):
        cur=len(oracle_bytes("A"*n))
        if cur>prev:
            return cur-prev
        prev=cur
    raise RuntimeError("Failed to detect block size")

def detect_prefix_alignment(bs: int) -> tuple[int, int]:
    """
    Return (pad_len, base_index)
    pad_len: how many bytes to prepend to align our controlled 'A's on a block boundary
    base_index: index of the first of the two equal adjacent blocks (where our A-blocks land)
    """
    marker = "A" * (bs * 2)  # two identical blocks

    for pad_len in range(bs):  # 0..bs-1
        pt = ("B" * pad_len) + marker
        ct = oracle_bytes(pt)
        blks = split_blocks(ct, bs)

        for i in range(len(blks) - 1):
            if blks[i] == blks[i + 1]:
                return pad_len, i

    # If never found, assume no random prefix; base_index=0 is a reasonable fallback
    return 0, 0

def leak_next_byte(bs: int, pad_len: int, base_index: int, known: str, charset: str) -> str | None:
    """
    Leaks the next unknown byte of SECRET (as a single character), or None if not found.
    """
    # how many 'A' to make the next unknown byte fall at end of a block
    padA = bs - 1 - (len(known) % bs)

    # prefix aligns our controlled area
    prefix = ("B" * pad_len) + ("A" * padA)

    # target block index rolls over every bs bytes leaked
    target_index = base_index + (len(known) // bs)

    # reference: encryption of prefix only
    ref_ct = oracle_bytes(prefix)
    ref_blocks = split_blocks(ref_ct, bs)
    if target_index >= len(ref_blocks):
        return None

    ref_block = ref_blocks[target_index]

    # dictionary match: prefix + known + guess
    for ch in charset:
        test_pt = prefix + known + ch
        test_ct = oracle_bytes(test_pt)
        test_blocks = split_blocks(test_ct, bs)

        if target_index < len(test_blocks) and test_blocks[target_index] == ref_block:
            return ch

    return None

def leak_secret(bs: int, pad_len: int, base_index: int, max_len: int = 256) -> str:
    charset = string.ascii_letters + string.digits + "{}_:-@!$.,/+=()[]<>\"'\\| "
    known = ""

    for _ in range(max_len):
        ch = leak_next_byte(bs, pad_len, base_index, known, charset)
        if ch is None:
            break
        known += ch
        print("Leaked so far:", known)

    return known
        



if __name__ == "__main__":
    print("Testing oracle...")
    print("ct(hex) for SuperUser:", ecb_brute_oracle.oracle_encrypt("SuperUser"))

    print("\nDetecting block size...")
    bs = detect_block_size()
    print("Block size (bytes):", bs)

    print("\nDetecting prefix alignment (pad_len) and base_index...")
    pad_len, base_index = detect_prefix_alignment(bs)
    print("pad_len:", pad_len, "base_index:", base_index)

    print("\nLeaking secret...")
    secret = leak_secret(bs, pad_len, base_index, max_len=256)
    print("\nFinal leaked secret:", secret)

ecb_brute_oracle.py

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import os

BLOCK_SIZE=16
KEY=os.urandom(16)
PREFIX =os.urandom(7)
SECRET =b"FLAG{ECB_IS_BROKEN}"

def oracle_encrypt(username:str)->str:
    pt = PREFIX+username.encode("utf-8")+SECRET
    pt=pad(pt,BLOCK_SIZE)

    # ECB 暗号文
    ct= AES.new(KEY,AES.MODE_ECB).encrypt(pt)

    return ct.hex()

結果

eaking secret...
Leaked so far: F
Leaked so far: FL
Leaked so far: FLA
Leaked so far: FLAG
Leaked so far: FLAG{
Leaked so far: FLAG{E
Leaked so far: FLAG{EC
Leaked so far: FLAG{ECB
Leaked so far: FLAG{ECB_
Leaked so far: FLAG{ECB_I
Leaked so far: FLAG{ECB_IS
Leaked so far: FLAG{ECB_IS_
Leaked so far: FLAG{ECB_IS_B
Leaked so far: FLAG{ECB_IS_BR
Leaked so far: FLAG{ECB_IS_BRO
Leaked so far: FLAG{ECB_IS_BROK
Leaked so far: FLAG{ECB_IS_BROKE
Leaked so far: FLAG{ECB_IS_BROKEN
Leaked so far: FLAG{ECB_IS_BROKEN}

Final leaked secret: FLAG{ECB_IS_BROKEN}

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?