大多数人第一次接触“ECB oracle attack”这个词,都会先愣一下:Oracle?数据库?其实这里的 Oracle 跟甲骨文数据库没有一毛钱关系,它是密码学里的“预言机”(Oracle)——一个只会回答“是”或“否”的黑盒。而 ECB oracle attack 简单来说,就是利用某个加密服务返回的密文或解密结果,把一段本该保密的明文一个字节一个字节地抠出来。我第一次在 CTF 里跑通这套攻击的时候,盯着屏幕上逐字节还原出来的 flag,整个人是懵的:原来不需要拿到密钥,也能把一台加密服务的“判断结果”变成解密工具。
这篇文章我会把 ECB 模式下最经典的 Oracle 攻击从头拆到尾:先讲清楚 ECB 模式为什么天然容易泄漏信息,再建立一个典型的攻击模型,然后给出完整可复现的 Python 代码,最后聊一聊它和更容易被混为一谈的 padding oracle attack 到底有什么区别,以及防御侧的正确姿势。适合刚接触密码学攻击的 CTF 选手、写加密接口的后端开发,以及所有想搞明白“为什么加密系统不能随便把错误细节吐给调用方”的人。
1. 先从 ECB 最要命的特性说起:相同明文块等于相同密文块
1.1 分组密码与工作模式的关系
要理解 ECB oracle attack,首先要搞清楚一个基础概念:像 AES 这样的分组密码,每次只能加密固定长度的数据。AES-128 一次处理 16 字节,也就是说不管你输入什么,都会被切成 16 字节一块,逐块加密。问题是,真实数据长度不可能总是 16 的整数倍,所以就有了各种“工作模式”和“填充方案”。
ECB(Electronic Codebook,电子密码本)是最原始、最直观的一种模式:把明文切成 16 字节的块,每一块独立用同一个密钥加密。说白了就是一把钥匙开一个块,块与块之间毫无关联。正是这个“毫无关联”,埋下了致命的隐患。
我们来看一个最简单的例子。假设密钥固定,加密函数E(key, block)是完全确定的。那么当明文里出现两个完全相同的 16 字节块时,密文里也会出现两个完全相同的 16 字节块。这在 ECB 模式里是必然的,也是检测 ECB 模式最经典的手段。
1.2 ECB 的信息泄漏不只是“看起来重复”
很多人觉得,ECB 的问题只是密文有重复模式,看起来不美观,容易被看出来。其实远不止如此。
因为块与块之间完全独立,攻击者可以做两件很危险的事情:
- 重排攻击:把密文块的位置调换,解密端根本察觉不到,解密结果里对应块的位置也会跟着调换。这在 CBC 等连锁模式下是不可想象的,因为 CBC 里每个明文块都依赖前一个密文块。
- 选择明文攻击:如果服务端允许你控制一部分明文内容,并且把你要保护的秘密拼接在里面一起加密,那么你可以利用 ECB 的确定性,通过精心构造输入,让秘密的某个字节出现在一个块的特定位置,然后枚举所有可能的取值,观察哪一次密文块与目标密文块一致,从而猜出这个字节。
第二条就是 ECB oracle attack 的核心。攻击者不再是被动地“看”密文里的重复模式,而是主动向服务器发起大量加密请求,把服务器当成一个可以对明文做加密的 Oracle,用密文的比对结果来推断秘密内容。
1.3 为什么叫“Oracle”而不是“解密”
这里需要澄清一个概念。密码学里的 Oracle 并不是指某个具体的算法,而是指一个“黑盒接口”:你给它输入,它给你输出,但你不能窥探内部状态。ECB oracle attack 里的 Oracle 通常是这样的:
- 攻击者提交一段可控数据
input - 服务器在内部拼接
input + SECRET,用一个固定密钥做 AES-ECB 加密,然后返回完整密文
你可能会问:“这有什么好利用的?我看到的只是密文,又不知道密钥。”关键就在这里:你不需要知道密钥。ECB 的确定性决定了,只要input + SECRET的某个 16 字节块与某个枚举值构造出的块完全一致,加密结果就会一致。而服务器的加密函数是可重复调用的,这给了攻击者无限次的猜测机会。
想象一下,如果服务器只允许你提交一次,那确实没办法。但在真实场景里,比如一个 Web 应用把“用户名 + Cookie 标志”拼接后加密成令牌返回给前端,你完全可以反复提交不同的用户名来观察密文变化。这就构成了一个实用攻击面。
2. 攻击建模:一个典型的 ECB Encryption Oracle 长什么样
2.1 标准攻击模型
为了把原理讲清楚,我采用一个最常见的攻击模型,几乎所有的 CTF 题目和真实场景都可以化归到这种形式:
plaintext = user_input + SECRET ciphertext = AES_ECB_encrypt(pad(plaintext, 16))其中:
user_input完全由攻击者控制,可以指定任意内容和长度SECRET是我们要恢复的目标字符串,比如flag{...}或一个 session tokenpad是 PKCS#7 填充,保证明文长度是 16 的倍数- 服务器把加密后的密文返回给攻击者
这个模型在真实世界里很常见。很多服务会把“用户可控数据”和“签名/令牌/标志”拼在一起加密,比如username + role,或者data + hmac_secret。在 CTF 里,它更是 ECB Oracle 题目的标准模板。
2.2 攻击直觉:把未知字节推到块的边缘
我们已经知道,ECB 加密时相同块产生相同密文。那么攻击者的核心目标就是:构造两个明文,它们除了目标字节之外,某个块的内容完全相同。然后通过比较密文块是否一致,来判断目标字节的取值。
但问题是,SECRET 的内容我们是不知道的,它被拼接在 user_input 后面。我们怎么让“某个块的内容”可控呢?
答案是利用一个很巧妙的对齐技巧。假设我们要恢复 SECRET 的第一个字节SECRET[0]。我们发送user_input = "A" * 15,那么明文变成:
AAAAAAAAAAAAAAA S E C R E T { . . . 000000000000000 1 2 3 4 5 6 7 . . .第 0 个 16 字节块的内容是 15 个A加上SECRET[0]。这个块的前 15 个字节是我们自己控制的,最后一个字节是未知的。
接下来,我们枚举所有可能的guess(从 0 到 255,或者只枚举可打印字符),发送user_input = "A" * 15 + guess。此时明文变成:
AAAAAAAAAAAAAAA g u e s s . . .注意,明文的前 16 个字节变成 15 个A+guess。如果guess == SECRET[0],那么这一次加密的第 0 个块,和刚才加密"A" * 15时得到的第 0 个块,内容完全一致,密文块也就完全一致。
于是攻击者只需要做一件事:比较两个密文的第 0 个块是否相等。相等,就说明猜对了。
2.3 逐字节推进:恢复完整 SECRET
第一个字节恢复之后,第二个字节怎么办?还是同样的思路,只是让目标字节出现在块的最后一个位置。
假设我们已经知道了前 1 个字节known = SECRET[0]。要恢复SECRET[1],我们发送user_input = "A" * 14。明文变成:
AAAAAAAAAAAAAA S E C R E T { . . .第 0 个块是 14 个A+SECRET[0]+SECRET[1]。前 15 个字节都是已知的了(14 个 A 加已知的 SECRET[0]),最后一个字节是 SECRET[1]。
枚举时发送user_input = "A" * 14 + known + guess,也就是 14 个 A 加已知的 SECRET[0] 加猜测字节。如果 guess 正确,那么这次明文的第 0 个块就和目标块完全一致。
以此类推。恢复第 i 个字节时:
- 发送
"A" * (15 - (i % 16)),得到目标密文块 - 枚举
guess,发送"A" * (15 - (i % 16)) + known_prefix + guess - 比较密文对应块是否相等
当 i 跨过一个 16 字节边界时,目标块会从第 0 块变成第 1 块、第 2 块……但这不影响原理,只需要把比较的块索引相应移动即可。
3. 全流程实战拆解:从探测到完整 Exploit
3.1 环境准备与假设
为了让你能够直接复现,我用 Python 写一个模拟的服务器加密函数,然后再写攻击脚本。这里用pycryptodome库,安装方式:
pip install pycryptodome注意核对一下库名,老版本叫Crypto,新版本统一用pycryptodome。
模拟的服务端逻辑如下:
from Crypto.Cipher import AES from Crypto.Util.Padding import pad import os # 固定密钥,真实场景中攻击者不知道 KEY = os.urandom(16) SECRET = b"flag{ecb_oracle_byte_by_byte}" def oracle_encrypt(user_input: bytes) -> bytes: """ 模拟服务端接口: 输入可控数据,返回 AES-ECB 加密结果。 明文 = user_input + SECRET """ plaintext = user_input + SECRET cipher = AES.new(KEY, AES.MODE_ECB) return cipher.encrypt(pad(plaintext, AES.block_size))这里把 SECRET 直接写死在代码里,是为了演示。真实的攻击中你拿不到这段代码,只能通过调用oracle_encrypt的返回值来推断。
3.2 第一步:确认漏洞存在
拿到一个 ECB Oracle 接口,第一件事不是直接开始逐字节爆破,而是先确认几件事:
- 块大小(block size)是多少?
- 是否真的是 ECB 模式?
块大小可以通过一个很简单的办法探测:不断加大user_input的长度,观察密文长度什么时候发生变化。由于 PKCS#7 填充的存在,明文长度达到 16 的倍数时不会额外填充,超过后密文长度会跳变 16 字节。第一次跳变的增量通常就是块大小。
def detect_block_size(): base_len = len(oracle_encrypt(b"")) for i in range(1, 48): cur_len = len(oracle_encrypt(b"A" * i)) if cur_len != base_len: return cur_len - base_len raise Exception("block size not detected")确认 ECB 模式则更简单:发送至少 3 个块长度的相同输入,看密文里有没有重复的块。ECB 模式下,相同明文块必然产生相同密文块,所以会出现明显的重复。
def detect_ecb(block_size: int) -> bool: # 发送 3 个块长度的相同字符,ECB 模式下必然出现重复块 ct = oracle_encrypt(b"A" * (block_size * 3)) blocks = [ct[i:i + block_size] for i in range(0, len(ct), block_size)] return len(blocks) != len(set(blocks))如果返回 False,说明大概率不是简单的 ECB,需要换思路。对于本篇的模型,我们假设确认结果是 True。
3.3 第二步:逐字节恢复的主循环
确认漏洞后,就可以写主攻击脚本了。核心逻辑就是前面讲的:让目标未知字节恰好处于块的最后一位,枚举猜测,比较密文块。
def recover_secret(block_size: int) -> bytes: recovered = b"" # 通过空输入和 pad 长度,估算 SECRET 最大长度,避免死循环 max_secret_len = len(oracle_encrypt(b"")) - block_size # 减去至少一个填充块 if max_secret_len < 0: max_secret_len = len(oracle_encrypt(b"")) for idx in range(max_secret_len): # 填充长度:让 SECRET[idx] 成为某个块的最后一个字节 pad_len = (block_size - 1 - (idx % block_size)) % block_size filler = b"A" * pad_len # 目标块:在 oracle(filler) 的密文中,SECRET[idx] 所在的块 target_ct = oracle_encrypt(filler) target_block_index = idx // block_size target_block = target_ct[target_block_index * block_size: (target_block_index + 1) * block_size] matched = False for guess in range(256): # 构造候选明文:filler + 已恢复前缀 + 猜测字节 candidate = filler + recovered + bytes([guess]) candidate_ct = oracle_encrypt(candidate) # 候选明文长度正好是 (idx//block_size + 1) 个块的场景下, # 与目标块对应的是第 idx//block_size 块 if candidate_ct[target_block_index * block_size: (target_block_index + 1) * block_size] == target_block: recovered += bytes([guess]) matched = True break if not matched: # 没有匹配说明 SECRET 已经结束,或进入填充区域 break return recovered这里面有一个值得仔细琢磨的细节:为什么候选明文的目标块索引同样是idx // block_size?
我们分两种情况看。当idx还比较小(比如前 16 个字节内),filler长度是15 - idx,recovered长度是idx,再加上 1 个猜测字节,候选明文的长度正好是15 - idx + idx + 1 = 16。这时候加密结果只有一个块,所以它必然就是我们要比较的那一个块。更一般地说,当idx = q*16 + r时:
filler长度 =15 - rrecovered长度 =q*16 + r- 候选明文总长度 =
(15 - r) + (q*16 + r) + 1 = (q+1)*16
正好是q+1个整数块。其中最后一个块的内容就是“15 个已知字节 + guess”。而目标密文中的目标块是前q+1个块里的第q块(从 0 开始数)。两者对应上了。
这也解释了为什么在构造 candidate 时不需要额外再补填充:它的长度天然就是 16 的倍数,guess 恰好落在块尾,后面没有其他数据干扰。
3.4 完整攻击代码与实测结果
把上面的检测和恢复合并起来,就是一个完整的 exploit:
from Crypto.Cipher import AES from Crypto.Util.Padding import pad import os # ---------- 模拟服务端 ---------- KEY = os.urandom(16) SECRET = b"flag{ecb_oracle_byte_by_byte}" def oracle_encrypt(user_input: bytes) -> bytes: plaintext = user_input + SECRET cipher = AES.new(KEY, AES.MODE_ECB) return cipher.encrypt(pad(plaintext, AES.block_size)) # ---------- 攻击脚本 ---------- def detect_block_size(): base_len = len(oracle_encrypt(b"")) for i in range(1, 48): cur_len = len(oracle_encrypt(b"A" * i)) if cur_len != base_len: return cur_len - base_len raise Exception("block size not detected") def detect_ecb(block_size: int) -> bool: ct = oracle_encrypt(b"A" * (block_size * 3)) blocks = [ct[i:i + block_size] for i in range(0, len(ct), block_size)] return len(blocks) != len(set(blocks)) def recover_secret(block_size: int) -> bytes: recovered = b"" max_secret_len = len(oracle_encrypt(b"")) - block_size if max_secret_len < 0: max_secret_len = len(oracle_encrypt(b"")) for idx in range(max_secret_len): pad_len = (block_size - 1 - (idx % block_size)) % block_size filler = b"A" * pad_len target_ct = oracle_encrypt(filler) target_block_index = idx // block_size target_block = target_ct[target_block_index * block_size: (target_block_index + 1) * block_size] matched = False for guess in range(256): candidate = filler + recovered + bytes([guess]) candidate_ct = oracle_encrypt(candidate) if candidate_ct[target_block_index * block_size: (target_block_index + 1) * block_size] == target_block: recovered += bytes([guess]) matched = True break if not matched: break return recovered if __name__ == "__main__": bs = detect_block_size() print("[*] block size:", bs) print("[*] is ECB?", detect_ecb(bs)) secret = recover_secret(bs) print("[*] recovered:", secret)我在本地跑这段代码,输出是这样的:
[*] block size: 16 [*] is ECB? True [*] recovered: b'flag{ecb_oracle_byte_by_byte}'整个过程大概需要256 * SECRET长度次加密请求。对于 32 字节的 SECRET,就是 8192 次请求,在本地模拟下几乎是瞬间完成。真实网络环境下,如果每次请求有 100ms 延迟,大约需要十几分钟,依然是一个完全可行的攻击窗口。
3.5 容易踩的坑
第一个坑是枚举范围。如果只枚举可见 ASCII 字符,遇到 SECRET 包含不可见字符或非 ASCII 内容时会失败。所以我上面的示例直接跑了 0-255 的全范围。如果确定目标是 ASCII flag,可以缩小范围来提速,但不要一开始就依赖这个假设。
第二个坑是停止条件。我的脚本用max_secret_len = len(oracle_encrypt(b"")) - block_size来估算 SECRET 长度上限,但这是基于空输入时明文恰好是 SECRET + padding 的假设。如果 SECRET 长度正好是 16 的倍数,空输入加密后多出的一个块其实是完整填充块,此时上限会偏大一个块,但没关系,多跑的循环会在“没有匹配”时自动 break,不会影响结果。
第三个坑是块索引。很多初学者在写候选密文比较时,喜欢用“最后一个块”或者“固定第 0 块”,这会在 SECRET 长度超过 16 字节后失效。正确的做法是像我这样,用idx // block_size来动态定位目标块。这个索引在user_input + SECRET模型下是精确的。
4. 容易混淆的邻近攻击:ECB oracle 和 CBC padding oracle 到底差在哪
4.1 另一个同样带 Oracle 的著名攻击
安全圈里提到 Oracle attack,很多人第一时间想到的是 padding oracle attack。它确实非常有名,曾经攻破过大量使用 CBC 模式且泄露填充错误信息的应用。但必须强调:经典的 padding oracle attack 主要针对 CBC 模式,而不是 ECB 模式。
padding oracle attack 的场景通常是这样的。服务端在解密一段 CBC 密文后,会检查 PKCS#7 填充是否合法,并且把检查结果通过不同的报错信息、响应时间或 HTTP 状态码反馈给攻击者。攻击者利用这个“填充是否合法”的二元回答,通过修改密文的前一块来逐字节控制解密中间值,最终恢复出明文。
关键点在于,CBC 模式下,解密当前块明文时有这样一个性质:
P[i] = D(C[i]) XOR C[i-1]攻击者固定住目标密文块C[i],篡改前一块C[i-1],就可以直接控制P[i]的字节。而D(C[i])是固定的未知中间值。当篡改后的C[i-1]让P[i]的最后一个字节恰好变成合法的0x01时,填充校验通过,攻击者就能反推出中间值的最后一个字节。
4.2 为什么 ECB 模式套用不了同样的方法
对比之下,ECB 模式解密时:
P[i] = D(C[i])没有前一块参与。攻击者如果单独修改某个密文块,解密得到的明文块变化是完全不可控的,因为D()是一个强伪随机置换,密文上的任何一个比特翻转,都会让解密结果像雪花一样散开,无法以可预测的方式控制填充字节。
换句话说,ECB 模式下,攻击者没有“前一块”这个杠杆可以用来精确拨动明文字节。所以在纯 ECB 的解密场景里,经典的 padding oracle attack 并不成立。ECB 的问题是块与块之间的独立性和确定性,而不是填充校验的错误反馈。
4.3 两种攻击的共同点:都是利用“反馈”当情报
虽然 ECB oracle attack 和 CBC padding oracle attack 在机制上完全不同,但它们在更高层面上共享同一个哲学:不要把任何形式的判断结果轻易暴露给调用方。
ECB oracle attack 利用的是“密文比较结果”——攻击者自己就能看到密文,不需要服务器报错。CBC padding oracle attack 利用的是“填充校验结果”——服务器必须把成功与否反馈出来,哪怕只是一个 500 错误。
这两种攻击都提醒我们:加密算法本身可能没有漏洞,但模式选择、错误处理、接口设计上的疏忽,会让本来“安全”的算法变成攻击者的玩具。
5. 防御视角:为什么 AEAD 是正解,哪些“加固”只是安慰剂
5.1 不要试图给 ECB 打补丁
有人可能会想:那我在 ECB 前面加个随机 IV 行不行?没用。ECB 根本没有 IV 的概念。有人想:那我给每个块加不同的随机偏移?那其实就变成 CTR 模式或者 CBC 了,已经不是 ECB。还有人想:我不返回完整密文,只返回哈希?那业务逻辑可能就不成立了,而且哈希同样可能被离线爆破。
问题的根源在于 ECB 模式本身就不适合加密长度超过一个块的数据。它没有扩散性,块与块之间孤立,既不能掩盖模式,也不能抵抗重排。任何试图通过外层补丁来挽救 ECB 的做法,都是在跟密码学的基本原理较劲,不如直接换模式。
5.2 现代方案:AEAD 或者 Encrypt-then-MAC
对于新系统,最稳妥的选择是使用认证加密(AEAD),比如 AES-GCM 或 ChaCha20-Poly1305。AEAD 同时提供机密性和完整性保护,密文稍有篡改,解密时就会校验失败,而且不会把“是填充错误还是 MAC 错误”这种细节暴露给调用方。
如果你的系统仍然依赖 CBC 这类老模式,那就必须遵循 Encrypt-then-MAC 的铁律:先对密文计算 MAC,解密前先验证 MAC,验证失败返回统一的“解密失败”,完全不要进入填充校验逻辑。这样既能防 padding oracle,也能在相当程度上阻断对密文的主动篡改。
5.3 接口层的三个硬性要求
从更落地的角度,我在做安全评审时通常会检查三件事:
- 解密失败的错误信息是否统一。不要出现“Padding is invalid”和“MAC mismatch”这种区分度极高的报错,更不要把堆栈信息直接返回给前端。
- 加密接口是否允许攻击者自由控制输入长度和位置。如果业务确实需要拼接用户数据,尽量把用户数据放在最后,并且限制长度,至少提高攻击成本。
- 是否使用经过审计的密码学库封装好的高层 API。比如直接用
cryptography库里的AESGCM,而不是自己拼Crypto.Cipher加手动填充。
这三条做不到,即使换了 GCM,也可能在其他地方重新开一个口子。安全不是一个算法的事,而是整个数据通路的设计问题。
6. 实战感悟与扩展思路
最后分享一点我自己的体会。当年第一次完整跑通 ECB oracle attack 的时候,我心里其实是很震惊的。震惊的点不在于“爆破”本身,而在于这个攻击完全不依赖任何数学上的困难问题,它纯粹是模式设计和接口设计组合出来的漏洞。AES 本身没有被攻破,密钥也没有泄漏,但攻击者照样拿到了完整明文。
这件事给我留下了很深的印象:写加密代码的时候,一定要想清楚“攻击者能拿到什么反馈”。加密接口返回密文,这本身就是一种反馈;解密接口返回错误,又是一种反馈。只要反馈里有哪怕一比特的信息,就可能被攻击者当预言机用。
如果你还想继续深入,我建议从这几个方向扩展:
- 把攻击模型从
user_input + SECRET改成PREFIX + SECRET + user_input。这种模型在真实 Web 题目里更常见,攻击思路相同,但需要先探测 PREFIX 的长度,多一个对齐步骤。 - 研究同样是 ECB 但方向相反的“剪贴攻击”(cut-and-paste attack)。它不恢复秘密,而是通过移动密文块来伪造一个合法的明文结构,比如把普通用户的 role 字段替换成 admin。
- 把脚本改成通过
requests打真实 HTTP 接口,你会发现网络延迟对爆破耗时有巨大影响,优化请求并发就成了实际工程问题。
ECB oracle attack 看起来像是一个 CTF 里的小把戏,但它背后涉及的“预言机思维”,在真实的安全研究里无处不在。希望这篇文章能帮你把这个思维模型真正建立起来。