Cite us! section of the README, checked 2026-08-17. That paper is at EuroS&P, one of several venues this corpus does not cover — which is why the counts in Use in publications are a lower bound.This is an old revision of the document!
Table of Contents
SAP Project Foxhound
Project Foxhound is a Firefox fork that propagates taint through strings. It patches SpiderMonkey and Gecko so that a value read from an instrumented source — location.hash, document.cookie, localStorage.getItem, a postMessage payload, a DOM read — carries a per-character taint label through every string operation, and so that reaching an instrumented sink — innerHTML, eval, fetch, XMLHttpRequest.send, sendBeacon — fires a __taintreport DOM event describing the whole path. It is not a crawler: you drive it with Playwright, which the project supports and pins a version for. The browser is described in Klein et al. [1Klein, David; Barber, Thomas; Bensalim, Souphiane; Stock, Ben; Johns, Martin (2022): "Hand Sanitizers in the Wild: A Large-scale Study of Custom JavaScript Sanitizer Functions", in: 2022 IEEE 7th European Symposium on Security and Privacy (EuroS&P), pp. 236-250. (DOI)], which is also the citation its README asks for.1) The browser is GPL-3.0.
Use Foxhound when the question is “did this value reach that sink, and what was done to it on the way?” That is a different question from “what did the page load” (a request log is far cheaper) and from “which code unit caused this event” (PageGraph answers that without a browser build of your own). And a taint report is a flow, not a vulnerability: run as an off-the-shelf XSS detector on 42K pages it produced 72 flows of which 2 were confirmed exploitable [2Khodayari, Soheil; Glauber, Kai; Pellegrino, Giancarlo (2025): "Do (Not) Follow the White Rabbit: Challenging the Myth of Harmless Open Redirection", in: Proceedings of the Network and Distributed System Security Symposium. (Link)].
What a taint report actually contains
Two objects, and it is worth knowing both before you design a schema for your results.
The event detail — what your harness receives per sink hit:2)
| Field | What it is | Why a measurement needs it |
|---|---|---|
str | the full string that entered the sink | the tainted part is a substring of this, not the whole thing |
sink | sink name, e.g. innerHTML | your primary grouping key |
loc / parentloc | URL of the document and of its parent | tells a top-level flow from a framed one |
subframe | boolean | a landing-page-only crawl still collects third-party frame flows; decide whether they are in your denominator |
referrer | document referrer of the document the flow was seen in | cheap cross-check that the visit reached the page the way you think it did |
stack | JavaScript stack, when available | empty ({}) in the project's own worked example — do not plan a design around it |
The taint metadata — report.detail.str.taint, an array with one entry per tainted range:
begin/end— character offsets intostr. This is the part people miss: Foxhound tells you which five characters of a 22-character string came from the source, so “a tainted value reachedinnerHTML” can be qualified by how much of the sink argument the attacker controls.flow— an array of operation nodes, ordered sink first, source last. Each node carriesoperation(concat,substr,unescape,innerHTML,location.hash, orfunctionfor an application call),builtin,source,arguments, and alocationwithfilename,line,pos,scriptlineand ascripthash.
The project's own worked example is the clearest specification, and reading it backwards gives the program: location.hash → substr → unescape → concat → concat → innerHTML, with characters 11–16 of Welcome <b>12345</b>!! tainted.3)
Three consequences for study design:
scripthashis your deduplication key. It hashes the script content that touched the value. The same third-party library served from twenty paths on twenty sites has one hash and twenty URLs, so a per-script prevalence figure keyed on URL is not the figure you meant.- The operation chain is what makes sanitiser questions answerable at all. Klein et al.'s original result — a large-scale study of hand-written JavaScript sanitisers — exists because the chain names the functions a value passed through [1Klein, David; Barber, Thomas; Bensalim, Souphiane; Stock, Ben; Johns, Martin (2022): "Hand Sanitizers in the Wild: A Large-scale Study of Custom JavaScript Sanitizer Functions", in: 2022 IEEE 7th European Symposium on Security and Privacy (EuroS&P), pp. 236-250. (DOI)]. Nothing that records only events can produce it.
- The chain is explicitly best-effort, and it is blind under JIT. The project's own documentation says so: “The engine will generally fail to record function call information when executing in one of the JIT modes (instead of the interpreter). As such, functions that are used often by the application will likely not show up in the trace”, and a few
String.prototypemethods are inlined by the JIT and are not recorded either.4)
That JIT caveat is a systematic, not random, bias: the hotter a function, the likelier it is missing from the chain. Source, sink and character range survive it; “the flow passed through n sanitiser calls” does not. If a claim depends on the presence or absence of a node, report how many of your flows recorded any application function call at all.
The source and sink surface, and why you must report it
On main today, taint sources and sinks are preferences, not compile-time constants: 34 tainting.source.*, 61 tainting.sink.* and one tainting.active master switch, all defaulting to true, all settable from about:config or a Playwright launch profile.5)
| Source group | Prefs (all 34) |
|---|---|
| URL of the document | location.hash, .host, .hostname, .href, .origin, .pathname, .port, .protocol, .search, document.baseURI, document.documentURI |
| Client-side storage | document.cookie, localStorage.getItem, sessionStorage.getItem |
| Cross-document messages | MessageEvent, WebSocket.MessageEvent.data, PushMessageData, PushSubscription.endpoint |
| Network response | XMLHttpRequest.response |
| User and page content | input.value, textarea.value, script.innerHTML, window.name, document.referrer |
| DOM reads | document.getElementById, document.getElementsByTagName, document.getElementsByTagNameNS, document.getElementsByClassName, document.querySelector, document.querySelectorAll, document.elementFromPoint, document.elementsFromPoint, element.attribute, element.closest |
| Sink group | Prefs (all 61) |
|---|---|
| Code execution | eval, Function.ctor, setTimeout, setInterval, eventHandler |
| Markup | innerHTML, outerHTML, insertAdjacentHTML, insertAdjacentText, document.write, document.writeln, script.innerHTML, Range.createContextualFragment(fragment), element.after, element.before, element.style |
| Resource loading | script.src, script.text, script.textContent, img.src, img.srcset, srcset, iframe.src, iframe.srcdoc, embed.src, object.data, media.src, track.src, source, a.href, area.href, form.action |
| Navigation | location.assign, .replace, .href, .hash, .host, .pathname, .port, .protocol, .search, window.open |
| Network | fetch.url, fetch.body, XMLHttpRequest.open(url), .open(username), .open(password), .send, .setRequestHeader(name), .setRequestHeader(value), navigator.sendBeacon(url), navigator.sendBeacon(body), WebSocket, WebSocket.send, EventSource |
| Storage and messaging | document.cookie, localStorage.setItem, localStorage.setItem(key), sessionStorage.setItem, sessionStorage.setItem(key), window.postMessage |
Sources and sinks overlap on purpose: document.cookie, localStorage, sessionStorage and the location.* properties appear on both sides, so a storage-to-storage or storage-to-URL flow is observable without the value ever leaving the client.
The network sinks are why this page belongs next to the privacy pages and not only next to the XSS literature: a flow from localStorage.getItem to fetch.url is a stateful-tracking observation, and that is exactly how Calzavara et al. used it against filter lists [3Calzavara, Stefano; Casarin, Samuele; Squarcina, Marco; Maffei, Matteo (2026): "From Syntactic Matching to Taint Tracking and Back: A Comparative Study of Web Tracking Detection Techniques", in: Proceedings on Privacy Enhancing Technologies. (Link)].
The pref mechanism is recent, and its arrival is datable release by release:
| Release | Date | tainting.* prefs | Sources | Sinks | DOM-read sources |
|---|---|---|---|---|---|
| v80.0 – v115 | 2021-10 – 2024-01 | 0 | — | — | no |
| v118.0.1 | 2024-01-17 | 85 | 25 | 59 | no |
| v119.0 | 2024-03-07 | 96 | 34 | 61 | yes |
| v121.0 – 142.0.1 | 2024-04 – 2026-06 | 96 | 34 | 61 | yes |
Before v118.0.1 the source/sink set was fixed at build time, which is why the older papers that needed a different set had to fork the browser rather than flip a switch. The DOM-read sources arrived in v119.0 via a single merged pull request from the maintainer.6) A later pull request added flow-trace operations for URL.parse, URLSearchParams.get and .getAll, merged 2025-11-26; the string “URLSearchParams.get” is absent from v130.0 and present from v140.0.2 onwards. So in every release a paper published before 2026 could have used, a flow through URLSearchParams.get() still propagated taint but produced no node naming that step — the diff assigns the already-tainted value and then annotates it, so this is a gap in the trace, not in the tracking.7)
Report the version or commit, and report every pref you changed. Two studies that both say “we used Foxhound” can differ by nine sources, and one of them can be blind to every DOM-read flow. In this corpus only 3 of the 7 papers that used or extended Foxhound as an instrument state a version at all (see Use in publications). Nobody reports the pref set either, but that is not something this corpus can count, because the extraction has no field for it.
Report taintfox.escapeURL. This is the configuration knob with the largest measured effect on a published number, it is not one of the 96 prefs above, and almost nobody mentions it. Stock Foxhound does not percent-encode URL components the way stock Firefox does: netwerk/base/nsStandardURL.cpp reads Preferences::GetBool(“taintfox.escapeURL”, false) on the main thread, so escaping is off by default. The maintainer's stated reason is that they “initially disabled URL encoding in order to mimic the behavior of legacy browsers (such as IE), which didn't encode parts of the URL (in particular the hash)”.8)
What it costs to ignore: Sabino et al. re-ran their confirmation stage with escaping off and quintupled the confirmed DOM-XSS flows, from 68 to 347, while their manual sample found the extra ones “would not be exploitable in any modern browser”. Their own reading of the apparent decade-long decline in DOM-XSS prevalence is that “several factors, such as dataset and methodology, may contribute” and that this is the first of them — “modern browsers encode any special characters in URLs”. The others they name include genuine improvements, such as better ad blocking and HTTPS/mixed-content enforcement, so treat this as one measurement artefact among real effects rather than as a debunking [4Sabino, Nuno; Cassel, Darion; Abreu, Rui; Adão, Pedro; Bauer, Lujo; Jia, Limin (2026): "DOM-XSS Detection via Webpage Interaction Fuzzing and URL Component Synthesis", in: Proceedings of the Network and Distributed System Security Symposium. (Link)]. Whichever way you set the pref, say so, and say what the other setting would have produced.
What it does not track
- Strings only. Taint lives in the string classes —
JSString,StringBuffer,nsAString/nsACString,nsStringBuffer,FakeString,nsTextFragment9) — so a value that becomes a number and comes back is clean. Calzavara et al. found this in the wild: for the trackermc.yandex.com, “the numeric string read from storage (tainted) is converted to a number and then back to an untainted string before reaching the network sink” [3Calzavara, Stefano; Casarin, Samuele; Squarcina, Marco; Maffei, Matteo (2026): "From Syntactic Matching to Taint Tracking and Back: A Comparative Study of Web Tracking Detection Techniques", in: Proceedings on Privacy Enhancing Technologies. (Link)]. Their estimate of the cost of fixing it is “substantial modifications to the taint tracking engine, in the order of thousands of lines of code”. Liu et al. rejected Foxhound for DOM-clobbering work on the same ground [5Liu, Zhengyu; Lee, Theo; Yu, Jianjia; Kang, Zifeng; Cao, Yinzhi (2025): "The DOMino Effect: Detecting and Exploiting DOM Clobbering Gadgets via Concolic Execution with Symbolic DOM", in: Proceedings of the USENIX Security Symposium. (Link)]. FP-tracer added numeric tainting itself [6Boussaha, Soumaya; Hock, Lukas; Bermejo, Miguel; Rumín, Rubén Cuevas; Rumín, Ángel Cuevas; Klein, David; Johns, Martin; Compagna, Luca; Antonioli, Daniele; Barber, Thomas (2024): "FP-tracer: Fine-grained Browser Fingerprinting Detection via Taint-tracking and Entropy-based Thresholds", in: Proceedings on Privacy Enhancing Technologies. (DOI)], and that extension is not onmain. - It over-taints on slices. Split a tainted identifier and the whole result carries taint, so transmitting a constant, non-identifying fragment still reports a flow. Calzavara et al.'s example: with
TRK-a55bd7c6in storage, sending only the constant prefixTRKstill produces an information flow “since the entire parts array is tainted”, which is a false positive for a tracking measurement [3Calzavara, Stefano; Casarin, Samuele; Squarcina, Marco; Maffei, Matteo (2026): "From Syntactic Matching to Taint Tracking and Back: A Comparative Study of Web Tracking Detection Techniques", in: Proceedings on Privacy Enhancing Technologies. (Link)]. - It sees only what executes. Taint tracking is dynamic, so an un-triggered branch is an un-observed flow. Sabino et al. got 15% more confirmed flows than passive analysis by fuzzing 55 realistic event handlers, and 20 further vulnerabilities from symbolically synthesising URL parameters [4Sabino, Nuno; Cassel, Darion; Abreu, Rui; Adão, Pedro; Bauer, Lujo; Jia, Limin (2026): "DOM-XSS Detection via Webpage Interaction Fuzzing and URL Component Synthesis", in: Proceedings of the Network and Distributed System Security Symposium. (Link)]; Khodayari et al. record Foxhound missing an XSS flow because “it could not trigger the vulnerable execution path (branches)” [2Khodayari, Soheil; Glauber, Kai; Pellegrino, Giancarlo (2025): "Do (Not) Follow the White Rabbit: Challenging the Myth of Harmless Open Redirection", in: Proceedings of the Network and Distributed System Security Symposium. (Link)].
- A flow is not an exploit. Foxhound “only detects the presense [sic] of data flows that may lead to XSS but does not verify whether these flows are actually attacker-controlled” [2Khodayari, Soheil; Glauber, Kai; Pellegrino, Giancarlo (2025): "Do (Not) Follow the White Rabbit: Challenging the Myth of Harmless Open Redirection", in: Proceedings of the Network and Distributed System Security Symposium. (Link)]. Every paper here that reports vulnerabilities pairs it with an exploit generator or manual confirmation.
- Atoms are a known soft spot. SpiderMonkey interns strings, and tainting an interned atom would taint unrelated strings, so the engine avoids atomising tainted values and prints a warning if taint is ever applied to an atom — the documentation says such warnings “indicate a bug in the engine”.10) If you see them, they belong in your limitations section.
- Firefox only, and behind.
mainis Firefox 142.0.1 while stable Firefox is 153.0.4, and the browser reports that version in its user agent — which matters for differential serving and bot detection, not just for language features. A Chromium-only behaviour is invisible; PanoptiChrome is the Chromium-side option [7Kanyal, Rahul; Sarangi, Smruti R. (2024): "PanoptiChrome: A Modern In-browser Taint Analysis Framework", in: Proceedings of the ACM Web Conference. (DOI)]. - Instrumentation gaps are normal and are worth checking before you commit. Two examples from the corpus that have since closed: Khodayari et al. had to add request-API instrumentation themselves, calling the result “Foxhound+” [8Khodayari, Soheil; Barber, Thomas; Pellegrino, Giancarlo (2024): "The Great Request Robbery: An Empirical Study of Client-side Request Hijacking Vulnerabilities on the Web", in: Proceedings of the IEEE Symposium on Security and Privacy. (DOI)], and Rautenstrauch et al. note “Foxhound does not support
iframe.srcdocdetection” [9Rautenstrauch, Jannis; Mitkov, Metodi; Helbrecht, Thomas; Hetterich, Lorenz; Stock, Ben (2024): "To Auth or Not To Auth? A Comparative Analysis of the Pre- and Post-Login Security Landscape", in: Proceedings of the IEEE Symposium on Security and Privacy. (DOI)] — a sink that is a default-on pref today. Check the pref list for the sinks your claim needs, and write a positive test for each.
Foxhound versus the alternatives
| Instrument | Best question | What it gives, what it costs |
|---|---|---|
| Request log / HAR / proxy | which resources and responses occurred? | cheapest and most portable; no value semantics and initiator often inferred — see Traffic files |
| Filter lists over requests | which requests look like tracking? | no browser build at all, and the labels are a noisy oracle: 16%–19% likely false positives [3Calzavara, Stefano; Casarin, Samuele; Squarcina, Marco; Maffei, Matteo (2026): "From Syntactic Matching to Taint Tracking and Back: A Comparative Study of Web Tracking Detection Techniques", in: Proceedings on Privacy Enhancing Technologies. (Link)] — see Requests |
| PageGraph | which actor caused this page action or request? | causal actor–action graph over the whole page, in a shipped Brave build; no value flow, so it cannot say whether a request carried an identifier |
| In-page JS shims | which API calls did page JavaScript make? | trivial to deploy, page-detectable and defeatable |
| Patched V8 (VisibleV8) | which browser-API calls happened, from which script? | not page-detectable; API-call traces without data flow |
| Project Foxhound | did this value reach that sink, and through what operations? | per-character ranges plus an operation chain with source locations; a browser build, strings only, Firefox only, executed paths only |
| PanoptiChrome [7Kanyal, Rahul; Sarangi, Smruti R. (2024): "PanoptiChrome: A Modern In-browser Taint Analysis Framework", in: Proceedings of the ACM Web Conference. (DOI)] | the same question, in Chromium | the Chromium-side answer; measured at 36.7× overhead against Foxhound's 1.4× [10Calzavara, Stefano; Casarin, Samuele; Focardi, Riccardo (2025): "Dynamic Security Analysis of JavaScript: Are We There Yet?", in: Proceedings of the ACM Web Conference. (DOI)] |
The distinction from PageGraph is worth stating precisely, because both are patched browsers and both are sometimes described as “tracking data flow”. PageGraph records causality between page entities: this parser created that element, that element's src caused this request, this script was inserted by that script. Foxhound records provenance of a value: these five characters of the string handed to innerHTML came from location.hash and passed through substr, unescape and two concatenations, in this script, at this line. Neither subsumes the other:
- PageGraph will tell you a third-party script caused a request. Only Foxhound will tell you the request URL contained a value read from
localStorage— the difference between “a tracker was contacted” and “an identifier was exfiltrated” [3Calzavara, Stefano; Casarin, Samuele; Squarcina, Marco; Maffei, Matteo (2026): "From Syntactic Matching to Taint Tracking and Back: A Comparative Study of Web Tracking Detection Techniques", in: Proceedings on Privacy Enhancing Technologies. (Link)]. - Foxhound will tell you a tainted value reached a sink. Only PageGraph will tell you which upstream insertion chain put the script that did it on the page, or attribute a request no instrumented sink was involved in.
- Cost differs in kind, not degree. PageGraph rides in a released Brave build; Foxhound is a fork you obtain or compile, and whose version you must pin and report. Neither has a published per-page throughput benchmark on a modern crawl — the 1.4× figure in the table that follows is an analysis-time ratio on landing pages, not a crawl-rate measurement, so pilot a few hundred pages before sizing a population.
The one head-to-head evaluation in this corpus is unusually direct. Calzavara et al. screened 18 dynamic JavaScript information-flow tools, got eight to run at all, dropped two more (JSFlow exceeded a five-minute-per-page timeout on every site; GIFC could not be configured), and evaluated the remaining six on 6,921 live Tranco domains for compatibility, transparency, coverage and overhead [10Calzavara, Stefano; Casarin, Samuele; Focardi, Riccardo (2025): "Dynamic Security Analysis of JavaScript: Are We There Yet?", in: Proceedings of the ACM Web Conference. (DOI)]. Five reached the coverage stage:
| Tool | Scripts analysed without breaking | Transparent | Flows detected | Overhead |
|---|---|---|---|---|
| Project Foxhound | 95%, out of the box | 97% | 919 (94% met an agreement heuristic) | 1.4× |
| PanoptiChrome | 50% | 77% | 128 | 36.7× |
| JalangiTT | 67%, 96% after transpiling to ES5 | 71% | 67 | 8.6× |
| LinvailTaint | 47%, 63% after transpiling | 7% | 35 | 23.9× |
| IF-Transpiler | 16% even after transpiling | 82% | 0 | — |
The sixth, JEST, reached 32% eventual compatibility. PanoptiChrome's 50% is not a parsing failure but a liveness one — “the tool becomes unresponsive when analyzing complex websites”, which the paper notes its own authors did not hit because they drove it by hand rather than through automation [10Calzavara, Stefano; Casarin, Samuele; Focardi, Riccardo (2025): "Dynamic Security Analysis of JavaScript: Are We There Yet?", in: Proceedings of the ACM Web Conference. (DOI)]. That is worth carrying as a general warning: a taint browser that works in a demo can still fail under a crawler.
Their conclusion is quotable and load-bearing for anyone choosing an instrument today: “out of all the evaluated tools, just one of them (Project Foxhound) is effective enough for practical adoption at scale” [10Calzavara, Stefano; Casarin, Samuele; Focardi, Riccardo (2025): "Dynamic Security Analysis of JavaScript: Are We There Yet?", in: Proceedings of the ACM Web Conference. (DOI)]. Three cautions before treating that as settled: it is one study, one 2024 crawl of landing pages only; its transparency and coverage measures are its own definitions; and one of its authors is a listed Foxhound contributor.
Getting a browser: three routes, none free
Route 1 — TU Braunschweig's prebuilt binaries. The project points at foxhound.ias.tu-bs.de, which publishes per-commit builds, each in a vanilla and a Playwright-integrated flavour with a SHA-256 checksum and the matching Playwright version named. Checked 2026-08-17: 31 commits offered, Linux only, all compiled under Debian Bookworm, newest commit 1bb8dbd6 from 2026-05-19. That is the fastest route by a wide margin and the checksums make it citable.
Route 2 — GitHub release assets. Weaker than it looks: of 17 releases, only 2 carry binaries (v128.0 and v140.0.2). The current release, 142.0.1, has none.11)
Route 3 — build it. build.sh in the repository root wraps mach and, with -p, clones Playwright next to your checkout and applies the browser patches. Copy one of the platform mozconfigs first — taintfox_mozconfig_ubuntu, _win, _mac, _ubuntu_debug or _spidermonkey. Do not copy the README's literal cp taintfox_mozconfig .mozconfig: there is no unsuffixed taintfox_mozconfig in the tree, only the platform variants the surrounding prose names.12) Real numbers, from the project's own CI on standard GitHub-hosted runners:
| Job | Compile | Test | Total |
|---|---|---|---|
| Ubuntu 22.04 | 81.3 min | 34.2 min JS + 7.2 min taint | 130.6 min |
| Windows | 134.1 min | — | 147.5 min |
| macOS | 148.1 min | — | 153.0 min |
Budget the resources too, because the workflow itself has to: it frees the runner's tool cache, allocates 10 GB of swap on Ubuntu and a 4–10 GB pagefile on Windows, and the checkout alone is 6.1 GiB. The wiki names the failure you will actually hit: “The current version of Foxhound sometimes fails with an error about gkrust. This is caused by out-of-memory and can usually be solved by running the build again.”14)
The Linux and Windows build configurations turn off the Firefox sandbox. taintfox_mozconfig_ubuntu and taintfox_mozconfig_win both set --disable-sandbox, --without-wasm-sandboxed-libraries and --disable-crashreporter in their release configuration; taintfox_mozconfig_mac does not, and the README says macOS builds are untested. So the TU Braunschweig Linux binaries and any Windows build you make are unsandboxed.15) Two consequences: you are visiting arbitrary sites with the browser's main containment mechanism removed, which belongs in your ethics section as much as in your ops plan; and because the crash reporter is gone, a crashed content process is silent unless your harness notices the page never finished. Count crashes yourself.
Four traps that are documented but easy to walk into:
- The toolchain hash.
mach bootstrapdownloads Mozilla's prebuilt toolchains, keyed by a hash over files in the tree, and Mozilla purges old ones aggressively. The README's workaround is to check out thefirefox-releasebranch first. This is not hypothetical: as of 2026-08-17 the project's own browser and Playwright workflows fail onmainat head7ce49d32(2026-07-24) — in theBootstrapstep on Linux and macOS andConfigureon Windows — while the JavaScript-only workflow still passes. The last green full-browser build onmainis the 2026-05-19 commit that TU Braunschweig hosts a binary for. - The Rust pin. The required Rust version is not “latest”: it is written in
.PLAYWRIGHT_VERSION(RUST_VERSION=“1.86.0”today, alongsidePLAYWRIGHT_VERSION=“v1.58.2”), and a newer compiler produces errors or crashes. - The Playwright patch workflow is stateful. The patches are applied to your working tree, so a second
build.sh -pon the same branch fails because they are already applied; develop on a checkout without them, commit before applying, and use-rto reset or-sto resume. - The git history was rewritten. In February 2026 the whole
mainhistory was resynced onto the official Firefox GitHub repository. A checkout older than 2026-02-09 must be re-cloned; local changes have to be re-applied.16)
The maintenance lag is the thing to plan around. By design Foxhound tracks the Firefox baseline of a Playwright release and will “always be one release behind Playwright”.17) In practice, on 2026-08-17: main is Firefox 142.0.1 / Playwright v1.58.2, whose Firefox baseline is exactly 142.0.1 — while Playwright's current release is v1.62.1, baselined on Firefox 153.0, against a current Firefox stable of 153.0.4. So the gap is four Playwright releases and eleven Firefox major versions, and the branch closing it (playwright-1.62) was failing the browser build when checked. Plan for a crawl on a browser roughly a year behind stable, and say so in your limitations.
If you want a human in the loop rather than a crawl, there is a taint-flow visualiser extension (Apache-2.0, last pushed 2026-03-11) and a first-party ZAP add-on (Apache-2.0, last pushed 2026-08-06) which is listed in ZAP's own add-on catalogue with status Alpha. Both repositories and the catalogue entry checked 2026-08-17.
Reducing taint reports to something countable
The unit trap on this tool is sharper than on a request-level crawler. One __taintreport is one sink call, so counting reports counts page activity; the same source–sink pair fires again on every execution.
Getting the reports out is the part the project documents: an addInitScript that adds a __taintreport listener in every frame, plus an exposeBinding the listener calls with r.detail and r.detail.str.taint.18) Serialise the taint array separately from detail, because detail.str is a plain string by the time it crosses the binding and its .taint property does not survive. The script below assumes you did that and stores it as str_taint.
It reduces the reports to (source, sink) pairs counted in an explicit unit, and marks three things that quietly change a headline number: flows whose chain recorded no application function call (the JIT blind spot), flows where an encoding operation is the last thing before the sink, and flows whose tainted substring merely happened to contain no HTML syntax on the visit you made. The last two are not the same, and conflating them is how true positives get dropped.
Note the direction of flow: it is ordered sink first, so the source is the last node flagged source, and reading it the other way round mislabels every row.
- foxhound_flows.py
#!/usr/bin/env python3 """Reduce Project Foxhound taint reports to the unit a measurement should count. Input: newline-delimited JSON, one object per `__taintreport` event, exactly as a Playwright harness gets it from `exposeBinding`: {"site": "example.org", "detail": {"subframe": false, "loc": "...", "parentloc": "...", "referrer": "", "str": "...", "sink": "innerHTML", "stack": {}, "str_taint": [ {"begin": 11, "end": 16, "flow": [ ... ]} ]}} `str_taint` is `report.detail.str.taint` — the harness has to serialise it separately, because the `str` property of the event detail is a plain string by the time it crosses the binding. See the project wiki's Playwright-Integration page for the minimal `addInitScript` + `exposeBinding` pair that produces this. Why this file exists rather than a one-liner over the reports: * A taint report is NOT a vulnerability, a site, or a tracker. It is one (source, sink) pair reaching one sink call. The same source/sink pair fires once per execution, so counting reports counts page activity, not prevalence. `--unit` picks the denominator explicitly. * `flow` is ordered sink-first, source-last, so the SOURCE is the last node with `"source": true` — not the first entry. Reading it the wrong way round silently mislabels every flow. * The flow trace is best-effort. Foxhound's own docs say function-call nodes are generally NOT recorded under JIT, so hot code produces flows whose operation chain is builtins only. `jit_blind` marks those, because a study that counts "flows that passed through a sanitizer" must not treat a missing node as an absent call. YOUR OWN REPORTING HARNESS APPEARS AS A `function` NODE and would otherwise make every flow look non-blind — see HARNESS_RE. * `scripthash` identifies the script content that touched the value. It is the only stable key for deduplicating the same third-party script across sites; a URL is not, because the same code is served from many paths. The harness's own script must be skipped here too, for the same reason. * Two different things get confused when counting "exploitable" flows, and this script keeps them apart: - `encoded_at_sink`: an encoding operation (encodeURIComponent, escape…) is the last transformation before the sink, with no later decode. This is a property of the CODE. - `no_syntax_chars`: the tainted substring observed at crawl time simply contained no HTML/JS-significant character. This is a property of the INPUT you happened to send, not of the code, and a flow can be fully exploitable with a real payload while looking benign in a crawl. The canonical DOM-XSS example in Foxhound's own documentation is exactly this case. Neither is the same as Foxhound's `taintfox.escapeURL` browser pref, which decides whether the browser percent-encodes URL components at all and which moved a published DOM-XSS total by 5x. Report that pref separately. Usage: python3 foxhound_flows.py reports.jsonl # flow table python3 foxhound_flows.py reports.jsonl --unit site # sites per pair python3 foxhound_flows.py reports.jsonl --unit script # scripts per pair python3 foxhound_flows.py reports.jsonl --drop-encoded # code-level only python3 foxhound_flows.py reports.jsonl --harness 'my_hooks\\.js' python3 foxhound_flows.py --selftest No dependencies. Tested against the example taint flow in the project wiki's "Observing Taint Flows" page. """ from __future__ import annotations import argparse import collections import json import re import sys # Sinks where HTML or JavaScript syntax in the tainted substring is what makes a # flow dangerous. For a network sink, syntax characters are irrelevant. HTML_JS_SINKS = { "innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "document.writeln", "eval", "Function.ctor", "script.text", "script.innerHTML", "eventHandler", "setTimeout", "setInterval", "Range.createContextualFragment(fragment)", } ENCODING_OPS = {"encodeURI", "encodeURIComponent", "escape"} DECODING_OPS = {"decodeURI", "decodeURIComponent", "unescape"} # Not a completeness claim: encodeURIComponent leaves ' ( ) untouched, so an # encoded flow can still be dangerous in some contexts. This is a screen for # manual review, not a verdict. DANGEROUS = re.compile(r"""["'<>()]""") # Nodes contributed by the taint-reporting harness itself. Foxhound records the # call that reports the flow as an ordinary `function` node, so without this the # JIT-blindness metric and the scripthash key both describe your own code. # This is a NAME heuristic and it cuts both ways: a page function called # something like `reportTaintSinkStats` would be misclassified as harness code. # Set --harness to a pattern that matches your harness and nothing else. HARNESS_RE = re.compile(r"taint_reporting|ReportTaintSink|__playwright", re.I) def is_harness(node: dict, harness_re: re.Pattern) -> bool: """True when this flow node was contributed by the reporting harness.""" blob = " ".join(str(a) for a in node.get("arguments") or []) loc = node.get("location") or {} blob += " " + str(loc.get("filename", "")) + " " + str(loc.get("function", "")) return bool(harness_re.search(blob)) def source_of(flow: list[dict]) -> str: """The taint source. `flow` runs sink-first, so take the LAST source node.""" sources = [n for n in flow if n.get("source")] if not sources: # No node is flagged as a source. This happens for manually tainted # strings and for flows the engine could not attribute; it must not be # silently relabelled as one of the real sources. return "unattributed" return sources[-1]["operation"] def script_of(flow: list[dict], harness_re: re.Pattern) -> tuple[str, str]: """(scripthash, filename) of the node nearest the sink that is page code. A node can carry a filename with no scripthash (inline handlers, and nodes the engine could not attribute to a compiled script). Keep the first such filename so `--unit script` has something to fall back on rather than silently collapsing every unhashed flow into one empty key. """ fallback = "" for node in flow: if is_harness(node, harness_re): continue loc = node.get("location") or {} if loc.get("scripthash"): return loc["scripthash"], loc.get("filename", "") if not fallback and loc.get("filename"): fallback = loc["filename"] return "", fallback def is_jit_blind(flow: list[dict], harness_re: re.Pattern) -> bool: """True when no APPLICATION function call was recorded in the chain.""" return not any( n.get("operation") == "function" and not is_harness(n, harness_re) for n in flow ) def is_encoded_at_sink(taint_range: dict) -> bool: """True when the last transformation before the sink is an encode. `flow` is sink-first, so walk it forwards and let the first encoding or decoding operation decide. An encode followed later by a decode (which appears EARLIER in this ordering) does not protect the sink. """ for node in taint_range.get("flow", []): op = node.get("operation") if op in ENCODING_OPS: return True if op in DECODING_OPS: return False return False def has_no_syntax_chars(sink: str, value: str, taint_range: dict) -> bool: """True when the substring seen at crawl time held no HTML/JS syntax char. A property of the input you sent, not of the code. Useful for triage, never a reason to call a flow safe. """ if sink not in HTML_JS_SINKS: return False substring = value[taint_range["begin"]:taint_range["end"]] return not DANGEROUS.search(substring) def flows(reports, harness_re: re.Pattern = HARNESS_RE) -> list[dict]: """One row per tainted range per report. This is the finest honest unit.""" rows = [] for report in reports: detail = report["detail"] value = detail["str"] sink = detail["sink"] for taint_range in detail["str_taint"]: flow = taint_range["flow"] scripthash, filename = script_of(flow, harness_re) rows.append({ "site": report["site"], "page": detail["loc"], "subframe": detail["subframe"], "source": source_of(flow), "sink": sink, "scripthash": scripthash, "script": filename, "chars": taint_range["end"] - taint_range["begin"], "operations": [n["operation"] for n in flow], "jit_blind": is_jit_blind(flow, harness_re), "encoded_at_sink": is_encoded_at_sink(taint_range), "no_syntax_chars": has_no_syntax_chars(sink, value, taint_range), }) return rows UNITS = { "flow": None, # handled specially in table(): every row is its own unit "site": lambda r: r["site"], "page": lambda r: r["page"], "script": lambda r: r["scripthash"] or r["script"], } def table(rows: list[dict], unit: str) -> list[tuple]: """(source, sink) pairs, counted in the requested unit.""" seen = collections.defaultdict(set) total_flows = collections.Counter() jit_blind = collections.Counter() for i, row in enumerate(rows): pair = (row["source"], row["sink"]) seen[pair].add(i if unit == "flow" else UNITS[unit](row)) total_flows[pair] += 1 if row["jit_blind"]: jit_blind[pair] += 1 out = [] for pair, members in seen.items(): out.append((pair[0], pair[1], len(members), total_flows[pair], jit_blind[pair])) return sorted(out, key=lambda t: (-t[2], t[0], t[1])) def report(rows: list[dict], unit: str, dropped: int) -> str: lines = [] lines.append(f"flows: {len(rows)} sites: {len({r['site'] for r in rows})} " f"pages: {len({r['page'] for r in rows})} " f"scripts: {len({r['scripthash'] for r in rows if r['scripthash']})}") lines.append(f"dropped as encoded at the sink: {dropped} " f"(report this number whichever way you decide)") benign = sum(1 for r in rows if r["no_syntax_chars"]) lines.append(f"tainted substring held no HTML/JS syntax character at crawl time: " f"{benign} of {len(rows)} — a property of your input, NOT a safe verdict") blind = sum(1 for r in rows if r["jit_blind"]) lines.append(f"flows with no recorded application function call (JIT-blind): " f"{blind} of {len(rows)}" + (f" = {100 * blind / len(rows):.0f}%" if rows else "")) subframe = sum(1 for r in rows if r["subframe"]) lines.append(f"flows observed in a subframe: {subframe} " f"(a landing-page-only crawl still sees these; say so)") lines.append("") lines.append(f"{'source':<28} {'sink':<24} {unit + 's':>8} {'flows':>8} {'jit-blind':>10}") for source, sink, n, total, blind_n in table(rows, unit): lines.append(f"{source:<28} {sink:<24} {n:>8} {total:>8} {blind_n:>10}") return "\n".join(lines) # The taint flow published in the project wiki's "Observing Taint Flows" page, # for https://domgo.at/cxss/example/1?payload=abcd&sp=x#12345 — location.hash, # substr, unescape, two concats, innerHTML. Trimmed to the fields this script # reads, with the node order and the source flag exactly as documented. Note the # only `function` node is the harness's own ReportTaintSink call. WIKI_EXAMPLE = { "site": "domgo.at", "detail": { "subframe": False, "loc": "https://domgo.at/cxss/example/1?payload=abcd&sp=x#12345", "parentloc": "https://domgo.at/cxss/example/1?payload=abcd&sp=x#12345", "referrer": "", "str": "Welcome <b>12345</b>!!", "sink": "innerHTML", "stack": {}, "str_taint": [{ "begin": 11, "end": 16, "flow": [ {"operation": "function", "builtin": False, "source": False, "location": {"filename": "https://domgo.at/cxss/example/1", "line": 204, "scripthash": "d7069063759edbf2dcf45741802bc405"}, "arguments": ["ReportTaintSink", "taint_reporting.js:1", "0", "3"]}, {"operation": "innerHTML", "builtin": True, "source": False, "location": {"filename": "https://domgo.at/cxss/example/1", "line": 204, "scripthash": "d7069063759edbf2dcf45741802bc405"}, "arguments": ["//xhtml:div[@id='msgboard']"]}, {"operation": "concat", "builtin": True, "source": False, "location": {"filename": "https://domgo.at/cxss/example/1", "line": 203, "scripthash": "d7069063759edbf2dcf45741802bc405"}, "arguments": ["Welcome <b>12345", "</b>!!", "tainted:L"]}, {"operation": "concat", "builtin": True, "source": False, "location": {"filename": "https://domgo.at/cxss/example/1", "line": 203, "scripthash": "d7069063759edbf2dcf45741802bc405"}, "arguments": ["Welcome <b>", "12345", "tainted:R"]}, {"operation": "unescape", "builtin": True, "source": False, "location": {"filename": "https://domgo.at/cxss/example/1", "line": 202, "scripthash": "d7069063759edbf2dcf45741802bc405"}, "arguments": ["12345"]}, {"operation": "substr", "builtin": True, "source": False, "location": {"filename": "https://domgo.at/cxss/example/1", "line": 202, "scripthash": "d7069063759edbf2dcf45741802bc405"}, "arguments": ["1", "undefined"]}, {"operation": "location.hash", "builtin": True, "source": True, "location": {"filename": "https://domgo.at/cxss/example/1", "line": 200, "scripthash": "d7069063759edbf2dcf45741802bc405"}, "arguments": []}, ], }], }, } def selftest() -> int: import copy failures = [] checks = 0 def check(label, got, want): nonlocal checks checks += 1 if got != want: failures.append(f"{label}: got {got!r}, want {want!r}") rows = flows([WIKI_EXAMPLE]) check("one range -> one row", len(rows), 1) row = rows[0] # The source is the LAST source-flagged node, not the first entry. check("source", row["source"], "location.hash") check("sink", row["sink"], "innerHTML") check("tainted characters", row["chars"], 5) check("scripthash", row["scripthash"], "d7069063759edbf2dcf45741802bc405") # The only `function` node here is the harness's own ReportTaintSink call, so # NO application function was recorded: this flow IS JIT-blind. Counting the # harness would have hidden that, which is the whole point of HARNESS_RE. check("harness call does not count as an application call", row["jit_blind"], True) # `unescape` is a DECODE nearest the sink, so nothing protects innerHTML. check("decode nearest the sink is not 'encoded'", row["encoded_at_sink"], False) # "12345" has no HTML-significant character — a property of the crawl input, # and NOT a reason to call this textbook DOM-XSS flow safe. check("benign substring is flagged separately", row["no_syntax_chars"], True) # Same flow with a real payload in the hash: the substring is now dangerous. attack = copy.deepcopy(WIKI_EXAMPLE) payload = '<img src=x onerror=alert(1)>' attack["detail"]["str"] = f"Welcome <b>{payload}</b>!!" attack["detail"]["str_taint"][0]["end"] = 11 + len(payload) check("dangerous substring", flows([attack])[0]["no_syntax_chars"], False) check("still not encoded", flows([attack])[0]["encoded_at_sink"], False) # An encoding op nearest the sink does mark the flow. enc = copy.deepcopy(attack) enc["detail"]["str_taint"][0]["flow"].insert( 1, {"operation": "encodeURIComponent", "builtin": True, "source": False, "location": {}, "arguments": []}) check("encode nearest the sink", flows([enc])[0]["encoded_at_sink"], True) # Encode-then-decode: the decode is nearer the sink, so it does NOT count. # (flow is sink-first, so the decode appears before the encode.) encdec = copy.deepcopy(attack) encdec["detail"]["str_taint"][0]["flow"][1:1] = [ {"operation": "unescape", "builtin": True, "source": False, "location": {}, "arguments": []}, {"operation": "encodeURIComponent", "builtin": True, "source": False, "location": {}, "arguments": []}, ] check("decode after encode wins", flows([encdec])[0]["encoded_at_sink"], False) # A real application function call makes the flow non-blind. app = copy.deepcopy(WIKI_EXAMPLE) app["detail"]["str_taint"][0]["flow"].insert( 2, {"operation": "function", "builtin": False, "source": False, "location": {"filename": "https://domgo.at/app.js", "line": 7, "scripthash": "aaaabbbbccccddddeeeeffff00001111"}, "arguments": ["sanitize", "app.js:7"]}) check("application call clears jit_blind", flows([app])[0]["jit_blind"], False) # A harness injected as its own script must not supply the scripthash: here # the node nearest the sink is the harness, and the key must skip past it to # the page's own script. This is the realistic Playwright shape, where the # listener is added with addInitScript and so has its own script identity. hh = copy.deepcopy(WIKI_EXAMPLE) hh["detail"]["str_taint"][0]["flow"][0]["location"] = { "filename": "https://domgo.at/taint_reporting.js", "line": 1, "scripthash": "ffffffffffffffffffffffffffffffff"} check("scripthash skips the harness script", flows([hh])[0]["scripthash"], "d7069063759edbf2dcf45741802bc405") check("harness script still does not clear jit_blind", flows([hh])[0]["jit_blind"], True) # A filename with no scripthash is still a usable key for --unit script. nohash = copy.deepcopy(WIKI_EXAMPLE) for n in nohash["detail"]["str_taint"][0]["flow"]: loc = n.get("location") or {} loc.pop("scripthash", None) loc["filename"] = "https://domgo.at/inline" n["location"] = loc nh = flows([nohash])[0] check("no scripthash -> empty hash", nh["scripthash"], "") check("no scripthash -> filename fallback", nh["script"], "https://domgo.at/inline") check("script unit falls back to filename", UNITS["script"](nh), "https://domgo.at/inline") # An empty flow must not crash and must not be attributed to anything. empty = copy.deepcopy(WIKI_EXAMPLE) empty["detail"]["str_taint"][0]["flow"] = [] er = flows([empty])[0] check("empty flow source", er["source"], "unattributed") check("empty flow is jit_blind", er["jit_blind"], True) check("empty flow not encoded", er["encoded_at_sink"], False) # No source-flagged node must not be relabelled. orphan = copy.deepcopy(WIKI_EXAMPLE) for n in orphan["detail"]["str_taint"][0]["flow"]: n["source"] = False check("unattributed", flows([orphan])[0]["source"], "unattributed") # Counting units: the same pair on two pages of one site is 1 site, 2 pages. second = copy.deepcopy(WIKI_EXAMPLE) second["detail"]["loc"] += "&page=2" check("site unit", table(flows([WIKI_EXAMPLE, second]), "site")[0][2], 1) check("page unit", table(flows([WIKI_EXAMPLE, second]), "page")[0][2], 2) check("flow unit", table(flows([WIKI_EXAMPLE, second]), "flow")[0][2], 2) if failures: print("SELFTEST FAILED") for f in failures: print(" " + f) return 1 print(f"selftest: {checks} checks passed") print() print(report(flows([WIKI_EXAMPLE]), "site", 0)) return 0 def main() -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("reports", nargs="?", help="newline-delimited JSON taint reports") ap.add_argument("--unit", choices=sorted(UNITS), default="flow", help="denominator for the pair table (default: flow)") ap.add_argument("--drop-encoded", action="store_true", help="exclude flows whose last transformation before an HTML/JS " "sink is an encode; the count is printed either way. This " "does NOT drop flows that merely looked benign at crawl " "time — those are counted and reported separately") ap.add_argument("--harness", default=None, help="regex matching your reporting harness, so its own calls " f"are not counted as application code (default: {HARNESS_RE.pattern})") ap.add_argument("--selftest", action="store_true") args = ap.parse_args() if args.selftest: return selftest() if not args.reports: ap.error("give a reports file or --selftest") harness_re = re.compile(args.harness, re.I) if args.harness else HARNESS_RE with open(args.reports, encoding="utf8") as fh: reports = [json.loads(line) for line in fh if line.strip()] rows = flows(reports, harness_re) dropped = 0 if args.drop_encoded: before = len(rows) rows = [r for r in rows if not r["encoded_at_sink"]] dropped = before - len(rows) print(report(rows, args.unit, dropped)) return 0 if __name__ == "__main__": sys.exit(main())
Its self-test runs the flow from the project's own documentation plus ten mutations of it — including the three that matter most, a harness-only function node, an encode followed by a decode, and a location with no scripthash — and its real output is:
$ python3 foxhound_flows.py --selftest selftest: 25 checks passed flows: 1 sites: 1 pages: 1 scripts: 1 dropped as encoded at the sink: 0 (report this number whichever way you decide) tainted substring held no HTML/JS syntax character at crawl time: 1 of 1 — a property of your input, NOT a safe verdict flows with no recorded application function call (JIT-blind): 1 of 1 = 100% flows observed in a subframe: 0 (a landing-page-only crawl still sees these; say so) source sink sites flows jit-blind location.hash innerHTML 1 1 1
Both flags are screens for manual review, not verdicts, and the difference between them is the trap. encoded_at_sink is a claim about the code: an encoding operation is the last transformation before the sink, with no decode after it. no_syntax_chars is a claim about your input: the substring you happened to send contained no HTML-significant character on that visit. The documented worked example is exactly the second case and not the first — 12345 is harmless, unescape is a decode, and the flow is the textbook exploitable DOM-XSS — which is why only encoded_at_sink feeds --drop-encoded and the benign-substring count is reported beside it rather than acted on. Even encoded_at_sink is not a safety proof: encodeURIComponent leaves ', ( and ) alone. Every confirmation in the corpus is a payload-execution test or manual analysis, and yours should be too.
Use in publications
The full-text sweep /fox ?hound/i over the corpus returns 13 papers. Two are homographs — an ImageNet class label americanfoxhound and “English Foxhound” as an example crowdsourcing label — and are excluded from every figure here. Two cite the project without running it, one of them in order to reject it [5Liu, Zhengyu; Lee, Theo; Yu, Jianjia; Kang, Zifeng; Cao, Yinzhi (2025): "The DOMino Effect: Detecting and Exploiting DOM Clobbering Gadgets via Concolic Execution with Symbolic DOM", in: Proceedings of the USENIX Security Symposium. (Link)]. That leaves 9 papers that actually ran the browser, out of the 1,120 papers in the corpus that ran a crawl (0.8%).19) The corpus is the seven venues on corpus; EuroS&P, where the tool is described, is not among them, and the project's own list of publications naming Foxhound has 14 entries across more venues.20)
| Year | Corpus papers | Ran Foxhound | Share of that year |
|---|---|---|---|
| 2010–2023 | 3,984 | 0 | 0.0% |
| 2024 | 690 | 4 | 0.6% |
| 2025* | 770 | 3 | 0.4% |
| 2026* | 415 | 2 | 0.5% |
Starred years are provisional: 2025 is thin at the edges and 2026 is incomplete by construction, so do not read 2026's count as a decline. The shape that matters is the fourteen zeroes. Foxhound appears in these venues in 2024 and in every year since; it did not exist in the corpus before then, and no version of “taint tracking is an established method here” is supported by the corpus before 2024.
That is not because taint tracking is new. It is because taint-tracking browsers used to be private patches that did not outlive their paper, and this corpus shows that directly:
| Year | Paper | The engine it built |
|---|---|---|
| 2013 | Lekies et al., CCS [11Lekies, Sebastian; Stock, Ben; Johns, Martin (2013): "25 Million Flows Later: Large-scale Detection of DOM-based XSS", in: Proceedings of the ACM SIGSAC Conference on Computer and Communications Security. (DOI)] | patched Chromium — V8 and the WebKit DOM, byte-level taint |
| 2014 | Stock et al., USENIX Security [12Stock, Ben; Lekies, Sebastian; Mueller, Tobias; Spiegel, Patrick; Johns, Martin (2014): "Precise Client-side Protection against DOM-based Cross-Site Scripting", in: Proceedings of the USENIX Security Symposium. (Link)] | taint-aware parsers inside the browser, for client-side XSS filtering |
| 2015 | Stock et al., CCS [13Stock, Ben; Pfistner, Stephan; Kaiser, Bernd; Lekies, Sebastian; Johns, Martin (2015): "From Facepalm to Brain Bender: Exploring Client-Side Cross-Site Scripting", in: Proceedings of the ACM SIGSAC Conference on Computer and Communications Security. (DOI)] | patched Firefox — SpiderMonkey and Gecko |
| 2019 | Steffens et al., NDSS [14Steffens, Marius; Rossow, Christian; Johns, Martin; Stock, Ben (2019): "Don't Trust The Locals: Investigating the Prevalence of Persistent Client-Side Cross-Site Scripting in the Wild", in: Proceedings of the Network and Distributed System Security Symposium. (Link)] | modified Chromium, storage and cookie sources |
None of those engines has a name in the extracted tool lists of any corpus paper outside its own author lineage — they appear as “a taint-aware Firefox engine” or “a taint tracking engine”, produced and used by the same group, and never obtained by anyone else. By 2024 the reason was concrete: Rautenstrauch et al. chose Foxhound over the 2019 Chromium tracker because it “provides support for recent JavaScript language features not present in the Chromium taint tracker used by Steffens et al.” [9Rautenstrauch, Jannis; Mitkov, Metodi; Helbrecht, Thomas; Hetterich, Lorenz; Stock, Ben (2024): "To Auth or Not To Auth? A Comparative Analysis of the Pre- and Post-Login Security Landscape", in: Proceedings of the IEEE Symposium on Security and Privacy. (DOI)]. The methodological change Foxhound represents is not taint tracking; it is a maintained, shared, versioned taint browser. Alongside it in the corpus, the other in-browser taint engines are thin: PanoptiChrome 2 papers, JSFlow, LinvailTaint and IF-Transpiler 1 each. The largest family in the report's table is actually Jalangi/JalangiTT at 6, but Jalangi is a general JavaScript source-instrumentation framework used mostly for things that are not web taint tracking, so it is not a competing instrument in the sense this page means. Among browsers, this is a field with one instrument and a fringe.
Reuse is real but concentrated. 6 of the 9 papers have at least one listed Foxhound contributor as a co-author; the three that do not are Rautenstrauch et al. [9Rautenstrauch, Jannis; Mitkov, Metodi; Helbrecht, Thomas; Hetterich, Lorenz; Stock, Ben (2024): "To Auth or Not To Auth? A Comparative Analysis of the Pre- and Post-Login Security Landscape", in: Proceedings of the IEEE Symposium on Security and Privacy. (DOI)], Khodayari et al. [2Khodayari, Soheil; Glauber, Kai; Pellegrino, Giancarlo (2025): "Do (Not) Follow the White Rabbit: Challenging the Myth of Harmless Open Redirection", in: Proceedings of the Network and Distributed System Security Symposium. (Link)] and Sabino et al. [4Sabino, Nuno; Cassel, Darion; Abreu, Rui; Adão, Pedro; Bauer, Lujo; Jia, Limin (2026): "DOM-XSS Detection via Webpage Interaction Fuzzing and URL Component Synthesis", in: Proceedings of the Network and Distributed System Security Symposium. (Link)]. Read that as a tool early in its diffusion, not as a community standard.
| Paper | Role | What it did with it |
|---|---|---|
| Kirchner et al., USENIX Security 2024 [15Kirchner, Robin; Möller, Jonas; Musch, Marius; Klein, David; Rieck, Konrad; Johns, Martin (2024): "Dancer in the Dark: Synthesizing and Evaluating Polyglots for Blind Cross-Site Scripting", in: Proceedings of the USENIX Security Symposium. (Link)] | used | client-side-XSS baseline that synthesized blind-XSS polyglots were measured against |
| Khodayari et al., IEEE S&P 2024 [8Khodayari, Soheil; Barber, Thomas; Pellegrino, Giancarlo (2024): "The Great Request Robbery: An Empirical Study of Client-side Request Hijacking Vulnerabilities on the Web", in: Proceedings of the IEEE Symposium on Security and Privacy. (DOI)] | extended | added request-API instrumentation (“Foxhound+”), fed flows into a hybrid property graph; 202,834 verified flows on 9.6% of the Tranco top 10K |
| Rautenstrauch et al., IEEE S&P 2024 [9Rautenstrauch, Jannis; Mitkov, Metodi; Helbrecht, Thomas; Hetterich, Lorenz; Stock, Ben (2024): "To Auth or Not To Auth? A Comparative Analysis of the Pre- and Post-Login Security Landscape", in: Proceedings of the IEEE Symposium on Security and Privacy. (DOI)] | used | taint reports before and after login on ~200 sites, then exploit generation; 7 vulnerable sites |
| Boussaha et al., PETS 2024 [6Boussaha, Soumaya; Hock, Lukas; Bermejo, Miguel; Rumín, Rubén Cuevas; Rumín, Ángel Cuevas; Klein, David; Johns, Martin; Compagna, Luca; Antonioli, Daniele; Barber, Thomas (2024): "FP-tracer: Fine-grained Browser Fingerprinting Detection via Taint-tracking and Entropy-based Thresholds", in: Proceedings on Privacy Enhancing Technologies. (DOI)] | extended | numeric tainting plus 62 sources and 25 sinks (FP-tracer); 269,784 fingerprinting flows over the Tranco top 100K |
| Khodayari et al., NDSS 2025 [2Khodayari, Soheil; Glauber, Kai; Pellegrino, Giancarlo (2025): "Do (Not) Follow the White Rabbit: Challenging the Myth of Harmless Open Redirection", in: Proceedings of the Network and Distributed System Security Symposium. (Link)] | baseline | one of six detectors on 42,288 pages; 72 flows, 2 confirmed XSS |
| Calzavara et al., TheWebConf 2025 [10Calzavara, Stefano; Casarin, Samuele; Focardi, Riccardo (2025): "Dynamic Security Analysis of JavaScript: Are We There Yet?", in: Proceedings of the ACM Web Conference. (DOI)] | used | the tool bake-off above, 6,921 domains |
| Drescher et al., CCS 2025 [16Drescher, Jan; Mirzaei, Sepehr; Khodayari, Soheil; Klein, David; Barber, Thomas; Johns, Martin; Pellegrino, Giancarlo (2025): "In the DOM We Trust: Exploring the Hidden Dangers of Reading from the DOM on the Web", in: Proceedings of the ACM SIGSAC Conference on Computer and Communications Security. (DOI)] | extended | made every DOM read a source; 357,982 verified DOM-to-sink gadgets across 14,345 pages on 2,259 sites, and a 38.5% false-negative rate on a 13-site benchmark |
| Calzavara et al., PETS 2026 [3Calzavara, Stefano; Casarin, Samuele; Squarcina, Marco; Maffei, Matteo (2026): "From Syntactic Matching to Taint Tracking and Back: A Comparative Study of Web Tracking Detection Techniques", in: Proceedings on Privacy Enhancing Technologies. (Link)] | used | taint tracking against filter-list-style syntactic matching on the Tranco top 10K |
| Sabino et al., NDSS 2026 [4Sabino, Nuno; Cassel, Darion; Abreu, Rui; Adão, Pedro; Bauer, Lujo; Jia, Limin (2026): "DOM-XSS Detection via Webpage Interaction Fuzzing and URL Component Synthesis", in: Proceedings of the Network and Distributed System Security Symposium. (Link)] | baseline | two configurations of it as the prior-work baseline; the URL-encoding result above |
Three of the nine did not use Foxhound so much as rebuild it, and all three extensions target the same gap — the source and sink set — which is the strongest argument on this page for checking the pref list against your research question before you start.
The result to read first depends on what you are measuring. For tracking, Calzavara et al. [3Calzavara, Stefano; Casarin, Samuele; Squarcina, Marco; Maffei, Matteo (2026): "From Syntactic Matching to Taint Tracking and Back: A Comparative Study of Web Tracking Detection Techniques", in: Proceedings on Privacy Enhancing Technologies. (Link)]: on the Tranco top 10K they collected 40,605 tracking requests, of which syntactic matching found 33,584 and taint tracking 23,109, with only 16,088 found by both; taint tracking's estimated false-positive rate was 4%–7% against 16%–19% for syntactic matching, and 7,021 requests (~17%) were found by taint tracking alone. Their recommendation is not “use taint tracking” but use both: the union reached 34,358 requests, +49% over taint tracking alone. For client-side vulnerabilities, read Klein et al. [1Klein, David; Barber, Thomas; Bensalim, Souphiane; Stock, Ben; Johns, Martin (2022): "Hand Sanitizers in the Wild: A Large-scale Study of Custom JavaScript Sanitizer Functions", in: 2022 IEEE 7th European Symposium on Security and Privacy (EuroS&P), pp. 236-250. (DOI)] for what the operation chain buys you and Sabino et al. [4Sabino, Nuno; Cassel, Darion; Abreu, Rui; Adão, Pedro; Bauer, Lujo; Jia, Limin (2026): "DOM-XSS Detection via Webpage Interaction Fuzzing and URL Component Synthesis", in: Proceedings of the Network and Distributed System Security Symposium. (Link)] for how much coverage costs. For fingerprinting, Boussaha et al. [6Boussaha, Soumaya; Hock, Lukas; Bermejo, Miguel; Rumín, Rubén Cuevas; Rumín, Ángel Cuevas; Klein, David; Johns, Martin; Compagna, Luca; Antonioli, Daniele; Barber, Thomas (2024): "FP-tracer: Fine-grained Browser Fingerprinting Detection via Taint-tracking and Entropy-based Thresholds", in: Proceedings on Privacy Enhancing Technologies. (DOI)]. For choosing an instrument at all, Calzavara et al. [10Calzavara, Stefano; Casarin, Samuele; Focardi, Riccardo (2025): "Dynamic Security Analysis of JavaScript: Are We There Yet?", in: Proceedings of the ACM Web Conference. (DOI)].
What to report in a paper
- The Foxhound version or commit and the Firefox version it forks, the Playwright version, and where the binary came from (TU Braunschweig commit hash, a release asset, or your own build with its commit). Only 3 of the 7 corpus papers that used or extended it as an instrument state even the version or commit.
- Every
tainting.*pref you changed from the default, in both directions, and whether the source/sink set was the shipped one or an extension. If you extended the browser, say so in the same breath as naming it — “we used Foxhound” and “we used Foxhound plus nine new sources” are different instruments. - Whether URL encoding was enabled, and how many confirmed flows the other setting would have produced. This one setting has moved a published total by 5×.
- The unit you count and its denominator: taint reports, tainted ranges, (source, sink) pairs, scripts by
scripthash, pages, or sites. State whether subframe flows are included, and whether a repeat visit can contribute the same flow twice. - Coverage, honestly: landing page or deep crawl, interaction or none, login state, and what fraction of flows required an interaction to appear. A dynamic instrument reports what executed, and your interaction policy is therefore part of the measurement, not part of the setup.
- The confirmation step, if you claim vulnerabilities rather than flows: payload generation, canary, or manual review, with the confirmed / refuted / unconfirmed counts kept separate. Do not report flows as vulnerabilities.
- What you did with flows whose chain recorded no application function call, if any claim depends on the operations in the chain — the JIT blind spot is systematic.
- Counts of attempted, loaded, crashed and timed-out pages, counted by you: the shipped build has
–disable-crashreporter, so nothing else will count them. Its user agent is also roughly a year behind stable, which belongs in the limitations. - Your harness. The init script, the binding, and the reducer that turns reports into rows are where the interesting decisions live, and none of them are visible from “we used Foxhound with Playwright”.
Methodology and limitations of these figures
- The corpus audit script is
scripts/report_foxhound.mjswith its folds and hand verdicts inscripts/fh_fold.mjs; the external facts are re-fetched byscripts/foxhound_probe.sh. Both scripts' unedited output, every query with its denominator, every role verdict with its deciding quote, the fold residue and the quote checks are on the provenance page. Corpus-wide selection and extraction caveats are on corpus. - The population is a full-text sweep, not a schema query: the extraction's
tools[]finds 9 of the 13 papers and misses both citation-only cases, which are the ones the page has to separate out. Every paper's role — used, extended, baseline, citation, homograph — was decided by reading the sentence, and the report fails loudly if the sweep and the hand map ever disagree. No name fold was needed: the three spellings in the corpus (Foxhound,FoxHound,Project Foxhound) are case variants of one token with no synonyms, so the residue is empty by construction rather than by luck. - The 2025–2026 rows are provisional, and the seven venues exclude EuroS&P (where the browser is described), ACSAC, RAID, AsiaCCS, CHI and SOUPS. Every count here is a lower bound on the tool's real use, and the project's own 14-entry publication list is the easiest demonstration of that.
- The nine schema evidence quotes were checked against
paper.cols.txt: 5 exact, 4 partial under the dataset's five-word-window test, 0 below threshold. The 13 hand-picked deciding quotes are all exact, two of them only after being shortened to a contiguous fragment because the surrounding sentence is spliced across columns in the repaired rendering. Every per-paper figure and quoted sentence used above — 50 of them — was located verbatim in the source text. - The contributor-overlap figure counts an exact, diacritic-folded name match against the README's contributor list, which is explicitly not exhaustive. It is a lower bound in both directions.
- Nothing on this page is a benchmark we ran. The build times are the project's CI on hosted runners, the overhead figures are Calzavara et al.'s on landing pages, and no source gives a per-page crawl rate for Foxhound on a modern population.
Related pages
- Crawler — the automation layers and the other specialised crawlers, compared.
- PageGraph — the causal-attribution instrument this one is most often confused with.
- Javascript — classifying the JavaScript whose flows this browser records.
- Requests — filter lists, and the measurement of how wrong their labels are.
- OpenWPM — in-page instrumentation through an unbranded Firefox, when you need prevalence rather than flows.
- Stateful stateless — statefulness, which decides whether storage sources have anything in them.
References
- [1]
- Klein, David; Barber, Thomas; Bensalim, Souphiane; Stock, Ben; Johns, Martin (2022): "Hand Sanitizers in the Wild: A Large-scale Study of Custom JavaScript Sanitizer Functions", in: 2022 IEEE 7th European Symposium on Security and Privacy (EuroS&P), pp. 236-250. (DOI)
- [2]
- Khodayari, Soheil; Glauber, Kai; Pellegrino, Giancarlo (2025): "Do (Not) Follow the White Rabbit: Challenging the Myth of Harmless Open Redirection", in: Proceedings of the Network and Distributed System Security Symposium. (Link)
- [3]
- Calzavara, Stefano; Casarin, Samuele; Squarcina, Marco; Maffei, Matteo (2026): "From Syntactic Matching to Taint Tracking and Back: A Comparative Study of Web Tracking Detection Techniques", in: Proceedings on Privacy Enhancing Technologies. (Link)
- [4]
- Sabino, Nuno; Cassel, Darion; Abreu, Rui; Adão, Pedro; Bauer, Lujo; Jia, Limin (2026): "DOM-XSS Detection via Webpage Interaction Fuzzing and URL Component Synthesis", in: Proceedings of the Network and Distributed System Security Symposium. (Link)
- [5]
- Liu, Zhengyu; Lee, Theo; Yu, Jianjia; Kang, Zifeng; Cao, Yinzhi (2025): "The DOMino Effect: Detecting and Exploiting DOM Clobbering Gadgets via Concolic Execution with Symbolic DOM", in: Proceedings of the USENIX Security Symposium. (Link)
- [6]
- Boussaha, Soumaya; Hock, Lukas; Bermejo, Miguel; Rumín, Rubén Cuevas; Rumín, Ángel Cuevas; Klein, David; Johns, Martin; Compagna, Luca; Antonioli, Daniele; Barber, Thomas (2024): "FP-tracer: Fine-grained Browser Fingerprinting Detection via Taint-tracking and Entropy-based Thresholds", in: Proceedings on Privacy Enhancing Technologies. (DOI)
- [7]
- Kanyal, Rahul; Sarangi, Smruti R. (2024): "PanoptiChrome: A Modern In-browser Taint Analysis Framework", in: Proceedings of the ACM Web Conference. (DOI)
- [8]
- Khodayari, Soheil; Barber, Thomas; Pellegrino, Giancarlo (2024): "The Great Request Robbery: An Empirical Study of Client-side Request Hijacking Vulnerabilities on the Web", in: Proceedings of the IEEE Symposium on Security and Privacy. (DOI)
- [9]
- Rautenstrauch, Jannis; Mitkov, Metodi; Helbrecht, Thomas; Hetterich, Lorenz; Stock, Ben (2024): "To Auth or Not To Auth? A Comparative Analysis of the Pre- and Post-Login Security Landscape", in: Proceedings of the IEEE Symposium on Security and Privacy. (DOI)
- [10]
- Calzavara, Stefano; Casarin, Samuele; Focardi, Riccardo (2025): "Dynamic Security Analysis of JavaScript: Are We There Yet?", in: Proceedings of the ACM Web Conference. (DOI)
- [11]
- Lekies, Sebastian; Stock, Ben; Johns, Martin (2013): "25 Million Flows Later: Large-scale Detection of DOM-based XSS", in: Proceedings of the ACM SIGSAC Conference on Computer and Communications Security. (DOI)
- [12]
- Stock, Ben; Lekies, Sebastian; Mueller, Tobias; Spiegel, Patrick; Johns, Martin (2014): "Precise Client-side Protection against DOM-based Cross-Site Scripting", in: Proceedings of the USENIX Security Symposium. (Link)
- [13]
- Stock, Ben; Pfistner, Stephan; Kaiser, Bernd; Lekies, Sebastian; Johns, Martin (2015): "From Facepalm to Brain Bender: Exploring Client-Side Cross-Site Scripting", in: Proceedings of the ACM SIGSAC Conference on Computer and Communications Security. (DOI)
- [14]
- Steffens, Marius; Rossow, Christian; Johns, Martin; Stock, Ben (2019): "Don't Trust The Locals: Investigating the Prevalence of Persistent Client-Side Cross-Site Scripting in the Wild", in: Proceedings of the Network and Distributed System Security Symposium. (Link)
- [15]
- Kirchner, Robin; Möller, Jonas; Musch, Marius; Klein, David; Rieck, Konrad; Johns, Martin (2024): "Dancer in the Dark: Synthesizing and Evaluating Polyglots for Blind Cross-Site Scripting", in: Proceedings of the USENIX Security Symposium. (Link)
- [16]
- Drescher, Jan; Mirzaei, Sepehr; Khodayari, Soheil; Klein, David; Barber, Thomas; Johns, Martin; Pellegrino, Giancarlo (2025): "In the DOM We Trust: Exploring the Hidden Dangers of Reading from the DOM on the Web", in: Proceedings of the ACM SIGSAC Conference on Computer and Communications Security. (DOI)
report.detail output printed in the project wiki's Playwright Integration page, checked 2026-08-17.taint/docs/TaintFlow.md on main, checked 2026-08-17. The same file calls the whole facility “a 'best-effort' service”.modules/libpref/init/all.js on main by scripts/foxhound_probe.sh, 2026-08-17. The project wiki's Source and Sink Listings page derives the same lists with a grep, and its output contains the grep's own noise — the literal ReportTaintSink and a truncated navigator.sendBeacon(url)n — so it is a reading aid, not a countable API surface. taint/README.md in the repository is staler still: it marks element.getAttribute as disabled, and the pref for it has been on since v119.0.modules/libpref/init/all.js and the HTML tokenizer. Release dates and pref counts per tag from scripts/foxhound_probe.sh, 2026-08-17.MarkTaintOperation(aRetval, “URLSearchParams.get”, aName) in netwerk/base/nsURLHelper.cpp after the assignment that already carries the taint. Presence per release tag is re-checked by scripts/foxhound_probe.sh.false default read out of netwerk/base/nsStandardURL.cpp on main, 2026-08-17. The pref is not declared in modules/libpref/init/all.js, so it does not appear in about:config until you type it — issue #260, still open, asks for it to be renamed and added to the default config. Note also that the guard is main-thread-only: on worker threads escaping stays on.taint/README.md, “Modified string classes”, on main, checked 2026-08-17. The same file notes that the frozen string API is not taint-aware, only the internal one.taint/docs/Atoms.md on main, checked 2026-08-17.scripts/foxhound_probe.sh.main, 2026-08-17. Run any documented command before it goes into your artifact appendix.browser.yml run on main, run id 26110426051 of 2026-05-19, from the GitHub Actions API. Runner-class figures, so they bound rather than predict a workstation build; treat them as “hours, not minutes”.main by scripts/foxhound_probe.sh, 2026-08-17, which prints every ac_add_options line. The DEBUG BUILD block in the Ubuntu file is commented out and --enable-release is set, so the shipped configuration is an optimised release build with taint instrumentation — not a debug build.tools[] tuples by usedOrMentioned, which files Khodayari et al.'s NDSS 2025 paper under compared and therefore in their “cites only” column; reading the sentence shows it ran the browser on 42,288 pages as one of six baseline detectors. Those two pages are queued for an errata edit; this page is the deeper audit.