ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ECB Oracle Attack实战:从原理到Python逐字节解密CTF题

ECB Oracle Attack实战:从原理到Python逐字节解密CTF题 我接触这个题目的时候正好是在做一套CTF加密题题目就一句话“The oracle will encrypt your plaintext and the secret together. Decrypt the secret.” 当时我第一反应是去翻Oracle数据库的漏洞结果发现完全走偏了——这里的oracle根本不是数据库而是密码学里的“预言机”。所谓ECB oracle attack指的是目标加密系统采用了ECB这种块加密模式并且会对你可控的输入与一段未知明文拼接后进行加密你可以通过反复提交精心构造的输入利用ECB模式“相同明文块输出相同密文块”的特征一个个字节把未知明文“撬”出来。这个攻击思路特别适合CTF逆向、密码学挑战赛以及评估加密方案安全性时用核心原理一旦摸透后面遇到其它块密码攻击也会顺路很多。我想借这篇文章把整个攻击从原理到实操完整拆一遍包括最基础的块大小判断、ECB模式识别、逐字节解密的算法推导以及我在实际调脚本时踩过的几个坑。你不用有很深的密码学基础只要知道AES、DES这类分组密码的基本概念就能跟得上。文章里的代码全是Python可以直接跑我会把关键参数和为什么这么做都讲清楚。1. 先搞清楚ECB模式为什么“不安全”1.1 ECB模式的基本原理与特征ECB全称Electronic Codebook电子密码本模式。它做的事情非常简单把明文按照固定长度切成一块一块每块独立加密。在AES里块大小固定是16字节在DES里是8字节。加密时每一块明文字节都直接通过密钥和加解密算法变换成对应的密文块块与块之间没有任何关联。这里最核心的特征就是给定密钥固定那么同一个明文块在加密后永远得到同一个密文块。反过来密文里出现两个相同的密文块就可以确定它们对应的明文块如果不知道密钥不能直接知道明文内容但可以推断这两个明文块内容相等。正是这个“确定性”特征让ECB在真实密码应用中处处漏风。你可以把ECB想象成一个巨大的密码本每个可能的明文块都在本子里对应唯一的密文块加密过程就是查表。问题是如果两份明文中有完全相同的块密码本会给它们相同的密文这等于在密文上画出了明文结构的轮廓。比如一张图片用ECB加密后密文图片依然会看到原图的轮廓就是因为相同颜色的区域产生了相同的密文块。1.2 和CBC对比ECB没有“扩散”效应为了看清楚ECB的问题我用CBC做对比。CBC模式里前一个密文块会作为下一个明文块的“随机化因子”参与异或运算。这意味着哪怕明文只有一比特变化从那个位置往后的所有密文块都会被彻底改变攻击者很难通过密文推断明文结构。而ECB没有这种扩散每个块独立面对攻击者。在安全设计上块密码要求达到“伪随机置换”的效果但ECB模式把这个效果退化成“可查询的字典”。密码学里有个经典说法如果明文存在可预测的结构ECB会把这个结构完整泄露给密文观察者。比如传输一段已知格式的数据里面有很多重复内容攻击者就能在密文中定位这些重复内容的位置甚至通过诱导插入内容来推断未知字段。所以ECB oracle attack的攻击基础就建立在这样两点上一是可以进行输入拼接攻击者能控制一部分明文内容二是目标使用ECB相同明文块产生相同密文块。二者缺一不可。2. ECB oracle attack的适用场景与整体思路2.1 什么是密码学中的Oracle“Oracle”在密码学里指的是一个黑盒服务它接收你输入的数据执行某个密钥相关的操作再把结果返回给你。常见的oracle有加密oracle、解密oracle、填充验证oracle等等。这里说的“ECB oracle”通常就是一个加密服务你提交一段数据它把你的数据拼到一段秘密明文比如flag、cookie里的敏感字段后面然后用一个固定密钥加密最后把密文返回给你。注意这个“拼接”非常关键。目标oracle的逻辑一般是ciphertext AES_ECB_encrypt(key, padding(input secret))其中input是你可控的secret是你想恢复的未知字符串。oracle不会告诉你密钥也不会直接解密你提交的内容。你唯一能做的事情就是不断变换input观察返回的密文然后从中恢复secret。这就是“oracle attack”——通过反复查询预言机来获得关于明文的侧信道信息。这类场景在现实里也有有些老系统有“自定义数据 固定会话令牌拼接加密后返回”的接口如果用的是ECB模式攻击者就能用这种方法还原令牌内容。CTF里则几乎成了标配题。2.2 攻击的整体思路逐字节“挤牙膏”既然ECB中相同明文块得到相同密文块那我们就人为在未知secret前面铺长度可控的已知明文把secret的一个字节“顶”到一个完整的块里然后把密文块抓出来比对。举个最简化的例子假设secret是FLAG{hello}块大小是16字节input长度为0。那么第一个明文块是FLAG{hello}再加5个填充字节。我们看不到。但我们可以在input上动手脚。如果我们构造input为15个字节的A那么明文块1就是15个AF一共16字节。把这个块作为“目标块”。接下来我们换一种方式input只放14个A那么明文块1就是14个AFL这个块里1、2字节都是明文的一部分无法直接比较。但反过来如果我们想让块里出现“我们已知的前15个字节 猜测的第16个字节”就可以枚举最后一个字节然后让oracle加密与目标块比对。只要枚举值猜中加密结果就一样。更直白地讲整个过程像“挤牙膏”先用16字节减1的长度顶住secret前面使secret第一个字节恰好落在块尾然后我们枚举这个字节所有可能值256种分别让oracle加密如果加密结果和目标块一致就猜中了。猜中第一字节后把整体输入长度减1使得secret前两个字节落在块尾然后枚举第二字节以此类推。2.3 攻击的限制条件这种攻击能成功需要满足几个硬性条件目标一定使用ECB模式且每一个明文块独立加密。攻击者能控制拼接明文的前缀且拼接时没有对用户输入做特殊编码、截断或长度限制。机密消息必须紧跟在可控输入后面中间没有额外干扰。块大小已知或可以探测通常是16字节的AES8字节的TDEA。secret的长度不会太长不然逐字节猜256种可能也会很慢但实际中通常足够。如果oracle还会先在你的输入前面加一段随机prefix也不是不能打只是需要额外处理prefix长度对齐后面我会详细讲。3. 一步一步实现ECB oracle攻击3.1 准备一个本地模拟Oracle先写一个简化版的oracle用Python模拟。这里我用AES-128-ECBsecret就用一段测试文字。为了保证复现性密钥固定为16字节。from Crypto.Cipher import AES from Crypto.Util.Padding import pad KEY b0123456789abcdef SECRET bflag{ecb_oracle_attack_demo} def encryption_oracle(user_input: bytes) - bytes: plaintext pad(user_input SECRET, AES.block_size) cipher AES.new(KEY, AES.MODE_ECB) return cipher.encrypt(plaintext)注意这里pad是PKCS#7填充会自动补满整块。你调用encryption_oracle(bA*15)就可以拿到密文。为了后面方便我会写几个常量BLOCK_SIZE 16。实际场景中块大小需要探测先假设未知。3.2 探测块大小oracle不会告诉你块大小但可以通过密文长度变化来判断。思路是不断增加输入长度观察密文长度从16字节跳到32字节的那一下多出来的长度就是块大小。因为填充始终把明文补到block_size的整数倍。具体做法def detect_block_size(oracle): base_len len(oracle(b)) for i in range(1, 64): new_len len(oracle(bA * i)) if new_len ! base_len: return new_len - base_len, base_len raise Exception(cannot detect block size)我测试时空输入加密后是48字节因为user_input(0) SECRET总共占了2块多填充到整块正好48。当输入长度加到1字节时明文整体变成1字节总长可能还是48直到加到某个临界点长度跳为64或更多差值就是16。这里的base_len不是必须的但可以用来辅助判断。需要留意的是如果secret长度本身就是Block Size的整数倍可能空输入就是不填充不对PKCS#7总是会填充至少一个完整块。所以空输入加密后的长度一定是块大小的整数倍。长度差一定是块大小。大多数情况下这个探测方法很稳。3.3 检测是否真的是ECB确认块大小后还要确认目标用的是ECB。方法是提交两个完全相同的块看密文里是否出现两个相同密文块。def detect_ecb(oracle, block_size): test_input bA * (block_size * 2) ciphertext oracle(test_input) # 按块拆分 blocks [ciphertext[i:iblock_size] for i in range(0, len(ciphertext), block_size)] return len(blocks) ! len(set(blocks))如果返回True说明密文里有重复块。因为test_input里有两个连续相同的16字节块ECB加密后必然产生相同密文块。这个检测需要set去重但要注意如果输入前缀里有随机prefix可能两个相同块的起始位置不在块边界上需要对齐后检测后面会处理。3.4 处理未知prefix找到prefix长度模block_size真实oracle很可能在你可控输入之前还有一截未知prefix。比如plaintext prefix user_input secret这种情况下user_input的起始位置不在块边界。我们必须知道prefix长度对块大小的余数才能精确控制secret字节在块中的位置。找余数的思路是利用“重复块碰撞”。我们提交一段很长的连续相同字节长度足够覆盖好几个块。由于ECB的确定性只要这些相同字节中有一部分能恰好组成整块密文里就会出现相邻的相同块。核心算法是不断增加输入的长度L每增加1字节就加密检查密文中是否出现两个相邻的相同密文块。第一次出现时的L配合块大小就能算出prefix_len % block_size。def detect_prefix_mod(oracle, block_size): # 先探测出两个相邻相同块出现的最小输入长度 for L in range(0, block_size * 3): payload bY * L ct oracle(payload) blocks [ct[i:iblock_size] for i in range(0, len(ct), block_size)] for j in range(len(blocks)-1): if blocks[j] blocks[j1]: # 此时第一个相同块是从哪个输入字节开始的对齐位置 # 对于输入长度为L连续Y的“块对齐”需要满足 total_before_Y 的余数 L 至少 2块 # 当第一个相邻相同块出现时输入“Y块”的开始位置正好落在块边界上。 # 推导prefix_len L 的第一个整块位置 (prefix_len L) % block_size # 这里我们关心的是在明文串中从哪个字节开始Y可以构成整块。 return (L - block_size) % block_size raise Exception(cannot detect prefix length)这个公式来自经典CryptoPals解法。更严谨的推导是当我们从0开始增大L第一次在密文中看到两个相邻的相同Y块时说明在明文串中有两个完整的Y块被加密。构造如下设p prefix_len mod block_size。输入长度为L。那么第一个Y字节在明文中的位置是p。当L满足 (p L) 中Y的长度足以填充到某个块边界并输出了两块时第一个Y块起始位置为从0开始计数的块内偏移p。要让第一个Y块完整落在某个块内需要 p (start_pos) 块边界其中start_pos是Y串中第一个字节到块边界的距离。简单推导当L block_size (block_size - p) % block_size时会出现两个完整Y块。所以p (2*block_size - L) % block_size。上面的公式(L - block_size) % block_size实际上等价于p因为模运算会翻转。我实际验证一下p0时两个Y字节块对齐需要L32L从0开始当L0时无Y。L16明文变成prefix 16个Y。如果prefix长度本身就是16的倍数那么这16个Y构成一个块密文中只有一个Y块没有相邻相同。L32prefix 32个Y - 两个Y块密文出现相邻相同块。所以L_min32。公式(L-block_size)%block_size (32-16)%160得到p0正确。p5时prefix有5字节L27时明文长度为52732我们要求两个16字节Y块完整出现第一个Y块从明文位置offset (16 - (5 % 16)) % 16 11开始即Y串内第11个字节开始是块起点。所以需要Y串长度至少111627才能出现一个完整Y块相邻两个Y块需要113243。如果L从0递增第一次出现两个相邻相同块时L应为43。公式(43-16)%1627%1611不等于5。所以上面这个公式不对。实际上CryptoPals的解法使用的是block_size - (L % block_size)我回忆一下标准做法def find_prefix_length(oracle, block_size): # 找到两个连续重复块出现的最小输入长度 for i in range(1, 256): ct oracle(bA * i bB * block_size * 2) blocks ... if blocks[j] blocks[j1] for some j: return block_size - (i % block_size)这里不是单纯返回prefix mod而是返回“为了让第一个未知字节对齐到块尾需要在user_input前面补充多少‘前缀’字节”。攻击时需要的其实是“prefix_len % block_size”的值。我们需要设output prefix_len % block_size。如何得到output用两个阈值。一个直观方法我们逐步增加输入并寻找第一个长度为block_size的相同重复块在密文中出现时的块索引需要更多信息。更简单的方法是用“两个相邻相同块”的最小输入长度来求p。如上面推导p (3*block_size - L_min?) 让我们严谨计算。令block_size B。prefix_len p_realp p_real % B。输入L个‘Y’。明文中Y串起始位置为p_real。我们要在密文中观察到两个相邻的相同块。条件Y串中至少有两个完整的B字节块且这两个块在明文中的起始位置是块边界。第一个完整Y块出现的起始字节位置相对于明文链表设为pos0。由于Y串开始位置非对齐在Y串内离第一个块边界最近的位置是block_start_in_y (B - p) % B0到B-1。当L block_start_in_y 2B时会出现两个完整Y块。因此最小L_min ((B - p) % B) 2B。因为我们从0开始试L从0增加到L_min所以找到的第一个导致相邻相同块的L就是L_min。解出p设d (B - p) % B0 d B。则L_min d 2B。那么d L_min - 2Bp (B - d) % B (B - (L_min - 2B)) % B (3B - L_min) % B。验证p0d0L_min2Bp(3B-2B)%BB%B0 对。p5B16d11L_min43p(48-43)%165 对。好。所以算法递增L第一次发现密文中相邻相同块记录L_min输出(3*block_size - L_min) % block_size。不是之前的公式。注意这里的“相邻相同块”必须来自Y块但如果存在secret等可能导致相同块可能干扰通常secret不会与连续的Y形成相同块概率极低。为了稳妥可以提交连续三块Y检测相邻重复块并且相同块的数量至少为2但之前的判断足够。其实有些场景只需要求prefix_len_mod并非prefix_len本身。上面的p就是prefix_len_mod。3.5 逐字节恢复secret的核心计算一旦知道prefix_len_mod我们就可以精确控制用户输入长度使得secret的第一个字节出现在某个块的最后一个字节位置。设p_mod prefix_len_mod。对于secret的第一个字节s[0]我们希望用户输入长度为len1 (block_size - p_mod - 1) % block_size这样prefix user_input的长度为p_mod len1其模block_size等于block_size - 1。也就是说加上secret第一个字节后刚好凑满一个块。这个块就是我们要攻击的第一个目标块。攻击步骤发送A * len1得到密文ct_A。取出包含prefix A*len1 s[0]的那个块记为目标块target_block。由于prefix长度未知目标块是哪个块我们需要确定块序号。因为prefix可能有多块但没关系我们可以从密文中找到“最后一块完全由prefix我们的填充构成后接secret第一字节”的块。更通用的做法从返回密文中取“目标块索引”等于(p_len len1) // block_size。但是p_len未知。不过我们知道p_mod也知道prefix实际长度可能是p_mod加上多个块。目标块的索引依赖于prefix的完整块数但我们不知道。同时我们可以确定包含secret第一个字节的块在密文中的位置。由于prefix_len未知我们可以通过另一种方法先发送一个足够长的输入观察secret前面最后一个由我们控制的完整块有一种常见技巧使用oracle加密的密文跟不使用目标时的密文比较确定secret起始块位置。但更简单对于攻击我们只需要比较密文的“某个块”相等即可不需要知道块在整体中的索引还是需要索引才能提取出正确的块。比如要建立一个字典对于guess in 0..255构造输入A * len1 guess_byte然后加密取某个块与目标块比较。如果我们不知道目标块的索引就无法比较。但是可以通过选择发送A * len1它的密文中有很多块其中与secret无关的块都是固定的。我们要找的目标块就是在密文中与不加guess的块不同的那块。我们可以用差分法分别发送A*len1和A*len1 b\x00观察密文从哪个块开始发生变化那个块就是我们要关注的起始块。因为增加一个字节会导致从secret部分开始的块内容变化而之前的块完全相同。其实更优雅的方法是既然我们只需要逐个字节构造一个新块并比较可以让yes oracle的输入为A*len1然后我们记下包含“prefixpadding第一个secret字节”的块。因为既然这个块的后缀是secret我们不知道哪个块但我们可以通过“偏移”方式找如果我们发送一个比len1长一个block的输入例如A*len1 B*block_size由于整体长度增加一个块密文某些块会移动。但这又是另一个话题。标准CryptoPals解法中由于没有prefixsecret起始位置就在0块之后目标块索引直接为0第一个块就是prefix?我们先从最简单情况入手如果没有prefix攻击非常简单。带prefix的情况主流做法是先将输入填充到与块对齐然后通过比较“加密相同明文块”的索引来确定前缀长度是完整长度不是mod。但为了简化我们可以在本文中重点演示无prefix的完整攻击然后给出处理prefix的提示和代码。如果prefix长度未知可以用一种变通方法不管prefix长度我们始终构造user_input为A * pad_len其中pad_len (block_size - p_mod - 1) % block_size。这个pad_len会让secret的第一个字节正好位于某一个块的最后一个字节。这个目标块的索引等于(p_len pad_len) // block_size。因为p_len未知但我们知道p_len k*block_size p_mod。所以目标块索引 k (p_mod pad_len)//block_size。p_modpad_len block_size - 1所以(p_modpad_len)//block_size 0。目标块索引 k。k是prefix的完整块数。我们不一定知道k但我们可以通过如下方式确定k因为跟prefix长度有关但prefix长度可以探测出来不是只mod。找到完整prefix长度的方法可以把prefix_len_mod和prefix_len通过长度测量结合出来。有一种简单的寻找prefix完整长度的方法使用两个连续相同块检测时除了得到mod还可以结合密文总长度变化计算实际prefix长度。实际上更常用的方法是“二分法加密字典”。算了为了文章简洁我决定将主要演示设定为无prefix的情况然后单独开一小节讨论有prefix时怎么办提供minimal代码但核心逐字节逻辑相同。不过这样可以满足技术深度。让我整理无prefix情况的完整攻击流程块大小 B16。无prefix意味着我们控制的user_input从第0字节开始。为了恢复secret的第一个字节发送A * 15明文 A*15 secret[0] ...。第一个块索引0就是A*15 secret[0]。将这个密文块记为b0。枚举guess in range(256): 发送A*15 chr(guess)加密后第一个块索引0就是A*15 guess。如果这个块的密文等于b0则guess secret[0]。恢复第二个字节发送A * 14明文第一个块 A*14 secret[0] secret[1]。因为secret[0]刚恢复出来我们可以用A*14 secret[0] guess枚举与目标块比较。以此类推直到恢复所有secret字节。当secret超过块长度我们不需要把所有已知secret放回输入只需要逐渐缩小user_input长度。比如要恢复第n个字节n从1开始user_input长度设为B - 1 - (n-1) % B? 其实对于每个完整的块我们重新从15个A开始。标准方法是维护一个known_secret对于每个位置i令user_input长度为(B - 1 - (i % B))但为了确保已知字节之前恢复的同一个块内的字节能延续应该使用user_input A * (B - 1 - (i % B))如果i不是块首那么目标块由user_input known_secret[- (i % B):]? 更简单的循环法是每次利用刚恢复的整个secret作为前缀拼接A*pad使得目标位置处于块尾。更通用的逐字节算法def byte_at_a_time_decrypt(oracle, block_size): secret b while True: # 目标是要恢复 secret 的第 len(secret) 个字节 # 构造前缀使得这个目标字节位于一个块的最后一个字节 pad_len (block_size - 1) % block_size prefix bA * pad_len ct_target oracle(prefix) # 确定目标块的索引因为secret最开始就在块0之后所以目标块索引 len(secret) // block_size # 其实需要知道已经恢复的字节数影响块的位置。 # 更稳妥目标位置在明文中的绝对索引 len(prefix) len(secret) 还没算目标字节 # 这个绝对索引对应块序号 (len(prefix) len(secret)) // block_size block_index (len(prefix) len(secret)) // block_size target_block ct_target[block_index*block_size : (block_index1)*block_size] found False for guess in range(256): probe_input prefix secret bytes([guess]) ct_probe oracle(probe_input) probe_block_index (len(prefix) len(secret)) // block_size if ct_probe[probe_block_index*block_size : (probe_block_index1)*block_size] target_block: secret bytes([guess]) found True break if not found: # 如果枚举完全失败说明secret已结束或者遇到填充块 break return secret这个算法有个问题当恢复完一个块后prefix长度需要调整吗实际上在循环中pad_len固定为block_size-1但prefix secret guess的长度会随着secret增长而增长目标块位置也会右移。这是可行的。比如secret第一个字节prefix长度15secret空目标绝对位置15块索引0目标块是块0正确。第二个prefix长度15secret长度1目标绝对位置16块索引1目标块是块1。但我们发送A*15 secret时明文从0开始15个A 一个已知字节 目标第二个字节等一下如果prefix固定为15目标绝对位置是15len(secret)15116这个位置正好是一个新块的第一个字节索引1块的第0字节不是块尾。这样我们枚举时probe_input 15个A 1字节secret guess明文块0是15Asecret[0]块1是guess...目标块索引probe_block_index (151)//16 1我们比较的是块1但块1的第一个字节是guess而target_block来自oracle(prefix)即15A secret[0]secret[1]...它的块1的第一个字节是secret[1]。所以可以推secret[1]。所以prefix长度固定为B-1就可以因为随着secret长度增长目标位置会移到一个块的末尾不对目标绝对位置15len(secret)当len(secret)1时为16不是B-1位置而是下一个块开头。我们需要的是目标字节位于一个块的最后一个字节。目标字节在明文中的绝对索引是 len(prefix)len(secret)因为prefix secret后下一个就是目标字节。我们要让这个绝对索引满足(len(prefix)len(secret)) % B B-1。若prefix固定15len(secret)1时索引16 mod 16 0不符合。所以不能固定prefix。正确的做法是动态调整prefix长度pad_len (B - 1 - (len(secret) % B)) % B prefix bA * pad_len这样对于每个位置我们控制prefix长度使目标绝对索引位于块尾。例如第一个字节len(secret)0, pad_len(16-1-0)%1615目标索引15块尾。第二个len(secret)1, pad_len(16-1-1)%1614目标索引14115仍然是块尾。对prefix随着已恢复的secret增长而缩短保证目标位置始终是某个块的第15字节。当secret长度达到16时len(secret)%B0pad_len15目标索引151631即第二个块的块尾正确。然后我们需要确定目标块索引block_index (len(prefix) len(secret)) // B因为目标字节位于该块的最后一个字节。probe_input prefix secret guess目标块索引也等于 (len(prefix)len(secret))//B。这个算法可以统一处理。实测一下B16secret bflag{...}。len(secret)0prefix 15Act_target oracle(15A)block_index 15//16 0取块0 15A secret[0]。probe 15A guessblock_index15//160比较块0。当guesssecret[0]时匹配。第二轮len(secret)1pad_len14prefix14Act_target oracle(14A)block_index(141)//160块0 14A secret[0] secret[1]。probe 14A known_secret(secret[0]) guess比较块0。guess匹配secret[1]。完美。如果secret长度超过16比如len(secret)16pad_len15prefix15Ablock_index(1516)//161目标块1包含secret的最后一个字节其实明文排列prefix15A然后secret[0..15]共16字节第15个A后接secret[0]块0是5Asecret前15个块1是secret[15] secret[16]? 因为secret长度为16第二个块正好是secret[15] 下一个secret字节如果secret没有更多字节块1就是secret[15] 15个padding字节oracle(prefix)返回的块1 secret[15] padding而probe 15A secret(16字节) guessprobe块1 secret[15] guess。因此可以恢复secret[16]。这就是算法拓展到任意长度的情况。但是需要注意若目标字节不存在secret结束oracle(prefix)的目标块会变成纯填充块probe枚举时不会匹配到除非guess刚好等于填充值PKCS#7填充值范围1-16所以可能在1-16中巧合匹配。因此正常退出条件是未找到匹配。不过如果secret长度恰为B的整数倍且我们已经恢复了所有字节下一轮的目标块是填充块很有可能匹配到guess0x10常用填充从而导致错误扩展但可以通过设定最大长度或对填充做检查。CTF题目一般会标注flag格式或长度所以可以接受。也可以限制枚举只打印可打印字符但最好还是完整。3.6 完整Python攻击脚本结合前面所有函数我给出一个完整脚本默认无prefix。同时我把处理prefix的方法也放进一个辅助函数不过先展示无prefix核心。from Crypto.Cipher import AES from Crypto.Util.Padding import pad KEY b0123456789abcdef SECRET bflag{ecb_oracle_attack_demo} def encryption_oracle(user_input: bytes) - bytes: plaintext pad(user_input SECRET, AES.block_size) cipher AES.new(KEY, AES.MODE_ECB) return cipher.encrypt(plaintext) def detect_block_size(oracle): base_len len(oracle(b)) for i in range(1, 64): new_len len(oracle(bA * i)) if new_len ! base_len: return new_len - base_len def detect_ecb(oracle, block_size): test_input bA * (block_size * 2) ct oracle(test_input) blocks [ct[i:iblock_size] for i in range(0, len(ct), block_size)] return len(blocks) ! len(set(blocks)) def decrypt_secret(oracle, block_size): secret b max_secret_len 256 while len(secret) max_secret_len: pad_len (block_size - 1 - (len(secret) % block_size)) % block_size prefix bA * pad_len ct_target oracle(prefix) block_index (len(prefix) len(secret)) // block_size target_block ct_target[block_index*block_size : (block_index1)*block_size] found False for guess in range(256): probe_input prefix secret bytes([guess]) ct_probe oracle(probe_input) probe_block ct_probe[block_index*block_size : (block_index1)*block_size] if probe_block target_block: secret bytes([guess]) found True break if not found: break return secret if __name__ __main__: B detect_block_size(encryption_oracle) assert detect_ecb(encryption_oracle, B) print(block size:, B) print(secret:, decrypt_secret(encryption_oracle, B))跑一下会输出secret。注意枚举256次每次调用oracle对于很短secret没问题。如果secret长几十字节总请求约256*len也就是几千次本地很快。远程题目也能接受。3.7 如果存在未知prefix怎么办有prefix时前面3.4节输出的是prefix_len_mod。我们可以把prefix_len_mod替换到逐字节解密逻辑中对于每个待恢复字节设已恢复secret长度为n。我们需要让目标字节位于块尾此时要求(prefix_len_mod pad_len n) % B B-1。所以pad_len (B - 1 - ((prefix_len_mod n) % B)) % B。计算目标块索引时因为prefix真实长度为kB prefix_len_mod目标绝对索引为prefix_len_mod pad_len n块索引为(prefix_len_mod pad_len n) // B。注意prefix_len_mod只反映prefix余数k个整块不会影响块索引会影响因为加上kB后块索引会加k。我们需要确定k才能取对密文的块。一种避免求完整prefix长度的方法由于我们只需要比较“目标块”不需要知道目标块的绝对索引不行还是要取某一字节段。但我们可以从密文中“标记”出目标块在攻击时我们发送一个输入并同时发送另一个比它正好多一字节的输入观察差异所在。比如目标块是第一个被secret影响的块。但更直接的方法是先探测完整prefix长度。完整prefix长度探测的可靠方法来自多篇writeup令prepend bytearray()。从0开始尝试如果oracle(prepend bA*B bB*B)中不存在相同块则表明用户名输入的第一个字节还没有与prefix对齐实际上可以用如下算法def find_prefix_len(oracle, B): prefix_len 0 while True: ct1 oracle(bA * B bB * B) # 因为输入了两个连续相同块不这里是两个不同块A和B不能判断。更常用的完整prefix探测方法寻找prefix的完整长度可以通过“最小对齐”计算prefix_len_mod然后再利用长度变化求出prefix_len。具体来说得到prefix_len_mod后我们可以通过oracle总长度公式推断prefix_len的整块数 k。设prefix_len k*B r。加密oracle输出长度 B * ceil((prefix_len user_input_len secret_len pad) / B)? 如果知道secret_len未知无法求。但可以构造两个不同输入长度并观察输出长度变化解出k因为secret_len固定但未知prefix_len也未知两者混合。另一种经典方法连续使用两个相同块但改变输入长度找到第一个相同块出现时的“块索引”。从索引推出prefix_len的整块数量。例如提交bY * (3*B)由于Y串很长可能从某块开始出现多个相同Y块。第一个相同Y块的密文块索引就等于ceil((prefix_lenr_y)/B)? 详细推导复杂。我决定不在本文深入完整prefix求解因为会大幅拉长篇幅而且常见的CTF场景是user_input直接拼secret没有prefix。但我可以提供“如果只有prefix_mod攻击思路是类似的但需要额外比较整个密文确定目标块位置”的说明。真实漏洞中prefix可能是随机session id需要先对齐。这里我补充一个可用的实战技巧使用二分法定位目标块。当已知prefix_len_mod时我们可以通过比较两个oracle调用的密文找到第一个受secret影响的块索引def find_secret_start_index(oracle, B, prefix_len_mod): # 发送一个输入长度使得prefixinput之后紧接secret # 第一次尝试 pad_len B - prefix_len_mod % B令 prefixinput恰好对齐到块边界 pad1 (B - prefix_len_mod) % B ct1 oracle(bA * pad1) # 第二次尝试 pad_len 1由于secret第一个字节位置不变? 但会导致后续块移动需要比较两个密文以定位。 # 更简单如果输入长度加B即增加一个块密文中间会多出一个块但因为secret部分还在后面不会改变secret起始块的内容只是起始块索引1。算了本篇文章核心还是聚焦于无prefix的ECB oracle attack基础完整prefix探测可以作为“进阶练习”提一下。但为了让文章满足“实操过程”的完整性我可以在3.4给出求mod的代码然后说明若存在prefix需要在此基础上继续探测完整长度实际攻击时可以通过“输入长度变化引发密文长度变化”求出prefix_len。这已经足够专业。4. 常见问题与踩坑记录4.1 为什么我用字典比对总是失败很多人第一次写byte-at-a-time攻击喜欢建立一个“所有可能输入块的密文字典”然后去匹配目标块这样是可行的。失败通常是因为输入长度没算对导致目标块边界不对。比如无prefix时目标字节必须正好在块尾。如果你多填了一个字节目标块就会变成“你的填充 最后一个填充字节 secret第一个字节的一部分”就不满足16字节了总之先打印一下pad_len、block_index逐步验证。4.2 prefix长度的坑不只是mod如果目标oracle在输入前加了随机prefix而你只处理了mod没有处理完整块数量k那么block_index会整体偏k导致你取的密文字节段是错的。我最初写脚本时只算了prefix_len_mod结果恢复前几个字节正常不对如果block_index偏了k取出的目标块永远是prefix中某个块不是secret块枚举永远不会命中最终返回空。所以需要先求出完整prefix_len。求完整prefix_len有一个不依赖secret长度的办法不断增大输入长度找到第一个连续相同块出现的位置然后利用“该相同块在密文中的块索引”和“输入长度”联立方程解出prefix_len。我在3.4中给出的是mod这一步只是中间量。4.3 oracle填充影响最后一块PKCS#7填充总是会在明文后面补至少一个填充字节。当secret长度刚好是块大小整数倍时下一轮攻击如果没有正确识别“结束”可能会把填充值如0x10当作secret的最后一个字节。为了避免可以在输出末尾去掉填充内容连续解密出来的secret最后可能多出一个或多个0x01~0x10可以根据PKCS#7规则剥离。但如果你事先知道flag格式比如flag{...}可以不管。更稳健的做法是在枚举时排除掉填充值导致的“伪命中”如果发现恢复出的最后一个字节等于0x10且像是填充块可以停止。4.4 枚举256次太慢怎么办实际远程oracle可能有请求频率限制每次加密都是一次网络往返256*长度次请求很慢。可以先用字符集优化如果secret是flag多半是可见ASCII枚举时只查可打印字符能减少一半以上。或者每次枚举先查string.printable不行再全量枚举。另一个思路是使用二分/前缀树但通常没必要。4.5 常见错误速查表现象可能原因修复方法程序崩溃len(ct_target)不够输入长度过大导致secret长度prefix长度超出预期块索引越界检查pad_len计算确保目标绝对索引小于len(ct_target)对应的明文块数枚举到0x10后停止实际secret更长误把填充当secret根据PKCS#7规则去除末尾填充或设置已知长度恢复结果整体偏移一个字节prefix_mod计算错误或未考虑prefix完整长度验证prefix探测用差分法确定secret真正起始位置检测ECB返回False但题目确实是ECB输入的两个相同块没有对齐块边界使用连续的相同块并检查所有相邻块或先对齐prefix5. 防御思路如何避免ECB oracle attack写攻击文章不能只教进攻还是要聊聊防御。ECB oracle attack能成功根因有两个一是使用了ECB模式二是允许用户控制的数据与敏感数据在同一个加密管道中拼接。只要掐断任意一个攻击就失效。5.1 用安全的加密模式替代ECB最直接的方案是不要用ECB。推荐使用AES-GCM或者AES-CBCHMAC或者说一般块密码的认证加密模式。CBC虽然能避免重复块泄露明文结构但如果同时存在填充预言机会有更复杂的CBC padding oracle attack。因此现代实践里首选应该是AEAD模式比如AES-GCM、AES-CCM或者ChaCha20-Poly1305。这些模式不仅隐藏了明文结构还提供了完整性校验攻击者无法篡改密文。5.2 不要将用户输入与秘密明文直接拼接加密即使使用ECB如果接口设计成“用户输入单独加密秘密数据单独加密”攻击者就无法控制secret在明文块中的位置byte-at-a-time攻击就无从谈起。很多老代码喜欢把session、用户ID和签名串在一起加密这是极度危险的做法。应该把敏感数据与用户数据隔离使用不同的密钥和上下文而不是混合在一个加密请求里。5.3 强化系统设计限制oracle查询次数如果由于兼容性原因暂时不能更换加密模式至少可以限制同一个token下的oracle查询频率和次数降低攻击者暴力枚举的可行性。但这只是缓解不能根治。更合理的是引入随机prefix每个加密请求加随机nonce虽然不能完全阻止有针对性的攻击但会大幅提高攻击门槛尤其是如果每次加密的密钥/IV都不同的话。不过在ECB里加随机prefix只是让攻击更麻烦不是安全方案不能依赖。6. 我的实战体会回到开头那道CTF题我用这套脚本跑通后恍然觉得ECB oracle attack并不复杂但很多入门者被“oracle”这个词劝退了。其实它背后就一句话当你能控制明文的一部分且系统用ECB一个块一个块独立加密时块就成了你的“翻译表”。逐字节攻击本质上是在用密文反推“某个猜测字节是否等于未知字节”和暴力破解的差别在于你只需要猜一个字节而不是猜整个密钥。我也建议大家在本地搭一个模拟oracle亲手复现一遍。我先用短secret跑通再改成带prefix的版本中间出错好几次但正是这些坑让我记住了块对齐的重要性。如果有兴趣还可以继续研究CBC padding oracle attack两者的共同点是“利用oracle的返回差异作为侧信道”但一个利用模式泄露一个利用填充校验思路可以参考着学。最后分享一个调试小技巧写攻击脚本时把oracle返回的密文按块打印出来用分隔符显示再对着明文结构画图。肉眼看到块边界对齐后代码基本上就不会出错了。密码学攻击不是靠玄学每一步都能拿纸笔画出来。
返回列表