Mert Özen Her Satırda Daha İleri
Güvenlik Web Güvenliği (OWASP Top 10) — #12

OWASP A07: Authentication Failures (1) — Brute Force, Lockout ve NIST Parola Kuralları

Mert Özen 6 Kas 2026 10 dk 3 görüntülenme
OWASP A07: Authentication Failures (1) — Brute Force, Lockout ve NIST Parola Kuralları

Sınırsız deneme kabul eden, kullanıcının var olup olmadığını mesajıyla ve cevap süresiyle ele veren bir giriş endpoint'iyle başlıyoruz. Brute force ile credential stuffing'i ayırıyor, Identity'nin varsayılan parola kurallarını NIST SP 800-63B-4 ile karşılaştırıyor, blocklist validator, lockout ve tek tip cevapla düzeltiyor, MFA ve passkey'lere kısaca bakıyoruz.

Sürüm notu: Bu yazı OWASP Top 10:2025'e göre yazılmıştır; kodlar ASP.NET Core 10 (.NET 10) ile denenmiştir.

Önce Tahmin Et

Bookstore API'nin giriş endpoint'ine aynı hesap için arka arkaya yirmi yanlış parola gönderiyorsun. Yirmi birinci istekte doğru parolayı yazarsan ne olur? Bir de var olmayan bir e-posta adresiyle dene: cevap sana o adresin sistemde kayıtlı olmadığını söyler mi? Söylemese bile cevabın ne kadar sürede geldiği bir şey anlatır mı?

İlk iki sorunun cevabını büyük ihtimalle tahmin ettin. Üçüncüsü, yani cevap süresi, çoğu kod incelemesinde gözden kaçan kısım ve birazdan milisaniyelerle göreceğiz.

A07:2025 Authentication Failures

A07:2025 Authentication Failures (kimlik doğrulama hataları) 2021'deki "Identification and Authentication Failures" adının kısalmış hâli; yeri yedinci sırada kaldı, altında 36 CWE var. Öne çıkanlar uygun olmayan kimlik doğrulama (CWE-287), session fixation (CWE-384) ve koda gömülü kimlik bilgileri (CWE-798). Session ve token tarafını bir sonraki yazıya bırakıp bu yazıda giriş kapısının kendisine bakıyoruz.

Giriş ekranına yapılan iki klasik saldırının farkını bilmek savunmayı seçerken önemli. Brute force tek bir hesaba çok sayıda parola dener. Credential stuffing ise başka bir siteden sızmış e-posta ve parola çiftlerini alır, her hesabı bir ya da birkaç kez dener. İnsanlar parolalarını siteler arasında tekrar kullandığı için ikincisi çok daha verimlidir ve hesap başına deneme sayısı düşük olduğu için hesap kilitleme (lockout) onu tek başına durdurmaz. OWASP'ın A07 sayfası da bu yüzden çok faktörlü kimlik doğrulamayı (MFA) ve NIST'in parola rehberini işaret ediyor.

Örneklerin hepsini kendi makinendeki lab'da çalıştırıyoruz; bu teknikleri yalnız sahibi olduğun ya da yazılı izin aldığın sistemlerde dene. Lab'daki parolalar da açıkça sahte.

Zafiyetli Giriş Endpoint'i

Lab bu kez ASP.NET Core Identity'yi EF Core ve SQLite ile kullanıyor. AppDbContext, IdentityDbContext<IdentityUser>'dan türüyor ve uygulama açılırken ada@example.com kullanıcısı Dev-only-Bookstore-2026 parolasıyla oluşturuluyor. İlk sürüm şöyle:

builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlite("Data Source=identity.db"));
builder.Services.AddIdentityCore<IdentityUser>()
    .AddEntityFrameworkStores<AppDbContext>();
...
app.MapPost("/login", async (LoginRequest req, UserManager<IdentityUser> users) =>
{
    IdentityUser? user = await users.FindByEmailAsync(req.Email);
    if (user is null) return Results.NotFound(new { error = "No user with this email" });
    if (!await users.CheckPasswordAsync(user, req.Password))
        return Results.BadRequest(new { error = "Wrong password" });
    return Results.Ok(new { message = "Welcome" });
});

