CBC字节翻转攻击和Padding Oracle

异或运算(XOR)


我们知道,计算机存储的数据是以二进制的格式存入的,把两段二进制数字进行异或运算的话,相同的得0,不同的得1,例如:

0101 XOR 0110 = 0011
65 XOR 42 = 107(十进制数先转为二进制再进行异或操作)

字符在计算机中有对应的ascii码值,对字符进行异或运算就是将两串字符对应的ascii码值相异或把得到的ascii码值对应的字符相异或,异或运算具有可逆性,如65 XOR 42 = 107 则 107 XOR 42 = 65,因此对于异或运算a XOR b = c,我们只需知道abc中的任意两个,将这两个相异或便可求出第三个。

CBC加密流程


CBC是一种加密的模式,经常把DES或者AES算法(两种分组密码算法)作为加密使用的算法。这里先简单介绍一下分组加密,所谓分组加密顾名思义,就是按一定规则把明文分成一块一块的小组,DES分组长度是八字节而AES分组长度是十六字节,每组长度一致,加密时是按组进行加密的。CBC模式的加密过程如下图所示:
图片.png
Plaintext:待加密的数据。
IV:用于随机化加密的比特块,保证即使对相同明文多次加密,也可以得到不同的密文。
Ciphertext:加密后的数据。

整个加密的过程简单说来就是:

1.对明文进行分组,使每组长度相同(通常为 8 或 16 字节,取决于加密算法),对长度不足的分组进行填充,通常,填充遵循的是 PKCS#5 标准,即填充的字符即为需要填充的字符的个数(十六进制表示)
2.随机生成一个 Initialization Vector(IV),将第一个明文分组与 IV 作异或运算
3.将 2 的结果进行加密,得到第一个明文分组的密文
4.从第二个明文分组开始,先将明文分组与上一组的密文作异或运算,再将结果进行加密,得到该分组的密文
5.将IV和这n块密文连在一起便得到了明文用CBC模式加密后的密文。

CBC解密流程


图片.png

1.首先按照一定长度将密文分好组,其中密文的第一组是初始的IV值,第二组密文对应第一组明文。
2.分好组后,从第二组密文开始依次用算法进行解密运算得到n组中间值,这时候得到的值并不是明文,要想得到明文还需进行一次异或操
作,第一个中间值与初始IV值异或得到明文,第二个中间值与前一组的密文异或得到第二组明文,以此类推最后一组中间值与倒数第二组密
文进行亦或便可得到最后一组明文,将所有的明文连在一起便是最终的明文。

CBC字节翻转攻击原理


以第一个分组为例:
加密过程:将原明文称为sourceStr,初始IV称为old_IV,sourceStr^old_IV得到中间值middlecipher。middlecipher经过分组加密算法(aes,des)得到第一组密文。
解密过程:第一组密文经分组解密算法得到中间值middlecipher,middlecipher^old_IV得到原明文sourceStr。

同理正常解密过程sourceStr=middlecipher^old_IV
我们希望通过提交构造的evil_IV得到我们想要的解密明文targeStr,也就是targeStr=middlecipher^evil_IV
而得到evil_IV的方式为:evil_IV=middlecipher^targeStr但是需要知道middlecipher

由上面可知中心为middlecipher,只要得到了middlecipher就可以构造出evil_IV。
因为sourceStr=middlecipher^old_IV所以知道了明文sourceStr和old_IV就能异或出middlecipher。
evil_IV=old_IV^sourceStr^targetStr
已知明文sourceStr和old_IV可以构造Evil-IV来改变明文为目标明文targetStr(这里只能改变第一个分组)。

#python 3
import os
import codecs
from Crypto.Cipher import AES
from Crypto import Random
SECRET_KEY=codecs.encode(os.urandom(8),'hex_codec').upper() #16byte密钥
IV=Random.new().read(16)    #16byte初始向量

sourceStr='hello,world'
aes = AES.new(SECRET_KEY,AES.MODE_CBC,IV)
length = 16
count = len(sourceStr)
add = length - (count % length)
sourceStr = sourceStr + (chr(0) * add) #正常情况下这里应该是补\x05 5由16-11得来,但由于我省事不想处理结果后面出现的乱码,就省事改为\x00了
cipher=aes.encrypt(sourceStr)
print("明文为:",sourceStr)
print("秘钥为:",SECRET_KEY)
print("IV为:",IV)
# print("明文序列为:",list(sourceStr))
print("密文为:",aes.encrypt(sourceStr))

