CBC
密码分组链接(Cipher Block Chaining,CBC),是分组密码的工作模式之一。加解密原理如下图:
每个分组长度为 16 字节或 32 字节。CBC的填充规则是缺少 N 位,就用 N 个 \xN
填充,如缺少 11 位则用 11 个 \x0b
填充。
其中 I V IV I V 为随机的初始向量,若第一个块的下标为1,则加密过程为:
C 0 = I V C i = E K ( P i ⊕ C i − 1 ) C_0 = IV \\
C_{i} = E_{K} (P_{i} \oplus C_{i-1})
C 0 = I V C i = E K ( P i ⊕ C i − 1 )
而其解密过程则为:
C 0 = I V P i = D K ( C i ) ⊕ C i − 1 C_0 = IV \\
P_{i}=D_{K}(C_{i})\oplus C_{i-1}
C 0 = I V P i = D K ( C i ) ⊕ C i − 1
CBC字节翻转攻击
假如加密前的明文字符串 P P P 为:0123456789abcdef_admiN
,按照 CBC 的分组及填充规则,则为:
P 1 P_1 P 1 : 0123456789abcdef
P 2 P_2 P 2 : _admiN\x0a\x0a\x0a\x0a\x0a\x0a\x0a\x0a\x0a\x0a
假如现在只知道此时加密后的密文 C C C 和初始向量 I V IV I V 。但想让解密后的明文中的 admiN
变为 admin
即 N
改为 n
,这就用到了 CBC 字节翻转攻击。
发现我们需要改变 P 2 P_2 P 2 ,所以:
n e w ‾ C 1 = D K ( C 2 ) ⊕ n e w ‾ P 2 = C 1 ⊕ P 2 ⊕ n e w ‾ P 2 \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}
n e w C 1 = D K ( C 2 ) ⊕ n e w P 2 = C 1 ⊕ P 2 ⊕ n e w P 2
由于 C 1 C_1 C 1 的改变,P 1 P_1 P 1 也发生了改变:
n e w ‾ 1 ‾ P 1 = D K ( n e w ‾ C 1 ) ⊕ I V new\underline { }1\underline { }P_1 = D_K(new\underline { }C_1) \oplus IV
n e w 1 P 1 = D K ( n e w C 1 ) ⊕ I V
为了不让 P 1 P_1 P 1 发生改变,也要相应的改变 I V IV I V :
n e w ‾ I V = D K ( n e w ‾ C 1 ) ⊕ P 1 = n e w ‾ 1 ‾ P 1 ⊕ I V ⊕ P 1 \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}
n e w I V = D K ( n e w C 1 ) ⊕ P 1 = n e w 1 P 1 ⊕ I V ⊕ P 1
改变 I V IV I V 后,P 1 P_1 P 1 与原始值相等:
n e w ‾ 2 ‾ P 1 = E K ( n e w ‾ C 1 ) ⊕ n e w ‾ I V = E K ( n e w ‾ C 1 ) ⊕ n e w ‾ 1 ‾ P 1 ⊕ I V ⊕ P 1 = 0 ⊕ P 1 = P 1 \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}
n e w 2 P 1 = E K ( n e w C 1 ) ⊕ n e w I V = E K ( n e w C 1 ) ⊕ n e w 1 P 1 ⊕ I V ⊕ P 1 = 0 ⊕ P 1 = P 1
综上,为了改变 P 2 P_2 P 2 ,我们必须可控 I V IV I V 和 C 1 C_1 C 1 ,并且需要知道服务器返回的 n e w ‾ 1 ‾ P 1 new\underline { }1\underline { }P_1 n e w 1 P 1 。因此攻击过程为:
计算 n e w ‾ C 1 new\underline { }C_1 n e w C 1
让服务器解密 n e w ‾ C 1 + C 2 new\underline { }C_1 + C_2 n e w C 1 + C 2 ,得到返回的 n e w ‾ 1 ‾ P 1 new\underline { }1\underline { }P_1 n e w 1 P 1
计算 n e w ‾ I V new\underline { }IV n e w I V
用初始向量 n e w ‾ I V new\underline { }IV n e w I V ,让服务器解密 n e w ‾ C 1 + C 2 new\underline { }C_1 + C_2 n e w 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 AESfrom binascii import b2a_hex, a2b_hexdef 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, flashimport jsonfrom hashlib import md5from base64 import b64decodefrom base64 import b64encodefrom Crypto import Randomfrom Crypto.Cipher import AESapp = Flask(__name__) app.secret_key = 'seed removed' flag_value = 'flag removed' BLOCK_SIZE = 16 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()
整理一下:
cookie base64 解密后前 16 位为 IV,之后就是密文。
admin 值要改为 1。
将 username、password 和 admin json 格式化时,由于有 sort_keys=True
,所以admin 靠前。因此需要改变的是 P 1 P_1 P 1 。
稍微推导一下就可知:
n e w ‾ I V = D K ( n e w ‾ C 1 ) ⊕ n e w ‾ P 1 = P 1 ⊕ I V ⊕ n e w ‾ P 1 \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}
n e w I V = D K ( n e w C 1 ) ⊕ n e w P 1 = P 1 ⊕ I V ⊕ n e w P 1
写个小脚本就可解决:
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
这个神奇的编码方式进行转换,很简洁。