Obsidian Desktop RCE via a MathJax Safe-Filter Bypass
CVE-2026-104078
CVSS 8.4

One invisible byte is all it takes. Slip a TAB between java and script, and the filter that is supposed to block dangerous links decides there is no link scheme to block at all. The browser disagrees — and by the time it disagrees, the victim has already clicked the equation.
This is the second of two code-execution paths our team found in Obsidian Desktop. Both end with JavaScript running inside Obsidian's Node-enabled renderer, with the authority of the user's account. This post covers the MathJax path; the Slides path is CVE-2026-104077. The two are worth reading together, because they reach the same privileged sink through completely different parser mistakes.
At a glance
CVE | CVE-2026-104078 |
|---|---|
Affected component | Bundled MathJax Safe component ( |
Vulnerability | Safe-filter bypass via a canonicalization mismatch, reaching a |
CVSS | 8.4 High (v4.0) · 7.8 High (v3.1) |
Prerequisite | The formula renders; the victim clicks the rendered link |
Affected versions | Obsidian Desktop < 1.14.0 |
Status | Addressed in the 1.14.x line per the CVE record; the TAB gap still reproduced on the public 1.14.x build |
Impact | Arbitrary OS commands as the Obsidian user |
Discoverer | CodeAnt AI Security Research Team |
Why a clicked equation can reach the operating system
Obsidian Desktop is an Electron application, so a note is not painted by a plain, sandboxed web view — and that is the precondition that turns a rendering bug into remote code execution.
Electron runs as two kinds of process. The main process is Node.js: it owns the windows and the filesystem. Each window is painted by a renderer, which is a Chromium page — the same engine as a browser tab. In an ordinary browser that tab is sandboxed: script can touch the page and the network, never the machine. Electron keeps the renderer that weak by default, and a window's webPreferences is what can loosen it.
Two settings decide the blast radius. nodeIntegration is the wall between page script and the machine: off, the page cannot read a file or start a program; on, its JavaScript can call require() and pull in Node built-ins such as fs and child_process. contextIsolation is the wall between the page and the trusted preload script: off, the two share one world. Turn the first on and the second off together, and page JavaScript runs with the authority of a local application.
The Obsidian vault window opened with exactly that configuration:
Configuration extracted from the tested desktop application, not an illustrative Electron sample.
As Electron's own security guidance and sandbox documentation spell out, enabling Node integration is what lets an XSS graduate into code execution. The bug is not in the sanitizer — it is in MathJax, a post-processor that runs after sanitization and makes its own decision about what counts as a safe link.
MathJax's safe filter, and what it checks
MathJax's \href macro lets a formula carry a clickable link. Obsidian configures MathJax's Safe component to reject dangerous link schemes such as javascript: and data:. The whole defense rests on one step: correctly identifying the URL's scheme so it can be checked against an allow-list.
To find the scheme, the filter reads characters from the start of the URL up to the first colon, accepting only letters. The reasoning is sound on its face: javascript: is a run of letters followed by a colon, so reading that run tells you the scheme. But the filter demands the letters be contiguous — an unbroken run right up to the colon.
The link is allowed when the protocol is on the safe list — or when there is no protocol at all (!protocol). That empty-protocol branch exists for relative links like #section. It is also the branch an attacker aims for.
The bug: a TAB the filter can't see but the browser removes
Slip one TAB character between java and script, and the run of letters is broken before the colon is reached. [a-z]+ matches java, then stops at the TAB; there is no colon immediately after, so the whole pattern fails. The filter finds no clean scheme, concludes the link has no protocol worth blocking, and takes the "safe" branch.
MathJax 4.1.3 — the version Obsidian bundles in the 1.14.x line — tightened this expression to tolerate line-feed and carriage-return characters, matching them inside the scheme and stripping them before comparing. But TAB was never added to the list, so the same gap remained:
Formatted from the exact minified MathJax 4.1.3 bundle shipped in Obsidian 1.14.x.
Here is the mismatch that makes it exploitable. MathJax keeps the tabbed value exactly as written and puts it into an anchor's href. When the user clicks, Chromium has to turn that href into a real URL to follow it, and Chromium's URL parser quietly discards stray TAB bytes as part of normalizing the address. So the exact string MathJax classified as "no scheme" becomes a clean, executable javascript: URL the instant the browser acts on it.
<TAB> represents one literal byte, 0x09.
Two parsers looked at one value and disagreed about what it was. The filter is the one that decides whether to block; the browser is the one that decides what to run — and they disagreed in the attacker's favor.

Proof of concept
The proof is a formula whose \href carries a javascript: URL with one TAB hidden in the scheme. The payload writes a single marker file and does nothing else. It fires only when the rendered link is clicked.
The formula renders as an ordinary clickable label (e.g. "Lemma 3.2"). The TAB is invisible in the source and absent from the rendered link text.
What makes this dangerous in practice is that the bait looks exactly like legitimate math. The reader sees a normal cross-reference or citation link inside an equation — no visible scheme, nothing suspicious on hover for most users — and clicking it is the expected interaction.
What the click actually triggers
The formula renders (Live Preview or reading view), and the victim clicks the rendered link. Until that click, nothing runs.
When those hold, Chromium normalizes java<TAB>script:… into javascript:…, executes it in the Node-enabled renderer, and require('fs') / require('child_process') give arbitrary OS commands as the Obsidian user. The marker in the PoC could as easily be a downloaded payload or a spawned process; the research proof deliberately stops at the marker.
The required click is why this scores UI:A / UI:R — active victim interaction, but a click on something that looks entirely routine.
Status: what's fixed, what isn't
This is the part worth stating precisely, because the vendor record and our measurement describe different boundaries.
CVE record: CVE-2026-104078 lists affected versions as all before 1.14.0 — i.e. marked addressed in the 1.14.x line, with the fix referenced in the Obsidian desktop changelog.
What we measured: on the tested public 1.14.x build, the TAB case still reproduced. The bundled MathJax 4.1.3 filter strips CR and LF but still does not account for TAB, so
java<TAB>script:was still classified as having no protocol, and a trusted click still created the child-process marker. The Slides path (CVE-2026-104077) closed on the same build; this one did not.Upstream: the MathJax filter defect was public before this writeup. Maintainer Davide Cervone opened and merged PR #1557, which adds TAB to the filtered protocol characters, for the 4.2 milestone.
In short: treat the TAB variant as live until your Obsidian build ships a MathJax that filters TAB (MathJax 4.2 or later), regardless of the version boundary in the CVE record.
See if you are affected
Affected: Obsidian Desktop before 1.14.0, and — per our testing — the public 1.14.x build with its bundled MathJax 4.1.3, wherever note math is rendered and clickable.
If you cannot confirm a TAB-filtering MathJax yet:
Treat shared notes as untrusted. Do not click links inside equations in notes you did not author. A rendered math link whose real target is a scheme you can't see is a red flag.
Be cautious with cloned or downloaded vaults. The payload survives as plain Markdown and renders silently.
Watch for the fixed MathJax (TAB in the protocol filter, MathJax 4.2+) in your Obsidian release notes, and upgrade when it lands.
If you embed MathJax in your own product, pull in the PR #1557 fix and add TAB to any scheme-matching you do yourself.
What this finding shows
Every "safe link" filter is really a bet that it parses a URL the same way the thing that follows the URL does. When those two parsers normalize differently — one keeps a TAB, the other drops it — the filter's verdict stops matching reality. The robust fix is not to enumerate one more whitespace byte; it is to canonicalize the URL the same way the browser will before deciding, or to match the scheme by its effect rather than its surface characters.
And, as with the Slides path, the reason this is an RCE at all is the renderer configuration: nodeIntegration: true with contextIsolation: false is what turns "a link scheme slipped past a filter" into "a process started on your machine."
Timeline
2026-09-16 | Reported to Obsidian. |
2026-10-06 | Obsidian 1.14.x available as a public release. |
2026-10-08 | CVE-2026-104078 published (assigned by VulnCheck). |
2026-10 | CodeAnt AI research published. |
References
Companion research: CVE-2026-104077 — RCE via the Slides Plugin
The MathJax filter defect was public upstream before this draft; the Obsidian-specific path to Node, affected versions, and current-build result were established by the CodeAnt AI Security Research Team.
[FAQ]
Frequently Asked
Questions
Does opening the note trigger it?
How is this different from the Slides bug?
I'm on 1.14.x — am I safe?
Is Obsidian Mobile affected?
More from CodeAnt
[GET STARTED]
START PENTEST






