Adversaries may use rare firewall rule changes via netsh to modify network access controls and evade detection. SOC teams should proactively hunt for this behavior in Azure Sentinel to identify potential lateral movement or persistence tactics.
KQL Query
let starttime = todatetime('{{StartTimeISO}}');
let endtime = todatetime('{{EndTimeISO}}');
// historical time frame
let lookback = totimespan((endtime-starttime)*7);
let AccountAllowList = dynamic(['SYSTEM']);
let tokens = dynamic(["add", "delete", "set"]);
(union isfuzzy=true
(
SecurityEvent
| where TimeGenerated >= ago(lookback)
// remove comment below to adjust for noise
// | where Process =~ "netsh.exe"
| where CommandLine has_all ("advfirewall", "firewall") and CommandLine has_any (tokens)
| where AccountType !~ "Machine" and Account !in~ (AccountAllowList)
| extend KeyValuePairs = extract_all(@'(?P<key>\w+)=(?P<value>[a-zA-Z0-9-\":\\\s$_@()."]+\"|[a-zA-Z0-9-\":$_\\@()."]+)', dynamic(["key","value"]), CommandLine)
| mv-apply KeyValuePairs on (
summarize CommandLineParsed = make_bag(pack(tostring(KeyValuePairs[0]), KeyValuePairs[1]))
)
| extend RuleName = tostring(parse_json(CommandLineParsed).name), Program = tostring(parse_json(CommandLineParsed).program)
| join kind=leftanti (
SecurityEvent
| where TimeGenerated between (starttime..endtime)
// remove comment below to adjust for noise
// | where Process =~ "netsh.exe"
| where CommandLine has_all ("advfirewall", "firewall") and CommandLine has_any (tokens)
| where AccountType !~ "Machine" and Account !in~ (AccountAllowList)
| extend KeyValuePairs = extract_all(@'(?P<key>\w+)=(?P<value>[a-zA-Z0-9-\":\\\s$_@()."]+\"|[a-zA-Z0-9-\":$_\\@()."]+)', dynamic(["key","value"]), CommandLine)
| mv-apply KeyValuePairs on (
summarize CommandLineParsed = make_bag(pack(tostring(KeyValuePairs[0]), KeyValuePairs[1]))
)
| extend RuleName = tostring(parse_json(CommandLineParsed).name), Program = tostring(parse_json(CommandLineParsed).program)
) on RuleName, Program
| summarize count() , StartTime= min(TimeGenerated), EndTime=max(TimeGenerated) by Type, Computer, Account, SubjectDomainName, SubjectUserName, RuleName, Program, CommandLineParsed = tostring(CommandLineParsed), Process, ParentProcessName
| extend timestamp = StartTime, AccountCustomEntity = Account, HostCustomEntity = Computer
),
(
DeviceProcessEvents
| where TimeGenerated >= ago(lookback)
// remove comment below to adjust for noise
// | where InitiatingProcessFileName =~ "netsh.exe"
| where InitiatingProcessCommandLine has_all ("advfirewall", "firewall") and InitiatingProcessCommandLine has_any (tokens)
| where AccountName !in~ (AccountAllowList)
| extend KeyValuePairs = extract_all(@'(?P<key>\w+)=(?P<value>[a-zA-Z0-9-\":\\\s$_@()."]+\"|[a-zA-Z0-9-\":$_\\@()."]+)', dynamic(["key","value"]), InitiatingProcessCommandLine)
| mv-apply KeyValuePairs on (
summarize CommandLineParsed = make_bag(pack(tostring(KeyValuePairs[0]), KeyValuePairs[1]))
)
| extend RuleName = tostring(parse_json(CommandLineParsed).name), Program = tostring(parse_json(CommandLineParsed).program)
| join kind=leftanti (
DeviceProcessEvents
| where TimeGenerated between (starttime..endtime)
// remove comment below to adjust for noise
// | where InitiatingProcessFileName =~ "netsh.exe"
| where InitiatingProcessCommandLine has_all ("advfirewall", "firewall") and InitiatingProcessCommandLine has_any (tokens)
| where AccountName !in~ (AccountAllowList)
| extend KeyValuePairs = extract_all(@'(?P<key>\w+)=(?P<value>[a-zA-Z0-9-\":\\\s$_@()."]+\"|[a-zA-Z0-9-\":$_\\@()."]+)', dynamic(["key","value"]), InitiatingProcessCommandLine)
| mv-apply KeyValuePairs on (
summarize CommandLineParsed = make_bag(pack(tostring(KeyValuePairs[0]), KeyValuePairs[1]))
)
| extend RuleName = tostring(parse_json(CommandLineParsed).name), Program = tostring(parse_json(CommandLineParsed).program)
) on RuleName, Program
| summarize count() , StartTime= min(TimeGenerated), EndTime=max(TimeGenerated) by Type, DeviceName, AccountName, InitiatingProcessAccountDomain, InitiatingProcessAccountName, RuleName, Program, CommandLineParsed = tostring(CommandLineParsed), InitiatingProcessFileName, InitiatingProcessParentFileName
| extend timestamp = StartTime, AccountCustomEntity = InitiatingProcessAccountName, HostCustomEntity = DeviceName
),
(
Event
| where TimeGenerated > ago(lookback)
| where Source == "Microsoft-Windows-Sysmon"
| where EventID == 1
| extend EventData = parse_xml(EventData).DataItem.EventData.Data
| mv-expand bagexpansion=array EventData
| evaluate bag_unpack(EventData)
| extend Key=tostring(['@Name']), Value=['#text']
| evaluate pivot(Key, any(Value), TimeGenerated, Source, EventLog, Computer, EventLevel, EventLevelName, EventID, UserName, RenderedDescription, MG, ManagementGroupName, Type, _ResourceId)
// remove comment below to adjust for noise
// | where OriginalFileName =~ "netsh.exe"
| where CommandLine has_all ("advfirewall", "firewall") and CommandLine has_any (tokens)
| where User !in~ (AccountAllowList)
| extend KeyValuePairs = extract_all(@'(?P<key>\w+)=(?P<value>[a-zA-Z0-9-\":\\\s$_@()."]+\"|[a-zA-Z0-9-\":$_\\@()."]+)', dynamic(["key","value"]), CommandLine)
| mv-apply KeyValuePairs on (
summarize CommandLineParsed = make_bag(pack(tostring(KeyValuePairs[0]), KeyValuePairs[1]))
)
| extend RuleName = tostring(parse_json(CommandLineParsed).name), Program = tostring(parse_json(CommandLineParsed).program)
| join kind=leftanti (
Event
| where TimeGenerated > ago(lookback)
| where Source == "Microsoft-Windows-Sysmon"
| where EventID == 1
| extend EventData = parse_xml(EventData).DataItem.EventData.Data
| mv-expand bagexpansion=array EventData
| evaluate bag_unpack(EventData)
| extend Key=tostring(['@Name']), Value=['#text']
| evaluate pivot(Key, any(Value), TimeGenerated, Source, EventLog, Computer, EventLevel, EventLevelName, EventID, UserName, RenderedDescription, MG, ManagementGroupName, Type, _ResourceId)
// remove comment below to adjust for noise
// | where OriginalFileName =~ "netsh.exe"
| where CommandLine has_all ("advfirewall", "firewall") and CommandLine has_any (tokens)
| where User !in~ (AccountAllowList)
| extend KeyValuePairs = extract_all(@'(?P<key>\w+)=(?P<value>[a-zA-Z0-9-\":\\\s$_@()."]+\"|[a-zA-Z0-9-\":$_\\@()."]+)', dynamic(["key","value"]), CommandLine)
| mv-apply KeyValuePairs on (
summarize CommandLineParsed = make_bag(pack(tostring(KeyValuePairs[0]), KeyValuePairs[1]))
)
| extend RuleName = tostring(parse_json(CommandLineParsed).name), Program = tostring(parse_json(CommandLineParsed).program)
) on RuleName, Program
| extend Type = strcat(Type, ": ", Source)
| summarize count() , StartTime= min(TimeGenerated), EndTime=max(TimeGenerated) by Type, Computer, User, Process, RuleName, Program, CommandLineParsed = tostring(CommandLineParsed), ParentImage
| extend timestamp = StartTime, AccountCustomEntity = User, HostCustomEntity = Computer
)
)
id: 3dc5dc8b-160b-407e-9925-24a91e3599df
name: Rare firewall rule changes using netsh
description: |
This query will show rare firewall rule changes using netsh utility by comparing rule names and program names from the previous day
with those from the historical chosen time frame.
- This technique was seen in relation to Solarigate attack but the results can indicate potential malicious activity used in different attacks.
- The process name in each data source is commented out as an adversary could rename it. It is advisable to keep process name commented but
if the results show unrelated false positives, users may want to uncomment it.
- Note also that the queries use the KQL "has_all" operator, which hasn't yet been documented officially, but will be soon.
In short, "has_all" will only match when the referenced field has all strings in the list.
Refer to netsh syntax: https://docs.microsoft.com/windows-server/administration/windows-commands/netsh
Refer to our Microsoft Defender XDR blog for details on use during the Solorigate attack:
https://www.microsoft.com/security/blog/2021/01/20/deep-dive-into-the-solorigate-second-stage-activation-from-sunburst-to-teardrop-and-raindrop/
severity: Low
requiredDataConnectors:
- connectorId: SecurityEvents
dataTypes:
- SecurityEvent
- connectorId: MicrosoftThreatProtection
dataTypes:
- DeviceProcessEvents
tactics:
- Execution
relevantTechniques:
- T1204
tags:
- Solorigate
- NOBELIUM
query: |
let starttime = todatetime('{{StartTimeISO}}');
let endtime = todatetime('{{EndTimeISO}}');
// historical time frame
let lookback = totimespan((endtime-starttime)*7);
let AccountAllowList = dynamic(['SYSTEM']);
let tokens = dynamic(["add", "delete", "set"]);
(union isfuzzy=true
(
SecurityEvent
| where TimeGenerated >= ago(lookback)
// remove comment below to adjust for noise
// | where Process =~ "netsh.exe"
| where CommandLine has_all ("advfirewall", "firewall") and CommandLine has_any (tokens)
| where AccountType !~ "Machine" and Account !in~ (AccountAllowList)
| extend KeyValuePairs = extract_all(@'(?P<key>\w+)=(?P<value>[a-zA-Z0-9-\":\\\s$_@()."]+\"|[a-zA-Z0-9-\":$_\\@()."]+)', dynamic(["key","value"]), CommandLine)
| mv-apply KeyValuePairs on (
summarize CommandLineParsed = make_bag(pack(tostring(KeyValuePairs[0]), KeyValuePairs[1]))
)
| extend RuleName = tostring(parse_json(CommandLineParsed).name), Program = tostring(parse_json(CommandLineParsed).program)
| join kind=leftanti (
SecurityEvent
| where TimeGenerated between (starttime..endtime)
// remove comment below to adjust for noise
// | where Process =~ "netsh.exe"
| where CommandLine has_all ("advfirewall", "firewall") and CommandLine has_any (tokens)
| where AccountType !~ "Machine" and Account !in~ (AccountAllowList)
| extend KeyValuePairs = extract_all(@'(?P<key>\w+)=(?P<value>[a-zA-Z0-9-\":\\\s$_@()."]+\"|[a-zA-Z0-9-\":$_\\@()."]+)', dynamic(
| Sentinel Table | Notes |
|---|---|
DeviceProcessEvents | Ensure this data connector is enabled |
SecurityEvent | Ensure this data connector is enabled |
Scenario: Scheduled firewall rule updates via Group Policy
Description: A system administrator uses Group Policy to push updated firewall rules to multiple endpoints, which may trigger the rule change detection due to the frequency of rule name changes.
Filter/Exclusion: Exclude events where the source is a Group Policy client-side extension (e.g., gpolc.exe) or where the rule change is part of a known GPO update schedule.
Scenario: Automated firewall rule testing using PowerShell
Description: A DevOps team runs a PowerShell script to test firewall rule configurations in a staging environment, which may result in temporary rule changes that are not malicious.
Filter/Exclusion: Exclude events where the process is powershell.exe and the command line includes testing-related keywords like Test-NetConnection or Invoke-Command with a known test script.
Scenario: Regular maintenance task using netsh
Description: An admin uses the netsh command-line tool to perform routine firewall rule maintenance, such as updating rule priorities or descriptions, which can be flagged as a rare change.
Filter/Exclusion: Exclude events where the user is a domain admin and the command is part of a known maintenance script or scheduled task (e.g., MaintenanceTask.ps1).
Scenario: Logon script modifying firewall rules
Description: A logon script runs on user login to adjust firewall rules based on user group membership, which may be flagged as an unusual change.
Filter/Exclusion: Exclude events where the process is a logon script (e.g., logonscript.bat) or where the rule change is associated with a user account in the administrators group.
Scenario: Firewall rule changes via third-party security tool
Description: A third-party endpoint protection tool automatically updates firewall rules to block known threats, which may trigger