old_IVList=[]
sourceStrList=[]
for i in range(0,len(codecs.encode(IV,'hex_codec').decode()),2):
    old_IVList.append(int(codecs.encode(IV,'hex_codec').decode()[i:i+2],16))
evil_IVList=old_IVList
for i in list(sourceStr):
    sourceStrList.append(ord(i))

evil_IVList[9]=sourceStrList[9]^old_IVList[9]^0  #让第10位消失
evil_IVList[10]=sourceStrList[10]^old_IVList[10]^ord('D')  #让解出来的明文第11位为D
evil_IVList[11]=sourceStrList[11]^old_IVList[11]^ord('!')  #再加个!
evil_IV=''

for i in evil_IVList:
    if len(hex(i)[2:])==1:  #为了保证构造出来的IV是16byte,1位的16进制补0变成2位
        evil_IV+='0'+hex(i)[2:]
    else:
        evil_IV += hex(i)[2:]
evil_IV=codecs.decode(evil_IV,'hex_codec')  #恶意构造的16byte IV
print("恶意构造出的IV:",evil_IV)
aes = AES.new(SECRET_KEY,AES.MODE_CBC,evil_IV)
dsc=aes.decrypt(cipher).decode()
print("利用恶意构造的IV结出来的明文:",dsc)

#这是在知道明文和IV的情况下并可以提交我们构造的IV的情况下,我们可改变第一个明文分组的值

图片.png
先不看怎么影响所有明文分组。

padding oracle攻击原理


padding oracle是用来获取明文source_Str的,但更准确的说应该是获取中间值middlecipher的。获取了中间值之后,如果我们知道了初始IV和密文cipher后,就可以得到明文sourceStr了(sourceStr=middlecipher^old_IV)。然后我们就可以进行CBC字节翻转攻击了。

CBC加密模式要对明文进行分组(通常为8或16字节,取决于算法,比如AES-128-CBC就是16字节,128bit=16byte),CBC分组遵循PKCS#5标准,填充的字符为余下字节的个数。用8byte分组做例子的话,如下图,需注意即便分组内容能正好平均分为n组,仍需要在最后一组后面填充一个八位分组,图片.png
假设我们向服务器提交了正确的密码,我们的密码在经过CBC模式加密后传给了服务器,这时服务器会对我们传来的信息尝试解密,如果可以正常解密会返回一个值表示正确,如果不能正常解密则会返回一个值表示错误。而事实上,判断提交的密文能不能正常解密,第一步就是判断密文最后一组的填充值是否正确,也就是观察最后一组解密得到的结果的最后几位,如果错误将直接返回错误,如果正确,再将解密后的结果与服务器存储的结果比对,判断是不是正确的用户。也就是说服务器一共可能有三种判断结果:

1.如果参数是完全正确的,身份认证成功,返回 HTTP 200 OK,提示认证成功(也就是解密的明文正确)
2.如果参数是可以解密为正确格式的明文的密文(明文 Padding 等正确),但是身份认证失败,返回 HTTP 200 OK,提示认证失败(只有padding格式正确,我们主要利用这一点)
3.如果参数不是可以解密为正确格式的明文的密文(明文的 Padding 错误等),服务器内部抛出异常,返回 HTTP 500 Internal Server Error
其中第一种情况与第二 三种情况的返回值一定不一样,这就给了我们可乘之机——我们可以利用服务器的返回值判断我们提交的内容能不能正常解密,进一步讲,我们可以知道最后一组密文的填充位符不符合填充标准。

图片.png
如上图所示,明文填充了四位时,如果最后一组密文解密后的结果(Intermediary Value也就是中间值)与前一组密文(Initialization Vector也就是IV值)异或得到的最后四位是0×04,那么服务器就会返回可以正常解密。

CBC模式的解密过程,第n组密文解密后的中间值与前一组的密文异或便可得到明文,现在我们不知道解密的密钥key,但我们知道所有的密文,因此只要我们能够得到中间值便可以得到正确的明文(进行一次异或运算便可),而中间值是由服务器解密得到的,因此我们虽然不知道怎么解密但我们可以利用服务器帮我们解密,我们所要做的是能确定我们得到的中间值是正确的,这也是padding oracle attack的核心——找出正确的中间值。

