CBC字节翻转攻击

很久之前做过这一类的题目,不过当时只是大概看了一下,用了别人的脚本,今天在做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。

打赏作者

发表评论

电子邮件地址不会被公开。 必填项已用*标注