Overview

tor browser safer leaks wasm into file:// workers

September 29, 2026
6 min read
share

TL;DR

I audited Tor Browser 15.0.23 for one narrow question: if the top page has no WebAssembly at Safer, is there another realm from the same page that still has it?

Short answer: yes for a local file:// Dedicated Worker, no for a normal https:// website. So this is a research note, not a remote bounty finding.

Locked target

  • Tor Browser Desktop 15.0.23 on Linux x86_64
  • Firefox 140.16.0esr, BuildID 20260914100000
  • SourceStamp ca2da456d746c357ef0f382c06e46e2184e5c923
  • Bundled NoScript 13.6.33.1984
  • Safer security_slider=2, Standard slider=4 as control
  • Stock profile, no source edits, no odd prefs

I used the two recent Wasm fixes as templates: 45296 (SharedWorker identity mismatch) and 45297 (missing setHTMLUnsafe hook). Both are about a realm at Safer that keeps Wasm because a hook missed it. I looked for the same pattern in sibling paths.

Why Wasm at Safer matters in Tor

Stock Firefox allows Wasm everywhere. Tor Browser 15 forces it on globally:

"javascript.options.wasm": [, true, true, true, true],

The comment in SecurityLevel.sys.mjs says Wasm is now handled by NoScript. The Safer policy for a normal site has script but no wasm cap. So the invariant is plain: at Safer, the page and every script realm born from it should have no WebAssembly.

NoScript enforces that by deleting the object, not only by blocking network.

How NoScript deletes Wasm

Two paths in nscl.

Window path in nscl/content/WasmHook.js (around lines 77 to 105):

Reflect.deleteProperty(globalThis, "WebAssembly");
patchWorkers(`(${workersPatch})()...`);

workersPatch is the worker deletion payload. Registration lives in nscl/content/patchWorkers.js:

const joinPatches = () => wrap([...patches].join("\n"));
patches.add(stringify(patch));

Worker path in nscl/main/patchWorkers.main.js in handlePatch:

  • data: and blob: get inline treatment. Code is fetched with sync XHR in loadWorkerSrc, then the current getPatch payload is prepended, then a new blob or data URL is built. Same global, already cleaned before attacker code runs.
  • Anything else is treated as remote. Code posts patchUrl to background and waits for patchedUrl or cancelUrl through failSafe.

Remote patch lives in service/patchWorkers.js (around lines 101 to 169):

urls: ["<all_urls>"],
types: ["script"],
if (name == "Sec-Fetch-Dest") {
if (!/work(er|let)/.test(value)) {
return;
}
}
let filter = browser.webRequest.filterResponseData(requestId);
filter.write(new TextEncoder().encode(patchInfo.patch));

So the remote patch only runs when the worker fetch carries Sec-Fetch-Dest: worker, sharedworker, serviceworker, or worklet. No header means early return and no patch.

The file:// gap

A file:// worker URL is not data: or blob:, so it goes down the remote path. But a file:// fetch has no HTTP headers, so no Sec-Fetch-Dest. The filter returns before injecting anything.

I proved it live in stock Tor Browser headless at Safer, with two files in one folder.

file-worker.js:

(async()=>{
const t = typeof WebAssembly;
let compiled = false;
let err = "";
try{
if(globalThis.WebAssembly && WebAssembly.compile){
const bytes = new Uint8Array([0,97,115,109,1,0,0,0]);
const m = await WebAssembly.compile(bytes);
compiled = !!m;
}
}catch(e){ err = String(e).slice(0,100); }
postMessage({t, compiled, err});
})();

file-remote-worker.html:

<!doctype html><html><head><meta charset="utf-8"></head><body>
<div id="out">loading...</div>
<script>
async function run(){
const out=[];
await new Promise(r=>setTimeout(r,3000));
out.push("top: "+typeof WebAssembly);
const w = new Worker('file-worker.js');
const m = await new Promise((res,rej)=>{
const to=setTimeout(()=>rej(new Error("timeout")),6000);
w.onmessage=e=>{clearTimeout(to);res(e.data);};
w.onerror=e=>{clearTimeout(to);rej(new Error(e.message));};
});
out.push("worker t: "+m.t+" compiled:"+m.compiled);
w.terminate();
document.getElementById("out").innerText=out.join("\n");
document.title="DONE";
}
run();
</script>
</body></html>

Safer file://, 6 of 6 runs:

top: undefined BLOCKED
worker t: object compiled:true

