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.

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.

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:

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
Get a Sneak Peek Into Our Upcoming Course

Practical Threat Hunting for Beginners

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