(1)假设我们捕获到了传输的密文并且我们知道是CBC模式采用的什么加密算法,我们把密文按照加密算法的要求分好组,然后对倒数第二组密文进行构造; 
(2)先假定明文只填充了一字节,对倒数第二组密文的最后一字节从0×00到0xff逐个赋值并逐个向服务器提交,直到服务返回值表示构造后
的密文可以正常解密,这意味着构造后的密文作为中间值(图中黄色的那一行)解密最后一组明文,明文的最后一位是0×01(如图所示
),也就是说构造的倒数第二组密文的最后一字节与最后一组密文对应中间值(绿色的那一行)的最后一位相异或的结果是0×01

图片.png

(3)利用异或运算的性质,我们把我们构造的密文的最后一字节与0×01异或便可得到最后一位密文解密后的中间值是什么,这里我们设为M
1,这一过程其实就是对应下图CBC解密过程中红圈圈出来的地方,1就是我们想要得到的中间值,二就是我们构造的密文也就是最后一组密
文的IV值,我们已经知道了plaintext的最后一字节是0×01,从图中可以看到它是由我们构造的IV值与中间值的最后一字节异或得到的;

图片.png

(4)再假定明文填充了两字节也就是明文最后两字节是0×02,接着构造倒数第二组密文,我们把M1与0×02异或可以得到填充两字节时密文
的最后一位应该是什么,这时候我们只需要对倒数第二位进行不断地赋值尝试(也是从0×00到0xff),当服务器返回值表示可以正常解密时
,我们把此时的倒数第二位密文的取值与0×02异或便可得到最后一组密文倒数第二字节对应的中间值;
(5)后再构造出倒数第三倒数第四直到得到最后一组密文的中间值,把这个中间值与截获的倒数第二组密文异或便可得到最后一组分组的明文; 
(6)舍弃掉最后一组密文,只提交第一组到倒数第二组密文,通过构造倒数第三组密文得到倒数第二组密文的铭文,以此类推,最后我们便
可以得到全部的明文

攻击成立的两个重要假设前提:
(1) 攻击者能够获得密文(Ciphertext),以及附带在密文前面的IV(初始化向量)
(2) 攻击者能够触发密文的解密过程,且能够知道密文的解密结果(是否是正常解密)

实例


ISCC2018

<?php
define("SECRET_KEY", file_get_contents('/root/key'));
define("METHOD", "aes-128-cbc");
session_start();

function get_random_iv(){
    $random_iv='';
    for($i=0;$i<16;$i++){
        $random_iv.=chr(rand(1,255));
    }
    return $random_iv;
}

function login($info){
    $iv = get_random_iv();
    $plain = serialize($info);
    $cipher = openssl_encrypt($plain, METHOD, SECRET_KEY, OPENSSL_RAW_DATA, $iv);
    $_SESSION['username'] = $info['username'];
    setcookie("iv", base64_encode($iv));
    setcookie("cipher", base64_encode($cipher));
}

function check_login(){
    if(isset($_COOKIE['cipher']) && isset($_COOKIE['iv'])){
        $cipher = base64_decode($_COOKIE['cipher']);
        $iv = base64_decode($_COOKIE["iv"]);
        if($plain = openssl_decrypt($cipher, METHOD, SECRET_KEY, OPENSSL_RAW_DATA, $iv)){
            $info = unserialize($plain) or die("<p>base64_decode('".base64_encode($plain)."') can't unserialize</p>");
            $_SESSION['username'] = $info['username'];
        }else{
            die("ERROR!");
        }
    }
}

function show_homepage(){
    if ($_SESSION["username"]==='admin'){
        echo $flag;
    }else{
        echo '<p>hello '.$_SESSION['username'].'</p>';
        echo '<p>Only admin can see flag</p>';
    }
    echo '<p><a href="loginout.php">Log out</a></p>';
}

