Broken Authentication Skills Assessment — HackTheBox Academy
Module: Broken Authentication | Platform: HackTheBox Academy
Key Vulnerabilities: User enumeration via error message diffing, password brute-force with policy filtering, 2FA bypass via direct access
Author: jkonpc | March 19, 2026
Executive Summary
The Broken Authentication Skills Assessment presents a PHP web application (“MetaDoc”) with a login page, user registration, and two-factor authentication. The intended attack chain covers the full broken auth lifecycle taught across the module: enumerate a valid username through differing error messages, brute-force the password using a policy-filtered wordlist, and bypass 2FA by exploiting a missing exit; after a redirect.
My approach diverged at the final step. After spending time trying to brute-force the OTP (which is rate-limited to 3 attempts per session), I bypassed 2FA entirely by requesting /profile.php directly — skipping the 2FA flow altogether. The application authenticated the session and served the flag without ever validating the OTP.
This writeup covers both paths: the intended direct access bypass and the verb tampering alternative.
| Property | Value |
|---|---|
| Target IP | 154.57.164.73:30240 |
| Application | MetaDoc |
| Flag | HTB{redacted} |
Attack Chain Overview
- User Enumeration — Diff error messages between valid and invalid usernames to find
gladys - Password Policy Discovery — Visit the registration page to discover the exact password requirements
- Password Brute-Force — Filter
rockyou.txtto match the policy, fuzzgladys’s password - 2FA Wall — OTP brute-force fails due to rate limiting (3 attempts per session)
- User Registration — Create an account using
gladys’s password to explore the authenticated app - 2FA Bypass — Request
/profile.phpdirectly asgladys, skipping OTP entirely
Phase 1: User Enumeration
Differing Error Messages
Navigating to the target IP presents a login page for “MetaDoc.” I started by testing the login with dummy credentials test:test:
1
Unknown username or password
That’s a generic error message. To test whether the application responds differently for valid usernames, I needed one. Rather than guessing, I went straight to fuzzing the username field with a wordlist and filtering on that error string. I saved the login request from Burp as req.txt and used ffuffer — a tool I wrote that builds ffuf commands from raw HTTP request files, like sqlmap’s -r flag:
1
2
$ ffuffer -r req.txt -w /usr/share/seclists/Usernames/xato-net-10-million-usernames.txt \
-fp username -fr "Unknown username or password." --run
ffuffer parses the raw request, identifies the username parameter, swaps its value with FUZZ, and builds the full ffuf command with all headers and cookies from the original request. The -fr flag filters out responses containing the invalid-username error string — whatever’s left is a valid user returning a different error.
1
gladys [Status: 200, Size: 4344, Words: 680, Lines: 91, Duration: 158ms]
One hit: gladys. I killed the scan — one username is enough.
Phase 2: Password Policy Discovery and Brute-Force
Discovering the Policy
With a valid username in hand, I needed the password. But before brute-forcing with raw rockyou.txt (14+ million entries), I wanted to see if the application enforced a password policy that I could use to trim the wordlist.
I navigated to the registration page and attempted to create an account with a simple password. The application rejected it and displayed the policy:
1
2
3
4
5
Contains at least one digit
Contains at least one lower-case character
Contains at least one upper-case character
Contains NO special characters
Is exactly 12 characters long
Every user account on this application — including gladys — must conform to this policy. That means every password that doesn’t match is a waste of time.
Filtering rockyou.txt
The grep chain to enforce all five constraints:
1
2
3
4
$ grep -P '^[a-zA-Z0-9]{12}$' /usr/share/wordlists/rockyou.txt \
| grep '[0-9]' \
| grep '[a-z]' \
| grep '[A-Z]' > custom_wordlist.txt
Breaking this down:
'^[a-zA-Z0-9]{12}$'— exactly 12 characters, alphanumeric only (no special chars)grep '[0-9]'— must contain at least one digitgrep '[a-z]'— must contain at least one lowercase lettergrep '[A-Z]'— must contain at least one uppercase letter
This is the single biggest efficiency gain in the entire assessment. The filtered wordlist is a tiny fraction of the original — the difference between a few minutes and several hours.
Brute-Forcing gladys’s Password
I updated req.txt with username=gladys and used ffuffer to fuzz the password field. The first attempt used the wrong filter string — I filtered on Unknown username or password which is the invalid-username error, not the wrong-password error. Every response matched because gladys is a valid user returning Invalid credentials instead. The result was noise: hundreds of false positives like password, 12345, iloveyou — none of which are 12-character alphanumeric strings.
The fix was using the correct filter string:
1
$ ffuffer -r req.txt -w custom_wordlist.txt -fp password -fr "Invalid credentials." --run
1
dWinaldasD13 [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 149ms]
The 302 redirect confirms a successful login. Credentials confirmed: gladys:dWinaldasD13.
Phase 3: The 2FA Wall
Brute-Force Fails
Logging in with gladys:dWinaldasD13 redirects to /2fa.php — a page requesting a one-time password. My first instinct was to brute-force it. I generated a 4-digit wordlist with seq -w 0 9999 and tried multiple approaches:
1
$ ffuffer -r req.txt -w tokens.txt -fp otp -fr 'Invalid OTP.' --run
Zero hits across 10,000 values. I expanded to 5 digits, tried matching on status codes (-mc 302), tried filtering 200s (-fc 200) — everything came back as a 302 redirect. Every single OTP returned the same response.
After stepping back and researching, I found the reason: the application rate-limits OTP attempts to 3 per session, then redirects back to login.php. My session was getting killed after the first few attempts, and every subsequent request in the ffuf run was hitting a dead session — which returned 302 redirects to the login page, not the 2FA page.
Brute-forcing the OTP was not going to work.
Phase 4: Exploring the Authenticated Application
Registering a User Account
I had started creating an account earlier when I discovered the password policy but hadn’t finished. Now I went back and completed the registration using gladys’s password dWinaldasD13 — if it met the policy for her, it meets the policy for me.
After registering and logging in as my user (this account didn’t have 2FA), I landed on /profile.php and saw:
1
2
3
4
5
<h1 class="display-5 title">Welcome jkonpc!</h1>
<div class="cards">
You do not have admin privileges. The site is still under construction
and only available to admins at this time.
</div>
Two important things here. First, the flag is behind admin privileges — my user account can’t see it. Second, and more important: I now know the post-auth endpoint is /profile.php and I know what the authenticated response looks like. The application serves different content based on who you are, and gladys presumably has admin access.
Phase 5: Bypassing 2FA
Direct Access to profile.php
The module covers “Authentication Bypass via Direct Access” — requesting a protected endpoint directly and exploiting a missing exit; after a redirect. The intended solution is to navigate to /profile.php as gladys, intercept the 302 redirect response in Burp, change the status code to 200 OK, and render the flag from the response body.
I took a slightly different approach to the same technique. I logged in as gladys, which authenticates the session even though it redirects to the 2FA page. Then in Burp, instead of letting the flow continue to 2fa.php, I requested /profile.php directly with gladys’s authenticated session cookie:
1
2
3
4
POST /profile.php HTTP/1.1
Host: 154.57.164.73:30240
Cookie: PHPSESSID=qve6dct7rp4a6np38bbr8l3am1
Content-Type: application/x-www-form-urlencoded
The application returned the profile page — with the flag. No OTP validation, no redirect interception needed. The session had been authenticated by the login step, and /profile.php only checked for a valid session — it didn’t verify that the 2FA step had been completed.
This is authentication bypass via direct access. The 2FA gate at 2fa.php intercepts the normal navigation flow, but requesting the protected endpoint directly bypasses the flow entirely. The application’s auth check on /profile.php verifies the session but not the 2FA state — a business logic flaw. The HTTP method (POST vs GET) isn’t what made the bypass work — what matters is that I skipped a step in the auth flow by going straight to the endpoint.
1
HTB{redacted}
The Intended Path (For Reference)
For anyone following the module material strictly, the intended bypass uses the Direct Access technique:
- Log in as
gladys→ get redirected to2fa.php - In the browser, change the URL from
/2fa.phpto/profile.php - The server returns a
302redirect back to2fa.php— but the response body contains the full profile page including the flag (the PHP script callsheader("Location: 2fa.php")withoutexit;) - In Burp, intercept the response and change
302 Foundto200 OK - Forward the response — the browser renders the flag
Both approaches exploit the same root cause: /profile.php doesn’t validate 2FA completion. The intended method reads the flag from a leaky redirect response body. My method skipped the redirect entirely by requesting the endpoint directly in Burp Repeater.
Key Takeaways
Error message diffing is the easiest user enumeration vector, and developers still ship it. The fix is trivial — return the same generic message regardless of whether the username or password is wrong. MetaDoc uses two distinct messages (
Unknown username or passwordvsInvalid credentials), making automated enumeration a one-liner.Password policy filtering is not optional — it’s the difference between hours and minutes. A known password policy lets you eliminate 99%+ of
rockyou.txtbefore a single request is sent. Always check the registration page for policy hints before brute-forcing.When brute-forcing fails, step back and think about bypass. I spent time hammering the OTP with ffuf before realizing the application rate-limits to 3 attempts per session. Both the intended path and my path avoid the OTP entirely — same vulnerability, different execution. If a control seems unbreakable, look for ways around it — not through it.
2FA is only as strong as its enforcement at every endpoint. MetaDoc validates the OTP at
/2fa.phpbut doesn’t check 2FA completion state at/profile.php. In a real application, every protected endpoint must verify that all authentication steps have been completed — not just that a session exists.Registering your own account is a recon technique. Creating an account revealed the password policy, the post-auth endpoint structure, and the privilege model. All of that informed the attack on
gladys’s account. Don’t overlook features that are freely available — registration pages, password reset flows, and error messages are all sources of intelligence.
Tools Used
- ffuffer — Build ffuf commands from raw HTTP request files (like sqlmap’s
-r). Used for user enumeration, password brute-force, and OTP fuzzing attempts. - ffuf, Burp Suite, grep