Parolalar Identity'nin PasswordHasher'ıyla saklanıyor, yani A04 yazısında anlattığım saklama tarafı doğru. Sorun kapının kendisinde. Uygulamayı dotnet run --urls http://localhost:5142 ile başlat ve yirmi yanlış denemenin ardından doğru parolayı gönder:

$ for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:5142/login -H "Content-Type: application/json" -d "{\"email\":\"ada@example.com\",\"password\":\"guess-$i\"}"; done
400 400 400 400 400 400 400 400 400 400 400 400 400 400 400 400 400 400 400 400
$ curl -i -X POST http://localhost:5142/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"Dev-only-Bookstore-2026"}'
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...

{"message":"Welcome"}

Hiçbir sınır yok. Yirmi deneme bir saniye bile sürmedi; bir saldırgan aynı döngüyü bir parola listesiyle saatlerce çalıştırabilir ve sistemin tek tepkisi her seferinde aynı 400 olur. CheckPasswordAsync lockout sayacına hiç dokunmaz; sayacı işleten SignInManager'daki method'lardır.

Kullanıcıyı Ele Veren Cevaplar

İkinci sorun cevabın içeriği. Var olmayan bir adresle ve var olan bir adresle aynı yanlış parolayı dene:

$ curl -i -X POST http://localhost:5142/login -H "Content-Type: application/json" -d '{"email":"grace@example.com","password":"anything"}'
HTTP/1.1 404 Not Found
...

{"error":"No user with this email"}
$ curl -i -X POST http://localhost:5142/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"anything"}'
HTTP/1.1 400 Bad Request
...

{"error":"Wrong password"}

Bu, user enumeration: saldırgan elindeki e-posta listesini önce bu endpoint'ten geçirip hangilerinin müşterin olduğunu öğrenir, sonra yalnızca onlara yüklenir. Mesajları eşitlemek yetmez, çünkü süre de konuşur:

$ curl -s -o /dev/null -w "unknown %{time_total}\n" -X POST http://localhost:5142/login -H "Content-Type: application/json" -d '{"email":"nobody@example.com","password":"x"}'
unknown 0.000877
$ curl -s -o /dev/null -w "known %{time_total}\n" -X POST http://localhost:5142/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"x"}'
known 0.030895

Kullanıcı yoksa kod hiç hash hesaplamadan dönüyor ve cevap bir milisaniyenin altında geliyor. Kullanıcı varsa PBKDF2'nin 100.000 turu çalışıyor ve cevap 30 milisaniye sürüyor. Otuz kat fark, ağ gürültüsünün içinde bile ölçülebilir.

NIST SP 800-63B-4 ve Identity'nin Parola Kuralları

Kayıt tarafına geçmeden Identity'nin varsayılanlarına bakalım. Lab'da boş bir IdentityOptions oluşturup değerlerini yazdıran küçük bir endpoint var:

IdentityOptions defaults = new();
PasswordOptions p = defaults.Password;
LockoutOptions l = defaults.Lockout;
$ curl http://localhost:5142/defaults
RequiredLength=6 RequiredUniqueChars=1
RequireDigit=True RequireLowercase=True RequireUppercase=True RequireNonAlphanumeric=True
MaxFailedAccessAttempts=5 DefaultLockoutTimeSpan=00:05:00 AllowedForNewUsers=True

Altı karakter, ama rakam, küçük harf, büyük harf ve sembol zorunlu. Bu, yıllarca "güçlü parola" diye öğretilen kompozisyon kuralı modeli. Sonucunu kayıt endpoint'inde görmek kolay:

$ curl -X POST http://localhost:5142/register -H "Content-Type: application/json" -d '{"email":"alan@example.com","password":"correct horse battery staple"}'
["Passwords must have at least one digit ('0'-'9').","Passwords must have at least one uppercase ('A'-'Z')."]
$ curl -X POST http://localhost:5142/register -H "Content-Type: application/json" -d '{"email":"grace@example.com","password":"Password1!"}'
{"message":"Registered"}

