TT Lab
开始
学习 学习路径 课程

会话与令牌 — 从浏览器到服务网格

用伪造请求检验 CSRF 防御

在 TT Lab 中继续学习

目标

为 127.0.0.1:8303 的转账服务器依次加上按会话区分的 CSRF 令牌、Sec-Fetch-Site 检查和 Origin 白名单,并亲自确认伪造请求不仅会被 403 拒绝,而且不会留下任何记录。

为什么重要

CSRF 能够成立只有一个原因:浏览器会自动附带目的地的 Cookie,所以从攻击页面发出的转账,在服务器看来与用户亲手点击的转账完全一样。因此所有防御都是要求一种“攻击页面无法伪造的信号”。绑定到会话的令牌,攻击页面读不到;Sec-Fetch-Site、Origin 由浏览器附带,页面无法更改。SameSite 只是在此之上再加一层。同级子域名会被当作同一站点,而用 GET 改变状态时,Lax Cookie 照样会被带上。本实验的评分器会用 curl 亲自构造伪造请求,在检查响应码的同时,也检查转账记录。

步骤

  1. 在 127.0.0.1:8303 上启动 /root/st/csrf/app.py。GET /healthz 返回 200 和 {"ok":true}。POST /login(JSON {"user","password"}、alice/wonderland、bob/builder)返回 __Host-sid 会话 Cookie,GET /transfers 以 {"transfers":[{"to","amount","memo"}...]} 返回该会话的转账列表。
  2. GET /csrf 对已登录的会话返回 {"csrf":"<토큰>"}(占位符为令牌)。令牌是至少 32 字节的随机数(base64url 至少 43 个字符),同一会话无论调用几次都相同,会话不同则必须不同。令牌是服务器保存在会话记录中的同步令牌,不会通过单独的 Cookie 下发。没有会话时调用返回 401。
  3. POST /transfer(JSON {"to","amount","memo"})没有 X-CSRF-Token 头,或值不是服务器签发过的值时,返回 403,且不记录转账。即使攻击者种下 csrf=X 这样的 Cookie,并在请求头里也放入 X,也必须返回 403(请求头要与保存在会话中的令牌比较,而不是与 Cookie 比较)。通过 GET /transfer?to=...&amount=...&memo=... 不能记录转账(响应码无所谓)。
  4. 把从 bob 会话拿到的真实令牌与 alice 的会话 Cookie 一起发送,必须返回 403,且不能记为 alice 的转账。
  5. 把自己会话的令牌放入 X-CSRF-Token 的转账返回 200,并记录在 GET /transfers 中。令牌比较用 hmac.compare_digest。
  6. 改变状态的请求(POST)的 Sec-Fetch-Site 为 cross-site 或 same-site 时,即使令牌正确也返回 403 并且不记录。为 same-origin 时用令牌判断并放行,没有该请求头时(旧浏览器、脚本)也用令牌判断。POST /login 也要接受同样的检查,对跨站登录不发会话 Cookie。
  7. 存在 Origin 头时,必须与白名单 http://127.0.0.1:8303 的整个字符串相同。http://evil.example、null、http://127.0.0.1:8303.evil.example、http://127.0.0.1:8300 即使令牌正确也返回 403,并且不记录。Origin: http://127.0.0.1:8303 的正常转账返回 200。
  8. 用户已登录 https://bank.example.com,会话 Cookie 是几小时前显式指定 SameSite=Lax 签发的。攻击页面是 https://evil.example.net,同级页面是 https://blog.example.com。判定下面七种情形中该 Cookie 是否会随请求发送,并在 /root/st/csrf/samesite.txt 中按 항목=sent 或 항목=not_sent(占位符为项目名)每行写一条。
    • link_get evil 页面中的链接被点击,跳转到 https://bank.example.com/account(顶级 GET)
    • form_post evil 页面的自动提交表单向 https://bank.example.com/transfer 发起 POST(顶级导航)
    • img_get evil 页面的 <img src="https://bank.example.com/transfer?to=mallory">
    • fetch_post evil 页面的 fetch("https://bank.example.com/transfer", {method:"POST", credentials:"include"})
    • iframe_get evil 页面把 https://bank.example.com/account 作为 iframe 加载
    • sibling_form_post blog 页面的表单向 https://bank.example.com/transfer 发起 POST(顶级导航)
    • link_get_transfer evil 页面的链接 https://bank.example.com/transfer?to=mallory&amount=100 被点击后跳转(顶级 GET)
  9. 用 /root/st/csrf/e2e.sh 以同一条备注向 alice 会话发送四种伪造转账(无令牌、bob 的令牌、Sec-Fetch-Site: cross-site、Origin: http://evil.example),再把以该备注记录的转账数与一笔正常转账的结果合在一起,将 no_token=403 other_session=403 cross_site=403 bad_origin=403 valid=200 forged_recorded=0 写成一行,保存到 /root/st/csrf/e2e.out。

参考

启动转账服务器

在 127.0.0.1:8303 上启动 /root/st/csrf/app.py。GET /healthz 返回 200 和 {"ok":true}。POST /login(JSON {"user","password"}、alice/wonderland、bob/builder)返回 __Host-sid 会话 Cookie,GET /transfers 以 {"transfers":[{"to","amount","memo"}...]} 返回该会话的转账列表。

以上一个模块的 Cookie 服务器为骨架即可。如果提前在会话记录中为用户、令牌和转账列表留好位置,后面的步骤会轻松很多。

签发按会话区分的 CSRF 令牌

GET /csrf 对已登录的会话返回 {"csrf":"<토큰>"}(占位符为令牌)。令牌是至少 32 字节的随机数(base64url 至少 43 个字符),同一会话无论调用几次都相同,会话不同则必须不同。令牌是服务器保存在会话记录中的同步令牌,不会通过单独的 Cookie 下发。没有会话时调用返回 401。

令牌在创建会话时一起生成并放进会话记录,就会与会话同生共死。和会话值一样,用 secrets 模块来生成。

拒绝没有令牌的转账

POST /transfer(JSON {"to","amount","memo"})没有 X-CSRF-Token 头,或值不是服务器签发过的值时,返回 403,且不记录转账。即使攻击者种下 csrf=X 这样的 Cookie,并在请求头里也放入 X,也必须返回 403(请求头要与保存在会话中的令牌比较,而不是与 Cookie 比较)。通过 GET /transfer?to=...&amount=...&memo=... 不能记录转账(响应码无所谓)。

攻击页面的表单会带上 Cookie,但不知道令牌。检查必须在记录转账之前进行,403 才有意义。Cookie 攻击者可以从同级子域名种下,所以不能作为比较基准;而用一次链接点击就能发出的 GET,不能用来改变状态。

把令牌绑定到会话

把从 bob 会话拿到的真实令牌与 alice 的会话 Cookie 一起发送,必须返回 403,且不能记为 alice 的转账。

如果与已签发的全部令牌列表比较,就会卡在这一步。只能与根据请求所带的会话 Cookie 找到的那个会话的令牌比较。

放行正确的令牌

把自己会话的令牌放入 X-CSRF-Token 的转账返回 200,并记录在 GET /transfers 中。令牌比较用 hmac.compare_digest。

防御要在放行正常请求时才算完成。字符串比较运算符在第一个不一致处就会停止,所以请使用比较时间不随值变化的函数。

用 Sec-Fetch-Site 拦截跨站请求

改变状态的请求(POST)的 Sec-Fetch-Site 为 cross-site 或 same-site 时,即使令牌正确也返回 403 并且不记录。为 same-origin 时用令牌判断并放行,没有该请求头时(旧浏览器、脚本)也用令牌判断。POST /login 也要接受同样的检查,对跨站登录不发会话 Cookie。

这个请求头由浏览器附带,页面脚本无法更改。请在所有 POST 都会经过的一处,先于令牌检查它。如果没有理由信任同级子域名,就把 same-site 也当作跨站处理。

检查 Origin 白名单

存在 Origin 头时,必须与白名单 http://127.0.0.1:8303 的整个字符串相同。http://evil.example、null、http://127.0.0.1:8303.evil.example、http://127.0.0.1:8300 即使令牌正确也返回 403,并且不记录。Origin: http://127.0.0.1:8303 的正常转账返回 200。

只看开头是否相同的比较,会被攻击者选的域名骗过。在沙箱 iframe 等场景中,Origin 会以字符串 null 的形式到来。

填写 SameSite=Lax 判定表

用户已登录 https://bank.example.com,会话 Cookie 是几小时前显式指定 SameSite=Lax 签发的。攻击页面是 https://evil.example.net,同级页面是 https://blog.example.com。判定下面七种情形中该 Cookie 是否会随请求发送,并在 /root/st/csrf/samesite.txt 中按 항목=sent 或 항목=not_sent(占位符为项目名)每行写一条。link_get evil 页面中的链接被点击,跳转到 https://bank.example.com/account(顶级 GET)· form_post evil 页面的自动提交表单向 https://bank.example.com/transfer 发起 POST(顶级导航)· img_get evil 页面的 <img src="https://bank.example.com/transfer?to=mallory"> · fetch_post evil 页面的 fetch("https://bank.example.com/transfer", {method:"POST", credentials:"include"}) · iframe_get evil 页面把 https://bank.example.com/account 作为 iframe 加载 · sibling_form_post blog 页面的表单向 https://bank.example.com/transfer 发起 POST(顶级导航)· link_get_transfer evil 页面的链接 https://bank.example.com/transfer?to=mallory&amount=100 被点击后跳转(顶级 GET)

Lax Cookie 能随跨站请求发送,只在两个条件同时满足时才成立。请先判断出发地与目的地是不是同一站点。站点比源更宽。以 # 开头的行可以当作注释。

用伪造请求组合一次性证明

用 /root/st/csrf/e2e.sh 以同一条备注向 alice 会话发送四种伪造转账(无令牌、bob 的令牌、Sec-Fetch-Site: cross-site、Origin: http://evil.example),再把以该备注记录的转账数与一笔正常转账的结果合在一起,将 no_token=403 other_session=403 cross_site=403 bad_origin=403 valid=200 forged_recorded=0 写成一行,保存到 /root/st/csrf/e2e.out。

把前面步骤的请求包装成几个函数,会更简短。给四条伪造请求带上同一条备注,就能通过该备注在记录列表中出现的次数一次数清。