Unsachgemäße Einschränkung übermäßiger Authentifizierungsversuche
Beschreibung
Unsachgemäße Einschränkung übermäßiger Authentifizierungsversuche tritt auf, wenn eine Webanwendung die Anzahl fehlgeschlagener Authentifizierungsversuche nicht oder unzureichend begrenzt. Dies ermöglicht Angreifern, Brute-Force-Angriffe durchzuführen und systematisch Passwörter auszuprobieren, bis das richtige gefunden wird. Ohne Rate-Limiting, Kontosperrung oder andere Schutzmaßnahmen können Angreifer Millionen von Passwortkombinationen ausprobieren, besonders mit automatisierten Tools. Diese Schwachstelle ermöglicht auch Credential-Stuffing-Angriffe unter Verwendung geleakter Benutzername/Passwort-Datenbanken.
Risiko
Brute-Force-Angriffe sind bei schwachen Passwörtern hocheffektiv. Mit moderner Hardware können Angreifer Milliarden von Hashes offline oder Tausende von Versuchen pro Sekunde online testen. Credential Stuffing unter Verwendung geleakter Anmeldedaten hat eine Erfolgsrate von 0,1-2%, was bei einem einzelnen Datenbankleck Millionen kompromittierter Konten bedeutet. Hochkarätige Datenlecks bei Unternehmen wie Dropbox, LinkedIn und Yahoo lieferten massive Anmeldedatenlisten, die noch heute bei Angriffen verwendet werden.
Lösung
Implementieren Sie Kontosperrung nach einer Schwelle fehlgeschlagener Versuche (z.B. 5-10 Versuche). Verwenden Sie progressive Verzögerungen, die mit jedem fehlgeschlagenen Versuch zunehmen. Implementieren Sie CAPTCHA nach anfänglichen Fehlversuchen. Verwenden Sie Multi-Faktor-Authentifizierung (MFA). Überwachen und blockieren Sie verdächtige IP-Adressen. Implementieren Sie Rate-Limiting pro IP, pro Konto und global. Erwägen Sie Device Fingerprinting. Offenbaren Sie niemals, ob Benutzername oder Passwort falsch war. Verwenden Sie bcrypt, Argon2 oder scrypt für Passwort-Hashing, um Offline-Angriffe zu verlangsamen.
Häufige Auswirkungen
| Auswirkung | Details |
|---|---|
| Zugriffskontrolle | Bereich: Kontoübernahme Erfolgreicher Brute-Force führt zu unbefugtem Kontozugriff. |
| Vertraulichkeit | Bereich: Datenexposition Kompromittierte Konten exponieren persönliche und sensible Daten. |
| Integrität | Bereich: Unbefugte Aktionen Angreifer können Daten ändern, Käufe tätigen oder Aktionen als Opfer durchführen. |
Beispielcode + Korrigierter Code
Anfälliger Code
# ANFÄLLIG: Kein Rate-Limiting oder Sperrung
from flask import Flask, request
@app.route('/login', methods=['POST'])
def login():
username = request.form['username']
password = request.form['password']
user = User.query.filter_by(username=username).first()
if user and check_password(password, user.password_hash):
# Keine Verfolgung fehlgeschlagener Versuche
# Angreifer kann unbegrenzt Passwörter ausprobieren
session['user_id'] = user.id
return redirect('/dashboard')
return 'Ungültige Anmeldedaten', 401 # Offenbart auch was falsch ist
// ANFÄLLIG: Kein Brute-Force-Schutz
@RestController
public class AuthController {
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
User user = userRepository.findByUsername(request.getUsername());
if (user != null && passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
String token = jwtService.generateToken(user);
return ResponseEntity.ok(new AuthResponse(token));
}
// Kein Rate-Limiting, keine Sperrung
return ResponseEntity.status(401).body("Ungültige Anmeldedaten");
}
}
// ANFÄLLIG: Kein Schutz gegen automatisierte Angriffe
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await User.findOne({ username });
if (user && await bcrypt.compare(password, user.passwordHash)) {
const token = jwt.sign({ userId: user._id }, secret);
return res.json({ token });
}
// Unbegrenzte Versuche erlaubt
res.status(401).json({ error: 'Ungültige Anmeldedaten' });
});
Korrigierter Code
# SICHER: Rate-Limiting und Kontosperrung
from flask import Flask, request
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
import redis
from datetime import datetime, timedelta
app = Flask(__name__)
limiter = Limiter(app, key_func=get_remote_address)
redis_client = redis.Redis()
MAX_FAILED_ATTEMPTS = 5
LOCKOUT_DURATION = 900 # 15 Minuten
def get_failed_attempts(username):
key = f"failed_login:{username}"
return int(redis_client.get(key) or 0)
def increment_failed_attempts(username):
key = f"failed_login:{username}"
pipe = redis_client.pipeline()
pipe.incr(key)
pipe.expire(key, LOCKOUT_DURATION)
pipe.execute()
def reset_failed_attempts(username):
redis_client.delete(f"failed_login:{username}")
def is_locked(username):
return get_failed_attempts(username) >= MAX_FAILED_ATTEMPTS
@app.route('/login', methods=['POST'])
@limiter.limit("10 per minute") # IP-basiertes Rate-Limiting
def login():
username = request.form['username']
password = request.form['password']
# Sperrung prüfen
if is_locked(username):
return jsonify({
'error': 'Konto vorübergehend gesperrt. Versuchen Sie es später erneut.'
}), 429
user = User.query.filter_by(username=username).first()
# Zeitkonstanten Vergleich verwenden um Timing-Angriffe zu verhindern
if user and check_password_hash(user.password_hash, password):
reset_failed_attempts(username)
session['user_id'] = user.id
log_successful_login(username, request.remote_addr)
return redirect('/dashboard')
# Immer inkrementieren, auch für nicht existierende Benutzer (verhindert Enumeration)
increment_failed_attempts(username)
# Generische Nachricht - nicht offenbaren was falsch war
remaining = MAX_FAILED_ATTEMPTS - get_failed_attempts(username)
return jsonify({
'error': 'Ungültige Anmeldedaten',
'attempts_remaining': max(0, remaining)
}), 401
// SICHER: Umfassender Brute-Force-Schutz
@RestController
public class SecureAuthController {
@Autowired
private LoginAttemptService loginAttemptService;
@Autowired
private PasswordEncoder passwordEncoder;
@PostMapping("/login")
@RateLimited(requests = 10, period = 60) // Benutzerdefinierte Annotation
public ResponseEntity<?> login(
@RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
String ip = getClientIP(httpRequest);
String username = request.getUsername();
// IP-basierte Sperrung prüfen
if (loginAttemptService.isIPBlöcked(ip)) {
return ResponseEntity.status(429)
.body(new ErrorResponse("Zu viele Anfragen. Versuchen Sie es später erneut."));
}
// Kontosperrung prüfen
if (loginAttemptService.isAccountLocked(username)) {
return ResponseEntity.status(423)
.body(new ErrorResponse("Konto gesperrt. Kontaktieren Sie den Support."));
}
User user = userRepository.findByUsername(username).orElse(null);
// Timing-sicherer Vergleich (immer Hash-Prüfung durchführen)
boolean validPassword = false;
if (user != null) {
validPassword = passwordEncoder.matches(
request.getPassword(),
user.getPasswordHash()
);
} else {
// Dummy-Prüfung um Timing-Angriffe zu verhindern
passwordEncoder.matches(request.getPassword(), DUMMY_HASH);
}
if (validPassword) {
loginAttemptService.loginSucceeded(username, ip);
String token = jwtService.generateToken(user);
return ResponseEntity.ok(new AuthResponse(token));
}
loginAttemptService.loginFailed(username, ip);
// Progressive Verzögerung hinzufügen
int attempts = loginAttemptService.getFailedAttempts(username);
int delay = Math.min(attempts * 1000, 10000); // Max 10 Sekunden Verzögerung
Thread.sleep(delay);
return ResponseEntity.status(401)
.body(new ErrorResponse("Ungültige Anmeldedaten"));
}
}
@Service
public class LoginAttemptService {
private static final int MAX_ATTEMPTS = 5;
private static final int LOCKOUT_MINUTES = 15;
@Autowired
private RedisTemplate<String, Integer> redisTemplate;
public void loginFailed(String username, String ip) {
String userKey = "login:user:" + username;
String ipKey = "login:ip:" + ip;
redisTemplate.opsForValue().increment(userKey);
redisTemplate.expire(userKey, LOCKOUT_MINUTES, TimeUnit.MINUTES);
redisTemplate.opsForValue().increment(ipKey);
redisTemplate.expire(ipKey, LOCKOUT_MINUTES, TimeUnit.MINUTES);
// Für Überwachung protokollieren
auditLog.warn("Fehlgeschlagener Anmeldeversuch", Map.of(
"username", username,
"ip", ip,
"attempts", getFailedAttempts(username)
));
}
public void loginSucceeded(String username, String ip) {
redisTemplate.delete("login:user:" + username);
// IP-Zähler bei Erfolg nicht zurücksetzen (verhindert verteilte Angriffe)
}
public boolean isAccountLocked(String username) {
Integer attempts = redisTemplate.opsForValue().get("login:user:" + username);
return attempts != null && attempts >= MAX_ATTEMPTS;
}
public boolean isIPBlöcked(String ip) {
Integer attempts = redisTemplate.opsForValue().get("login:ip:" + ip);
return attempts != null && attempts >= MAX_ATTEMPTS * 10; // Höherer Schwellwert für IP
}
public int getFailedAttempts(String username) {
Integer attempts = redisTemplate.opsForValue().get("login:user:" + username);
return attempts != null ? attempts : 0;
}
}
// SICHER: Express mit Rate-Limiting und Sperrung
const rateLimit = require('express-rate-limit');
const Redis = require('ioredis');
const redis = new Redis();
const MAX_ATTEMPTS = 5;
const LOCKOUT_SECONDS = 900;
// IP-basiertes Rate-Limiting
const loginLimiter = rateLimit({
windowMs: 60 * 1000, // 1 Minute
max: 10, // 10 Anfragen pro Minute pro IP
message: { error: 'Zu viele Anfragen, bitte versuchen Sie es später erneut' }
});
async function getFailedAttempts(username) {
const key = `login:failed:${username}`;
return parseInt(await redis.get(key)) || 0;
}
async function recordFailedAttempt(username) {
const key = `login:failed:${username}`;
await redis.multi()
.incr(key)
.expire(key, LOCKOUT_SECONDS)
.exec();
}
async function resetFailedAttempts(username) {
await redis.del(`login:failed:${username}`);
}
async function isLocked(username) {
return await getFailedAttempts(username) >= MAX_ATTEMPTS;
}
app.post('/login', loginLimiter, async (req, res) => {
const { username, password } = req.body;
// Sperrung prüfen
if (await isLocked(username)) {
return res.status(429).json({
error: 'Konto vorübergehend gesperrt',
retryAfter: LOCKOUT_SECONDS
});
}
const user = await User.findOne({ username });
// Immer Passwort-Hash prüfen (timing-sicher)
let valid = false;
if (user) {
valid = await bcrypt.compare(password, user.passwordHash);
} else {
// Dummy-Vergleich um Timing-Angriffe zu verhindern
await bcrypt.compare(password, '$2b$10$dummyhashvalue');
}
if (valid) {
await resetFailedAttempts(username);
const token = jwt.sign({ userId: user._id }, secret);
return res.json({ token });
}
await recordFailedAttempt(username);
const attempts = await getFailedAttempts(username);
const remaining = Math.max(0, MAX_ATTEMPTS - attempts);
// Progressive Verzögerung
const delay = Math.min(attempts * 500, 5000);
await new Promise(resolve => setTimeout(resolve, delay));
res.status(401).json({
error: 'Ungültige Anmeldedaten',
attemptsRemaining: remaining
});
});
Ausgenutzt in der Praxis
iCloud Promi-Foto-Leak (2014)
Angreifer verwendeten Brute-Force-Techniken gegen iClouds "Find My iPhone"-Funktion, der Rate-Limiting fehlte. Dies ermöglichte Credential-Guessing, das zum Leak privater Fotos von Prominenten-Konten führte.
GitHub Brute-Force-Angriff (2013)
GitHub erlitt einen Brute-Force-Angriff, bei dem Angreifer Listen von Anmeldedaten aus anderen Datenlecks verwendeten. GitHub implementierte als Reaktion Rate-Limiting und Zwei-Faktor-Authentifizierung.
Credential-Stuffing-Angriffe (Fortlaufend)
Große Dienste einschließlich Netflix, Spotify und verschiedene Finanzinstitute sind regelmäßig Credential-Stuffing-Angriffen unter Verwendung geleakter Anmeldedaten-Datenbanken ausgesetzt, was zu Kontokompromittierungen führt.
Tools zum Testen/Ausnutzen
-
Hydra — Netzwerk-Login-Cracker.
-
Burp Suite Intruder — Automatisiertes Authentifizierungstesting.
-
Medusa — Paralleler Passwort-Cracker.
-
fail2ban — Intrusion-Prevention-System.
CVE-Beispiele
-
CVE-2019-3462 — APT-Paketmanager Brute-Force-Schwachstelle.
-
CVE-2020-15228 — GitHub Actions Brute-Force-Schwachstelle.
-
CVE-2021-32617 — Exim kein Rate-Limiting bei AUTH.
Referenzen
-
MITRE. "CWE-307: Improper Restriction of Excessive Authentication Attempts." https://cwe.mitre.org/data/definitions/307.html
-
OWASP. "Blocking Brute Force Attacks." https://owasp.org/www-community/controls/Blocking_Brute_Force_Attacks