28 karakterlik bir parola cümlesi reddedildi, sızmış parola listelerinde sık görülen kalıbın ta kendisi olan Password1! kabul edildi. NIST'in Temmuz 2025'te final hâline gelen SP 800-63B-4 rehberi tam olarak bu modeli bırakıyor. Parola tek faktörse en az 15 karakter, yalnızca MFA'nın bir parçasıysa en az 8 karakter isteniyor; izin verilen üst sınır en az 64 olmalı. Kompozisyon kuralı dayatmak açıkça yasak (SHALL NOT), periyodik parola değiştirmeyi zorunlu kılmak da yasak; değişiklik ancak sızma kanıtı varsa zorunlu. Buna karşılık her yeni parola sızmış parolalar, sözlük kelimeleri ve bağlama özgü kelimeler (site adı, kullanıcı adı) içeren bir blocklist'e karşı kontrol edilmeli ve kullanıcıya neden reddedildiği söylenmeli. Parola yöneticilerine ve yapıştırmaya izin verilmeli, güvenlik sorusu kullanılmamalı.

Identity'de bunun karşılığı iki parça. Önce seçenekler:

builder.Services.AddAuthentication(IdentityConstants.ApplicationScheme).AddIdentityCookies();
builder.Services.AddIdentityCore<IdentityUser>(options =>
    {
        // NIST SP 800-63B-4: uzunluk evet, kompozisyon kuralı hayır
        options.Password.RequiredLength = 15;
        options.Password.RequireDigit = false;
        options.Password.RequireLowercase = false;
        options.Password.RequireUppercase = false;
        options.Password.RequireNonAlphanumeric = false;

        options.Lockout.MaxFailedAccessAttempts = 5;
        options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
    })
    .AddEntityFrameworkStores<AppDbContext>()
    .AddSignInManager()
    .AddPasswordValidator<BlocklistPasswordValidator>();

Sonra blocklist için kendi IPasswordValidator<TUser>'ımız:

public sealed class BlocklistPasswordValidator : IPasswordValidator<IdentityUser>
{
    // Gerçekte sızmış parola listesi; burada birkaç örnek
    private static readonly HashSet<string> Blocked = new(StringComparer.OrdinalIgnoreCase)
    {
        "123456789012345", "passwordpassword", "qwertyuiopasdfgh", "bookstorebookstore"
    };

    public Task<IdentityResult> ValidateAsync(UserManager<IdentityUser> manager, IdentityUser user, string? password)
    {
        if (password is not null && (Blocked.Contains(password)
            || password.Contains(user.UserName ?? "\0", StringComparison.OrdinalIgnoreCase)))
        {
            return Task.FromResult(IdentityResult.Failed(new IdentityError
            {
                Code = "BlockedPassword",
                Description = "This password is too common or contains your email. Please choose another one."
            }));
        }
        return Task.FromResult(IdentityResult.Success);
    }
}

Gerçek bir uygulamada Blocked birkaç kelime değil, sızmış parolalardan oluşan büyük bir listedir; sunucuda bir dosyadan ya da bir veritabanı tablosundan okunur. Aynı üç kaydı tekrar deneyelim:

$ curl -X POST http://localhost:5142/register -H "Content-Type: application/json" -d '{"email":"alan@example.com","password":"correct horse battery staple"}'
{"message":"Registered"}
$ curl -X POST http://localhost:5142/register -H "Content-Type: application/json" -d '{"email":"grace@example.com","password":"Password1!"}'
["Passwords must be at least 15 characters."]
$ curl -X POST http://localhost:5142/register -H "Content-Type: application/json" -d '{"email":"linus@example.com","password":"passwordpassword"}'
["This password is too common or contains your email. Please choose another one."]

Lockout ve Tek Tip Cevap

Giriş endpoint'i artık SignInManager.CheckPasswordSignInAsync'i lockoutOnFailure: true ile çağırıyor ve her başarısızlıkta aynı cevabı dönüyor:

