BreachBlocker Unlocker

Hopper needs your help to get the final key to the throne room. - by munra, melmols & Maxablancas

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:

  1. If the requested path exists as a file, Nginx serves it directly

  2. 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

For each position:

  • We brute-force one character at a time.

  • If the guessed character is correct:

    • One extra hopper_hash() executes

    • Response time increases measurably

  • Incorrect characters exit earlier → faster response

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.

Last updated

Was this helpful?