16. Attaque de CBC par renversement d'octet

16. Attaque de CBC par renversement d'octet

CBC bitflipping attacks

Generate a random AES key.
Combine your padding code and CBC code to write two functions.
The first function should take an arbitrary input string, prepend the string:
"comment1=cooking%20MCs;userdata="
.. and append the string:
";comment2=%20like%20a%20pound%20of%20bacon"
The function should quote out the ";" and "=" characters.

The function should then pad out the input to the 16-byte AES block length and encrypt it under the random AES key.

The second function should decrypt the string and look for the characters ";admin=true;" (or, equivalently, decrypt, split the string on ";", convert each resulting string into 2-tuples, and look for the "admin" tuple).

Return true or false based on whether the string exists.
If you've written the first function properly, it should not be possible to provide user input to it that will generate the string the second function is looking for. We'll have to break the crypto to do that.
Instead, modify the ciphertext (without knowledge of the AES key) to accomplish this.
You're relying on the fact that in CBC mode, a 1-bit error in a ciphertext block:

  • Completely scrambles the block the error occurs in
  • Produces the identical 1-bit error(/edit) in the next ciphertext block.

Stop and think for a second.
Before you implement this attack, answer this question: why does CBC mode have this property?

Fonctions de chiffrement et de validation

La première étape est de coder les différentes fonctions requises pour l'exercice ; celle qui ajoute préfixe et suffixe puis chiffre, celle qui déchiffre le texte et valide le statut de l'utilisateur:

import os
from cryptopals import cbc_encrypt, cbc_decrypt, padding, padding_unset

prepend = b"comment1=cooking%20MCs;userdata="
append = b";comment2=%20like%20a%20pound%20of%20bacon"

key = os.urandom(16)
iv = os.urandom(16)

def is_admin(text):
    return b";admin=true;" in cbc_decrypt(text, key, iv)

def submit_string(string):
    text = prepend + string.decode().replace("=", "_").replace(";", "_").encode() + append
    print(len(text))
    return cbc_encrypt(text, key, iv)

On peut les invoquer comme suit:

cipher_text = submit_string(b"abc")
print(is_admin(cipher_text))

Pour rappel, le fichier cryptopals.py qui nous sert à conserver les fonctions utiles pour plusieurs exercices à la contenu suivant :

from Crypto.Cipher import AES
from base64 import b64decode

import logging

def xor_hex(string_one, string_two):
    return hex(int(string_one, 16) ^ int(string_two, 16))[2:]

def xor_single_char_breaker(hex_string):
    length = len(hex_string) // 2

    xor_list = []

    for i in range(32, 127):
        try:
            xored_hex = xor_hex(hex_string, hex(i)[2:]*length)
            xor_list.append({
                "char": chr(i),
                "string": bytearray.fromhex(xored_hex).decode(),
                "score": char_frequency_score(bytearray.fromhex(xored_hex).decode())
            })
        except:
            pass

    return sorted(xor_list, key=lambda d: -d['score'])


FREQUENCY_SCORES = {
    "A": 8.2,
    "B": 4.4,
    "C": 5.2,
    "D": 4.3,
    "E": 13,
    "F": 4,
    "G": 1.6,
    "H": 6.1,
    "I": 7,
    "J": 0.51,
    "K": 0.86,
    "L": 2.4,
    "M": 3.8,
    "N": 6.7,
    "O": 7.5,
    "P": 4.3,
    "Q": 0.22,
    "R": 6,
    "S": 6.3,
    "T": 9.1,
    "U": 2.8,
    "V": 0.82,
    "W": 5.5,
    "X": 0.045,
    "Y": 0.76,
    "Z": 0.045,
    "'": 1,
    " ": 15,
}
def char_frequency_score(string):
    string = string.upper()
    score = 0
    for char in string:
        if(char in FREQUENCY_SCORES):
            score += FREQUENCY_SCORES[char]
        else:
            score -= 1
    return score

def xor_string(string, key):
    key_length = len(key)
    result = []
    for i in range(len(string)):
        result.append(chr(ord(string[i]) ^ ord(key[i%key_length])))
    return "".join(result)

def xor(var, key):
    return bytes(a ^ b for a, b in zip(var, key))

def padding(bytes_string, block_length=16):
    if len(bytes_string) == block_length:
        return bytes_string
    else:
        padding_length = block_length - (len(bytes_string) % block_length)
        return bytes_string+(bytes([padding_length])*padding_length)

def padding_unset(bytes_string, block_length=16):
    try:
        if(len(bytes_string) != block_length):
            raise ValueError("Block does not match expected block length")
        last_byte_value = bytes_string[-1]
        
        if(last_byte_value >= block_length):
            return bytes_string
        elif(
            len(set(bytes_string[-last_byte_value:])) == 1 
            and all(bytes_string[i] >= block_length for i in range(0,block_length-last_byte_value))
        ):
            return bytes_string[:block_length-last_byte_value]
        else:
            raise ValueError("Invalid padding value")
    except Exception as e :
        logging.error(e)

def ecb_encrypt(text, key):
    aes_object = AES.new(key, AES.MODE_ECB)
    return aes_object.encrypt(padding(text, 16))

