Skip to content

SmarterMail build-9777 wave (3 GHSAs): the JWT role claim is a snapshot

Wave in one line: The JWT role claim is a snapshot, the script-directory containment is a four-call chain away, and the sanitizer treats MathML as CDATA

Signal: GitHub Security Advisories published a coordinated three-advisory wave against SmarterMail (all fixed before build 9777) at 2026-10-09T18:31Z. SmarterMail is an internet-exposed Windows mail server with a long exploitation history (CVE-2026-24423 ConnectToHub RCE is already KEV-listed), and this wave reads like a field guide to its admin API: one leg defeats the token lifecycle, one leg walks a low-value authenticated primitive into SYSTEM code execution, one leg breaks the mail-content sanitizer with browser-reparse mechanics.

Advisory table

GHSA CVE Sev. Boundary
GHSA-3wv9-x54m-xfch CVE-2026-104084 high JWT access and refresh tokens embed a role claim minted at issuance; POST /api/v1/auth/refresh-token redeems the refresh token without revalidating the role against the account's current role. A refresh token captured before an admin demotion — or held by a demoted user whose session wasn't actively polling at demotion time — mints fresh access tokens that retain DomainAdmin/SysAdmin until natural token expiry.
GHSA-4rp5-r82m-g6p3 CVE-2026-104082 high RCE from a SysAdmin-scoped token via containment bypass: provision a mail domain with an arbitrary FileStore root path inside the trusted Scripts directory via the domain-put endpoint. Chain: AddOrUpdateMount discloses the Scripts path, global-mail endpoint clears the upload extension blacklist, ordinary mail file-storage upload lands the script, and saving a CommandMount triggers RunScript before validation → reverse shell as the SmarterMail service account (SYSTEM).
GHSA-vvh8-jc22-6q2f CVE-2026-104083 medium Stored mutation XSS delivered as a crafted iCal message: payload inside <style> nested in MathML foreign content (<math><mtext><mglyph>) — the custom HTML sanitizer treats it as inert CDATA text, the browser reparses it as live markup, and <img src=x onerror=…> executes when the recipient opens the message at /interface/message-iframe, unconstrained by the interface's permissive CSP.

Why this is durable operator value

  1. A role claim in a token is a snapshot, not a binding — and the refresh endpoint is where stale privileges resurface. Every RBAC audit asks "can I forge a role?"; almost none ask "can a legitimately issued token outlive a demotion?" The test is a lifecycle probe, not a forgery probe: issue a token as high-role, demote the account, then only refresh (never re-login). If the refreshed access token still carries the old role, de-privileging is decorative until the token TTL expires. Generalizes to every product with refresh-token flows and admin-demotion/revoke features (the revocation-free auth-cache side channel on the June 8 pyLoad page is the same genealogy — the credential's validity and the account's state are checked at different times, or not at all).
  2. Containment controls that gate a destination path lose to any feature that lets you name the destination. The Volume Mount script-directory control existed and enforced; the bypass was provisioning a different feature (domain FileStore root) whose path parameter accepted the same trusted directory. This is the validation-target-vs-write-target mismatch by way of a second endpoint. The chain also demonstrates three independent API-audit primitives composing: (a) an endpoint that discloses the containment-relevant path (AddOrUpdateMount revealing the Scripts dir), (b) a global setting reset an authenticated role can call (extension blacklist cleared via global-mail), (c) an execute-before-validate trigger (saving a CommandMount runs the script before any check). On any appliance admin API with a "run script/command from path" feature, enumerate which endpoints write the path field, which write the allow-list/blacklist, and which execute on save rather than on validated read.
  3. Sanitizer-vs-reparser grammar drift, foreign-content edition. The sanitizer made a correct-looking decision for its own parse model ("style content is CDATA text, harmless") that the browser's foreign-content reinsertion rules contradict — MathML/SVG contexts reset the tokenizer, so "inert text" becomes live markup on reparse. This is the DOMPurify mXSS family (May 7 page) with a different engine: not a sanitizer bug against its own grammar, but a custom sanitizer that never modeled foreign content at all. Operator rule: every HTML-folding mail/webmail sanitizer deserves the mXSS battery — <math><mtext><style>, <svg><foreignObject>, <noscript>, <xmp>, noembed — delivered through whatever content-type the product ingests (here iCal, which most sanitizer test suites never parse). Delivery-vector angle: the payload rides an inbound calendar invite, i.e., zero user interaction beyond opening a message, and the sink is the webmail message iframe with a permissive CSP.
  4. Product posture: same mail server, three boundary layers broken in one release — token lifecycle, admin-API containment, and inbound-content sanitization. On an authorized SmarterMail engagement, treat build < 9777 as "test every save-triggers-action endpoint and every path-typed admin field," not just CVE-by-CVE replay.

