Brute Force #
Brute force adalah serangan yang mencoba semua kemungkinan kombinasi sampai menemukan yang benar. Dalam konteks web security, ini paling sering berarti mencoba ribuan atau jutaan kombinasi password untuk satu akun, atau mencoba daftar credential yang sudah bocor dari pelanggaran data sebelumnya. Tidak ada kecanggihan teknis yang diperlukan — hanya automasi dan kesabaran.
Yang membuat brute force relevan dan berbahaya di 2025: jumlah credential yang bocor dari data breach sudah mencapai miliaran pasang. Koleksi seperti RockYou2021 mengandung 8.4 miliar password unik. Attacker tidak perlu menebak dari nol — mereka menggunakan credential yang sudah bocor dari satu layanan untuk mencoba masuk ke layanan lain (credential stuffing), asumsinya banyak orang menggunakan password yang sama di berbagai platform.
Sistem yang tidak memiliki perlindungan brute force adalah sistem yang menunggu dieksploitasi — bukan apakah, tapi kapan.
Empat Jenis Serangan Brute Force #
graph TD
A[Brute Force Attacks] --> B[Credential Stuffing]
A --> C[Password Spraying]
A --> D[Dictionary Attack]
A --> E[Pure Brute Force]
B --> B1["Gunakan credential bocor\ndari breach lain\n1 password per target"]
C --> C1["1 password umum\nke banyak akun\nHindari lockout"]
D --> D1["Wordlist password umum\nTop 10M passwords\nMangling rules"]
E --> E1["Semua kombinasi karakter\na, b, c... aa, ab...\nHanya untuk password pendek"]
B1 --> F[Skala besar, akurasi tinggi\nSuccess rate 0.1-2%]
C1 --> G[Tidak trigger lockout\nLambat tapi persistent]
D1 --> H[Efektif untuk password lemah]
E1 --> I[Tidak praktis untuk 12+ karakter]Credential Stuffing #
Ini adalah ancaman paling serius saat ini. Attacker mengambil pasangan email:password dari breach yang sudah ada dan mencobanya secara otomatis ke ratusan layanan berbeda.
Mengapa credential stuffing sangat efektif:
Data yang tersedia:
→ RockYou2021: 8.4 miliar credential
→ Collection #1-5: 2.2 miliar unik credential
→ Breach dari LinkedIn, Adobe, Yahoo, dll masih aktif digunakan
Success rate:
→ 0.1% - 2% credential masih valid di layanan lain
→ Dengan 10 juta credential: 10.000 - 200.000 akun berhasil dikompromikan
Alat yang digunakan:
→ Sentry MBA, OpenBullet, Snipr — tool otomatis yang tersedia bebas
→ Konfigurasi per-site, mendukung proxy rotation, CAPTCHA bypass
→ Bisa berjalan dari ribuan IP berbeda secara bersamaan
Tanda-tanda credential stuffing:
→ Login failure rate tiba-tiba naik
→ Login berhasil dari IP/lokasi yang tidak biasa
→ Banyak "successful login" tapi user tidak mengenalinya
→ Traffic login dari berbagai IP yang berbeda
Password Spraying #
Alih-alih mencoba banyak password untuk satu akun (yang akan trigger lockout), password spraying mencoba satu atau beberapa password yang sangat umum ke banyak akun. Teknik ini khususnya efektif di lingkungan enterprise.
Password spraying vs traditional brute force:
Traditional brute force (mudah terdeteksi):
Account: [email protected]
Attempt 1: password123
Attempt 2: qwerty123
Attempt 3: ali123
... (ribuan attempt → trigger lockout setelah 5 attempt)
Password spraying (sulit terdeteksi):
Password: Spring2025! (memenuhi policy tapi mudah ditebak)
Attempt 1: [email protected] → Spring2025! (tidak lock)
Attempt 2: [email protected] → Spring2025! (tidak lock)
Attempt 3: [email protected] → Spring2025! (tidak lock)
...ribuan akun...
Attempt N: [email protected] → Spring2025! ← BERHASIL
Setiap akun hanya dicoba sekali atau dua kali → tidak trigger lockout
Attacker menunggu beberapa jam antara batch untuk lebih aman
Rate Limiting yang Efektif #
Rate limiting adalah pertahanan pertama terhadap semua bentuk brute force. Tapi implementasi yang naif bisa dibypass dengan mudah.
import redis
import time
import hashlib
from dataclasses import dataclass
from typing import Optional
redis_client = redis.Redis(host='redis', port=6379, decode_responses=True)
@dataclass
class RateLimitResult:
allowed: bool
remaining_attempts: int
reset_after_seconds: int
lockout_seconds: Optional[int] = None
class LoginRateLimiter:
"""
Rate limiter berlapis untuk endpoint login:
1. Per IP — melindungi dari satu IP yang agresif
2. Per akun — melindungi dari credential stuffing dari banyak IP
3. Per IP + akun — kombinasi yang paling presisi
"""
# Konfigurasi: (max_attempts, window_seconds, lockout_seconds)
IP_LIMIT = (30, 300, 300) # 30 per 5 menit, lockout 5 menit
ACCOUNT_LIMIT = (5, 900, 900) # 5 per 15 menit, lockout 15 menit
GLOBAL_LIMIT = (10000, 60, None) # Global: 10k per menit (DDoS guard)
def check_and_record(
self, ip: str, email: str
) -> RateLimitResult:
"""
Cek semua rate limit dan record attempt.
Return False jika salah satu limit terlampaui.
"""
# Cek lockout aktif
lockout_result = self._check_lockout(ip, email)
if lockout_result:
return lockout_result
# Cek rate limit per IP
ip_result = self._check_limit(
f"rl:ip:{ip}", *self.IP_LIMIT
)
if not ip_result.allowed:
self._set_lockout(f"lockout:ip:{ip}", self.IP_LIMIT[2])
return ip_result
# Cek rate limit per akun (email di-hash untuk privacy)
email_hash = hashlib.sha256(email.lower().encode()).hexdigest()[:16]
account_result = self._check_limit(
f"rl:account:{email_hash}", *self.ACCOUNT_LIMIT
)
if not account_result.allowed:
self._set_lockout(
f"lockout:account:{email_hash}", self.ACCOUNT_LIMIT[2]
)
return account_result
return RateLimitResult(
allowed=True,
remaining_attempts=min(
ip_result.remaining_attempts,
account_result.remaining_attempts
),
reset_after_seconds=min(
ip_result.reset_after_seconds,
account_result.reset_after_seconds
)
)
def _check_limit(
self, key: str, max_attempts: int,
window: int, lockout: Optional[int]
) -> RateLimitResult:
now = time.time()
pipe = redis_client.pipeline()
pipe.zremrangebyscore(key, 0, now - window)
pipe.zcard(key)
pipe.zadd(key, {str(now): now})
pipe.expire(key, window)
_, count, _, _ = pipe.execute()
remaining = max(0, max_attempts - count - 1)
allowed = count < max_attempts
return RateLimitResult(
allowed=allowed,
remaining_attempts=remaining,
reset_after_seconds=window
)
def _check_lockout(
self, ip: str, email: str
) -> Optional[RateLimitResult]:
email_hash = hashlib.sha256(email.lower().encode()).hexdigest()[:16]
for key in [f"lockout:ip:{ip}", f"lockout:account:{email_hash}"]:
ttl = redis_client.ttl(key)
if ttl > 0:
return RateLimitResult(
allowed=False,
remaining_attempts=0,
reset_after_seconds=ttl,
lockout_seconds=ttl
)
return None
def _set_lockout(self, key: str, seconds: int):
redis_client.setex(key, seconds, 1)
def reset_on_success(self, ip: str, email: str):
"""Reset counter setelah login berhasil."""
email_hash = hashlib.sha256(email.lower().encode()).hexdigest()[:16]
redis_client.delete(
f"rl:ip:{ip}",
f"rl:account:{email_hash}",
f"lockout:ip:{ip}",
f"lockout:account:{email_hash}"
)
login_limiter = LoginRateLimiter()
@app.route('/login', methods=['POST'])
def login():
ip = get_real_ip(request) # handle proxy headers dengan hati-hati
email = request.json.get('email', '').lower().strip()
password = request.json.get('password', '')
# Cek rate limit sebelum apapun
limit_result = login_limiter.check_and_record(ip, email)
if not limit_result.allowed:
response = jsonify({
'error': 'Too many attempts',
'retry_after': limit_result.lockout_seconds or limit_result.reset_after_seconds
})
response.status_code = 429
response.headers['Retry-After'] = str(
limit_result.lockout_seconds or limit_result.reset_after_seconds
)
return response
# Proses login dengan timing yang konsisten
user = authenticate_user(email, password)
if user:
login_limiter.reset_on_success(ip, email)
session_token = create_session(user.id, request)
response = make_response(jsonify({'status': 'ok'}))
set_auth_cookie(response, session_token)
return response
# Login gagal — pesan yang sama untuk semua kasus
return jsonify({'error': 'Email atau password tidak valid'}), 401
Timing Attack: Celah yang Sering Diabaikan #
Waktu yang dibutuhkan untuk memproses login request bisa membocorkan informasi kepada attacker.
# ANTI-PATTERN: timing leak — attacker bisa bedakan "email tidak ada" vs "password salah"
@app.route('/login', methods=['POST'])
def login_with_timing_leak():
email = request.json['email']
password = request.json['password']
user = User.query.filter_by(email=email).first()
if not user:
# Return CEPAT — tidak ada hash computation
return jsonify({'error': 'Invalid credentials'}), 401
if not verify_password(password, user.password_hash):
# Return LAMBAT — hash computation terjadi
return jsonify({'error': 'Invalid credentials'}), 401
# Attacker mengukur response time:
# < 5ms = email tidak ada di database (langsung return)
# > 100ms = email ada tapi password salah (hash computation terjadi)
# → Attacker bisa enumerate email valid tanpa trigger rate limit
# BENAR: waktu respons yang konsisten
from argon2 import PasswordHasher
ph = PasswordHasher()
# Hash dummy yang valid untuk digunakan ketika user tidak ditemukan
DUMMY_HASH = ph.hash("dummy_password_that_never_matches")
@app.route('/login', methods=['POST'])
def login_constant_time():
email = request.json.get('email', '')
password = request.json.get('password', '')
user = User.query.filter_by(email=email).first()
if user:
password_hash = user.password_hash
else:
# Gunakan dummy hash — tetap jalankan verifikasi
# untuk memastikan waktu yang sama
password_hash = DUMMY_HASH
# Verifikasi selalu dijalankan, terlepas apakah user ada atau tidak
try:
is_valid = ph.verify(password_hash, password)
if user and is_valid:
# Login berhasil
return success_response(user)
except Exception:
pass
# Login gagal — pesan yang sama
return jsonify({'error': 'Email atau password tidak valid'}), 401
Account Lockout yang Tidak Merugikan User Legitimate #
Account lockout yang terlalu agresif bisa digunakan oleh attacker sebagai serangan availability: mereka sengaja mengunci akun user legitimate dengan mencoba password yang salah.
class SmartAccountLockout:
"""
Lockout yang cerdas:
- Tidak langsung lockout permanen
- Progressive delay
- Tidak memungkinkan attacker untuk DOS akun user lain
"""
def __init__(self):
# Progressive lockout: semakin banyak gagal, semakin lama locked
self.lockout_schedule = [
(3, 30), # Setelah 3 gagal: lock 30 detik
(5, 300), # Setelah 5 gagal: lock 5 menit
(10, 1800), # Setelah 10 gagal: lock 30 menit
(20, 86400), # Setelah 20 gagal: lock 24 jam
]
def get_lockout_duration(self, failure_count: int) -> int:
"""Return durasi lockout berdasarkan jumlah kegagalan."""
duration = 0
for threshold, seconds in self.lockout_schedule:
if failure_count >= threshold:
duration = seconds
return duration
def record_failure(self, account_key: str) -> dict:
key = f"login_failures:{account_key}"
# Increment failure count
failure_count = redis_client.incr(key)
# Set expiry jika baru dibuat
if failure_count == 1:
redis_client.expire(key, 86400) # reset setelah 24 jam tanpa activity
# Tentukan lockout duration
lockout_duration = self.get_lockout_duration(failure_count)
if lockout_duration > 0:
lockout_key = f"lockout:{account_key}"
redis_client.setex(lockout_key, lockout_duration, failure_count)
return {
'failure_count': failure_count,
'locked': lockout_duration > 0,
'lockout_seconds': lockout_duration
}
def is_locked(self, account_key: str) -> tuple[bool, int]:
"""Return (is_locked, seconds_remaining)."""
lockout_key = f"lockout:{account_key}"
ttl = redis_client.ttl(lockout_key)
return ttl > 0, max(0, ttl)
def unlock_on_success(self, account_key: str):
"""Reset setelah login berhasil."""
redis_client.delete(
f"login_failures:{account_key}",
f"lockout:{account_key}"
)
Strategi lockout yang aman:
✓ Progressive lockout — semakin banyak gagal, semakin lama locked
Attacker yang mencoba 100x akan kena lockout lama
User yang lupa password biasanya mencoba 2-3x
✓ Soft lockout dengan notifikasi email
"Akun Anda sementara terkunci. Jika bukan Anda yang mencoba login,
klik di sini untuk mengamankan akun."
✓ Unlock via email verification
Kirim link unlock ke email terdaftar
Attacker tidak bisa unlock kecuali punya akses ke email
✗ Jangan lockout permanent tanpa mekanisme unlock
User yang legitimate tidak bisa masuk ke akunnya sendiri
✗ Jangan lockout berdasarkan IP saja
Satu IP bisa share banyak user (NAT, corporate network)
Lockout IP bisa kena user innocent
CAPTCHA: Efektif tapi Bukan Solusi Lengkap #
CAPTCHA mempersulit automasi dengan menghadirkan challenge yang mudah bagi manusia tapi sulit bagi mesin.
# Integrasi Google reCAPTCHA v3 (invisible, berbasis score)
import requests
RECAPTCHA_SECRET = os.environ['RECAPTCHA_SECRET_KEY']
RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
RECAPTCHA_THRESHOLD = 0.5 # Score 0 (bot) sampai 1 (manusia)
def verify_recaptcha(token: str, action: str) -> tuple[bool, float]:
"""
Verifikasi reCAPTCHA token.
Returns: (is_human, score)
"""
try:
response = requests.post(RECAPTCHA_VERIFY_URL, data={
'secret': RECAPTCHA_SECRET,
'response': token,
}, timeout=5)
result = response.json()
if not result.get('success'):
return False, 0.0
# Verifikasi action sesuai
if result.get('action') != action:
return False, 0.0
score = result.get('score', 0)
return score >= RECAPTCHA_THRESHOLD, score
except requests.RequestException:
# Jika CAPTCHA service down, jangan blokir user legitimate
# Tapi log untuk monitoring
logger.warning("reCAPTCHA verification failed — service unavailable")
return True, 1.0 # Default allow jika service down
@app.route('/login', methods=['POST'])
def login():
# Ambil CAPTCHA token dari request
captcha_token = request.json.get('captcha_token')
if not captcha_token:
return jsonify({'error': 'CAPTCHA required'}), 400
is_human, score = verify_recaptcha(captcha_token, 'login')
if not is_human:
logger.warning(
"Low reCAPTCHA score on login",
extra={'score': score, 'ip': request.remote_addr}
)
return jsonify({'error': 'CAPTCHA verification failed'}), 429
# Lanjutkan proses login
# ...
Keterbatasan CAPTCHA:
reCAPTCHA v2 (teka-teki gambar):
→ Bisa diselesaikan oleh manusia yang dibayar murah (CAPTCHA farm)
→ Harga: $1 per 1000 solves — attacker bisa bypass dengan murah
reCAPTCHA v3 (invisible, score-based):
→ Tidak mengganggu UX
→ Bisa di-bypass dengan menggunakan browser nyata
→ Masih efektif untuk mendeteksi bot sederhana
hCaptcha, Turnstile (Cloudflare):
→ Alternatif yang makin populer
→ Trade-off privacy berbeda dari Google
CAPTCHA bukan solusi standalone:
→ Gunakan bersama rate limiting dan account lockout
→ CAPTCHA melengkapi, bukan menggantikan
Deteksi Credential Stuffing #
Pola traffic credential stuffing berbeda dari login normal — bisa dideteksi dengan analisis yang tepat.
from collections import defaultdict
import geoip2.database
# GeoIP database untuk deteksi lokasi
reader = geoip2.database.Reader('/path/to/GeoLite2-City.mmdb')
class CredentialStuffingDetector:
"""
Deteksi credential stuffing berdasarkan pola traffic yang anomalous.
"""
def __init__(self, window_seconds: int = 300):
self.window = window_seconds
def analyze_login_pattern(self, ip: str, email: str) -> dict:
"""Analisis apakah request ini terlihat seperti credential stuffing."""
signals = []
# Signal 1: User-Agent yang tidak umum atau sama persis antar request
user_agent = request.user_agent.string
if self._is_suspicious_user_agent(user_agent):
signals.append('suspicious_user_agent')
# Signal 2: Tidak ada header yang biasanya ada di browser nyata
expected_headers = ['accept', 'accept-language', 'accept-encoding']
missing = [h for h in expected_headers if h not in request.headers]
if len(missing) > 1:
signals.append('missing_browser_headers')
# Signal 3: Login dari banyak email berbeda dari satu IP dalam waktu singkat
ip_key = f"logins_from_ip:{ip}"
emails_from_ip = redis_client.pfcount(ip_key) # HyperLogLog untuk counting
if emails_from_ip > 10:
signals.append('many_accounts_from_ip')
# Signal 4: Lokasi yang tidak konsisten dengan pola login historis
try:
location = reader.city(ip)
country = location.country.iso_code
if self._is_impossible_location(email, country):
signals.append('impossible_location')
except Exception:
pass
# Signal 5: IP address dari daftar proxy/VPN/Tor yang diketahui
if self._is_known_proxy_ip(ip):
signals.append('proxy_ip')
# Record email ini dari IP (dengan TTL)
redis_client.pfadd(ip_key, email)
redis_client.expire(ip_key, self.window)
risk_score = len(signals)
return {
'risk_score': risk_score,
'signals': signals,
'action': self._determine_action(risk_score)
}
def _is_suspicious_user_agent(self, ua: str) -> bool:
# Tool credential stuffing sering menggunakan UA yang aneh
suspicious_patterns = [
'python-requests', 'axios/', 'okhttp/', 'curl/',
'go-http-client', 'java/', 'php/'
]
ua_lower = ua.lower()
return any(p in ua_lower for p in suspicious_patterns)
def _determine_action(self, risk_score: int) -> str:
if risk_score == 0:
return 'allow'
elif risk_score == 1:
return 'monitor' # log tapi izinkan
elif risk_score == 2:
return 'challenge' # tampilkan CAPTCHA
else:
return 'block' # blokir sementara
def _is_known_proxy_ip(self, ip: str) -> bool:
# Cek terhadap database IP proxy/VPN/Tor yang diketahui
# Bisa menggunakan layanan seperti IPQualityScore, MaxMind, dll
proxy_key = f"known_proxy:{ip}"
return redis_client.exists(proxy_key) == 1
def _is_impossible_location(self, email: str, current_country: str) -> bool:
# Bandingkan dengan country terakhir yang diketahui
history_key = f"login_country:{hashlib.sha256(email.encode()).hexdigest()[:16]}"
last_country = redis_client.get(history_key)
if last_country and last_country != current_country:
return True
return False
detector = CredentialStuffingDetector()
@app.route('/login', methods=['POST'])
def login():
ip = request.remote_addr
email = request.json.get('email', '')
# Analisis credential stuffing signals
analysis = detector.analyze_login_pattern(ip, email)
if analysis['action'] == 'block':
return jsonify({
'error': 'Aktivitas mencurigakan terdeteksi',
'retry_after': 300
}), 429
if analysis['action'] == 'challenge':
# Minta CAPTCHA untuk request mencurigakan
return jsonify({
'challenge_required': True,
'challenge_type': 'captcha'
}), 202
# Lanjutkan proses login normal
# ...
Breached Password Detection #
Menolak password yang sudah diketahui bocor adalah langkah proaktif yang sangat efektif.
import hashlib
import requests
HIBP_API_URL = "https://api.pwnedpasswords.com/range/{}"
def is_password_breached(password: str) -> tuple[bool, int]:
"""
Cek apakah password ada dalam database HaveIBeenPwned.
Menggunakan k-Anonymity — password tidak dikirim ke API.
Returns: (is_breached, breach_count)
"""
sha1 = hashlib.sha1(password.encode('utf-8')).hexdigest().upper()
prefix = sha1[:5]
suffix = sha1[5:]
try:
response = requests.get(
HIBP_API_URL.format(prefix),
headers={'User-Agent': 'MyApp-Security-Check/1.0'},
timeout=5
)
response.raise_for_status()
for line in response.text.splitlines():
line_suffix, count = line.split(':')
if line_suffix == suffix:
return True, int(count)
return False, 0
except requests.RequestException:
# Jika API tidak tersedia, jangan blokir — log dan lanjutkan
logger.warning("HIBP API unavailable")
return False, 0
# Gunakan saat registrasi dan saat ganti password
def validate_password_strength(password: str) -> list[str]:
errors = []
if len(password) < 12:
errors.append("Password minimal 12 karakter")
is_breached, count = is_password_breached(password)
if is_breached:
errors.append(
f"Password ini ditemukan dalam {count:,} data breach. "
f"Gunakan password yang unik dan belum pernah digunakan."
)
return errors
Monitoring dan Alerting #
Brute force yang tidak terdeteksi bisa berjalan berhari-hari tanpa ketahuan.
# Alert metric yang harus dipantau
# 1. Login failure rate — naik signifikan = tanda serangan
# Baseline: X failure per menit di jam normal
# Alert: jika melebihi 5x baseline selama 5 menit berturut
# 2. Unique email count dari satu IP dalam 5 menit
# Baseline: 1-2 email per IP
# Alert: > 10 email berbeda dari satu IP
# 3. Login success rate — turun signifikan = tanda serangan
# Baseline: 70-80% request login berhasil
# Alert: jika success rate turun di bawah 30% selama 10 menit
# 4. Akun yang sukses login dari lokasi baru
# Alert: user yang biasanya login dari Jakarta
# tiba-tiba login dari IP di Eropa Timur
# Implementasi sederhana dengan counter di Redis
def record_login_metric(success: bool, ip: str, email: str):
now = int(time.time())
minute_bucket = now - (now % 60)
pipe = redis_client.pipeline()
if success:
pipe.incr(f"login:success:{minute_bucket}")
else:
pipe.incr(f"login:failure:{minute_bucket}")
# Track unique emails per IP
pipe.sadd(f"login:ips:{ip}:{minute_bucket}", email)
pipe.expire(f"login:ips:{ip}:{minute_bucket}", 600)
pipe.expire(f"login:success:{minute_bucket}", 600)
pipe.expire(f"login:failure:{minute_bucket}", 600)
pipe.execute()
def check_brute_force_alert() -> list[str]:
now = int(time.time())
alerts = []
# Cek 5 menit terakhir
total_success = 0
total_failure = 0
for i in range(5):
bucket = now - (now % 60) - (i * 60)
success = int(redis_client.get(f"login:success:{bucket}") or 0)
failure = int(redis_client.get(f"login:failure:{bucket}") or 0)
total_success += success
total_failure += failure
total = total_success + total_failure
if total > 0:
failure_rate = total_failure / total
if failure_rate > 0.7 and total > 100:
alerts.append(
f"High login failure rate: {failure_rate:.1%} "
f"({total_failure}/{total} failed in last 5 min)"
)
return alerts
Anti-Pattern yang Harus Dihindari #
# ✗ Anti-pattern 1: tidak ada rate limiting pada login endpoint
@app.route('/login', methods=['POST'])
def login():
user = authenticate(request.json['email'], request.json['password'])
# Tidak ada rate limit — bisa coba jutaan password per detik
# ✗ Anti-pattern 2: pesan error yang membedakan kasus
if not user_exists:
return "Email tidak terdaftar", 401 # bocor: email tidak ada
if wrong_password:
return "Password salah", 401 # bocor: email valid
# ✓ Solusi: "Email atau password tidak valid" untuk semua kasus
# ✗ Anti-pattern 3: lockout yang bisa di-abuse untuk DOS akun orang lain
# Attacker sengaja mencoba 10x dengan email target
# → Akun target terkunci
# ✓ Solusi: progressive delay + notifikasi email, bukan hard lockout permanen
# ✗ Anti-pattern 4: rate limit hanya per IP
# Attacker menggunakan ribuan IP berbeda (botnet, residential proxy)
# → Setiap IP hanya 1-2 attempt → tidak pernah trigger IP limit
# ✓ Solusi: rate limit per akun JUGA, bukan hanya per IP
# ✗ Anti-pattern 5: tidak ada monitoring
# Credential stuffing berjalan berhari-hari tanpa terdeteksi
# ✓ Solusi: alert untuk anomali login failure rate
# ✗ Anti-pattern 6: reset rate limit setelah successful login dari IP yang sama
# Attacker berhasil login ke satu akun → rate limit direset untuk IP itu
# → Bisa lanjut coba akun lain dari IP yang sama tanpa limit
# ✓ Solusi: reset hanya untuk akun yang berhasil, bukan untuk IP
Checklist Brute Force Protection #
RATE LIMITING:
□ Rate limit per IP (lebih longgar — bisa shared NAT)
□ Rate limit per akun/email (lebih ketat)
□ Rate limit global endpoint login (guard terhadap DDoS)
□ Progressive: semakin banyak gagal, semakin lama wait
□ Retry-After header dikirim di response 429
ACCOUNT LOCKOUT:
□ Progressive lockout (bukan langsung permanent)
□ Notifikasi email saat akun terkunci
□ Mekanisme unlock via email (bukan hanya tunggu timeout)
□ Attacker tidak bisa lockout akun orang lain secara trivial
TIMING & INFORMATION:
□ Waktu respons konsisten: email tidak ada ≈ password salah
□ Pesan error tidak membedakan "email tidak ada" vs "password salah"
□ Dummy hash computation saat email tidak ditemukan
CREDENTIAL STUFFING:
□ HIBP API check pada registrasi dan ganti password
□ Deteksi User-Agent yang mencurigakan (python-requests, curl, dll)
□ Deteksi banyak email dari satu IP dalam window singkat
□ Deteksi login dari lokasi yang tidak biasa untuk akun tersebut
CAPTCHA:
□ CAPTCHA atau challenge setelah N kali gagal
□ CAPTCHA tidak memblokir user jika service CAPTCHA down
□ CAPTCHA bukan satu-satunya perlindungan (kombinasikan dengan rate limit)
MFA:
□ MFA tersedia dan dianjurkan ke semua user
□ MFA wajib untuk akun admin dan akun dengan privilege tinggi
□ Brute force MFA code juga di-rate-limit
MONITORING:
□ Alert untuk login failure rate yang anomalous
□ Alert untuk banyak email berbeda dari satu IP
□ Alert untuk login berhasil dari lokasi yang sangat tidak biasa
□ Dashboard real-time untuk login metrics
□ Log semua failed attempt dengan detail (IP, UA, timestamp, email hash)
Ringkasan #
- Credential stuffing adalah ancaman terbesar — bukan mencoba password acak, tapi menggunakan miliaran credential yang sudah bocor. Success rate kecil tapi volumenya sangat besar.
- Rate limiting harus di dua dimensi — per IP saja tidak cukup karena credential stuffing menggunakan ribuan IP. Rate limit per akun adalah perlindungan yang lebih penting.
- Progressive lockout lebih baik dari hard lockout — hard lockout bisa di-abuse untuk mengunci akun orang lain. Progressive delay (30 detik, 5 menit, 30 menit) memberikan perlindungan tanpa jadi senjata DoS.
- Timing attack membocorkan informasi — perbedaan waktu antara “email tidak ada” dan “password salah” memungkinkan enumeration email. Selalu jalankan hash computation bahkan ketika email tidak ditemukan.
- Pesan error yang seragam wajib — “Email atau password tidak valid” untuk semua kasus. Jangan pernah bedakan “email tidak terdaftar” dari “password salah”.
- HIBP check proaktif mencegah reuse credential yang bocor — menolak password yang ada di database breach lebih efektif dari semua policy kompleksitas lainnya.
- MFA adalah mitigasi paling efektif — bahkan jika password berhasil ditebak, attacker masih perlu faktor kedua. Credential stuffing hampir seluruhnya tidak efektif terhadap akun dengan MFA.
- Deteksi pola credential stuffing — banyak email berbeda dari satu IP, User-Agent yang tidak biasa, header browser yang missing, dan lokasi yang tidak mungkin adalah sinyal yang bisa dideteksi.
- CAPTCHA bukan solusi sendirian — CAPTCHA farm bisa menyelesaikan CAPTCHA dengan harga $1/1000. Gunakan sebagai salah satu lapisan, bukan satu-satunya.
- Monitoring real-time adalah kunci — brute force yang berjalan berhari-hari tanpa terdeteksi bisa mengkompromikan ribuan akun sebelum ada yang sadar.