TL;DR
Origin classifier'ımız her saldırı denemesini
dbo.AttackProbestablosuna yazıyor;ThreatScorePERSISTED computed kolonuSeverity × Confidence / 20formülüyle 0-25 arası bir puan üretiyor. 49 path pattern, 13 tarama aracı User-Agent imzası ve 12 query string pattern üç ayrı static registry'de tutuluyor. Puanı 11 eşiğini aşan probe, IP'yi otomatik bloklatıyor; aşmayanlar için 15 dakikalık burst sayacı devreye giriyor. Tam şema ve sınıflandırma kodu — canlı sistemden.
Yazar notu: Bu yazıdaki şema, kod ve eşik değerleri bilalkose.com.tr canlı sisteminden alındı. AttackProbes tablosu + üç pattern registry'si + RecordAttackAsync sınıflandırma akışı 2026-05 itibarıyla prod'da çalışıyor. Pillar 2 H2-3'te AttackProbes ve ThreatScore'u yüzeysel geçmiştik; bu yazıda tablo şeması, puanlama formülünün gerekçesi ve üç katmanlı pattern eşleştirme detaylı.
İçindekiler
AttackProbestablosu: 23 kolon + PERSISTED ThreatScore- ThreatScore formülü: neden Severity × Confidence / 20
- Path tabanlı tespit: 49 pattern, 14 kategori
- User-Agent tabanlı tespit: 13 tarama aracı imzası
- Query string pattern: SQLi, XSS, path traversal, Log4Shell
- Sınıflandırma kodu:
RecordAttackAsync+ dedup - Sıkça Sorulan Sorular
- İlgili Yazılar
AttackProbes Tablosu: 23 Kolon + PERSISTED ThreatScore
Pillar 2'de "saldırı denemelerini ayrı bir tabloda topluyoruz" demiştik. O tablo dbo.AttackProbes — her satır bir saldırı probe'u, request bağlamı ve tehdit puanıyla birlikte:
CREATE TABLE dbo.AttackProbes (
Id BIGINT IDENTITY(1,1) NOT NULL,
HitTime DATETIME2(0) NOT NULL CONSTRAINT DF_AttackProbes_HitTime DEFAULT SYSUTCDATETIME(),
DayIso CHAR(10) NOT NULL,
Host NVARCHAR(80) NULL,
Source VARCHAR(20) NOT NULL,
RawIp NVARCHAR(45) NOT NULL,
Country CHAR(2) NULL,
City NVARCHAR(80) NULL,
Asn NVARCHAR(120) NULL,
AsnNumber INT NULL,
Method VARCHAR(10) NOT NULL,
Path NVARCHAR(512) NOT NULL,
QueryString NVARCHAR(1024) NULL,
UserAgent NVARCHAR(512) NULL,
Referer NVARCHAR(512) NULL,
StatusCode SMALLINT NULL,
Category VARCHAR(40) NOT NULL,
Severity TINYINT NOT NULL,
Confidence TINYINT NOT NULL,
ThreatScore AS (CAST(Severity AS INT) * Confidence / 20) PERSISTED,
PatternMatched NVARCHAR(120) NULL,
CloudflareRayId VARCHAR(20) NULL,
CONSTRAINT PK_AttackProbes PRIMARY KEY CLUSTERED (Id),
CONSTRAINT CK_AttackProbes_Severity CHECK (Severity BETWEEN 1 AND 5),
CONSTRAINT CK_AttackProbes_Confidence CHECK (Confidence BETWEEN 1 AND 100)
);
Üç tasarım kararı dikkat çeker. Birincisi ThreatScore bir PERSISTED computed column — Severity × Confidence / 20 her INSERT'te bir kez hesaplanır, diske yazılır, sorguda yeniden hesaplanmaz. Index'lenebilir; uygulama kodu bu kolonu asla elle set etmez, edemez.
İkincisi CHECK constraint'leri: Severity 1-5, Confidence 1-100 aralığında olmak zorunda. Sınıflandırma kodunda bir bug bu aralığı bozarsa INSERT veritabanı seviyesinde reddedilir — bozuk bir puan tabloya hiç giremez.
Üçüncüsü DayIso CHAR(10) — "2026-05-12" formatında bir gün damgası. Günlük raporlama WHERE DayIso = @today ile, HitTime üzerinde tarih fonksiyonu çağırmadan, index dostu çalışır.
Tablo dört non-clustered index taşır: DayIso + Severity (günlük rapor), RawIp + HitTime (tek IP'nin geçmişi), Category + DayIso (kategori dağılımı), ThreatScore + HitTime (en tehlikeli probe'lar). Hepsi INCLUDE ile kapsayıcı — admin dashboard sorguları clustered index'e key lookup yapmaz.
ThreatScore Formülü: Neden Severity × Confidence / 20
Puan iki girdiden türer:
- Severity (1-5) — saldırının teknik ciddiyeti.
/.envdosyası sızdırma denemesi 5;/api-docstaraması 2. - Confidence (1-100) — pattern eşleşmesinin ne kadar kesin bir saldırı olduğu.
/.git/neredeyse her zaman saldırıdır (95);/consolemeşru bir sayfa da olabilir (70).
İkisini ayrı tutmak önemli. Yüksek ciddiyetli ama düşük güvenli bir sinyali (meşru olabilecek bir admin path'i) ile düşük ciddiyetli ama kesin bir sinyali aynı kovaya atmak istemiyoruz. Çarpım ikisini birleştirir, /20 bölme sonucu 0-25 aralığına oturtur.
| Path | Kategori | Severity | Confidence | ThreatScore |
|---|---|---|---|---|
/.env |
secret_disclosure | 5 | 95 | 23 |
/xmlrpc.php |
wordpress_probe | 4 | 90 | 18 |
/wp-admin |
wordpress_probe | 3 | 85 | 12 |
/console |
admin_panel_probe | 3 | 70 | 10 |
/api-docs |
api_doc_probe | 2 | 65 | 6 |
Otomatik blok eşiği 11. /wp-admin (12) eşiği geçer, /console (10) geçmez. Burada bir integer-division inceliği var: 3 × 70 / 20 matematiksel olarak 10.5, ama SQL tamsayı aritmetiğinde 10. Eşiği 11 seçmek bu kesirli sınır bölgesini bilinçli olarak blok-dışı bırakır — tek bir /console isteği IP'yi bloklamaz. Eşiği 12'ye koysaydık 3 × 80 / 20 = 12 gibi gerçek saldırı kalıpları sınırda kaçabilirdi; 11 doğru kesme noktası. Eşiği geçemeyen probe'lar yok sayılmaz — 15 dakikalık burst sayacına düşer (cluster C3 konusu).
Path Tabanlı Tespit: 49 Pattern, 14 Kategori
Tespitin ilk hattı, istenen path'i bilinen saldırı kalıplarına karşı eşlemek. Registry middleware'de static bir array — yani uygulama ömrü boyunca tek allocation, her istekte yeniden kurulmaz:
private static readonly (string Pattern, string Category, byte Severity, byte Confidence)[] AttackPaths =
[
// Sır / dosya açığa çıkarma — kritik
("/.env", "secret_disclosure", 5, 95),
("/.aws", "cloud_creds_probe", 5, 95),
("/.ssh", "secret_disclosure", 5, 95),
("/.htpasswd", "secret_disclosure", 5, 90),
// VCS açığa çıkarma
("/.git/", "vcs_disclosure", 4, 95),
("/.svn/", "vcs_disclosure", 4, 90),
// WordPress probe
("/wp-admin", "wordpress_probe", 3, 85),
("/wp-login", "wordpress_probe", 3, 85),
("/xmlrpc.php", "wordpress_probe", 4, 90),
// PhpMyAdmin / Exchange
("/phpmyadmin", "phpmyadmin_probe", 4, 90),
("/owa/", "exchange_probe", 4, 85),
("/autodiscover","exchange_probe", 4, 85),
// RCE — en yüksek ciddiyet
("/cmd.exe", "rce_pattern", 5, 95),
// ... toplam 49 pattern, 14 kategori
];
Kategoriler 14 aileye ayrılır: secret_disclosure, cloud_creds_probe, vcs_disclosure, wordpress_probe, phpmyadmin_probe, admin_panel_probe, exchange_probe, backup_probe, vendor_probe, rce_pattern, cgi_probe, oidc_probe, actuator_probe, api_doc_probe. Her aile aynı tip varlığı hedefler — wordpress_probe, WordPress hiç kurmadığımız halde gelen /wp-admin, /wp-login, /xmlrpc.php isteklerini; exchange_probe, ortada bir Exchange sunucusu sanan /owa/ ve /autodiscover taramalarını toplar.
Confidence değerlerinin neden farklı olduğu kritik. /.git/ 95 puan alır çünkü meşru bir tarayıcı bu path'i hiç istemez; /wp-admin 85 çünkü teorik olarak yanlış bir linkten gelinebilir; /console ise yalnızca 70 çünkü gerçekten bir konsol sayfası olabilir. Confidence, false-positive maliyetini formülün içine gömer — şüphe payı yüksek olan path daha düşük puan üretir.
User-Agent Tabanlı Tespit: 13 Tarama Aracı İmzası
Path temiz olsa bile User-Agent saldırganı ele verebilir. İkinci registry, tarama araçlarının UA imzalarını tutar:
private static readonly (string UaPattern, string Category, byte Severity, byte Confidence)[] AttackTools =
[
("sqlmap", "tool_sqli_scan", 5, 98),
("nuclei", "tool_nuclei_scan", 4, 95),
("nmap", "tool_port_scan", 3, 90),
("masscan", "tool_port_scan", 3, 90),
("nikto", "tool_dir_brute", 4, 95),
("dirbuster", "tool_dir_brute", 4, 95),
("gobuster", "tool_dir_brute", 4, 92),
("ffuf", "tool_dir_brute", 4, 90),
("wpscan", "tool_wp_scan", 3, 88),
("censys", "tool_recon", 2, 75),
("shodan", "tool_recon", 2, 75),
// ... toplam 13 araç
];
sqlmap 98 confidence ile listenin en kesini — kimse bir SQL injection aracının adını User-Agent'ına kaza eseri yazmaz. nikto, dirbuster, gobuster, ffuf dizin brute-force araçları, hepsi 90+ confidence. Diğer uçta censys ve shodan: sadece 75 confidence ve severity 2. Bunlar internet-geneli envanter tarama servisleri — doğrudan saldırı değil keşif. Düşük puanla işaretlenir ama tek başına blok tetiklemezler.
Burada bir incelik var: UA imzası contains mantığıyla aranır ve karşılaştırma kasıtlı olarak küçük harfe indirilir. B1'de Cloudflare WAF DSL'inin contains operatörünün case-sensitive olduğu tuzağını anlatmıştık — Sqlmap yazan bir UA'nın sqlmap imzasını kaçırmaması için origin tarafında aynı hataya düşmüyoruz.
Query String Pattern: SQLi, XSS, Path Traversal, Log4Shell
Üçüncü registry payload'a bakar — query string'in içindeki saldırı imzaları:
private static readonly (string Pattern, string Category, byte Severity, byte Confidence)[] QueryPatterns =
[
("union select", "sqli_pattern", 5, 92),
("' or '1'='1", "sqli_pattern", 5, 95),
("'; drop ", "sqli_pattern", 5, 98),
("../../", "path_traversal", 5, 92),
("/etc/passwd", "path_traversal", 5, 98),
("<script", "xss_in_url", 4, 88),
("javascript:", "xss_in_url", 4, 80),
("${jndi:", "log4shell", 5, 99),
("system(", "rce_pattern", 5, 90),
("eval(", "rce_pattern", 5, 88),
// ... toplam 12 pattern
];
${jndi: 99 confidence ile tüm tablonun en yüksek puanı — 2021 Log4Shell imzası, meşru hiçbir trafik bu string'i taşımaz. SQLi pattern'leri (union select, ' or '1'='1, '; drop ) klasik injection denemeleri; ../../ ve /etc/passwd directory traversal kalıpları. Hepsi severity 4-5 alır, çünkü payload seviyesinde saldırı niyeti açıkça görünür — path tahmininden farklı olarak burada şüphe payı yoktur.
Üç registry birlikte katmanlı bir filtre kurar: path temizse User-Agent'a, UA da temizse query string'e bakılır. Bir istek üç katmandan da temiz geçerse meşru sayılır ve PageHits tablosuna (cluster C1 konusu) yazılır — saldırı değil, ziyaretçi.
Sınıflandırma Kodu: RecordAttackAsync + Dedup
Üç registry bir eşleşme verdiğinde RecordAttackAsync devreye girer. İlk işi dedup:
private async Task RecordAttackAsync(HttpContext ctx, string ip, Classification c)
{
var path = ctx.Request.Path.Value ?? "/";
var category = c.Category ?? "unknown";
// 5 dakikada aynı IP+path+category bir kez yazılır
// (saldırı tarayıcılarında binlerce DB satırı oluşmasını engeller)
var dedupKey = $"{AttackDedupPrefix}{ip}:{path}:{category}";
if (_cache.TryGetValue(dedupKey, out _)) return;
_cache.Set(dedupKey, true, AttackDedupTtl);
// ... request bağlamı toplanır: GeoIP ülke/şehir/ASN, UA, referer, CF-Ray
// ... parametreli INSERT INTO dbo.AttackProbes (...)
Bir tarama aracı saniyede onlarca path dener; dedup olmasa tek bir saldırgan dakikada binlerce satır üretirdi. IP + path + category üçlüsü 5 dakika cache'lenir — aynı kombinasyon penceresi içinde yalnız tek satır yazılır. Dedup'tan sonra request bağlamı toplanır (GeoIP'den ülke/şehir/ASN, User-Agent, referer, Cloudflare'in CF-Ray header'ı) ve satır parametreli bir sorguyla INSERT edilir. Category, Severity, Confidence sınıflandırmadan gelir; ThreatScore'u veritabanı hesaplar.
INSERT'ten sonra otomatik blok kararı verilir:
// Auto-block — ThreatScore eşiği aşıldıysa IpBlocklist'e ekle
var threatScore = c.Severity * c.Confidence / 20;
if (threatScore >= AutoBlockThreshold)
{
await UpsertBlocklistAsync(ip, threatScore, category, c.PatternMatched);
return;
}
// Burst detector — düşük skorlu probe'lar bile 15 dk'da 3+ olursa otomatik block
var burstCount = await IncrementBurstCounterAsync(ip, category);
if (burstCount >= AttackBurstThreshold)
{
await UpsertBlocklistAsync(ip, BurstFakeThreatScore, "attack_burst",
$"{burstCount} probes/{(int)BurstWindow.TotalMinutes}min, last={category}");
await ResetBurstCounterAsync(ip);
}
}
İki yol vardır. ThreatScore tek başına 11'i aşarsa IP doğrudan bloklanır. Aşmazsa burst sayacı artar — düşük puanlı ama ısrarlı bir tarayıcı 15 dakikada üç probe yaparsa, tek tek hiçbiri eşiği geçmese bile toplu davranış bloklatır. UpsertBlocklistAsync IP'yi dbo.IpBlocklist'e yazar ve B2 cluster'ında anlattığımız fire-and-forget Cloudflare push'unu tetikler — yani tespit origin'de, blok edge'de gerçekleşir. Buradaki threatScore hesabı kodda tekrar görünür; bu yalnızca blok kararını INSERT tamamlanmadan verebilmek içindir — tabloya yazılan değer her zaman DB'nin PERSISTED hesabıdır.
Sıkça Sorulan Sorular
ThreatScore neden uygulama kodunda değil veritabanında hesaplanıyor?
PERSISTED computed column, formülü tek bir yere — şema tanımına — sabitler. İster middleware'in canlı INSERT'i, ister bir ETL backfill, ister elle bir kayıt; hangi yoldan girerse girsin ThreatScore aynı formülle üretilir. Kod tarafında ayrıca c.Severity * c.Confidence / 20 hesabını görürsün, ama bu yalnızca blok kararını INSERT'ten önce verebilmek içindir.
Severity 1-5, Confidence 1-100 — neden farklı aralıklar?
Severity kaba bir sınıf; beş kademe yeter (bilgi sızdırma mı, RCE mi). Confidence ince ayar ister — /wp-admin ile /wp-login ikisi de 85, ama /console 70 farkı önemli. 100'lük ölçek bu nüansı taşıyabilir, 5'lik ölçek taşıyamazdı. /20 böleni ikisini ortak 0-25 ölçeğine indirir.
49 path pattern saldırıların hepsini yakalar mı?
Hayır — yakalaması amaç da değil. Registry, en sık görülen otomatik tarama kalıplarını hedefler; gerçek prod'da gelen probe'ların büyük çoğunluğu bu listede. Hedefli, elle yapılan bir saldırı pattern'e uymayabilir. Onun için ThreatScore'a ek olarak burst sayacı ve (Pillar 2'de anlatılan) IsSuspectNetwork katmanı var — tek registry değil, katmanlı savunma.
Censys ve Shodan gibi tarayıcılar neden bloklanmıyor?
Severity 2, Confidence 75 → ThreatScore 7, yani 11 eşiğinin altında. Bunlar internet-geneli envanter servisleri; doğrudan bir saldırı değil keşif. İşaretlenir ve raporlanır ama tek başına blok tetiklemezler. Israrla tararlarsa 15 dakikalık burst sayacı yine de devreye girer.
İlgili Yazılar
- Pillar 2 — K8s'te .NET Security Telemetry: Gerçek Ziyaretçi Tespiti — bu cluster'ın bağlı olduğu pillar; AttackProbes + ThreatScore bölümünün derinleştirmesidir.
- C1 — PageHits Middleware: ASP.NET Core'da Minimal Allocation İstek Sayımı — saldırı olmayan trafiğin sayıldığı taraf; aynı middleware pipeline'ı.
- Cross-pillar — Cloudflare Edge Security ve .NET: K8s'te WAF + IP Blocklist — ThreatScore eşiğini aşan IP'nin edge'de bloklanması.
Yorumlar (0)
Henüz yorum yapılmamış. İlk yorumu sen bırak.