
PKCS#1 v1.5填充与漏洞根源
RSA加密本身是一种确定性算法,直接对明文进行模幂运算会带来严重的安全问题。为了解决这个问题,PKCS#1 v1.5定义了一种填充方案。加密前,明文会被加上特定的前缀字节,格式如下:0x00 0x02,后面跟着至少8个非零随机字节,然后是一个0x00分隔符,最后才是真正的明文数据。解密方在收到密文并执行m = c^d mod n后,会检查解密结果的前两个字节是否为0x00 0x02,以及第二个0x00分隔符是否存在于合理位置。如果检查不通过,解密方通常会返回一个错误提示,例如“decryption error”或者直接断开连接。
问题恰恰出在这里。攻击者虽然无法直接获得明文,但每次发送一个任意密文给服务器,服务器都会给出一个布尔值:填充合法或者不合法。这个一比特的信息在密码学上被称为“填充预言机”。Bleichenbacher发现,利用这个看似微小的信息泄露,攻击者可以系统性地构造一系列密文,逐步缩小明文可能的取值范围,最终还原出原始明文。整个过程不需要私钥,也不需要任何先验知识,只需要服务器愿意响应解密请求并区分填充是否合法。
这种漏洞在SSL/TLS协议的早期版本中尤为突出。例如TLS 1.0及之前的RSA密钥交换流程中,客户端生成一个随机预主密钥,用服务器的RSA公钥加密后发送给服务器。服务器用私钥解密并检查填充,如果填充非法,直接发送一个alert消息。攻击者可以作为中间人捕获这个加密的预主密钥,然后向服务器反复发送修改后的密文,通过观察服务器是否返回填充错误来逐步还原预主密钥。一旦预主密钥被恢复,整个会话的对称密钥也就被破解了。
Bleichenbacher攻击的数学原理与计算步骤
假设RSA模数为n,公钥指数为e,私钥指数为d。合法的PKCS#1 v1.5填充要求明文m满足m = 0x0002 || PS || 0x00 || D,其中PS是至少8字节的非零随机数,D是数据。定义B = 2^(8*(k-2)),其中k是模数的字节长度。那么合法的明文m必须落在区间[2B, 3B-1]内,即2B ≤ m < 3B。攻击者已知密文c0,想要恢复对应的明文m0。
攻击的第一步是找到一个整数s1,使得c0 * s1^e mod n解密后得到的明文落在合法区间内。这里的s1^e相当于对密文乘上了一个因子,由于RSA的同态性质:(c0 * s1^e)^d mod n = m0 * s1 mod n。如果m0 * s1 mod n恰好是一个合法的填充,那么服务器就会返回“合法”,攻击者就能知道这一点。Bleichenbacher证明了,从s1 = ceil(n / 3B)开始递增尝试,平均只需要很少次数就能找到一个满足条件的s1,因为合法区间的密度大约是1/2(当B较小时)。
一旦找到这样的s1,攻击者就可以利用它来缩小明文的范围。因为m0 * s1 mod n落在[2B, 3B-1]区间,这意味着m0必然落在若干个形如[ (2B + rn)/s1 , (3B-1 + rn)/s1 ]的区间内,其中r是整数,且这些区间必须落在[0, n-1]内。攻击者一开始只知道m0在[2B, 3B-1]内,经过这一步可以将范围缩小到几个不相交的子区间。接下来,攻击者需要寻找下一个s值,使得m0 * s mod n仍然合法,同时让新的区间集合进一步收敛。具体的选取策略有三种情况:如果当前只有一个区间,则直接按公式计算下一个s;如果有多个区间,则随机选取一个r和s使得某个子区间与合法区间相交。经过大约百万次左右的预言机查询(这也是“百万消息攻击”名称的由来),区间最终收敛到一个唯一的整数,即m0。
下面用Python模拟一个简化版的Bleichenbacher攻击过程,其中预言机函数返回解密结果是否具有合法填充。注意实际攻击中的模数长度和迭代次数会远大于示例。
from Crypto.Util.number import getPrime, inverse, bytes_to_long, long_to_bytes
import random
# 生成RSA密钥对
p = getPrime(512)
q = getPrime(512)
n = p * q
e = 65537
phi = (p-1)*(q-1)
d = inverse(e, phi)
k = (n.bit_length() + 7) // 8
B = 2 ** (8*(k-2))
def pkcs1_v15_pad(data, total_len):
# 构造合法填充:00 02 || PS(非零) || 00 || data
ps_len = total_len - len(data) - 3
ps = b'\x01' * ps_len # 简化,实际应随机非零
return b'\x00\x02' + ps + b'\x00' + data
def oracle(c):
# 模拟服务器解密并检查填充
m = pow(c, d, n)
m_bytes = long_to_bytes(m, k)
if m_bytes[0] == 0 and m_bytes[1] == 2:
# 检查分隔符00在至少8字节PS之后
for i in range(2, k):
if m_bytes[i] == 0:
if i - 2 >= 8:
return True
else:
return False
return False
else:
return False
# 生成合法密文
message = b'Attack at dawn'
padded = pkcs1_v15_pad(message, k)
m0 = bytes_to_long(padded)
c0 = pow(m0, e, n)
# 攻击过程:简化版只找第一个s
s = (n + 3*B - 1) // (3*B) # ceil(n / 3B)
found = False
while not found:
c_test = (c0 * pow(s, e, n)) % n
if oracle(c_test):
found = True
print(f"Found s = {s}")
else:
s += 1
if s > 100000:
break
# 区间计算(第一步)
intervals = []
for r in range(0, s+1):
low = (2*B + r*n + s - 1) // s
high = (3*B - 1 + r*n) // s
if low <= high and high < n and low >= 2*B:
intervals.append((low, high))
print(f"Intervals after first step: {intervals}")
上述代码只展示了攻击的第一步,实际完整攻击需要循环迭代几千次甚至更多,并处理区间合并的逻辑。核心思想是利用RSA的乘法同态性,将明文乘以某个因子后观察填充合法性,从而反推出明文的具体范围。
防御Bleichenbacher攻击的工程实践
最彻底的防御方法是彻底放弃PKCS#1 v1.5填充,改用更为安全的填充方案,例如OAEP(Optimal Asymmetric Encryption Padding)。OAEP引入了哈希函数和随机种子,使得解密后的明文与填充之间不存在可利用的代数关系,从根本上杜绝了填充预言机攻击。在现代TLS协议(TLS 1.2及以后)中,RSA密钥交换已被禁止,推荐使用ECDHE等前向安全算法。但如果必须兼容旧客户端,仍然需要使用RSA解密,此时一定要对解密过程进行特殊处理。
另一种缓解措施是“隐式拒绝”(implicit rejection),即当填充检查失败时,不返回错误,而是继续使用一个随机生成的假密钥进行后续计算,并将计算结果与预期值进行比较,最终仍然以失败结束但行为完全一致。这样攻击者就无法区分填充是否合法,因为无论合法与否,服务器都执行相同的后续操作。这种方法的代表是TLS 1.2中的RSA解密实现,很多库(如OpenSSL)在早期版本就采用了类似的技巧,但实现细节仍有漏洞(例如返回不同的alert类型或者计时差异)。
更极端的做法是在填充失败时干脆终止连接,并且不发送任何与填充相关的错误信息。但即使如此,攻击者仍可能通过连接是否被关闭来判断填充是否合法,所以必须保证连接关闭的原因与填充合法性无关。目前行业内的共识是:如果要支持RSA解密,必须使用常量时间的填充检查,并且在失败时使用预先生成的随机密钥继续流程,确保时间、数据流和错误码等侧信道完全一致。
Bleichenbacher攻击的变种层出不穷,例如Manger攻击针对的是PKCS#1 v2.0的OAEP填充,但通过不同的数学技巧也能实现类似效果。还有针对TLS 1.3中零RTT的变体攻击。因此,密码协议的设计者必须牢记:任何在解密后区分填充是否合法的行为都可能成为攻击面,永远不要泄露这一比特的信息。
Bleichenbacher攻击RSA填充选择密文攻击修改时间:2026-09-21 15:13:02