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

OWASP A04: Cryptographic Failures (1) — Encoding, Hashing, Encryption ve HTTPS

Mert Özen 6 Kas 2026 9 dk 3 görüntülenme
OWASP A04: Cryptographic Failures (1) — Encoding, Hashing, Encryption ve HTTPS

Base64 neden şifreleme değil, hash ile encryption arasındaki fark ne? AES-GCM ile değiştirilen veriyi yakalıyor, parola sıfırlama kodunda Random yerine RandomNumberGenerator kullanıyor, MD5'in parola için neden yanlış olduğunu görüyor, CA5351 ve CA5394 analyzer'larını açıyor, ASP.NET Core'da HTTPS yönlendirmesi ve HSTS varsayılanlarını curl ile inceliyoruz.

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 veritabanında bir sipariş notunun yanında Y2FyZCBlbmRpbmcgNDI0MiwgZGV2LW9ubHk= diye bir değer görüyorsun. Kodu yazan arkadaşın "kart bilgisi şifreli duruyor" diyor. Haklı mı? Peki aynı tablodaki iki kullanıcının parola sütununda harfi harfine aynı değer duruyorsa bu sana ne anlatır?

İki sorunun da cevabını bu yazıda kodla göreceğiz. Konu A04:2025 Cryptographic Failures (kriptografik hatalar). 2021'de ikinci sıradaydı, 2025'te dördüncü sıraya indi ve 32 CWE'yi kapsıyor; en tipik olanlar kırık algoritma kullanmak (CWE-327), yetersiz entropi (CWE-331) ve zayıf rastgele sayı üreteci (CWE-338). Kriptografi bu kategoride nadiren "kırılır"; genellikle yanlış yerde, yanlış araçla ya da hiç kullanılmaz.

Örnekleri kendi makinende, kendi lab uygulamana karşı çalıştır. Bu teknikleri yalnız sahibi olduğun ya da yazılı izin aldığın sistemlerde dene.

Encoding, Hashing, Encryption: Üç Farklı İş

Bu üç kelime sık karıştırılır, oysa her biri farklı bir soruya cevap verir. Küçük bir console uygulamasıyla üçünü yan yana koyalım:

using System.Security.Cryptography;
using System.Text;

string cardNote = "card ending 4242, dev-only";
byte[] plain = Encoding.UTF8.GetBytes(cardNote);

// 1) Encoding: gizlilik yok, sadece biçim değişiyor
string encoded = Convert.ToBase64String(plain);
Console.WriteLine($"Base64 : {encoded}");
Console.WriteLine($"Decoded: {Encoding.UTF8.GetString(Convert.FromBase64String(encoded))}");

// 2) Hashing: tek yönlü, aynı girdi hep aynı çıktı
string hash1 = Convert.ToHexString(SHA256.HashData(plain));
string hash2 = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(cardNote + "!")));
Console.WriteLine($"SHA-256: {hash1}");
Console.WriteLine($"SHA-256: {hash2}");

// 3) Encryption (AES-GCM): anahtar olmadan geri dönmez
byte[] key = RandomNumberGenerator.GetBytes(32);
byte[] nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize);
byte[] cipher = new byte[plain.Length];
byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize];

using (AesGcm aes = new AesGcm(key, tag.Length))
{
    aes.Encrypt(nonce, plain, cipher, tag);
}
Console.WriteLine($"Cipher : {Convert.ToHexString(cipher)}");

byte[] decrypted = new byte[cipher.Length];
using (AesGcm aes = new AesGcm(key, tag.Length))
{
    aes.Decrypt(nonce, cipher, tag, decrypted);
}
Console.WriteLine($"Plain  : {Encoding.UTF8.GetString(decrypted)}");

// Şifreli metinde tek bit değiştir
cipher[0] ^= 0x01;
try
{
    using AesGcm aes = new AesGcm(key, tag.Length);
    aes.Decrypt(nonce, cipher, tag, decrypted);
}
catch (CryptographicException ex)
{
    Console.WriteLine($"Tampered: {ex.GetType().Name}: {ex.Message}");
}
Base64 : Y2FyZCBlbmRpbmcgNDI0MiwgZGV2LW9ubHk=
Decoded: card ending 4242, dev-only
SHA-256: 8A1FDC089A34E37C38845B27A15CF7756EEFCB2455F442DCAAC77AE9D6807751
SHA-256: 316636F6C9CC5CBA34BD69E6A4447E0DA7470AF9E9E96C8959D9055344A1C88C
Cipher : F7FA33ADA1147F51EE8CD3E8800BE23945060C5F45EBA2312E4F
Plain  : card ending 4242, dev-only
Tampered: AuthenticationTagMismatchException: The computed authentication tag did not match the input authentication tag.

