TT Lab
Get started
Learn Learning paths Courses

Sessions and Tokens — From the Browser to the Mesh

Test CSRF defenses with forged requests

Continue in TT Lab

Goal

On the transfer server at 127.0.0.1:8303, add in turn a per-session CSRF token, a Sec-Fetch-Site check, and an Origin allowlist, and confirm yourself that a forged request is not only rejected with a 403 but also does not get recorded.

Why it matters

There is one reason CSRF works. Because the browser automatically attaches the destination's cookie, a transfer that started from an attacking page looks to the server exactly like a transfer the user pressed. So every defense works by requiring "a signal that an attacking page cannot imitate". A token bound to the session cannot be read by the attacking page, and Sec-Fetch-Site and Origin are attached by the browser and cannot be changed by the page. SameSite adds only one more layer to this. A sibling subdomain is treated as the same site, and if you change state with GET, the Lax cookie is carried as is. This lab's grader makes forged requests directly with curl and checks the transfer record together with the response code.

Steps

  1. Bring up /root/st/csrf/app.py at 127.0.0.1:8303. GET /healthz returns 200 and {"ok":true}. POST /login (JSON {"user","password"}, alice/wonderland, bob/builder) gives a __Host-sid session cookie, and GET /transfers gives that session's transfer list as {"transfers":[{"to","amount","memo"}...]}.
  2. GET /csrf gives {"csrf":"<토큰>"} (the placeholder is the token) to a logged-in session. The token is random of at least 32 bytes (at least 43 base64url characters), must be the same no matter how many times you call it in the same session, and must differ when the session differs. The token is a synchronizer token that the server stores in the session record and is not sent down as a separate cookie. Calling it without a session is a 401.
  3. If POST /transfer (JSON {"to","amount","memo"}) has no X-CSRF-Token header or a value the server never issued, it returns 403 and does not record the transfer. Even if an attacker plants a cookie such as csrf=X and also puts X in the header, it must be 403 (the header is compared not with the cookie but with the token stored in the session). A transfer must not be recorded through GET /transfer?to=...&amount=...&memo=... (the response code does not matter).
  4. If you send the genuine token received in bob's session together with alice's session cookie, it is a 403 and must not be recorded as alice's transfer.
  5. A transfer that carries your own session's token in X-CSRF-Token is 200 and is recorded in GET /transfers. Do the token comparison with hmac.compare_digest.
  6. If the Sec-Fetch-Site of a state-changing request (POST) is cross-site or same-site, it is 403 and is not recorded even when the token is right. If it is same-origin, judge by the token and let it through, and if the header is absent (an old browser or a script), judge by the token. POST /login goes through the same check, and a cross-site login is not given a session cookie.
  7. If there is an Origin header, it must equal the allowlist http://127.0.0.1:8303 as a whole string. http://evil.example, null, http://127.0.0.1:8303.evil.example, and http://127.0.0.1:8300 are 403 and not recorded even when the token is right. A normal transfer with Origin: http://127.0.0.1:8303 is 200.
  8. The user is logged in to https://bank.example.com, and the session cookie was issued a few hours ago with SameSite=Lax stated explicitly. The attacking page is https://evil.example.net and the sibling page is https://blog.example.com. For the seven cases below, judge whether that cookie is carried on the request and write one line each into /root/st/csrf/samesite.txt as 항목=sent or 항목=not_sent (the placeholder is the case name).
    • link_get click a link on the evil page to go to https://bank.example.com/account (top-level GET)
    • form_post an auto-submitted form on the evil page POSTs to https://bank.example.com/transfer (top-level navigation)
    • img_get <img src="https://bank.example.com/transfer?to=mallory"> on the evil page
    • fetch_post fetch("https://bank.example.com/transfer", {method:"POST", credentials:"include"}) on the evil page
    • iframe_get the evil page loads https://bank.example.com/account as an iframe
    • sibling_form_post a form on the blog page POSTs to https://bank.example.com/transfer (top-level navigation)
    • link_get_transfer click the link https://bank.example.com/transfer?to=mallory&amount=100 on the evil page to go there (top-level GET)
  9. With /root/st/csrf/e2e.sh, send four forged transfers to alice's session with the same memo (no token, bob's token, Sec-Fetch-Site: cross-site, Origin: http://evil.example), add the number of transfers recorded under that memo and the result of one normal transfer, and write no_token=403 other_session=403 cross_site=403 bad_origin=403 valid=200 forged_recorded=0 as a single line in /root/st/csrf/e2e.out.

Notes

Bring up the transfer server

Bring up /root/st/csrf/app.py at 127.0.0.1:8303. GET /healthz returns 200 and {"ok":true}. POST /login (JSON {"user","password"}, alice/wonderland, bob/builder) gives a __Host-sid session cookie, and GET /transfers gives that session's transfer list as {"transfers":[{"to","amount","memo"}...]}.

