Introduction

This is a short write-up on using Kusto Query Language (KQL) to detect Remote Monitoring and Management (RMM) artefacts in your process- and network telemetry. It uses multiple open-source projects that aggregate and centrally collect information on available RMMs.

Threat actors often make use of legitimate and well-known RMM solutions during real world intrusions. These remote access tools are typically used as initial access vectors after a successful social engineering campaign and then used as a beachhead into the compromised network to pivot, deploy new tooling or exfiltrate data.

KQL is the query language and engine used in various Microsoft-native monitoring solutions. Think of Azure’s Resource Explorer that allows one to query indexed data on deployed resources and Microsoft’s own commercial SIEM/EDR offerings: Azure Sentinel and Defender for Endpoint.


Overview of Existing Projects

There are multiple projects that have published details on RMMs in a structured way, here are the ones I am aware of at this moment:

Project Notes
Splunk Splunks' research team provides a remote_access_software lookup list through their content repository. It points to this CSV as of this writing. They make use of this list in multiple of their own anomaly and hunting queries, see for example here, here and here.
RMML RMML is a dedicated Github project that collects RMM-information in a structured way using YAML-files. A cumulative JSON object is built through Github Workflows.
RedCanary RedCanary has created a JSON-formatted definition file of RMM tooling for their “Surveyor” baselining tool.
RMM-Catalogue RMM-Catalogue is a CSV file where multiple network and process indicators are collected. This list is based off of brokensound77’s work as published in this gist.
LOLRMM This is only a teased project as of this writing, but might turn out to be interesting (from the creator of the loldrivers project).

An already-existing KQL query that is worth mentioning is Alex Teixeiras' rule on Bert-Jan Pals' Hunting queries repository: Known RAT/RMM process patterns. Some pros of this particular rule are the case-statement that tie a detection match back to a particular tool as well as filtering to reduce noise out-of-the-box. A con of this rule is that matching patterns are entered manually in the rule, making it hard to debug and update the rule with new RMM-solutions.


Basic Matching

All of the previously mentioned projects post their information in some structured fashion, be it in CSV or JSON. However this data still needs to be converted into something that can be used to match against existing telemetry. The easiest way to perform this action in KQL would be to use the built-in has_any-operator, which would result in something that resembles the following code.

# 1) Pseudo KQL to download the lookup list..
let rmms = externaldata(binary_name:string)[@'https://url-to-csv-file'] with (format="csv",ignoreFirstRecord=true);

# 2) .. create a list of unique values...
let unique_rmms = rmms | distinct binary_name | where isnotempty( binary_name );

# 3) .. and then match them against a column in a table
DeviceProcessEvents | where FolderPath has_any(unique_rmms)

This approach will catch most of the documented binaries and domains. However many of these projects seem to occasionally use wildcards (*) in reported domains and binary names/paths, which is a problem for KQL’s has-operator. This operator uses indexed terms to match against the patterns, but treats those wildcards as a literal string which will therefore never match.

Let’s start overcomplicating things ^_^

Constructing Regular Expressions

One way to solve this under-detection is to start converting the documented patterns into valid regular expressions. The following function does just that. It escapes strings, converts wildcards (*) to regex quantifiers (.*) and adds modifiers ((?i)) for case-insensitive matching.

Calling this function on an array: ["C:\Windows\Somefile.exe","Test-*-.exe"]
Results into a regex pattern like: ((?i)C:\\Windows\\Somefile.exe|Test-.*-.exe).

# Function definition taking in a dynamic string array object as parameter (e.g., list of RMM binaries).
let regexConstructor = (arr:dynamic) 
{ 
    replace_string( 
        replace_string(
            replace_string(
                # Create the regex pattern using:
                #  -> case-insentive (?i)-modifier
                #  -> OR-metachar ('|') allow for multiple patterns to be evaluated at once.
                #  -> Parentheses to clarify where alternatives start and end.
                strcat('((?i)', strcat_array( arr,'|') ,')')
                , @'\',@'\\'    # Escape DOS paths.
            )
            , @'/',@'\/'        # Escape UNIX paths.
        ),
        @'*', @'.*'             # Convert wildcards to regex equivalent `.*`
    ) 
};

Our previous Pseudo-code would then be converted into the following.

# 1) Pseudo KQL to download the lookup list..
let rmms = externaldata(binary_name:string)[@'https://url-to-csv-file'] with (format="csv",ignoreFirstRecord=true);

# 2) .. create a list of unique values...
let unique_rmms = rmms | distinct binary_name | where isnotempty( binary_name ) | summarize unique_rmms = make_set(binary_name);

# 3) .. create the regex pattern
let rmm_regex = regexConstructor(toscalar(unqiue_rmms))

# 4) .. and then regex match it against a relevant column
DeviceProcessEvents | where FolderPath matches regex rmm_regex

This catches much more than before, unfortunately a bit too much. rv.exe,rd.exe and other shorter binary names in the lookup lists start matching against well-known benign binaries like “winword.exe" or “wmiapsrv.exe". Adding a filter statement like | where binary_name contains '*', solves this issue for the most part by only focusing on those patterns that were missed by the previous has_any-based query.


RMML and Targeted Queries

An additional way to increase fidelity would be to use the previously mentioned RMML-project to target the queries since it groups binary names by source platform: Windows vs. MacOS vs. Linux. See below for an example entry for the NetSupport RMM which includes binary patterns for all three platforms and a visualization on the proposed process itself.

