CBC

密码分组链接(Cipher Block Chaining,CBC),是分组密码的工作模式之一。加解密原理如下图:

cbc_1

每个分组长度为 16 字节或 32 字节。CBC的填充规则是缺少 N 位,就用 N 个 \xN 填充,如缺少 11 位则用 11 个 \x0b 填充。

其中 IVIV 为随机的初始向量,若第一个块的下标为1,则加密过程为:

C0=IVCi=EK(PiCi1)C_0 = IV \\ C_{i} = E_{K} (P_{i} \oplus C_{i-1})

而其解密过程则为:

C0=IVPi=DK(Ci)Ci1C_0 = IV \\ P_{i}=D_{K}(C_{i})\oplus C_{i-1}

CBC字节翻转攻击

假如加密前的明文字符串 PP 为:0123456789abcdef_admiN,按照 CBC 的分组及填充规则,则为:
P1P_10123456789abcdef
P2P_2_admiN\x0a\x0a\x0a\x0a\x0a\x0a\x0a\x0a\x0a\x0a

假如现在只知道此时加密后的密文 CC 和初始向量 IVIV。但想让解密后的明文中的 admiN 变为 adminN 改为 n,这就用到了 CBC 字节翻转攻击。

发现我们需要改变 P2P_2,所以:

newC1=DK(C2)newP2=C1P2newP2\begin{aligned} new\underline { }C_1 &= D_K(C_2) \oplus new\underline { }P_2 \\ &= C_1 \oplus P_2 \oplus new\underline { }P_2 \end{aligned}

由于 C1C_1 的改变,P1P_1 也发生了改变:

new1P1=DK(newC1)IVnew\underline { }1\underline { }P_1 = D_K(new\underline { }C_1) \oplus IV

为了不让 P1P_1 发生改变,也要相应的改变 IVIV

newIV=DK(newC1)P1=new1P1IVP1\begin{aligned} new\underline { }IV &= D_K(new\underline { }C_1) \oplus P_1 \\ &= new\underline { }1\underline { }P_1 \oplus IV \oplus P_1 \end{aligned}

改变 IVIV 后,P1P_1 与原始值相等:

new2P1=EK(newC1)newIV=EK(newC1)new1P1IVP1=0P1=P1\begin{aligned} new\underline { }2\underline { }P_1 &= E_K(new\underline { }C_1) \oplus new\underline { }IV \\ &= E_K(new\underline { }C_1) \oplus new\underline { }1\underline { }P_1 \oplus IV \oplus P_1 \\ &= 0 \oplus P_1 \\ &= P_1 \\ \end{aligned}

综上,为了改变 P2P_2,我们必须可控 IVIVC1C_1,并且需要知道服务器返回的 new1P1new\underline { }1\underline { }P_1。因此攻击过程为:

  1. 计算 newC1new\underline { }C_1
  2. 让服务器解密 newC1+C2new\underline { }C_1 + C_2,得到返回的 new1P1new\underline { }1\underline { }P_1
  3. 计算 newIVnew\underline { }IV
  4. 用初始向量 newIVnew\underline { }IV,让服务器解密 newC1+C2new\underline { }C_1 + C_2,攻击完成。

一个 python 的完整脚本如下:

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
from Crypto.Cipher import AES
from binascii import b2a_hex, a2b_hex

def pad(s):
block_size = 16
num = block_size - len(s) % block_size
return s + chr(num) * num

def unpad(s):
return s[:-ord(s[len(s)-1])]

def encrypt(key, iv, plaintext):
pad_plaintext = pad(plaintext)
aes_encrypt = AES.new(key, AES.MODE_CBC, IV=iv)
return b2a_hex(aes_encrypt.encrypt(pad_plaintext))

def decrypt(key, iv, cipher):
aes_decrypt = AES.new(key, AES.MODE_CBC, IV=iv)
pad_plaintext = aes_decrypt.decrypt(a2b_hex(cipher))
plaintext = unpad(pad_plaintext)
return plaintext

def xor(a, b):
assert len(a) == len(b)
return ''.join([chr(ord(a[i])^ord(b[i])) for i in range(len(a))])

if __name__ == '__main__':
iv = 'ABCDEF1234567890'
plaintext = '0123456789abcdef_admiN'
key = 'hduwhdiwa21534dw'
cipher = encrypt(key, iv, plaintext)
print cipher
de_cipher = decrypt(key, iv, cipher)
print de_cipher

c1 = a2b_hex(cipher[:32])
c2 = a2b_hex(cipher[32:64])
p1 = '0123456789abcdef'
p2 = pad('_admiN')
new_p2 = pad('_admin')
new_c1 = xor(xor(c1, p2), new_p2)
new_1_p1 = decrypt(key, iv, b2a_hex(new_c1+c2))[:16]
print new_1_p1
new_iv = xor(xor(new_1_p1, iv), p1)
print new_iv
new_m = decrypt(key, new_iv, b2a_hex(new_c1+c2))
print new_m

