Query Details

HUNT 12 AD GPO Modification Full Audit 30d

Query

// =========================================================
// HUNT-12 | AD-GPO-Modification-Full-Audit-30d
// Description : Complete audit of all GPO-related changes
//               (5136 on groupPolicyContainer objects),
//               new GPO link events (5137), and GPO
//               deletions (5141). Flags unauthorized
//               editors, scope changes, and suspicious
//               script/immediate task injections.
// Period      : 30 days
// Use Case    : GPO abuse detection, persistence via
//               Group Policy, privilege escalation
// Tables      : SecurityEvent
// =========================================================

let Period = 30d;

// Admin baseline: known accounts with legitimate GPO edit rights
let GpoAdmins = SecurityEvent
    | where TimeGenerated > ago(90d)
    | where EventID in (4728, 4732, 4756)
    | where TargetUserName has_any ("Domain Admins", "Enterprise Admins",
                                    "Group Policy Creator Owners",
                                    "Administrators")
    | summarize by GPOAdmin = tolower(tostring(column_ifexists("MemberName", "")));

// GPO attribute writes
let GPOWrites = SecurityEvent
    | where TimeGenerated > ago(Period)
    | where EventID == 5136
    | where tostring(column_ifexists("ObjectClass", "")) =~ "groupPolicyContainer"
    | extend
        GPOName    = extract(@"CN=\{([^}]+)\}", 0, ObjectName),
        Actor      = strcat(SubjectDomainName, "\\", SubjectUserName),
        ActorNorm  = tolower(SubjectUserName),
        AttrName   = tostring(column_ifexists("AttributeLDAPDisplayName", "")),
        OldValue   = OldValue,
        NewValue   = tostring(column_ifexists("AttributeValue", "")),
        IsScriptPath = tostring(column_ifexists("AttributeValue", "")) has_any (".ps1", ".bat", ".cmd", ".vbs", ".js",
                                               "scripts", "powershell", "cscript",
                                               "wscript", "rundll32"),
        IsOffHours = hourofday(TimeGenerated) < 7 or hourofday(TimeGenerated) >= 20;

// GPO object creations
let GPOCreations = SecurityEvent
    | where TimeGenerated > ago(Period)
    | where EventID == 5137
    | where tostring(column_ifexists("ObjectClass", "")) =~ "groupPolicyContainer"
    | extend
        GPOName   = extract(@"CN=\{([^}]+)\}", 0, ObjectName),
        Actor     = strcat(SubjectDomainName, "\\", SubjectUserName),
        ActorNorm = tolower(SubjectUserName),
        EventType = "GPOCreated";

// GPO deletions
let GPODeletions = SecurityEvent
    | where TimeGenerated > ago(Period)
    | where EventID == 5141
    | where tostring(column_ifexists("ObjectClass", "")) =~ "groupPolicyContainer"
    | extend
        GPOName   = extract(@"CN=\{([^}]+)\}", 0, ObjectName),
        Actor     = strcat(SubjectDomainName, "\\", SubjectUserName),
        ActorNorm = tolower(SubjectUserName),
        EventType = "GPODeleted";

// Identify unauthorized GPO editors
GPOWrites
| join kind=leftouter (GpoAdmins) on $left.ActorNorm == $right.GPOAdmin
| extend IsAuthorizedEditor = isnotempty(GPOAdmin)
| summarize
    TotalEdits          = count(),
    UnauthorizedEdits   = countif(not(IsAuthorizedEditor)),
    ScriptPathChanges   = countif(IsScriptPath),
    OffHoursEdits       = countif(IsOffHours),
    UniqueEditors       = make_set(Actor, 10),
    UnauthorizedEditors = make_set_if(Actor, not(IsAuthorizedEditor), 10),
    ChangedAttributes   = make_set(AttrName, 20),
    ScriptValues        = make_set_if(NewValue, IsScriptPath, 10),
    LastEdit            = max(TimeGenerated),
    FirstEdit           = min(TimeGenerated)
  by GPOName
