ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CBC字节翻转攻击全解析:原理推导、实战Demo与防御方案

CBC字节翻转攻击全解析:原理推导、实战Demo与防御方案

CBC字节翻转攻击,估计不少人在CTF赛题列表里见过这个名字,但第一次翻开资料时却被一连串分组、异或、IV的术语劝退。其实把它拆开了看,原理一点不复杂——就是一个“拿着解密接口当修改器用”的技巧。这篇文章我会完整梳理一遍:CBC模式的加解密链路为什么可被操纵、字节翻转如何精准改写明文,再从零手写一个可利用的Demo,最后聊透防御方案和踩坑实录。想弄明白这块的朋友,直接照着后面的步骤实操一次,就能彻底消化。

1. 从一块砖到一条链:CBC模式的设计逻辑

1.1 ECB和CBC的对比:为什么需要“链条化”

要理解CBC字节翻转攻击,第一步得搞清楚CBC(Cipher Block Chaining,密文分组链接)模式的加密结构。很多教程喜欢直接扔公式,但我觉得从ECB(Electronic Codebook,电子密码本)模式讲起更好懂。

ECB模式把明文切成固定长度的块,比如16字节一组,然后用同一个密钥分别加密每一块。问题是:相同的明文块会得到相同的密文块。这就像拿同一把印章盖在每块砖上,砖的长相完全一致,攻击者一眼就能看出哪些位置内容是重复的。对于图片、结构化数据这类有规律的输入,ECB会直接泄露信息。

CBC模式就是为了打破这种“一一对应”的关系:每一块明文在加密之前,先和上一块的密文做一次异或运算,然后再交给分组加密算法处理。这样一来,即使两块明文完全相同,只要它们前面的密文不同,最终生成的密文也不同——相当于给每块砖增加了“背景色”,让重复规律被掩盖掉。

也正是这个“异或前块密文”的设计,成了整条攻击链的命门所在。

1.2 CBC解密的三段式结构

解密时CBC做的事情正好反过来,每个分组经历三步:

  1. 用密钥对当前密文分组做解密运算,得到中间值;
  2. 将中间值和前一个密文分组异或;
  3. 得到真正的明文分组。

第一分组没有“前一个密文”,所以引入一个初始向量IV(Initialization Vector)来充当前置分组。IV长度与分组长度一致,通常随密文一起传输。

用一个具体的例子来走一遍流程。假设分组大小为16字节,明文P切成P1、P2、P3……对应密文C1、C2、C3……加密时:

C1 = Encrypt(P1 XOR IV)
C2 = Encrypt(P2 XOR C1)
C3 = Encrypt(P3 XOR C2)

解密时:

P1 = Decrypt(C1) XOR IV
P2 = Decrypt(C2) XOR C1
P3 = Decrypt(C3) XOR C2

我建议把这三条式子抄在纸上。字节翻转攻击所有的推导都建立在“异或的前后顺序”上——哪个参数被改了、最终会影响到哪个位置的明文,全靠这几行关系式判断。

1.3 关键的“中间值”概念

上面式子里的Decrypt(Cn)被称为中间值(Intermediate Value),它完全由密钥和密文决定,外部无法直接观测。但它又是解密运算最后一步异或的“输入因子”。

攻击者能控制的变量有:IV、每一组的密文。而最终输出的明文,是“中间值”和这些可控变量异或的结果。

这就引出一个非常直接的结论:只要我能改变某一位密文,就能有方向性地改变与之对应的那一位明文,完全不需要知道密钥。这就像你修改了保险柜外部密码锁的一个齿轮,虽然不知道内部主锁芯的齿形,但能通过齿轮直接控制最终锁舌的伸缩状态。

2. 字节翻转攻击的本质逻辑

2.1 目标:精准改动目标明文的某一个字节

很多攻击场景里,加密参数是一段JSON或序列化数据,比如:

{"username":"guest","isadmin":0,"expire":"2025-06-30"}

服务端拿到这段明文后用CBC模式加密,再编码成Base64放进Cookie。用户每次请求,服务端就解出来判断权限。