app.MapPost("/login", async (LoginRequest req, UserManager<IdentityUser> users,
    SignInManager<IdentityUser> signIn, IPasswordHasher<IdentityUser> hasher, ILogger<Program> logger) =>
{
    IdentityUser? user = await users.FindByEmailAsync(req.Email);
    if (user is null)
    {
        // Kullanıcı yoksa da bir hash doğrula ki cevap süresi ele vermesin
        hasher.VerifyHashedPassword(DummyUser.Instance, DummyUser.Hash, req.Password);
        return Results.Problem("Invalid email or password.", statusCode: StatusCodes.Status401Unauthorized);
    }

    SignInResult result = await signIn.CheckPasswordSignInAsync(user, req.Password, lockoutOnFailure: true);
    if (result.IsLockedOut)
        logger.LogWarning("Account {UserId} is locked out", user.Id);
    if (!result.Succeeded)
        return Results.Problem("Invalid email or password.", statusCode: StatusCodes.Status401Unauthorized);

    return Results.Ok(new { message = "Welcome" });
});

DummyUser.Hash, uygulama açılırken bir kez hesaplanan sahte bir parola hash'i. Kullanıcı bulunmasa da aynı maliyette bir doğrulama yapılıyor. SignInManager kimlik doğrulama servislerine ihtiyaç duyduğu için AddAuthentication satırı da gerekli; o satırı silince ilk istekte Unable to resolve service for type 'Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider' hatası alıyorsun. Şimdi bir yanlış deneme, dört yanlış deneme daha ve ardından doğru parola:

$ curl -i -X POST http://localhost:5142/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"anything"}'
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
...

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"detail":"Invalid email or password."}
$ for i in $(seq 1 4); do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:5142/login -H "Content-Type: application/json" -d "{\"email\":\"ada@example.com\",\"password\":\"guess-$i\"}"; done
401 401 401 401
$ curl -i -X POST http://localhost:5142/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"Dev-only-Bookstore-2026"}'
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
...

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"detail":"Invalid email or password."}

Beşinci başarısız denemede hesap 15 dakikalığına kilitlendi ve doğru parola da reddedildi. Var olmayan grace@example.com için dönen cevap da bayt bayt aynı. Kilitlenmeyi istemciye söylemiyoruz ama sunucu logunda görüyoruz:

warn: Program[0]
      Account 34cefe7c-803c-4fc5-b6b2-3a7371c93dd2 is locked out

Süre ölçümünü var olmayan adresle ve kayıtlı alan@example.com ile tekrarlayınca fark da kapanıyor:

unknown 0.029102
known 0.030123
unknown 0.029194
known 0.033231

Lockout'un bir bedeli var: saldırgan senin e-postanla bilerek beş yanlış deneme yaparak seni hesabından 15 dakika uzak tutabilir. Bu yüzden lockout süresini kısa tut, kilitlenmeyi kullanıcıya e-postayla bildir ve hesap başına lockout'u IP ya da cihaz başına rate limiting ile birleştir. Credential stuffing'e karşı asıl katman hesap başına değil, istek kaynağı başına sınırdır; önceki yazıdaki rate limiter'ı giriş endpoint'ine de bağla.

MFA ve Passkey'ler

En iyi parola politikası bile parolanın sızmasını engellemez; sızmış bir parolanın tek başına yetmemesini MFA sağlar. Identity, authenticator uygulamalarıyla TOTP tabanlı iki faktörlü doğrulamayı yerleşik olarak destekliyor. Kullanıcıda TwoFactorEnabled açıksa PasswordSignInAsync başarılı bir parolada bile RequiresTwoFactor döner ve akış ikinci adıma geçer. Dikkat: bu yazıda kullandığımız CheckPasswordSignInAsync yalnızca parolayı ve lockout'u kontrol eder, ikinci faktöre bakmaz; MFA kullanıyorsan oturumu açan akışın ikinci faktörü de istediğinden emin ol.

NIST SP 800-63B-4 elle girilen tek kullanımlık kodları phishing'e dayanıklı saymıyor ve AAL2 seviyesinde en az bir phishing'e dayanıklı seçenek sunulmasını istiyor. Bugün bunun en pratik karşılığı passkey'ler, yani WebAuthn ve FIDO2 tabanlı, anahtarı cihazda ya da parola yöneticisinde duran kimlik bilgileri. NIST bu adı kullanmıyor ama senkronize edilebilen bu tür doğrulayıcılara AAL2'ye kadar izin veriyor. .NET 10'da ASP.NET Core Identity passkey desteği aldı ve Blazor Web App şablonu passkey yönetimini hazır getiriyor. İlginç bir ayrıntı: OWASP'ın A07:2025 sayfası MFA'yı öneriyor ama passkey kelimesini hiç geçirmiyor.