def ecb_decrypt(cipher_text, key):
    aes_object = AES.new(key, AES.MODE_ECB)
    return aes_object.decrypt(cipher_text)

def cbc_encrypt(text, key, mask):
    block_size = len(mask)
    blocks = [padding(text[i:i+block_size], block_size) for i in range(0, len(text), block_size)]
    result = b""
    for block in blocks:
        mask = ecb_encrypt(xor(block, mask), key)
        result += mask
    return result

def cbc_decrypt(cipher_text, key, mask):
    block_size = len(mask)
    blocks = [cipher_text[i:i+block_size] for i in range(0, len(cipher_text), block_size)]
    result = b""
    for block in blocks:
        result += padding_unset(xor(ecb_decrypt(block, key), mask), block_size)
        mask = block
    return result

Théorie de l'attaque par renversement d'octet

Pour rappel, voici à quoi ressemble les étapes de déchiffrement de CBC :

CBC decryption D'après un schéma de wikimedia

Chaque bloc subi une opération XOR (avec l'IV pour le premier bloc, puis avec le bloc chiffré précédent pour les autres), puis est déchiffé.

Imaginons maintenant que l'on modifie un octet dans le second bloc chiffré (représenté par le octet rouge sur le schéma). Cela aura pour effet de rendre tout le contenu déchiffré du second bloc illisible, mais modifiera aussi par XOR le résultat du dernier bloc une fois déchiffré:

CBC decryption attack

De manière très simple, cela signifie que si l'on chiffre une première fois une chaine de donnée, alors en modifiant de manière précise celle-ci et en la renvoyant à une fonction qui va la décoder, on peut insérer de la donnée arbitraire.

Ainsi, si l'on sait que ce texte est généré par la fonction de chiffrement:

comment1=cooking%20MCs;userdata=abc;comment2=%20like%20a%20pound%20of%20bacon

Et que l'on veut également voir apparaitre la suite de caractères suivant:

;admin=true;

Alors si l'on prend les 12 derniers caractères du texte qui sera généré et qu'on le XOR avec le texte qu'on veut insérer, on a :

from cryptopals import xor

xor_result = xor(b"20of%20bacon", b";admin=true;")
print(xor_result)

Qui nous donne la valeur:

\tQ\x0b\x0bL\\\r\x16\x13\x16\nU

Il faut de nouveau appliquer une transformation XOR avec les octets chiffrés originiaux du bloc précédent celui qu'on veut modifier

Ainsi, si on remplace les 8 derniers caractères de l'avant dernier bloc du texte chiffré retourné par la fonction submit_string et que l'on utilise ces blocs modifiés en argument de la fonction is_admin, on obtient bien le texte ;admin=true;

Code

L'implémentation en code Python est triviale :

cipher_text = submit_string(b"11111111111111111111111111111111")
custom_mask = xor(xor(cipher_text[32:48], b"1111111111111111"), b";admin=true;1111")
cipher_text = custom_mask.join([cipher_text[:32], cipher_text[32+len(custom_mask):]])

result = cbc_decrypt(cipher_text, key, iv)
print(is_admin(cipher_text))

La première ligne crée un texte qui, avant d'être chiffré, aura la forme suivante (les retours à la ligne représentes les séparations de blocs) :

comment1=cooking
%20MCs;userdata=
1111111111111111
1111111111111111
;comment2=%20lik
e%20a%20pound%20
of%20bacon

Une fois chiffré il ressemblera à cela :

\xc3#u\xdeW>N\xe8\xe7-#$\xd3\x19\r\xae
\xcd\xcc\xce'\xb3\xdd\xa2\xd2\xe4Da\x84\xf0\xe5\xac\xd4
W)\xbe\xb8h\x05$\xf1<\xb1\xc8\xeb\xee:j>
\x97qN\x8a\xe3\xc4\xcfY\x82\xfd\x84\xa8\xcdGR4
\xbe\xd3\xb0\x17\x15\x85g\x9d*\xbd\x1eCt\x9en\xd1
\x85b\xd6ey\x8e\xa0HLz\x14\x983\xa6\x08\xcd
6:\xb6\x15?\xf9\x0c\xf4\x18\xc48\x15\xc3\xfa\xd8\x8f

La ligne de code créant la variable custom_mask a pour effet d'appliquer deux opérations XOR sur le bloc 4:

1111111111111111 ^ \x97qN\x8a\xe3\xc4\xcfY\x82\xfd\x84\xa8\xcdGR4 ^ ;admin=true;1111

De cette manière là, on trouve la valeur qui en remplaçant le bloc 3, sera appliqué comme masque XOR lors du déchiffrement du bloc 4.

Le résultat de cette valeur vient ensuite logiquement remplacer le bloc 3 dans notre donnée chiffrée.

Lorsque l'on exécute ce script, la fonction is_admin nous reconnait bien comme administrateur car elle déchiffre la chaine de caractère ;admin=true;1111 depuis la donnée qu'on lui envoie :

comment1=cooking%20MCs;userdata=\xf0\x17\n3\x98\xc3\xfbn6\xbc\x05f\xbc\x96>\xed;admin=true;1111;comment2=%20like%20a%20pound%20of%20bacon