很久之前做过这一类的题目,不过当时只是大概看了一下,用了别人的脚本,今天在做picoCTF(国庆七天乐)的时候碰到了个这一类的题目,研究了一下,顺便记录。
其实最近上的课——密码学从入门到入殓,刚好讲到了CBC这一块。
CBC是一种加密模式,全名Cipher Block Chaining,这种模式是先将明文切分成若干小段,然后每一小段与初始块或者上一段的密文段进行异或运算后,再与密钥进行加密,详情见图(盗用一下乌云的图)
每组解密时,先进行分组加密算法的解密,然后与前一组的密文进行异或才是最初的明文。对于第一组则是与IV进行异或。
Plaintext:明文数据
IV:初始向量
Key:分组加密使用的密钥
Ciphertext:密文数据
了解了CBC加密模式的基本规则,接下来是如何去利用
CBC字节翻转攻击发生在解密的过程中,实质就是通过更改上一个块的密文,来间接修改明文中的内容。从图中可以很清晰的看到,每次修改的块只会影响到下一块的明文中相同偏移量的字节,同时对之后的块不产生影响。CBC反转攻击的用途主要体现在在不知道加密密钥的情况下,通过修改密文,可以间接修改明文。
设上一块的密文为A(如果是第一块,"上一块"就是iv),当前块密文使用key解密后的数据为B,明文为C
我们目前已知的是A和C
易知B = A ^ C
那么考虑如果把A的值修改为A ^ C,那么原本的A ^ B变为 A ^ C ^ B = A ^ C ^ A ^ C = 0
在考虑把A的值修改为A ^ C ^ ?(任意字符),那么A ^ B得到的明文变为0 ^ ? = ?
这样,就通过修改密文,可以得到我们想要的任意的明文。
不过要注意,虽然我们可以控制下一块的明文,但是由于修改了本块的密文,所以本块解密后的明文将大概率变成乱码(总之就是不是原来的模样了),不过如果能知道明文的内容(哪怕是乱码,毕竟本质是01),可以继续通过修改上一块的密文(或者iv)来将其复原。
接下来一一道题为例具体讲解如何操作。
这是picoCTF2018的一道题:
http://2018shell2.picoctf.com:13747
同时给了源码:
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()
分析一波源码,从24-41行的login函数可以看到,服务器将我们的username,password以及一个admin=0以json的方式储存并使用了AES加密存储在cookie中。
而在50-66行的flag函数表明了我们想要get flag,需要把admin置为1。
再看一下下方的AESCipher类,初始化使用了随机的密钥,加密是将数据用数据长度这个值填充至16字节(BLOCK_SIZE)的整数倍(即将数据长度重复加在数据后面直到长度能被16整除)。然后利用随机生成的初始向量iv,以CBC的模式进行AES加密,最后把iv加在密文前面,base64编码后返回。
而解密是加密的逆过程,不再赘述。
打开网页发现是个登录界面
没有注册的选项,但是尝试之后发现可以直接"登录"
下方打印了我们的信息,cookie中也看起来符合代码的加密
这道题就是一道最直接的CBC字节翻转攻击的题目,思路也是非常清晰明了,通过CBC字节翻转攻击,将admin的0修改为1即可。
注意代码第37行,在转化为json格式储存时,使用了参数sort_keys=True,这样会使存储顺序按照key的字典序排列,例如
{'admin': 0,'password':'e','username':'eustiar'}
而不是像上面图片里网页中显示的顺序。
当时做的时候因为想当然的认为是按照网页里的顺序,导致admin是在第二个块中,结果修改了第一个块的密文之后,就没法通过修改iv来把第一个块复原,也就没法被正确的解析了(大概率是乱码,没法json.loads),不过在请教@zblee师傅后,给出了一种不同的思路:可以尝试使用较长的password来把admin挤到第三块,让第二块全是password,这样在修改了第二块的密文之后,解密后只要不出现"干扰json解析,同样也可以把admin改成1并解析成功,不过我没有实际配置去尝试,有兴趣的大佬可以试一下。
考虑这样的明文:
{'admin': 0,'password':'e','username':'eustiar'}
将他按照16字节一组分开(注意有空格)
{'admin': 0,'pas sword':'e','user name':'eustiar'}
我们的目标在第一个块的第十一位,可以通过修改初始向量iv的第十一位来改变。
index = 10 ivarr[index] = ivarr[index] ^ ord('1') ^ ord('0')
iv原本的第11位(对应上面所说的A)与字符1(我们想要的值)的ascii码以及0(原本的值,对应上面所说的C)的ascii码做异或
附带写的脚本:
import base64 cipher = '76+0mw9u9OfMv58sgaIAZPMqdjE8CjqWyFeKwCIK6fI8BJB3yAQJWEP122YPR9A8L/5Oo5Okc0KXeW5sdHzthNfPPho5J5eoFPSLRAVsllk=' cipher = base64.b64decode(cipher) iv = cipher[:16] cipher = cipher[16:] index = 10 ivarr = bytearray(iv) ivarr[index] = ivarr[index] ^ ord('1') ^ ord('0') newiv = bytes(ivarr) print(base64.b64encode(newiv + cipher))
将得到的新cookie代替原来的即可get flag。