"Tool": NetSupport,
"JSON": {
	"Executables": {
		"Linux": [
			"nsm/support",
			"support"
		],
		"MacOS": [
			"NetSupportDNA/SystemAgent/NetSupport*.pkg",
			"NetSupport*.pkg"
		],
		"MacOSSigner": null,
		"SignerSubjectName": null,
		"Windows": [
			"presentationhost.exe",
			"remcmdstub.exe"
		]
	},
	"NetConn": {
		"Domains": null,
		"Ports": [443]
	}
}

1. Selecting platform-specific folderpath patterns and compiling them into valid regex.

The first step would be to download the JSON object with all RMM-metadata and process it as a JSON object. The structure of the published document unfortunately doesn’t allow to easily map all columns using the multijson-format specifier. Instead the code requires some projecting and shuffling around of the raw JSON string before we can access all required properties (although if someone does manage to get that working I’d love to hear about it :D).

# This code downloads the published JSON file and parses it as such.
let RMMs = externaldata(RMMs:string)[h@'https://github.com/LivingInSyn/RMML/releases/download/v1.4.0/rmms.json'] with(format='raw');
let ParsedRMMs = RMMs
                | extend   RMMs = todynamic(extract_json('$',RMMs))
                | mv-apply Tool = bag_keys(RMMs) on ( extend T = RMMs["Tool"] )
                | extend   JSON = RMMs[tostring(Tool)]
                | project-away RMMs,T;

We can then create a new function which collects the binaries based on target platform and conditionally calls to the same regexConstructor-function.

let BinArrayToRegexp = (desiredPlatform:string){
   ParsedRMMs
   | summarize WindowsBins = make_set( JSON.Executables.Windows ),
               MacOSBins   = make_set( JSON.Executables.MacOS   ),
               LinuxBins   = make_set( JSON.Executables.Linux   )
   | extend Regexp = iff(desiredPlatform == 'Windows', regexConstructor(WindowsBins),'')
   | extend Regexp = iff(desiredPlatform == 'MacOS',   regexConstructor(MacOSBins),Regexp)
   | extend Regexp = iff(desiredPlatform == 'Linux',   regexConstructor(LinuxBins),Regexp)
   | distinct Regexp
};

Or do the same but create a lookuplist instead so that it can be used as a parameter to the has_any-function.

let BinArrayToLookupList = (desiredPlatform:string){
   ParsedRMMs
   | summarize WindowsBins = make_set( JSON.Executables.Windows ),
               MacOSBins   = make_set( JSON.Executables.MacOS   ),
               LinuxBins   = make_set( JSON.Executables.Linux   )
   | extend BinArr = iff(desiredPlatform == 'Windows', WindowsBins, '')
   | extend BinArr = iff(desiredPlatform == 'MacOS',   MacOSBins,   BinArr)
   | extend BinArr = iff(desiredPlatform == 'Linux',   LinuxBins,   BinArr)
   | project-keep BinArr
};

Our main query then targets each device with a platform specific regular expression or lookuplist which should help reduce false positives and increases fidelity.

DeviceProcessEvents
| where MachineGroup == "Macs"    and (FolderPath matches regex toscalar(BinArrayToRegexp("MacOS"))   or FolderPath has_any(BinArrayToLookupList("MacOS")))   or
        MachineGroup == "Windows" and (FolderPath matches regex toscalar(BinArrayToRegexp("Windows")) or FolderPath has_any(BinArrayToLookupList("Windows"))) or
        MachineGroup == "Linux"   and (FolderPath matches regex toscalar(BinArrayToRegexp("Linux"))   or FolderPath has_any(BinArrayToLookupList("Linux")))
| summarize Devices     = make_set(DeviceName),
            NrOfDevices = dcount(DeviceName) by FolderPath

Sources

Some great write-ups on how such RMM tools are used during actual engagements are published by the DFIR-report here, here and here.

An additional great source is Microsofts' write-up on Octo Tempest, the ALPHV/BlackCat affiliate who make heavy use of common RMM-tooling during initial access phase.

Closing Thoughts

Writing this blog post allowed me to explore RMMs, play around with some different KQL-features and allowed me to contribute some rules that improve on the existing open-sources ones. I did this by making use of more comprehensive lists of RMM-tooling in a dynamic way, allowing for future updates to those lists without breaking the detections.

Some (minor?) bugs still exist in these queries though. For example this code doesn’t deal well with other special characters in regular expressions (e.g., the braces () in DOS paths like Program Files(x86) or dots ('.') in the published domain patterns).

Some changes / improvements to these queries would be to:

  • Find a way to evaluate the documented patterns or constructed regex on a per-tool basis. This would allow you to identify false-positives more quickly and enrich a match with other fields defined in the lookup lists, for example a link to documentation.

  • Simplify’ by removing functions like BinArrayToRegexp and BinArrayToLookupList with static calls instead, in case you’re not interested in using workspace functions.

Queries

Target Link
RMML / Network https://gist.github.com/Korving-F/04d476bf5ab1fec2ac61c104d5a40d8b
RMML / Binaries https://gist.github.com/Korving-F/012385fd2aa304b5bbd400ebd0077fe3
Splunk / Network https://gist.github.com/Korving-F/96e0e474a65fd059874fc9a35b304d56
Splunk / Binaries https://gist.github.com/Korving-F/440afac99189cd201a2ea05c57c8a03b