Sık Düşülen Tuzaklar

İlk tuzak, lockout'u açtığını sanmak. MaxFailedAccessAttempts ayarını yapıp girişte CheckPasswordAsync çağırırsan sayaç hiç artmaz. Sayacı işleten lockoutOnFailure: true parametresidir ve yalnızca SignInManager method'larında vardır. Eski kullanıcılarda LockoutEnabled kolonunun false kalmış olabileceğini de kontrol et.

İkincisi, mesajı eşitleyip diğer kanalları unutmak. Durum kodu, cevap süresi, kayıt formundaki "bu e-posta zaten kullanılıyor" mesajı ve parola sıfırlama ekranındaki "böyle bir kullanıcı yok" cevabı aynı bilgiyi sızdırır. Parola sıfırlamada her zaman "adres kayıtlıysa e-posta gönderdik" demek standart çözümdür.

Üçüncüsü, kompozisyon kuralını "ekstra güvenlik" diye tutmak. Kurallar kullanıcıyı Password1! gibi tahmin edilebilir kalıplara iter ve NIST bunları açıkça yasaklıyor. Aynı şekilde 90 günde bir zorunlu parola değişikliği de kullanıcıyı Password2!'ye götürür. Uzunluk ve blocklist, kuralların yerini alır.

Dördüncüsü, giriş endpoint'ini rate limiting'in dışında bırakmak. Hesap başına lockout brute force'u yavaşlatır, credential stuffing'i yavaşlatmaz. İkisi birlikte çalışmalı ve ikisi de log üretmeli; A09 yazısında bu logları nasıl alarma çevireceğimizi göreceğiz.

Kontrol Listesi

Giriş endpoint'i başarısız denemede kullanıcı var olsun ya da olmasın aynı durum kodunu, aynı gövdeyi ve yaklaşık aynı süreyi dönüyor mu? Lockout gerçekten devrede mi, yani lockoutOnFailure: true ile çağrılıyor mu, ve kilitlenme loglanıyor mu?

Parola politikan en az 15 karakter istiyor, kompozisyon kuralı dayatmıyor ve yeni parolaları bir blocklist'e karşı kontrol ediyor mu? Periyodik değişim zorunluluğu kaldırıldı mı? Giriş, kayıt ve parola sıfırlama endpoint'lerinde rate limiting var mı? Kullanıcılara MFA sunuluyor mu ve yönetici hesaplarında zorunlu mu? .NET 10 kullanıyorsan passkey seçeneğini değerlendirdin mi?

Küçük Bir Deneme

★☆☆ Zafiyetli sürümde zaman ölçümünü kendin tekrarla, sonra düzeltilmiş sürümde VerifyHashedPassword satırını yorum satırı yapıp aynı ölçümü al. Farkın geri geldiğini gör.

★★☆ BlocklistPasswordValidator'ı bir metin dosyasından okuyacak şekilde değiştir ve dosyaya on bin satırlık bir örnek liste koy. Doğrulamanın kayıt süresine etkisini ölç; listeyi HashSet yerine bir List'te tutunca ne değiştiğine bak.

★★★ Önceki yazıdaki rate limiter'ı giriş endpoint'ine, partition key'i istemci IP'si olacak şekilde ekle. Sonra aynı IP'den farklı e-postalarla deneme yapan bir credential stuffing senaryosunu curl döngüsüyle canlandır ve hangi katmanın (lockout mu, rate limiting mi) seni durdurduğunu gözle.

Kapıdan doğru kişinin geçtiğinden emin olduk. Bir sonraki yazıda içeri giren kullanıcının kimliğinin her istekte nasıl taşındığına bakıyoruz: cookie bayrakları, session fixation, JWT tuzakları ve Top 10'da ayrı bir başlığı kalmasa da hâlâ yaşayan CSRF.