Detection rules › Kusto

Server Network Connection Anomalies

Group by
RemoteIP
Author
Cyb3rMonk
Source
github.com/Cyb3r-Monk/Threat-Hunting-and-Detection

Servers have a specific baseline. This makes it easy to create a baseline and detect anomalies.
Below queries analyze the network connections made by the specified servers and detects the rare/anomalous ones.
You can add process info to the analysis, but it will probably generate more results(different processes for the same IP).

Event coverage

Rule body kusto

// Define servers you want to monitor. 
let Servers = dynamic(["server1","server2","etc."]);
// Get rare connections by RemoteIP and InitiatingProcessFileName
DeviceNetworkEvents
| where Timestamp > ago(30d)
| where DeviceName in (Servers) and ActionType == "ConnectionSuccess"
| where RemoteIPType !in ( "Private", "Loopback" )
| where RemoteIP !startswith "169.254."
| summarize make_set(RemoteUrl), count() by RemoteIP
| where count_ < 50
// Exclude traffic to known destinations.
| where not ( set_RemoteUrl has_any (".microsoft.com",".windowsupdate.com","login.microsoftonline.com","login.live.com","autodiscover-s.outlook.com","ocsp.digicert.com","ocsp.verisign.com","login.windows.net", "outlook.office365.com","accounts.accesscontrol.windows.net"))
// Get details of the connections that were made in the last 5 days.
// If you are going to check the results everyday, change the threshold to 1d. 
| join kind=inner
    (
    DeviceNetworkEvents
    | where Timestamp > ago(5d)
    | where DeviceName in (Servers) and ActionType == "ConnectionSuccess"
    | where RemoteIPType !in ( "Private", "Loopback" )
    | where RemoteIP !startswith "169.254."
    ) on RemoteIP

// Define servers you want to monitor. 
let Servers = dynamic(["server1","server2","etc."]);
// Get rare connections by DestinationIp
let _lookback = 30d;
let _timeframe = 5d;
let PrivateIPregex = @'^127\.|^10\.|^172\.1[6-9]\.|^172\.2[0-9]\.|^172\.3[0-1]\.|^192\.168\.';
// Parse Sysmon network connections and get only the ones towards the internet.
let parse_sysmon_id3 = (T:(TimeGenerated:datetime,EventID:int, Source:string,RenderedDescription:string, EventData:string))
{
T 
| where TimeGenerated > ago(_lookback)
| where Source == "Microsoft-Windows-Sysmon" and EventID == 3
| extend RenderedDescription = tostring(split(RenderedDescription, ":")[0])
| extend EventData = parse_xml(EventData).DataItem.EventData.Data
| mv-expand bagexpansion=array EventData
| evaluate bag_unpack(EventData)
| extend Key=tostring(['@Name']), Value=tostring(['#text'])
| evaluate pivot(Key, any(Value), TimeGenerated, Source, EventLog, Computer, EventLevel, EventLevelName, EventID, UserName, RenderedDescription, MG, ManagementGroupName, Type, _ResourceId)
| extend RuleName = column_ifexists("RuleName", ""), TechniqueId = column_ifexists("TechniqueId", ""),  TechniqueName = column_ifexists("TechniqueName", "")
| parse RuleName with * 'technique_id=' TechniqueId ',' * 'technique_name=' TechniqueName
// Filter connections towards the internet
| where not (DestinationIp matches regex PrivateIPregex)
};
// Get rare connections by DestinationIp
Event
| where TimeGenerated > ago(_lookback)
| where Computer in (Servers)
| invoke parse_sysmon_id3()
| summarize count() by DestinationIp
| where count_ < 50
// get details of the rare connections for further analysis.
| join kind=inner
    (
    Event
    | where TimeGenerated > ago(_timeframe)
    | where Computer in (Servers)
    | invoke parse_sysmon_id3()
    ) on DestinationIp

Stages and Predicates

Parameters

let Servers = dynamic(["server1","server2","etc."]);

Stage 1: source

DeviceNetworkEvents

Stage 2: where

| where Timestamp > ago(30d)

Stage 3: where

| where DeviceName in (Servers) and ActionType == "ConnectionSuccess"

Stage 4: where

| where RemoteIPType !in ( "Private", "Loopback" )

Stage 5: where

| where RemoteIP !startswith "169.254."

Stage 6: summarize

| summarize make_set(RemoteUrl), count() by RemoteIP

Stage 7: where

| where count_ < 50

Stage 8: where

| where not ( set_RemoteUrl has_any (".microsoft.com",".windowsupdate.com","login.microsoftonline.com","login.live.com","autodiscover-s.outlook.com","ocsp.digicert.com","ocsp.verisign.com","login.windows.net", "outlook.office365.com","accounts.accesscontrol.windows.net"))

Stage 9: join

| join kind=inner
    (
    DeviceNetworkEvents
    | where Timestamp > ago(5d)
    | where DeviceName in (Servers) and ActionType == "ConnectionSuccess"
    | where RemoteIPType !in ( "Private", "Loopback" )
    | where RemoteIP !startswith "169.254."
    ) on RemoteIP

Exclusions

Top-level NOT(...) conjuncts: predicates this rule actively suppresses.

FieldKindExcluded values
RemoteIPTypeinLoopback, Private
RemoteIPstarts_with169.254.
set_RemoteUrlmatch.microsoft.com, .windowsupdate.com, login.microsoftonline.com, login.live.com, autodiscover-s.outlook.com, ocsp.digicert.com, ocsp.verisign.com, login.windows.net, outlook.office365.com, accounts.accesscontrol.windows.net
RemoteIPstarts_with169.254.
RemoteIPTypeinLoopback, Private

Indicators

Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.

FieldKindValues
ActionTypeeq
  • ConnectionSuccess transforms: cased corpus 9 (kusto 9)
DeviceNamein
  • etc. transforms: cased
  • server1 transforms: cased
  • server2 transforms: cased
count_lt
  • 50 transforms: cased

Output fields

Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.

FieldSource
RemoteIPsummarize