Skip to main content
Solved

Help with Yara Rule to detect internal horizontal port scan

  • October 31, 2025
  • 12 replies
  • 126 views

MikelSA
Forum|alt.badge.img+8

Hi as i said, I want to detect horizontal and vertical port scan detect within the same host as source.

 

I have this, but to be honest im struggling a bit with the code, maybe someone can help me with this?

 

 

 events:

 

 $e.metadata.event_type = "NETWORK_CONNECTION" or

 $e.metadata.product_event_type = /TRAFFIC|traffic/

 $src_ip  = $e.principal.ip

 $dst_ip  = $e.target.ip

 $dst_port = $e.target.port


 

re.regex($src_ip, `^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)`)

re.regex($dst_ip, `^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)`)


 

not re.regex($dst_ip, `^(224\.|239\.|255\.)`)

not re.regex($src_ip, `^(224\.|239\.|255\.)`)

 

 match:

 $src_ip over 30m

 

outcome:

$unique_dsts = count_distinct($dst_ip)

$unique_ports = count_distinct($dst_port)

$risk_score = 80

 

condition:

 

$e and $unique_ports > 250 and $unique_dsts > 250

}

 

Dont know if this will trigger or not, im a little bit lost.

 

Thanks!

Best answer by AbdElHafez

@MikelSA Please look at and try this version, this is as close as I managed to get.

  1. Currently there is no IF in Yara-L events section, so I moved HasPrivilegedPort to outcome.
  2. The $PrivilegedPortCount will get the count of ALL privileged ports in the repeated port array, so if the array is [80,80,2000] ; KQL will yield HasPrivilegedPort =1 for port 80  , but Yara-L has no current way to do more than 1 aggregated operator in outcome, so HasPrivilegedPort will be = 2 (80 x 2) which is the main difference in the logic.
rule kql {
meta:
//Regex Reference: https://stackoverflow.com/questions/2814002/private-ip-address-identifier-in-regular-expression
events:
$e.metadata.event_type = "NETWORK_FLOW" or $e.metadata.event_type = "NETWORK_CONNECTION"

$e.principal.hostname = $SourceHostName
$e.principal.ip = $SourceIP
$e.target.ip = $DestinationIP
$e.target.port = $DestinationPort

$SourceHostName != ""
cast.as_string($DestinationPort) != "" //isnotempty(DestinationPort)
$DestinationIP = /(^127\.)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)/ //isnotempty(DestinationIP) AND where (ipv4_is_private(DestinationIP)) merged into 1 condition

match:
$SourceIP, $SourceHostName, $DestinationIP over 1h //summarize....by SourceIP, SourceHostName, DestinationIP, bin(TimeGenerated, 1h) We used $event_timestamp which is the default time field for UDM
//$SourceIP over 12h //For Testing
outcome:
$arr = array($DestinationPort) //Optional,used just to display the intermediate repeated variable
$arr_distinct = array_distinct($DestinationPort) //Optional,used just to display the intermediate non-repeated variable
$Port_count = count($DestinationPort) //Optional, not used
$TotalPortCount = count_distinct($DestinationPort) //TotalPortCount = dcount(DestinationPort)

$HasPrivilegedPort = if(min($DestinationPort)<1024,True,False) //extend IsPrivilegedPort = iff(DestinationPort < 1024, true, false)
$PrivilegedPortCount = sum(if($DestinationPort < 1024,1,0 )) //PrivilegedPortCount = dcountif(DestinationPort, IsPrivilegedPort)
$TotalPortNonPrivilegedCount = sum(if($DestinationPort > 1024,1,0 )) //also equal to $Port_count - $PrivilegedPortCount
condition:
$e and ($TotalPortCount >=90 or $PrivilegedPortCount>=20) //TotalPortCount >= 90 or PrivilegedPortCount >= 20
}

 

12 replies

AbdElHafez
Staff
Forum|alt.badge.img+12
  • Staff
  • October 31, 2025

Hi ​@MikelSA , 

The rule will trigger but I have some suggestions ;

  1. You should use a data table in the exclusions instead of the regex.
  2. It is possible to split the rule into host-based and port-based scanning, one for the unique_ports only, and another with unique_hosts only. Port scanning will usually target multiple ports on the same host , OR same port with multiple targets, so 250 different hosts+250 different ports within 30 minutes will trigger for very aggressive scans in my opinion, OR you could use the summation unique_hosts + unique_ports = 250 instead.
  3. Also 250 is a high threshold number, I would suggest you would use lesser thresholds just for testing, or run it in a dashboard to see your environment trend first.
  4. Your code is similar to SQL ; 
    Select IP, count (distinct(port)), count(distinct(host))
    Group by IP
    Having count(distinct(port))>250 and count(distinct(port))>250

MikelSA
Forum|alt.badge.img+8
  • Author
  • Bronze 2
  • October 31, 2025

Hi ​@MikelSA , 

