Research/element-fragment-guest
0-dayNo CVEHighPublic

Element Web, fragment guest creds wipe a session

Element Web v1.12.29 loadSession prefers guest_user_id and guest_access_token from the URL hash over restore. Guests default on. clearStorage runs before whoami. A garbage token logs the victim out and wipes local crypto. No CVE yet.

Name
Element Web, fragment guest creds wipe a session
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Element
Affected
Element Web v1.12.29 (2d90d6b). Guests enabled (default). app.element.io config.json does not set disable_guests. Gadget lab of loadSession.
Published
12 Oct 2026
Updated
12 Oct 2026
Tags
0-day, element-web, session, csrf, cwe-384, cwe-352, cwe-613, unauthenticated

the comment said these keys looked unused. they still win

I am @abraxas_null. The proof of concept is on GitHub: abraxas/element-fragment-guest (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, gadget.js. Authorized lab only. It talks to loopback.

This is Element Web v1.12.29 (2d90d6b7b601ecb9ccdf81239a43b4396b4b0c9e). No CVE yet. CWE-384 / CWE-352 / CWE-613. 7.1 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H). Remote session destruction. UI:R. Not token theft. Not OS RCE.

Lifecycle.loadSession prefers guest_user_id and guest_access_token from the URL hash over restoreSessionFromStorage. Guests are on by default. onTokenLoginCompleted strips SSO and OIDC params only. It never strips the guest keys. doSetLoggedIn(..., true) runs clearStorage() before any /_matrix/client/v3/account/whoami. A garbage token is enough.

Same pin: vector: Windows drive-letter, Univention ICS javascript: href.

Why Element Web

I had just finished Moodle leftovers and wanted a client that people actually live in. Element Web is a large TypeScript tree with a history of XSS and token-handling GHSAs. The security model is honest about Desktop: sandbox on, nodeIntegration off, XSS is Matrix ATO not OS RCE. Session restore is a different class. Logout CSRF that also wipes the E2EE store is High even without stealing the token.

I only wanted Critical or High. Local is in-scope. Medium stays on the floor unless it chains. Univention ICS is the Medium sibling. This one is the High.

Pinning v1.12.29

Latest GitHub release on the day I cloned was 22 Sep 2026, tag v1.12.29, commit 2d90d6b. Monorepo: apps/web, apps/desktop, packages/*, modules/*. I did not take a nightly.

The skip list is half the hunt

On this tag the interesting GHSAs were already closed:

SSO loginToken is query-only and stripped after use. That is the control I wanted guest keys to copy. They do not.

Surfaces that returned nothing

Five hunts: timeline/export XSS, widgets/tokens/Call, Electron IPC, SSO/crypto/session, URL-preview/CSP/modules.

Timeline formatted_body XSS did not fire on this pin. Widget $matrix_access_token stayed in the SW origin check. Electron XSS did not become OS RCE (nodeIntegration: false, sandbox). SSO token-in-URL is stripped. Those hunts returned NONE in this bar.

Session restore did not.

The guest-first branch

apps/web/src/Lifecycle.ts:

Plain text
if (enableGuest && guestHsUrl && urlParams?.guest?.guest_user_id && urlParams?.guest?.guest_access_token) {
    logger.log("Using guest access credentials");
    await doSetLoggedIn(
        {
            userId: urlParams.guest.guest_user_id,
            accessToken: urlParams.guest.guest_access_token,
            homeserverUrl: guestHsUrl,
            identityServerUrl: guestIsUrl,
            guest: true,
        },
        true,
        false,
    );
    return true;
}

doSetLoggedIn with clearStorageEnabled=true calls clearStorage() before the Matrix client starts. There is no whoami on this path.

apps/web/src/vector/app.tsx defaults guests on:

Plain text
enableGuest={!config.disable_guests}

apps/web/element.io/app/config.json does not set disable_guests.

url_utils.ts already comments that these fragment params look unused. They still parse. Query-string guest params are ignored because the keys live in the hash.

The lab

Gadget of the pin functions. Not a live Element SPA. how=gadget. Loopback :19600. Compose project element-fragment-guest.

Seeded @victim:example.org plus a crypto pickle, then opened #/home?guest_user_id=@x:example.org&guest_access_token=not-a-real-token. used_guest=yes, prior_session_kept=no, crypto_empty=yes, whoami_called=no. Query-string-only guest params were ignored. disable_guests kept the victim. A patched loadSession that skips guest when a non-guest session exists also kept it.

Plain text
SUCCESS element-fragment-guest how=gadget guest-wipe=yes prior-kept=no garbage-token=yes disable-guests-keeps=yes ELEMENT-FRAGMENT-GUEST-WITNESS
Plain text
python3 element-fragment-guest-Abraxas-Labs.py

or cd lab && ./run.sh. Target only http://127.0.0.1:19600. Stack tears down after the run.

Wrong turns, in the order I made them

Treating query-string ?guest_user_id= as the vector. Keys live in the hash. Query-only is the negative.

Calling this token theft. The real access token is deleted, not sent to the attacker. Logout and crypto wipe. If the guest token is valid on the default HS, the victim is parked on that guest. That is fixation, not exfil.

Expecting disable_guests on app.element.io config. It is unset. Default is on.

Expecting onTokenLoginCompleted to strip guest keys. It strips SSO and OIDC. Guest keys stay in the hash, so a reload does it again.

Booting a full Element SPA. The guest-first branch is the leftover. A gadget of loadSession is the honest lab.

A reverse shell. Theatre. The oracle is the wiped pickle plus whoami_called=no.

What an attacker can do

Get a signed-in user to open a hash URL with guest fragment params. The client logs them out, wipes local crypto, and may park them on an attacker guest.

Class: Session destruction. Reach: Remote.

Privilege Method / sink Recon / integrity / availability Lab-proven or inferred
Parse guest creds from the hash url_utils.ts fragment keys guest_user_id / guest_access_token Integrity of session selection Lab-proven. Hash became guest_params. Query-string only did not.
Prefer guest over a stored non-guest session Lifecycle.loadSession guest-first branch Integrity Lab-proven. Seeded @victim:example.org was not restored.
Wipe localStorage, IndexedDB account, sessionStorage, crypto doSetLoggedIn(..., clearStorageEnabled=true) then clearStorage() Integrity / availability of the local session Lab-proven. crypto_empty=yes, prior token gone.
Skip token validation Guest path never calls /_matrix/client/v3/account/whoami Integrity Lab-proven. whoami_called=no. Garbage token still wiped.
Persist attacker guest creds persistCredentials after the wipe Integrity Lab-proven. after_user=@x:example.org, mx_is_guest=true.
Fixate onto a real guest on the default HS Same persist if the guest token is valid Integrity Inferred. Lab used a garbage token, so no live HS login.
Keep doing this on reload Guest keys stay in the hash Integrity Inferred from pin app.tsx. Lab opened the URL once.

Closed: steal the real access token; OS RCE / LPE; query-string guest params; disable_guests: true keeps the non-guest session.

The fix

Ignore guest fragment params when a non-guest session already exists. Validate the guest token with /_matrix/client/v3/account/whoami before clearStorage. Strip guest_user_id and guest_access_token the same way SSO tokens are stripped. Default disable_guests: true for hosted app.element.io.

I am not going to print a permalink you can send to someone else's Element tab. The guest-first branch is the useful part. If you own the client, run the proof of concept against loopback.

References

Element Web, fragment guest creds wipe a session · Abraxas Labs