Standard file:// control:

top: object ALLOWED
worker t: object compiled:true

Standard shows no violation because both sides may have Wasm. Safer shows the violation because top is cleaned and the worker is not. SharedWorker('file://...') threw in my runs, so the repro is Dedicated Worker only.

The test that decides bounty eligibility: https:// equivalent worker

A local file PoC is not the same as a website exploit. I needed a real Tor connected secure origin I fully control, with both index.html and worker.js same origin.

I used a local onion service:

  • Local HTTP server on 127.0.0.1:8899 serving the PoC folder
  • Separate Tor daemon publishing an ephemeral onion Port=80,127.0.0.1:8899
  • Tor Browser stock proxy 127.0.0.1:9150 forwarded to real system Tor
  • No custom proxy prefs, no source edits

Onion http: counts as secure like https: in NoScript through Sites.parse, so the Wasm policy matches a normal https: site at Safer: script allowed, Wasm not.

index.html on onion:

<!doctype html><html><head><meta charset="utf-8"></head><body>
<div id="out">loading...</div>
<script>
async function run(){
const out=[];
out.push("top origin "+location.origin);
await new Promise(r=>setTimeout(r,2500));
out.push("top typeof WebAssembly: "+typeof WebAssembly);
const w=new Worker('/worker.js');
const m=await new Promise((res,rej)=>{
const to=setTimeout(()=>rej(new Error("timeout-no-msg")),15000);
w.onmessage=e=>{clearTimeout(to);res(e.data);};
w.onerror=e=>{clearTimeout(to);rej(new Error("worker-error"));};
});
out.push("worker wasmType="+m.wasmType+" compiled="+m.compiled);
document.getElementById("out").innerText=out.join("\n");
document.title="DONE";
}
run();
</script>
</body></html>

worker.js on onion:

(async()=>{
const t = typeof WebAssembly;
const bytes = new Uint8Array([0,97,115,109,1,0,0,0]);
let compiled = false;
try{ const m = await WebAssembly.compile(bytes); compiled = !!m; }catch(_){}
postMessage({wasmType: t, compiled});
})();

Safer onion over real Tor:

top origin http://zhrj46...onion
top typeof WebAssembly: undefined
worker wasmType=undefined compiled=false

Standard onion control:

top typeof WebAssembly: object
worker wasmType=object compiled=true

Server log for both runs:

PATH=/index.html Sec-Fetch-Dest=document Sec-Fetch-Mode=navigate
PATH=/worker.js Sec-Fetch-Dest=worker Sec-Fetch-Mode=same-origin

That log matters. It proves the https:// equivalent worker fetch carries the worker dest, so it hits the filterResponseData path. The worker is cleaned. No bypass.

Every https:// to file:// bridge I tried was rejected

From an http://127.0.0.1:8899 top as a stand-in for scheme and same-origin logic:

  • new Worker('file:///...'): throw worker error
  • new SharedWorker('file:///...'): throw
  • new Worker('/redirect-file') with 302 to file://: throw, and the first fetch still logged Sec-Fetch-Dest=worker
  • Blob worker importScripts('file:///...'): SecurityError inside worker
  • Blob worker spawning sub Worker('file:///...'): timeout with no Wasm message
  • import('file:///...'): throw TypeError
  • Worker('chrome://...'), resource://, moz-extension://, about:blank, javascript:..., view-source:...: all throw
  • audioWorklet.addModule('file:///...'): throw AbortError

blob: and data: workers on a warm Safer profile stayed blocked because the inline patch still carries the deletion. Shadow DOM iframes were invisible to window.length and not observed, but the child content script still removed Wasm for srcdoc, data:, and blob: in my runs. No stable steal there.

Honest close

  • Technical bug is real: a file:// Dedicated Worker at Safer keeps working WebAssembly.compile while the top page does not. Root cause is the header-gated filter plus inline handling limited to data: and blob:.
  • Remote bounty is not made: a normal https:// attacker has no shown path to that file:// realm, and live https:// equivalent workers are patched with headers present.
  • Right status is HOLD as a local file note, DROP as a web attacker finding.
  • Sensible fix in one line: handle file: workers with the same inline prepend used for blob: and data:, or refuse file: workers at Safer if local workers are out of scope.

I keep this as research because the method transfers: lock the version, prove top blocked, prove worker compiled, prove the Standard control, prove the headers, test bridges until one breaks or all reject, then stop and label what was not proved.

Related posts