AttackProbes and ThreatScore: Scoring Attack Probes in .NET 10

0 comments 439 views

Cybersecurity dotnet ASP.NET Core SQL Server Middleware Telemetry Security

13 min read 2534 words

TL;DR

Our origin classifier writes every attack attempt into the dbo.AttackProbes table; the ThreatScore persisted computed column produces a 0-25 score from the formula Severity × Confidence / 20. Forty-nine path patterns, thirteen scanner User-Agent signatures and twelve query-string patterns live in three separate static registries. A probe whose score crosses the threshold of 11 gets the IP auto-blocked; the ones below it fall to a 15-minute burst counter. The full schema and the classification code are below — straight from the live system.

Author's note: The schema, code and threshold values here come from the live bilalkose.com.tr system. The AttackProbes table, the three pattern registries and the RecordAttackAsync classification flow have run in production since May 2026. Pillar 2 covered AttackProbes and ThreatScore only briefly. This article covers the table schema, the reasoning behind the scoring formula and the three-layer pattern matching in detail.


Contents

  1. The AttackProbes Table: 23 Columns + a Persisted ThreatScore
  2. The ThreatScore Formula: Why Severity × Confidence / 20
  3. Path-Based Detection: 49 Patterns, 14 Categories
  4. User-Agent-Based Detection: 13 Scanner Signatures
  5. Query-String Patterns: SQLi, XSS, Path Traversal, Log4Shell
  6. The Classification Code: RecordAttackAsync + Dedup
  7. Frequently Asked Questions
  8. Related Articles

ThreatScore ≥ 11 ThreatScore < 11 Incoming Request Path Registry /wp-login, /.env, /.git User-Agent Registry sqlmap, nikto, scanners Query Registry SQLi / XSS signatures Match produces category + severity (1–5) + confidence (1–100) AttackProbes — INSERT row DB computes ThreatScore IpBlocklist IP auto-blocked at the edge Burst Counter reported, watched for repeats AttackProbes → ThreatScore: every request scored, only real threats blocked
AttackProbes ThreatScore pipeline: an incoming request passes through three registries (path, User-Agent, query string), a match produces a category plus severity plus confidence, the row is INSERTed into AttackProbes, the DB computes the ThreatScore, and if the threshold of 11 is crossed the IP goes to IpBlocklist, otherwise to the burst counter


The AttackProbes Table: 23 Columns + a Persisted ThreatScore

Pillar 2 said we collect attack attempts in a separate table. That table is dbo.AttackProbes — one row per attack probe, stored together with the request context and a threat score:

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)
);

Three design decisions stand out. The first: ThreatScore is a persisted computed column — Severity × Confidence / 20 is calculated once per INSERT, written to disk, never recomputed at query time. It can be indexed, and application code never sets the column by hand, nor can it.

The second: the CHECK constraints. Severity has to sit between 1 and 5, Confidence between 1 and 100. If a bug in the classification code breaks that range, the INSERT is rejected at the database level — a broken score never enters the table at all.

The third: DayIso CHAR(10) — a day stamp in the form "2026-05-12". Daily reporting runs on that column with WHERE DayIso = @today, index-friendly, without calling a date function over HitTime.

The table carries four non-clustered indexes: DayIso + Severity for the daily report, RawIp + HitTime for a single IP's history, Category + DayIso for the category breakdown, and ThreatScore + HitTime for the most dangerous probes. All four are covering with INCLUDE, so the admin dashboard queries never make a key lookup into the clustered index.


The ThreatScore Formula: Why Severity × Confidence / 20

The score derives from two inputs:

  • Severity (1-5) — the technical seriousness of the attack. A /.env file disclosure attempt is a 5; an /api-docs scan is a 2.
  • Confidence (1-100) — how certain it is that a pattern match really is an attack. A /.git/ request is almost always an attack (95); a /console request could be a legitimate page (70).

Keeping the two apart matters. We do not want to drop a high-severity but low-confidence signal — a path that might be a legitimate admin route — into the same bucket as a low-severity but certain one. The product joins them, and dividing by 20 lands the result in a 0-25 range.

Path Category 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

