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

OWASP A08: Software or Data Integrity Failures — Deserialization ve Mass Assignment

Mert Özen 6 Kas 2026 9 dk 7 görüntülenme
OWASP A08: Software or Data Integrity Failures — Deserialization ve Mass Assignment

Gelen verinin kendi tipini seçmesinin neden tehlikeli olduğunu kavramsal olarak anlatıyor, BinaryFormatter'ın net10.0'da derleme hatası ve PlatformNotSupportedException ile nasıl sona erdiğini gerçek çıktılarla gösteriyoruz. System.Text.Json'da allowlist ile polimorfizm kuruyor, DTO ve JsonUnmappedMemberHandling ile mass assignment'ı kapatıyor, CI/CD ve SRI ile kodun bütünlüğüne 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'de kullanıcı profilini güncelleyen bir endpoint var: görünen ad ve kısa bir biyografi. İstemci gövdeye bir de "isAdmin": true eklerse ne olur? İkinci soru eski bir koddan: kitap cache'ini BinaryFormatter ile diske yazan bir sınıfı .NET 10'a taşıyıp derlediğinde ne görürsün?

İki sorunun ortak noktası aynı: dışarıdan gelen veriye, onun ne olduğunu ve nereden geldiğini doğrulamadan güvenmek. A08'in özü bu.

A08:2025 Software or Data Integrity Failures

A08:2025 Software or Data Integrity Failures (yazılım veya veri bütünlüğü hataları) 2021'deki yerini korudu; adındaki "and" "or" oldu ve altında 14 CWE var. 2017 listesindeki ayrı Insecure Deserialization başlığı 2021'de bu kategoriye katılmıştı. Öne çıkan CWE'ler güvenilmeyen verinin deserialization'ı (CWE-502), nesne alanlarının kontrolsüz değiştirilmesi, yani mass assignment (CWE-915) ve güvenilmeyen bir kaynaktan işlevsellik almak (CWE-829).

Kategori iki şeyin bütünlüğüyle ilgileniyor. Biri veri: istemciden, bir kuyruktan ya da bir dosyadan gelen içerik uygulamanın içinde ne olacağına kendisi karar vermemeli. Diğeri kod: uygulamana giren paketler, build çıktıları, CDN'den yüklenen script'ler ve otomatik güncellemeler beklediğin kaynaktan, beklediğin hâliyle gelmeli. A03 Software Supply Chain Failures ikinci konunun daha geniş hâlini zaten anlattı; burada ağırlık veride.

Örnekler kendi makinendeki lab'da çalışıyor; bu teknikleri yalnız sahibi olduğun ya da yazılı izin aldığın sistemlerde dene.

Insecure Deserialization Neden Tehlikeli?

Serialization bir object'i byte'lara ya da metne çevirmek, deserialization da geri dönüştürmek. Tehlike, deserializer'ın hangi tipi oluşturacağını gelen verinin içinden okuduğu durumda başlıyor. BinaryFormatter, NetDataContractSerializer, SoapFormatter ve TypeNameHandling ayarı None dışında bir değere çekilmiş Newtonsoft.Json bu ailedendir. Akışın içinde bir tip adı yazar ve deserializer o tipi oluşturur.

Neden sorun olduğunu bir postane benzetmesiyle düşün. Normalde gelen paketi açar, içindekini kendi listene göre yerleştirirsin. Bu deserializer'lar ise paketin üstündeki etikete bakar ve "beni şu odaya koy, oradaki makineyi de çalıştır" talimatını sorgusuz uygular. Saldırgan, uygulamanın yüklü assembly'lerindeki herhangi bir class'ı seçebilir; o class'ın constructor'ı, property setter'ları ya da deserialization callback'leri saldırganın verdiği değerlerle çalışır. Uygulamanın kendisinde hiç hata olmasa bile, framework'te ve paketlerde yan etkisi olan bir class bulunması yeter. Sonuç uzaktan kod çalıştırmaya kadar gidebilir; bu yüzden bu sorun bir input validation eksikliği değil, tasarım hatasıdır. Gelen veriyi doğrulamak işe yaramaz, çünkü zarar doğrulamadan önce, nesne oluşturulurken verilir.

Kural kısa: dışarıdan gelen veri yalnızca veriyi belirler, tipi asla. Tipi senin kodun, derleme zamanında seçer.

BinaryFormatter'ın Sonu

