Post

Broken Authentication Skills Assessment — HackTheBox Academy

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.

PropertyValue
Target IP154.57.164.73:30240
ApplicationMetaDoc
FlagHTB{redacted}

Attack Chain Overview

  1. User Enumeration — Diff error messages between valid and invalid usernames to find gladys
  2. Password Policy Discovery — Visit the registration page to discover the exact password requirements
  3. Password Brute-Force — Filter rockyou.txt to match the policy, fuzz gladys’s password
  4. 2FA Wall — OTP brute-force fails due to rate limiting (3 attempts per session)
  5. User Registration — Create an account using gladys’s password to explore the authenticated app
  6. 2FA Bypass — Request /profile.php directly as gladys, 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 digit
  • grep '[a-z]' — must contain at least one lowercase letter
  • grep '[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:

  1. Log in as gladys → get redirected to 2fa.php
  2. In the browser, change the URL from /2fa.php to /profile.php
  3. The server returns a 302 redirect back to 2fa.php — but the response body contains the full profile page including the flag (the PHP script calls header("Location: 2fa.php") without exit;)
  4. In Burp, intercept the response and change 302 Found to 200 OK
  5. 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 password vs Invalid 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.txt before 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.php but 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
This post is licensed under CC BY 4.0 by the author.