| union (
    GPOCreations
    | join kind=leftouter (GpoAdmins) on $left.ActorNorm == $right.GPOAdmin
    | extend IsAuthorizedEditor = isnotempty(GPOAdmin)
    | summarize
        TotalEdits          = count(),
        UnauthorizedEdits   = countif(not(IsAuthorizedEditor)),
        ScriptPathChanges   = 0,
        OffHoursEdits       = 0,
        UniqueEditors       = make_set(Actor, 10),
        UnauthorizedEditors = make_set_if(Actor, not(IsAuthorizedEditor), 10),
        ChangedAttributes   = pack_array("GPOCreated"),
        ScriptValues        = pack_array(""),
        LastEdit            = max(TimeGenerated),
        FirstEdit           = min(TimeGenerated)
      by GPOName
)
| union (
    GPODeletions
    | join kind=leftouter (GpoAdmins) on $left.ActorNorm == $right.GPOAdmin
    | extend IsAuthorizedEditor = isnotempty(GPOAdmin)
    | summarize
        TotalEdits          = count(),
        UnauthorizedEdits   = countif(not(IsAuthorizedEditor)),
        ScriptPathChanges   = 0,
        OffHoursEdits       = 0,
        UniqueEditors       = make_set(Actor, 10),
        UnauthorizedEditors = make_set_if(Actor, not(IsAuthorizedEditor), 10),
        ChangedAttributes   = pack_array("GPODeleted"),
        ScriptValues        = pack_array(""),
        LastEdit            = max(TimeGenerated),
        FirstEdit           = min(TimeGenerated)
      by GPOName
)
| extend
    RiskScore = (UnauthorizedEdits * 30)
              + (ScriptPathChanges * 40)
              + (OffHoursEdits * 10)
              + (TotalEdits * 2),
    RiskLevel = case(
        ScriptPathChanges >= 1 and UnauthorizedEdits >= 1,
            "Critical - Script_Injected_By_Unauthorized_User",
        ScriptPathChanges >= 1,
            "High - Script_Injected_Into_GPO",
        UnauthorizedEdits >= 1,
            "High - Unauthorized_GPO_Modification",
        OffHoursEdits >= 3,
            "Medium - OffHours_GPO_Activity",
        "Low"
    )
| project
    GPOName,
    RiskLevel,
    RiskScore,
    TotalEdits,
    UnauthorizedEdits,
    ScriptPathChanges,
    OffHoursEdits,
    UnauthorizedEditors,
    UniqueEditors,
    ChangedAttributes,
    ScriptValues,
    FirstEdit,
    LastEdit
| order by RiskScore desc

Explanation

This query is designed to monitor and audit changes to Group Policy Objects (GPOs) over the past 30 days. It focuses on detecting unauthorized modifications, new GPO creations, and deletions, as well as identifying potentially suspicious activities such as script injections or changes made during off-hours. Here's a simple breakdown of what the query does:

  1. Define the Monitoring Period: The query looks at events from the last 30 days.

  2. Identify Authorized GPO Editors: It establishes a baseline of known accounts with legitimate rights to edit GPOs by looking at specific security events from the past 90 days.

  3. Track GPO Changes:

    • Modifications: It checks for changes to GPO attributes, especially looking for script-related changes and those made outside regular working hours.
    • Creations: It identifies new GPOs that have been created.
    • Deletions: It tracks any GPOs that have been deleted.
  4. Detect Unauthorized Activity: The query compares the actors making changes to the list of authorized editors to flag any unauthorized modifications.

  5. Calculate Risk Scores: Each GPO is assigned a risk score based on the number and type of unauthorized edits, script injections, and off-hours activities. The risk level is categorized as Critical, High, Medium, or Low based on these factors.

  6. Output: The results are presented in a table, sorted by risk score, showing details such as the GPO name, risk level, total edits, unauthorized edits, script changes, off-hours edits, and the list of unique and unauthorized editors involved.

This query is useful for detecting potential GPO abuse, persistence mechanisms, and privilege escalation attempts within an organization's network.

Details

David Alonso profile picture

David Alonso

Released: March 24, 2026

Tables

SecurityEvent

Keywords

SecurityEventGroupPolicyContainerDomainAdminsEnterpriseAdminsGroupPolicyCreatorOwnersAdministratorsGPOAdminMemberNameObjectClassObjectNameSubjectDomainNameSubjectUserNameAttributeLDAPDisplayNameOldValueAttributeValueTimeGeneratedGPOCreatedGPODeletedActorNormRiskScoreRiskLevel

Operators

letwhereinsummarizetolowertostringcolumn_ifexistsextendextractstrcathas_anyorjoinkindonisnotemptycountcountifmake_setmake_set_ifmaxminunionpack_arraycaseprojectorder by

Actions