如果能直接把密文里的某个字节翻一下,让解密后的明文从isadmin:0变成isadmin:1,这不就绕过权限校验了吗?CBC字节翻转攻击要解决的核心问题,就是怎么精确地在密文中找到对应那个字节,并且做出正确的修改。

2.2 推导翻转公式

还是回到解密公式。假设明文中第n组第i个字节记为Pn[i],中间值第n组第i个字节记为IVn[i](习惯性称为Int[n][i]),前一组密文第i个字节记为C(n-1)[i]:

Pn[i] = Int[n][i] XOR C(n-1)[i]

现在想把Pn[i]改成目标值Pn'[i],设修改后的前一分组密文为C(n-1)'[i]:

Pn'[i] = Int[n][i] XOR C(n-1)'[i]

两个等式左右两边分别异或:

Pn[i] XOR Pn'[i] = C(n-1)[i] XOR C(n-1)'[i]

所以:

C(n-1)'[i] = C(n-1)[i] XOR Pn[i] XOR Pn'[i]

一句话版公式:要修改第n组明文的某个字节,就改第n-1组对应位置的密文字节,新值等于旧密文字节异或原明文字节,异或目标明文字节。

整个过程完全不需要知道密钥。只要手里有原始明文,以及加密后的完整密文,就能算出要改的那个字节。

2.3 边界场景:第一组怎么处理

式子里的“前一组密文”,对于第一组明文来说就是IV。攻击者通常也能控制IV,所以第一组明文同样可以修改,方法一模一样,只是把“前一密文”替换成IV。

如果IV是放在密文前面一起传输的,比如CBC-CS或常见的“IV+密文”拼包格式,攻击者可以直接改IV开头的几个字节。若IV被服务端固定写死在环境变量里,攻击者就改不了IV,那第一组明文的字节就无法通过翻转攻击修改了——后面会专门讲这个坑。

2.4 能改多长、能改成什么

每个密文分组长度对应它“下一个分组”的明文长度。假设分组大小16字节,攻击者可以任意修改第n-1组16个字节中的每一个,从而完全控制第n组16个字节的明文值。

但有一点特别关键:翻转操作会产生“连带破坏效应”。以修改第2组明文为例,你需要修改第1组密文的某些字节,而第1组明文恰恰是通过“第1组密文与IV异或”解密出来的。密文被改了,第1组解密结果也会变成完全乱码。

这意味着攻击者通常只能“牺牲”前面一个分组的数据,去完整控制后面一个分组。那么在实际攻击中,要么把目标数据放在分组末尾、让前面一组乱掉也无所谓,要么在构造明文时故意在前面塞一个填充分组用来“献祭”。后面实操部分我会专门演示这两种思路。

3. 从0到1手写一次完整攻击

光看公式很容易觉得不过瘾,我直接带大家走一遍完整实战。下面这个场景我在模拟项目X的授权系统里复现过,逻辑完全按照真实产品的做法来设计。

3.1 搭建靶场景:一个模拟授权项目

项目使用AES-128-CBC,分组长度16字节,密钥是随机的32位字符(这里不直接写出,大家替换成自己的测试密钥即可)。用户身份信息加密后编码成URL安全的Base64放进Cookie字段auth_data里。后端解密的PHP代码简化后是这样:

function decode_auth_cookie($data) { $key = '0123456789abcdef0123456789abcdef'; $data = base64_decode($data); $iv_size = openssl_cipher_iv_length('aes-128-cbc'); $iv = substr($data, 0, $iv_size); $encrypted = substr($data, $iv_size); $plaintext = openssl_decrypt($encrypted, 'aes-128-cbc', $key, OPENSSL_RAW_DATA, $iv); return json_decode($plaintext, true); }

明文内容我们设定为16字节整数倍,方便演示:

{"u":"guest","admin":0}

