15-and-16
In [1]:
from libmatasano import html_test

PKCS#7 padding validation

Write a function that takes a plaintext, determines if it has valid PKCS#7 padding, and strips the padding off.

The string:

"ICE ICE BABY\x04\x04\x04\x04"

... has valid padding, and produces the result "ICE ICE BABY".

The string:

"ICE ICE BABY\x05\x05\x05\x05"

... does not have valid padding, nor does:

"ICE ICE BABY\x01\x02\x03\x04"

If you are writing in a language with exceptions, like Python or Ruby, make your function throw an exception on bad padding.

Crypto nerds know where we're going with this. Bear with us.

In [2]:
class PaddingError(Exception):
    pass
In [3]:
block_size = 16

def pkcs7_strip(x, block_size):
    # we should also check that the padded message has a correct size
    if not len(x) % block_size == 0:
        raise PaddingError
        
    last_byte = x[-1]
    
    # the 'int' is superfluous here
    # as last_byte is already an int
    # (for Python a byte string is a list of integers)
    # but this way it's clearer what we are doing
    padding_size = int(last_byte)
    
    if not x.endswith(bytes([last_byte])*padding_size):
        raise PaddingError

    return x[:-padding_size]

assert pkcs7_strip(b'ICE ICE BABY\x04\x04\x04\x04', block_size) == b'ICE ICE BABY'

try:
    pkcs7_strip(b'ICE ICE BABY\x05\x05\x05\x05', block_size)
    pkcs7_strip(b'ICE ICE BABY\x01\x02\x03\x04', block_size)
except PaddingError:
    # got padding error as expected
    html_test(True)
else:
    print("ERROR: expected a padding error")
    html_test(False)
OK

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.

Again, an attack that is possible because encryption does not provide integrity protection.

Quick remark about padding: The functions encrypt_aes_128_cbc and decrypt_aes_128_cbc that I put in libmatasano already apply PKCS#7 padding. So don't be surprised if you don't see anything about padding in this code.

In [4]:
import os
import urllib

from libmatasano import encrypt_aes_128_cbc, decrypt_aes_128_cbc

class Oracle:
    def __init__(self):
        # the key is not regenerated for each encryption,
        # otherise the attack wouldn't work
        self.key = os.urandom(16)
        
    def encrypt(self, msg):
        # using urllib to quote characters (a bit overkill)
        quoted_msg = urllib.parse.quote_from_bytes(msg).encode()
        
        full_msg = (
            b"comment1=cooking%20MCs;userdata="
            + quoted_msg
            + b";comment2=%20like%20a%20pound%20of%20bacon"
        )
                
        iv = os.urandom(16)
        ciphertext = encrypt_aes_128_cbc(full_msg, iv, self.key)
        
        return {"iv":iv, "ciphertext":ciphertext}
    
    def decrypt_and_check_admin(self, ctxt):
        ptxt = decrypt_aes_128_cbc(ctxt["ciphertext"], ctxt["iv"], self.key)
        
        if b";admin=true;" in ptxt:
            return True
        else:
            return False
In [5]:
oracle = Oracle()

cryptogram = oracle.encrypt(b"hi")
# having a look what the decrypted message looks like
print("decrypted:", decrypt_aes_128_cbc(cryptogram["ciphertext"], cryptogram["iv"], oracle.key))
# are we admin ? we shouldn't be
print("admin:", oracle.decrypt_and_check_admin(cryptogram))
decrypted: b'comment1=cooking%20MCs;userdata=hi;comment2=%20like%20a%20pound%20of%20bacon'
admin: False

We want to make ;admin=true; appear in the message but we cannot input any ";" or "=":

In [6]:
cryptogram = oracle.encrypt(b"lol;admin=true")
print("decrypted:", decrypt_aes_128_cbc(cryptogram["ciphertext"], cryptogram["iv"], oracle.key))
print("admin:", oracle.decrypt_and_check_admin(cryptogram))
decrypted: b'comment1=cooking%20MCs;userdata=lol%3Badmin%3Dtrue;comment2=%20like%20a%20pound%20of%20bacon'
admin: False

Like they said in CBC decryption flipping a bit in a ciphertext block will flip the exact same bit in the next block of plaintext. Let's observe that:

In [7]:
cryptogram = oracle.encrypt(b"whatever")
In [8]:
# In Python, 'bytes' objects are immutable
# So I am using the mutable version, "bytearray",
# to modify bytes in-place
# https://docs.python.org/3/library/stdtypes.html#bytearray-objects
c = bytearray(cryptogram["ciphertext"])
# we're flipping the lowest bit of the 21st byte of the ciphertext
# with a "XOR" operation
c[20] = c[20] ^ 0x01

print("decrypted:", decrypt_aes_128_cbc(bytes(c), cryptogram["iv"], oracle.key))
decrypted: b'comment1=cooking\xc1\x85H\x1d\xb3*\xc0\x1b\xe1+~8\xa0-D\xbawhatdver;comment2=%20like%20a%20pound%20of%20bacon'

It's pretty clear here: a whole block has been completely scrambled, and a single bit has been flipped in the next block ("whatever" became "whatdver").

So here's the plan:

  • have some known plaintext encrypted; It does not really matter what's in the message, as long as we know exactly what the message is. We will just use XXXX...
  • flip the bits of this known plaintext so that this will make the "admin" thing appear in the message.

Conveniently enough, we see that the content of "userdata" ("whatever" in our previous example) starts exactly at the beginning of a block. (in the last output it appears right after the block of messed up bits caused by our bitflipping).

To make it simple, we will give a value for userdata that is as long as ;admin=true and that is just a list of "X":

In [9]:
len(b";admin=true")
Out[9]:
11
In [10]:
from libmatasano import bxor
In [11]:
# flipping bits is equivalent to applying a XOR operation;
pad = bxor(b";admin=true", b"X"*11)
In [12]:
cryptogram = oracle.encrypt(b"X"*11)
In [13]:
cryptogram["ciphertext"] = bxor(cryptogram["ciphertext"], b'\x00'*16 + pad)
In [14]:
print("decrypted:", decrypt_aes_128_cbc(cryptogram["ciphertext"], cryptogram["iv"], oracle.key))
decrypted: b'comment1=cookingiw]\xcf\x8a\x01V\\\x05t\x9f\xd0\xbac\t\xe6;admin=true;comment2=%20like%20a%20pound%20of%20bacon'
In [15]:
print("admin:", oracle.decrypt_and_check_admin(cryptogram))
admin: True

Et voilĂ  !

Again: All this is possible because encryption only protect the privacy of the message, not its integrity.