Açılıştaki değer Base64'tü ve tek satırda geri döndü. Encoding veriyi başka bir sistemin taşıyabileceği biçime çevirir; anahtarı yoktur, herkes geri çevirebilir. Bu yüzden Base64 "şifreleme" değildir. Arkadaşın haklı değil.

Hashing tek yönlüdür. Girdiye tek bir ! eklemek çıktının tamamını değiştirdi, ama aynı girdi her zaman aynı hash'i verir. Bütünlük kontrolü için harika, gizlilik için tek başına yetmez: tahmin edilebilir bir girdinin hash'ini saldırgan da hesaplayabilir.

Encryption ise anahtarla geri döndürülebilir dönüşümdür. AES-GCM burada iki iş birden yapıyor: içeriği gizliyor ve tag ile bütünlüğü koruyor. Şifreli metinde tek bit değiştirdiğimde Decrypt veriyi sessizce bozuk çözmedi, exception fırlattı. Bu "authenticated encryption" özelliği; şifreleme seçerken aradığın şey bu. Bir kural: aynı anahtarla aynı nonce'u asla iki kez kullanma. Her şifrelemede yeni bir nonce üret ve şifreli metinle birlikte sakla.

Rastgelelik: Random ile RandomNumberGenerator

Bookstore'da parola sıfırlama için altı haneli bir kod üreten şu endpoint'i düşün. Lab'da e-posta göndermek yerine kodu log'a yazıyoruz; gerçek bir uygulamada token'ı log'a yazmak ayrı bir hata.

app.MapPost("/password-reset", (ResetRequest request, ILogger<Program> logger) =>
{
    // ZAYIF: zamana bağlı seed, aynı saniyede aynı token
    Random random = new Random((int)DateTimeOffset.UtcNow.ToUnixTimeSeconds());
    string token = random.Next(100_000, 999_999).ToString();

    logger.LogInformation("Reset mail to {Email}: token {Token}", request.Email, token);
    return Results.Accepted();
});

Sorun iki katmanlı. Birincisi seed: aynı seed aynı diziyi üretir ve burada seed saniye cinsinden saat. Lab'da iki farklı e-posta için aynı anda istek attığımda iki log satırında da aynı kod vardı; yani kendi hesabı için sıfırlama isteyen biri, aynı saniyede başkası için üretilen kodu da bilmiş oluyor. İkincisi Random'un kendisi: seed vermesen de kriptografik amaçla tasarlanmamıştır. Üçüncü bir sorun da altı hane: yalnızca 900.000 civarı olasılık var, rate limiting olmadan bu da zayıf.

Düzeltme tek satır. RandomNumberGenerator işletim sisteminin kriptografik rastgele sayı üretecini kullanır ve .NET 8'den beri doğrudan string üretebilir:

using System.Security.Cryptography;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapPost("/password-reset", (ResetRequest request, ILogger<Program> logger) =>
{
    // 32 byte = 256 bit, işletim sisteminin CSPRNG'sinden
    string token = RandomNumberGenerator.GetHexString(64, lowercase: true);

    logger.LogInformation("Reset mail to {Email}: token {Token}", request.Email, token);
    return Results.Accepted();
});

app.Run();

record ResetRequest(string Email);

Aynı iki isteği yine aynı anda attım:

curl -s -X POST https://localhost:5124/password-reset -H "Content-Type: application/json" -d '{"email":"mallory@example.com"}'
curl -s -X POST https://localhost:5124/password-reset -H "Content-Type: application/json" -d '{"email":"ada@example.com"}'
info: Program[0]
      Reset mail to ada@example.com: token 30d17a93bd6663dbc83ebffff3f3b476ce6e104b6afbcc9e2a453e79902d2dce
info: Program[0]
      Reset mail to mallory@example.com: token 8da54cb228aa9e6fada84a9c2f26a013a833d6dad3be86c8d1ac1103306ef9ba

Bu hatayı code review'da yakalamak zorunda değilsin. .NET'in güvenlik analyzer'ları varsayılan olarak kapalı; projeye bir .editorconfig ekleyince açılıyorlar:

root = true

[*.cs]
dotnet_diagnostic.CA5350.severity = warning
dotnet_diagnostic.CA5351.severity = warning
dotnet_diagnostic.CA5394.severity = warning
.../Program.cs(14,20): warning CA5394: Random is an insecure random number generator. Use cryptographically secure random number generators when randomness is required for security. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca5394)

