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:andblob:get inline treatment. Code is fetched with sync XHR inloadWorkerSrc, then the currentgetPatchpayload 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
patchUrlto background and waits forpatchedUrlorcancelUrlthroughfailSafe.
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 BLOCKEDworker t: object compiled:trueStandard file:// control:
top: object ALLOWEDworker t: object compiled:trueStandard 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...oniontop typeof WebAssembly: undefinedworker wasmType=undefined compiled=falseStandard onion control:
top typeof WebAssembly: objectworker wasmType=object compiled=trueServer log for both runs:
PATH=/index.html Sec-Fetch-Dest=document Sec-Fetch-Mode=navigatePATH=/worker.js Sec-Fetch-Dest=worker Sec-Fetch-Mode=same-originThat 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 errornew SharedWorker('file:///...'): thrownew Worker('/redirect-file')with 302 tofile://: throw, and the first fetch still loggedSec-Fetch-Dest=worker- Blob worker
importScripts('file:///...'): SecurityError inside worker - Blob worker spawning sub
Worker('file:///...'): timeout with no Wasm message import('file:///...'): throw TypeErrorWorker('chrome://...'),resource://,moz-extension://,about:blank,javascript:...,view-source:...: all throwaudioWorklet.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 workingWebAssembly.compilewhile the top page does not. Root cause is the header-gated filter plus inline handling limited todata:andblob:. - Remote bounty is not made: a normal
https://attacker has no shown path to thatfile://realm, and livehttps://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 forblob:anddata:, or refusefile: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.