Microsoft bu kuralı .NET'te en sert şekilde uyguladı. Eski bir kitap cache'i şöyle yazılmış olsun:

using System.Runtime.Serialization.Formatters.Binary;

BookCacheEntry entry = new() { Id = 42, Title = "Clean Code", Price = 34.90m };

BinaryFormatter formatter = new();
using MemoryStream stream = new();
formatter.Serialize(stream, entry);
Console.WriteLine($"Serialized {stream.Length} bytes");

[Serializable]
public sealed class BookCacheEntry
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public decimal Price { get; set; }
}

net10.0 ile derleyince uyarı değil hata alıyorsun:

$ dotnet build
Program.cs(5,1): error SYSLIB0011: 'BinaryFormatter' is obsolete: 'BinaryFormatter serialization is obsolete and should not be used. See https://aka.ms/binaryformatter for more information.' (https://aka.ms/dotnet-warnings/SYSLIB0011)
Build FAILED.
    1 Error(s)

Biri bu hatayı NoWarn ile susturup eski .NET sürümlerinde işe yarayan EnableUnsafeBinaryFormatterSerialization ayarını da açarsa derleme geçer, ama çalışma zamanında başka bir duvar var:

<NoWarn>$(NoWarn);SYSLIB0011</NoWarn>
<EnableUnsafeBinaryFormatterSerialization>true</EnableUnsafeBinaryFormatterSerialization>
$ dotnet run
Unhandled exception. System.PlatformNotSupportedException: BinaryFormatter serialization and deserialization have been removed. See https://aka.ms/binaryformatter for more information.
   at System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.Serialize(Stream serializationStream, Object graph)
   at Program.<Main>$(String[] args) in .../Program.cs:line 7

.NET 9'dan beri runtime'da BinaryFormatter'ın gerçek bir implementasyonu yok; API'ler duruyor ama her çağrı PlatformNotSupportedException fırlatıyor. Geri dönmenin tek yolu, Microsoft'un desteklemediğini açıkça yazdığı System.Runtime.Serialization.Formatters NuGet paketi. Onu yüklemek yerine veriyi taşı: cache'i System.Text.Json ile yeniden yaz. Eski dosyaları bir kerelik okumak zorundaysan System.Formats.Nrbf paketindeki NrbfDecoder, akışı tip oluşturmadan kayıtlar hâlinde ayrıştırır; net10.0'da bu paket ayrıca eklenmeli.

Güvenli Yol: System.Text.Json ve Allowlist

System.Text.Json gelen JSON'daki bir alana bakarak tip seçmez; senin verdiğin tipe, o tipin property'lerine göre okur. Polimorfik bir veri gerekiyorsa, örneğin ödeme isteği kart ya da hediye kartı olabiliyorsa, izin verilen alt tipleri derleme zamanında sen sayarsın:

[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(CardPayment), "card")]
[JsonDerivedType(typeof(GiftCardPayment), "giftcard")]
public abstract record PaymentRequest(decimal Amount);
public sealed record CardPayment(decimal Amount, string Last4) : PaymentRequest(Amount);
public sealed record GiftCardPayment(decimal Amount, string Code) : PaymentRequest(Amount);

kind alanı bir tip adı değil, senin tanımladığın bir etiket. Dört farklı gövdeyi JsonSerializer.Deserialize<PaymentRequest> ile okuyan küçük bir konsol uygulamasının çıktısı:

OK   CardPayment 34.90
OK   GiftCardPayment 10
FAIL JsonException: Read unrecognized type discriminator id 'crypto'. Path: $ | LineNumber: 0 | BytePositionInLine: 26.
FAIL NotSupportedException: The JSON payload for polymorphic interface or abstract type 'PaymentRequest' must specify a type discriminator. Path: $ | LineNumber: 0 | BytePositionInLine: 10.

Listede olmayan bir etiket de, etiketsiz bir gövde de reddediliyor. Seçenekler iki alt tip ve hiçbiri değil. Newtonsoft.Json kullanan eski bir projen varsa TypeNameHandling'in varsayılanı olan None'da kaldığından emin ol; başka bir değere ihtiyaç duyduğunu düşünüyorsan izin verilen tipleri sayan bir ISerializationBinder olmadan bunu yapma ve önce System.Text.Json'daki allowlist modeline geçmeyi değerlendir.

Mass Assignment: Entity Değil DTO

