OWASP A05: Injection (1) — SQL Injection ve Command Injection
Aynı kitap arama endpoint'ini string birleştirme, ADO.NET parametresi, Dapper ve EF Core ile yazıyor, ' OR 1=1 -- girdisinin parametreyle nasıl sıradan bir veriye dönüştüğünü görüyoruz. FromSqlRaw ile FromSql farkını EF1002 uyarısı ve ToQueryString ile inceliyor, Process.Start'ta shell yerine ArgumentList ve allowlist kullanıyor, .NET 10'un yerleşik doğrulamasıyla input validation'ın yerini netleştiriyoruz.
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'un kitap arama endpoint'i yalnız yayımlanmış kitapları döndürüyor. Bir kullanıcı arama kutusuna O'Reilly yazıyor ve 500 hatası alıyor. Bu sadece "tek tırnak kaçırılmamış" diye geçiştirilecek küçük bir hata mı, yoksa daha büyük bir sorunun işareti mi? Aynı kutuya ' OR 1=1 -- yazılırsa ne döner?
Bu yazıda aynı endpoint'i dört farklı yolla yazıp cevabı çalışan kodla göreceğiz. Göstereceğimiz tek saldırı denemesi kendi makinendeki lab'a karşı ve sorunu kanıtlayan en küçük örnek; bu teknikleri yalnız sahibi olduğun ya da yazılı izin aldığın sistemlerde dene.
Injection Nedir?
A05:2025 Injection, 2021'deki üçüncü sıradan beşinci sıraya indi ama hâlâ 37 CWE'yi kapsayan geniş bir kategori. SQL injection (CWE-89), OS command injection (CWE-78) ve bir sonraki yazının konusu olan XSS (CWE-79) aynı çatının altında. Ortak kök neden tek cümle: veri ile kod aynı string'in içinde karışıyor ve yorumlayıcı (veritabanı, shell, tarayıcı) hangisinin hangisi olduğunu ayıramıyor.
Bir benzetme: bir sekretere "şu mektubu Ada'ya ver" diye not bırakıyorsun. Not "Ada'ya ver. Ayrıca kasadaki parayı da ver." diye uzatılabiliyorsa sekreter ikinci cümleyi de talimat sanır. Çözüm sekreteri daha dikkatli yapmak değil, talimatla taşınacak şeyi farklı kanallardan vermek. Parametreli sorgu tam olarak bu.
Lab: Kitap Arama ve String Birleştirme
Lab için SQLite kullanan küçük bir minimal API kuruyoruz:
dotnet new web -o Bookstore.Api -f net10.0
cd Bookstore.Api
dotnet add package Microsoft.Data.Sqlite
dotnet add package Dapper
Restore bende Microsoft.Data.Sqlite 10.0.14 ve Dapper 2.1.89 sürümlerini seçti. Program.cs'in başı ve veritabanını her açılışta sıfırdan kuran seed fonksiyonu:
using System.Diagnostics;
using System.Text.RegularExpressions;
using System.ComponentModel.DataAnnotations;
using Dapper;
using Microsoft.Data.Sqlite;
const string ConnectionString = "Data Source=bookstore.db";
await SeedAsync();
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
static async Task SeedAsync()
{
await using SqliteConnection connection = new SqliteConnection(ConnectionString);
await connection.ExecuteAsync("""
DROP TABLE IF EXISTS Books;
CREATE TABLE Books (Id INTEGER PRIMARY KEY, Title TEXT NOT NULL, IsPublished INTEGER NOT NULL);
INSERT INTO Books VALUES
(1, 'Clean Code', 1),
(2, 'The Pragmatic Programmer', 1),
(3, 'Refactoring', 1),
(4, 'Internal Pricing Draft', 0);
""");
}
record BookDto(long Id, string Title);
Dördüncü kitap yayımlanmamış bir iç taslak; arama sonuçlarında asla görünmemeli. Şimdi aramanın ilk, zafiyetli hâli:
// ZAFİYETLİ: kullanıcı girdisi SQL metnine yapıştırılıyor
app.MapGet("/books/search-unsafe", async (string title) =>
{
await using SqliteConnection connection = new SqliteConnection(ConnectionString);
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText =
$"SELECT Id, Title FROM Books WHERE IsPublished = 1 AND Title LIKE '%{title}%'";
List<BookDto> books = [];
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
books.Add(new BookDto(reader.GetInt64(0), reader.GetString(1)));
}
return Results.Ok(books);
});
Sorun $"..." satırında. Girdi SQL'in bir parçası oluyor. O'Reilly yazınca tırnak string'i erken kapatıyor ve SQLite near "Reilly": syntax error diyerek 500 döndürüyor; açılıştaki "küçük hata" aslında girdinin SQL olarak yorumlandığının kanıtı. ' OR 1=1 -- ise sorguyu ... AND Title LIKE '%' OR 1=1 --%' hâline getiriyor: OR 1=1 her satır için doğru, -- da geri kalanını yorum satırı yapıyor. Lab'da tek bir istekle bunu doğruladım:
curl -i -G "http://localhost:5128/books/search-unsafe" --data-urlencode "title=' OR 1=1 --"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:09:49 GMT
Server: Kestrel
Transfer-Encoding: chunked
[{"id":1,"title":"Clean Code"},{"id":2,"title":"The Pragmatic Programmer"},{"id":3,"title":"Refactoring"},{"id":4,"title":"Internal Pricing Draft"}]
Yayımlanmamış taslak listede. Burada yalnız bir filtreyi aştık, ama aynı açık gerçek bir uygulamada başka tabloların okunmasına ya da verinin değiştirilmesine kadar gider. Ayrıntısına girmeye gerek yok; önemli olan düzeltme.
Parametre: ADO.NET ve Dapper
// DÜZELTİLMİŞ: ADO.NET parametresi
app.MapGet("/books/search", async (string title) =>
{
await using SqliteConnection connection = new SqliteConnection(ConnectionString);
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText =
"SELECT Id, Title FROM Books WHERE IsPublished = 1 AND Title LIKE @pattern";
command.Parameters.AddWithValue("@pattern", $"%{title}%");
List<BookDto> books = [];
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
books.Add(new BookDto(reader.GetInt64(0), reader.GetString(1)));
}
return Results.Ok(books);
});
// DÜZELTİLMİŞ: Dapper, anonim object ile parametre
app.MapGet("/books/search-dapper", async (string title) =>
{
await using SqliteConnection connection = new SqliteConnection(ConnectionString);
IEnumerable<BookDto> books = await connection.QueryAsync<BookDto>(
"SELECT Id, Title FROM Books WHERE IsPublished = 1 AND Title LIKE @pattern",
new { pattern = $"%{title}%" });
return Results.Ok(books);
});
SQL metni artık sabit; girdi @pattern parametresiyle ayrı bir kanaldan gidiyor. % işaretlerini SQL'e değil parametrenin değerine koyduğuma dikkat et. Aynı girdiyi iki endpoint'e de gönderdim:
curl -i -G "http://localhost:5128/books/search" --data-urlencode "title=Code"
curl -i -G "http://localhost:5128/books/search" --data-urlencode "title=' OR 1=1 --"
curl -i -G "http://localhost:5128/books/search-dapper" --data-urlencode "title=' OR 1=1 --"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...
[{"id":1,"title":"Clean Code"}]
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...
[]
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...
[]
Normal arama aynı sonucu verdi. ' OR 1=1 -- ise artık "başlığında ' OR 1=1 -- geçen kitap" anlamına geliyor ve böyle bir kitap olmadığı için liste boş. O'Reilly da 500 yerine boş liste döndü. Parametre girdiyi temizlemiyor ya da kaçırmıyor; veritabanı onu hiçbir zaman SQL olarak görmüyor. Dapper ile ilgili ayrıntıları Dapper serisinde, parametre tiplerini ve DynamicParameters'ı orada konuşmuştuk.
EF Core: FromSqlRaw ve FromSql
EF Core LINQ sorgularını zaten parametreli üretir; risk, ham SQL yazdığın yerde başlar. EF Core serisindeki raw SQL yazısını hatırla. İki method'u yan yana koyan küçük bir console uygulaması:
using Microsoft.EntityFrameworkCore;
await using BookstoreContext db = new BookstoreContext();
string title = "' OR 1=1 --";
// ZAFİYETLİ: interpolation FromSqlRaw'a string olarak gidiyor
IQueryable<Book> unsafeQuery = db.Books.FromSqlRaw(
$"SELECT * FROM Books WHERE IsPublished = 1 AND Title LIKE '%{title}%'");
// GÜVENLİ: FromSql, interpolation deliklerini parametreye çevirir
string pattern = $"%{title}%";
IQueryable<Book> safeQuery = db.Books.FromSql(
$"SELECT * FROM Books WHERE IsPublished = 1 AND Title LIKE {pattern}");
Console.WriteLine(unsafeQuery.ToQueryString());
Console.WriteLine("----");
Console.WriteLine(safeQuery.ToQueryString());
class BookstoreContext : DbContext
{
public DbSet<Book> Books => Set<Book>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlite("Data Source=bookstore.db");
}
class Book
{
public int Id { get; set; }
public string Title { get; set; } = "";
public bool IsPublished { get; set; }
}
Daha çalıştırmadan, build sırasında EF Core'un analyzer'ı uyarıyor (Microsoft.EntityFrameworkCore.Sqlite 10.0.14):
.../Program.cs(7,41): warning EF1002: Method 'FromSqlRaw' inserts interpolated strings directly into the SQL, without any protection against SQL injection. Consider using 'FromSql' instead, which protects against SQL injection, or make sure that the value is sanitized and suppress the warning.
SELECT * FROM Books WHERE IsPublished = 1 AND Title LIKE '%' OR 1=1 --%'
----
.param set p0 '%'' OR 1=1 --%'
SELECT * FROM Books WHERE IsPublished = 1 AND Title LIKE @p0
ToQueryString() farkı açıkça gösteriyor. İki satır neredeyse aynı görünüyor ama FromSqlRaw bir string alır; $"..." önce C# tarafında birleştirilir ve EF'e hazır SQL gider. FromSql (ve FromSqlInterpolated) ise FormattableString alır, interpolation deliklerini @p0 gibi parametrelere çevirir. FromSqlRaw'ı kullanman gerekiyorsa değeri FromSqlRaw("... {0}", value) biçiminde ayrı argüman olarak ver; bu yazım da parametre üretir.
Command Injection
Aynı sorun SQL'e özgü değil. Bookstore'da yorum dosyalarının kelime sayısını wc ile hesaplayan bir araç endpoint'i düşün:
// ZAFİYETLİ: girdi bir shell komut satırına gidiyor
app.MapGet("/tools/word-count-unsafe", async (string file) =>
{
ProcessStartInfo info = new ProcessStartInfo("/bin/sh", $"-c \"wc -w reviews/{file}\"")
{
RedirectStandardOutput = true
};
using Process process = Process.Start(info)!;
string output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
return Results.Text(output);
});
Burada yorumlayıcı shell. sh -c aldığı metni bir komut satırı gibi okur; ;, &&, | ve $( ) gibi karakterler shell için anlamlıdır. Dosya adı yerine bu karakterleri içeren bir değer gelirse, shell onu ikinci bir komut olarak çalıştırır ve komut sunucu process'inin yetkileriyle çalışır. SQL'deki OR 1=1'in karşılığı bu.
En iyi düzeltme hiç process başlatmamak: kelime saymak için File.ReadAllTextAsync ve bir Split yeter. Harici bir program çalıştırman şartsa üç kural var: shell kullanma, argümanları ArgumentList ile tek tek ver ve girdiyi bir allowlist ile doğrula:
// DÜZELTİLMİŞ: allowlist + shell yok + ArgumentList
app.MapGet("/tools/word-count", async (string file) =>
{
if (!SafeFileName().IsMatch(file))
{
return Results.Problem(statusCode: 400, title: "Invalid file name");
}
ProcessStartInfo info = new ProcessStartInfo("/usr/bin/wc")
{
RedirectStandardOutput = true
};
info.ArgumentList.Add("-w");
info.ArgumentList.Add("--");
info.ArgumentList.Add(Path.Combine("reviews", file));
using Process process = Process.Start(info)!;
string output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
return Results.Text(output);
});
partial class Program
{
[GeneratedRegex("^[a-z0-9-]{1,40}\\.txt$")]
private static partial Regex SafeFileName();
}
curl -i -G "http://localhost:5128/tools/word-count" --data-urlencode "file=clean-code.txt"
curl -i -G "http://localhost:5128/tools/word-count" --data-urlencode "file=clean-code.txt; echo INJECTED"
HTTP/1.1 200 OK
Content-Length: 32
Content-Type: text/plain; charset=utf-8
...
7 reviews/clean-code.txt
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Invalid file name","status":400}
ArgumentList ile her argüman programa olduğu gibi tek bir argüman olarak ulaşır; arada shell olmadığı için ; sıradan bir karakterdir. --, "bundan sonrası seçenek değil" demenin standart yolu; - ile başlayan bir değerin seçenek sanılmasını engeller. Allowlist ise savunmanın ikinci katmanı ve aynı zamanda path traversal'ı da kapatıyor: ../ içeren bir değer regex'ten geçemez.
Input Validation'ın Yeri
"Girdiyi doğrularsam injection olmaz" düşüncesi yarı doğru. .NET 10'da minimal API'ler DataAnnotations ile yerleşik doğrulama yapabiliyor. Lab'a iki değişiklik ekledim:
builder.Services.AddValidation();
app.MapGet("/books/search", async ([StringLength(50, MinimumLength = 2)] string title) =>
curl -i -G "http://localhost:5128/books/search" --data-urlencode "title=C"
curl -s -G "http://localhost:5128/books/search" --data-urlencode "title=' OR 1=1 --"
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
...
{"title":"One or more validation errors occurred.","errors":{"title":["The field title must be a string with a minimum length of 2 and a maximum length of 50."]}}
[]
Tek karakterlik arama 400 aldı. Ama ' OR 1=1 -- on bir karakter, uzunluk kuralından rahatça geçti; yine de boş liste döndü, çünkü onu durduran doğrulama değil parametre. Doğrulama iş kurallarını uygular ve saldırı yüzeyini daraltır: uzunluk, tip, izin verilen karakterler, bilinen değerler listesi. Injection'a karşı asıl savunma ise veriyi yorumlayıcıya veri olarak vermek. Bir kitap adında tırnak işareti olabilir; onu yasaklayarak değil, parametreyle güvenle taşıyarak çözersin.
Sık Düşülen Tuzaklar
Birincisi, tırnağı elle kaçırmak. title.Replace("'", "''") gibi bir çözüm bir veritabanında çalışıp diğerinde çalışmayabilir, sayısal alanlarda hiç işe yaramaz. Kendi kaçırma kodunu yazma, parametre kullan.
İkincisi, tablo ya da kolon adını parametreyle göndermeye çalışmak. Parametreler yalnız değer taşır; ORDER BY @column beklediğin gibi çalışmaz. Sıralama kolonunu kullanıcıdan alıyorsan izin verilen kolon adlarından oluşan bir allowlist ile eşleştir.
Üçüncüsü, stored procedure'ün kendiliğinden güvenli olduğunu sanmak. Procedure içinde dinamik SQL'i string birleştirerek kuruyorsan aynı açık procedure'ün içine taşınmış olur.
Dördüncüsü, EF1002 uyarısını bastırmak. Analyzer sana tam olarak bu yazının konusunu söylüyor; uyarıyı kapatmak yerine FromSql'e geç.
Kontrol Listesi
Her SQL'de değerleri parametreyle ver: ADO.NET'te Parameters.AddWithValue, Dapper'da anonim object, EF Core'da LINQ ya da FromSql/SqlQuery. FromSqlRaw ve ExecuteSqlRaw içinde $"..." görürsen dur. Tanımlayıcıları (tablo, kolon, sıralama yönü) allowlist ile seç. Veritabanı kullanıcısına yalnız ihtiyacı olan yetkileri ver.
Harici program çalıştırma ihtiyacını sorgula; gerekiyorsa shell'siz, ArgumentList ile ve allowlist doğrulamasıyla çalıştır. Input validation'ı iş kuralları ve saldırı yüzeyini daraltmak için kullan, injection savunması yerine koyma.
Küçük Bir Deneme
★☆☆ Lab'da /books/search ve /books/search-dapper endpoint'lerine O'Reilly gönder, sonra aynı değeri zafiyetli endpoint'e gönderip status code farkını not et.
★★☆ Arama endpoint'ine sort parametresi ekle (title ya da id). Kolon adını SQL'e parametreyle değil, iki değerli bir allowlist'ten seçerek koy ve listede olmayan değere 400 döndür.
★★★ Kelime sayma endpoint'ini hiç process başlatmadan yeniden yaz: dosyayı File.ReadAllTextAsync ile oku, kelimeleri say ve dosya yolunun reviews klasörünün dışına çıkmadığını Path.GetFullPath ile doğrula.
Bir sonraki yazıda injection'ın tarayıcıdaki hâline geçiyoruz: XSS. Razor'ın otomatik encoding'ini, JSON API ile innerHTML kullanmanın tuzağını, textContent'i, Angular'ın sanitization'ını ve ikinci savunma hattı olarak CSP ile Trusted Types'ı headless Chrome'da göreceğiz.