Codefest CTF 2017: Rick's Secure Scheme writeup

Written on September 24, 2017

Last week I was invited by the Defcon Toronto team to play at Codefest 2017 CTF. There were some pretty good challenges, but unfortunately the CTF was plagued with frustrating issues like an unresponsive website, and no easy way to communicate with admins.

This was the only pwnable in the CTF and it was worth 300 points. The instructions on how to submit the flag were confusing and changed at some point during the competition. Initially it said:

Flag is ‘flag{md5 of the integer value of the pass concatenated}’

At some point this changed to:

flag is now md5 of the secret timestamp

On top of that, when I finally did get a working solution, something on the target was broken in that it wasn’t returning the secret contents. It was only a few hours later that everything started working again and I was able to score the flag.

Anyway, moving on to exploiting the binary. Connecting to the service prints out the following menu:

# nc localhost 9999
Welcome to my...
___________      .__           _________                  .__
\_   _____/_____ |__| ____    /   _____/ ______________  _|__| ____  ____
 |    __)_\____ \|  |/ ___\   \_____  \_/ __ \_  __ \  \/ /  |/ ___\/ __ \
 |        \  |_> >  \  \___   /        \  ___/|  | \/\   /|  \  \__\  ___/
/_______  /   __/|__|\___  > /_______  /\___  >__|    \_/ |__|\___  >___  >
        \/|__|           \/          \/     \/                    \/    \/

