用伪造请求检验 CSRF 防御
目标
为 127.0.0.1:8303 的转账服务器依次加上按会话区分的 CSRF 令牌、Sec-Fetch-Site 检查和 Origin 白名单,并亲自确认伪造请求不仅会被 403 拒绝,而且不会留下任何记录。
为什么重要
CSRF 能够成立只有一个原因:浏览器会自动附带目的地的 Cookie,所以从攻击页面发出的转账,在服务器看来与用户亲手点击的转账完全一样。因此所有防御都是要求一种“攻击页面无法伪造的信号”。绑定到会话的令牌,攻击页面读不到;Sec-Fetch-Site、Origin 由浏览器附带,页面无法更改。SameSite 只是在此之上再加一层。同级子域名会被当作同一站点,而用 GET 改变状态时,Lax Cookie 照样会被带上。本实验的评分器会用 curl 亲自构造伪造请求,在检查响应码的同时,也检查转账记录。
步骤
- 在 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"}...]}返回该会话的转账列表。 GET /csrf对已登录的会话返回{"csrf":"<토큰>"}(占位符为令牌)。令牌是至少 32 字节的随机数(base64url 至少 43 个字符),同一会话无论调用几次都相同,会话不同则必须不同。令牌是服务器保存在会话记录中的同步令牌,不会通过单独的 Cookie 下发。没有会话时调用返回 401。POST /transfer(JSON{"to","amount","memo"})没有X-CSRF-Token头,或值不是服务器签发过的值时,返回 403,且不记录转账。即使攻击者种下csrf=X这样的 Cookie,并在请求头里也放入X,也必须返回 403(请求头要与保存在会话中的令牌比较,而不是与 Cookie 比较)。通过GET /transfer?to=...&amount=...&memo=...不能记录转账(响应码无所谓)。- 把从 bob 会话拿到的真实令牌与 alice 的会话 Cookie 一起发送,必须返回 403,且不能记为 alice 的转账。
- 把自己会话的令牌放入
X-CSRF-Token的转账返回 200,并记录在GET /transfers中。令牌比较用hmac.compare_digest。 - 改变状态的请求(POST)的
Sec-Fetch-Site为cross-site或same-site时,即使令牌正确也返回 403 并且不记录。为same-origin时用令牌判断并放行,没有该请求头时(旧浏览器、脚本)也用令牌判断。POST /login也要接受同样的检查,对跨站登录不发会话 Cookie。 - 存在
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。 - 用户已登录
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_getevil 页面中的链接被点击,跳转到https://bank.example.com/account(顶级 GET)form_postevil 页面的自动提交表单向https://bank.example.com/transfer发起 POST(顶级导航)img_getevil 页面的<img src="https://bank.example.com/transfer?to=mallory">fetch_postevil 页面的fetch("https://bank.example.com/transfer", {method:"POST", credentials:"include"})iframe_getevil 页面把https://bank.example.com/account作为 iframe 加载sibling_form_postblog 页面的表单向https://bank.example.com/transfer发起 POST(顶级导航)link_get_transferevil 页面的链接https://bank.example.com/transfer?to=mallory&amount=100被点击后跳转(顶级 GET)
- 用
/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。
参考
- 取出会话 Cookie 值:
curl -s -D - -o /dev/null ... | sed -n 's/^[Ss]et-[Cc]ookie: *__Host-sid=\([^;]*\).*/\1/p' - 模拟伪造请求:
curl -b "__Host-sid=<값>" -H "Sec-Fetch-Site: cross-site" -H "Origin: http://evil.example" ...(占位符为值)——在真实浏览器中,页面无法更改这两个请求头。 - 检查顺序是来源信号(Sec-Fetch-Site、Origin)→ 令牌 → 记录。如果在记录之后才检查,即使返回了 403,转账也会留下。
- 常见错误 1:只检查令牌“是否签发过”——攻击者用自己账号拿到的令牌会通过。
- 常见错误 2:用
startswith比较 Origin——http://127.0.0.1:8303.evil.example会通过。 - 第 9 步的评分器会在结果文件之外,另行重新发送四种伪造请求和
GET /transfer,检查它们是否没有被记录。
启动转账服务器
在 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。
把前面步骤的请求包装成几个函数,会更简短。给四条伪造请求带上同一条备注,就能通过该备注在记录列表中出现的次数一次数清。