Zayıf Algoritmalar: MD5 ve SHA-1

Açılıştaki ikinci soruya gelelim. Eski bir sistemden taşınmış, parolaları MD5 ile saklayan bir kullanıcı tablosunu canlandıralım:

using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;

string[] users = ["ada", "alan", "grace"];
string[] passwords = ["123456", "123456", "Bookstore2026!"];

for (int i = 0; i < users.Length; i++)
{
    byte[] md5 = MD5.HashData(Encoding.UTF8.GetBytes(passwords[i]));
    Console.WriteLine($"{users[i],-6} {Convert.ToHexString(md5).ToLowerInvariant()}");
}

Stopwatch watch = Stopwatch.StartNew();
for (int i = 0; i < 1_000_000; i++)
{
    MD5.HashData(Encoding.UTF8.GetBytes($"guess{i}"));
}
Console.WriteLine($"1,000,000 MD5 hashes: {watch.ElapsedMilliseconds} ms");
ada    e10adc3949ba59abbe56e057f20f883e
alan   e10adc3949ba59abbe56e057f20f883e
grace  453a55a9ad763e2d11c33dfd63ca46c9
1,000,000 MD5 hashes: 232 ms

Üç ders var. Aynı parola aynı hash'i veriyor, yani tabloya bakan biri ada ile alan'ın aynı parolayı kullandığını hemen görüyor. Salt olmadığı için hazır tablolarla eşleştirme yapılabiliyor; e10adc39... değeri bu yüzden pek çok geliştiricinin ezbere tanıdığı bir değer. En önemlisi hız: benim makinemde (Apple M5, tek thread) bir milyon MD5 yaklaşık 230 ms sürdü. Parola için hızlı hash bir özellik değil, zafiyettir; tahmin denemesini de aynı hızda yapar. Analyzer bunu da yakalıyor:

.../Program.cs(10,18): warning CA5351: <Main>$ uses a broken cryptographic algorithm MD5 (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca5351)

MD5 ve SHA-1'i dosya bütünlüğü gibi güvenlik dışı işlerde görebilirsin, ama yeni kodda bütünlük için SHA-256 ya da daha güçlüsünü kullan. Parola için ise SHA-256 da yanlış araç; doğrusu yavaş ve salt'lı algoritmalar. Onu bir sonraki yazıda ASP.NET Core Identity'nin PasswordHasher'ıyla göreceğiz.

Aktarımdaki Veri: HTTPS ve HSTS

En iyi şifreleme bile, veri ağda düz metin giderse işe yaramaz. ASP.NET Core template'indeki iki satırı lab'a koyup Production ortamında çalıştıralım:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(365);
    options.IncludeSubDomains = true;
    // YALNIZ LAB: localhost'ta da header'ı görmek için
    options.ExcludedHosts.Clear();
});
var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}
app.UseHttpsRedirection();

app.MapGet("/health", () => Results.Ok(new { status = "ok" }));

app.Run();
curl -i http://localhost:5123/health
HTTP/1.1 307 Temporary Redirect
Content-Length: 0
Date: Fri, 06 Nov 2026 19:05:28 GMT
Server: Kestrel
Location: https://localhost:5124/health
curl -i https://localhost:5124/health
HTTP/2 200
content-type: application/json; charset=utf-8
date: Fri, 06 Nov 2026 19:05:43 GMT
server: Kestrel
strict-transport-security: max-age=31536000; includeSubDomains

{"status":"ok"}

UseHttpsRedirection HTTP isteğini varsayılan olarak 307 ile HTTPS'e yönlendiriyor. Ama ilk istek yine düz HTTP'den gitti; ağdaki biri o ilk isteğe müdahale edebilir. HSTS bunu kapatır: tarayıcı Strict-Transport-Security header'ını bir kez gördükten sonra max-age süresince o siteye HTTP ile hiç gitmez. Header yalnız HTTPS yanıtında gönderilir; HTTP'deki 307 yanıtında yok.

UseHsts()'in varsayılanları temkinli: MaxAge 30 gün, IncludeSubDomains ve Preload kapalı, ExcludedHosts içinde localhost var. Lab'da ExcludedHosts.Clear() satırı olmadan aynı istekte header hiç gelmedi; localhost'ta HSTS'i göremeyince "çalışmıyor" sanma. 30 günü uygulamanı tanıdıkça uzat, alt alan adlarının hepsi HTTPS değilse IncludeSubDomains'i açma, preload'u ise geri almanın zor olduğunu bilerek seç.