if(isset($_POST['username']) && isset($_POST['password'])){
    $username = (string)$_POST['username'];
    $password = (string)$_POST['password'];
    if($username === 'admin'){
        exit('<p>admin are not allowed to login</p>');
    }else{
        $info = array('username'=>$username,'password'=>$password);
        login($info);
        show_homepage();
    }
}else{
    if(isset($_SESSION["username"])){
        check_login();
        show_homepage();
    }else{
        echo '<body class="login-body">
                <div id="wrapper">
                    <div class="user-icon"></div>
                    <div class="pass-icon"></div>
                    <form name="login-form" class="login-form" action="" method="post">
                        <div class="header">
                        <h1>Login Form</h1>
                        <span>Fill out the form below to login to my super awesome imaginary control panel.</span>
                        </div>
                        <div class="content">
                        <input name="username" type="text" class="input username" value="Username" onfocus="this.value=\'\'" />
                        <input name="password" type="password" class="input password" value="Password" onfocus="this.value=\'\'" />
                        </div>
                        <div class="footer">
                        <input type="submit" name="submit" value="Login" class="button" />
                        </div>
                    </form>
                </div>
            </body>';
    }
}
?>

1.如果username===admin就输出flag,但是禁止了admin登陆。
2.把username和password构造为一个数组,然后进行序列化,并对这个序列化对象进行aes-128-cbc加密,iv值为16位随机数。加密值赋值给cipher。
openssl_encrypt()函数
3.session[username]=username序列化并cbc加密后的值。对iv和cipher进行base64加密并设置了cookie
4.最后对cipher和iv进行base64解密。然后对cipher进行cbc解密,如果与原序列化对象值相等则对其进行反序列化,错误则报错die("<p>base64_decode('".base64_encode($plain)."') can't unserialize</p>。把反序列化后username值赋给session[username]。

上面就是整个过程。这里把登录的用户名及其密码存入数组,序列化后进行AES-CBC模式的加密,其中iv,和密文以cookie储存,可以控制,导致存在攻击的可能。
使用skctf登陆,因为翻转目标是admin5位,所以登陆的用户名最好也为5位。登陆后存入数组序列化后变为: a:2:{s:8:"username";s:5:"skctf";s:8:"password";s:5:"skctf";}
图片.png
1.首先将明文修改为16字节的四组:

a:2:{s:8:"userna
me";s:5:"skctf";
s:8:"password";s
:5:"skctf";}

2.根据CBC攻击原理,只要修改第一组密文对应第二组'skctf'的位置的明文,就可以实现对第二组明文的改变。即第10-14位。(我们抓包获得的cipher是所有加密后的密文,因此只要我们更改cipher中第10-14位就可以。因为第二组明文和第一组密文已知,则可以求出第二组的中间值。将中间值中对应的第10-14位和明文中的skctf进行异或则可以得到应该修改的cipher的第10-14位的值。图片.png

3.下面是网上找的重新生成密文的脚本

    # -*- coding: utf-8 -*-
    import base64
    cipher = 'EvX06IpeOJ9iTJ7J1L9iP7ymKC1Phg7KySWJP0ZbAtlwVMnniIkdvRH60+BY5g+8i0WKbQvgJIZVbGsQGTzH5A=='.decode('base64')
    old = "me\";s:5:\"skctf\";"
    new = "me\";s:5:\"admin\";"

    for i in xrange(16):
        cipher = cipher[:i] + chr(ord(cipher[i]) ^ ord(old[i]) ^ ord(new[i])) + cipher[i+1:]

    print cipher.encode('base64').strip()

图片.png
用上面生成的密文修改cookie:cipher,访问,因为不能正常反序列化,所以报错返回一个cbc解密后的明文值。图片.png
4.根据CBC解密原理,修改第一块的密文可以达到修改第二块明文的结果,但同时也破坏了第一块明文。所以我们需要修复被破坏的第一块明文。根据上面得到的破坏后的明文,让他与原来的IV进行异或得到第一组的中间值,然后用这个中间值去跟修补后的明文异或从而得到一个新的IV,提交这个新的IV和cipher即可得到flag。
可以利用下面的脚本重新生成IV:

# -*- coding: utf-8 -*-
import base64

plain = 'uSb7VZM9bSikP7AiZD5F5m1lIjtzOjU6ImFkbWluIjtzOjg6InBhc3N3b3JkIjtzOjQ6IjIzMzMiO30='.decode('base64')
iv = 'KvfyPMU0MPU2PalcPn3TwA=='.decode('base64')

old = plain[:16]
new = "a:2:{s:8:\"userna";
for i in xrange(16):
    iv = iv[:i] + chr(ord(iv[i]) ^ ord(old[i]) ^ ord(new[i])) + iv[i+1:]

print iv.encode('base64').strip()

用生成的新IV替换cookie中的IV
图片.png

参考文献:

Padding oracle attack详细解析
Padding Orlace
CBC字节翻转攻击