logo Menu
Home > Competition

BlackHat 2022 - Webbed (Crypto) Writeup

25 October 2024

Challenge

We are given the source-code for a web service that allows the user to register and login.

Register

input: Username Output: cookie in the form of

token = {
	'username' : username, 
	'admin'	   : True, False
  }

Login

input: Username Output: Flag if validate_token returns True

validate_token

def validate_token(self, enc):
	try:
		tok = self.remove_illegal_chars(self.decrypt(enc))
		raw = json.loads(tok)
		return raw['username'], raw['admin']
	except:
		raise ValueError()

remove_illegal_chars

ALLOWED = b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_'
def remove_illegal_chars(self, token):
	il = token.index(b': "') + 3
	ir = il + token[il:].index(b'"')
	return token[:il] + bytes(i for i in token[il:ir] if i in ALLOWED) + token[ir:]

So we should Register with our user, change the admin parameter in the ciphertext token to True from False.

The encryption method being used to create the tokens is AES in Cipher Block Chaining (CBC) mode. Which is suscepticle to bit flip attacks.

def encrypt(self, msg):
	pdm = pad(msg, 16)
	riv = os.urandom(16)
	aes = AES.new(self.key, AES.MODE_CBC, riv)
	cip = riv + aes.encrypt(pdm)
	return cip

The CBC mode of AES Encryption is detailed below.

AES CBC Encryption Encryption flow of AES in CBC mode

AES operates in block sizes of 16 bytes per block. The message is always padded using PKcs7 padding.

An example of plaintext after being padded:

b'{"username": "test____________________________", "admin": false}\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10'

after being split into 16 byte chunks:

blocks = [
		  b'{"username": "te', 
		  b'st______________', 
		  b'______________",', 
		  b' "admin": false}', 
		  b'\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10'
		  ]

We want to change the block

b' "admin": false}'

into

b'","admin": true}'

From the diagram for the CBC mode of AES Decryption we can see that each block is decrypted and then XOR’d with the ciphertext of the previous block. And since we can control the ciphertext blocks, we are able to influence the output of dec(ct_block[i]) XOR ct_block[i-1]to get the result we want.

AES CBC Decryption Decryption flow of AES in CBC mode

The drawback to this is that the block we are changing will get ruined in the output. If we are changing ct_block[i-1] to influence the output of dec(ct_block[i]) XOR ct_block[i-1], Then the output of dec(ct_blocks[i-1]) will not be the expected result.

Looking at the blocks structure, we should create a username long enough so that we are not messing up data that’s crucial in the structure of the json object

blocks = [
		  b'{"username": "te', 
		  b'st______________', 
		  b'______________",', <----- We will influence the encryption of this
		  b' "admin": false}', <----- To maniuplate this block
		  b'\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10'
		  ]

The Attack

First let’s create two functions to register and login

Register/Login

def register(username: str):
    session = requests.Session()
    endpoint = f'/register/{username}'
    session.get(HOST+endpoint)
    token = session.cookies.get('token')
    token = bytes.fromhex(token)
    return token
def login(username: str, token: str):
    endpoint = f'/login/{username}'
    cookies = {'token': token}
    r = requests.get(HOST+endpoint, cookies=cookies)
    return r

Register Registering a user

We receive a token in hex format, which is the encryption of the actual token in the structure shown above.

Let’s create two helper functions to split ciphertext into blocks of data, and a function to xor blocks together.

def xor(a: bytes, b: bytes, repeat = True) -> bytes:
    '''
        to get the key to roll over, the module operator
        is used over the length of the key, 
        so string_pos MOD key_length
        ex: 0%3 = 0, 1%3 = 1, 2%3 = 2, 3%3 = 0, etc..
    '''
    xored = []
    if repeat:
        for char_pos, c in enumerate(a):
            xored.append(c ^ b[char_pos%len(b)])
    else:
        if len(a)<len(b):
            b, a = a, b
        for char_pos in range(len(b)):
            xored.append(a[char_pos] ^ b[char_pos])
        for char_pos in range(len(b), len(a)):
            xored.append(a[char_pos])
    return bytes(xored)
    
def get_blocks(data: bytes, bs: int = 16) -> list:
    return [data[i:i+bs] for i in range(0, len(data), bs)]

Next we need to create a function that takes:

  1. ciphertext_block[i]
  2. Known plaintext of ciphertext_block[i+1]
  3. Target plaintext of ciphertext_block[i+1]

And returns a new ciphertext_block[i] that we can use in the login token. This will change our admin parameter to True.

