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.
// 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: (maxAttempts, windowSeconds, lockoutSeconds)
var (
ipLimit = [3]int{30, 300, 300} // 30 per 5 menit, lockout 5 menit
accountLimit = [3]int{5, 900, 900} // 5 per 15 menit, lockout 15 menit
globalLimit = [3]int{10000, 60, -1} // Global: 10k per menit (DDoS guard)
)
type RateLimitResult struct {
Allowed bool
RemainingAttempts int
ResetAfterSeconds int
LockoutSeconds int // -1 jika tidak ada lockout
}
type LoginRateLimiter struct{}
// CheckAll mengecek semua rate limit dan mencatat attempt.
// Return hasil dengan Allowed=false jika salah satu limit terlampaui.
func (l LoginRateLimiter) CheckAll(ip, email string) RateLimitResult {
// Cek lockout aktif
if result, locked := l.checkLockout(ip, email); locked {
return result
}
// Cek rate limit per IP
ipResult := l.checkLimit("rl:ip:"+ip, ipLimit)
if !ipResult.Allowed {
l.setLockout("lockout:ip:"+ip, ipLimit[2])
return ipResult
}
// Cek rate limit per akun (email di-hash untuk privacy)
emailHash := hashEmail(email)
accountResult := l.checkLimit("rl:account:"+emailHash, accountLimit)
if !accountResult.Allowed {
l.setLockout("lockout:account:"+emailHash, accountLimit[2])
return accountResult
}
remaining := ipResult.RemainingAttempts
if accountResult.RemainingAttempts < remaining {
remaining = accountResult.RemainingAttempts
}
reset := ipResult.ResetAfterSeconds
if accountResult.ResetAfterSeconds < reset {
reset = accountResult.ResetAfterSeconds
}
return RateLimitResult{Allowed: true, RemainingAttempts: remaining, ResetAfterSeconds: reset}
}
func (l LoginRateLimiter) checkLimit(key string, limit [3]int) RateLimitResult {
now := time.Now().Unix()
// Redis sorted set: hapus entri lama, hitung, tambah, set TTL
redis.ZRemRangeByScore(key, "0", strconv.FormatInt(now-int64(limit[1]), 10))
count := redis.ZCard(key)
redis.ZAdd(key, redis.Z{Score: float64(now), Member: strconv.FormatInt(now, 10)})
redis.Expire(key, time.Duration(limit[1])*time.Second)
remaining := limit[0] - int(count) - 1
if remaining < 0 {
remaining = 0
}
return RateLimitResult{
Allowed: count < int64(limit[0]),
RemainingAttempts: remaining,
ResetAfterSeconds: limit[1],
}
}
func (l LoginRateLimiter) checkLockout(ip, email string) (RateLimitResult, bool) {
for _, key := range []string{"lockout:ip:" + ip, "lockout:account:" + hashEmail(email)} {
if ttl := redis.TTL(key).Seconds(); ttl > 0 {
return RateLimitResult{
Allowed: false,
RemainingAttempts: 0,
ResetAfterSeconds: int(ttl),
LockoutSeconds: int(ttl),
}, true
}
}
return RateLimitResult{}, false
}
func (l LoginRateLimiter) setLockout(key string, seconds int) {
redis.SetEX(key, time.Duration(seconds)*time.Second, 1)
}
// ResetOnSuccess membersihkan counter setelah login berhasil.
func (l LoginRateLimiter) ResetOnSuccess(ip, email string) {
redis.Del("rl:ip:"+ip, "rl:account:"+hashEmail(email),
"lockout:ip:"+ip, "lockout:account:"+hashEmail(email))
}
func hashEmail(email string) string {
sum := sha256.Sum256([]byte(strings.ToLower(email)))
return hex.EncodeToString(sum[:])[:16]
}
func main() {
var loginLimiter LoginRateLimiter
// Registrasi router (pseudo-code, meniru route Flask)
app.Post("/login", func(req Request, res Response) {
ip := getRealIP(req) // handle proxy headers dengan hati-hati
email := strings.ToLower(strings.TrimSpace(req.JSON("email")))
// Cek rate limit sebelum apapun
result := loginLimiter.CheckAll(ip, email)
if !result.Allowed {
retryAfter := result.ResetAfterSeconds
if result.LockoutSeconds > 0 {
retryAfter = result.LockoutSeconds
}
res.Status(429)
res.Header("Retry-After", strconv.Itoa(retryAfter))
res.JSON(map[string]any{"error": "Too many attempts", "retry_after": retryAfter})
return
}
// Proses login dengan timing yang konsisten
user := authenticateUser(email, req.JSON("password"))
if user != nil {
loginLimiter.ResetOnSuccess(ip, email)
setAuthCookie(res, createSession(user.ID, req))
res.JSON(map[string]any{"status": "ok"})
return
}
// Login gagal — pesan yang sama untuk semua kasus
res.Status(401)
res.JSON(map[string]any{"error": "Email atau password tidak valid"})
})
}
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"
func main() {
app.Post("/login", func(req Request, res Response) {
email := req.JSON("email")
password := req.JSON("password")
user := findUserByEmail(email)
if user == nil {
// Return CEPAT — tidak ada hash computation
res.Status(401)
res.JSON(map[string]any{"error": "Invalid credentials"})
return
}
if !verifyPassword(password, user.PasswordHash) {
// Return LAMBAT — hash computation terjadi
res.Status(401)
res.JSON(map[string]any{"error": "Invalid credentials"})
return
}
})
}
// 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
var ph = PasswordHasher{}
// Hash dummy yang valid untuk digunakan ketika user tidak ditemukan
var dummyHash = ph.Hash("dummy_password_that_never_matches")
func init() {
app.Post("/login", func(req Request, res Response) {
email := req.JSON("email")
password := req.JSON("password")
user := findUserByEmail(email)
passwordHash := dummyHash
if user != nil {
passwordHash = user.PasswordHash
}
// Verifikasi selalu dijalankan, terlepas apakah user ada atau tidak
isValid, err := ph.Verify(passwordHash, password)
if err == nil && user != nil && isValid {
// Login berhasil
res.JSON(successResponse(user))
return
}
// Login gagal — pesan yang sama
res.Status(401)
res.JSON(map[string]any{"error": "Email atau password tidak valid"})
})
}
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.
// Lockout yang cerdas:
// - Tidak langsung lockout permanen
// - Progressive delay
// - Tidak memungkinkan attacker untuk DOS akun user lain
type SmartAccountLockout struct {
// Progressive lockout: semakin banyak gagal, semakin lama locked
lockoutSchedule [][2]int
}
func NewSmartAccountLockout() *SmartAccountLockout {
return &SmartAccountLockout{
lockoutSchedule: [][2]int{
{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
},
}
}
// GetLockoutDuration mengembalikan durasi lockout berdasarkan jumlah kegagalan.
func (s *SmartAccountLockout) GetLockoutDuration(failureCount int) int {
duration := 0
for _, entry := range s.lockoutSchedule {
if failureCount >= entry[0] {
duration = entry[1]
}
}
return duration
}
func (s *SmartAccountLockout) RecordFailure(accountKey string) map[string]any {
key := "login_failures:" + accountKey
// Increment failure count
failureCount := redis.Incr(key)
// Set expiry jika baru dibuat
if failureCount == 1 {
redis.Expire(key, 24*time.Hour) // reset setelah 24 jam tanpa activity
}
// Tentukan lockout duration
lockoutDuration := s.GetLockoutDuration(failureCount)
if lockoutDuration > 0 {
redis.SetEX("lockout:"+accountKey, time.Duration(lockoutDuration)*time.Second, failureCount)
}
return map[string]any{
"failure_count": failureCount,
"locked": lockoutDuration > 0,
"lockout_seconds": lockoutDuration,
}
}
// IsLocked mengembalikan (is_locked, seconds_remaining).
func (s *SmartAccountLockout) IsLocked(accountKey string) (bool, int) {
ttl := int(redis.TTL("lockout:" + accountKey).Seconds())
if ttl < 0 {
ttl = 0
}
return ttl > 0, ttl
}
// UnlockOnSuccess reset setelah login berhasil.
func (s *SmartAccountLockout) UnlockOnSuccess(accountKey string) {
redis.Del("login_failures:"+accountKey, "lockout:"+accountKey)
}
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)
var (
func main() {
recaptchaSecret = os.Getenv("RECAPTCHA_SECRET_KEY")
}
func init() {
recaptchaVerifyURL = "https://www.google.com/recaptcha/api/siteverify"
}
func init() {
recaptchaThreshold = 0.5 // Score 0 (bot) sampai 1 (manusia)
}
)
// VerifyRecaptcha memverifikasi reCAPTCHA token.
// Returns: (is_human, score)
func VerifyRecaptcha(token, action string) (bool, float64) {
resp, err := http.PostForm(recaptchaVerifyURL, url.Values{
"secret": []string{recaptchaSecret},
"response": []string{token},
})
if err != nil {
// Jika CAPTCHA service down, jangan blokir user legitimate
// Tapi log untuk monitoring
func init() {
logger.Warning("reCAPTCHA verification failed — service unavailable")
}
return true, 1.0 // Default allow jika service down
}
defer resp.Body.Close()
var result struct {
Success bool `json:"success"`
Action string `json:"action"`
Score float64 `json:"score"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return false, 0.0
}
if !result.Success {
return false, 0.0
}
// Verifikasi action sesuai
if result.Action != action {
return false, 0.0
}
return result.Score >= recaptchaThreshold, result.Score
}
// Registrasi router (pseudo-code, meniru route Flask)
func init() {
app.Post("/login", func(req Request, res Response) {
// Ambil CAPTCHA token dari request
captchaToken := req.JSON("captcha_token")
if captchaToken == "" {
res.Status(400)
res.JSON(map[string]any{"error": "CAPTCHA required"})
return
}
isHuman, score := VerifyRecaptcha(captchaToken, "login")
if !isHuman {
logger.Warning("Low reCAPTCHA score on login",
"score", score, "ip", req.RemoteAddr)
res.Status(429)
res.JSON(map[string]any{"error": "CAPTCHA verification failed"})
return
}
// 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.
// GeoIP database untuk deteksi lokasi
var reader = geoip2.Open("/path/to/GeoLite2-City.mmdb")
type CredentialStuffingDetector struct {
Window int
}
// NewCredentialStuffingDetector membuat detector dengan window tertentu.
func NewCredentialStuffingDetector(windowSeconds int) *CredentialStuffingDetector {
return &CredentialStuffingDetector{Window: windowSeconds}
}
// AnalyzeLoginPattern menganalisis apakah request ini terlihat seperti credential stuffing.
func (d *CredentialStuffingDetector) AnalyzeLoginPattern(ip, email string) map[string]any {
signals := []string{}
// Signal 1: User-Agent yang tidak umum atau sama persis antar request
userAgent := req.UserAgent()
if d.isSuspiciousUserAgent(userAgent) {
signals = append(signals, "suspicious_user_agent")
}
// Signal 2: Tidak ada header yang biasanya ada di browser nyata
expectedHeaders := []string{"accept", "accept-language", "accept-encoding"}
missing := 0
for _, h := range expectedHeaders {
if req.Header(h) == "" {
missing++
}
}
if missing > 1 {
signals = append(signals, "missing_browser_headers")
}
// Signal 3: Login dari banyak email berbeda dari satu IP dalam waktu singkat
ipKey := "logins_from_ip:" + ip
emailsFromIP := redis.PFCount(ipKey) // HyperLogLog untuk counting
if emailsFromIP > 10 {
signals = append(signals, "many_accounts_from_ip")
}
// Signal 4: Lokasi yang tidak konsisten dengan pola login historis
country, err := reader.Country(ip)
if err == nil && d.isImpossibleLocation(email, country) {
signals = append(signals, "impossible_location")
}
// Signal 5: IP address dari daftar proxy/VPN/Tor yang diketahui
if d.isKnownProxyIP(ip) {
signals = append(signals, "proxy_ip")
}
// Record email ini dari IP (dengan TTL)
redis.PFAdd(ipKey, email)
redis.Expire(ipKey, time.Duration(d.Window)*time.Second)
riskScore := len(signals)
return map[string]any{
"risk_score": riskScore,
"signals": signals,
"action": d.determineAction(riskScore),
}
}
func (d *CredentialStuffingDetector) isSuspiciousUserAgent(ua string) bool {
// Tool credential stuffing sering menggunakan UA yang aneh
suspiciousPatterns := []string{
"python-requests", "axios/", "okhttp/", "curl/",
"go-http-client", "java/", "php/",
}
uaLower := strings.ToLower(ua)
for _, p := range suspiciousPatterns {
if strings.Contains(uaLower, p) {
return true
}
}
return false
}
func (d *CredentialStuffingDetector) determineAction(riskScore int) string {
switch {
case riskScore == 0:
return "allow"
case riskScore == 1:
return "monitor" // log tapi izinkan
case riskScore == 2:
return "challenge" // tampilkan CAPTCHA
default:
return "block" // blokir sementara
}
}
func (d *CredentialStuffingDetector) isKnownProxyIP(ip string) bool {
// Cek terhadap database IP proxy/VPN/Tor yang diketahui
// Bisa menggunakan layanan seperti IPQualityScore, MaxMind, dll
proxyKey := "known_proxy:" + ip
return redis.Exists(proxyKey) == 1
}
func (d *CredentialStuffingDetector) isImpossibleLocation(email, currentCountry string) bool {
// Bandingkan dengan country terakhir yang diketahui
historyKey := "login_country:" + hashEmail(email)
lastCountry := redis.Get(historyKey)
return lastCountry != "" && lastCountry != currentCountry
}
// Registrasi router (pseudo-code, meniru route Flask)
func main() {
detector := NewCredentialStuffingDetector(300)
app.Post("/login", func(req Request, res Response) {
ip := req.RemoteAddr
email := req.JSON("email")
// Analisis credential stuffing signals
analysis := detector.AnalyzeLoginPattern(ip, email)
if analysis["action"] == "block" {
res.Status(429)
res.JSON(map[string]any{"error": "Aktivitas mencurigakan terdeteksi", "retry_after": 300})
return
}
if analysis["action"] == "challenge" {
// Minta CAPTCHA untuk request mencurigakan
res.Status(202)
res.JSON(map[string]any{"challenge_required": true, "challenge_type": "captcha"})
return
}
// Lanjutkan proses login normal
// ...
})
}
Breached Password Detection #
Menolak password yang sudah diketahui bocor adalah langkah proaktif yang sangat efektif.
const hibpAPIURL = "https://api.pwnedpasswords.com/range/{}"
// CheckPasswordBreached mengecek apakah password ada dalam database HaveIBeenPwned.
// Menggunakan k-Anonymity — password tidak dikirim ke API.
// Returns: (is_breached, breach_count)
func CheckPasswordBreached(password string) (bool, int) {
sum := sha1.Sum([]byte(password))
sha1Hex := strings.ToUpper(hex.EncodeToString(sum[:]))
prefix, suffix := sha1Hex[:5], sha1Hex[5:]
resp, err := http.Get(fmt.Sprintf(hibpAPIURL, prefix))
if err != nil {
// Jika API tidak tersedia, jangan blokir — log dan lanjutkan
logger.Warning("HIBP API unavailable")
return false, 0
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return false, 0
}
for _, line := range strings.Split(string(body), "\n") {
parts := strings.Split(line, ":")
if len(parts) == 2 && parts[0] == suffix {
count, _ := strconv.Atoi(parts[1])
return true, count
}
}
return false, 0
}
// Gunakan saat registrasi dan saat ganti password
func ValidatePasswordStrength(password string) []string {
var errors []string
if len(password) < 12 {
errors = append(errors, "Password minimal 12 karakter")
}
isBreached, count := CheckPasswordBreached(password)
if isBreached {
errors = append(errors,
fmt.Sprintf("Password ini ditemukan dalam %d data breach. Gunakan password yang unik dan belum pernah digunakan.", count))
}
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
func RecordLoginMetric(success bool, ip, email string) {
now := time.Now().Unix()
minuteBucket := now - (now % 60)
pipe := redis.Pipeline()
if success {
pipe.Incr(fmt.Sprintf("login:success:%d", minuteBucket))
} else {
pipe.Incr(fmt.Sprintf("login:failure:%d", minuteBucket))
// Track unique emails per IP
pipe.SAdd(fmt.Sprintf("login:ips:%s:%d", ip, minuteBucket), email)
pipe.Expire(fmt.Sprintf("login:ips:%s:%d", ip, minuteBucket), 600*time.Second)
}
pipe.Expire(fmt.Sprintf("login:success:%d", minuteBucket), 600*time.Second)
pipe.Expire(fmt.Sprintf("login:failure:%d", minuteBucket), 600*time.Second)
pipe.Execute()
}
func CheckBruteForceAlert() []string {
now := time.Now().Unix()
var alerts []string
// Cek 5 menit terakhir
totalSuccess, totalFailure := 0, 0
for i := 0; i < 5; i++ {
bucket := now - (now % 60) - int64(i*60)
success := redis.GetInt(fmt.Sprintf("login:success:%d", bucket))
failure := redis.GetInt(fmt.Sprintf("login:failure:%d", bucket))
totalSuccess += success
totalFailure += failure
}
total := totalSuccess + totalFailure
if total > 0 {
failureRate := float64(totalFailure) / float64(total)
if failureRate > 0.7 && total > 100 {
alerts = append(alerts, fmt.Sprintf(
"High login failure rate: %.1f%% (%d/%d failed in last 5 min)",
failureRate*100, totalFailure, total))
}
}
return alerts
}
Anti-Pattern yang Harus Dihindari #
// ✗ Anti-pattern 1: tidak ada rate limiting pada login endpoint
func loginNoRateLimit(req Request, res Response) {
user := authenticate(req.JSON("email"), req.JSON("password"))
_ = user
// Tidak ada rate limit — bisa coba jutaan password per detik
}
// ✗ Anti-pattern 2: pesan error yang membedakan kasus
func leakyLogin(req Request, res Response) {
if !userExists {
res.Status(401)
res.Write("Email tidak terdaftar") // bocor: email tidak ada
}
if wrongPassword {
res.Status(401)
res.Write("Password salah") // 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.