Mehmet Ergene
Introducing LOLRMM-KQL
Detecting abused RMM tools at scale is harder than it looks. LOLRMM helps by documenting these tools, but turning that dataset into something actionable in Microsoft Defender still takes some work.
LOLRMM was created to track legitimate Remote Monitoring and Management (RMM) tools that are commonly abused during intrusions. These tools show up frequently in initial access or post-exploitation scenarios for persistence, remote access, and lateral movement, while blending in with normal activity.
After LOLRMM was released, I wanted to build a single KQL query that could detect all LOLRMM entries. Once I started working with the data, it became clear why this had not been done already. It turned out each entry relied on artifacts with different conditions, which makes a single, clean detection difficult. At that point, I put the idea aside.
LOLRMM was created to track legitimate Remote Monitoring and Management (RMM) tools that are commonly abused during intrusions. These tools show up frequently in initial access or post-exploitation scenarios for persistence, remote access, and lateral movement, while blending in with normal activity.
After LOLRMM was released, I wanted to build a single KQL query that could detect all LOLRMM entries. Once I started working with the data, it became clear why this had not been done already. It turned out each entry relied on artifacts with different conditions, which makes a single, clean detection difficult. At that point, I put the idea aside.
A New Opportunity
In December 2025, Dodge This Security published a Sysmon configuration after analysing LOLRMM installers based on sandbox execution. The full write-up is available here:
https://www.dodgethissecurity.com/2025/11/30/sysmon-config-creation-for-the-lolrmm-framework/
Because Sysmon events follow a predefined and consistent schema, this work made it realistic to revisit the idea of a single LOLRMM detection query.
Because Sysmon events follow a predefined and consistent schema, this work made it realistic to revisit the idea of a single LOLRMM detection query.
First Attempt
The first approach was to combine all Sysmon-based conditions into a single query. This quickly resulted in several thousand lines of KQL and exceeded the 15,000-character limit for Microsoft Sentinel analytics rules.
Second Attempt
The second attempt focused on a single Sysmon field that was common across all LOLRMM rules. While this reduced the overall size of the query, it still exceeded the detection rule limit and was not usable in practice.
Final Attempt: Approximate and Combined Lookups
The final approach used approximate, partial, and combined lookups in KQL. This is a quite old trick explained on
https://techcommunity.microsoft.com/blog/microsoftsentinelblog/approximate-partial-and-combined-lookups-in-azure-sentinel/1393795
Simply put, this method relies on a lookup table that stores operators and values. Combining with mv-apply allows multiple conditions to be evaluated dynamically for a single record. It is not a common pattern for detection engineering, but it allows complex logic to be expressed in a much smaller query.
After a few iterations, this resulted in two queries:
Simply put, this method relies on a lookup table that stores operators and values. Combining with mv-apply allows multiple conditions to be evaluated dynamically for a single record. It is not a common pattern for detection engineering, but it allows complex logic to be expressed in a much smaller query.
After a few iterations, this resulted in two queries:
Query 1
This query uses functionality of the has operator for the contains any/all condition, for better performance. It is faster but may introduce false positives or miss some edge cases.
// Description: Detects LOLRMM Entries using process creation logs
// Uses has functionality for contains all/any
// Author: Cyb3rMonk(https://x.com/Cyb3rMonk)
// Website: https://academy.bluraven.io
// References:
// - https://github.com/shotgunner101/Sysmon-LOLRMM
// - https://lolrmm.io/
//
let lolrmm_sysmon = externaldata (rmm_info:dynamic) ["https://raw.githubusercontent.com/Cyb3r-Monk/LOLRMM-KQL/refs/heads/main/sysmon_lolrmm_process_create_rules.json"]
with (format=multijson, ingestionMapping='[{"Column":"rmm_info","Properties":{"Path":"$"}}]')
| evaluate bag_unpack(rmm_info) : (condition_type:string, field_name:string, field_value:string, rule_name:string)
| extend mde_field = case(
field_name == "Image", "FolderPath",
field_name == "ParentImage", "InitiatingProcessFolderPath",
field_name == "OriginalFileName", "ProcessVersionInfoOriginalFileName",
field_name == "Description", "ProcessVersionInfoFileDescription",
field_name == "Product", "ProcessVersionInfoProductName",
field_name == "Company", "ProcessVersionInfoCompanyName",
"Unknown"
)
;
let lolrmm_lookup = toscalar(
lolrmm_sysmon
| summarize l = make_list(bag_pack_columns(rule_name, condition_type, mde_field, field_value))
);
let result_summary = materialize (
DeviceProcessEvents
| where Timestamp > ago(1h)
| where ActionType == "ProcessCreated"
| where FileName endswith ".exe"
| summarize hint.strategy=shuffle arg_min(Timestamp, FolderPath, InitiatingProcessFolderPath, ProcessVersionInfoOriginalFileName, ProcessVersionInfoFileDescription, ProcessVersionInfoProductName, ProcessVersionInfoCompanyName) by FolderPath
| partition hint.strategy=shuffle by FolderPath (
extend row_data = bag_pack(
"FolderPath", FolderPath,
"InitiatingProcessFolderPath", InitiatingProcessFolderPath,
"ProcessVersionInfoOriginalFileName", ProcessVersionInfoOriginalFileName,
"ProcessVersionInfoFileDescription", ProcessVersionInfoFileDescription,
"ProcessVersionInfoProductName", ProcessVersionInfoProductName,
"ProcessVersionInfoCompanyName", ProcessVersionInfoCompanyName
)
| mv-apply l = lolrmm_lookup on
(
extend field_val = tostring(row_data[tostring(l['mde_field'])]),
pattern = tostring(l['field_value']),
condition = tostring(l['condition_type'])
| extend match = case(
condition == "is" and isnotempty(field_val),
field_val =~ pattern,
condition == "is any" and isnotempty(field_val),
array_length(set_difference(extract_all(@"(.+)", field_val), split(pattern, ";"))) == 0 and isnotempty(field_val),
condition == "image" and isnotempty(field_val),
field_val endswith pattern or field_val =~ pattern,
condition == "contains" and isnotempty(field_val),
field_val contains pattern,
condition == "contains any" and isnotempty(field_val),
array_length(set_difference(extract_all(@"(\w+)", field_val), split(pattern, ";"))) == 0,
condition == "contains all" and isnotempty(field_val),
array_length(set_difference(extract_all(@"(\w+)", pattern), extract_all(@"(\w+)", field_val))) == 0,
false
)
| where match
| project rule_name = tostring(l['rule_name']), condition, mde_field = tostring(l['mde_field']), pattern
)
)
| project-reorder rule_name, mde_field, condition, pattern
)
;
result_summary
| join hint.strategy=shuffle kind=inner (
DeviceProcessEvents
| where Timestamp > ago(1h)
| where ActionType == "ProcessCreated"
| where FileName endswith ".exe"
| summarize hint.strategy=shuffle arg_min(Timestamp, *) by DeviceId, FolderPath
) on FolderPath
Empty space, drag to resize
Empty space, drag to resize
Query 2
The second query performs 1-to-1 matching with the Sysmon conditions derived from the Sysmon-LOLRMM configuration. This version is less performant but more precise.
// Description: Detects LOLRMM Entries using process creation logs
// Uses 1-to-1 Sysmon condition matching logic
// Author: Cyb3rMonk(https://x.com/Cyb3rMonk)
// Website: https://academy.bluraven.io
// References:
// - https://github.com/shotgunner101/Sysmon-LOLRMM
// - https://lolrmm.io/
//
let lolrmm_sysmon = externaldata (rmm_info:dynamic) ["https://raw.githubusercontent.com/Cyb3r-Monk/LOLRMM-KQL/refs/heads/main/sysmon_lolrmm_process_create_rules.json"]
with (format=multijson, ingestionMapping='[{"Column":"rmm_info","Properties":{"Path":"$"}}]')
| evaluate bag_unpack(rmm_info) : (condition_type:string, field_name:string, field_value:string, rule_name:string)
| extend mde_field = case(
field_name == "Image", "FolderPath",
field_name == "ParentImage", "InitiatingProcessFolderPath",
field_name == "OriginalFileName", "ProcessVersionInfoOriginalFileName",
field_name == "Description", "ProcessVersionInfoFileDescription",
field_name == "Product", "ProcessVersionInfoProductName",
field_name == "Company", "ProcessVersionInfoCompanyName",
"Unknown"
)
;
let lolrmm_lookup = toscalar(
lolrmm_sysmon
| summarize l = make_list(bag_pack_columns(rule_name, condition_type, mde_field, field_value))
);
let summary_results = materialize (
DeviceProcessEvents
| where Timestamp > ago(1h)
| where ActionType == "ProcessCreated"
| where FileName endswith ".exe"
| summarize hint.strategy=shuffle arg_min(Timestamp, FolderPath, InitiatingProcessFolderPath, ProcessVersionInfoOriginalFileName, ProcessVersionInfoFileDescription, ProcessVersionInfoProductName, ProcessVersionInfoCompanyName) by FolderPath
| partition hint.strategy=shuffle by FolderPath (
extend row_data = bag_pack(
"FolderPath", FolderPath,
"InitiatingProcessFolderPath", InitiatingProcessFolderPath,
"ProcessVersionInfoOriginalFileName", ProcessVersionInfoOriginalFileName,
"ProcessVersionInfoFileDescription", ProcessVersionInfoFileDescription,
"ProcessVersionInfoProductName", ProcessVersionInfoProductName,
"ProcessVersionInfoCompanyName", ProcessVersionInfoCompanyName
)
| mv-apply l = lolrmm_lookup on
(
extend field_val = tostring(row_data[tostring(l['mde_field'])]),
condition = tostring(l['condition_type']),
raw_pattern = tostring(l['field_value'])
| extend patterns = split(raw_pattern, ";")
| mv-apply p = patterns on
(
extend single_pattern = tostring(p)
| extend pattern_match = case(
condition in ("is", "is any"),
field_val =~ single_pattern,
condition == "image",
field_val endswith single_pattern or field_val endswith strcat("\\", single_pattern),
condition in ("contains", "contains any", "contains all"),
field_val contains single_pattern,
false
)
| summarize
any_matched = max(toint(pattern_match)),
all_matched = min(toint(pattern_match)),
pattern_count = count()
)
| extend match = case(
condition == "is", any_matched == 1,
condition == "is any", any_matched == 1,
condition == "image", any_matched == 1,
condition == "contains", any_matched == 1,
condition == "contains any", any_matched == 1,
condition == "contains all", all_matched == 1,
false
)
| where match
| project matched_rule = tostring(l['rule_name']), condition, mde_field = tostring(l['mde_field']), raw_pattern
)
)
| project-away row_data
)
;
summary_results
| join hint.strategy=shuffle (
DeviceProcessEvents
| where Timestamp > ago(1h)
| where ActionType == "ProcessCreated"
| where FileName endswith ".exe"
| summarize hint.strategy=shuffle arg_min(Timestamp, *) by DeviceId, FolderPath
) on FolderPath
Both queries were tested at scale in terms of performance and functionality. Testing against all entries was not possible, but it detected the existing LOLRMM entries in the environment.
Individual Queries and Repository
In addition to the single queries, I generated individual KQL queries for each LOLRMM entry, using GitHub CoPilot. You can find everything in a single repository: LOLRMM-KQL
Use these as a baseline and tune as needed. If you identify issues, please submit them to the respective repository so they can be addressed in future updates. Also note that some legitimate tools, such as Windows RDP and PuTTY, are included in the ruleset and may need to be excluded depending on your environment.
Credits
Share
Copyright © 2026
Featured Links
Subscribe to our Newsletter!
Thank you!
New Challenge Lab
We're excited to launch our first hands-on lab challenge: Threat Hunting and Incident Response Case #001!
This lab simulates a real-world breach with two investigation paths:
This lab simulates a real-world breach with two investigation paths:
1️⃣ Incident Response: Triage an initial alert and unfold the attack.
2️⃣ Threat Hunting: Start with a TTP and hunt for adversary activity.
Select your country
Please choose your country to see the correct page.