Mass assignment aynı fikrin daha sessiz bir hâli. Endpoint parametresi olarak doğrudan EF Core entity'si olan User'ı alıp veritabanına yazan kodda, istemci gövdeye ne koyarsa entity'nin o property'si dolar. User class'ında IsAdmin varsa, profil formunda böyle bir alan olmaması bir şey değiştirmez; isteği senin formun değil, istemci oluşturur ve istemciye istediği her şeyi yazabilir. Düzeltme, endpoint'in yalnızca değişmesine izin verdiğin alanları taşıyan bir DTO almasıdır:

app.MapPut("/me/profile", async (UpdateProfileRequest req, AppDb db) =>
{
    User user = await db.Users.SingleAsync(u => u.Id == Lab.CurrentUserId);
    // Yalnızca DTO'daki alanlar entity'ye kopyalanır
    user.DisplayName = req.DisplayName;
    user.Bio = req.Bio;
    await db.SaveChangesAsync();
    return Results.Ok(new { user.Id, user.DisplayName, user.Bio, user.IsAdmin });
});

public sealed record UpdateProfileRequest(string DisplayName, string? Bio);

Lab'da kimlik doğrulama bu yazının konusu olmadığı için oturum açmış kullanıcı sabit: Ada, id 1. Şimdi gövdeye fazladan bir alan ekleyelim:

$ curl -X PUT http://localhost:5153/me/profile -H "Content-Type: application/json" -d '{"displayName":"Ada L.","bio":"Reads a lot","isAdmin":true}'
{"id":1,"displayName":"Ada L.","bio":"Reads a lot","isAdmin":false}
$ curl http://localhost:5153/me
{"id":1,"email":"ada@example.com","displayName":"Ada L.","bio":"Reads a lot","isAdmin":false}

isAdmin DTO'da olmadığı için System.Text.Json onu sessizce yok saydı. Güvenli, ama sessiz. İstemcinin beklemediğin alan göndermesini bir hata olarak görmek istiyorsan bunu açıkça söyleyebilirsin. Bir ayrıntı: .NET 10'un AddValidation() ile gelen yerleşik doğrulaması bilinmeyen alanları reddetmez; bunun için ayrı bir ayar var:

builder.Services.ConfigureHttpJsonOptions(options =>
    options.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow);
$ curl -i -X PUT http://localhost:5153/me/profile -H "Content-Type: application/json" -d '{"displayName":"Ada L.","bio":"Reads a lot","isAdmin":true}'
HTTP/1.1 400 Bad Request
Content-Length: 0
...
$ curl -X PUT http://localhost:5153/me/profile -H "Content-Type: application/json" -d '{"displayName":"Ada L.","bio":"Reads a lot"}'
{"id":1,"displayName":"Ada L.","bio":"Reads a lot","isAdmin":false}

Production'da cevap boş bir 400. Aynı isteği Development ortamında atınca sebebi de görüyorsun: The JSON property 'isAdmin' could not be mapped to any .NET member contained in type 'UpdateProfileRequest'. Bu ayarın bir bedeli var: istemci yeni bir alan eklediğinde eski sunucu sürümü isteği reddeder. Dışarıya açık, sürümlenen bir API'de bunu bilerek seç; kendi Angular istemcinle konuşan bir API'de genellikle doğru tercih.

Kodun Bütünlüğü: Güncellemeler, CI/CD ve SRI

Bütünlüğün ikinci yarısı kod. Uygulaman bir eklentiyi ya da güncellemeyi bir URL'den indirip çalıştırıyorsa, indirdiği şeyin imzasını ya da hash'ini, güvendiğin bir anahtarla doğrulamadan çalıştırmamalı. Sadece HTTPS yetmez; HTTPS kanalı korur, dosyayı sunucuya kimin koyduğunu değil.

CI/CD hattı da bu zincirin parçası. Build'i kim tetikleyebiliyor, pipeline tanımını kim değiştirebiliyor, hangi secret'lara erişiyor? Pull request'ten gelen bir değişiklik production secret'larıyla çalışabiliyorsa, kodun bütünlüğü o PR'ı açan kişinin elindedir. Lock dosyaları, korumalı branch'ler, gözden geçirilmeden birleşmeyen pipeline değişiklikleri ve üçüncü taraf action'ları tag yerine commit SHA ile sabitlemek burada temel önlemler. Korumalı branch'leri ve ruleset'leri Git serisinin ekip çalışması yazısında, commit imzalamayı da hook'lar yazısında konuşmuştuk.

