HollowGraph: Hiding C2 in 2050 Calendar Events via Graph API
Just caught the Group-IB report on HollowGraph, and it’s a textbook example of 'living off the land' taken to the next level. Instead of setting up a new C2 infrastructure, the actors are hijacking Microsoft 365 calendars. They're creating events dated to the year 2050 to store operator instructions and exfiltrating stolen data as attachments.
Since this leverages the legitimate Microsoft Graph API (/me/events), the traffic looks completely normal to standard egress filters. This bypasses most network-based detection logic because it originates from trusted O365 IP ranges.
From a detection standpoint, we need to pivot away from network signatures and focus on the behavior. Specifically, we should hunt for calendar events created with Start or End dates far in the future.
Here’s a KQL query I’m drafting for our Sentinel instance to hunt for this specific behavior:
OfficeActivity
| where Operation in ("New-CalEvent", "Update-CalEvent")
| extend EventDate = parse_(CalculatedFields).Start
| where isnotempty(EventDate)
| where datetime_part("year", todatetime(EventDate)) > 2026
| project TimeGenerated, UserId, ClientIP, Operation, EventDate, ObjectId
| sort by TimeGenerated desc
Has anyone else started hunting for anomalies in Graph API POST requests? I'm wondering if strict Conditional Access policies restricting 'unmanaged devices' would help mitigate this, or if the token theft aspect makes that moot?
We've been seeing a rise in Graph API abuse in general, not just for this. The issue is that blocking these endpoints often breaks legitimate business workflows. Your KQL query is solid, but I’d recommend adding a filter for specific User-Agent strings if you can identify them. We correlate this with IdentityLogonEvents to flag if the calendar creation happens immediately after a suspicious login from a new geo-location.
This is clever OpSec because it turns the victim's own infrastructure into the storage mechanism. As a pentester, I love seeing this, but as a defender, it's a nightmare. If the token has Calendars.ReadWrite permissions, Conditional Access won't stop it. You really need to focus on auditing app permissions. Run this PowerShell snippet to see which registered apps have overly broad calendar access:
Get-MgServicePrincipal -All | foreach {
$app = $_
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $app.Id |
Where-Object { $_.AppRoleId -eq 'YOUR_CALENDAR_READWRITE_GUID' } |
Select-Object ResourceDisplayName, AppDisplayName
}
The 2050 timestamp is the key signature here. We saw a similar concept with steganography in OneNote notes a few months back. The real challenge is the sheer volume of calendar API calls in a large org. You're going to need to tune that query heavily to avoid alert fatigue, maybe focus only on accounts with high-privilege access or those recently flagged in password spray attacks.
The 2050 date is a great immediate signature, but we can't rely on it forever. I recommend enforcing stricter Conditional Access policies specifically for the Calendars.ReadWrite permission, requiring device compliance. Additionally, monitor for anomalous MIME types in calendar attachments—events shouldn't have executables.
OfficeActivity | where Operation == "New-CalendarEvent" and AttachmentCount > 0
Beyond filtering, we need to hunt for what might already be inside. You can use the Microsoft Graph PowerShell SDK to scan for suspiciously dated events across your tenant:
$DateThreshold = (Get-Date).AddYears(5)
Get-MgUserEvent -UserId "user@domain.com" -Filter "start/dateTime ge '$DateThreshold'" | Select-Object Subject, Start
This allows you to quickly identify if any stashed payloads are waiting for execution.
Tom’s PowerShell script is great for retroactive hunting, but we need automated detection in the SIEM. Since the C2 relies on batch creation, we can flag high-frequency event additions. Here is a KQL query for Sentinel to catch this behavior:
AuditLog
| where Operation == "Add calendar event"
| where TargetResources contains "2050"
| summarize EventCount = count() by UserId, ClientIP
| where EventCount > 10
This helps catch the batch instruction sets actors usually drop.
Verified Access Required
To maintain the integrity of our intelligence feeds, only verified partners and security professionals can post replies.
Request Access