You can use the cookie server from the earlier module as a skeleton. If you leave room in the session record in advance for the token and the transfer list along with the user, the later steps get easier.

Issue a per-session CSRF token

GET /csrf gives {"csrf":"<토큰>"} (the placeholder is the token) to a logged-in session. The token is random of at least 32 bytes (at least 43 base64url characters), must be the same no matter how many times you call it in the same session, and must differ when the session differs. The token is a synchronizer token that the server stores in the session record and is not sent down as a separate cookie. Calling it without a session is a 401.

If you make the token when you make the session and put it inside the session record, it shares the session's fate. Make it with the secrets module, just like the session value.

Reject a transfer with no token

If POST /transfer (JSON {"to","amount","memo"}) has no X-CSRF-Token header or a value the server never issued, it returns 403 and does not record the transfer. Even if an attacker plants a cookie such as csrf=X and also puts X in the header, it must be 403 (the header is compared not with the cookie but with the token stored in the session). A transfer must not be recorded through GET /transfer?to=...&amount=...&memo=... (the response code does not matter).

A form on an attacking page carries the cookie but does not know the token. The 403 is meaningful only if you do the check before recording the transfer. A cookie can also be planted by an attacker from a sibling subdomain, so it cannot be the basis of comparison, and you do not leave state changes to a GET that can be sent with a single link.

Bind the token to the session

If you send the genuine token received in bob's session together with alice's session cookie, it is a 403 and must not be recorded as alice's transfer.

If you compare against a list of all the tokens issued, you get stuck at this step. You must compare only with the token of the session that you found with the session cookie attached to the request.

Let the correct token through

A transfer that carries your own session's token in X-CSRF-Token is 200 and is recorded in GET /transfers. Do the token comparison with hmac.compare_digest.

A defense is complete when it lets a normal request through. A string comparison operator stops at the first mismatch, so use a function whose comparison time does not vary with the value.

Block cross-site requests with Sec-Fetch-Site

If the Sec-Fetch-Site of a state-changing request (POST) is cross-site or same-site, it is 403 and is not recorded even when the token is right. If it is same-origin, judge by the token and let it through, and if the header is absent (an old browser or a script), judge by the token. POST /login goes through the same check, and a cross-site login is not given a session cookie.

The browser attaches this header and page scripts cannot change it. Look at it before the token, in the one place that every POST goes through. If you have no reason to trust sibling subdomains, treat same-site as cross as well.

Check the Origin allowlist

If there is an Origin header, it must equal the allowlist http://127.0.0.1:8303 as a whole string. http://evil.example, null, http://127.0.0.1:8303.evil.example, and http://127.0.0.1:8300 are 403 and not recorded even when the token is right. A normal transfer with Origin: http://127.0.0.1:8303 is 200.

A comparison that only checks whether the beginning matches is fooled by a domain name the attacker chose. In places such as a sandboxed iframe, Origin arrives as the string null.

Fill in the SameSite=Lax judgment table

The user is logged in to https://bank.example.com, and the session cookie was issued a few hours ago with SameSite=Lax stated explicitly. The attacking page is https://evil.example.net and the sibling page is https://blog.example.com. For the seven cases below, judge whether that cookie is carried on the request and write one line each into /root/st/csrf/samesite.txt as 항목=sent or 항목=not_sent (the placeholder is the case name). link_get click a link on the evil page to go to https://bank.example.com/account (top-level GET) · form_post an auto-submitted form on the evil page POSTs to https://bank.example.com/transfer (top-level navigation) · img_get <img src="https://bank.example.com/transfer?to=mallory"> on the evil page · fetch_post fetch("https://bank.example.com/transfer", {method:"POST", credentials:"include"}) on the evil page · iframe_get the evil page loads https://bank.example.com/account as an iframe · sibling_form_post a form on the blog page POSTs to https://bank.example.com/transfer (top-level navigation) · link_get_transfer click the link https://bank.example.com/transfer?to=mallory&amount=100 on the evil page to go there (top-level GET)

A Lax cookie is carried on a cross-site request only when two conditions hold at the same time. First sort out whether the origin and the destination are the same site. A site is wider than an origin. A line that starts with # can be left as a comment.

Prove it all at once with a batch of forged requests

With /root/st/csrf/e2e.sh, send four forged transfers to alice's session with the same memo (no token, bob's token, Sec-Fetch-Site: cross-site, Origin: http://evil.example), add the number of transfers recorded under that memo and the result of one normal transfer, and write no_token=403 other_session=403 cross_site=403 bad_origin=403 valid=200 forged_recorded=0 as a single line in /root/st/csrf/e2e.out.

If you group the requests of the earlier steps into a few functions, it gets shorter. If you put the same memo on the four forgeries, you can count them all at once by how many times that memo appears in the record list.