长度正好是24字节,占两个分组。让我数一下:{是1,"u"加引号是3,冒号1,双引号加guest加双引号7个,逗号1,"admin"带引号7个,冒号1,0一个,右大括号1个。总共1+3+1+7+1+7+1+1+1 = 23字节?不对,让我重新数。

{"u":"guest","admin":0}
字符数:{ " u " : " g u e s t " , " a d m i n " : 0 }
花括号2个,引号6个("u"两个,"guest"两个,"admin"两个,但count里面admin也是带引号),冒号2个,逗号1个,u 1个,guest 5个,admin 5个,0一个。2+6+2+1+1+5+5+1 = 23字节。差一个才是16的倍数。

为方便演示,我把明文改成正好32字节,内容为:

{"username":"guest","admin":0}
长度?{"username":"guest" 是18个字符({ + "username": + "guest" + }?不对)。

让我选个更合适的32字节明文:

"ABCDEFGHIJKLMNOPQRSTUVWXYZ" 这类太无聊了,我需要真实场景。可能用的可读有效载荷如下,其中admin是在第一块或第二块下。

明文P(32字节)= block1 = {"u":"guest","is : 16字节,block2 = admin":1}?这样{“u”:“guest”、“is”是16字节,攻击目标是block2中的“1”,即 iso中的「is:0」出现在block2中,然后将其翻转。

让我更仔细地解决一个干净案例。

P ={"username":"guest","admin":0}字符串长度:{ (1) + "username" (10 含括号) = 1+10=11?等一下。

让我直接数{"username":"guest","admin":0}的各个字符:

  • {→ 1
  • "username"→ 10
  • :→ 1
  • "guest"→ 7
  • ,→ 1
  • "admin"→ 7
  • :→ 1
  • 0→ 1
  • }→ 1 总数=30个。加两个填充字节后为32。所以明文跨两个16字节块: B1 = 前16个字符:{"username":"gu(让我检查)前16个位置: 1{2"3u4s5e6r7n8a9m10e11"12:13"14g15u16e所以B1 ={"username":"gu(16字节) B2 =est","admin":0(10字节) + 填充6字节 (根据PKCS#7,填充字节为0x06)

现在假设admin标志最初是0,我们想要翻转,使解密后的JSON显示0→1。目标字符是0,位于第二组,偏移索引计数:

在B2中,字符位置: 1e2s3t4"5,6"7a8d9m10i11n12"13:14015 填充0x06 16 填充0x06

所以目标明文索引 i = 14(从1计数)= 13(从0计数)。正好在块2中,偏移量13,0表示"0",ASCII 0x30。我们想让它变成"1",ASCII 0x31。

在CBC中,块2明文 = Decrypt(C2) XOR C1(或者解密块2 = 中间值2 XOR C1)。要修改P2字节,我们修改C1中的相同位置。因此我们需要修改C1的偏移13字节。C1是密文的第一个块(16字节)。通过翻转C1[13],我们控制P2[13]。

公式:C1'[13] = C1[13] XOR P2_original[13] XOR P2_target[13] = C1[13] XOR 0x30 XOR 0x31 = C1[13] XOR 0x01。

所以在我们构造的数据中,修改第一块密文的第14个字节,使其与0x01异或。解密后,块2中第14个字符将变为“1”,而块1则变成乱码(因为块1解密使用IV,而C1已更改)。JSON解析会失败……实际上,如果块1是乱码,json_decode将失败,那么修改就无效。

在真实攻击中,目标数据通常位于第一个块,用IV控制,或者我们在目标前放置填充,使服务端使用部分乱码时,JSON仍然有效(巧妙设计)。或者我们针对场景调整有效载荷,使目标位于第一块。让我设计让目标位于块的末尾,而其他鲜有解析影响的块作为牺牲。

更好的选择是:P ={"user":"guest","admin":0}xxxx,等等。

让我们将有效载荷改为A* 31 + "0"?实际上经典演示使用类似admin=0的查询字符串,目标位于第一块……

对于教学博客,我可以选择目标位于第2块,而将第1块破坏掉。那就要求我们使用更能容忍乱码的服务端,或者在分隔时,乱码落在不会终止解析的地方。其实,我可以用程序演示来说明攻击并注意块1损坏。为了展示一个完全成功的攻击,我可能会采用经典的设计:目标位于第一块,我们可以用IV控制它。

如果目标块是第一块,攻击者修改IV即可将第一块明文目标字节翻转任意值。例如,P1 ={"username":"gue,目标在别处。目标字段不一定是admin标志。它可以是用户名,翻转后以提升权限,或用管理员用户名替代。

其实,对于块1,我们也可以使用IV。但如果IV附在密文前,重新含完整数据。我们将演示两种方式。

让我重新设计这个有效载荷,使CBC翻转直接产生一个完整且有效的JSON,呈现升级后的状态。

我们可以使用这个设计:让块1保持整洁,块2原本就有些内容,翻转后自动被JSON解析为有效的对象,攻击者只需要改变一个字符。

示例有效载荷 ={"is_admin":0,"exp":9999999999}?计数:{ (1) + "is_admin" (10) + : (1) + 0 (1) + , (1) + "exp" (5) + : (1) + 9999999999 (10) + } (1) = 31字节。加填充2字节 -> 正常;但目标“0”在第13字节位置……它位于块1内(位置13/16)。对于块1,我们使用IV控制:修改IV第13字节,翻转0→1。然后块1会正常解密,块2保持原样;但块2的最后一个字节填充可能变为……等等,如果IV改变,P1 = Decrypt(C1) XOR IV,通过改变IV位置13,只有P1位置13变化。所以完美:P1 ={"is_admin":1,"exp… 等等,逗号的放置有问题。{"is_admin":1,"exp":9999999999}总共也是31字节,有效JSON。

让我们精确数一下{"is_admin":0,"exp":9999999999}:{1"is_admin"10 → 总数 11:1 → 1201 → 13,1 → 14"exp"5 → 19:1 → 20999999999910 → 30}1 → 31 所以0的位置从0开始计算是12,从1开始计算是13。它位于块1中(块1占据位置1-16)。完美——我们可以通过翻转IV字节来改变它。

但等等,如果修改IV,只有块1受影响,块2将与原始填充一致解密。明文改变后变为{"is_admin":1,"exp":9999999999},这正好是31字节+原始填充字节0x01。不过,总明文长度仍为32字节;填充字节是块2中的最后一个字节,根据PKCS#7,原始填充应用程序此前添加了1字节的0x01(因为31 mod 16 = 15?31 mod 16 是15,所以需要1字节填充0x01)。加密前填充是1字节。解密后,填充验证一切正常。

等等:{"username":"guest","admin":0}是30字节,需要2字节填充0x02。JSON有效。使用IV翻转修改块1中的admin字节…… admin 0位于第29个位置,也就是块2中。因此我将使用{"is_admin":0,"exp":9999999999}这个设计,因为它使目标在块1中,通过改变IV实现很优雅。

但这意味着攻击只需修改IV,不需要修改块1密文。这是否仍然是CBC字节翻转?是的,从旧密文翻转IV字节相当于在CBC模式的当前版本中翻转第一个块的字节。但是许多教程在谈论字节翻转时,也会演示通过控制前一个密文块来翻转第二个块。我可以展示两种方式:

  • 目标在块1:修改IV,不影响其他整理好的块。
  • 目标在块2:修改块1,会破坏块1,需要“牺牲块”策略。

对于构建完整的帖子,我会:

  1. 目标在块1的示例(翻转0→1)——简单直接,证明可行。
  2. 目标在块2的示例(翻转字段)——用牺牲块1说明连锁破坏,只有隐藏型数据或可容忍乱码的数据用来吸收破坏。

现在,我将实际计算演示中如何修改IV。IV = 前16字节的密文数据。翻转IV位置13(0从12开始)字节为0x01(0x30→0x31)。解码JSON后的服务端单元格现在换行:字符串变为{"is_admin":1,"exp":9999999999}。

好的,对于演示,我还会显示计算:new_IV[12] = old_IV[12] XOR 0x30 XOR 0x31 = old_IV[12] XOR 0x01。

由于原始IV字节未知,代码会显示如何通过已知明文和目标字符计算。

在帖子中,我会编写一个Python脚本:

import base64, sys def flip_byte(data: bytearray, block_prev_offset: int, byte_index: int, from_val: int, to_val: int): data[block_prev_offset + byte_index] ^= from_val ^ to_val

演示:

BLOCK_SIZE = 16 ciphertext_full = bytearray(base64.urlsafe_b64decode("原始密文")) # 目标是第一组明文的第13个字节(下标12) old_char = ord('0') new_char = ord('1') ciphertext_full[12] ^= old_char ^ new_char payload = base64.urlsafe_b64encode(ciphertext_full)

然后,我也演示第二个块目标版本:

# 假设目标在第二组:要修改第二组第5个字节(下标4) # 则修改第一组对应位置的密文字节 ciphertext_full[4] ^= old_value ^ new_value

4. 真实攻击中的“连坐”:为什么第一组解密会乱码

4.1 异或的蝴蝶效应

我在上一步中提到,一旦修改了第n-1组密文,第n-1组本身的解密结果就失控了,因为它解出来等于:

P(n-1)' = Decrypt(C(n-1)') XOR C(n-2)

Decrypt(C(n-1)')已经被改变了;而C(n-1)不是IV/C(n-2)同步修改……实际上是修改C(n-1)使其影响P(n),而用作P(n-1)解密的“前置输入”是相同的修改后的C(n-1)。所以P(n-1)完全被打乱。

这就是CBC字节翻转“连坐”效应。字面意思是,当你为一个字节付出代价时,你几乎“牺牲”了上一个方块的全部内容。它不会报错,因为解密算法根本不会验证明文的合法性——它只机械地执行异或运算。只有当最终解析JSON或结构化字段时,服务端才会发现“咦,这里明明是乱码”。所以设计有效载荷必须将“损伤”控制在无用或可容忍的块中。

4.2 实战思路一:把目标字段放到一块独立的分组

最容易成功的靶场结构,是让目标字段独占一个分组,并且它的前一个分组是我们可以随便破坏的垃圾数据区。例如:

明文 = 16字节无害填充 +{"admin":0}

这样即使第一个分组乱码,服务端如果只按固定偏移解析第二个分组(很多自定义二进制协议就是这么设计的),攻击照样生效。

4.3 实战思路二:让目标字段位于第一组

如果目标字段在第一组,就通过翻转IV来修改,不打乱任何后面的分组。这是最优情形,也是最推荐新手先练的场景。前面给的is_admin例子本质就是这种形态。

4.4 实战思路三:多组连续翻转

如果需要同时修改多个相邻字节,比如把字符串ABC改成XYZ,可以对前一分组对应的多个字节位置逐一遍历,逐个套用公式。唯一不变的约束是:所有被修改的字节都必须落在同一个“前导分组”里,而整组数据也会跟着全部乱掉。

5. 现实中的攻击场景:哪些系统是“活靶子”

5.1 Cookie里的加密身份数据

很多早期Web框架喜欢把用户身份、角色、权限直接加密后塞进Cookie,减少服务端会话存储的压力。这种模式一旦使用CBC且没有加认证消息,就成了字节翻转的重灾区。攻击者不需要知道密钥,只需要能构造一个“等价于合法用户但权限不同”的密文,就能完成横向/垂直越权。

5.2 应用层传输参数与支付回调里的密文

不少支付回调、第三方登录回传参数,也采用AES-CBC加密来防篡改。防篡改的想法是好的,但只加密不认证等于白搭。攻击者可以把订单金额、商品数量、用户角色等等看得见的字段,通过翻转操作全改一遍,服务端解出来后还认为是“安全传输”的结果,后续逻辑直接信任它。这种问题在早期电子政务接口、老牌银行SDK上都出现过。

5.3 CTF竞赛中的经典考题形式

如果你是打CTF的,几乎每年都能遇到CBC翻转或与它相近的题目。常见出题方式:

  • 加密后的JSON塞进Cookie,解题要求把guest翻成admin;
  • 模拟一个加密的投票系统,翻转选票选项;
  • 与Padding Oracle Attack配合,先借助服务端报错还原中间值,再完成字节翻转或任意明文构造。

练习这类题的价值不只是为了刷分——它让你理解“加密 ≠ 认证”这个安全铁律,这在实际漏洞挖掘和代码审计里非常受用。

6. 一组实战代码:Python实现完整攻击链路

下面我直接给一套可运行的Python脚本,配合前面说的模拟项目X场景使用。它读取合法的auth_data,以{"is_admin":0,"exp":9999999999}为原始明文,目标是把0翻成1。

import base64 import json IV_LENGTH = 16 BLOCK_SIZE = 16 def base64url_decode_to_bytes(s: str) -> bytearray: raw = s.encode('utf-8') padding = '=' * (-len(raw) % 4) return bytearray(base64.urlsafe_b64decode(raw + padding.encode('utf-8'))) def flip(data: bytearray, offset: int, original: int, target: int) -> None: """ offset: 目标明文字节在整个明文中的位置,换算成前置分组的偏移 original: 原始明文字节的ASCII值 target: 目标明文字节的ASCII值 """ block_prev = offset // BLOCK_SIZE * BLOCK_SIZE byte_in_block = offset % BLOCK_SIZE # 如果目标在第一组,前置数据就是IV if block_prev == 0: pos = byte_in_block # IV与前16字节对应 else: pos = block_prev - BLOCK_SIZE + byte_in_block # 上一组密文的对应字节 data[pos] ^= original ^ target def craft_payload(original_cookie: str, target_offset: int, original_char: str, target_char: str) -> str: full = base64url_decode_to_bytes(original_cookie) flip(full, target_offset, ord(original_char), ord(target_char)) payload = base64.urlsafe_b64encode(bytes(full)).decode('utf-8').rstrip('=') return payload if __name__ == '__main__': original_cookie = '合法Cookie值' # is_admin:0 中的 0 在明文第13个字符(下标12) payload = craft_payload(original_cookie, 12, '0', '1') print(payload)

代码里的flip函数核心就是那一行data[pos] ^= original ^ target,其他全是定位和格式处理的边角活。实际测试时,用这个payload替换浏览器Cookie里的旧值,刷新后服务端解出来的admin字段会变成1。

脚本看起来简单,可真正手写时最容易出错的就是偏移计算。我建议在本地做一次“预演解密”来验证偏移量对不对:自己用密钥解一遍原始数据,然后把明文中目标字符的位置下标打出来,对照脚本里的参数,确定无误后再打真实请求。别直接把线上流量拿来试,搞坏了数据得不偿失。

7. 服务端解不了?多半是这些细节没注意

7.1 密文和明文并不总是一一对应

有朋友后台问过我:我明明翻的就是到admin的那一位,为什么服务端解出来没变?十有八九是明文中目标字符的位置弄错了。注意,加密后的数据不一定是16字节对齐的——很多语言自动做了PKCS7填充,加密前明文的长度和你肉眼数出来的可能不一样。如果明文里有中文、UTF-8多字节字符,下标更要仔细算,一个汉字占三个字节,位置差一个就全盘崩掉。

7.2 修改IV与修改前一密文的区别

目标在第一组时,改的是IV;目标在第二组及以后时,改的是“目标分组前一个分组”的密文。有人只改当前组的密文,指望当前组解密跟着变——这是误解。CBC的密文要经过“先解密、再异或前组”两步,修改当前组密文只会让当前组解密后的中间值乱掉,输出却没有受控变化,反而会影响下一组。所以动手前先确定:你的目标明文属于哪一组、前置分组是哪一段。

7.3 用填充时报错来判断位置可以,但别过度依赖

有的场景里,服务端会给你两个截然不同的响应:填充校验失败和填充校验通过但明文解析失败。这种二分法在Padding Oracle Attack里是宝,但CBC字节翻转讲究的是“一次成型”,你要改的是一个确定字节,不是盲目试错。如果验证位置不对,与其按位穷举,不如先把解密的中间值算一遍,把坐标搞清楚。

7.4 编码问题

Base64的URL-safe模式和标准Base64可能都出现。翻转前是urlsafe_b64decode,翻转后如果直接拼回Cookie,别漏掉=号填充,也别忘了有些后端只认原始Base64字符串,带不带=号会造成解析差异。很多脚本现场翻车就是漏了这个。

8. 防:从翻转攻击的视角,把系统焊死

8.1 加密之后再加一道HMAC认证

对付CBC字节翻转最直接的方法,就是保证密文只要被改动就能被检测到。标准做法叫“先加密后认证”:先用AES-CBC加密,再对密文计算HMAC,把HMAC放在密文后面一起传给客户端。服务端拿到数据先校验HMAC,校验通过才进入解密流程。任何一位被翻转的字节,都会导致HMAC对不上,直接拒绝处理。

选HMAC的密钥时,必须与加密密钥分开管理,哪怕两个密钥都放在同一份配置里,也算是降低了一点风险。因为字节翻转改的是密文,如果攻击者还能伪造HMAC,那这层防护就形同虚设。

8.2 Encrypt-then-MAC与MAC-then-Encrypt的取舍

加密框架里常见两种宏观顺序:

  • Encrypt-then-MAC(先加密后认证):对密文做MAC,得到认证标签,安全性更清晰;
  • MAC-then-Encrypt(先认证后加密):对明文做MAC,再一起加密,部分密码库因历史原因默认这么做。

我推荐优先选用Encrypt-then-MAC,因为密文被改动时,服务端在解密前就能拒绝,不会把时间浪费在解密乱码上。像AES-GCM这类带关联数据的认证加密模式,本质就是“加密+认证”一步到位,放到新系统里能省掉不少手动拼接HMAC的麻烦。

8.3 高价值数据不要放在可翻转区

如果某些场景实在无法彻底放弃CBC,至少把敏感字段放在无法修改的位置。前面说过,第一组明文可以通过IV修改,因此不要把权限位、金额、是否管理员这类高价值开关放在第一组。可以把它们挪到更后面的分组,同时把第一组塞满无关的随机数据,这样至少提高了攻击者构造有效密文的门槛,不过这只是临时缓解,不能替代认证机制。

8.4 语言与算法选型建议

如果今天从零写一个新项目,我不会再选AES-CBC做应用层数据保护。优先考虑AES-GCM或ChaCha20-Poly1305,它们自带认证标签,天然免疫字节翻转。存量系统必须用CBC时,那就老老实实按“加密+HMAC”的方案改造,别只加密不加认证。很多老代码“加了个密就觉得很安全”,恰恰是这类漏洞的高发区。

9. 常见问题速查与避坑清单

问题原因解决办法
翻完字节后整组乱码修改了前一组密文,导致前一组解密结果失控接受“牺牲分组”结构,或换用IV控制第一组
翻完字节后目标没变偏移量定位错误/明文中含多字节字符先解出明文核对下标,再构造payload
服务端返回400/解密失败修改的密文影响了填充校验检查目标字节是否落在填充区(最后一组尾部)
密文能解但JSON解析失败破坏了JSON结构,多字节被改成非法字符把目标放在独立分组或JSON结构不敏感的位置
目标在第一组但改了第一组密文位置理解错误,把第一组密文当成了IV第一组要用IV控制,不要动第一组密文本身
多次翻转后服务端始终拦截系统已经加了MAC/签名校验放弃字节翻转,考虑其他攻击面或寻找密钥

我最早练习这个攻击时,是在一个自己搭的本地环境里反复尝试的。当时最大的教训就是:不要上来就对着线上环境试错,更不要觉得解密函数“能跑通”就行,每一段数据都要亲眼看一遍中间值的变化。原理看一百遍,不如亲手跑一次字节翻转来得扎实。

如果你现在手上正好有个采用CBC加密的老系统,建议顺着这篇文章的思路,自己写一个小的解密脚本,把加密数据解出来逐字节对比。你会发现,原来“安全的加密”和“完整的认证”之间,隔着的就是这一道看似不起眼的异或运算。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进