Unveiling APT29's Stealthy Method of Disabling Unified Advanced Auditing and Cracking Parsing Issues of AuditLogs
Kloudynet Technologies
Experts in Cloud Governance | Cybersecurity | Compliance | SOC | MDR | Microsoft's Advanced Security Solutions Partner
Summary:
This article delves into APT29's stealthy method of disabling advanced auditing by targeting the M365_ADVANCED_AUDITING service plan. It explores emulations that mimic these actions, demonstrates detection techniques using various log sources with KQL queries from Kloudynet, and addresses the complexities involved in accurately parsing AuditLogs to effectively detect and respond to such activities.
Introduction
It all started when we were trying to write a use case query for detecting the disabling of the Unified Audit Log(Microsoft Purview Audit or Advanced Audit). In the process, we explored multiple ways to disable Unified Audit Logging to bypass detection mechanisms, applying this knowledge to enhance our use-case scenarios.
During this exploration, we encountered a stealthy technique detailed in a Mandiant research paper where APT29 targets the M365_ADVANCED_AUDITING service plan, a component of the E5 license that enables Unified Audit Logging for tracking user activities. APT29 disables this plan as shown to effectively disable audit logging for that particular user, potentially evading detection.
Can you be more detailed? Okay, here you go:
For Microsoft's SaaS cloud offerings, a license allows a specific user account to use the services of the cloud offering. Certain licenses also oversee security and compliance settings, including log retention and enable logging for activities such as mailbox operations (e.g., MailItemsAccessed, mailbox rule creation, SMTP mail forwarding) using Advanced Auditing. E5 is such license which provides Advanced Audit Logging feature for users via Microsoft 365 Advanced Auditing(M365_ADVANCED_AUDITING) service plan. This includes tracking events like when mail items were accessed, replied to, forwarded, and user searches in Exchange Online and SharePoint Online. Such detailed logging is crucial for forensic and compliance investigations to investigate breaches and determine the scope of compromise.
But threat actors despise this feature because it's a major headache. This log is crucial for spotting if a threat actor has accessed a specific mailbox and understanding the full extent of the breach, as it can even detect activity through the Graph API or PowerShell.
As per microsoft "Assigning and removing licenses for a user requires the User.ReadWrite.All permission scope or one of the other permissions listed in the 'Assign license' Microsoft Graph API reference page." Therefore, if an attacker compromises a user account with these permissions, they could potentially disable the mentioned service plan, effectively turning off advanced auditing for that user.
Emulating APT29 Technique: Disabling Advanced Audit by Modifying Licenses
This stealthy method falls under the following tactics and techniques:
To emulate APT29's tactics, we utilized Microsoft Graph PowerShell to disable the M365_ADVANCED_AUDITING service plan from E5 license, akin to their method of using Azure AD PowerShell, which has been deprecated since March 30, 2024.
# Authenticate
Connect-MgGraph -Scopes User.ReadWrite.All, Organization.Read.All
#Find whether user assigned with E5 license and serviceplan
Get-MgUserLicenseDetail -UserId <UserEmail> | ? {$_.SkuId -eq "06ebc4ee-1bb5-47dd-8120-11324bc54e06"} | Select-Object -ExpandProperty ServicePlans | ?{$_.ServicePlanName -eq "M365_ADVANCED_AUDITING"} | Select-Object @{Name='UserPrincipalName';Expression={$user.UserPrincipalName}},AppliesTo,ProvisioningStatus,ServicePlanId,ServicePlanName
#collect GUID of license and serviceplan names.
$e5Sku = Get-MgSubscribedSku -All | Where SkuPartNumber -eq 'SPE_E5'
$disabledPlans = $e5Sku.ServicePlans | Where ServicePlanName -eq "M365_ADVANCED_AUDITING" | Select -ExpandProperty ServicePlanId
$addLicenses = @(
@{
SkuId = $e5Sku.SkuId
DisabledPlans = $disabledPlans
}
)
# finally disable the service plan
Set-MgUserLicense -UserId "<UserEmail>" -AddLicenses $addLicenses -RemoveLicenses @()
(OR)
#You can directly use these IDs to execute the Set-MgUserLicense command instead of first fetching the SKUids.
#Microsoft 365 E5 (06ebc4ee-1bb5-47dd-8120-11324bc54e06)
#M365_ADVANCED_AUDITING(2f442157-a11c-46b9-ae5b-6e39ff4e5849)
$addLicenses = @(
@{
SkuId = $e5Sku.SkuId
DisabledPlans = $disabledPlans
}
)
Set-MgUserLicense -UserId "[email protected]" -AddLicenses $addLicenses -RemoveLicenses @()
Detection
Reconnaisance
The following API calls were observed in the logs(MicrosoftGraphActivityLogs) during the reconnaissance steps.
The query below attempts to find the same activity.
MicrosoftGraphActivityLogs
| where TimeGenerated >ago(1h)
| where RequestUri endswith "subscribedSkus" or RequestUri endswith "/licenseDetails" or RequestUri endswith "$select=assignedLicenses"
| where RequestMethod=="GET"
| where ResponseStatusCode==200
//| where UserAgent contains "PowerShell"
License Modification
When the license is modified, the API calls were observed to be made to the following endpoints.
MicrosoftGraphActivityLogs
| where TimeGenerated >ago(1h)
| where RequestUri endswith "/microsoft.graph.assignLicense" or RequestUri endswith "/assignLicense"
| where RequestMethod=="POST"
| where ResponseStatusCode==200
Note: You can also identify suspicious user agents such as Mozilla/5.0 (Linux; Ubuntu 22.04 LTS; en-US) PowerShell/7.4.3
License Modification - Disabling Advanced Audit
Some properties in the AuditLogs have changed since Mandiant wrote the paper in 2022.
As highlighted in the above image, Mandiant was experiencing difficulty in parsing nested JSON values because sometimes the JSON object is not valid. Hence, they provided a simple query without parsing, as shown.
Cracking JSON Chaos: Conquering Parsing Puzzles!
So, we decied to solve the above mentioned JSON parsing issue to write effective KQL query. Hence, after modifying the license via PowerShell, we began reviewing the AuditLogs. We noticed that two operation names were generated for the same action: "Change user license" and "Update user." This occurrence is common because any modification to a user's properties triggers the "Update user" operation. This is because modifying a user's license is considered a change to the user's properties, which triggers the "Update user" operation. However, "Change user license" specifically denotes a license modification. Therefore, both operations may appear with identical values, indicating duplication in the logs.
Additionally, each record in the AuditLogs shows a minute difference in timestamp, and all records share the same CorrelationId. This indicates that they represent a single specific operation, despite multiple records being generated.
Since both 'Update user' and 'Change user license' contain identical data, we focused solely on the 'Change user license' operation. We began investigating why five records were generated for a single operation. We observed that only the values of the "seq" and "b" keys were changing across all rows.
Next, we extracted the JSON value with nested JSON from the "b" key starting with {\"targetUpdatedProperties\", and we found that it was incomplete.
During our deep dive, we discovered that the modified properties in JSON format were too large to fit in a single row. To handle this, Microsoft divided the entire JSON value into 5 parts and distributed each part across 5 rows. The 'c' key indicates how many rows the value was divided into, which in our case is 5. Additionally, to ensure the parts can be correctly reassembled into the original JSON structure, a sequence number ('seq' key) is assigned to each part in every row, indicating its order.
领英推荐
We've solved most of the puzzle. Now, we just need to combine all parts into a single JSON value. Before doing that, we need to sort the rows by the "seq" value. Otherwise, we might end up with the wrong sequence, resulting in incorrect JSON data. The sort by sequence asc part from below query does that.
AuditLogs
| where TimeGenerated > ago(1d)
| where OperationName == "Change user license"
| mv-expand TargetResources
| extend InitiatedApp=tostring(InitiatedBy.app.displayName)
| extend InitiatedPrincipalId=tostring(InitiatedBy.app.servicePrincipalId)
| extend InitiatedUser=tostring(InitiatedBy.user.userPrincipalName)
| extend TargetUser=tostring(TargetResources.userPrincipalName)
| extend ipAddress=tostring(InitiatedBy.user.ipAddress)
| extend value=parse_json(AdditionalDetails[2].value)
| extend AppDisplayName=tostring(InitiatedBy.user.displayName)
| extend sequence=iff(AdditionalDetails[1].key == "seq", toint(AdditionalDetails[1].value), toint(AdditionalDetails[1].value))
//| extend maximumlength=iff(AdditionalDetails[3].key == "c", toint(AdditionalDetails[3].value), toint(AdditionalDetails[3].value))
| sort by sequence asc
Now that the data is sorted by the seq value in ascending order as shown in below image.
Now we can start reassembling JSON value. When reconstructing the JSON data from its divided parts, each part is ordered via sequence number and combined based on a unique identifier(either the CorrelationId or AdditionalDetails[0].id) to ensure that only parts belonging to the same operation are combined together and prevents mixing parts from different operations. To sum up all parts, we used the make_list() function to create a list of all parts and then the strcat_array() function to combine them into one value as shown.
LicenseUpdateProperties=strcat_array(make_list(value), '') by CorrelationId
Here's the KQL query that sums up all parts into one:
AuditLogs
| where TimeGenerated > ago(1d)
| where OperationName == "Change user license"
| mv-expand TargetResources
| extend InitiatedApp=tostring(InitiatedBy.app.displayName)
| extend InitiatedPrincipalId=tostring(InitiatedBy.app.servicePrincipalId)
| extend InitiatedUser=tostring(InitiatedBy.user.userPrincipalName)
| extend TargetUser=tostring(TargetResources.userPrincipalName)
| extend ipAddress=tostring(InitiatedBy.user.ipAddress)
| extend value=parse_json(AdditionalDetails[2].value) //JSON data of modified properties.
| extend AppDisplayName=tostring(InitiatedBy.user.displayName)
| extend sequence=iff(AdditionalDetails[1].key == "seq", toint(AdditionalDetails[1].value), toint(AdditionalDetails[1].value))
//| extend maximumlength=iff(AdditionalDetails[3].key == "c", toint(AdditionalDetails[3].value), toint(AdditionalDetails[3].value))
| sort by sequence asc
| summarize TimeGenerated=min(TimeGenerated),OperationName=make_set(OperationName)[0],InitiatedVia=make_list(AppDisplayName)[0],InitiatedUser=make_list(InitiatedBy.user.userPrincipalName)[0],InitiatedApp=make_list(InitiatedApp)[0],InitiatedPrincipalId=make_list(InitiatedPrincipalId)[0], TargetUser=make_list(TargetUser)[0], ipAddress=make_list(ipAddress)[0],LicenseUpdateProperties=strcat_array(make_list(value), '') by CorrelationId
Now that the combined data is parsed beautifully and appears well-structured, as shown:
The breakdown below indicates that M365_ADVANCED_AUDITING was disabled from the E5 license (SPE_E5).
Later, we parsed those fields to provide a fully developed KQL query to detect Advanced Auditing disablement as shown:
//Find Advanced Auditing disablement
AuditLogs
| where TimeGenerated > ago(10h)
| where OperationName == "Change user license"
| mv-expand TargetResources
| extend InitiatedApp=tostring(InitiatedBy.app.displayName)
| extend InitiatedPrincipalId=tostring(InitiatedBy.app.servicePrincipalId)
| extend InitiatedUser=tostring(InitiatedBy.user.userPrincipalName)
| extend TargetUser=tostring(TargetResources.userPrincipalName)
| extend ipAddress=tostring(InitiatedBy.user.ipAddress)
| extend value=parse_json(AdditionalDetails[2].value)
| extend AppDisplayName=tostring(InitiatedBy.user.displayName)
| extend sequence=toint(AdditionalDetails[1].value)
| extend maximumlength=toint(AdditionalDetails[3].value)
| sort by sequence asc
| summarize TimeGenerated=min(TimeGenerated),OperationName=make_set(OperationName)[0],InitiatedVia=make_list(AppDisplayName)[0],InitiatedUser=make_list(InitiatedBy.user.userPrincipalName)[0], TargetUser=make_list(TargetUser)[0], ipAddress=make_list(ipAddress)[0],InitiatedApp=make_list(InitiatedApp)[0],InitiatedPrincipalId=make_list(InitiatedPrincipalId)[0],LicenseUpdateProperties=strcat_array(make_list(value), '') by CorrelationId // strcat_array function combines the divided modified properties into one proper json value.
| extend UserAgent=parse_json(tostring(parse_json(LicenseUpdateProperties).additionalDetails)).["User-Agent"]
| extend AssignedLicense=parse_json(tostring(parse_json(LicenseUpdateProperties).targetUpdatedProperties))[0]
| extend AssignedLicenseNewValue=parse_json(tostring(parse_json(LicenseUpdateProperties).targetUpdatedProperties))[0].NewValue
| extend AssignedLicenseOldValue=parse_json(tostring(parse_json(LicenseUpdateProperties).targetUpdatedProperties))[0].OldValue
| extend AssignedLicenseDetails=parse_json(tostring(parse_json(LicenseUpdateProperties).targetUpdatedProperties))[2]
//| extend AssignedLicenseDetailsNewValue=parse_json(tostring(parse_json(LicenseUpdateProperties).targetUpdatedProperties))[2].NewValue
//| extend AssignedLicenseDetailsOldValue=parse_json(tostring(parse_json(LicenseUpdateProperties).targetUpdatedProperties))[2].OldValue
| extend AssignedLicenseDetailsNewValue=iff(parse_json(tostring(parse_json(LicenseUpdateProperties).targetUpdatedProperties))[2].Name=="LicenseAssignmentDetail",parse_json(tostring(parse_json(LicenseUpdateProperties).targetUpdatedProperties))[2].NewValue,parse_json(''))
| extend AssignedLicenseDetailsOldValue=iff(parse_json(tostring(parse_json(LicenseUpdateProperties).targetUpdatedProperties))[2].Name=="LicenseAssignmentDetail",parse_json(tostring(parse_json(LicenseUpdateProperties).targetUpdatedProperties))[2].OldValue,parse_json(''))
| mv-expand AssignedLicenseNewValue, AssignedLicenseDetailsNewValue
//convert the string data to proper dictionary
| extend AssignedLicenseNewValue=iff(isnotempty(AssignedLicenseNewValue),parse_json(strcat('{"',substring(replace_string(replace_string(replace_string(replace_string(replace_string(tostring(parse_json(AssignedLicenseNewValue)),'=[','":["'),"=",'":"'),",",'","'),"]]",'"]}'),'" ','"'),1))),parse_json(''))
| where isnotempty(parse_json(AssignedLicenseNewValue).DisabledPlans[0]) //exclude records that does not have any disabled plans
| mv-expand AssignedLicenseDetailsOldValue, AssignedLicenseOldValue
| extend AssignedLicenseOldValue=iff(isnotempty(AssignedLicenseOldValue),parse_json(strcat('{"',substring(replace_string(replace_string(replace_string(replace_string(replace_string(tostring(parse_json(AssignedLicenseOldValue)),'=[','":["'),"=",'":"'),",",'","'),"]]",'"]}'),'" ','"'),1))),parse_json(''))
| extend OldSkuName=tostring(AssignedLicenseOldValue.SkuName)
| extend NewSkuName=tostring(AssignedLicenseNewValue.SkuName)
| extend Oldskuid=tostring(AssignedLicenseDetailsOldValue.SkuId)
| extend NewSkuid=tostring(AssignedLicenseDetailsNewValue.SkuId)
| extend OldDisabledPlan=AssignedLicenseOldValue.DisabledPlans
| extend NewDisabledPlan=AssignedLicenseNewValue.DisabledPlans
| extend OldDisabledPlanGUID=AssignedLicenseDetailsOldValue.DisabledPlans
| extend NewDisabledPlanGUID=AssignedLicenseDetailsNewValue.DisabledPlans
| where OldSkuName==NewSkuName and Oldskuid==NewSkuid // both the SKU names should be same. Then only we can calculate what has changed from old and new for each SKU license.
| extend ['Disabled Service Plan']=iff(isnotempty(OldDisabledPlan[0]),set_difference(NewDisabledPlan,OldDisabledPlan),NewDisabledPlan)
| extend ['Disabled Service Plan GUID']=iff(isnotempty(OldDisabledPlanGUID[0]), set_difference(NewDisabledPlanGUID,OldDisabledPlanGUID),NewDisabledPlanGUID)
| where array_length(['Disabled Service Plan'])!=0 // Do not show any empty values. This can occur if there are any errors.
| where ['Disabled Service Plan'] has "M365_ADVANCED_AUDITING" or ['Disabled Service Plan GUID'] has "2f442157-a11c-46b9-ae5b-6e39ff4e5849" //Look for M365_ADVANCED_AUDITING disablement
| project TimeGenerated,OperationName, InitiatedUser,UserAgent, InitiatedVia, ipAddress,TargetUser,['Disabled Service Plan'],['Disabled Service Plan GUID'], InitiatedApp, InitiatedPrincipalId,LicenseUpdateProperties,AssignedLicense, OldSkuName, NewSkuName, OldDisabledPlan, NewDisabledPlan,OldDisabledPlanGUID,NewDisabledPlanGUID
We have also written a KQL query for detecting Deletion of Entire License
We have also written a KQL query for detecting Deletion of Entire License
//Find Deleted Licenses
AuditLogs
| where TimeGenerated > ago(3d)
| where OperationName == "Change user license"
| mv-expand TargetResources
| extend InitiatedApp=tostring(InitiatedBy.app.displayName)
| extend InitiatedPrincipalId=tostring(InitiatedBy.app.servicePrincipalId)
| extend InitiatedUser=tostring(InitiatedBy.user.userPrincipalName)
| extend TargetUser=tostring(TargetResources.userPrincipalName)
| extend ipAddress=tostring(InitiatedBy.user.ipAddress)
| extend value=parse_json(AdditionalDetails[2].value)
| extend AppDisplayName=tostring(InitiatedBy.user.displayName)
| extend sequence=iff(AdditionalDetails[1].key == "seq", toint(AdditionalDetails[1].value), toint(AdditionalDetails[1].value))
//| extend maximumlength=iff(AdditionalDetails[3].key == "c", toint(AdditionalDetails[3].value), toint(AdditionalDetails[3].value))
| sort by sequence asc
| summarize TimeGenerated=min(TimeGenerated),OperationName=make_set(OperationName)[0],InitiatedVia=make_list(AppDisplayName)[0],InitiatedUser=make_list(InitiatedBy.user.userPrincipalName)[0],InitiatedApp=make_list(InitiatedApp)[0],InitiatedPrincipalId=make_list(InitiatedPrincipalId)[0], TargetUser=make_list(TargetUser)[0], ipAddress=make_list(ipAddress)[0],LicenseUpdateProperties=strcat_array(make_list(value), '') by CorrelationId
| extend UserAgent=parse_json(tostring(parse_json(LicenseUpdateProperties).additionalDetails)).["User-Agent"]
//| extend AssignedLicense=parse_json(tostring(parse_json(LicenseUpdateProperties).targetUpdatedProperties))[0]
| extend AssignedLicenseNewValue=array_sort_asc(parse_json(tostring(parse_json(LicenseUpdateProperties).targetUpdatedProperties))[0].NewValue)
| extend AssignedLicenseOldValue=array_sort_asc(parse_json(tostring(parse_json(LicenseUpdateProperties).targetUpdatedProperties))[0].OldValue)
| where isnotempty(AssignedLicenseOldValue[0])
| where array_length(AssignedLicenseNewValue) < array_length(AssignedLicenseOldValue)
| mv-expand AssignedLicenseOldValue, AssignedLicenseNewValue
| extend AssignedLicenseOldValue=iff(isnotempty(AssignedLicenseOldValue), parse_json(strcat('{"',substring(replace_string(replace_string(replace_string(replace_string(replace_string(tostring(parse_json(AssignedLicenseOldValue)),'=[','":["'),"=",'":"'),",",'","'),"]]",'"]}'),'" ','"'),1))),parse_json(''))
| extend OldSkuName=tostring(AssignedLicenseOldValue.SkuName)
| extend AssignedLicenseNewValue=iff(isnotempty(AssignedLicenseNewValue),parse_json(strcat('{"',substring(replace_string(replace_string(replace_string(replace_string(replace_string(tostring(parse_json(AssignedLicenseNewValue)),'=[','":["'),"=",'":"'),",",'","'),"]]",'"]}'),'" ','"'),1))),parse_json(''))
| extend NewSkuName=tostring(AssignedLicenseNewValue.SkuName)
//| project TargetUser,AssignedLicenseNewValue, AssignedLicenseOldValue, LicenseUpdateProperties
| summarize
TimeGenerated=min(TimeGenerated),
OperationName=make_set(OperationName)[0],
InitiatedVia=make_set(InitiatedVia)[0],
InitiatedUser=make_set(InitiatedUser)[0],
TargetUser=make_set(TargetUser)[0],
ipAddress=make_set(ipAddress)[0],
LicenseUpdateProperties=make_set(LicenseUpdateProperties)[0],
OldSkuName=make_list(OldSkuName),
NewSkuName=make_list(NewSkuName),
UserAgent=make_list(UserAgent)[0],
InitiatedApp=make_list(InitiatedApp)[0],
InitiatedPrincipalId=make_list(InitiatedPrincipalId)[0]
by CorrelationId
| extend ['Deleted Licenses']=tostring(set_difference(OldSkuName,NewSkuName))
| project TimeGenerated, OperationName, InitiatedUser,InitiatedApp,ipAddress,TargetUser, ['Deleted Licenses'],InitiatedVia,InitiatedPrincipalId,LicenseUpdateProperties, UserAgent
We also observed an interesting value that indicates whether the service plan was enabled or disabled:
Values from the "AssignedPlan" key from parsed JSON.
NewValue:
{"SubscribedPlanId":"<subscribedPlanID>","ServiceInstance":"exchange/<masked>","CapabilityStatus":3,"AssignedTimestamp":"2024-07-02T12:55:31.6938273Z","InitialState":null,"Capability":null,"ServicePlanId":"2f442157-a11c-46b9-ae5b-6e39ff4e5849"}
OldValue:
{"SubscribedPlanId":"<subscribedplanId>","ServiceInstance":"exchange/<masked>","CapabilityStatus":0,"AssignedTimestamp":"2023-10-13T07:21:40Z","InitialState":null,"Capability":null,"ServicePlanId":"2f442157-a11c-46b9-ae5b-6e39ff4e5849"}
As we referred to the API documentation for the CapabilityStatus property, we found that the values are denoted differently. Perhaps Microsoft is using a different convention for logs.
You can find the GUID's of Licenses and their ServicePlans from here
Other Methods to disable Unified Audit Logging
This method uses Exchange Powershell Online cmdlets to disable Advanced Auditing.
Emulation:
The below Microsoft Graph Powershell commands can disable Advanced Audit.
Connect-ExchangeOnlineManagement
Get-AdminAuditLogConfig |Select-Object -ExpandProperty UnifiedAuditLogIngestionEnabled
Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $false
Detection
The below KQL query detects disabling of Unified Audit Logs.
OfficeActivity
| where OfficeWorkload=="Exchange"
| where Operation=="Set-AdminAuditLogConfig"
| where ResultStatus==true
| mv-expand Parameters=parse_json(Parameters)
| where Parameters.Name=="UnifiedAuditLogIngestionEnabled" and Parameters.Value=="False"
| extend AppName=iff(AppId=="fb78d390-0c51-40cd-8e17-fdbfab77341b","Microsoft Exchange REST API Based Powershell",AppId)
| project TimeGenerated,ElevationTime, UserType,Operation,ResultStatus, OfficeObjectId, UserId, ClientIP, ExternalAccess, AppName, OrganizationName
Defender for XDR also detects this change as shown.
Disabling Unified Audit Logs generates an Incident in Microsoft Defender XDR(Microsoft 365 Defender) as shown.
CloudAppEvents table is another source for detecting this.
CloudAppEvents
| where ActionType=~"Set-AdminAuditLogConfig"
| mv-expand ActivityObjects
| where parse_json(ActivityObjects).Name == 'UnifiedAuditLogIngestionEnabled'
| where parse_json(ActivityObjects).Value == 'True'
| extend Parameters=parse_json(RawEventData).Parameters
You can also detect via powershell commands.
Search-AdminAuditLog -Cmdlets Set-AdminAuditLogConfig -Parameters UnifiedAuditLogIngestionEnabled
Author: Ashokkrishna Vemuri