A Guide to Hunting Client-Side Path Traversal With Polecat, a Zypher Firefox Extension
- Tool
- github.com/theemperorspath/polecat
- Built by
- Zypher offensive engineering (formerly shipped as CSPT-Hunter, renamed to Polecat)
- Runs on
- Firefox 128 or later, MV3 add-on
- Category
- Browser-side reconnaissance for client-side path traversal and CSPT2CSRF
- Version
2.0.0- Use for
- Authorised web application testing, bug bounty programmes with scope, red team engagements
Client-side path traversal (CSPT) is a browser-side bug class that keeps producing four- and five-figure bug bounty payouts and remains largely absent from most engagement reports. It leaves no reflected string, no console error, no failed request, and no visible anomaly in the DevTools waterfall. It is invisible unless you are already looking. The rest of this article is a mechanic-first explanation of CSPT and CSPT2CSRF, a survey of the current-generation research from Doyensec, Matan Berson, and the Critical Thinking Bug Bounty (CTBB) crowd, and a walkthrough of Polecat, the Firefox extension we use on engagements to surface the class live.
What client-side path traversal actually is
The mechanic is one move. The application reads a value from a route parameter, a query string, or location.hash, and concatenates it into the path of a request the page then issues. If the value contains ../, the WHATWG URL parser inside the browser normalises the URL before it goes on the wire, and the request lands on a different endpoint than the developer expected.
Client code trusts URL-derived values because they look like route parameters. The URL structure is app-controlled. The values are not. Any code path that does new URL(base + userValue) or a raw string + userValue in a fetch will end up honouring .. segments in userValue. That is the WHATWG URL spec; every browser and every polyfill built on top of it behaves the same way.
The three source surfaces live on location: pathname, query string, and fragment. The fragment (#...) is the highest-yield hunt because it never travels on the wire. Server logs, CDN logs, and any proxy-side tool are blind to it. An SPA that keeps its route in #/orders/:id is running its whole router on values only the client can see, and developers who would sanitise a pathname parameter routinely forget the same rule applies to window.location.hash.
The variant that eats accounts: CSPT2CSRF
A traversed request that lands nowhere useful is a curiosity. The interesting variant, formalised by Doyensec as CSPT2CSRF, is the one where the traversed request rides the victim's credentials into a state-changing endpoint. The attacker never sends the request. The victim's browser does, from the victim's app, with the victim's session cookie automatically attached, because the browser attaches cookies to same-origin requests whether the app opts in or not.
Any anti-CSRF header the app inlines into the request rides along too. Same-origin double-submit tokens, custom X-CSRF-Token headers, meta-tag-derived tokens: all still valid, because the app really did issue the request from its own JavaScript. That is the point of those tokens, and CSPT2CSRF turns the guarantee into an exploit primitive rather than working around it.
One constraint from the Doyensec paper worth remembering when you write up a chain: "The CSPT will reroute a legitimate API request. Therefore, the attacker may not have control over the HTTP method, headers and body request." You get to pick the path. You do not get to pick the verb or the body. Whatever the app was about to send, that is what fires at the traversed endpoint. That constraint drives everything downstream: sink categorisation, gadget hunting, and how to phrase impact in the report.
Sources, sinks, and gadgets (the Doyensec vocabulary)
The Doyensec CSPT2CSRF whitepaper introduced a vocabulary that has quietly become standard on bounty programmes. Worth adopting even if you never touch the whitepaper again, because it is the vocabulary triagers now expect to see in reports:
- Primary source. Where the attacker-controllable value enters the client. URL path segment, query parameter, fragment, a DOM sink the attacker can seed via reflection or storage, or a value pulled out of
localStorage. - Sink. The API endpoint the client's request lands on after normalisation. Sinks split into two useful buckets:
- GET sinks: safe to probe, useful as stepping stones. On their own, generally low-impact.
- State-changing sinks (POST, PUT, PATCH, DELETE): the ones that make CSPT2CSRF a real finding. This is the case Polecat flags as high severity.
- Secondary source (gadget). A mechanism that lets the attacker plant a value which is then fetched back through a primary source. This is where chains get interesting. The archetype is the JSON file-upload gadget covered below.
Reading a Polecat record with this vocabulary: the record itself is primary source → sink. If the sink is state-changing, Polecat labels it CSPT2CSRF and you have a submittable finding. If the sink is GET-only, the report gets stronger the moment you find a gadget that hangs off it.
Encoding-level WAF bypasses (Matan Berson)
Matan Berson's "Bypassing WAFs to Exploit CSPT Using Encoding Levels" is the crispest single write-up on the "my payload should work but the WAF is eating it" failure mode. His framing: the encoding level of a string is the number of times you have to URL-decode it before it fully unwraps.
| Level | Payload for .. |
|---|---|
| 0 | ../ |
| 1 | %2e%2e%2f |
| 2 | %252e%252e%252f |
| 3 | %25252e%25252e%25252f |
The bypass turns on the discrepancy between how many times the WAF decodes before it inspects, versus how many times the application (and eventually the browser's URL parser) decodes before it uses the value. Berson enumerates three cases, and all three are worth learning as reflexes.
Case 1: WAF decodes fewer times than the app
The WAF unwraps to level N, the app unwraps to level N+k. Send a payload at level > N. The WAF sees a benign string, waves it through. The app finishes decoding, finds a real .., and traverses. Berson's canonical example: ..%252f..%252f..%252fasdf. WAF (one decode) reads ..%2f..%2f..%2fasdf: no literal ../. App (two decodes) reads ../../../asdf: traversal.
Case 2: WAF decodes more times than the app
Reverse the disparity. Add filler like a%252fa%252f that the WAF unwraps to a/a/ (benign, no traversal literal even after decode) but which the app never unwraps. Then attach a payload at the app's actual level. Berson's example: a%252fa%252fa%252fa%2f..%2f..%2f..%2f..%2fasdf.
Case 3: WAF and app decode the same number of times
This is the one worth internalising, because it is the one most testers get wrong. Rely on the browser's own equivalence: when the URL parser evaluates a pathname, %2e%2e/ and ../ are semantically identical. Neither the WAF nor the app has to decode anything for the browser to traverse. Berson's payload: %252e%252e%2f%252e%252e%2f%252e%252e%2fredirect. Both WAF and app see a literal string with no ..; only the browser, when the request URL is actually constructed, collapses the segments.
Practical workflow implication for Polecat: the built-in probe fires with a literal /../, which is level 0. If a probe reports "did not collapse", assume the target is blocking level 0 in a middleware layer and re-run the same payload by hand at each level from 1 to 3 before writing the record off. In our own engagements, roughly one in four probe-negative records turns positive after a level-2 rerun.
The JSON file-upload gadget
You have a fragment-source CSPT into a GET sink that fetches a JSON blob and pulls an id field out of it. The id is then fed into a second request, which happens to be state-changing. On its own, the GET sink is not exploitable. What you want is control over the id field of the JSON that the GET sink returns.
That is the file-upload gadget, and it is the most common secondary source Doyensec catalogued in the CSPT2CSRF paper: "the most common gadget we found was to abuse file upload/download features." Their 2025 follow-up post walks through the parser tricks that make it work on real targets. The move: upload a file that is simultaneously (a) valid JSON with an attacker-controlled id, and (b) whatever content type the upload endpoint enforces.
Several concrete bypasses, all of which come up on real targets:
- Magic-byte MIME sniffing (Node
mmmagic): the parser reads the first bytes and looks for%PDF. A body like{ "id": "../CSPT_PAYLOAD", "%PDF": "1.4" }sniffs as PDF and is still valid JSON. - PDF structural validators (
pdflib): replace all line-feeds with spaces so the file is one long line. The xref table is broken but the header validates, and the whole thing parses as JSON becauseJSON.parsetolerates arbitrary whitespace. file(1)command: pad the JSON with whitespace pastfile's default 1MB parse limit. The command gives up looking for JSON structure and defaults to whatever the leading bytes look like.file-typelibrary: place image magic bytes at the offsets the library checks. Doyensec's WEBP example:{"aaa":"WEBP","_id":"../../../../CSPT?"}passes as WEBP while still being valid JSON.
When your target has an upload endpoint of any kind and a GET sink that pulls a JSON blob and reads a nested value out of it, the file-upload gadget is the first thing to reach for. If your Polecat records are dominated by GET sinks and you are convinced there is nothing there, this is the next hop.
Real case chains worth studying
Four public 2024-2025 chains that map neatly onto the Polecat workflow and are worth reading in full:
- Rocket.Chat (Doyensec, disclosed alongside the CSPT2CSRF whitepaper). Fragment source → GET sink returning attacker-uploaded JSON → state-changing sink. Textbook example of the source-sink-gadget shape.
- Mattermost (also Doyensec). Same pattern shape, different sink surface. Full walkthrough is in the whitepaper PDF.
- Grafana OSS,
CVE-2025-4123and the follow-upCVE-2025-6023. Traversal gadget chained with an open redirect to force the Grafana plugin loader to fetch an attacker-controlled bundle, producing XSS in the Grafana origin and SSRF in the follow-up CVE. Directly applicable to any target that embeds Grafana plugin loading, and structurally applicable to any app that dereferences a URL-derived plugin/module name on the client. - Meta bug bounty, $111,750 (2025 program report). Path traversal chained to RCE. Public detail is sparse, but the payout signals what a full chain on a mature programme is worth.
On the podcast side, Justin Gardner and Joel Margolis's Critical Thinking Bug Bounty podcast has covered the class from three useful angles worth queueing up: Episode 141 with Nick Copi on CSPT chained with React quirks and CSS injection, Episode 137 with Joseph Thacker on CSPT and cache deception together (the pairing you see most on CDN-fronted apps), and the episode with Brandon Roldan (xssdoctor) walking through unpublished CSPT research. Episode 175's mobile CSPT segment is a preview of where the class is moving next.
Why manual hunting breaks down
The naive approach is DevTools Network tab plus eyeballing. On a small demo app it works. On a real SPA it does not, and the reasons are worth being explicit about:
- Traffic volume. A modern SPA fires dozens of requests per page load. Multiply by the routes you have to walk in a scoped engagement and the manual correlation dies of throughput before it finds anything.
- Fragment invisibility.
location.hashnever appears on the wire. Any proxy-side tool (Burp, ZAP, mitmproxy, orwebRequest) is blind to hash-source CSPT unless it also sees the driving URL in-browser. - Co-location noise. A page at
/orders/inv_88371/editfetches its own data at/api/orders/inv_88371. Every segment matches, no bug exists. Any correlator that does not suppress this shape fires so often it becomes wallpaper. - Credential inference. The CSPT2CSRF axis needs to know whether cookies attach, whether an
Authorizationheader is set, whether the method is state-changing. Cookies are invisible to JavaScript so this has to be inferred from same-origin pluscredentialsmode plus whatwebRequestsees at the header layer.
Polecat in one sentence
You browse the target normally; any value from the current URL (path segment, query value, #hash fragment) that shows up inside the path of an outgoing request is recorded, badged, and ranked, with credentialed state-changing sinks stamped as CSPT2CSRF candidates.
How Polecat is built
Polecat is an MV3 Firefox add-on with three moving parts. Two content scripts, one event page.
injected.js is the sensor. It is declared in the manifest with "world": "MAIN", so Firefox 128+ injects it into the page's own JavaScript context at document_start, ahead of the page bundle. It replaces window.fetch, window.XMLHttpRequest, navigator.sendBeacon, and window.EventSource with wrappers that call the original after publishing a postMessage. Page CSP cannot block this because the injection is not a <script> tag; it happens through the extension loader.
content.js is the isolated-world bridge. It receives postMessage events from the page hook and forwards them to the background page via chrome.runtime.sendMessage. It also pushes settings back into the page world.
background.js is the store. It keeps a per-tab findings list, deduplicates on a signature that includes method, URL, matched segments, and heuristics, drives the toolbar badge, listens on webRequest.onBeforeSendHeaders for resource loads the page hook cannot see, and persists to storage.session so a background wake does not drop state.
The split matters because the two detectors see different things. Page world is the only place location.hash is visible, so it is the only way to catch hash-source CSPT. webRequest is the only place <img src>, <script src>, <link href>, <iframe>, and WebSocket URLs are visible, so it is the only way to catch the CSS-injection CSPT class. Both run in parallel; the store deduplicates.
The correlation, in code
The core of the correlation is a segment-level comparison between the current URL's surface and the outgoing request's path. Surface = every value on location the app might feed into a URL string:
function currentSurface() {
var out = [];
var u = new URL(location.href);
u.pathname.split("/").filter(Boolean).forEach(function (s) { out.push({ v: s, from: "path" }); });
var hash = u.hash.slice(1);
if (hash) hash.split(/[\/?#&=]/).filter(Boolean).forEach(function (s) { out.push({ v: s, from: "hash" }); });
u.searchParams.forEach(function (val, key) { out.push({ v: val, from: "query:" + key }); });
return out;
}
For every outgoing request, the sensor walks that surface against the request path segment by segment. A hit is either exact (segment equals value, decoded on both sides) or contained (value is a non-trivial substring of a segment). If a match is found and the sink is a credentialed state-changing method, the record is stamped cspt2csrf at high severity. Other same-origin reflections land at medium. Records with only heuristic evidence, a literal null or undefined segment, or pre-encoded traversal (%2e%2e, %2f%2e%2e, ..%2f) in the path, drop to low.
Credential inference is what it has to be, given the constraints. Cookies are invisible to JavaScript, so fetch with credentials !== "omit" on a same-origin request is treated as credentialed. XHR with withCredentials, or an explicit Authorization header via setRequestHeader, are credentialed. webRequest-observed resource loads look at the actual Cookie or Authorization header on the wire. Same-origin defaults to credentialed because the browser attaches session cookies unconditionally.
Precision knobs
Co-location guard
Without a guard, correlation fires on every page that fetches its own data. A page at /orders/inv_88371/edit making an API call to /api/orders/inv_88371 has a perfect segment match and is not a bug. Polecat computes the common leading prefix between the page URL and the request URL, and ignores path-source matches inside that prefix. Query and hash sources are always in scope, because those never share a prefix with the request path in the first place.
In field use, the guard is the difference between a five-minute badge of 473 and a badge of 3. Toggleable per-tab in settings for the rare case where a real CSPT lives inside a shared prefix.
Dynamic-only mode
Path-source matches only count if the matched segment looks like an identifier: contains a digit, is at least 16 characters long, or matches a hex-ish pattern that catches UUIDs and short hashes. Off by default (kills recall on slug-heavy apps), on for enterprise apps with UUID-dominant routes.
Confirming with the safe traversal probe
A reflection proves the injection point. It does not prove the browser will normalise .. at that point. Polecat's per-record probe fills that gap.
Pressing probe tells the page-world sensor to re-fire the request with a marker payload at the matched segment:
parts[target] = parts[target] + "/../" + tag;
The request goes out with credentials: "include", follows redirects, and the sensor inspects the final response.url. If the resolved path contains the marker and no longer contains /../, the browser collapsed the path on the way to the server and the record is stamped Mechanism A confirmed. Otherwise, the probe reports what it saw and stays neutral.
The probe is GET-only. Confirming traversal at a POST sink is the operator's call, because at that point the operator is writing up a vulnerability rather than doing recon. The probe reuses the original fetch captured at page load so it goes through the app's own credential context, and it tags the URL with a PROBE prefix plus a random suffix so the sensor recognises its own traffic and does not emit a finding for it.
Important limit: the probe fires at encoding level 0 only. If a probe returns "did not collapse", middleware may be catching literal ../. Rerun by hand at level 1 (%2e%2e/), level 2 (%252e%252e%2f), and Berson's Case 3 pattern (%252e%252e%2f-style with mixed levels) before writing the record off. Wiring level-N reruns into the probe is on the roadmap.
Canary mode for stored and DOM sources
Route-derived CSPT is the majority case. The second flavour lives in the DOM or in localStorage: a value the user typed into a form, a name captured at signup, a filename uploaded and remembered. location-based correlation cannot catch these.
Canary mode addresses this. Seed a token (set one in options, or use the generate button) into any input surface: a display name, a note, a filename. Every outgoing request path is scanned for the token, and any hit is stamped canary at high severity. The mechanism is deliberately dumb, which is why it works: "wherever you tell me to look, if this exact string shows up in a request path, flag it."
Where Polecat fits alongside other CSPT tools
Polecat is one tool in an active ecosystem. The Doyensec CSPT resources page is the current index; the tools you probably want to know about, and how they complement Polecat:
- CSPTPlayground (Maxence Schmitt): the training ground. Labs cover every mechanic in the class. Walk through it before hunting live and Polecat will make immediate sense.
- CSPT Burp Extension (Maxence Schmitt): proxy-side counterpart. Handles the requests Polecat cannot see, for example when a chain test fires from curl or from a non-browser client during exploit development.
- Eval Villain (Dennis Goodlett): instruments JavaScript sinks. Complementary axis: Eval Villain tells you when the app evaluates an attacker-controlled string; Polecat tells you when that string ends up in an outgoing path.
- DOMLoggerpp (Kevin Mizu): DOM operation logging. Useful for confirming that a stored-source value actually reached a sink you care about.
- DOM Invader (PortSwigger): built into Burp. Worth leaving on for any test where CSPT is in scope, alongside Polecat.
What Polecat gives you that the others do not: every same-origin outgoing path is correlated live against the current URL surface (including the fragment) in the browser, with a co-location guard and a per-record safe probe. That specific overlap of "browser-side, live, fragment-aware, probe-ready" is Polecat's niche.
A field workflow you can copy
The workflow we use on client engagements. Not the only shape, but the one that has found the most real findings for us:
- Dedicated Firefox profile. Polecat runs against every http(s) tab it is loaded into. A separate profile keeps daily browsing off the finding list.
- Load the temporary add-on, then reload the target tab. Firefox does not retroactively inject temporary add-ons into open tabs. The popup pill reads off when this is the case; reload flips it to live.
- Sanity-check the hook. From the browser console,
window.__CSPT_HUNTER__returnstrue. If not,about:debugging, this Firefox, Polecat, Inspect, and read the JS error. - Mute the noisy hosts. The first minute on a real target fires records against the CDN, telemetry, and analytics beacons. Mute host on each. Muted set is editable in settings.
- Walk the app. Every route type once. Load a record, edit it, delete something, search, open modals, use filters. Polecat is passive; you are just generating traffic.
- Read the badge. Red badge = at least one high-severity finding on the current tab. Grey with a number = non-high records. Set the badge to high-only in settings if you want the toolbar quiet until CSPT2CSRF fires.
- Triage top-down. Sort by severity, filter to CSPT2CSRF, read the top records first.
- Probe medium-and-high candidates. Records that come back TRAVERSED are the ones worth writing up. Records that come back negative are the ones to retry at Berson's encoding levels 1 through 3.
- Copy payload for report. The copy payload button emits a traversal starting point at the matched segment, ready to paste into a write-up or into a follow-up request in Burp.
- Enable canary mode for the stored-source pass. Set a canary, seed it into every persistent user-controlled field you can find (display name, filename, org name, description). Revisit affected pages. Any hit is stored-source CSPT.
- Chain for GET sinks. If your best records are GET sinks, look for a file-upload endpoint or any other secondary source that lets you plant a JSON blob with a controlled
id. That is the file-upload gadget from Doyensec's 2025 write-up. - Export before you navigate away. Records clear on navigation by default (toggleable). Markdown export is report-ready; JSON preserves everything; CSV is for spreadsheet triage.
Severity, made explicit
| Label | Meaning |
|---|---|
| high | Reflected value in a credentialed state-changing request (CSPT2CSRF), or any canary hit. |
| medium | Same-origin reflection into a request path. Injection point confirmed, sink not yet weaponisable. |
| low | Heuristic only: literal null / undefined segment, or pre-encoded traversal in the path. Suggestive, not confirmed. |
| info | Detection ran, nothing above threshold. Not surfaced by default. |
Field notes
Hash-routed SPAs are overrepresented
Any SPA that keeps its route in #/orders/:id is a fertile CSPT surface. The fragment is invisible to server logs, CDN caches, and webRequest. Developers who sanitise pathname parameters routinely forget the same discipline for window.location.hash. Polecat's page-world sensor is the reason these are visible at all.
"Just for display" fields
Reflections often start in fields the developer thinks are display-only (a filename, a title, alt text) and end up concatenated into a fetch when someone adds an "open in new tab" or "copy link" button months later. Canary mode is the fastest way to find these without reading source.
Encoded matches are more valuable than they look
When a matched segment shows the encoded tag in the popup, the request path was URL-encoded and the reflection was decoded before comparison. That means the client concatenated the value into a string and relied on fetch to encode it, which is exactly the failure mode where a %2f%2e%2e%2f payload survives the client's string handling and gets decoded by the server or CDN into a real traversal. Encoded-tagged records deserve a probe and, if the probe negatives, a level-2 rerun.
Resource loads are underhunted
Everyone hunts fetch and XHR. Fewer people hunt <img src>, <link rel="stylesheet">, and <script src>, which is exactly why the CSS-injection CSPT class keeps producing bounties. Grafana CVE-2025-4123 is the current model. Polecat's webRequest layer picks these up; any medium-severity record with a resource tag is worth reading closely.
Install and run
1. git clone https://github.com/theemperorspath/polecat
2. Open about:debugging#/runtime/this-firefox
3. Load Temporary Add-on
4. Select manifest.json
Firefox 128 or later required (page-world content scripts). Temporary add-ons unload on restart; sign the build via web-ext sign for persistence. <all_urls> plus webRequest are required by design because the extension watches every request from every tab. Only load it on browser profiles used for authorised testing.
What Polecat is not
Not a static analyser. It has no view into application source, only into the requests the running page issues. Code paths the operator does not exercise stay invisible. This is a deliberate trade: static analysis of large JS bundles is a poor fit for CSPT, and a running browser has the ground truth about what a URL-derived value actually turns into once all abstractions evaluate.
Not a scanner. It does not crawl, it does not fuzz, and it does not attempt exploitation beyond the opt-in traversal probe on records the operator selects.
Not a Burp extension. There is a design case for both, and a Burp version for the same correlation over proxy history is on the roadmap. The browser-side tool exists because the fragment and DOM-source cases only work inside the browser.
Roadmap
- Level-aware probe: fire probes at encoding levels 1, 2, and Case 3 automatically, not just level 0.
- Chromium build, gated on MV3
"world": "MAIN"stabilising in Chrome for disk-loaded extensions. - Burp Suite issue export from a confirmed probe, so a chain-in-progress can move straight from browser to proxy.
- Auto-canary for stored sources: seed and refresh a canary in common form fields without operator intervention.
- Header-source support (
Referer-derived reflection) for the referrer-into-path variant.
References and further reading
- Polecat on GitHub
- Doyensec: Exploiting Client-Side Path Traversal to Perform CSRF (CSPT2CSRF)
- Doyensec CSPT2CSRF whitepaper (PDF)
- Doyensec: Bypassing file upload restrictions to exploit CSPT (the JSON gadget)
- Doyensec: CSPT the Eval Villain way
- Doyensec: CSPT resources index
- Matan Berson: Bypassing WAFs to exploit CSPT using encoding levels
- CSPTPlayground (Maxence Schmitt)
- Critical Thinking Bug Bounty podcast (episodes 137, 141, and 175 for CSPT, cache deception, and mobile CSPT respectively)
- MDN: MV3
worldand dynamic script execution - MDN:
webRequestAPI - 0dayscyber on YouTube