This function will go through every byte and basically xor the previous block with the xor of input and target texts.

def bit_flip(previous_block: bytes, input_text: bytes, target: bytes) -> bytes:
    '''
        input a block of 16 bytes previous to the block of inputted text 
        to be manipulated to produce target_text when decrypted, use '_'
        in spots where you want the bits to flip
    '''
    if len(input_text) != len(target):
        raise ValueError("mismatched input and target sizes")
    flipped_bytes = b''
    for index, c in enumerate(input_text):
        if chr(c) == '_':
            flipped_bytes += bytes([previous_block[index]])
        else:
            flipping_key = input_text[index] ^ target[index]
            flipped_byte = bytes([previous_block[index] ^ flipping_key])
            flipped_bytes += flipped_byte
    if len(input_text) < len(previous_block):
        flipped_bytes += previous_block[len(input_text):]
    return flipped_bytespassword

Use a username long enough to have more than 1 block of data so it could overwritten with no issue. and correct the next block by adding ", to fix the json block.

blocks = [
    b'{"username": "te', 
    b'st______________', 
    b'______________",', 
    b' "admin": false}', 
    b'\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10'
    ]

blocks[-2] = b' "admin": false}'
token = ('test____________________________', False)

Wanted token: token = ('test____________________________', True)

Method:

wanted third block = b'","admin": true}'  
bitflip previous block
new_token = ('test______________OhH', True)

New token will have random characters that didnt get removed by filter. And since the validate_token function checks if the login username is equal to the token username, we will have to bruteforce until the remove_illegal_chars function removes all characters in the corrupted block.

After a while we will get a match and the flag prints. FLAG{1672:67:8f5697b14454abc63f170a80893c0d288ee932f9}

Full Script

import requests
import re
HOST = "https://blackhat4-81667643f7753de8b518252083265730-0.chals.bh.ctf.sa"
HOST = "http://localhost:5000"
def xor(a: bytes, b: bytes, repeat = True) -> bytes:
    '''
        to get the key to roll over, the module operator
        is used over the length of the key, 
        so string_pos MOD key_length
        ex: 0%3 = 0, 1%3 = 1, 2%3 = 2, 3%3 = 0, etc..
    '''
    xored = []
    if repeat:
        for char_pos, c in enumerate(a):
            xored.append(c ^ b[char_pos%len(b)])
    else:
        if len(a)<len(b):
            b, a = a, b
            for char_pos in range(len(b)):
                xored.append(a[char_pos] ^ b[char_pos])
            for char_pos in range(len(b), len(a)):
                xored.append(a[char_pos])
    return bytes(xored)
def get_blocks(data: bytes, bs: int = 16) -> list:
    return [data[i:i+bs] for i in range(0, len(data), bs)]
def bit_flip(previous_block: bytes, input_text: bytes, target: bytes) -> bytes:
    '''
        input a block of 16 bytes previous to the block of inputted text 
        to be manipulated to produce target_text when decrypted, use '_'
        in spots where you want the bits to flip
    '''
    if len(input_text) != len(target):
        raise ValueError("mismatched input and target sizes")
    flipped_bytes = b''
    for index, c in enumerate(input_text):
        if chr(c) == '_':
            flipped_bytes += bytes([previous_block[index]])
        else:
            flipping_key = input_text[index] ^ target[index]
            flipped_byte = bytes([previous_block[index] ^ flipping_key])
            flipped_bytes += flipped_byte
    if len(input_text) < len(previous_block):
        flipped_bytes += previous_block[len(input_text):]
    return flipped_bytes
def register(username: str):
    session = requests.Session()
    endpoint = f'/register/{username}'
    session.get(HOST+endpoint)
    token = session.cookies.get('token')
    token = bytes.fromhex(token)
    return token
def login(username: str, token: str):
    endpoint = f'/login/{username}'
    cookies = {'token': token}
    r = requests.get(HOST+endpoint, cookies=cookies)
    return r
username = 'test____________________________'
while True:
    token = register('test____________________________')
    ct_blocks = get_blocks(token)
    input_text = b' "admin": false}'
    target_text = b'","admin": true}'
    ct_blocks[-3] = bit_flip(ct_blocks[-3], input_text, target_text)
    ct_new = b''.join(ct_blocks)
    ct_new = ct_new.hex()
    r = login('test______________', ct_new)
    flag = re.findall(r'flag{.*}', r.text)
    if flag:
        print(flag[0]) # flag{spl4t_th3m_bugs}
        break
Written By

Mohamed Al Hammadi