-
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathindex.html
More file actions
3594 lines (3188 loc) · 286 KB
/
index.html
File metadata and controls
3594 lines (3188 loc) · 286 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OAuthSentry - OAuth application intelligence for defenders</title>
<meta name="description" content="Search OAuth Application IDs across Microsoft Entra, Google Workspace and more. Three classification feeds for defenders: compliance, risky, and malicious. Includes investigation playbooks, forensic traces, and Splunk detections.">
<meta name="theme-color" content="#0a0b10">
<link rel="icon" type="image/png" sizes="32x32" href="assets/img/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="assets/img/favicon-16.png">
<link rel="icon" type="image/png" sizes="48x48" href="assets/img/favicon-48.png">
<link rel="apple-touch-icon" sizes="180x180" href="assets/img/favicon-180.png">
<link rel="shortcut icon" href="assets/img/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<header class="topbar">
<div class="shell topbar-inner">
<div class="brand">
<a href="#/search" data-route="/search" style="display:flex;align-items:center;gap:12px;">
<span class="brand-mark" aria-hidden="true"></span>
OAuthSentry
</a>
<span class="tag">OAuth APP INTEL FOR DEFENDERS</span>
</div>
<nav class="nav" id="topnav">
<a href="#/search" data-route="/search">Search</a>
<a href="#/investigation/tradecraft" data-route="/investigation">Investigation</a>
<a href="#/triage" data-route="/triage">Triage</a>
<a href="#/tokens" data-route="/tokens">Token decoder</a>
<a href="#/submit" data-route="/submit">Submit</a>
<a href="#/api" data-route="/api">API</a>
<a href="#/feeds" data-route="/feeds">Feeds</a>
<a href="#/methodology" data-route="/methodology">Methodology</a>
<a href="https://github.com/oauthsentry/oauthsentry.github.io" target="_blank" rel="noopener">GitHub ↗</a>
</nav>
</div>
</header>
<main id="app">
<!-- ============ SEARCH PAGE ============ -->
<section id="page-search">
<div class="hero">
<div class="shell">
<h1>OAuth application <span class="accent">intelligence</span></h1>
<p class="lede">
Search any OAuth Application ID across identity platforms. Triage in one click: legitimate references for allowlisting, OAuth apps abused by threat actors, and known-malicious applications observed in BEC, AiTM and consent-phishing campaigns.
</p>
<div class="hero-stats">
<div class="stat"><div class="label">Total tracked</div><div class="value" id="stat-total">...</div></div>
<div class="stat compliance"><div class="label">Compliance</div><div class="value" id="stat-compliance">...</div></div>
<div class="stat risky"><div class="label">Risky</div><div class="value" id="stat-risky">...</div></div>
<div class="stat malicious"><div class="label">Malicious</div><div class="value" id="stat-malicious">...</div></div>
</div>
</div>
</div>
<section class="search-section">
<div class="shell">
<div class="search-shell">
<span class="prompt" aria-hidden="true">></span>
<input id="search" type="search"
placeholder="search by app id, app name, threat actor, or scope - press / to focus"
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<button class="share" id="share-btn" type="button" title="copy a shareable URL of this query">share</button>
<button class="clear" id="clear" type="button">clear</button>
</div>
<div class="filters">
<span class="filter-label">category</span>
<button class="chip on" data-category="all">all</button>
<button class="chip" data-category="compliance">compliance</button>
<button class="chip" data-category="risky">risky</button>
<button class="chip" data-category="malicious">malicious</button>
</div>
<div class="filters">
<span class="filter-label">severity</span>
<button class="chip on" data-severity="all">all</button>
<button class="chip" data-severity="critical">critical</button>
<button class="chip" data-severity="high">high</button>
<button class="chip" data-severity="medium">medium</button>
<button class="chip" data-severity="low">low</button>
<button class="chip" data-severity="info">info</button>
</div>
<div class="filters" id="services-bar">
<span class="filter-label">service</span>
<button class="chip on" data-service="all">all</button>
</div>
<div class="filters">
<span class="filter-label">source</span>
<select class="chip" id="source-filter" aria-label="filter by reference source">
<option value="all">all sources</option>
</select>
<span class="filter-label">last seen</span>
<select class="chip" id="year-filter" aria-label="filter by last-seen year">
<option value="all">any year</option>
</select>
<button class="chip" id="has-comment" data-toggle="off" type="button">analyst notes only</button>
</div>
<div class="results-meta" id="results-meta">
<span><strong>-</strong> results</span>
<span><button class="reset" id="reset-filters" type="button">reset filters</button></span>
</div>
<div id="results" class="results">
<div class="loading">loading data sources</div>
</div>
</div>
</section>
</section>
<!-- ============ INVESTIGATION PAGE ============ -->
<section id="page-investigation" hidden>
<div class="shell">
<div style="padding-top: 48px;">
<div class="pretitle" style="font-family: var(--font-display); font-size: 11px; text-transform: uppercase; letter-spacing: 0.16em; color: var(--text-faint); margin-bottom: 16px; display: flex; align-items: center; gap: 10px;">
<span style="width: 28px; height: 1px; background: var(--accent);"></span>
Investigation playbooks
</div>
<h1 style="font-family: var(--font-display); font-size: clamp(30px, 4vw, 44px); font-weight: 700; letter-spacing: -0.02em; margin: 0 0 16px; line-height: 1.1;">Run an OAuth abuse case<br>from <span style="color: var(--accent);">consent</span> to closure.</h1>
<p style="max-width: 720px; color: var(--text-dim); font-size: 16px; margin: 0 0 36px;">
Three operator-grade playbooks built for SOC, IR and threat hunters: how to evict a malicious app from a tenant, where to find every forensic trace it leaves behind, and how to detect the next one before consent is granted.
</p>
</div>
<nav class="subtabs" id="invest-subtabs">
<a href="#/investigation/tradecraft" data-route="/investigation/tradecraft" class="active">Entra: Tradecraft</a>
<a href="#/investigation/remediation" data-route="/investigation/remediation">Entra: Remediation</a>
<a href="#/investigation/forensics" data-route="/investigation/forensics">Entra: Forensic traces</a>
<a href="#/investigation/detections" data-route="/investigation/detections">Entra: Detections</a>
<a href="#/investigation/hunting" data-route="/investigation/hunting">Entra: Hunting</a>
<a href="#/investigation/hardening" data-route="/investigation/hardening">Entra: Hardening</a>
<a href="#/investigation/google" data-route="/investigation/google">Google Workspace (beta)</a>
<a href="#/investigation/github" data-route="/investigation/github">GitHub (beta)</a>
</nav>
<!-- ===== Tradecraft ===== -->
<div class="invest-section" id="sub-tradecraft">
<div class="doc-block">
<h2>OAuth attack tradecraft against Entra ID</h2>
<p class="doc-lede">A field guide to how attackers actually use OAuth against Microsoft tenants in 2024-2026, organised by phase. Every technique here has been documented in public incident reports - the references are linked. Read it as the attacker's playbook so the rest of this tab (forensics, detections, hunting) makes sense in context.</p>
<div class="callout">
<strong>Two modern shifts to internalise</strong>
First: the centre of gravity has moved from "phish a password and bypass MFA" to <strong>steal or mint a token</strong>. Tokens survive password resets, expire on their own clock, and bypass MFA by design. Second: attackers prefer <strong>legitimate first-party Microsoft apps</strong> (VSCode, Authentication Broker, Azure CLI, Teams) to malicious app registrations - first-party apps are pre-consented in every tenant, can't be blocked, and don't trip "new OAuth app" alerts.
</div>
<h3><span class="num">1</span> Initial access - getting a token in the first place</h3>
<h4>Consent phishing (classic)</h4>
<p>Attacker registers a multi-tenant app in their own tenant, sends a consent URL to the victim. Victim clicks, authenticates against the real Microsoft login, accepts the consent prompt. Service principal is created in the victim's tenant; attacker now has a refresh token. Highest-volume variant; aggregated lists are the basis of OAuthSentry's malicious feed.</p>
<ul>
<li>Common scopes requested: <code>Mail.Read</code>, <code>Mail.ReadWrite</code>, <code>Files.Read.All</code>, <code>offline_access</code>, <code>User.Read</code>.</li>
<li>Homoglyph display names (Cyrillic <code>о</code> instead of Latin <code>o</code> in "OneDrive") are common - 9 entries in the Entra dataset are confirmed homoglyph apps, preserved verbatim as IOCs.</li>
<li>Real campaigns: Wiz's 2025 campaign (19 apps spoofing OneDrive/DocuSign/Adobe), RH-ISAC/Proofpoint MFA-themed phishing.</li>
</ul>
<h4>Device code phishing (Storm-2372 / APT29 / UTA0304 / UTA0307)</h4>
<p>Attacker initiates a device-code flow against any first-party client (most often <code>Microsoft Authentication Broker</code> <code>29d9ed98-a469-4536-ade2-f981bc1d605e</code>). Microsoft returns an 8-character user code and a verification URL. Attacker DMs/emails the victim with a pretext ("enter this code to join the meeting") and the victim authenticates on Microsoft's real login portal. Attacker polls the token endpoint and receives the access token + family refresh token.</p>
<ul>
<li>Sign-in log fingerprint: <code>AuthenticationProtocol = deviceCode</code> + <code>OriginalTransferMethod = deviceCodeFlow</code>.</li>
<li>Microsoft began rolling out blocks on device-code flow via Conditional Access in 2024; verify your tenant has the block in place unless you have a specific need.</li>
</ul>
<h4>OAuth code phishing via first-party apps (UTA0352)</h4>
<p>March 2025 onward. Attacker uses Volexity-tracked <code>aebc6443-996d-45c2-90f0-388ff96faa56</code> (Visual Studio Code) with redirect URI <code>https://insiders.vscode.dev/redirect</code> or <code>https://vscode-redirect.azurewebsites.net</code>. Victim authenticates, gets redirected to in-browser VS Code which displays the auth code; attacker extracts and exchanges for token. No malicious app, no consent prompt, no novel infrastructure - everything terminates on Microsoft domains.</p>
<h4>AiTM / token theft (Evilginx, Mamba2FA)</h4>
<p>Reverse-proxy phishing kits sit between the victim and the real Microsoft login, intercepting the session cookie and refresh token after MFA completes. Token theft accounted for an estimated 31% of M365 breaches in 2025. The stolen token has the same MFA claim as the legitimate sign-in, so a one-time replay from a different IP looks like a normal non-interactive sign-in.</p>
<h3><span class="num">2</span> Privilege escalation - getting more than the token grants</h3>
<h4>Service principal credential backdoor (Midnight Blizzard / Solorigate)</h4>
<p>The attacker enumerates application objects and service principals. For any SP they can write to (because they own the app, or have <code>Application.ReadWrite.All</code>, or the SP has the <code>microsoft.directory/servicePrincipals/credentials/update</code> right), they add their own client secret or X.509 certificate. They now authenticate via OAuth 2.0 client-credentials flow as that app, completely outside any user context, MFA, or interactive Conditional Access.</p>
<h4>App ownership abuse ("Misowned and Dangerous", Semperis 2025)</h4>
<p>The attack path documented in <em>EntraGoat</em>. A compromised low-privileged user discovers they own an enterprise application that has a privileged role assignment. The owner can add a client secret to the SP without any admin role. They then auth as the app, use its role to reset a Global Administrator's password, issue a Temporary Access Pass (TAP), and log in interactively as the GA. Tenant compromise, no admin role required at the start.</p>
<h4>Federated identity credential persistence</h4>
<p>Attackers with sufficient permissions add a <strong>federated identity credential</strong> to a high-privilege application object pointing at an attacker-controlled IdP (e.g. <code>https://attacker.example.com</code>). They then exchange tokens from the attacker's IdP for Microsoft tokens via <code>api://AzureADTokenExchange</code>. No client secret to rotate, no certificate thumbprint to spot - just a small JSON record that looks legitimate. Subtle, durable, often missed in audits.</p>
<h4>Actor Token Forgery (CVE-2025-55241)</h4>
<p>Disclosed September 2025 by Dirk-jan Mollema. A flaw in the legacy Access Control Service let attackers forge "actor" tokens (used for service-to-service delegation) impersonating <strong>any user in any tenant</strong>, including Global Admins. Critically, the forged-token request <strong>generates no sign-in log entry</strong> - downstream Graph activity is the only trace. Microsoft patched in September 2025 but: (a) any pre-patch abuse is largely invisible after the fact, and (b) the class of bug (S2S token tenant validation gaps) is unlikely to be the last. Hunt for actor-token usage where the SP <code>displayName</code> is a Microsoft service but the <code>userPrincipalName</code> is a real user.</p>
<h4>FOCI / family refresh token misuse</h4>
<p>An undocumented Entra behaviour, researched by Secureworks. ~16 first-party Microsoft client IDs are part of a "family"; a refresh token issued to any one of them can be redeemed for an access token to any other. Steal a token from <code>Azure CLI</code> and you can request tokens for Teams, Outlook, OneDrive, Office, etc. - the union of every family scope. <strong>TeamFiltration</strong> (UNK_SneakyStrike, 80,000+ targeted accounts since Dec 2024) industrialised this.</p>
<p>Known FOCI app IDs from Secureworks' <code>known-foci-clients.csv</code>:</p>
<table class="kv-table">
<thead><tr><th>App</th><th>App ID</th></tr></thead>
<tbody>
<tr><td>Microsoft Azure CLI</td><td class="mono">04b07795-8ddb-461a-bbee-02f9e1bf7b46</td></tr>
<tr><td>Microsoft Azure PowerShell</td><td class="mono">1950a258-227b-4e31-a9cf-717495945fc2</td></tr>
<tr><td>Microsoft Teams</td><td class="mono">1fec8e78-bce4-4aaf-ab1b-5451cc387264</td></tr>
<tr><td>Microsoft Office</td><td class="mono">d3590ed6-52b3-4102-aeff-aad2292ab01c</td></tr>
<tr><td>OneDrive SyncEngine</td><td class="mono">ab9b8c07-8f02-4f72-87fa-80105867a763</td></tr>
<tr><td>Outlook Mobile</td><td class="mono">27922004-5251-4030-b22d-91ecd9a37ea4</td></tr>
<tr><td>Microsoft Authenticator App</td><td class="mono">4813382a-8fa7-425e-ab75-3b753aab3abb</td></tr>
<tr><td>Visual Studio</td><td class="mono">872cd9fa-d31f-45e0-9eab-6e460a02d1f1</td></tr>
<tr><td>Office 365 Management</td><td class="mono">00b41c95-dab0-4487-9791-b9d2c32c80f2</td></tr>
<tr><td>OneDrive iOS App</td><td class="mono">af124e86-4e96-495a-b70a-90f90ab96707</td></tr>
<tr><td>Windows Search</td><td class="mono">26a7ee05-5602-4d76-a7ba-eae8b7b67941</td></tr>
</tbody>
</table>
<p>Adjacent to the FOCI list, but distinct: the <strong>Microsoft Authentication Broker</strong> (MAB, <code>29d9ed98-a469-4536-ade2-f981bc1d605e</code>) is not on Secureworks' canonical FOCI list, but it is the client used by Windows for Entra device-join and is the favourite vector for Primary Refresh Token (PRT) phishing per Dirk-jan Mollema's research. UTA0355 and Storm-2372 both used MAB. Hunt MAB sign-ins with <code>AuthenticationProtocol = deviceCode</code> the same way you hunt the FOCI list - the playbook is the same, the family membership is not.</p>
<h3><span class="num">3</span> Persistence - staying after credentials change</h3>
<h4>Device registration to mint a Primary Refresh Token (UTA0355)</h4>
<p>Once the attacker has an OAuth code, they call the Device Registration Service to register their own device against the victim's tenant. They then convince the victim to approve a 2FA prompt ("to access SharePoint for the conference"). The attacker now has a Primary Refresh Token (PRT) tied to a "compliant" device they control. PRTs survive password resets and most refresh-token revocations - the only reliable cleanup is unregistering the device.</p>
<h4>Inbox rules + transport rules</h4>
<p>Classic BEC. Forward-then-delete rules to hide replies during invoice fraud. Tenant-wide transport rules created via Graph after admin-consent abuse to siphon mail at the org level.</p>
<h4>Conditional Access policy exclusions</h4>
<p>Attacker (with admin or app-perm equivalent) modifies an existing CA policy to add an "exclusion" for a specific user or named location they control. Looks like a routine policy edit in the audit log; effect is a permanent bypass.</p>
<h4>Cross-tenant access trust</h4>
<p>Attacker who compromises a trusted partner tenant (B2B collaboration / cross-tenant access settings) can keep accessing the victim tenant's resources even after every credential in the victim tenant is reset.</p>
<h3><span class="num">4</span> Defense evasion - blending in</h3>
<ul>
<li><strong>Residential proxies</strong> (Spur.us-tracked): NSOCKS, 911, BrightProxies. Attacker traffic comes from IPs that also belong to legitimate users in the same neighbourhood.</li>
<li><strong>First-party apps</strong>: VSCode, Azure CLI, Office traffic blends into developer noise.</li>
<li><strong>CAE token extension</strong>: when CAE is enabled, attackers prefer to obtain CAE-aware tokens because they last 24-28h and skip mid-session re-auth - good for the attacker as long as the user/admin doesn't trigger an explicit revocation event.</li>
<li><strong>Throttling MailItemsAccessed</strong>: Microsoft caps MailItemsAccessed bind-event logging at 1,000 audit records per mailbox per 24 hours; once a mailbox crosses that threshold, bind events are not logged for the next 24 hours (other mailbox audit actions like <em>Send</em>, <em>SoftDelete</em>, and sync operations continue unaffected). The first 1,000 events ARE recorded - what gets silenced is follow-on bind activity in the next 24 hours, so attackers use this to hide a quiet "second pass" rather than the initial dump. Sync-based exfiltration (Outlook desktop dumping an entire folder) generates one record per folder and never trips the cap regardless of message count. Microsoft says <1% of mailboxes ever hit throttling, so an event with <code>IsThrottled = True</code> is itself a high-fidelity IOC.</li>
<li><strong>Removing audit trail</strong>: post-compromise, deleting the added credential or the new SP after using it. Only catches if your retention is long enough to look back before the cleanup.</li>
</ul>
<h3><span class="num">5</span> The MITRE ATT&CK cloud mapping</h3>
<p>For SOC ticketing, the techniques above map to:</p>
<table class="kv-table">
<thead><tr><th>Tactic</th><th>Technique</th><th>What we covered</th></tr></thead>
<tbody>
<tr><td>Initial Access</td><td class="mono">T1566.002 - Spearphishing Link</td><td>Consent / device-code / OAuth-code phishing</td></tr>
<tr><td>Initial Access</td><td class="mono">T1078.004 - Cloud Accounts</td><td>AiTM / token theft replay</td></tr>
<tr><td>Persistence</td><td class="mono">T1098.001 - Additional Cloud Credentials</td><td>SP credential backdoor, federated credential</td></tr>
<tr><td>Persistence</td><td class="mono">T1098.003 - Additional Cloud Roles</td><td>App role assignment / admin consent</td></tr>
<tr><td>Persistence</td><td class="mono">T1098.005 - Device Registration</td><td>UTA0355 device join + PRT</td></tr>
<tr><td>Privilege Escalation</td><td class="mono">T1078.004 - Cloud Accounts</td><td>App ownership pivot, Actor Token Forgery</td></tr>
<tr><td>Defense Evasion</td><td class="mono">T1550.001 - Application Access Token</td><td>FRT replay across FOCI apps</td></tr>
<tr><td>Credential Access</td><td class="mono">T1528 - Steal Application Access Token</td><td>Consent phishing harvest</td></tr>
<tr><td>Collection</td><td class="mono">T1114.002 - Remote Email Collection</td><td>MailItemsAccessed via OAuth</td></tr>
<tr><td>Exfiltration</td><td class="mono">T1567 - Exfiltration Over Web Service</td><td>Microsoft Graph as exfil channel</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ===== Remediation ===== -->
<div class="invest-section" id="sub-remediation" hidden>
<div class="doc-block">
<h2>Remediating a malicious OAuth consent grant</h2>
<p class="doc-lede">Use this playbook when a user has consented to a malicious OAuth application in Microsoft Entra ID. Order matters: revoke before you investigate, preserve evidence before you delete.</p>
<div class="callout danger">
<strong>Containment first - what disable does and does not do</strong>
Disabling the service principal blocks new sign-ins and stops Entra issuing new access tokens, but per the OAuth 2.0 spec <strong>access tokens already in the attacker's hands cannot be revoked</strong>; they remain valid until they expire. <strong>How long is "until they expire"?</strong> Microsoft's default access-token TTL is a randomized <strong>60-90 minutes (75 min average)</strong>, or <strong>2 hours flat</strong> for Microsoft Teams / Microsoft 365 clients in tenants without Conditional Access. Tenants with a Configurable Token Lifetime (CTL) policy can extend this up to <strong>24 hours</strong> - which doubles or quadruples the post-incident exposure window before the stolen token expires on its own. <strong>Refresh tokens</strong> live <strong>90 days</strong> (24h for single-page apps), so until you also revoke the refresh token (<code>Revoke-MgUserSignInSession</code>), the attacker can mint fresh access tokens for three months. The only mechanism that can invalidate a live access token before expiry is Continuous Access Evaluation (CAE) - and CAE only applies to CAE-aware resources (Exchange Online, SharePoint Online, Teams, Microsoft Graph) and CAE-aware clients. Resetting the user's password while tokens are still valid does not evict the attacker. Treat the window between containment and full eviction as live; assume continued read access until proven otherwise, and size that window using your tenant's actual CTL setting (audit via <code>Get-MgPolicyTokenLifetimePolicy</code>).
</div>
<h3><span class="num">1</span> Preserve evidence before you change anything</h3>
<p>The lede above says "preserve evidence before you delete" - this is the step that makes that real. Once you start disabling SPs, revoking tokens, and deleting grants, the tenant state changes. If you skip this step, you lose the ability to answer "what did the application object look like at the moment of compromise" months later when legal, insurance, or regulators ask.</p>
<ul>
<li><strong>Snapshot the SP and all its credentials.</strong> Run the discovery query in Step 2 first, but pipe the output to a file rather than just the console - keep the raw JSON of <code>Get-MgServicePrincipal</code>, <code>Get-MgOauth2PermissionGrant</code>, and <code>Get-MgServicePrincipalAppRoleAssignment</code> so you have the unmodified record of what the consent looked like.</li>
<li><strong>Capture the broader tenant state with EntraExporter.</strong> The <code>EntraExporter</code> PowerShell module (now <code>microsoft/EntraExporter</code>, originally by Merill Fernando): <code>Install-Module EntraExporter</code>, then <code>Export-Entra -Path .\<case-id> -All</code> writes the entire tenant config to disk: applications, service principals, OAuth2 grants, app role assignments, conditional access policies, role assignments, named locations. This is the cleanest way to capture "the tenant at incident time" before remediation rewrites it. Treat the export as evidence: hash it, copy it off-host, and reference the hash in your incident timeline.</li>
<li><strong>Pull and archive the relevant audit data.</strong> Your SIEM retention may be shorter than your investigation timeline - export the raw <code>Consent to application</code>, <code>Add OAuth2PermissionGrant</code>, <code>Add app role assignment to service principal</code>, and <code>Add service principal credentials</code> events for the malicious app id from the M365 unified audit log to a case archive. The Splunk Forensic Traces queries in the Investigation tab generate this set; pin the time range to the suspected exposure window plus 30 days on either side.</li>
<li><strong>Note the snapshot timestamp in the case file.</strong> Everything that follows in this playbook is dated relative to "evidence captured at T0". Reviewers in three months need this anchor.</li>
</ul>
<h3><span class="num">2</span> Identify scope of the consent</h3>
<p>Pull every consent grant for the malicious application id across the tenant. Both delegated (per-user) and application (admin-consented) grants must be enumerated.</p>
<div class="code-block">
<div class="code-header">PowerShell - Microsoft Graph SDK<button class="copy-snippet" type="button">copy</button></div>
<pre># Write scopes are required because steps 2-5 will disable, revoke, and remove
Connect-MgGraph -Scopes `
"Application.ReadWrite.All", `
"DelegatedPermissionGrant.ReadWrite.All", `
"AppRoleAssignment.ReadWrite.All", `
"Directory.AccessAsUser.All", `
"User.RevokeSessions.All"
$badAppId = "<malicious-application-id>"
# 1. Locate the service principal in this tenant
$sp = Get-MgServicePrincipal -Filter "appId eq '$badAppId'"
$sp | Format-List Id, AppId, DisplayName, AppOwnerOrganizationId, `
PublisherName, VerifiedPublisher, ServicePrincipalType, `
ReplyUrls, Tags, AccountEnabled, DisabledByMicrosoftStatus
# 2. List delegated grants (per-user "OAuth2PermissionGrants")
# consentType "Principal" = single user; "AllPrincipals" = tenant-wide admin consent
Get-MgOauth2PermissionGrant -Filter "clientId eq '$($sp.Id)'" |
Select-Object Id, ClientId, ConsentType, PrincipalId, ResourceId, Scope
# 3. List application-role grants (admin consent only, "AppRoleAssignments")
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id |
Select-Object Id, AppRoleId, PrincipalDisplayName, ResourceDisplayName, CreatedDateTime
# 4. List any client secrets / certificates the app added (post-consent persistence)
$sp.PasswordCredentials | Select-Object KeyId, DisplayName, StartDateTime, EndDateTime
$sp.KeyCredentials | Select-Object KeyId, DisplayName, StartDateTime, EndDateTime, Type</pre>
</div>
<h3><span class="num">3</span> Disable the service principal</h3>
<p>This stops new sign-ins and token requests immediately. Disabling is reversible if the verdict turns out to be wrong; deletion is not, and Entra retains a tombstone for 30 days regardless.</p>
<div class="code-block">
<div class="code-header">PowerShell<button class="copy-snippet" type="button">copy</button></div>
<pre># Disable the service principal (single command, tenant-wide)
Update-MgServicePrincipal -ServicePrincipalId $sp.Id -AccountEnabled:$false
# Verify
(Get-MgServicePrincipal -ServicePrincipalId $sp.Id).AccountEnabled</pre>
</div>
<h3><span class="num">4</span> Revoke all refresh tokens for affected users</h3>
<p>
<code>Revoke-MgUserSignInSession</code> calls Microsoft Graph <code>POST /users/{id}/revokeSignInSessions</code>, which invalidates every refresh token and session token issued to that user across every app, not just the malicious one. This is the right hammer - the attacker's refresh token was probably scoped to a different app id than the one the user thinks they revoked, and you want all of them dead. The cmdlet returns immediately; replication to resource providers (Exchange, SharePoint) takes seconds for CAE-aware services and up to one hour for non-CAE replication.
</p>
<div class="code-block">
<div class="code-header">PowerShell<button class="copy-snippet" type="button">copy</button></div>
<pre># 1. Users who consented to this specific app (delegated grants)
$grantPrincipals = (Get-MgOauth2PermissionGrant -Filter "clientId eq '$($sp.Id)'").PrincipalId |
Where-Object { $_ } | Sort-Object -Unique
# 2. Anyone who signed in to the malicious app within the lookback window.
# Pull from sign-in logs to catch users who got tokens but were not in the grant list
# (e.g. AllPrincipals admin consent grants do not have a PrincipalId).
$signInUsers = Get-MgAuditLogSignIn `
-Filter "appId eq '$badAppId' and createdDateTime ge 2026-04-01T00:00:00Z" `
-All | Select-Object -ExpandProperty UserId | Sort-Object -Unique
$affectedUsers = ($grantPrincipals + $signInUsers) | Sort-Object -Unique
foreach ($uid in $affectedUsers) {
Write-Host "Revoking sessions for $uid"
Revoke-MgUserSignInSession -UserId $uid
}</pre>
</div>
<h3><span class="num">5</span> Remove every consent grant</h3>
<p>Disabling the SP blocks new tokens but leaves the consent records in place; remove them so the app cannot be re-enabled silently and so audit reporting stays accurate.</p>
<div class="code-block">
<div class="code-header">PowerShell<button class="copy-snippet" type="button">copy</button></div>
<pre># Remove delegated permission grants
Get-MgOauth2PermissionGrant -Filter "clientId eq '$($sp.Id)'" |
ForEach-Object { Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $_.Id }
# Remove application role assignments (admin-consented permissions)
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id |
ForEach-Object {
Remove-MgServicePrincipalAppRoleAssignment `
-ServicePrincipalId $sp.Id `
-AppRoleAssignmentId $_.Id
}</pre>
</div>
<h3><span class="num">6</span> Block the app from being re-consented</h3>
<p>If the app is registered in another tenant (multi-tenant, AppOwnerOrganizationId is not your tenant), tenant-wide consent restrictions and a tenant-level block prevent re-introduction.</p>
<div class="code-block">
<div class="code-header">PowerShell<button class="copy-snippet" type="button">copy</button></div>
<pre># Tenant-wide hard-disable of the service principal
Update-MgServicePrincipal -ServicePrincipalId $sp.Id `
-AccountEnabled:$false `
-Tags @("HideApp","BlockedFromConsent")
# Restrict end-user consent globally (do this once tenant-wide, not just for this app)
Update-MgPolicyAuthorizationPolicy `
-DefaultUserRolePermissions @{ permissionGrantPoliciesAssigned = @() }</pre>
</div>
<div class="callout warn">
<strong>Multi-tenant note</strong>
If the malicious app is multi-tenant (registered outside your tenant), disabling it in your tenant only stops it locally. Microsoft can globally disable known-bad apps via the <code>disabledByMicrosoftStatus</code> property; report to MSRC and CISA so the app is taken down for everyone.
</div>
<h3><span class="num">7</span> Investigate what the app accessed</h3>
<p>Pivot to the Forensic traces tab. The questions you need answered: which mailboxes did it read, did it send mail, did it create inbox rules, did it modify MFA, did it register a device.</p>
<div class="callout">
<strong>Translate scopes into concrete data exposure</strong>
The consent event tells you the scope (e.g. <code>Mail.ReadWrite</code>, <code>Files.Read.All</code>, <code>Directory.Read.All</code>) but not what specific Graph endpoints that scope unlocks. Use Merill Fernando's <a href="https://graphpermissions.merill.net/">graphpermissions.merill.net</a> to reverse-look-up each scope into the exact list of Graph endpoints and resource types it grants - this is what you tell legal/compliance the attacker had access to. Combine that with the actual Graph activity log records (see Forensics tab section 5) to narrow "could have accessed" to "did access". Knowing the difference between "Mail.Read on one mailbox" and "Mail.ReadWrite tenant-wide" is the difference between a one-user incident and a regulatory disclosure.
</div>
<h3><span class="num">8</span> User communication & post-incident</h3>
<ul class="checklist">
<li><strong>Notify affected users</strong> Tell them the app was disabled, that a password reset alone would not have stopped the attacker, and that the access has been revoked.</li>
<li><strong>Force password reset</strong> only after revoking refresh tokens. Resetting first while tokens are still valid does not evict the attacker.</li>
<li><strong>Re-enroll MFA</strong> if there is any indication the attacker added their own MFA method.</li>
<li><strong>Review and tighten consent policy</strong> Move to admin-consent workflow for any app requesting Mail.*, Files.*, or Directory.* scopes.</li>
<li><strong>Add the App ID to your watchlist</strong> Push the OAuthSentry malicious feed into your SIEM so the next consent attempt alerts immediately.</li>
</ul>
<h4>Microsoft Graph REST equivalent (one-liner per step)</h4>
<table class="kv-table">
<thead><tr><th>Step</th><th>HTTP request</th></tr></thead>
<tbody>
<tr><td>Find SP</td><td class="mono">GET /v1.0/servicePrincipals?$filter=appId eq '<guid>'</td></tr>
<tr><td>Disable SP</td><td class="mono">PATCH /v1.0/servicePrincipals/<sp-id> { "accountEnabled": false }</td></tr>
<tr><td>List grants</td><td class="mono">GET /v1.0/oauth2PermissionGrants?$filter=clientId eq '<sp-id>'</td></tr>
<tr><td>Revoke grant</td><td class="mono">DELETE /v1.0/oauth2PermissionGrants/<grant-id></td></tr>
<tr><td>Revoke sessions</td><td class="mono">POST /v1.0/users/<user-id>/revokeSignInSessions</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ===== Forensics ===== -->
<div class="invest-section" id="sub-forensics" hidden>
<div class="doc-block">
<h2>Forensic traces of OAuth abuse</h2>
<p class="doc-lede">Every step of an OAuth consent attack leaves an artifact somewhere in Entra ID, Microsoft Graph activity logs, or the user's mailbox metadata. This is the operator's map of where to look.</p>
<h3><span class="num">1</span> Application registration & service principal creation</h3>
<p>The first event is creation of the service principal in your tenant - the moment the app appears as a "thing" Entra knows about. All of the operations below appear in either the <strong>M365 unified audit log</strong> (Splunk macro <code>oauthsentry_o365_audit</code>, <code>RecordType = AzureActiveDirectory</code>) or the <strong>Entra audit log</strong> streamed via Azure Monitor diagnostic settings (macro <code>oauthsentry_aad_audit</code>). The <strong>Operation</strong> field is what to filter on.</p>
<div class="callout">
<strong>Authoritative reference for "is this Microsoft first-party"</strong>
When an SP is multi-tenant, the question is always "is this a real Microsoft app or someone impersonating one". The <code>appOwnerOrganizationId</code> field gives the answer if you cross-check against Microsoft's tenant ids - and Merill Fernando's <a href="https://github.com/merill/microsoft-info">merill/microsoft-info</a> repository (<code>aka.ms/AppNames</code>) maintains a daily-refreshed CSV/JSON of every known Microsoft first-party AppId, display name, and owner tenant. Pull <a href="https://raw.githubusercontent.com/merill/microsoft-info/main/_info/MicrosoftApps.csv">MicrosoftApps.csv</a> as a Splunk lookup (<code>| inputlookup microsoft_apps.csv</code>) and join it on appid; any SP active in your tenant whose AppId is NOT in that list AND whose <code>appOwnerOrganizationId</code> matches a Microsoft tenant id is suspicious by definition. Merill also publishes <a href="https://raw.githubusercontent.com/merill/microsoft-info/main/_info/GraphAppRoles.csv">GraphAppRoles.csv</a> and <a href="https://raw.githubusercontent.com/merill/microsoft-info/main/_info/GraphDelegateRoles.csv">GraphDelegateRoles.csv</a> for translating Graph permission GUIDs (the values you see in <code>ModifiedProperties.NewValue</code>) back into human-readable scope names.
</div>
<table class="kv-table">
<thead><tr><th>Source</th><th>Operation / field</th><th>What it tells you</th></tr></thead>
<tbody>
<tr><td>Audit log op</td><td class="mono">Add service principal</td><td>SP appeared in your tenant. Captures app id, displayName, initiator. First event in the chain for any new app.</td></tr>
<tr><td>Audit log op</td><td class="mono">Add service principal credentials</td><td>Client secret or certificate added to the SP - very common attacker persistence step (e.g. Midnight Blizzard added their own creds to existing SPs).</td></tr>
<tr><td>Audit log op</td><td class="mono">Update application - Certificates and secrets management</td><td>Cert/secret added on the application object (vs the SP). Same intent, slightly different code path.</td></tr>
<tr><td>Audit log op</td><td class="mono">Consent to application</td><td>User-level consent prompt accepted. <code>ConsentContext.IsAdminConsent</code> and the scope string in <code>ConsentAction.Permissions</code> are the key fields.</td></tr>
<tr><td>Audit log op</td><td class="mono">Add OAuth2PermissionGrant</td><td>The actual delegated permission grant object created. Often appears alongside <code>Consent to application</code>; also fires when admin grants directly via Graph (<code>POST /oauth2PermissionGrants</code>).</td></tr>
<tr><td>Audit log op</td><td class="mono">Add delegated permission grant</td><td>Variant name surfaced by some log pipelines for the same underlying event. Hunt both strings.</td></tr>
<tr><td>Audit log op</td><td class="mono">Add app role assignment to service principal</td><td>App-level (admin-consent) permission granted - much higher impact. Anything in this stream that contains <code>.All</code>, <code>Mail.</code>, <code>Files.</code>, <code>Directory.</code> deserves immediate review.</td></tr>
<tr><td>SP object</td><td class="mono">appOwnerOrganizationId</td><td>Tenant that owns the app. If not your tenant id, it is multi-tenant; cross-reference with Microsoft's first-party tenants <code>f8cdef31-a31e-4b4a-93e4-5f571e91255a</code> and <code>72f988bf-86f1-41af-91ab-2d7cd011db47</code>.</td></tr>
<tr><td>SP object</td><td class="mono">verifiedPublisher</td><td>Empty for nearly all malicious apps. Verified publishers are a strong negative IOC.</td></tr>
<tr><td>SP object</td><td class="mono">disabledByMicrosoftStatus</td><td><code>DisabledDueToViolationOfServicesAgreement</code> means Microsoft has globally tombstoned the app. If you see this, treat it as confirmed malicious.</td></tr>
<tr><td>SP object</td><td class="mono">replyUrls / homepage</td><td>Where Entra redirects auth codes. Attacker-controlled domains here are decisive evidence; for UTA0352 watch for <code>insiders.vscode.dev</code> and <code>vscode-redirect.azurewebsites.net</code>.</td></tr>
<tr><td>SP object</td><td class="mono">passwordCredentials / keyCredentials</td><td>Any new entry with a recent <code>startDateTime</code> = attacker minted their own credential.</td></tr>
<tr><td>SP object</td><td class="mono">tags</td><td><code>WindowsAzureActiveDirectoryIntegratedApp</code> is normal; <code>HideApp</code>, unusual or attacker-script-injected values are not.</td></tr>
</tbody>
</table>
<h3><span class="num">2</span> The consent event itself</h3>
<p>This is the event everyone hunts for. In Entra audit logs:</p>
<div class="code-block">
<div class="code-header">Audit log shape (Entra ID)<button class="copy-snippet" type="button">copy</button></div>
<pre>{
"operationName": "Consent to application",
"category": "ApplicationManagement",
"result": "success",
"initiatedBy": { "user": { "userPrincipalName": "victim@org.com" } },
"targetResources": [
{
"id": "<service-principal-id>",
"type": "ServicePrincipal",
"displayName": "Adobe Drive X",
"modifiedProperties": [
{ "displayName": "ConsentAction.Permissions",
"newValue": "Scope=Mail.Read Mail.Send offline_access" },
{ "displayName": "ConsentContext.IsAdminConsent", "newValue": "False" },
{ "displayName": "TargetId.ServicePrincipalNames",
"newValue": "<application-id-guid>" }
]
}
]
}</pre>
</div>
<p>Pivot fields: the application id, scopes consented, IsAdminConsent flag (admin = much higher impact), and the user principal who clicked.</p>
<div class="callout">
<strong>Two audit formats, two schemas</strong>
The example above is the <strong>Azure AD audit log</strong> shape (camelCase: <code>operationName</code>, <code>targetResources[].modifiedProperties[]</code>) - that's what you get if you stream Entra diagnostic logs to Azure Monitor or Log Analytics. The <strong>Microsoft 365 unified audit log</strong> emits the same event with a different shape (PascalCase, flatter <code>ModifiedProperties[]</code> at the top level) and different field names. The OAuthSentry SPL macros target the M365 unified shape, because that's what the Splunk Add-on for Microsoft Office 365 ingests via the Management Activity API. Below is the real M365 unified shape of a single <code>Consent to application.</code> event (anonymized) - match this against your raw logs to confirm the field paths the SPL queries pivot on.
</div>
<div class="code-block">
<div class="code-header">Audit log shape (M365 unified - what oauthsentry_o365_audit hits)<button class="copy-snippet" type="button">copy</button></div>
<pre>{
"CreationTime": "2026-04-27T07:20:47",
"Id": "<event-uuid>",
"Operation": "Consent to application.",
"OrganizationId": "<your-tenant-id>",
"RecordType": 8,
"ResultStatus": "Success",
"Workload": "AzureActiveDirectory",
"AzureActiveDirectoryEventType": 1,
"ObjectId": "<consented-app-id>", ``` AppId of the app being consented to ```
"UserId": "admin.consenting@yourtenant.onmicrosoft.com", ``` UPN of the user who clicked ```
"UserKey": "<puid>@yourtenant.onmicrosoft.com",
"ExtendedProperties": [
{ "Name": "additionalDetails",
"Value": "{\"User-Agent\":\"Mozilla/5.0 (Windows NT 10.0; ...) Chrome/<ver> Safari/537.36\",\"AppId\":\"<consented-app-id>\",\"ServicePrincipalProvisioningType\":\"Other\"}" },
{ "Name": "extendedAuditEventCategory", "Value": "ServicePrincipal" }
],
"ModifiedProperties": [
{ "Name": "ConsentContext.IsAdminConsent", "NewValue": "True", "OldValue": "" },
{ "Name": "ConsentContext.IsAppOnly", "NewValue": "False", "OldValue": "" },
{ "Name": "ConsentContext.OnBehalfOfAll", "NewValue": "True", "OldValue": "" },
{ "Name": "ConsentContext.Tags", "NewValue": "", "OldValue": "" },
{ "Name": "ConsentAction.Permissions",
"NewValue": "[[Id: <grant-id>, ClientId: <sp-objectid>, PrincipalId: , ResourceId: <resource-sp-id>, ConsentType: AllPrincipals, Scope: User.Read, CreatedDateTime: , LastModifiedDateTime ]] => [[Id: <grant-id>, ClientId: <sp-objectid>, PrincipalId: , ResourceId: <resource-sp-id>, ConsentType: AllPrincipals, Scope: User.Read, CreatedDateTime: , LastModifiedDateTime ]];",
"OldValue": "" }
],
"Actor": [
{ "ID": "admin.consenting@yourtenant.onmicrosoft.com", "Type": 5 }, ``` Type 5 = UPN ```
{ "ID": "<puid>", "Type": 3 }, ``` Type 3 = PUID ```
{ "ID": "Microsoft_AAD_RegisteredApps", "Type": 1 }, ``` Type 1 = source app/portal ```
{ "ID": "<user-objectid>", "Type": 2 }
],
"Target": [
{ "ID": "ServicePrincipal_<sp-objectid>", "Type": 2 },
{ "ID": "<sp-objectid>", "Type": 2 }, ``` SP ObjectId in tenant ```
{ "ID": "ServicePrincipal", "Type": 2 },
{ "ID": "<App display name as it appeared at consent time>", "Type": 1 },
{ "ID": "<consented-app-id>", "Type": 4 } ``` AppId again, Type 4 ```
],
"ActorContextId": "<your-tenant-id>",
"TargetContextId": "<your-tenant-id>"
}</pre>
</div>
<p>The four fields the OAuthSentry SPL pivots on, with paths confirmed against the shape above:</p>
<ul>
<li><strong>AppId of the consented app</strong> → <code>ObjectId</code> at the top level (also redundantly available as <code>ExtendedProperties[].additionalDetails.AppId</code> and <code>Target[]</code> entries with <code>Type=4</code>). The <code>TargetId.ServicePrincipalNames</code> field that some older detections key on does <strong>not</strong> exist in the M365 unified shape - it lives only in the Azure AD diagnostic stream.</li>
<li><strong>Admin vs user consent</strong> → <code>ModifiedProperties[]</code> where <code>Name=="ConsentContext.IsAdminConsent"</code>, value is the string <code>"True"</code> / <code>"False"</code>.</li>
<li><strong>Granted scopes</strong> → <code>ModifiedProperties[]</code> where <code>Name=="ConsentAction.Permissions"</code>. The value is a bracket-delimited string, not JSON; one or more <code>[[Id: ..., ClientId: ..., ResourceId: ..., ConsentType: ..., Scope: SCOPE_NAME, ...]]</code> groups separated by <code>;</code>, with an <code>old => new</code> arrow showing the diff. Multi-scope consents emit multiple groups; <code>regex max_match=20</code> against <code>Scope:\s*(?<scope>[^,\]]+)</code> extracts each.</li>
<li><strong>User-Agent at consent time</strong> → <code>ExtendedProperties[]</code> where <code>Name=="additionalDetails"</code>. The <code>Value</code> field is a JSON-encoded string (escaped quotes); <code>spath input=Value</code> drills in to <code>User-Agent</code> and the redundant <code>AppId</code>. Hunt for <code>python-requests</code>, <code>curl</code>, <code>ROADtools</code>, headless browsers - they're rarely present on legitimate consent prompts.</li>
</ul>
<h3><span class="num">3</span> Sign-in logs - the app actually using its tokens</h3>
<p>Once consent is granted the app starts signing in on behalf of the user. These appear in <strong>service principal sign-in logs</strong>, not interactive sign-ins. The Splunk macro stack splits them three ways: <code>oauthsentry_aad_sp_signin</code> for the SP authenticating as itself (<code>category = ServicePrincipalSignInLogs</code> on the underlying records), <code>oauthsentry_aad_signin</code> with <code>category = SignInLogs</code> for interactive sign-ins (the user clicking the consent prompt), and the same macro with <code>category = NonInteractiveUserSignInLogs</code> for token refreshes on behalf of the user.</p>
<table class="kv-table">
<thead><tr><th>Field</th><th>What to hunt for</th></tr></thead>
<tbody>
<tr><td class="mono">AppId</td><td>Match against the OAuthSentry malicious or risky feed.</td></tr>
<tr><td class="mono">ResourceDisplayName / ResourceId</td><td>What was accessed. <code>00000003-0000-0000-c000-000000000000</code> = Microsoft Graph; <code>00000002-0000-0ff1-ce00-000000000000</code> = Exchange Online; <code>00000003-0000-0ff1-ce00-000000000000</code> = SharePoint.</td></tr>
<tr><td class="mono">IPAddress / Location</td><td>Source. Cross-check against the user's normal sign-in pattern; residential proxies (Spur.us, GreyNoise) are common in UTA0352/APT29 ops.</td></tr>
<tr><td class="mono">UserAgent</td><td><code>python-requests</code>, <code>curl</code>, <code>ROADtools</code>, <code>Mozilla/5.0 (X11; Linux ...)</code> against a normally-Windows mailbox = attacker tooling.</td></tr>
<tr><td class="mono">IncomingTokenType</td><td><code>refreshToken</code> after the first sign-in. Long sequences of refresh-token grants from one IP = active attacker session.</td></tr>
<tr><td class="mono">ResultType</td><td><code>0</code> = success. Filter on this when correlating with downstream activity.</td></tr>
<tr><td class="mono">AuthenticationProtocol</td><td><code>deviceCode</code> = device-code phishing (Storm-2372). <code>authCode</code> with first-party app id = UTA0352 pattern.</td></tr>
<tr><td class="mono">ConditionalAccessStatus</td><td><code>notApplied</code> = no policy matched, often an indicator the attacker found a CA gap. <code>failure</code> = blocked.</td></tr>
<tr><td class="mono">"Continuous access evaluation"</td><td>Field on sign-in details indicating whether the issued token is CAE-aware. <code>true</code> means revocation will work near-real-time; <code>false</code> means the token must run out the clock.</td></tr>
</tbody>
</table>
<h3><span class="num">4</span> Linkable identifiers - the cross-log correlation key</h3>
<p>Three identifiers are stamped on every Microsoft-issued token and propagate into every downstream log. These are how you turn "this user signed in" into "and here is every Exchange operation, every Graph call, every SharePoint hit that token authorized." Capture them from the sign-in log entry, then pivot.</p>
<table class="kv-table">
<thead><tr><th>Identifier</th><th>Aliases in logs</th><th>Granularity</th><th>What it links</th></tr></thead>
<tbody>
<tr><td class="mono">SessionId (SID)</td><td><code>SessionId</code> on Entra sign-in events; <code>AADSessionId</code> on the <code>AppAccessContext</code> object in M365 unified audit log; <code>SessionId</code> field directly in Exchange mailbox audit records since 2019</td><td>One sign-in session</td><td>Every action that token-chain authorized: mailbox reads, file accesses, Graph calls. The single most useful field for OAuth investigations.</td></tr>
<tr><td class="mono">UniqueTokenIdentifier (UTI)</td><td><code>uniqueTokenIdentifier</code> on Entra SP sign-in events; <code>uti</code> claim in the JWT; surfaces in Microsoft Graph activity logs via the same name</td><td>One specific access token</td><td>Pinpoints which token did exactly which action. Use this when you need to tell apart actions by the same user from two different sessions.</td></tr>
<tr><td class="mono">CorrelationId</td><td><code>correlationId</code> across Entra sign-in logs, Entra audit logs, and the M365 unified audit log</td><td>One request chain</td><td>Ties together the consent event, the resulting SP sign-in, and the audit-log changes that flow from it.</td></tr>
</tbody>
</table>
<div class="callout">
<strong>Note on legacy auth</strong>
<code>SessionId</code> is an Entra ID construct that only exists when the user authenticates via modern auth (OAuth 2.0 / ADAL / MSAL). Legacy basic-auth sessions have no SessionId. If your tenant still permits legacy auth, the absence of a SessionId on a mailbox audit event is itself a signal worth investigating.
</div>
<h4>Tracking a token when you don't have the username</h4>
<p>Real IR rarely starts with "user X is compromised, find their actions". It usually starts with "we have <em>this</em> indicator from somewhere - what did it touch?" The indicator is a token artifact (a UTI, a hashed token, a session id), and the username is what you're trying to <em>derive</em> from it. Five concrete workflows, ordered by how often they come up:</p>
<p><strong>1. You have a UTI from a Graph activity log entry.</strong> The UTI in <code>properties.signInActivityId</code> is the same value as the <code>uti</code> JWT claim and the <code>uniqueTokenIdentifier</code> field on Entra sign-in events. Walk it backwards to recover the user, then forward to find every other action that token authorized:</p>
<div class="code-block">
<div class="code-header">SPL - resolve UTI → user, then find every action by that token<button class="copy-snippet" type="button">copy</button></div>
<pre>``` Step 1: find the originating sign-in (UTI is unique per token, ~75 min lifetime). ```
``` This recovers userPrincipalName, source IP, device, MFA factor, IdP, conditional ```
``` access result - everything you need to scope the actor. ```
`oauthsentry_aad_signin` earliest=-7d
| where uniqueTokenIdentifier="AAAAExampleUtiXXXXXXXX"
| table _time, userPrincipalName, userId, appDisplayName, appId, ipAddress,
location.city, deviceDetail.operatingSystem, authenticationDetails{}.authenticationMethod,
conditionalAccessStatus
``` Step 2: find every Graph call made with that token. Same UTI, different stream. ```
`oauthsentry_graph_activity` earliest=-7d
| where 'properties.signInActivityId'="AAAAExampleUtiXXXXXXXX"
| table _time, 'properties.requestMethod', 'properties.requestUri',
'properties.responseStatusCode', 'properties.responseSizeBytes'
| sort _time
``` Step 3: M365 unified audit log entries authorized by tokens that share the ```
``` same SESSION (a session can mint many tokens). The session lives longer than ```
``` any one token, so this is the broader scope. ```
`oauthsentry_o365_audit` earliest=-7d
| where like('AppAccessContext.AADSessionId', "<session-id-from-step-1>")
| table _time, Operation, ObjectId, UserId, ClientIP, AppId</pre>
</div>
<p><strong>2. You have a GitHub <code>hashed_token</code> from one audit log event.</strong> GitHub publishes the SHA-256 hash of every issued token in the audit log. <a href="https://docs.github.com/en/enterprise-cloud@latest/admin/monitoring-activity-in-your-enterprise/reviewing-audit-logs-for-your-enterprise/identifying-audit-log-events-performed-by-an-access-token">GitHub's hashed-token search</a> takes the hash directly and returns every event - clones, fetches, API calls, repo reads - made by tokens that produced that hash:</p>
<div class="code-block">
<div class="code-header">curl - GitHub audit log hashed-token pivot<button class="copy-snippet" type="button">copy</button></div>
<pre>``` Take the hashed_token value from one event, find every event with the same hash. ```
``` Returns every action that specific token took, regardless of which user owns it. ```
HASH="ExAmPLEhAsHEdT0kEnVaLuEf0rD0cs00xx00000000="
ENC=$(printf %s "$HASH" | jq -sRr @uri)
curl -s \
-H "Authorization: Bearer $PAT" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/orgs/$ORG/audit-log?phrase=hashed_token:$ENC&per_page=100" \
| jq '.[] | {ts: .["@timestamp"], action, user, actor_ip, repo, oauth_application_name}'</pre>
</div>
<p><strong>3. You have a token captured from a phishing kit, stealer log, or beacon.</strong> Decode the JWT (it's three base64-url segments separated by dots; the middle segment is JSON). The <code>uti</code> claim is what you pivot on - same value as the audit-log fields above. The <code>oid</code> claim gives you the user object id, <code>scp</code> lists the granted scopes, <code>iat</code> / <code>exp</code> give you the validity window:</p>
<div class="code-block">
<div class="code-header">bash - decode a captured JWT and pull the linkable claims<button class="copy-snippet" type="button">copy</button></div>
<pre>TOKEN="eyJ0eXAiOi..." # paste the access_token portion only
``` Decode the middle segment. Bash trick: replace -_/+/= padding, base64 decode. ```
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq '{
uti, oid, tid, appid, app_displayname,
iat: (.iat | todate), exp: (.exp | todate),
scope: .scp,
upn: (.upn // .preferred_username // "(no upn claim)"),
ipaddr
}'
``` The 'uti' value plugs straight into the queries in workflow 1 above to find ```
``` everything that token did inside your tenant. ```</pre>
</div>
<p><strong>4. You have a Splunk SessionId but no clue who it belongs to.</strong> The <code>SessionId</code> appears on Entra sign-in events, mailbox audit events, and inside the <code>AppAccessContext</code> on every M365 unified audit log entry that involved an OAuth-authorized action. One query against any of those streams resolves the user:</p>
<div class="code-block">
<div class="code-header">SPL - resolve a SessionId to a user across streams<button class="copy-snippet" type="button">copy</button></div>
<pre>``` Try the sign-in log first - cleanest mapping ```
`oauthsentry_aad_signin` SessionId="<session-id>"
| stats values(userPrincipalName) as upns
values(userId) as user_oids
values(appId) as app_ids
values(appDisplayName) as apps
values(ipAddress) as src_ips
earliest(_time) as first_seen
latest(_time) as last_seen
``` If no hit there, try the M365 audit log - the SessionId lives nested under ```
``` AppAccessContext (delegated tokens) or directly on Exchange mailbox events. ```
`oauthsentry_o365_audit` ('AppAccessContext.AADSessionId'="<session-id>" OR SessionId="<session-id>")
| stats values(UserId) as upns dc(Operation) as ops earliest(_time) as first_seen latest(_time) as last_seen</pre>
</div>
<p><strong>5. You have an OAuth Application's AppId but want all tokens issued under it.</strong> Every access token under one OAuth app shares the same <code>AppId</code>; what differs is the UTI per token and the SessionId per session. List the unique UTIs the app has minted in your tenant, and you have the full set of distinct tokens it has issued (and therefore the full set of users who consented or signed in via it):</p>
<div class="code-block">
<div class="code-header">SPL - enumerate every token issued for one app<button class="copy-snippet" type="button">copy</button></div>
<pre>`oauthsentry_aad_signin` appId="<app-id>" earliest=-30d
| stats values(uniqueTokenIdentifier) as token_utis
dc(uniqueTokenIdentifier) as distinct_tokens
values(userPrincipalName) as users
dc(userPrincipalName) as distinct_users
values(SessionId) as sessions
dc(SessionId) as distinct_sessions
earliest(_time) as first_seen
latest(_time) as last_seen
by appId</pre>
</div>
<p><strong>6. You have an app-only token's traces and need to follow the service principal (no user exists).</strong> App-only tokens (<code>C_Idtyp == "app"</code> in Graph activity logs) have an empty <code>userId</code>; the principal is the service principal itself. The pivot key is <code>servicePrincipalId</code>, and the cross-stream join on UTI still works the same way as for delegated flows. Common case: a vendor like Salesloft Drift discloses a compromise of one of their app registrations - you have an <code>appId</code> from the disclosure, you want every Graph call any of that app's tokens made against any user mailbox in your tenant. Two independent queries (run sequentially):</p>
<div class="code-block">
<div class="code-header">SPL - step 1: enumerate app-only Graph activity for one OAuth app<button class="copy-snippet" type="button">copy</button></div>
<pre>``` Returns one row per (servicePrincipalId, target user object-id) pair, with ```
``` the endpoints touched, distinct token count, and status codes seen. ```
``` Copy the targeted_user_oid values out and feed them to step 2 below. ```
`oauthsentry_graph_activity` earliest=-7d
| eval req_appid = 'properties.appId'
| eval req_spid = 'properties.servicePrincipalId'
| eval req_uri = 'properties.requestUri'
| eval req_uti = 'properties.signInActivityId'
| eval req_idtyp = 'properties.C_Idtyp'
| eval req_status = 'properties.responseStatusCode'
| where lower(req_appid) = "<app-id-from-disclosure>"
| where req_idtyp = "app"
``` Pull the target user object id out of /users/{id}/... requestUris ```
| rex field=req_uri "(?i)/v1\.0/users/(?<targeted_user_oid>[0-9a-f-]{36})"
| stats count
values(req_uri) as endpoints
values(req_uti) as token_utis
dc(req_uti) as distinct_tokens
values(targeted_user_oid) as targeted_user_oids
dc(targeted_user_oid) as distinct_users_targeted
values(req_status) as status_codes
earliest(_time) as first_seen
latest(_time) as last_seen
by req_spid</pre>
</div>
<div class="code-block">
<div class="code-header">SPL - step 2: resolve target user object-ids to display names<button class="copy-snippet" type="button">copy</button></div>
<pre>``` Run after step 1. Replace <oid1>, <oid2>, ... with the targeted_user_oid ```
``` values from step 1's output. Pulls a single audit-log row per user that gives ```
``` you the displayName and userPrincipalName. ```
`oauthsentry_aad_audit` earliest=-30d
targetResources{}.id IN ("<oid1>", "<oid2>", "<oid3>")
| dedup targetResources{}.id
| table targetResources{}.id, targetResources{}.displayName, targetResources{}.userPrincipalName</pre>
</div>
<div class="callout">
<strong>Why these workflows matter</strong>
An attacker who steals a token doesn't usually announce themselves. The first sign is often a single anomalous event surfacing an unfamiliar UTI / session id / hashed token / service-principal id - and the user (or service principal) the token belongs to is exactly what you're trying to figure out. The token identifier is the through-line; the username is downstream of the identifier, not a prerequisite, and for app-only flows there is no user at all - the service principal is the principal. The six workflows above cover the realistic distribution: most cases start from a UTI in a Graph or sign-in log; the GitHub hash and JWT cases cover external-IOC-driven IR; the SessionId case covers M365-only deployments without the Graph stream; the AppId and app-only servicePrincipalId cases are for assessing exposure scope when a vendor (Drift, Context.ai, etc.) discloses a compromise affecting one of their app registrations.
</div>
<h3><span class="num">5</span> Microsoft Graph activity logs - the post-token-theft visibility layer</h3>
<p>Graph activity logs are the only place that records every individual Graph API call the malicious app made. Without this stream, you can prove the app got a token but not what it did with it - which is the difference between "we were targeted" and "this is the regulator-disclosable scope of the breach". Stream them via Azure Monitor diagnostic settings (category <code>MicrosoftGraphActivityLogs</code>); in Splunk they land under the dedicated <code>oauthsentry_graph_activity</code> macro defined in the Detections tab.</p>
<p>An anonymized reference event - what one Graph activity record actually looks like in a French-region tenant after a delegated GET against <code>/v1.0/users?$top=2000</code>:</p>
<div class="code-block">
<div class="code-header">Microsoft Graph activity log event (anonymized)<button class="copy-snippet" type="button">copy</button></div>
<pre>{
"time": "2026-04-27T21:30:08.296Z",
"resourceId": "/TENANTS/<tenant-id>/PROVIDERS/MICROSOFT.AADIAM",
"operationName": "Microsoft Graph Activity",
"operationVersion": "v1.0",
"category": "MicrosoftGraphActivityLogs",
"resultSignature": "400",
"durationMs": "488000",
"callerIpAddress": "<source-ip>",
"correlationId": "<correlation-id>",
"level": "Informational",
"location": "France Central",
"properties": {
"timeGenerated": "2026-04-27T21:30:08.296Z",
"requestId": "<request-id>",
"operationId": "<operation-id>",
"clientRequestId": "<client-request-id>",
"apiVersion": "v1.0",
"requestMethod": "GET",
"responseStatusCode": 400,
"tenantId": "<tenant-id>",
"durationMs": 488000,
"responseSizeBytes": 286,
"signInActivityId": "<uti>",
"appId": "d3590ed6-52b3-4102-aeff-aad2292ab01c", ``` Microsoft Office (FOCI app) ```
"UserPrincipalObjectID": "<user-objectid>",
"scopes": "Mail.ReadWrite Mail.Send Files.ReadWrite.All Directory.Read.All Directory.AccessAsUser.All Group.ReadWrite.All User.ReadWrite ... (~45 scopes)",
"wids": "b79fbf4d-3ef9-4689-8143-76b194e85509", ``` default-user wid: regular non-admin user ```
"userId": "<user-objectid>",
"userAgent": "User-Agent: Mozilla/5.0 (compatible, MSIE 11, Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko",
"ipAddress": "<source-ip>",
"requestUri": "https://graph.microsoft.com/v1.0/users?$top=2000&$select=id,displayName,mail,userPrincipalName,accountEnabled",
"policyEvaluated": false,
"tokenIssuedAt": "2026-04-27T13:19:23Z"
},
"tenantId": "<tenant-id>"
}</pre>
</div>
<p>The same event shape, but for an <strong>app-only</strong> token reading another user's mailbox content - the post-vendor-compromise pattern that drives most of OAuthSentry's malicious feed (Salesloft Drift, Context.ai etc. all fall in this category):</p>
<div class="code-block">
<div class="code-header">Microsoft Graph activity log event - app-only flow (anonymized)<button class="copy-snippet" type="button">copy</button></div>
<pre>{
"time": "2026-04-28T14:44:12.562Z",
"resourceId": "/TENANTS/<tenant-id>/PROVIDERS/MICROSOFT.AADIAM",
"operationName": "Microsoft Graph Activity",
"category": "MicrosoftGraphActivityLogs",
"resultSignature": "200", ``` success ```
"callerIpAddress": "<source-ip>", ``` AWS eu-west-1 in this real example ```
"location": "North Europe",
"properties": {
"requestMethod": "GET",
"responseStatusCode": 200,
"responseSizeBytes": 11785, ``` ~12 KB returned ```
"signInActivityId": "<uti>",
"appId": "<app-id>", ``` custom OAuth app, not a FOCI app ```
"servicePrincipalId": "<sp-objectid>", ``` app-only: SP is the principal ```
"UserPrincipalObjectID": "<sp-objectid>", ``` same as servicePrincipalId on app-only ```
"userId": "", ``` empty - no user ```
"scopes": "", ``` empty - app-only tokens have no scopes ```
"roles": "Mail.Read MailboxSettings.Read User.Read.All", ``` granted application permissions ```
"wids": "0997a1d0-0d1d-4acb-b408-d5ca73121e90", ``` Directory Readers - typical sole wid for an SP with no other roles ```
"C_Idtyp": "app", ``` THE single-best delegated-vs-app-only marker ```
"C_Sid": "", ``` empty - no user session ```
"C_DeviceId": "", ``` empty - no device ```
"userAgent": "Python/3.11 aiohttp/3.13.4", ``` async Python HTTP client - tooling UA ```
"ipAddress": "<source-ip>",
"requestUri": "https://graph.microsoft.com/v1.0/users/<target-user-objectid>/messages/<message-id>/$value",
"policyEvaluated": false,
"tokenIssuedAt": "2026-04-28T14:18:56Z" ``` token 26 min old when used ```
},
"tenantId": "<tenant-id>"
}</pre>
</div>
<p>Five things this app-only event tells a defender, contrasting with the delegated event above:</p>
<ul>
<li><strong>This is an app-only flow, not delegated.</strong> The single highest-fidelity tell is <code>properties.C_Idtyp == "app"</code> - the same JWT <code>idtyp</code> claim. Reinforced by <code>scopes</code> being empty (app-only tokens have <em>roles</em>, not scopes) and <code>roles</code> being populated with the granted application permissions instead. <strong>Every detection and pivot for app-only flows must use <code>servicePrincipalId</code> instead of <code>userId</code></strong> - the latter is empty.</li>
<li><strong>The app token is reading another user's specific email by object-id and message-id.</strong> <code>requestUri</code> matches <code>/v1.0/users/{user-objectid}/messages/{message-id}/$value</code>. The <code>/$value</code> suffix returns raw MIME (full email including attachments), which is the most data-bearing form of mail read in the Graph API. App-only <code>Mail.Read</code> can read <em>any</em> mailbox in the tenant - this is the threat surface that Salesloft Drift, Context.ai and similar publisher-compromise scenarios exploited.</li>
<li><strong>Source is in published Amazon EC2 IP space.</strong> The <code>34.x.x.x</code> blocks are unambiguously AWS - cross-check the specific region against the live <a href="https://ip-ranges.amazonaws.com/ip-ranges.json">ip-ranges.json</a> (in this real example the address resolves to <code>eu-west-1</code> / Ireland, but exact ranges shift over time so always verify against the current published list). Many legitimate enterprise mail-reading SaaS products run on AWS - eDiscovery, archiving, journaling, security-scanning vendors. The legitimacy question is whether the <em>specific</em> SP shown here has a documented business reason to read mail from AWS infrastructure. Build a per-tenant allowlist of (servicePrincipalId, source-ASN) pairs that map to known mail-reading vendors; alert on any other combination.</li>
<li><strong>User-agent is a Python async HTTP client.</strong> <code>Python/3.11 aiohttp/3.13.4</code> is async Python. Real enterprise mail products almost always identify themselves with a vendor-branded UA. <code>aiohttp</code> shows up in attacker tooling and in homegrown SOC scripts in roughly equal measure - context determines which.</li>
<li><strong>Token is fresh (~26 min old).</strong> <code>tokenIssuedAt</code> at 14:18:56, event at 14:44:12 - well within the default 60-90 minute access-token lifetime. Recent token issuance combined with mailbox-content reads is consistent with active operation rather than legacy automation that just hasn't been touched in months.</li>
</ul>
<p>How to tell delegated and app-only flows apart in the Graph activity log, at a glance:</p>
<table class="kv-table">
<thead><tr><th>Field</th><th>Delegated (user-on-behalf)</th><th>App-only (service principal)</th></tr></thead>
<tbody>
<tr><td class="mono">properties.C_Idtyp</td><td class="mono">user</td><td class="mono"><strong>app</strong></td></tr>
<tr><td class="mono">properties.scopes</td><td>populated (space-separated scope strings like <code>Mail.Read User.Read.All</code>)</td><td><strong>empty string</strong></td></tr>
<tr><td class="mono">properties.roles</td><td>typically empty</td><td><strong>populated</strong> with granted application permissions</td></tr>
<tr><td class="mono">properties.userId</td><td>the acting user's object id</td><td><strong>empty / absent</strong></td></tr>
<tr><td class="mono">properties.UserPrincipalObjectID</td><td>the acting user's object id</td><td>the <strong>service principal's</strong> object id (same as <code>servicePrincipalId</code>)</td></tr>
<tr><td class="mono">properties.servicePrincipalId</td><td>absent</td><td><strong>populated</strong> (the principal)</td></tr>
<tr><td class="mono">properties.C_Sid</td><td>the user's session id</td><td><strong>empty</strong> (no user session)</td></tr>
<tr><td class="mono">properties.C_DeviceId</td><td>often populated</td><td><strong>empty</strong></td></tr>
<tr><td class="mono">properties.wids</td><td>user's role list; <code>b79fbf4d-...</code> = default-user (no admin role)</td><td>SP's role list; <code>0997a1d0-...</code> = Directory Readers (the typical sole wid when the SP has no other directory roles assigned)</td></tr>
<tr><td class="mono">requestUri shape</td><td>often <code>/me/...</code> for the acting user's own data</td><td>almost always <code>/users/{id}/...</code> or <code>/groups/{id}/...</code> - app reads any user's data</td></tr>
</tbody>
</table>
<p>Five things this single event tells a defender, in order of fidelity:</p>
<ul>
<li><strong>Token came from a FOCI client.</strong> <code>properties.appId == d3590ed6-52b3-4102-aeff-aad2292ab01c</code> is Microsoft Office, one of the eleven Family of Client IDs apps. Microsoft Office talking to <code>/users</code> is firmly outside its normal traffic shape - Office hits <code>/me/messages</code>, <code>/me/drive</code>, <code>/me/calendar</code>, <code>/me/joinedTeams</code>, never <code>/users</code> at scale. Off-pattern FOCI traffic is the post-token-theft signature TeamFiltration / UTA0352 / UNK_SneakyStrike rely on.</li>
<li><strong>Directory enumeration intent (T1087.004).</strong> <code>requestUri</code> contains <code>/v1.0/users?$top=2000&$select=id,displayName,mail,userPrincipalName,accountEnabled</code> - a textbook recon query: pull every account, who's enabled, enough metadata to pivot. The request returned 400 because Graph caps <code>$top</code> at 999 for <code>/users</code> and high page sizes need <code>ConsistencyLevel: eventual</code>; the attacker's next attempt would be <code>$top=999</code> with the right header. <strong>Alert on the attempt, not the result.</strong></li>
<li><strong>Massive scope set on a delegated token.</strong> The <code>scopes</code> field carries ~45 distinct scopes including multiple <code>*.ReadWrite.All</code>. A normal Microsoft Office client doesn't ship this much surface area in a single token; this looks like custom developer tooling (or attacker tooling impersonating a FOCI client). Tokens with >20 scopes including any <code>*.ReadWrite.All</code> against a FOCI app id are anomalous on their own.</li>
<li><strong>Acting user is a regular non-admin.</strong> <a href="https://learn.microsoft.com/en-us/answers/questions/678108/what-b79fbf4d-3ef9-4689-8143-76b194e85509-in-wids">The <code>wids</code> claim of <code>b79fbf4d-3ef9-4689-8143-76b194e85509</code> is the default-user wid; it appears on every non-guest account and indicates an ordinary user with no admin role</a>. A non-admin pulling 2000 users via Graph from an Office token is exactly the kind of "compromised account doing unusual recon" you should page on.</li>
<li><strong>User-agent is wrong twice.</strong> First, <code>Mozilla/5.0 (...; MSIE 11, Windows NT 6.3; Trident/7.0; ...)</code> is Internet Explorer 11 on Windows 8.1 - both end-of-life by 2023. Real Microsoft Office clients in 2026 don't ship that UA. Second, the literal value starts with the string <code>User-Agent: User-Agent:</code> (doubly-prefixed); this is a tooling bug where whatever made the request set the header value to <code>User-Agent: Mozilla/5.0...</code> instead of just <code>Mozilla/5.0...</code>. Both are weak signals on their own; doubly-prefixed UAs across a tenant are rare enough to hunt directly.</li>
</ul>
<p>The full set of fields on every record:</p>
<ul>
<li><code>properties.requestUri</code> - which Graph endpoint was called (<code>/v1.0/users</code>, <code>/v1.0/me/messages</code>, <code>/v1.0/me/drive/root/children</code>, etc). The single most useful field for attack-class identification.</li>
<li><code>properties.requestMethod</code> - GET for read, POST/PATCH/DELETE for modification. Modification verbs against directory or app endpoints from a delegated user token are very rare in normal traffic.</li>
<li><code>properties.responseStatusCode</code> - 200/204 = data returned; 400 = malformed (often attacker first attempt); 401/403 = blocked by CA or scope; 429 = throttled (high-volume recon).</li>
<li><code>properties.appId</code> - the OAuth app id whose token authorized this call. Match against OAuthSentry's malicious or risky feeds, and check whether it's a FOCI app talking to off-pattern endpoints.</li>
<li><code>properties.userId</code> / <code>properties.UserPrincipalObjectID</code> - on whose behalf the call was made (delegated tokens) or null/empty for app-only flows.</li>
<li><code>properties.scopes</code> - the scopes carried by the token. Cross-reference against Merill's <a href="https://graphpermissions.merill.net/">graphpermissions.merill.net</a> to translate each scope into the exact list of endpoints it unlocks.</li>
<li><code>properties.wids</code> - well-known role IDs the user holds. Combined with <code>userId</code>, this tells you whether the actor is a regular user or a privileged admin - which dramatically changes what "this Graph call is normal" means.</li>
<li><code>properties.signInActivityId</code> - the <strong>UTI</strong> claim of the token, the same identifier surfaced as <code>uniqueTokenIdentifier</code> on Entra sign-in events. The link back to the originating sign-in. Pivot via the section 4 table.</li>
<li><code>properties.userAgent</code> - the HTTP User-Agent header. Real Microsoft client UAs are well-known and stable per-app; anything else (Trident, python-requests, curl, Go-http-client, Java, doubly-prefixed) is suspicious.</li>
<li><code>properties.ipAddress</code> / <code>callerIpAddress</code> - source. Cross-check against the user's normal sign-in pattern.</li>
<li><code>properties.tokenIssuedAt</code> - when the token was originally issued. The delta between <code>tokenIssuedAt</code> and the event time tells you whether the call was made with a fresh or a refresh-derived token, which sets your window of vulnerability after compromise. <strong>Microsoft's defaults:</strong> access tokens last a randomized <strong>60-90 minutes (75 min average)</strong> for Conditional-Access-enabled tenants, or a flat <strong>2 hours</strong> for clients like Microsoft Teams and Microsoft 365 in tenants without CA; refresh tokens last <strong>90 days</strong> (24 hours for single-page apps), rolling-renewed on every use. Authorization codes (used only at the consent prompt) live exactly <strong>10 minutes</strong> and are not configurable. <strong>If your tenant has a Configurable Token Lifetime (CTL) policy that sets <code>AccessTokenLifetime</code> above the default</strong> - up to the maximum 24 hours - that is itself a security finding: it widens the window during which a stolen access token remains valid and unrevocable (per the <a href="#/investigation/remediation" data-route="/investigation/remediation">Remediation</a> note on revocation limits). Audit your CTL policies via <code>Get-MgPolicyTokenLifetimePolicy</code>; treat any policy with <code>AccessTokenLifetime</code> above two hours as worth a documented business justification.</li>
<li><code>properties.responseSizeBytes</code> - large responses on <code>/messages</code> or <code>/drive/...</code> endpoints = bulk exfiltration.</li>
<li><code>properties.policyEvaluated</code> - whether Conditional Access evaluated this call. Many Graph operations don't trigger CA evaluation; this is normal but worth knowing during incident reconstruction.</li>
</ul>
<div class="callout">
<strong>Enable this now</strong>
Graph activity logs are off by default. Turn them on (Entra admin center → Monitoring & health → Diagnostic settings → add <code>MicrosoftGraphActivityLogs</code>) and route them to whatever destination feeds your Splunk index. The <a href="#/investigation/detections" data-route="/investigation/detections">Detections tab</a> ships Detection 10 (FOCI-token directory enumeration) and the <a href="#/investigation/hunting" data-route="/investigation/hunting">Hunting tab</a> ships Hunts 9 and 10 specifically for this stream. Without retroactive logs, post-incident scope is largely guesswork.
</div>
<h3><span class="num">6</span> Mailbox & artifact-level traces</h3>
<p>For mail-scope abuse - the most common goal of OAuth phishing - Exchange has its own audit trail. The richest event is <code>MailItemsAccessed</code>, but it has caveats every investigator must know.</p>
<div class="callout warn">
<strong>License and throttling caveats for MailItemsAccessed</strong>
Following Microsoft's response to Storm-0558 in 2023, <code>MailItemsAccessed</code> moved from Purview Audit (Premium) to Purview Audit (Standard) - rolled out from June 2024 and complete by September 2024 - so it is now generated by default for any user on an Office 365 E3/E5 or Microsoft 365 E3/E5 license. <strong>Caveat</strong>: for mailboxes whose audit configuration was customized before the rollout, the new events are not auto-added; check <code>(Get-Mailbox).DefaultAuditSet</code> and add <code>MailItemsAccessed</code> to <code>AuditOwner</code>/<code>AuditDelegate</code> via <code>Set-Mailbox</code> if missing. Throttling: bind-event logging is capped at <strong>1,000 audit records per mailbox per 24 hours</strong>; once exceeded, MailItemsAccessed bind events for that mailbox stop logging for 24 hours (sync events, <code>Send</code>, <code>SoftDelete</code>, and other audit actions continue unaffected; admin searches against other mailboxes are also exempt). The first 1,000 events ARE recorded - the silenced window is the follow-on 24 hours. Throttled mailboxes are flagged with <code>IsThrottled = True</code> on the trigger record. Microsoft says <1% of mailboxes ever throttle, so an <code>IsThrottled = True</code> event is itself a strong IOC.
</div>
<table class="kv-table">
<thead><tr><th>Artifact</th><th>Where to look</th><th>What it shows</th></tr></thead>
<tbody>
<tr><td>Mailbox audit (M365 unified)</td><td class="mono">Search-UnifiedAuditLog -Operations MailItemsAccessed -FreeText <appid></td><td>Every read keyed to <code>ClientAppId</code>, <code>ClientInfoString</code>, <code>SessionId</code>, <code>OperationProperties.ClientIPAddress</code>. <code>MailAccessType = Sync</code> = bulk folder pull (Outlook desktop / IMAP / EWS); <code>Bind</code> = single message access. CISA's Sparrow uses this exact query.</td></tr>
<tr><td>Mailbox audit</td><td class="mono">Send / SendAs / SendOnBehalf</td><td>Outbound mail by the attacker through the OAuth token. Pivot on the same <code>SessionId</code> as the read events.</td></tr>
<tr><td>Inbox rules</td><td class="mono">New-InboxRule / Set-InboxRule / UpdateInboxRules</td><td>Auto-forward, move-to-RSS-feeds, mark-as-read rules created during the attack window. Common pattern: forward-then-delete to hide replies.</td></tr>
<tr><td>Mail flow rules</td><td class="mono">New-TransportRule / Set-TransportRule</td><td>Tenant-wide forwarding rules created via Graph after admin-consent abuse.</td></tr>
<tr><td>Mailbox permissions</td><td class="mono">Add-MailboxPermission / Add-RecipientPermission</td><td>Send-As / Send-On-Behalf delegations added to give the attacker continued sending access after token revocation.</td></tr>
<tr><td>Search activity (E5)</td><td class="mono">SearchQueryInitiatedExchange / SearchQueryInitiatedSharePoint</td><td>Off by default - must be enabled per-mailbox via <code>Set-Mailbox -AuditOwner @{Add="SearchQueryInitiated"}</code>. Reveals exactly what the attacker searched for.</td></tr>
</tbody>
</table>
<p>Pivot pattern using SessionId once you have one suspicious event:</p>
<div class="code-block">
<div class="code-header">PowerShell - Exchange Online<button class="copy-snippet" type="button">copy</button></div>
<pre># Find candidate sessions tied to the malicious app id
Search-UnifiedAuditLog -StartDate 2026-04-20 -EndDate 2026-04-27 `
-Operations MailItemsAccessed -FreeText "<malicious-app-id>" `
-ResultSize 5000 |
Select-Object -ExpandProperty AuditData | ConvertFrom-Json |
Select-Object CreationTime, UserId, ClientAppId, SessionId, ClientIPAddress, MailAccessType |
Sort-Object SessionId, CreationTime
# Pull every record under one SessionId across all Exchange operations
$sid = "<session-id-from-above>"
Search-UnifiedAuditLog -StartDate 2026-04-20 -EndDate 2026-04-27 `
-RecordType ExchangeItem,ExchangeItemAggregated,ExchangeAdmin -ResultSize 5000 |
Select-Object -ExpandProperty AuditData | ConvertFrom-Json |
Where-Object { $_.SessionId -eq $sid } |
Select-Object CreationTime, UserId, Operation, ClientIPAddress |
Sort-Object CreationTime</pre>
</div>
<h3><span class="num">7</span> Device registration (the persistence step)</h3>
<p>Recent campaigns (UTA0355, Storm-2372) chain consent abuse with device registration to mint a Primary Refresh Token (PRT) - a token-granting token that survives password reset and outlasts most refresh-token revocations because it's tied to the registered device, not the user session.</p>
<ul>
<li><strong>Audit log op</strong> <code>Add device</code> or <code>Add registered owner to device</code> where the registering app id is <code>29d9ed98-a469-4536-ade2-f981bc1d605e</code> (Microsoft Authentication Broker) - the same client used by ROADtools / ROADtx in UTA0355 emulation.</li>
<li><strong>Sign-in logs</strong> entries where <code>DeviceDetail.DeviceId</code> is freshly created (not seen in last 7-30 days) and <code>IsCompliant = false</code>, <code>IsManaged = false</code>.</li>
<li><strong>Sign-in logs</strong> entries where <code>AuthenticationProtocol = deviceCode</code> = device-code flow phishing.</li>
<li><strong>Conditional Access bypasses</strong> where the new device satisfies "compliant device" requirement because the attacker registered it themselves and joined it.</li>
<li><strong>Audit log op</strong> <code>Register device</code> immediately followed by an MFA registration event (<code>Update user</code> with <code>StrongAuthenticationMethod</code> changes) for the same user is the strongest indicator of UTA0355's playbook.</li>
</ul>
<h3><span class="num">8</span> Reconstructing the timeline</h3>
<p>Pull all of the above into a single chronology keyed off the application id and the affected user principal. The complete attack chain looks like this; use <code>CorrelationId</code> to glue events 1-2 together and <code>SessionId</code> + <code>UniqueTokenIdentifier</code> to glue events 3-5 together:</p>
<ol>
<li>User clicks phishing link → first sign-in to attacker app (<code>oauthsentry_aad_signin</code> with <code>category = SignInLogs</code>, captures <code>SessionId</code>)</li>
<li>User accepts consent prompt → <code>Consent to application</code> + <code>Add OAuth2PermissionGrant</code> + (if new) <code>Add service principal</code> in <code>oauthsentry_o365_audit</code> (same <code>CorrelationId</code> as the sign-in)</li>
<li>App receives access + refresh tokens → service principal sign-ins begin (<code>oauthsentry_aad_sp_signin</code>, each with its own <code>uniqueTokenIdentifier</code>)</li>
<li>App calls Graph → Graph activity log entries linked by <code>SignInActivityId</code> = the UTI from step 3</li>
<li>App reads mail → <code>MailItemsAccessed</code> events in <code>oauthsentry_o365_audit</code> with the same <code>AppAccessContext.AADSessionId</code> from step 1</li>
<li>(optional persistence) <code>New-InboxRule</code>, <code>Add registered owner to device</code>, <code>Add service principal credentials</code> events in the same audit stream</li>
<li>Attacker uses refresh token from new IPs → service principal sign-ins from anomalous geography, but with the same <code>appId</code></li>
</ol>
<h3><span class="num">9</span> SPL starter pack</h3>
<p>Five investigator-grade SPL queries that map onto everything described in the table above. They reuse the macro stack defined in the Detections tab (<code>oauthsentry_o365_audit</code>, <code>oauthsentry_aad_signin</code>, etc.) so you only configure index/sourcetype once.</p>
<div class="code-block">
<div class="code-header">SPL<button class="copy-snippet" type="button">copy</button></div>
<pre>``` 1. Every consent grant or grant-creation event in the last 30 days for known-malicious app ids ```
`oauthsentry_o365_audit` earliest=-30d
| eval Operation = replace(Operation, "\.$", "")
| where Operation IN (
"Consent to application",
"Add OAuth2PermissionGrant",
"Add delegated permission grant",
"Add app role assignment to service principal",
"Add service principal",
"Add service principal credentials"
)
| spath path=ModifiedProperties{}.Name output=mp_names
| spath path=ModifiedProperties{}.NewValue output=mp_values
| eval appid = lower(ObjectId) ``` ObjectId is the AppId on consent events; M365 unified shape has no TargetId.ServicePrincipalNames ```
| eval scope_idx = mvfind(mp_names, "(?i)ConsentAction\.Permissions")
| eval scope = lower(mvindex(mp_values, scope_idx))
| eval admin_idx = mvfind(mp_names, "(?i)ConsentContext\.IsAdminConsent")
| eval is_admin = lower(mvindex(mp_values, admin_idx))
| `oauthsentry_malicious_lookup`
| where oas_category = "malicious"
| table _time Operation UserId ClientIP appid scope is_admin oas_severity oas_comment
``` 2. Service principal sign-ins from a known-bad app id, summarised ```
`oauthsentry_aad_sp_signin` earliest=-30d
| eval appid = lower(appId)
| `oauthsentry_malicious_lookup`
| where oas_category = "malicious" AND ResultType = "0"
| stats earliest(_time) as first_seen
latest(_time) as last_seen
values(ipAddress) as ips
values(location) as countries
values(resourceDisplayName) as resources
dc(uniqueTokenIdentifier) as tokens
by appid, servicePrincipalName
``` 3. Pivot from one suspicious sign-in to every Exchange action in the same session. ```
``` Replace <SID> with the SessionId from the sign-in record. ```
`oauthsentry_o365_audit` earliest=-7d
| eval session = coalesce('AppAccessContext.AADSessionId', SessionId)
| where session = "<SID>"
| eval src_ip = coalesce(ClientIPAddress, ClientIP)
| spath path=Item.Subject output=item_subject
| table _time UserId Operation src_ip item_subject ResultStatus
| sort _time
``` 4. Pivot from one suspicious sign-in to every other audited action by the same UTI. ```
``` Replace <UTI> with the UniqueTokenIdentifier from the sign-in record. UTI surfaces both ```
``` in Entra sign-in logs (uniqueTokenIdentifier) and in M365 audit data (AppAccessContext.UniqueTokenId). ```
(`oauthsentry_aad_signin` uniqueTokenIdentifier="<UTI>") OR
(`oauthsentry_o365_audit` "AppAccessContext.UniqueTokenId"="<UTI>")
| eval src_ip = coalesce(ipAddress, ClientIPAddress, ClientIP)
| eval who = coalesce(UserId, userPrincipalName, servicePrincipalName)
| table _time sourcetype Operation operationName who src_ip
| sort _time
``` 5. Newly-observed first-party app id with broad scopes (catches UTA0352-style abuse). ```
``` Visual Studio Code is a legit app, but a non-developer principal consenting it is suspicious. ```
`oauthsentry_aad_signin` appId="aebc6443-996d-45c2-90f0-388ff96faa56" earliest=-7d
| eval upn = lower(userPrincipalName)
| join type=leftanti upn [
search `oauthsentry_aad_signin` appId="aebc6443-996d-45c2-90f0-388ff96faa56"
earliest=-187d latest=-7d
| eval upn = lower(userPrincipalName)
| dedup upn
| fields upn
]
| table _time upn ipAddress location resourceDisplayName</pre>
</div>
</div>
</div>
<!-- ===== Detections ===== -->
<div class="invest-section" id="sub-detections" hidden>
<div class="doc-block">
<h2>Detections for OAuth abuse</h2>
<p class="doc-lede">Thirteen SPL detections built for teams running M365 audit and Microsoft Entra logs through Splunk. Every search uses macros so the same logic works whether you're on the <strong>Splunk Add-on for Microsoft Office 365</strong> (sourcetypes <code>o365:management:activity</code>, <code>azure:audit</code>) or the <strong>Splunk Add-on for Microsoft Cloud Services</strong> (sourcetypes prefixed <code>mscs:</code>) - define the macros once and the searches travel with you. Tradecraft references in each detection point back to the Tradecraft tab.</p>
<div class="callout">
<strong>Macros to define before running anything below</strong>
Drop these into <code>$SPLUNK_HOME/etc/apps/<your-app>/local/macros.conf</code>, restart, and adjust the <code>index=</code> and <code>sourcetype=</code> values to match your environment. Every detection in this tab references at least one of them, so you change five lines in one place rather than nine searches.
</div>