The rule will trigger but I have some suggestions ;

  1. You should use a data table in the exclusions instead of the regex.
  2. It is possible to split the rule into host-based and port-based scanning, one for the unique_ports only, and another with unique_hosts only. Port scanning will usually target multiple ports on the same host , OR same port with multiple targets, so 250 different hosts+250 different ports within 30 minutes will trigger for very aggressive scans in my opinion, OR you could use the summation unique_hosts + unique_ports = 250 instead.
  3. Also 250 is a high threshold number, I would suggest you would use lesser thresholds just for testing, or run it in a dashboard to see your environment trend first.
  4. Your code is similar to SQL ; 
    Select IP, count (distinct(port)), count(distinct(host))
    Group by IP
    Having count(distinct(port))>250 and count(distinct(port))>250

Thank you so much for the answer, I will take the suggestions. Thanks!!!


MikelSA
Forum|alt.badge.img+8
  • Author
  • Bronze 2
  • October 31, 2025

I have another issue, I want to copy this kql rule to Yara-L but im unable:

 

CommonSecurityLog | where isnotempty(SourceHostName) and isnotempty(DestinationIP) and isnotempty(DestinationPort) and isnotempty(Protocol)

| where (ipv4_is_private(DestinationIP)) // IPs privadas | extend IsPrivilegedPort = iff(DestinationPort < 1024, true, false)

| summarize TotalPortCount = dcount(DestinationPort), PrivilegedPortCount = dcountif(DestinationPort, IsPrivilegedPort) by SourceIP, SourceHostName, DestinationIP, bin(TimeGenerated, 1h)

| where TotalPortCount >= 90 or PrivilegedPortCount >= 20

| project TimeGenerated, SourceHostName, SourceIP, DestinationIP, TotalPortCount, PrivilegedPortCount


AbdElHafez
Staff
Forum|alt.badge.img+12
  • Staff
  • October 31, 2025

Give me two hours and I will get sometime to look at the exact context of the  KQL rule to translate it properly, from what I can pick up right now ;

1. The is_private() function could be done using regex but I will need to write a pattern for the RFC1918 IPs.
2. The “by” operator will be translated to match: sourceIP, sourceHostName while the destination port should be either added to the match or be an array_distinct() in the outcome depending on the rule context I need to examine.
3. “where” operator conditions are the conditions in the Yara-L “condition” section ; $e and count_port >=90 ,...etc but the count variables will need to be defined in the outcome.

 


MikelSA
Forum|alt.badge.img+8
  • Author
  • Bronze 2
  • October 31, 2025

Sure, thank you, take your time, really appreciate it


AbdElHafez
Staff
Forum|alt.badge.img+12
  • Staff
  • October 31, 2025

@MikelSA  This is the closest version I managed to get, I am looking for a way to implement the conditional count ;

 

rule kql_to_yaral{

meta:


events:

$e.metadata.event_type = "NETWORK_FLOW" or $e.metadata.event_type = "NETWORK_CONNECTION"

$e.principal.hostname = $SourceHostName
$e.principal.ip = $SourceIP
$e.target.ip = $DestinationIP
$e.target.port = $DestinationPort

$SourceHostName != ""
$SourceIP != ""
$DestinationIP != ""
cast.as_string($DestinationPort) != ""
//network.ip_protocol = "UDP" is always non-empty in UDM Network events

match:
$SourceIP, $SourceHostName, $DestinationIP over 1h
//$SourceIP over 12h


outcome:

$arr = array($DestinationPort)
$TotalPortCount = count_distinct($DestinationPort)
$HasPrivilegedPort = if(min($DestinationPort)<1024,1,0)
//$TotalPortPrivilegedCount = sum(if($DestinationPort < 1024,1,0 ))
//$TotalPortNonPrivilegedCount = sum(if($DestinationPort > 1024,1,0 ))

condition:
$e
}

 


AbdElHafez
Staff
Forum|alt.badge.img+12
  • Staff
  • Answer
  • October 31, 2025

@MikelSA Please look at and try this version, this is as close as I managed to get.

  1. Currently there is no IF in Yara-L events section, so I moved HasPrivilegedPort to outcome.
  2. The $PrivilegedPortCount will get the count of ALL privileged ports in the repeated port array, so if the array is [80,80,2000] ; KQL will yield HasPrivilegedPort =1 for port 80  , but Yara-L has no current way to do more than 1 aggregated operator in outcome, so HasPrivilegedPort will be = 2 (80 x 2) which is the main difference in the logic.
rule kql {
meta:
//Regex Reference: https://stackoverflow.com/questions/2814002/private-ip-address-identifier-in-regular-expression
events:
$e.metadata.event_type = "NETWORK_FLOW" or $e.metadata.event_type = "NETWORK_CONNECTION"

$e.principal.hostname = $SourceHostName
$e.principal.ip = $SourceIP
$e.target.ip = $DestinationIP
$e.target.port = $DestinationPort

$SourceHostName != ""
cast.as_string($DestinationPort) != "" //isnotempty(DestinationPort)
$DestinationIP = /(^127\.)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)/ //isnotempty(DestinationIP) AND where (ipv4_is_private(DestinationIP)) merged into 1 condition