Durağan Veri

Disk ve veritabanı şifrelemesi (bulut sağlayıcının disk şifrelemesi, SQL Server TDE gibi) fiziksel diskin çalınmasına karşı korur, ama uygulama veriyi okuyabildiği sürece SQL injection ile sızan veri düz metindir. Bu yüzden önce "bu veriyi saklamam gerekiyor mu?" diye sor. OWASP'ın önerisi de bu: gereksiz hassas veriyi hiç tutma, tutman gerekenleri sınıflandır. Kart numarası gibi bir şeyi saklaman gerekiyorsa uygulama katmanında, anahtarı veriden ayrı tutarak şifrele. Anahtarı nerede saklayacağın sorusu, bir sonraki yazının konusu.

Kendi Kriptonu Yazma

"Parolayı ters çevirip XOR'larım, kimse çözemez" cümlesi her ekipte bir kez söylenir. Kendi algoritmanı tasarlama, kendi şifreleme protokolünü de kurma. System.Security.Cryptography içindeki yüksek seviyeli API'ler (AesGcm, RandomNumberGenerator, SHA256.HashData, Rfc2898DeriveBytes.Pbkdf2) ve ASP.NET Core'un Data Protection API'si zaten doğru varsayılanlarla gelir. İki hash'i karşılaştırman gerekiyorsa == yerine CryptographicOperations.FixedTimeEquals kullan; normal karşılaştırma ilk farklı byte'ta durur ve süre farkı bilgi sızdırabilir.

Sık Düşülen Tuzaklar

Birincisi, Base64'ü şifreleme sanmak. JWT'nin gövdesi, Basic authentication header'ı, "gizli" diye saklanan config değerleri çoğu zaman yalnızca Base64'tür ve herkes okuyabilir.

İkincisi, token, parola sıfırlama kodu, API anahtarı ya da session id için Random veya Guid.NewGuid() kullanmak. Guid benzersiz olmak için tasarlanmıştır, tahmin edilemez olmak için değil; güvenlik amaçlı değerler RandomNumberGenerator'dan gelsin.

Üçüncüsü, ECB modu ya da sabit IV/nonce. Aes.Create() ile elle şifreleme yapıyorsan mod, padding ve IV seçimini sen üstleniyorsun. Mümkünse AesGcm kullan ve her şifrelemede yeni nonce üret.

Dördüncüsü, sertifika doğrulamasını kapatmak. HttpClientHandler.ServerCertificateCustomValidationCallback'ten koşulsuz true döndürmek, geliştirme ortamında "geçici" diye eklenip Production'a giden klasik bir satır.

Kontrol Listesi

Encoding'i yalnız taşıma için, hashing'i bütünlük için, encryption'ı gizlilik için kullan ve üçünün adını kodda doğru koy. Güvenlik amaçlı her rastgele değeri RandomNumberGenerator ile üret. CA5350, CA5351 ve CA5394 analyzer'larını .editorconfig ile aç.

Production'da UseHttpsRedirection ve UseHsts açık olsun, HSTS süresini bilinçli seç. Şifreleme için authenticated bir mod (AES-GCM) kullan, nonce'u tekrar etme, sertifika doğrulamasını kapatma. Saklaman gerekmeyen hassas veriyi saklama.

Küçük Bir Deneme

★☆☆ Console örneğinde cipher[0] ^= 0x01; satırı yerine tag[0] ^= 0x01; yaz ve sonucu karşılaştır. Sonra aynı metni iki kez şifreleyip şifreli metinlerin farklı çıktığını gör.

★★☆ Lab'daki parola sıfırlama endpoint'ine .editorconfig'i ekle, önce zayıf hâliyle build alıp CA5394 uyarısını gör, sonra RandomNumberGenerator'a geçip uyarının kaybolduğunu doğrula.

★★★ HSTS lab'ında ExcludedHosts.Clear() satırını kaldırıp header'ın kaybolduğunu gör. Ardından MaxAge ve IncludeSubDomains değerlerini değiştirip header'ın nasıl değiştiğini curl -i ile izle.

Bir sonraki yazıda kriptografik hataların ikinci yarısına geçiyoruz: ASP.NET Core Identity'nin PasswordHasher'ı parolayı tam olarak nasıl saklıyor, OWASP'ın bugünkü önerisiyle arasında ne fark var, Data Protection API ne işe yarar ve secret'lar kodda değilse nerede durmalı.