例子

picoCTF 2018: Secure Logon
http://2018shell.picoctf.com:13747

这道题进去之后用户名除了 admin 之外,密码随便输入都能进去,然后发现 Cookie 中有东西:

1
Fh0ySuvX4QOr3ZxVu4faX5lJwmTfPWuakg6IVlM5MoOghe3wAsXfbB8MKdDVKSwDsVsMS8EtsnsRgzl8iXyHej5plKSpvfAGRjVNKvBV6bg=

它还告诉了源码:

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
from flask import Flask, render_template, request, url_for, redirect, make_response, flash
import json
from hashlib import md5
from base64 import b64decode
from base64 import b64encode
from Crypto import Random
from Crypto.Cipher import AES

app = Flask(__name__)
app.secret_key = 'seed removed'
flag_value = 'flag removed'

BLOCK_SIZE = 16 # Bytes
pad = lambda s: s + (BLOCK_SIZE - len(s) % BLOCK_SIZE) * \
chr(BLOCK_SIZE - len(s) % BLOCK_SIZE)
unpad = lambda s: s[:-ord(s[len(s) - 1:])]


@app.route("/")
def main():
return render_template('index.html')

@app.route('/login', methods=['GET', 'POST'])
def login():
if request.form['user'] == 'admin':
message = "I'm sorry the admin password is super secure. You're not getting in that way."
category = 'danger'
flash(message, category)
return render_template('index.html')
resp = make_response(redirect("/flag"))

cookie = {}
cookie['password'] = request.form['password']
cookie['username'] = request.form['user']
cookie['admin'] = 0
print(cookie)
cookie_data = json.dumps(cookie, sort_keys=True)
encrypted = AESCipher(app.secret_key).encrypt(cookie_data)
print(encrypted)
resp.set_cookie('cookie', encrypted)
return resp

@app.route('/logout')
def logout():
resp = make_response(redirect("/"))
resp.set_cookie('cookie', '', expires=0)
return resp

@app.route('/flag', methods=['GET'])
def flag():
try:
encrypted = request.cookies['cookie']
except KeyError:
flash("Error: Please log-in again.")
return redirect(url_for('main'))
data = AESCipher(app.secret_key).decrypt(encrypted)
data = json.loads(data)

try:
check = data['admin']
except KeyError:
check = 0
if check == 1:
return render_template('flag.html', value=flag_value)
flash("Success: You logged in! Not sure you'll be able to see the flag though.", "success")
return render_template('not-flag.html', cookie=data)

class AESCipher:
"""
Usage:
c = AESCipher('password').encrypt('message')
m = AESCipher('password').decrypt(c)
Tested under Python 3 and PyCrypto 2.6.1.
"""

def __init__(self, key):
self.key = md5(key.encode('utf8')).hexdigest()

def encrypt(self, raw):
raw = pad(raw)
iv = Random.new().read(AES.block_size)
cipher = AES.new(self.key, AES.MODE_CBC, iv)
return b64encode(iv + cipher.encrypt(raw))

def decrypt(self, enc):
enc = b64decode(enc)
iv = enc[:16]
cipher = AES.new(self.key, AES.MODE_CBC, iv)
return unpad(cipher.decrypt(enc[16:])).decode('utf8')

if __name__ == "__main__":
app.run()

整理一下:

  1. cookie base64 解密后前 16 位为 IV,之后就是密文。
  2. admin 值要改为 1。
  3. 将 username、password 和 admin json 格式化时,由于有 sort_keys=True,所以admin 靠前。因此需要改变的是 P1P_1

稍微推导一下就可知:

newIV=DK(newC1)newP1=P1IVnewP1\begin{aligned} new\underline { }IV &= D_K(new\underline { }C_1) \oplus new\underline { }P_1 \\ &= P_1 \oplus IV \oplus new\underline { }P_1 \end{aligned}

写个小脚本就可解决:

1
2
3
4
5
6
7
8
9
s = 'Fh0ySuvX4QOr3ZxVu4faX5lJwmTfPWuakg6IVlM5MoOghe3wAsXfbB8MKdDVKSwDsVsMS8EtsnsRgzl8iXyHej5plKSpvfAGRjVNKvBV6bg='
t = base64.b64decode(s)
cipher = [t[i:i+16].decode('l1') for i in range(0, 80, 16)]
iv, cipher = cipher[0], cipher[1:]
p1 = '{"admin": 0, "pa'
new_p1 = '{"admin": 1, "pa'
new_iv = xor(xor(p1, iv), new_p1)
new_cipher = (new_iv + ''.join(cipher)).encode('l1')
print(base64.b64encode(new_cipher))

这里顺便吐槽一下 python3 中 string 与 bytes 的转换完全没有 python2 方便。但发现可以利用 l1 这个神奇的编码方式进行转换,很简洁。