Tarayıcı tarafında aynı sorunun karşılığı CDN'den yüklenen script'ler. A03 yazısında gördüğümüz polyfill.io olayı, başka bir alan adından gelen bir script'in bir gün bambaşka bir içerikle gelebileceğini gösterdi. Subresource Integrity (SRI) bunun cevabı: script etiketine dosyanın beklenen hash'ini integrity attribute'u olarak yazarsın ve tarayıcı içerik o hash'e uymuyorsa script'i çalıştırmaz. Mümkünse script'leri kendi sunucundan servis et; edemiyorsan SRI olmadan dışarıdan script yükleme.

Bir de veri tarafında unutulan bütünlük var: istemciye gidip geri dönen değerler. Gizli bir form alanındaki fiyat, bir cookie'deki indirim oranı ya da query string'deki rol bilgisi istemcinin elindedir. Böyle bir değere güvenmen gerekiyorsa sunucuda yeniden hesapla ya da Data Protection API ile koruyup geri geldiğinde doğrula.

Sık Düşülen Tuzaklar

İlk tuzak, SYSLIB0011'i bir uyarı gibi görüp susturmak ve uyumluluk paketini eklemek. Bu, runtime'ın kapattığı kapıyı kendi elinle açmaktır. Hata çıkıyorsa veriyi taşımanın zamanı gelmiş demektir.

İkincisi, "biz BinaryFormatter kullanmıyoruz" deyip bağımlılıkları unutmak. Eski bir cache library'si, bir mesaj kuyruğu istemcisi ya da Newtonsoft'u TypeNameHandling.Auto ile yapılandıran bir paket aynı riski taşır. Kod tabanında TypeNameHandling ara.

Üçüncüsü, DTO yazıp sonra AutoMapper gibi bir araçla DTO'yu entity'ye "her şeyi eşle" ayarıyla kopyalamak. DTO'da fazladan bir property belirdiği anda mass assignment geri döner. Eşlemeyi açık yaz ya da eşleme yapılandırmasını testle doğrula.

Dördüncüsü, aynı DTO'yu hem okuma hem yazma için kullanmak. Cevapta IsAdmin göstermek isteyebilirsin; o alan güncelleme isteğinin DTO'suna sızmamalı. Okuma ve yazma DTO'ları ayrı olsun.

Kontrol Listesi

Kod tabanında ve bağımlılıklarda BinaryFormatter, NetDataContractSerializer, SoapFormatter ya da TypeNameHandling ayarı None olmayan Newtonsoft kullanımı kaldı mı? Polimorfik JSON okuyorsan alt tipler [JsonDerivedType] ile sayılmış mı ve discriminator bir tip adı değil kendi etiketin mi?

Yazma endpoint'lerinin hepsi entity yerine yalnızca değişebilen alanları taşıyan bir DTO mu alıyor? Bilinmeyen alanlar için bilinçli bir karar verdin mi (yok saymak ya da Disallow)? İstemciden dönen fiyat, rol ya da indirim gibi değerler sunucuda yeniden hesaplanıyor mu? Dışarıdan yüklenen script'lerde SRI var mı, pipeline'daki action'lar SHA ile sabit mi, güncellemeler imza doğrulamasıyla mı alınıyor?

Küçük Bir Deneme

★☆☆ PaymentRequest'e üçüncü bir alt tip ekle, örneğin BankTransferPayment, ama [JsonDerivedType] satırını ekleme. Bu tipin etiketiyle gelen gövdenin reddedildiğini gör, sonra satırı ekleyip tekrar dene.

★★☆ Lab'daki /me endpoint'inin cevabını ayrı bir ProfileResponse record'una taşı ve güncelleme DTO'suyla okuma DTO'sunu ayır. Disallow açıkken istemcinin cevapta gördüğü isAdmin alanını geri göndermesinin neden 400 aldığını açıkla.

★★★ Kitap cache'ini System.Text.Json ile diske yazan ve okuyan bir sürüm yaz. Dosyanın değiştirilmediğini anlamak için içeriği Data Protection API ile koru; dosyada tek bir byte'ı değiştirip okumayı denediğinde ne olduğunu gözle.

Bütünlüğü bozulmuş bir isteği reddetmeyi öğrendik. Peki reddettiğimiz isteği kim görüyor? Bir sonraki yazıda A09 Security Logging and Alerting Failures'a geçiyoruz: neyi loglamalı, neyi asla, log injection, structured logging ve logdan alarm üretmek.