writeups/2021/corctf/babypad
xenia 01b330f7d1 2021: corctf: babypad 2021-08-23 23:37:33 -04:00
..
README.md 2021: corctf: babypad 2021-08-23 23:37:33 -04:00
exploit.py 2021: corctf: babypad 2021-08-23 23:37:33 -04:00
server.py 2021: corctf: babypad 2021-08-23 23:37:33 -04:00

README.md

babypad

by haskal

crypto / 484 pts / 35 solves

padding makes everything secure right

nc babypad.be.ax 1337

note: if you are finding that your exploit script is very slow, it is highly recommended to use a shell from meta/shell

provided files: server.py

solution

this server runs AES in counter mode, which essentially turns AES into a stream cipher, where the output of AES on counter blocks initialized with a random nonce are XOR'd with the plaintext to produce the ciphertext. Traditionally, XOR-based stream ciphers should have a MAC to make sure the message was not tampered with but we notice this server produces and accepts messages with no MAC. this means we can change the content of the ciphertext to produce different plaintext if we know what some of the plaintext is. the general method is encrypted_data ^ wanted_plaintext ^ known_plaintext

there's a problem with this... we don't know any of the plaintext because that's what we're trying to find out. luckily there is also a pkcs#7 padding operation done. this is totally useless for a stream cipher, and as you'll find out it actually makes it less secure because it turns into an oracle for telling us whether our plaintext guess was right or not

the exploit method is start with a pad size of 1, and guess every possible value of the last character. send off that guess with the last char of the ciphertext XOR'd with 1 XOR guess. if it's correct, the server will say so and you've guessed the last character. if it's incorrect, the unpad function will fail because the decrypted message will contain invalid padding. now on the first step, this gets us the value of the real padding amount (which turns out to be 4), so we skip back 4 chars and start guessing actual flag values. basically, if you know the last n chars of a block, you XOR with those chars and then XOR with an n+1 size pad to try to guess the n+1th char. once you reach 16 bytes, which is the block size, cut off the last block and start from pad 1 on the next last block

known_content = bytearray([0] * len(enc))

# runs a test for a byte position by adding trial padding of the size (16 - (position % 16)) and
# guessing every possible byte value until it works
def run_test(position):
    npads = 16 - (position % 16)
    for x in range(256):
        if x == npads:
            continue
        test = bytearray(xor(enc, known_content))
        test[position] ^= x ^ npads
        for z in range(position + 1, position + npads):
            test[z] ^= npads

        if trial(test[:position + npads]):
            return x
    raise Exception("none found for", position)

# first, find the actual pad size
# if this turns out to be 1 then you'll get an error here (but then you know it's 1)
actual_pad = run_test(len(enc) - 1)
print("found pad", actual_pad)

# turns out it's 4
actual_pad = 4
known_content[-4:] = b"\x04\x04\x04\x04"

# start back 4 chars and guess the flag
i = len(enc) - 5
while i >= 0:
    result = run_test(i)
    known_content[i] = result
    print(known_content)
    i -= 1

this is pretty slow, as the challenge text says, on your own computer but running it will eventually produce

...
bytearray(b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00rctf{CTR_p4dd1ng?n0_n33d!}\x04\x04\x04\x04')
bytearray(b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00orctf{CTR_p4dd1ng?n0_n33d!}\x04\x04\x04\x04')
bytearray(b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00corctf{CTR_p4dd1ng?n0_n33d!}\x04\x04\x04\x04')