match:
$SourceIP, $SourceHostName, $DestinationIP over 1h //summarize....by SourceIP, SourceHostName, DestinationIP, bin(TimeGenerated, 1h) We used $event_timestamp which is the default time field for UDM
//$SourceIP over 12h //For Testing
outcome:
$arr = array($DestinationPort) //Optional,used just to display the intermediate repeated variable
$arr_distinct = array_distinct($DestinationPort) //Optional,used just to display the intermediate non-repeated variable
$Port_count = count($DestinationPort) //Optional, not used
$TotalPortCount = count_distinct($DestinationPort) //TotalPortCount = dcount(DestinationPort)

$HasPrivilegedPort = if(min($DestinationPort)<1024,True,False) //extend IsPrivilegedPort = iff(DestinationPort < 1024, true, false)
$PrivilegedPortCount = sum(if($DestinationPort < 1024,1,0 )) //PrivilegedPortCount = dcountif(DestinationPort, IsPrivilegedPort)
$TotalPortNonPrivilegedCount = sum(if($DestinationPort > 1024,1,0 )) //also equal to $Port_count - $PrivilegedPortCount
condition:
$e and ($TotalPortCount >=90 or $PrivilegedPortCount>=20) //TotalPortCount >= 90 or PrivilegedPortCount >= 20
}

 


AbdElHafez
Staff
Forum|alt.badge.img+12
  • Staff
  • October 31, 2025

This rule probably could be rewritten using the metrics, but I will need more time to check if the logic could be ported exactly or not.


MikelSA
Forum|alt.badge.img+8
  • Author
  • Bronze 2
  • November 2, 2025

@MikelSA Please look at and try this version, this is as close as I managed to get.

  1. Currently there is no IF in Yara-L events section, so I moved HasPrivilegedPort to outcome.
  2. The $PrivilegedPortCount will get the count of ALL privileged ports in the repeated port array, so if the array is [80,80,2000] ; KQL will yield HasPrivilegedPort =1 for port 80  , but Yara-L has no current way to do more than 1 aggregated operator in outcome, so HasPrivilegedPort will be = 2 (80 x 2) which is the main difference in the logic.
rule kql {
meta:
//Regex Reference: https://stackoverflow.com/questions/2814002/private-ip-address-identifier-in-regular-expression
events:
$e.metadata.event_type = "NETWORK_FLOW" or $e.metadata.event_type = "NETWORK_CONNECTION"

$e.principal.hostname = $SourceHostName
$e.principal.ip = $SourceIP
$e.target.ip = $DestinationIP
$e.target.port = $DestinationPort

$SourceHostName != ""
cast.as_string($DestinationPort) != "" //isnotempty(DestinationPort)
$DestinationIP = /(^127\.)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)/ //isnotempty(DestinationIP) AND where (ipv4_is_private(DestinationIP)) merged into 1 condition

match:
$SourceIP, $SourceHostName, $DestinationIP over 1h //summarize....by SourceIP, SourceHostName, DestinationIP, bin(TimeGenerated, 1h) We used $event_timestamp which is the default time field for UDM
//$SourceIP over 12h //For Testing
outcome:
$arr = array($DestinationPort) //Optional,used just to display the intermediate repeated variable
$arr_distinct = array_distinct($DestinationPort) //Optional,used just to display the intermediate non-repeated variable
$Port_count = count($DestinationPort) //Optional, not used
$TotalPortCount = count_distinct($DestinationPort) //TotalPortCount = dcount(DestinationPort)

$HasPrivilegedPort = if(min($DestinationPort)<1024,True,False) //extend IsPrivilegedPort = iff(DestinationPort < 1024, true, false)
$PrivilegedPortCount = sum(if($DestinationPort < 1024,1,0 )) //PrivilegedPortCount = dcountif(DestinationPort, IsPrivilegedPort)
$TotalPortNonPrivilegedCount = sum(if($DestinationPort > 1024,1,0 )) //also equal to $Port_count - $PrivilegedPortCount
condition:
$e and ($TotalPortCount >=90 or $PrivilegedPortCount>=20) //TotalPortCount >= 90 or PrivilegedPortCount >= 20
}

 

Thank you so much for the effort. This teach me a lot, really really thank you!!!


AbdElHafez
Staff
Forum|alt.badge.img+12
  • Staff
  • November 3, 2025

@MikelSA I am glad that was helpful.

Just a minor fix ; Please use  “$DestinationPort <= 1024” instead of “$DestinationPort < 1024” just to cover the equal case as I mistakenly missed it.


gkush
Staff
Forum|alt.badge.img+5
  • Staff
  • November 3, 2025

Some language points/ideas:

Hope this helps!


MikelSA
Forum|alt.badge.img+8
  • Author
  • Bronze 2
  • November 4, 2025

Thank you so much for the advice!