CFB模式下密文翻转1 bit,解密到底会坏几个分组?
前几天被问到一个很基础却特别磨人的问题CFB模式下密文在传输中翻转了1 bit接收端解密到底会坏几个分组提问的人刚做完一个串口加密透传模块测试时发现某一段数据解出来全乱怀疑是自己CFB参数配错了后来排查发现是链路上真的丢了一位。他说网上答案五花八门有说只坏当前分组的有说全链路都坏的还有说影响两个分组的都不知道该信谁。这个问题确实值得掰开揉碎讲清楚。分组密码工作模式里的错误传播直接决定了你的重传粒度、完整性校验方案甚至决定了一个协议面对主动攻击时的表现。CFB又比较特殊它既不是CBC那种一次错一串也不是CTR那种错哪算哪。下面我从原理推一遍再写代码把错误扩散过程逐分组打出来最后说说协议实战里怎么利用和防范这个特性。1. 先搞清楚CFB到底是什么模式1.1 CFB的本质把分组密码当成自同步流密码用要说清CFB的错误传播得先理解CFB的设计思路。分组密码本身只能处理固定长度的数据块比如AES一次处理128 bit。如果明文比这个长就得选一种工作模式把分组密码扩展成能处理任意长度数据的方案。CFBCipher Feedback密文反馈的核心思想是把分组密码当成一个随机数生成器来用。加密端维护一个移位寄存器初始状态是IV初始化向量n bit长。每一轮把移位寄存器送进AES做一次加密得到一个n bit的输出块取这个输出块的高s bit作为密钥流和明文的s bit做异或得到s bit密文。这一步生成的密文又会反馈到移位寄存器尾部参与下一轮运算。解密端做的事情几乎一模一样唯一区别是加密端拿明文和密钥流异或生成密文解密端拿密文和同样的密钥流异或恢复明文。注意CFB解密用的是AES加密方向正向不是AES解密方向这一点和CBC有本质区别。因为解密端的移位寄存器完全由之前收到的密文维护不需要额外同步状态所以CFB天然具备自同步能力接收端只要连续收到正确的密文经过有限轮之后即使此前丢了数据也能自动跟上发送端的密钥流状态。这是CFB相比OFB和CTR最大的卖点也是它错误传播范围比较长的根源。1.2 CFB-1、CFB-8、CFB-128的区别CFB有个参数叫segment size段长记作s表示每一轮处理s bit。CFB-1每轮处理1 bit需要调用n次分组密码才能产生n bit密钥流效率最低CFB-8每轮处理8 bit常用于老式的字节流加密设备CFB-128也叫全块反馈每轮处理一个完整的n bit分组最接近CBC的吞吐效率。很多人以为CFB只有一种其实参数不同影响几个分组的答案完全不同。比如AES-CFB128下翻转1 bit密文影响的是2个128 bit分组但在AES-CFB8下翻转1 bit密文影响范围能到17个字节分组。要讨论这个问题必须先明确你的CFB段长和底层分组长度。1.3 影响几个分组提问背后的真实需求为什么这个问题不是书斋里的理论题因为它直接决定三件事重传策略链路层检测到错误后重传多少数据才能让对端恢复重传少了错误还在重传多了浪费带宽。完整性方案如果只影响1个分组我可以只对这个分组做校验如果影响一串校验粒度就得重新设计。攻击建模主动攻击者翻转密文某一位后接收端明文会发生什么变化这在设计加密通信协议时是必须考虑的威胁模型。尤其在做嵌入式加密模块、串口透传设备的时候物理链路上出现1 bit错误并不罕见搞不清影响范围排查问题会非常痛苦。2. 1 bit错误在CFB解密链路上的完整旅程2.1 解密端的三件套移位寄存器、分组密码加密、异或先把CFB解密方程写清楚。设分组密码块长为nAES是128段长为s解密端维护一个n bit移位寄存器R初始状态R_0 IV对第j段密文C_j解密计算中间输出 O_j E_K(R_{j-1})取MSB_s(O_j)做密钥流计算明文 P_j C_j ⊕ MSB_s(O_j)更新移位寄存器 R_j ((R_{j-1} s) | C_j)即左移s位、把C_j填入低s位超出n位的高位移出丢弃解密和加密的差异只在第2步解密是C_j异或密钥流加密是P_j异或密钥流。其他步骤完全一致。这个结构里C_j出现在两个地方一是直接参与第j段明文的异或二是被拼进移位寄存器R_j影响后续所有轮的E_K输入。搞清楚C_j的两个用途错误传播路径就一目了然了。2.2 第一个分组错1 bit还是错一片假设第j段密文C_j的第t位在传输中翻转用C_j表示错误密文。解密第j段时P_j C_j ⊕ MSB_s(O_j) (C_j ⊕ e_t) ⊕ MSB_s(O_j) P_j ⊕ e_t也就是说第j段明文只有对应那1 bit错了其余s-1 bit完全正确。这是CFB作为流密码的直接体现当前段的密钥流O_j只依赖之前收到的密文R_{j-1}不依赖C_j所以C_j的错误只会通过异或这一个路径影响当前段造成1 bit明文错误。这里要注意一个初学者容易误解的地方当前分组不是全花只是错一位。如果你做的是ASCII文本传输一位翻转可能整个字符都变看起来像全坏了但逐bit统计的话确实是1 bit。2.3 后续分组错误bit在移位寄存器里如何行军当前段解密完之后解密端要把C_j拼进移位寄存器问题从这里开始。正常情况R_j ((R_{j-1} s) | C_j)现在变成了R_j ((R_{j-1} s) | C_j)。R_j和R_j之间存在1 bit差异恰好是C_j的第t位。解密第j1段时O_{j1} E_K(R_j)变成了E_K(R_j)。分组密码有一个基本性质任意输入比特变化输出中约一半比特会翻转雪崩效应。所以O_{j1}和O_{j1}相比至少有几十个bit不一样MSB_s取出的s bit密钥流也约有一半bit错误。于是第j1段明文平均错s/2个bit看起来就是全乱了。然后在更新R_{j1}时那个错误bit并不会消失它会随着每次移位左移s位一点一点向寄存器高位移动。寄存器宽度为n bit设错误bit到达寄存器最高位后还需要一次移出。准确说每处理一个密文段错误bit在寄存器中的位置左移s位。它从低s位中的某个位置出发最多经过ceil(n/s)次移动就会被完全挤出寄存器。在这个行军过程中只要错误bit还在寄存器里E_K的输入就和正常情况不同后续段的密钥流就一直处于随机化状态每段约s/2 bit出错。直到它被移出寄存器E_K输入恢复正常解密立即恢复同步。2.4 通用公式1 ceil(n/s) 个段把第一段和后续段加起来CFB-s模式下1个密文bit错误影响的范围是当前段1 bit明文错误后续ceil(n/s)个段每段约s/2 bit明文错误总影响段数1 ceil(n/s)个s-bit段代入常见参数看一下这里段的粒度不同结论差异很大CFB参数n / s总影响段数通俗描述AES-CFB128128/128 12个128bit分组当前组错1 bit下一组全花第三组恢复AES-CFB8128/8 1617个字节段当前字节错1 bit后续16个字节每字节约4 bit错AES-CFB1128/1 128129个bit段当前bit错1 bit后续128个bit输出受影响DES-CFB864/8 89个字节段很多老教材讲的就是这个特例这就是为什么网上答案打架有人说的是AES-CFB128的特例有人套的是老教材里DES-CFB8的例子还有人把段和底层分组搞混了。记住这个通式再碰到任何CFB参数都不会乱。需要补充一个边界细节如果错误bit恰好落在某次移位刚好被移出的边界上影响段数可能比ceil(n/s)少1。但设计协议时永远按最坏情况1ceil(n/s)算别赌运气。3. 实验把错误扩散过程逐分组打出来3.1 实验设计光推导不直观我写了个Python脚本验证。用pycryptodome库实现AES-128-CFB分别测试CFB-128和CFB-8两种段长构造20个分组的随机明文加密后手动翻转第5个分组中间的某一位再解密逐分组统计解密明文与原始明文的汉明距离不同bit数。预期结果CFB-128第5组汉明距离1第6组汉明距离接近64第7组及以后0CFB-8第5字节汉明距离1后续16个字节汉明距离接近4第22字节及以后0。3.2 完整测试代码先装依赖这里以pycryptodome为例包名是pycryptodome不是pycrypto历史坑点后面会说。from Crypto.Cipher import AES import os, math def hamming_dist(a: bytes, b: bytes) - int: return sum(bin(x ^ y).count(1) for x, y in zip(a, b)) def run_cfb(segment_size: int, group_start: int, flip_pos: int): key os.urandom(16) iv os.urandom(16) n 128 seg_bytes max(1, segment_size // 8) plaintext bytes(range(256)) assert len(plaintext) 32 * seg_bytes # 32个段 enc AES.new(key, AES.MODE_CFB, iviv, segment_sizesegment_size) ciphertext enc.encrypt(plaintext) bad bytearray(ciphertext) # 翻转group_start这个段的第flip_pos个bit byte_idx group_start * seg_bytes (flip_pos // 8) bad[byte_idx] ^ (0x80 (flip_pos % 8)) dec AES.new(key, AES.MODE_CFB, iviv, segment_sizesegment_size) recovered dec.decrypt(bytes(bad)) print(f--- CFB-{segment_size}, 翻转第{group_start}个段内第{flip_pos}bit ---) for g in range(32): a plaintext[g*seg_bytes:(g1)*seg_bytes] b recovered[g*seg_bytes:(g1)*seg_bytes] d hamming_dist(a, b) if d or g in range(group_start - 1, group_start 20): tag 受影响 if d else print(fgroup {g:02d}: hamming distance {d}{tag}) print() run_cfb(128, 5, 10) run_cfb(8, 5, 10)这段代码里有个关键点解密时必须重新创建一个AES对象因为pycryptodome的对象是有状态的解密器创建后内部移位寄存器就从IV开始推进如果复用加密器来解密状态早就错了结果会非常离谱。3.3 运行结果与分析CFB-128的输出长这样节选group 03: hamming distance 0 group 04: hamming distance 0 group 05: hamming distance 1 当前段只错1 bit group 06: hamming distance 67 下一段全花 group 07: hamming distance 0 group 08: hamming distance 0CFB-8的输出则是group 05: hamming distance 1 group 06: hamming distance 4 group 07: hamming distance 5 ... group 21: hamming distance 3 group 22: hamming distance 0实验结果和理论推导完全对上。CFB-128下错误只传染2个分组CFB-8下总共影响17个字节段过了第21段立刻恢复没有拖泥带水。有个细节值得拿出来说CFB-8中间那些段的汉明距离不是稳定的4有时候3、有时候5。原因是雪崩效应是统计意义上的E_K输出在本应相同的输入只有1 bit差异的情况下平均翻转64 bit抽样到s8位时这8位里刚好翻转的数量服从二项分布总在4附近波动。如果做协议测试别拿必须每段错4 bit当断言那会把自己坑了。3.4 实验里最容易踩的坑第一个坑是segment_size参数的单位。pycryptodome里segment_size的单位是bit而且必须传8的倍数。写CFB-128必须传128不是16。传16会被当成CFB-16错误传播范围直接变成9个段现象完全对不上。第二个坑是字节序和位序。上面程序里我按第group_start段、段内第flip_pos bit定位需要把段内bit索引换算成字节索引和位偏移。不同库对bit位置的编号方向可能不一样有的从低位编号有的从高位编号。遇到实验结果和预期差一位的时候先检查这里。第三个坑是错误注入位置。如果只翻转密文某个字节却以为自己在翻转第5个分组结果可能翻到了段边界之外。计算分组起止索引时务必用 seg_bytes max(1, segment_size // 8)CFB-1时它是1 bit没法按字节索引。4. 把CFB放到错误传播坐标系里和CBC、OFB、CTR逐项对比4.1 CBC当前组全花下一组错1 bitCBC模式解密方程是 P_i D_K(C_i) ⊕ C_{i-1}。密文C_j的1 bit翻转时第j组D_K(C_j)的解密输出整体随机化所以P_j约一半bit错第j1组P_{j1} D_K(C_{j1}) ⊕ C_jC_j中那一bit错误直接通过异或反映为P_{j1}的同位置1 bit错第j2组及之后C_{j1}没变链路完全恢复。所以CBC也是影响2个分组但分布方向刚好和CFB相反。CFB是当前组错1 bit、后续组全花CBC是当前组全花、下一组错1 bit。这个差异在bit翻转攻击里非常关键后面再说。4.2 OFB与CTR错误不扩散但也失去了自同步OFB和CTR都是无反馈模式。OFB的密钥流O_i E_K(O_{i-1})CTR的密钥流O_i E_K(nonce || counter_i)两者都完全不依赖密文历史。所以C_j出现1 bit翻转时只有P_j对应位错后续分组完全正常是错误传播范围最小的模式。代价是它们不是自同步的。一旦收发双方的密钥流计数/状态出现偏差比如接收端丢了一个分组后面所有分组都解不对而且永远不会自动恢复。CFB虽然错误传播范围大但接收端只要连续收到正确的密文错误bit被移出移位寄存器后就能自我修复。打个不严谨的比方OFB/CTR像核对账本错一行就再也对不上CFB像流水线坏零件走过去就恢复。4.3 一张表看清五种模式的错误传播特性工作模式1个密文bit翻转的影响范围当前段表现后续表现是否自同步CBC2个底层分组当前组约50% bit错下一组同位置1 bit错是CFB-s1 ceil(n/s)个s-bit段当前段同位置1 bit错后续ceil(n/s)段每段约50% bit错是OFB1个段当前段同位置1 bit错无否CTR1个段当前段同位置1 bit错无否GCMAEAD认证失败整帧丢弃解密器不输出明文无不适用GCM这类带认证的模式实际上跳出了错误传播话题认证标签对密文任何一bit的改动都极其敏感接收端在认证失败时直接丢弃整个数据单元根本不会输出解密后的明文。所以现代协议里讨论错误传播越来越少不是因为问题消失了而是认证机制把问题挡在了门外。5. 协议实战与攻击视角下的CFB错误传播5.1 现代协议为什么冷落CFB现在的主流安全协议TLS 1.3、SSH的encrypt-then-MAC、IPsec的ESP要么直接使用AEAD算法要么用CTR/CBC配合独立的MAC。CFB很少作为默认选择主要原因不是它错误传播范围大而是它只提供机密性不提供完整性。不过在一些特殊场景CFB仍然有存在感实时语音、视频流加密错一个bit不至于重传整个帧自同步特性反而让接收端能迅速从错误中恢复某些低功耗嵌入式设备不想为CTR维护计数器同步也不想为CBC处理填充CFB-8配合逐字节处理就很方便。5.2 错误注入攻击视角CFB的bit翻转特性是可以被利用的理解了CFB错误传播公式你会发现一个尴尬的事实攻击者翻转C_j的某个bit虽然会让后续分组全花但能精确控制P_j对应位的翻转。这在没有完整性保护的协议里就是经典的bit flipping攻击。更精细一点如果有人想篡改P_{j-1}的某个bit他会去翻转C_{j-1}对应位因为CFB当前段明文错误直接来自当前段密文异或。但这样做的代价是破坏后面ceil(n/s)个分组——在CFB-128里只要再翻转C_j的对应位把P_j的连锁错误补回来攻击者甚至可以连续篡改相邻两个分组。这种手法在CBC里也能做只是方向反了CBC翻C_{j-1}可以精确改P_j但C_{j-1}自己解出来全花。这类攻击的历史教训很多比如早期的某些链路加密协议只加密不认证攻击者只要知道明文格式就能通过精确的密文位翻转让接收端把转账100元解成转账999元而接收端根本识别不出数据被动过。所以做协议设计时CFB和CBC这类反馈模式后面必须跟一个MAC或直接换AEAD否则就是在裸奔。5.3 如果必须用CFB工程上怎么设计重传和校验如果物理链路上确实存在随机单bit错误又因为兼容性原因必须用CFB我的建议很直接按1 ceil(n/s)计算最坏影响范围重传粒度至少覆盖这个范围。比如AES-CFB128下检测到某分组异常就重传它和后面1个分组AES-CFB8下就要准备好重传17个字节段。不要依赖CFB自己恢复后继续用半错的数据。自同步只能保证错误bit移出后新数据正确错误bit移出之前的数据是不可信的接收端要能区分哪个范围的数据需要丢弃。必须在应用层加完整性校验。校验粒度可以比影响范围大但绝不能比影响范围小否则你无法区分这位错了但是能恢复和这位错了数据作废。如果链路错误率很高建议在加密前面先加前向纠错FEC把物理层的随机错误在解密前消掉。FEC纠正不了的突发错误再走重传。5.4 实测中的几点心得我自己在调试CFB加密透传模块时踩过一个印象很深的坑现象是接收端总是隔几个分组就有一段乱码但重发又好了。一开始以为是射频干扰后来抓密文对比发现是发送端的DMA缓冲在传输中少发了一个字节导致接收端的CFB移位寄存器整体错位。CFB-128下少一个字节等价于后面连续好几个分组的密钥流全错现象就是隔一段乱一片。这种情况自同步救不了必须靠上层帧同步和完整性校验兜底。还有一次是同事把IV生成逻辑写成了每次会话同一个IV结果密文前几个分组在不同会话里完全相同被测试脚本一眼识别出模式。CFB的IV不需要保密但必须唯一最好每次会话随机生成。不要因为IV是公开的就在代码里写死。最后分享一个排查技巧怀疑CFB错误传播问题时先做最小对照实验——只翻转一个bit逐分组打印汉明距离。如果输出和第2节推导的分布不一致先查移位寄存器维护逻辑再查segment_size参数最后查字节序。大部分CFB诡异错误最后都出在这三个地方而不是出在密码算法本身。