Replayable validation boundaries (lab/authorized scope only)

  • Stale-role refresh probe (own lab tenant, own accounts): create a lab DomainAdmin, capture its refresh token, demote it from a second admin session, then call POST /api/v1/auth/refresh-token with the captured value and decode the returned access token's role claim. Evidence = decision table (role at issue / role at redeem / claim in refreshed token). Never capture or replay a real user's token; the probe uses tokens you were issued.
  • Containment-chain inventory (read-only posture + lab proof): with a lab SysAdmin token, walk the four steps against a scratch path you own: does the mount endpoint echo the Scripts path? does the global-mail endpoint accept a blacklist-clear? does an ordinary upload accept an extension that was just cleared? Use an inert script that writes a marker file; stop before touching the live Scripts directory on any shared system. The decision table per leg (allowed/blocked, path disclosed yes/no) is the deliverable even where full execution isn't attempted.
  • mXSS battery via iCal (owned lab mailbox pair): send yourself a crafted invite containing the MathML foreign-content wrapper with a benign <img onerror> marker; open in your own webmail session; evidence = DOM mutation in the message iframe. Test wrapper variants across <style>/<textarea>/<title> inside <mtext>/<foreignObject>. Never seed payloads into third-party mailboxes.

October 10 follow-up: Strapi admin API tokens keep removed content fields — stale-ceiling drift lives on the token side too (CVE-2026-108114 / GHSA-wmch-qg2f-grh3, medium, 5.47.0–5.57.0)

Strapi's reconcileTokenPermissionsToUserCeiling — the routine meant to shrink admin API token permissions when the owner's role is field-restricted — skips any token permission with omitted or null fields, so those tokens keep reading content fields an administrator just removed from the role. Same invariant as this page's refresh-token leg (3wv9): the credential keeps the old, wider authority after the identity's authority shrinks — but the lifecycle probe is different. The SmarterMail probe is issue-high → demote → refresh; the Strapi probe is issue-all-fields token → restrict role fields → call the Content API with the unchanged token.

Sweep rule for any platform with both user roles and long-lived API tokens: after restricting a field in a lab role, re-run reads through (a) a fresh session, (b) a refreshed token, (c) a pre-existing API token — all three paths must deny. A positive on (b) or (c) while (a) denies is the revocation-gap finding. The null/omitted-means-inherit-vs-inherit-means-unbounded confusion is the code fingerprint: grep the reconcile/sync functions for if (!fields) return / if (fields == null) continue shapes — skipping the sync is not shrinking.

Cross-references

  • mXSS and sanitizer-context family: May 7 DOMPurify mXSS page (DOMPurify-side context switching; this leg is custom-sanitizer-vs-foreign-content); Obsidian MathJax URL filter leg on the Sept 23 desktop page.
  • Revocation-free credential validity: pyLoad auth-cache leg on the June 8 pyLoad page; Keycloak DCR silent revocation-disable on the Sept 16 IdP page.
  • Validation-target-vs-write-target and save-time execution: Nginx UI extractor-before-decide on the Oct 9 Nginx UI wave; Splunk sidecar file-primitive page (Oct 7).
  • Prior SmarterMail KEV leg (missing-auth ConnectToHub command execution): Feb 5 page — treat that legacy page as historical KEV context; this page is the operator workflow.
  • Windows mail-host trust density: Oct 8 hMailServer wave.