I have developed a new, revolutionary cipher that is not constrained to one block cipher. It is safe and secure. If you are not convinced, I will provide a flag to anyone who manages to win the ‘In No Desirable Case Attacks Possible’ (IND-CPA) mode.

Category: Crypto

Solver: Greenscreen23, SchizophrenicFish2nds

Flag: GPNCTF{stop_breaking_it_It_is_even_called_safe}

Writeup

Context

We are presented with an IND-CPA game for an AES cipher with a custom block mode.

The game is as usual: We are able to encrypt an arbitrary amount of blocks, until we are ready to start the challenge. To start the challenge, we send two messages, one of which is encrypted and send back. We then have to decide which of two messages was chosen to solve the challenge. There are 1000 rounds of challenges we have to pass to get the flag.

The block cipher mode is as follows: An IV (a number of 16 bytes) is chosen at random. Each plaintext block is then first XORed with the IV and the IV is incremented by one. Afterwards, each block is encrypted using AES with a random key. The key and initial IV are chosen for each round, so subsequent messages use the same key and the incremented IV. Blocks are padded with 0-bytes to a multiple of 16 byte.

The randomness used comes from the python secrets module, and we therefore assume it to be secure.

Winning the game

We know that increasing an even number by one will only flip the last bit. But we don’t know whether we start with an even or an odd IV. To find out, we send two messages m1, m2, which are both exactly one block long and differ only in their last bit. If the IV was initially even, the two messages will be the same after the XOR and so the ciphertexts will be the same. Otherwise, the ciphertexts will differ.

If the ciphertexts were the same, then the IV will be even after the operation. Now we ask for an encryption e of the message m1. We know that the next encryption of the message m2, which differs only in the last bit compared to m1, will also yield e. So we start the challenge with the message m2 and a randomly chosen message. If we see the same ciphertext again, m2 was most likely encrypted. Otherwise, the random message must have been picked.

If the ciphertext differed, the solution is almost the same. We know that the IV was initially odd, so it must have been even when m2 was encrypted. So the next encryption of m1 must yield the same ciphertext e. We therefore start the challenge with m1 and a random message. If we receive e, then m1 was most likely encrypted. Otherwise, the random message must have been picked.

After all rounds are finished, we get the flag. This is implemented as a script [1], where BLOCK1 and BLOCK2 are m1 and m2. BLOCK_OTHER is the randomly chosen block that is used as the second message in all challenges.

Mitigations and further notes

Our described solution exploits the fact that their block cipher mode is deterministic and thus cannot be IND-CPA secure, by constructing chosen messages that map to the same input for the AES-ECB encryption function. For IND-CPA security, such “constructed” deterministic encryption needs to be avoided, as is the case with CTR and CBC. Here are more details about the game. Notice how we exploit the ECB determinism in our attack.

However, the SAFE challenge oracle itself is also flawed, by not checking whether the challenge messages allow for a trivial win by comparing ciphertext sizes. This flaw is unrelated to ECB and the custom SAFE mode of operation, as learning the maximum length of a message can never be avoided in symmetric cryptography.

Other resources

Scripts

[1] The script to solve the challenge

from pwn import *

BLOCK1 = b'\0' * 16
BLOCK2 = b'\0' * 15 + b'\x01'
BLOCK_OTHER = b'jemdogwgdwsdgpea'
chal = process('./cipher.py')

def enc(block):
    chal.recvuntil(b'oracle:')
    chal.sendline(block.hex().encode())
    return bytes.fromhex(chal.recvline().decode().strip())

def challenge(first, second):
    chal.recvuntil(b'oracle:')
    chal.sendline(b'challenge'.hex().encode())
    chal.recvuntil(b'first challenge plaintext:')
    chal.sendline(first.hex().encode())
    chal.recvuntil(b'second challenge plaintext:')
    chal.sendline(second.hex().encode())
    return bytes.fromhex(chal.recvline().decode().strip())

def ans(b):
    chal.recvuntil(b'b = ?:')
    chal.sendline(b)

for i in range(1000):
    print(i)
    enc_block_1 = enc(BLOCK1)
    enc_block_2 = enc(BLOCK2)

    if enc_block_1 == enc_block_2:
        enc_block_3 = enc(BLOCK1)
        test_block = challenge(BLOCK2, BLOCK_OTHER)
        ans(str(int(test_block != enc_block_3)).encode())
    else:
        test_block = challenge(BLOCK1, BLOCK_OTHER)
        ans(str(int(test_block != enc_block_2)).encode())

chal.interactive()