ʕ·ᴥ·ʔ






Integral Communication

01/11/2024

By: stuxf

Tags: crypto IrisCTF-2024

Problem Description:

I've found this secret communication system running on a server. Unfortunately it uses AES so there's not much I can do.

Hints:

Reveal Hints none

Provided Files

I’ve reproduced a copy of the challenge code below:

from json import JSONDecodeError, loads, dumps
from binascii import hexlify, unhexlify
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes

with open("flag") as f:
    flag = f.readline()

key = get_random_bytes(16)


def encrypt(plaintext: bytes) -> (bytes, bytes):
    iv = get_random_bytes(16)
    aes = AES.new(key, AES.MODE_CBC, iv)
    print("IV:", hexlify(iv).decode())
    return iv, aes.encrypt(plaintext)


def decrypt(ciphertext: bytes, iv: bytes) -> bytes:
    aes = AES.new(key, AES.MODE_CBC, iv)
    return aes.decrypt(ciphertext)


def create_command(message: str) -> (str, str):
    payload = {"from": "guest", "act": "echo", "msg": message}
    payload = dumps(payload).encode()
    while len(payload) % 16 != 0:
        payload += b'\x00'
    iv, payload = encrypt(payload)
    return hexlify(iv).decode('utf-8'), hexlify(payload).decode('utf-8')


def run_command(iv: bytes, command: str):
    try:
        iv = unhexlify(iv)
        command = unhexlify(command)
        command = decrypt(command, iv)

        while command.endswith(b'\x00') and len(command) > 0:
            command = command[:-1]
    except:
        print("Failed to decrypt")
        return

    try:
        command = command.decode()
        command = loads(command)
    except UnicodeDecodeError:
        print(f"Failed to decode UTF-8: {hexlify(command).decode('UTF-8')}")
        return
    except JSONDecodeError:
        print(f"Failed to decode JSON: {command}")
        return

    match command["act"]:
        case "echo":
            msg = command['msg']
            print(f"You received the following message: {msg}")
        case "flag":
            if command["from"] == "admin":
                print(f"Congratulations! The flag is: {flag}")
            else:
                print("You don't have permissions to perform this action")
        case action:
            print(f"Invalid action {action}")


def show_prompt():
    print("-" * 75)
    print("1. Create command")
    print("2. Run command")
    print("3. Exit")
    print("-" * 75)

    try:
        sel = input("> ")
        sel = int(sel)

        match sel:
            case 1:
                msg = input("Please enter your message: ")
                iv, command = create_command(msg)
                print(f"IV: {iv}")
                print(f"Command: {command}")
            case 2:
                iv = input("IV: ")
                command = input("Command: ")
                run_command(iv, command)
            case 3:
                exit(0)
            case _:
                print("Invalid selection")
                return
    except ValueError:
        print("Invalid selection")
    except:
        print("Unknown error")


if __name__ == "__main__":
    while True:
        show_prompt()

Solution

Program Overview

Reading over the code, we see that we’re given a program that allows us to first encrypt a command, and then allows us to provide user input to decrypt and run that command. The encryption is done using AES in CBC mode.

Here is a high level overview of how the program works:

State Machine describing the program

Essentially, as a user we’re able to create a command, which we can then run later. However, in the create_command() function, we’re restricted to only using the echo action. This means that we can’t run the flag action, which is the only action that will print the flag. Furthermore, when we create a command, we’re going to be a guest, while the flag action requires us to be an admin.

The JSON object that we get after creating a command looks like this:

{ "from": "guest", "act": "echo", "msg": "hello" }

However, the JSON object that we need to run the flag action looks like this:

{ "from": "admin", "act": "flag" }

So, we need to find a way to modify the JSON object that we create to run the flag action. The problem is that we can’t modify the JSON object directly, since we only receive the encrypted version of it. Or can we?

CBC Bit Flipping

Explanation

The key to this challenge is to realize that we can modify the JSON object that we create, even though we only receive the encrypted version of it. This is because CBC mode is vulnerable to what’s referred to as a bit flipping attack. CBC (alongside ECB, CTR modes) are malleable modes of encryption. Essentially, they only offer confidentiality, but not integrity. This means that an attacker can modify the ciphertext in a way that will cause the plaintext to be modified in a predictable way.