The auto-block threshold is 11. /wp-admin clears it at 12; /console does not at 10. There is an integer-division subtlety here: 3 × 70 / 20 is 10.5 in plain arithmetic, but 10 in SQL integer arithmetic. Choosing 11 as the threshold deliberately leaves that fractional border zone outside the block — a single /console request will not block an IP. Set the threshold at 12 and genuine attack patterns like 3 × 80 / 20 = 12 could slip through on the edge; 11 is the right cut point. Probes that fail to clear the threshold are not ignored — they fall to the 15-minute burst counter (the subject of cluster C3).

ThreatScore = (Severity × Confidence) / 20 integer division — result clamped to the 0–25 range report only auto-block BLOCK THRESHOLD = 11 0 5 10 15 20 25 10–11 border zone — the integer-division edge Worked examples Severity 5 × Confidence 80 = 400 → 400 / 20 = 20BLOCK Severity 3 × Confidence 70 = 210 → 210 / 20 = 10report only (border) Severity 2 × Confidence 50 = 100 → 100 / 20 = 5report only
The ThreatScore scale: a Severity 1-5 axis times a Confidence 1-100 axis, the product divided by 20 lands in the 0-25 range; below the threshold of 11 a probe is only reported, above it the IP is auto-blocked, and the 10-11 border zone is highlighted as the integer-division edge


Path-Based Detection: 49 Patterns, 14 Categories

The first line of detection is matching the requested path against known attack patterns. The registry is a static array in the middleware — a single allocation for the lifetime of the app, not rebuilt on every request:

private static readonly (string Pattern, string Category, byte Severity, byte Confidence)[] AttackPaths =
[
    // Secret / file disclosure — critical
    ("/.env",        "secret_disclosure", 5, 95),
    ("/.aws",        "cloud_creds_probe", 5, 95),
    ("/.ssh",        "secret_disclosure", 5, 95),
    ("/.htpasswd",   "secret_disclosure", 5, 90),
    // VCS disclosure
    ("/.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 — highest severity
    ("/cmd.exe",     "rce_pattern",       5, 95),
    // ... 49 patterns, 14 categories in total
];

The categories split into 14 families: 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 and api_doc_probe. Each family targets the same kind of asset — wordpress_probe collects the /wp-admin, /wp-login and /xmlrpc.php requests that arrive even though we never installed WordPress; exchange_probe collects the /owa/ and /autodiscover scans that assume there is an Exchange server in the way.

Why the confidence values differ is the critical part. /.git/ scores 95 because a legitimate browser never asks for that path; /wp-admin scores 85 because in theory you could arrive from a wrong link; /console scores only 70 because it really could be a console page. Confidence bakes the cost of a false positive straight into the formula — a path with more room for doubt produces a lower score.


User-Agent-Based Detection: 13 Scanner Signatures

Even when the path is clean, the User-Agent can give the attacker away. The second registry holds the UA signatures of scanning tools:

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),
    // ... 13 tools in total
];

sqlmap is the most certain entry on the list at 98 confidence — nobody writes the name of a SQL injection tool into their User-Agent by accident. nikto, dirbuster, gobuster and ffuf are directory brute-force tools, all above 90 confidence. At the other end sit censys and shodan: just 75 confidence and severity 2. Those are internet-wide inventory scanners — reconnaissance, not a direct attack. They get flagged with a low score but will not trigger a block on their own.

There is a subtlety here: the UA signature is searched with a contains match, and the comparison is lowercased on purpose. B1 covered the trap of the Cloudflare WAF DSL's contains operator being case-sensitive — to keep a UA that writes Sqlmap from missing the sqlmap signature, we do not repeat that mistake on the origin side.


Query-String Patterns: SQLi, XSS, Path Traversal, Log4Shell

The third registry looks at the payload — the attack signatures inside the query string:

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),
    // ... 12 patterns in total
];