Your options:

            1. Login with the password (*).
            2. View the secret contents (#).
            3. View the usage logs.
            4. Exit.

* => It's theoretically impossible that you can login.
# => Requires login.

Option 2 prints out the secret contents, which I assumed to be the flag. However in order to get that, I needed to use option 1 and login with the password. As it says in the footnote of the menu, it was theoretically impossible to login. More on this in a bit. Option 3 prints out a log of successful and failed logins, along with their timestamps. This would prove to be a useful hint later on. The binary just runs on a loop until option 4 is selected and the whole thing exits.

The binary accepts input only when asking for the password, and there was no way to overflow the buffer. So I poked around using Binary Ninja to see what it was doing. The interesting bit where it checks if the password I entered was valid or not was at 0x400dba

It uses strncmp() to compare 100 bytes of the entered password with the password hardcoded in binary at 0x400e60. Here’s the hardcoded password:

pwndbg> x/100bx 0x400e60
0x400e60:       0x33    0x12    0x46    0x67    0xf6    0x2b    0x5a    0x2e
0x400e68:       0x5b    0x7e    0xd6    0xf7    0xa2    0x33    0xd5    0x7a
0x400e70:       0x87    0x39    0x5f    0x92    0x73    0xf5    0xb1    0xa5
0x400e78:       0x81    0xb0    0x6a    0x84    0x38    0xcd    0x9b    0xea
0x400e80:       0x99    0xda    0x57    0x65    0x6c    0x63    0x6f    0x6d
0x400e88:       0x65    0x20    0x74    0x6f    0x20    0x6d    0x79    0x2e
0x400e90:       0x2e    0x2e    0x00    0x00    0x00    0x00    0x00    0x00
0x400e98:       0x5f    0x5f    0x5f    0x5f    0x5f    0x5f    0x5f    0x5f
0x400ea0:       0x5f    0x5f    0x5f    0x20    0x20    0x20    0x20    0x20
0x400ea8:       0x20    0x2e    0x5f    0x5f    0x20    0x20    0x20    0x20
0x400eb0:       0x20    0x20    0x20    0x20    0x20    0x20    0x20    0x5f
0x400eb8:       0x5f    0x5f    0x5f    0x5f    0x5f    0x5f    0x5f    0x5f
0x400ec0:       0x20    0x20    0x20    0x20

Sending this password results in an invalid match, because by the time strncmp() is called, the entered password has been mangled. For a 300 point challenge, it can’t be that easy anyway. So what’s happening?

Right before strncmp() is called, the entered password is passed into a function 0x400a26. Here’s what it looks like:

This basically translates to the following pseudocode

encode_msg(char *my_password) {
    char x; 
    srand(time(0));

    for (i = 0; i <= 33; i++) {
        x = my_password[i];
        my_password[i] = x ^ rand() % 255
    }
}

The current time is used to seed the pseudo-random number generator. Each character in my_password is then XORd with a randomly generated number from 0x0 to 0xFF. The end result is compared with the hardcoded password. Since random numbers are used to XOR my_password, it’s impossible enter the correct password such that it will match the hardcoded one after all the XOR encoding.

Or is it?

The vulnerability here was obvious to me because I’d actually solved a similar challenge before in TJCTF 2016. The issue is that srand(time(0)) is called everytime option 1 is selected, rather than at the start of the program. Since it uses the current time to seed the pseudo-random number generator, I could duplicate that same functionality and therefore predict the values that every call to rand() would return. This allows me to generate the correct password that would match the hardcoded password after the XOR encoding.

Here’s a C program that does just that:

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

int main() {
    char *hardcoded_password = "\x33\x12\x46\x67\xf6\x2b\x5a\x2e\x5b\x7e\xd6\xf7\xa2\x33\xd5\x7a\x87\x39\x5f\x92\x73\xf5\xb1\xa5\x81\xb0\x6a\x84\x38\xcd\x9b\xea\x99\xda";
    char my_password[34];
    int i;

    srand(time(0));

    for (i = 0; i < 34; i++) {
        my_password[i] = hardcoded_password[i] ^ rand() % 255;
    }
    printf("%s", my_password);
    return 0;
}

I could now write a python script that would take the output of that helper program and send it to the service with a 99% chance that the password would match. This worked perfectly locally, but not remotely. The reason for that was the time. The time on my machine was probably different from the time running on the server. This is where option 3 came in. It printed the timezone the server was running, which was IST. Once I changed my machine’s timezone to IST, I was able to get it working. Here’s the final exploit that gets a successful login and prints out the secret code from option 2:

!/usr/bin/env python

from pwn import *
import subprocess

# binary does strncmp(my_password, hardcoded_password, 100). only 33 bytes of my_password are encoded.
# so we need to pad it with the extra junk that strncmp() looks for when comparing against
# the hardcoded_password. This is basically stuff that starts at 0x400e82
junk = (
    "\x57\x65\x6c\x63\x6f\x6d\x65\x20\x74\x6f\x20"
    "\x6d\x79\x2e\x2e\x2e\x00\x00\x00\x00\x00\x00"
    "\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f"
    "\x20\x20\x20\x20\x20\x20\x2e\x5f\x5f\x20\x20"
    "\x20\x20\x20\x20\x20\x20\x20\x20\x20\x5f\x5f"
    "\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x20\x20\x20\x20"
)

r = remote("13.126.83.119", 10987)

r.recv()          # Welcome...
r.sendline("1")   # Send password option
r.recv()
r.recv()

# run helper program that decodes the hardcoded_password  based on the random value generated from the current time
proc = subprocess.Popen("./helper", stdout=subprocess.PIPE)
my_password = proc.stdout.read()[:34]

buf = "".join([my_password, junk, "\n"])

r.sendline(buf)
d = r.recv()

# send option 2 if login was successful
if "success" in d:
    r.sendline("2")
    r.recv()
    print r.recv()
else:
    print "Didn't work, try again."

So right after I solved this and got it working on my machine, I tested it on the target. I was able to match the password, select option 2, but it wouldn’t print out the secret. No idea why. I suspected that something was wrong on the server side since no other team had scored the flag either. A few hours later I tried again and it worked. At this point a handful of teams had also successfully solved it. Here’s the result:

# ./sploit.py SILENT=1
You're logged in! Here's the secret: Flag is the password at epoch 1505768803.


Your options:

            1. Login with the password (*).
            2. View the secret contents (#).
            3. View the usage logs.
            4. Exit.

* => It's theoretically impossible that you can login.
# => Requires login.

It was also at this point that I noticed the challenge description had changed. The flag was the MD5 of 1505768803: flag{c51edda648c6949638488457f32874d6}

300 points in the bag!