In CBC mode, the plaintext is XORed with the previous ciphertext block before being encrypted. The first block doesn’t have a block before it, so it’s XORed with the initialization vector instead. The encryption process looks like this:

CBC Encryption

The decryption process does the same thing, but in reverse. The ciphertext is decrypted, and then XORed with the previous ciphertext block (or the initialization vector for the first block) to get the plaintext. The decryption process looks like this:

CBC Decryption

The important thing to note here is that the ciphertext is XORed with the previous ciphertext block. This means that if we modify a bit in the ciphertext, it will cause the corresponding bit in the next blocks plaintext to be modified. So, if we want to flip a bit in the plaintext, we can flip the corresponding bit in the ciphertext. A diagram of this is shown below:

CBC Bit Flipping

As an example, if we flip the green bit in the Initialization Vector (IV), it will cause the green bit in the first block of plaintext to be flipped. This is because the first block of plaintext is XORed with the IV before being encrypted. So, if we flip a bit in the IV, it will cause the corresponding bit in the first block of plaintext to be flipped. However, because this new ciphertext is now changed, it will corrupt the second block of plaintext. This is a problem, because we need to modify both the first and second blocks of plaintext, without corrupting the message. Fortunately, the debug output of this program includes a nice oracle that will help us a bit with this.

The Oracle

The oracle is in the run_command() function.

    except UnicodeDecodeError:
        print(f"Failed to decode UTF-8: {hexlify(command).decode('UTF-8')}")
        return

We see that if the decryption process fails, it will print out the hex representation of the decoded text. So what we can do is to bit flip the first block of ciphertext, so that it will cause the second block of plaintext to be modified. However, this will mess up our first block of the message, so we need to fix that.

The oracle will output the hex representation of the decrypted text in the first block (gibberish), but we can XOR this gibberish with the IV to “fix” our first block of hex, and then do an additional bit flip on the IV to cause the first block of plaintext to be what we’d like.

MITM!! Attack

Quick sidenote: just wanted to point out that this is a MITM attack, since we’re modifying the ciphertext in transit. Here’s a diagram of the attack:

Actual Execution

Now, all we need to do is implement the attack described above. Here’s the code that I used to do this:

from pwn import *
from binascii import hexlify, unhexlify

def xor(byte1, byte2):
    # Check if the lengths of the bytes are equal
    if len(byte1) != len(byte2):
        raise ValueError("Byte objects must have the same length")

    # Perform XOR operation on each pair of bytes
    result = bytes([b1 ^ b2 for b1, b2 in zip(byte1, byte2)])
    return result

original = b'{"from": "guest", "act": "echo", "msg": "hello"}'
new = b'{"from": "admin", "act": "flag", "msg": "hello"}'

r = remote('integral-communication.chal.irisc.tf', 10103)

# Read prompt 
r.recvuntil("> ")

# Create command
r.sendline(b'1')

r.recvuntil("Please enter your message: ")

r.sendline(b"hello")

r.recvuntil("IV: ")
old_IV_hex = r.recvline().strip()
r.recvuntil("Command: ")
old_encrypted_hex = r.recvline().strip()

print(old_IV_hex)
print(old_encrypted_hex)

old_encrypted = unhexlify(old_encrypted_hex)
old_IV = unhexlify(old_IV_hex)

# Now need to change ciphertext in the first block to change the second block
new_encrypted = xor(old_encrypted[:16],xor(original[16:32],new[16:32])) + old_encrypted[16:]
new_encrypted_hex = hexlify(new_encrypted)

r.recvuntil("> ")

# Run command
r.sendline(b'2')

r.recvuntil("IV: ")

r.sendline(old_IV_hex)
r.recvuntil("Command: ")
r.sendline(new_encrypted_hex)

# f"Failed to decode UTF-8: {hexlify(command).decode('UTF-8')}" get the hex of the command
r.recvuntil("Failed to decode UTF-8: ")

need_to_be_xored = unhexlify(r.recvline().strip())
# Change first block by changing the iv
new_IV = xor(old_IV,new[:16])
new_IV = xor(new_IV,need_to_be_xored[:16])
new_IV_hex = hexlify(new_IV)

r.recvuntil("> ")

# Run command
r.sendline(b'2')

r.recvuntil("IV: ")

r.sendline(new_IV_hex)
r.recvuntil("Command: ")
r.sendline(new_encrypted_hex)

r.interactive()