UNC5221 / VerdantBamboo — Brickstorm + Plenet + AgentPSD in Microsoft 365
Executive Summary
UNC5221 (tracked as VerdantBamboo) is a Chinese espionage actor active since at least 2023, known for exploiting zero-days in internet-facing edge/appliance devices for initial access and then living in the cloud / Microsoft 365 for long-term collection. In the June 2026 campaign the actor maintained access to victim M365 / Entra ID tenants for roughly 18 months before detection, and additionally compromised the victim's managed service provider (MSP) to regain entry after partial remediation.
The hallmark tool is Brickstorm — a stealthy Go/Rust backdoor historically planted on devices that do not run EDR (SSL-VPNs, NAS, storage-sync appliances, retired email-archive servers). Brickstorm speaks WebSocket C2 and uses proxying to blend into legitimate traffic and to defeat Conditional Access from a trusted-looking source. Two newer families round out the arsenal: Plenet (a.k.a. Grimbolt), a cross-platform .NET backdoor with remote command execution, file management, interactive shell, and switchable C2 infrastructure; and AgentPSD, a lightweight Python reverse shell kept as a backup persistence mechanism on a separate domain.
Because the most durable access lives in M365/Entra audit telemetry and on EDR-less edge devices, this hunt deliberately balances native audit-log hunts (§7 — Entra/UAL anomalous OAuth consent, new service principals, mailbox/Graph collection, impossible travel, long-lived token reuse, MSP/delegated-admin anomalies) with a smaller set of endpoint CQL hunts (§8) for the parts that touch managed Windows/Linux hosts (Plenet .NET execution, AgentPSD python reverse shell, edge-adjacent egress, beaconing, persistence).
Source Review & Web Hunter Notes
| Tier | Source | Key Finding | Carry |
|---|---|---|---|
| 1 · News / vendor | BleepingComputer — Chinese APT new malware to keep access | Brickstorm (Go/Rust, WebSocket C2, proxy); Plenet/Grimbolt (.NET, switchable C2); AgentPSD (Python reverse shell, fallback domain); edge/NAS/MSP placement; CA evasion via proxy | yes |
| 1 · News | IBTimes — hid inside M365 ~18 months with secret malware arsenal | ~18-month dwell inside M365; arsenal of three families; espionage motive | yes |
| 2 · News | Abijita — Brickstorm backdoor to access M365 environments | Brickstorm as the access-maintenance tool into M365; edge-device origin | yes |
| 2 · Historical | UNC5221 / Brickstorm prior reporting (edge 0-days, 2023+) | Pattern: edge-device 0-day → cloud/M365 espionage; Brickstorm on EDR-less appliances | partial |
Decisions: cloud-first (the durable persistence is M365/Entra control-plane access, not on-host) — §7 native hunts carry the most weight. Atomic IOCs (hashes/domains) are sparse in public reporting and the actor rotates C2, so the pack anchors on behavior: anomalous OAuth/service-principal creation, long-lived token reuse, proxy-sourced auth, Plenet .NET / AgentPSD python execution, edge-adjacent egress. Brickstorm-on-edge is explicitly logged as a GAP (no EDR coverage) — handled via egress pivots and out-of-band appliance review rather than pretending endpoint telemetry exists.
Hunt Brief & Attack Chain
Working hypothesis: UNC5221 exploited an internet-facing edge/appliance device to plant Brickstorm, proxied through it to harvest and reuse credentials/tokens against M365, established cloud persistence via rogue OAuth apps / service principals, collected mail and data over Graph, dropped Plenet and AgentPSD on reachable hosts as backups, and pivoted to the MSP for resilient re-entry.
| Step | Behavior | Telemetry | Hunt Angle |
|---|---|---|---|
| 1 · Initial access | 0-day on internet-facing edge/appliance (SSL-VPN, storage-sync, NAS) | Appliance logs (out-of-band) | N6; Q3 (edge-adjacent) |
| 2 · Brickstorm implant | Go/Rust backdoor on EDR-less device; WebSocket C2 + proxy | Netflow; appliance logs | Q3; N6 · GAP |
| 3 · Cloud auth abuse | Proxied/stolen-cred logon to M365; CA evasion | Entra sign-in; UAL | N1, N4 |
| 4 · Cloud persistence | Rogue OAuth consent; new service principal / app registration | Entra audit; UAL | N1, N2 |
| 5 · Collection | Mailbox / eDiscovery / Graph API mass read & export | UAL MailItemsAccessed; Graph | N3 |
| 6 · Endpoint backups | Plenet (.NET) + AgentPSD (python reverse shell) on reachable hosts | ProcessRollup2 | Q1, Q2, Q5 |
| 7 · MSP pivot | BSD Brickstorm on MSP infra; delegated-admin abuse | UAL/partner; appliance | N5; N6 |
| 8 · C2 / beacon | WebSocket beaconing; Plenet C2 switching; AgentPSD fallback domain | NetworkConnectIP4; DNS | Q4 |
Consolidated IOC Table
REPLACE_WITH_… placeholders and populate from the vendor GitHub IOC list during triage. The durable surface is the cloud-control-plane and execution behaviors below.| Type | Value | Conf | Action | Context |
|---|---|---|---|---|
| Behavior (cloud) | Rogue OAuth app consent / new service principal granting Graph Mail.Read, full_access_as_app | high | hunt | Cloud persistence — flagship |
| Behavior (cloud) | Long-lived token reuse / proxied sign-in evading Conditional Access | high | hunt | Brickstorm proxy auth |
| Behavior (cloud) | Mass MailItemsAccessed / New-ComplianceSearch / Graph bulk export | medium | hunt | Collection |
| Malware | Plenet / Grimbolt — cross-platform .NET backdoor (switchable C2) | medium | hunt | Endpoint backup |
| Malware | AgentPSD — Python reverse shell (fallback persistence, separate domain) | medium | hunt | Endpoint backup |
| Malware | Brickstorm — Go/Rust backdoor, WebSocket C2, proxy (edge/NAS/BSD/MSP) | medium | hunt | Edge — EDR-less GAP |
| Network | WebSocket beaconing from edge-adjacent hosts; AgentPSD fallback C2 domain | medium | hunt | C2 |
| Indicator | REPLACE_WITH_SHA256 / REPLACE_WITH_C2 (populate from vendor GitHub IOCs) | low | enrich | Perishable / rotating |
Affected Surface & Telemetry Matrix
| Surface | Required Telemetry | Priority | Gap Risk |
|---|---|---|---|
| M365 / Entra ID control plane | Unified Audit Log + Entra sign-in/audit + Graph activity | Critical | Medium — UAL/Graph audit must be ON & retained |
| Exchange Online mailboxes | MailItemsAccessed, eDiscovery / ComplianceSearch audit | Critical | Medium — mailbox auditing must be enabled |
| Internet-facing edge / appliances | Appliance/syslog + netflow (no EDR) | High | GAP — Brickstorm runs where Falcon cannot |
| Managed Windows/Linux hosts | Process lineage + module + network (Falcon) | High | Low — standard EDR telemetry |
| MSP / delegated admin (GDAP) | Partner/UAL delegated-admin activity | High | High — cross-tenant visibility often thin |
| Egress (C2 / WebSocket) | Proxy / DNS / netflow | Medium | Medium — proxied / TLS-blended |
ATT&CK Mapping
| Tactic | Technique | Observed Behavior | Query / Control |
|---|---|---|---|
| Initial Access | T1190 — Exploit public-facing application | 0-day on internet-facing edge/appliance | N6; Q3 |
| Persistence (cloud) | T1098.001 / T1136.003 — Additional cloud credentials / create cloud account | Rogue OAuth app / new service principal | N1, N2 |
| Persistence (endpoint) | T1543 / T1053 — Service / scheduled task | Plenet / AgentPSD persistence on hosts | Q5 |
| Defense Evasion | T1550.001 / T1090 — App access token / proxy | Token reuse + Brickstorm proxy evading CA | N4, Q3 |
| Credential Access | T1528 — Steal application access token | Long-lived token harvest/reuse | N4 |
| Collection | T1114 / T1530 — Email & cloud data collection | MailItemsAccessed / Graph bulk export | N3 |
| Execution | T1059.006 / T1106 — Python / native API | AgentPSD python reverse shell; Plenet .NET | Q1, Q2 |
| Command & Control | T1071.001 / T1573 — Web (WebSocket) / encrypted C2 | Brickstorm WebSocket; Plenet switchable C2 | Q4 |
| Initial Access (supply chain) | T1199 — Trusted relationship (MSP) | MSP / delegated-admin abuse for re-entry | N5 |
Native Audit-Log Hunts (Entra ID / M365 UAL — KQL starters)
Search-UnifiedAuditLog queries). Confirm Unified Audit Log, mailbox auditing, and Graph activity logs are enabled and retained ≥ 1 year before trusting an absence of hits.N1 · Anomalous OAuth app consent
Newly granted consent to apps requesting high-privilege Graph scopes (Mail.Read, Mail.ReadWrite, full_access_as_app, Directory.Read.All) — the classic cloud-persistence move.
AuditLogs
| where OperationName in ("Consent to application","Add app role assignment grant to user","Add delegated permission grant")
| extend props = TargetResources
| where tostring(props) has_any ("Mail.Read","Mail.ReadWrite","full_access_as_app","Directory.Read.All","Files.Read.All")
| project TimeGenerated, OperationName, InitiatedBy, TargetResources, Result
N2 · New service principal / app registration
Service principals or app registrations created then immediately granted credentials (secret/cert) — UNC5221 establishes app-only access that survives password resets.
AuditLogs
| where OperationName in ("Add service principal","Add application","Add application credentials",
"Update application - Certificates and secrets management","Add service principal credentials")
| project TimeGenerated, OperationName, InitiatedBy, TargetResources, AdditionalDetails
| order by TimeGenerated asc
N3 · Mailbox / eDiscovery / Graph bulk collection
Mass mailbox reads, new compliance searches, or app-driven bulk Graph mail export — the espionage collection step.
OfficeActivity
| where Operation in ("MailItemsAccessed","New-ComplianceSearch","New-ComplianceSearchAction",
"SearchCreated","Add-MailboxPermission")
| where ClientAppId != "" or AppId != ""
| summarize Mailboxes = dcount(MailboxOwnerUPN), Events = count() by ClientAppId, Operation, bin(TimeGenerated, 1h)
| where Mailboxes > 25 or Events > 500
N4 · Long-lived token reuse / impossible travel / proxied sign-in
Token reuse across distant geographies, or sign-ins from hosting/proxy ASNs that nonetheless satisfy Conditional Access (Brickstorm proxy blending). Flag refresh-token age and non-interactive sign-ins from new ASNs.
SigninLogs
| where ResultType == 0
| extend ASN = tostring(parse_json(tostring(AutonomousSystemNumber)))
| summarize Locations = make_set(Location), ASNs = make_set(ASN), Apps = make_set(AppDisplayName)
by UserPrincipalName, bin(TimeGenerated, 1h)
| where array_length(Locations) > 1
| where ASNs has_any ("hosting","datacenter") // tune to known proxy/VPS ASNs
N5 · MSP / delegated-admin (GDAP) access anomalies
Partner / delegated-admin actions from unusual times, ASNs, or against privileged objects — the MSP pivot used for resilient re-entry.
AuditLogs
| where InitiatedBy has "partner" or AdditionalDetails has "DelegatedAdmin" or LoggedByService == "Partner"
| where OperationName has_any ("Add member to role","Reset password","Update application","Add service principal")
| project TimeGenerated, OperationName, InitiatedBy, TargetResources, AdditionalDetails
N6 · Out-of-band edge / appliance review (no EDR)
Not a SIEM query — a runbook item. Pull SSL-VPN / NAS / storage-sync / email-archive appliance logs and netflow out of band; hunt for unexpected outbound WebSocket sessions, new listening services, and persistence on devices Falcon cannot see. This is the only reliable path to Brickstorm.
CrowdStrike LogScale CQL Hunt Queries
SyntheticProcessRollup2.Looks for: python.exe/python3 spawning a shell (cmd/powershell/bash) — the AgentPSD reverse-shell pattern. FP: dev tooling, build agents, and DevOps automation legitimately spawn shells from python — exclude known build/CI hosts and developer endpoints.
// HUNT: AgentPSD python reverse shell -> shell child // MITRE: T1059.006, T1059.001 | CONF: high FP: medium COST: low // REQUIRES: Falcon process telemetry (managed hosts) // FALSE POSITIVES: dev/CI hosts, IT automation; exclude known build agents #event_simpleName=/ProcessRollup2|SyntheticProcessRollup2/ | ParentBaseFileName=/^python(3|\d\.\d+)?(\.exe)?$/i | FileName=/^(cmd|powershell|pwsh|bash|sh)(\.exe)?$/i | table([@timestamp, ComputerName, UserName, ParentBaseFileName, FileName, CommandLine], limit=200)
Looks for: a .NET assembly or dotnet.exe running from a user/temp/public path (Plenet is cross-platform .NET), or a non-standard binary loading the CLR from those paths. FP: some legitimate .NET apps run from AppData (installers/updaters) — flag user/temp/public + unsigned context and correlate to Q4 egress.
// HUNT: Plenet/.NET backdoor from user/temp/public path // MITRE: T1059, T1106 | CONF: medium FP: medium COST: low // REQUIRES: Falcon process telemetry // FALSE POSITIVES: AppData updaters/installers; flag unsigned + correlate Q4 #event_simpleName=/ProcessRollup2|SyntheticProcessRollup2/ | ImageFileName=/\\(Temp|AppData|ProgramData|Users\\Public)\\/i | (FileName=/^dotnet(\.exe)?$/i or CommandLine=/(System\.Net\.WebSockets|ClientWebSocket|Assembly\.Load|\.dll["' ]+-)/i) | table([@timestamp, ComputerName, UserName, ParentBaseFileName, ImageFileName, FileName, CommandLine], limit=200)
Looks for: managed hosts in DMZ / edge subnets making unexpected outbound connections (proxy pivot from a Brickstorm-implanted appliance toward the cloud). FP: medium — you must scope to your real edge/DMZ subnets; baseline normal egress first. This is a proxy/pivot pivot, not direct Brickstorm visibility.
// HUNT: edge-adjacent host unexpected outbound (Brickstorm proxy pivot) // MITRE: T1090, T1071.001 | CONF: medium FP: medium COST: med // REQUIRES: Falcon network telemetry; edit DMZ/edge subnet regex below // FALSE POSITIVES: legit edge mgmt traffic; baseline before alerting #event_simpleName=NetworkConnectIP4 | LocalAddressIP4=/^10\.(0|1)\./ | in(RemotePort, values=[443, 80, 8443, 53]) | ContextImageFileName=/\\(Temp|AppData|ProgramData|Users\\Public)\\/i | table([@timestamp, ComputerName, ContextImageFileName, LocalAddressIP4, RemoteAddressIP4, RemotePort], limit=200)
Looks for: a non-browser user/temp-path binary making repeated outbound connections to a small set of remote IPs (WebSocket beacon / Plenet C2 switching shows as a few rotating destinations). FP: medium — updaters and telemetry agents beacon too; exclude signed system/browser paths and known SaaS endpoints.
// HUNT: beaconing from user-path binary (WebSocket C2 / Plenet switching) // MITRE: T1071.001, T1573 | CONF: medium FP: medium COST: med // REQUIRES: Falcon network telemetry // FALSE POSITIVES: updaters/telemetry agents; exclude signed system paths #event_simpleName=NetworkConnectIP4 | ContextImageFileName=/\\(Temp|AppData|ProgramData|Users\\Public)\\/i | ContextImageFileName!=/\\(chrome|msedge|firefox|brave|teams|onedrive)\.exe$/i | groupBy([ComputerName, ContextImageFileName, RemoteAddressIP4], function=count(as=hits)) | hits >= 12 | table([ComputerName, ContextImageFileName, RemoteAddressIP4, hits], limit=200)
Looks for: a newly registered service or scheduled task whose image lives in a user/temp/public path — Plenet/AgentPSD backup persistence. FP: low — few legitimate services run from those paths.
// HUNT: service/scheduled task persistence from user path // MITRE: T1543, T1053 | CONF: med-high FP: low COST: low // REQUIRES: Falcon service/scheduled-task telemetry #event_simpleName=/CreateService|ModifyServiceBinary|ScheduledTaskRegistered/ | (ImageFileName=/\\(Temp|AppData|ProgramData|Users\\Public)\\/i or CommandLine=/\\(Temp|AppData|ProgramData|Users\\Public)\\/i or CommandLine=/(python|dotnet)/i) | table([@timestamp, ComputerName, UserName, FileName, ImageFileName, CommandLine], limit=200)
Looks for: python.exe making outbound connections (AgentPSD reverse shell calling home to its fallback domain). FP: medium — python services/agents make outbound calls; exclude known data-science/automation hosts and correlate to Q1.
// HUNT: python outbound (AgentPSD reverse-shell call-home) // MITRE: T1059.006, T1071.001 | CONF: medium FP: medium COST: low // REQUIRES: Falcon network telemetry; correlate to Q1 on same host // FALSE POSITIVES: data-science / automation hosts; exclude & correlate Q1 #event_simpleName=NetworkConnectIP4 | ContextImageFileName=/\\python(3|\d\.\d+)?\.exe$/i | RemotePort != 53 | table([@timestamp, ComputerName, ContextImageFileName, RemoteAddressIP4, RemotePort], limit=200)
CrowdStrike Custom IOA Recommendations
| IOA Name | Field Patterns | Benign Exclusions | Deployment Path |
|---|---|---|---|
| UNC5221 — python reverse shell (AgentPSD) | Parent python(3).exe; Child cmd/powershell/bash/sh | CI/build agents; sanctioned IT automation (allow-list by host/grouping tag) | Endpoint Security → Custom IOA → Process Creation, Detect |
| UNC5221 — service/task from user path | ServiceInstalled / ScheduledTaskRegistered with image in Temp/AppData/ProgramData/Users\Public | Vetted user-path agents (rare) | Custom IOA → Process Creation, Detect+Block |
| UNC5221 — Plenet .NET from user path | dotnet.exe or CLR-loading binary from user/temp/public path | AppData updaters/installers (allow-list) | Custom IOA → Process Creation, Detect |
Machine-Readable IOC Appendix
Grouped IOC Quick-Copy
Atomic IOCs are sparse/rotating — use placeholders and populate from the vendor GitHub IOC list during triage. The durable surface is the cloud-control-plane and execution behaviors.
type,value,action,severity,expiration,description,tags sha256,REPLACE_WITH_PLENET_SHA256,detect,critical,2026-12-17,Plenet .NET backdoor,campaign:UNC5221 sha256,REPLACE_WITH_AGENTPSD_SHA256,detect,high,2026-12-17,AgentPSD python reverse shell,campaign:UNC5221 sha256,REPLACE_WITH_BRICKSTORM_SHA256,detect,critical,2026-12-17,Brickstorm backdoor,campaign:UNC5221 domain,REPLACE_WITH_BRICKSTORM_C2,detect,high,2026-09-17,Brickstorm WebSocket C2,campaign:UNC5221 domain,REPLACE_WITH_AGENTPSD_FALLBACK_C2,detect,high,2026-09-17,AgentPSD fallback C2,campaign:UNC5221 ipv4,REPLACE_WITH_C2_IP,hunt,medium,2026-09-17,Rotating C2 host,campaign:UNC5221
# Run in Sentinel / Defender Advanced Hunting / Search-UnifiedAuditLog N1 OAuth consent to apps requesting Mail.Read / full_access_as_app / Directory.Read.All N2 New service principal or app registration + immediate secret/cert add N3 Mass MailItemsAccessed / New-ComplianceSearch / app-driven Graph bulk export N4 Token reuse across geographies; non-interactive sign-in from hosting/proxy ASN evading CA N5 Partner / delegated-admin (GDAP) actions on privileged objects from unusual ASN/time N6 (out-of-band) edge/NAS/SSL-VPN/email-archive appliance logs: outbound WebSocket, new services # Verify UAL + mailbox auditing + Graph activity logs enabled and retained >= 1 year
Rogue OAuth app / new service principal granted high-privilege Graph scopes Long-lived token / refresh-token reuse; proxied sign-in that satisfies Conditional Access python.exe spawning cmd/powershell/bash (AgentPSD reverse shell) dotnet/.NET assembly executing from Temp/AppData/ProgramData/Users\Public (Plenet) Service or scheduled task registered with image in a user/temp/public path Non-browser user-path binary beaconing to a small rotating set of remote IPs (WebSocket C2) Edge/DMZ managed host making unexpected outbound (Brickstorm proxy pivot)
UNC5221 / VerdantBamboo # Chinese espionage, active since 2023, edge-0day -> cloud/M365 Brickstorm # Go/Rust backdoor, WebSocket C2 + proxy; EDR-less edge/NAS/BSD/MSP placement Plenet / Grimbolt # cross-platform .NET backdoor: shell, RCE, file mgmt, switchable C2 AgentPSD # lightweight Python reverse shell; backup persistence, separate fallback domain Access path: edge-device 0-day -> stolen creds/tokens -> M365 persistence -> MSP pivot Dwell: ~18 months inside M365 before detection
Restrict user OAuth consent; require admin consent for high-privilege Graph scopes Audit ALL existing service principals / app registrations for unexpected credentials Phishing-resistant MFA; revoke + reissue refresh tokens; cut token lifetimes Conditional Access: block legacy auth; require compliant device / managed-network for admin GDAP least-privilege for MSP; review + time-box delegated-admin; alert on partner actions Patch + out-of-band review of internet-facing edge/appliances (no EDR) -> Brickstorm GAP WDAC/app control: default-deny execution from Temp/AppData/Users\Public
Detection Validation Gates
| Gate | Check | Pass Criterion |
|---|---|---|
| Telemetry ready | UAL + mailbox auditing + Graph activity + Entra sign-in logs ON and retained | ≥ 1 year retention; logs queryable in SIEM |
| Telemetry ready (endpoint) | Falcon process + network + service/task telemetry on managed hosts | Coverage gaps mapped; edge devices flagged as GAP |
| Benign baseline | Enumerate sanctioned OAuth apps, service principals, CI/build hosts, AppData updaters | Allow-lists built before promoting N1/N2/Q1/Q2 |
| Positive test | Lab: benign python→cmd (Q1); benign user-path service (Q5); test consent grant (N1) | Each query fires on the benign analog |
| Promotion | Promote Q5 (low FP) + service-principal/consent alerting; keep Q1–Q4/Q6 as tuned hunts | Alerts wired with documented exclusions |
Hardening — Tiered & Deployable
The durable foothold is in the cloud. Prioritize evicting cloud persistence and closing the OAuth/token surface over endpoint controls.
- Audit & remove rogue OAuth apps and service principals; revoke their credentials — M1018 / M1047. The flagship eviction step.
- Revoke and reissue all refresh tokens; shorten token lifetimes — M1026. Kills long-lived token reuse (N4).
- Restrict user consent; require admin approval for high-privilege Graph scopes — M1018.
- Phishing-resistant MFA for all admins; block legacy auth via Conditional Access — M1032 / M1036.
- GDAP least-privilege + time-boxed delegated admin for the MSP; alert on partner actions — M1018. Closes the MSP pivot (N5).
- Conditional Access: require compliant device / managed network for privileged roles — M1035. Blunts proxy-blended sign-in.
- WDAC/app control: default-deny execution from
Temp/AppData/Users\Public— M1038. Blocks Plenet/AgentPSD staging. - Out-of-band patch & review of internet-facing edge/appliances — M1051. The only path at the Brickstorm GAP.
- Continuous app-registration / service-principal governance & anomaly alerting — M1047.
- EDR/visibility for appliance-class devices where supported; segment edge from cloud-admin paths — M1030.
- Mailbox + Graph data-access monitoring with bulk-export thresholds — M1047. Surfaces collection earlier than 18 months.
Deployable Playbooks
1 · Audit & revoke suspicious service principals (Microsoft Graph PowerShell)
Connect-MgGraph -Scopes "Application.Read.All","AuditLog.Read.All"
# List service principals with app-only credentials added recently
Get-MgServicePrincipal -All |
Where-Object { $_.KeyCredentials.Count -gt 0 -or $_.PasswordCredentials.Count -gt 0 } |
Select-Object DisplayName, AppId, Id |
Format-Table
# After confirming a rogue SP, remove its credentials / disable it:
# Update-MgServicePrincipal -ServicePrincipalId <id> -AccountEnabled:$false
2 · Revoke refresh tokens for a compromised / suspect user
Connect-MgGraph -Scopes "User.ReadWrite.All" Revoke-MgUserSignInSession -UserId user@contoso.example # Forces re-auth and invalidates outstanding refresh tokens (kills token reuse, N4)
3 · Restrict user OAuth consent (admin consent workflow)
# Entra admin center -> Enterprise applications -> Consent and permissions # User consent settings: "Do not allow user consent" # Enable the admin consent request workflow for vetted approvals. # Equivalent policy hardening blocks the rogue-consent persistence path (N1).
4 · Hunt + isolate an endpoint backdoor host (Falcon)
# Confirm with Q1/Q2/Q5/Q6, then network-contain via Falcon Host Management. # Preserve memory + collect the user-path binary before remediation. # Block C2 IPs/domains via IOC Management (see CSV in section 10).
Containment Runbook
| Phase | Actions | Owner | Evidence |
|---|---|---|---|
| Isolate | Network-contain confirmed endpoint hosts; out-of-band isolate suspect edge/appliances; preserve UAL/Entra logs and memory before any reset | SOC L2 / IR | Containment timestamps; N1–N5 events; memory images |
| Triage | Map the cloud foothold (rogue apps/SPs, token reuse), collection scope (mail/Graph export), endpoint backups (Plenet/AgentPSD), and MSP path; scope all tenants under shared GDAP | IR | SP/app inventory; sign-in timeline; export volumes |
| Eradicate | Remove rogue OAuth apps/SPs + their creds; revoke/reissue all refresh tokens; rotate admin + service creds; remove endpoint malware & persistence; close MSP path | IR | Removal log; token-revocation record; cred rotation |
| Recover | Reimage backdoored hosts; rebuild/patch compromised edge appliances; verify clean before reconnecting; re-baseline CA policy | IR + IT | Rebuild tickets; appliance firmware versions |
| Harden | Restrict consent; admin-consent workflow; phishing-resistant MFA; GDAP least-privilege; WDAC user-path deny; promote endpoint IOAs; enable bulk-export alerting | Detection Eng | Policy diffs; IOAs enabled; alerts wired |
Detection Coverage Map
| Technique | Behavior | CQL | Native / IOA | Coverage |
|---|---|---|---|---|
| T1098.001 / T1136.003 | Rogue OAuth / service principal | — | N1, N2 | Good (cloud audit) |
| T1550.001 / T1528 | Token reuse / proxied auth (CA evasion) | — | N4 | Partial needs ASN baseline |
| T1114 / T1530 | Mailbox / Graph bulk collection | — | N3 | Partial needs mailbox audit on |
| T1059.006 | AgentPSD python reverse shell | Q1, Q6 | IOA-1 | Good on managed hosts |
| T1059 / T1106 | Plenet .NET execution | Q2 | IOA-3 | Partial correlate Q4 |
| T1543 / T1053 | Endpoint persistence | Q5 | IOA-2 | Good on managed hosts |
| T1071.001 / T1573 | WebSocket C2 / Plenet switching | Q4 | — | Partial beacon heuristic |
| T1199 | MSP / delegated-admin abuse | — | N5 | Partial cross-tenant gap |
| T1190 / T1071.001 | Brickstorm on EDR-less edge device | Q3 (pivot) | N6 (out-of-band) | GAP no EDR — appliance review only |
Validation gates: confirm cloud audit + mailbox + Graph + Entra logs are ON and retained ≥ 1 year (otherwise N1–N5 silently under-report); baseline sanctioned OAuth apps, service principals, CI/build hosts and AppData updaters; lab-test benign analogs for Q1/Q5 and a test consent grant for N1; promote Q5 + consent/service-principal alerting and keep the rest as tuned hunts. The Brickstorm edge GAP cannot be closed with Falcon — it requires out-of-band appliance review (N6).
Hunt Summary Ticket
TITLE: Hunt — UNC5221 / VerdantBamboo (Brickstorm + Plenet + AgentPSD in M365)
SEVERITY: Critical (nation-state espionage; ~18-month M365 dwell; MSP supply-chain pivot)
SCOPE: M365 / Entra control plane; Exchange Online; edge/appliances (no EDR); managed hosts; MSP/GDAP
HYPOTHESIS: Edge 0-day -> Brickstorm on EDR-less appliance -> proxied/stolen-cred M365 access ->
rogue OAuth/service-principal persistence -> mail/Graph collection ->
Plenet (.NET) + AgentPSD (python) endpoint backups -> MSP pivot for re-entry.
QUERIES RUN: Native N1 OAuth consent | N2 new service principal | N3 mailbox/Graph collection |
N4 token reuse / proxied sign-in | N5 MSP/GDAP anomalies | N6 out-of-band appliance review
CQL Q1 AgentPSD python->shell | Q2 Plenet .NET | Q3 edge-adjacent egress |
Q4 beaconing/C2 switching | Q5 user-path persistence | Q6 python outbound
DO FIRST: N1-N3 + N5 — the 18-month dwell lives in the cloud control plane, not the endpoint
FINDINGS: <pending analyst execution>
GAPS: Brickstorm on EDR-less edge devices (no Falcon visibility); cross-tenant MSP visibility;
atomic IOCs sparse/rotating (populate from vendor GitHub IOC list)
ACTIONS: Remove rogue apps/SPs; revoke+reissue refresh tokens; restrict consent; phishing-resistant MFA;
GDAP least-privilege; WDAC user-path deny; promote Q5 + consent/SP alerting; patch edge appliances
OWNER: HuntPack
VERSION: v0.1 - 2026-06-17
Changelog
table() calls now carry an explicit row limit: the default is 200 and truncation is silent, so a capped result was indistinguishable from a complete one. Atomic-IOC sweeps (filters over 5+ hashes or C2 IPs) use limit=max so a wide infection is never silently under-scoped; behavioural hunts use limit=200, where exceeding the cap indicates the query needs tuning. Where present, event names that do not exist in the Falcon data model were corrected (e.g. ServiceInstalled is a Sysmon concept, not a Falcon event; ElfFileWritten is ELFFileWritten) — such queries could never return a row. No detection logic, fields, or IOCs changed.References
| Tier | Source | Used For | Access Date |
|---|---|---|---|
| 1 | BleepingComputer — Chinese APT deploys new malware to keep access | Brickstorm (Go/Rust, WebSocket, proxy), Plenet/Grimbolt (.NET), AgentPSD (python), edge/NAS/MSP placement, CA evasion | 2026-06-17 |
| 1 | IBTimes — Hid inside Microsoft 365 networks 18 months | ~18-month M365 dwell; secret malware arsenal; espionage motive | 2026-06-17 |
| 2 | Abijita — Brickstorm backdoor to access Microsoft 365 | Brickstorm as the M365 access-maintenance tool; edge-device origin | 2026-06-17 |
| 2 | MITRE ATT&CK T1098.001 — Additional Cloud Credentials | Cloud-persistence technique mapping (rogue OAuth / service principal) | 2026-06-17 |
HuntPack v0.1 · UNC5221 / VerdantBamboo (Brickstorm + Plenet + AgentPSD in M365) · Generated 2026-06-17 · Defensive use only — no offensive code. The durable persistence is in the M365/Entra control plane and on EDR-less edge devices — hunt the cloud audit logs (N1–N5) first and review edge appliances out of band (N6). Validate field names before promoting any query.