${jndi: is the single highest score in the whole table at 99 confidence — the 2021 Log4Shell signature, which no legitimate traffic ever carries. The SQLi patterns (union select, ' or '1'='1, '; drop ) are classic injection attempts; ../../ and /etc/passwd are directory-traversal patterns. They all take severity 4 or 5, because at the payload level the intent of an attack is plain to see — unlike a path guess, there is no room for doubt here.

The three registries together build a layered filter: if the path is clean, the User-Agent is checked; if the UA is clean too, the query string is checked. A request that comes through all three layers clean is treated as legitimate and written to PageHits (the subject of cluster C1) — a visitor, not an attack.


The Classification Code: RecordAttackAsync + Dedup

When the three registries produce a match, RecordAttackAsync takes over. Its first job is dedup:

private async Task RecordAttackAsync(HttpContext ctx, string ip, Classification c)
{
    var path = ctx.Request.Path.Value ?? "/";
    var category = c.Category ?? "unknown";

    // The same IP+path+category is written once per 5 minutes
    // (stops attack scanners from creating thousands of DB rows)
    var dedupKey = $"{AttackDedupPrefix}{ip}:{path}:{category}";
    if (_cache.TryGetValue(dedupKey, out _)) return;
    _cache.Set(dedupKey, true, AttackDedupTtl);
    // ... request context is gathered: GeoIP country/city/ASN, UA, referer, CF-Ray
    // ... parameterised INSERT INTO dbo.AttackProbes (...)

A scanning tool tries dozens of paths a second; without dedup a single attacker would produce thousands of rows a minute. The IP + path + category triple is cached for 5 minutes — only one row is written per combination inside that window. After dedup, the request context is gathered (country, city and ASN from GeoIP, the User-Agent, the referer, Cloudflare's CF-Ray header) and the row is INSERTed with a parameterised query. Category, Severity and Confidence come from the classification; ThreatScore is computed by the database.

After the INSERT, the auto-block decision is made:

    // Auto-block — if the ThreatScore threshold is crossed, add to IpBlocklist
    var threatScore = c.Severity * c.Confidence / 20;
    if (threatScore >= AutoBlockThreshold)
    {
        await UpsertBlocklistAsync(ip, threatScore, category, c.PatternMatched);
        return;
    }

    // Burst detector — even low-score probes auto-block at 3+ within 15 min
    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);
    }
}

There are two paths. If the ThreatScore on its own crosses 11, the IP is blocked outright. If it does not, the burst counter goes up — a low-score but persistent scanner that makes three probes within 15 minutes gets blocked on the collective behaviour, even when no single probe cleared the threshold. UpsertBlocklistAsync writes the IP into dbo.IpBlocklist and triggers the fire-and-forget Cloudflare push covered in cluster B2 — detection on the origin, the block at the edge. The threatScore calculation shows up in the code a second time; that is only there to make the block decision before the INSERT completes — the value written to the table is always the database's persisted computation.


Frequently Asked Questions

Why is ThreatScore computed in the database rather than in application code?

A persisted computed column fixes the formula to one place — the schema definition. Whether it is the middleware's live INSERT, an ETL backfill or a row added by hand, no matter the path in, ThreatScore is produced by the same formula. You will also see a c.Severity * c.Confidence / 20 calculation in the code; that exists only to make the block decision before the INSERT finishes.

Severity 1-5, Confidence 1-100 — why the different ranges?

Severity is a coarse class; five steps are enough. Confidence needs fine tuning — /wp-admin and /wp-login are both 85, but the /console 70 difference matters. A 100-point scale can carry that nuance; a 5-point scale could not. Dividing by 20 brings both onto a shared 0-25 scale.

Do the 49 path patterns catch every attack?

No — and catching every attack is not the goal. The registry targets the most common automated scanning patterns; the large majority of the probes that arrive in real production are on this list. A targeted, hand-crafted attack might not fit a pattern. That is why, on top of ThreatScore, there is a burst counter and the IsSuspectNetwork layer covered in Pillar 2 — layered defence, not a single registry.

Why are scanners like Censys and Shodan not blocked?

Severity 2, Confidence 75, so ThreatScore 7 — below the threshold of 11. Those are internet-wide inventory services; reconnaissance, not a direct attack. They get flagged and reported, but will not trigger a block on their own. If they scan persistently, the 15-minute burst counter still steps in.


Comments (0)

Leave a comment and rating

No comments yet. Be the first to comment.