一段服务端模板里嵌着会话启动与令牌赋值,同一页面每次刷新输出的值都在变

表单里有一个隐藏字段,扫描器报“已实现令牌校验”,代码评审时谁也没细看它的取值从哪来、活多久、什么时候被换掉。这是 CSRF 防护里最容易蒙混过关的一步:令牌的存在不等于令牌有效。运行时的补丁修的是核心层问题,而跨站请求伪造属于应用层未实现令牌校验,不会因为版本升级而消失。要判断一处防护是不是真的成立,得把令牌当成一条生命周期来看:生成、存放、比对、销毁,四段各有各的错法,四段全对才叫防护。

一、生成段:熵源、编码、定长三个判据

生成段只有三个问题需要回答。第一,随机值是不是来自密码学安全的随机数发生器。可用的做法是取十六字节二进制再转成三十二位十六进制字符串,这样得到的值熵足、长度固定、不含特殊字符,嵌进 HTML 时不容易和标签语法打架。第二,输出编码是否适配嵌入位置。十六进制字符串对 HTML 属性、对日志记录、对等值比对都友好;反过来,如果直接把原始二进制塞进表单值,就要面对编码转换和截断的风险,这类风险不该由令牌层来承担。第三,长度是否固定。定长让比对和日志侧都能做长度断言,可变长度往往埋着“只比前若干位”这类弱化实现。

“用时间派生”为什么不算随机。有人写 md5(uniqid()),或者把时间戳和 rand() 拼起来。这两类的共同问题是输出可预测:唯一 ID 本身由时间生成,攻击者只要知道自己拿到页面的大致时刻,就能把候选值枚举到一个很小的集合里。rand() 与 mt_rand() 是伪随机序列,mcrypt 一类旧接口已经废弃不可用。代码里还留着这些写法,说明这一段从来没被人认真审过,而不是审过之后选择了它们。

二、存放段:一次性生成,别在输出里反复刷

被反复踩的那处坑是把会话启动和令牌生成写进输出表单字段的内联代码里。它的后果是一串连锁:同一个请求里会话启动函数被多次调用,触发警告甚至中断输出;每次渲染都重新生成一次令牌,用户页面加载完还没提交,令牌已经变了;等到真正提交时,会话里存的已经是另一个值,比对必然失败。而扫描器只看响应里有没有那个字段,于是“有令牌”和“令牌活不过一次渲染”在报告里长得一模一样。正确的姿势是表单生成时一次性写入会话,此后只在会话为空或过期时才刷新。

存放段还有两个位置问题要在设计阶段定下来。一个会话内多个表单是否共用同一个键:共用意味着并发打开两个页面时后写入者覆盖前一个,第一个页面提交时读到的是第二个页面的令牌,用户看到的是莫名其妙的失败。做法是按表单或操作标识建键,而不是全站一个值。另一个是把令牌存在会话之外,比如放进签名的客户端存储:校验成本随之上升,服务端要多做一层签名验证与有效期判定,而客户端持有的值和服务端状态之间还会出现不一致,这类方案的复杂度往往被低估。

三、比对段:存在性、一致性、顺序与入口覆盖

比对这一步的标准动作是三步:先看提交里有没有这个键,再做严格等于的一致性比较,通过后立即销毁。第一步容易被跳过,字段缺失时直接取会话外的键会先产生告警,输出内容被污染,错误页面上还可能带出实现细节。第二步的关键是严格等于:弱类型比较会把形如字符串零与布尔假值这类不同种类的值判成相等,比对函数于是变成一扇谁都能推开的门。第三步是销毁,留到下面一段讲。

在这三步之外,还有三个隐性坑。第一是顺序:校验必须排在任何写库、发信、扣款、改配置的动作之前,先执行副作用再校验等于没有校验。第二是入口覆盖:很多实现只在一个校验函数里比对,其它调用路径自己读参数、自己判断,等于绕过了这道门。第三是提交格式:同一接口既接受表单也接受 JSON 时,读取令牌的代码往往只覆盖其中一种格式,另一种提交不带这个字段就径直走到业务逻辑里。校验失败要终止请求并返回明确错误标识,只记日志继续执行是常见错法。

四、销毁段:一次性与重放窗口之间的绑定做法

销毁段是个两难。校验通过后马上从会话里移除,是最防重放的写法,但代价是用户体验:用户后退、重复点击、网络重试都会撞上“页面已过期”,客服工单里这类现象很难解释。反过来不销毁,重放窗口就一直存在——同一个令牌在会话有效期内可以无限次使用,一次伪造请求只要构造成功就能连续执行多次。

折中的做法不是简单删除或永久保留,而是把令牌和它要干的活绑在一起:用时间戳加哈希的方式绑定操作标识,让一个令牌只对生成它时对应的那类操作有效,并在一段有效期内可复用。这样后退重提在窗口内可以成功,跨操作挪用则失败,重放的影响面从“整个会话”缩到“同一个操作”。选哪种要在验收口径里写明白,让业务方知道“一次一令牌”意味着什么交互代价,而不是在上线后被工单推着临时改。

五、相邻防护各管哪一段,令牌防不住什么

同源校验和自定义请求头判定常被当成令牌的替代品,实际它们解决的是暴露面问题:判定依赖浏览器在请求里带的来源信息,经过代理与某些客户端时这类信息可能缺失或被改写,把它们当唯一依据就会在链路复杂的环境里失效。跨域场景下 Cookie 属性各管一段:SameSite 决定请求是否携带会话 Cookie,压缩的是伪造请求带着身份到达服务端的概率;Secure 保证值只在加密信道传输;HttpOnly 让脚本读不到会话标识,但它管的是会话 Cookie,不是写在页面里的那个令牌字段。

这正是转义和令牌的关系所在:令牌输出到 HTML 前始终要做转义。一旦输出未转义,脚本可以直接读到页面里的令牌并随请求带走,此时生成段的熵再高也没有意义,防护效果归零。还有一件必须说清的事:令牌防不住登录后的越权操作与业务逻辑滥用。攻击者诱导受害者发出的请求带着合法令牌、执行的是受害者权限内的动作,这部分要靠对象级授权检查和业务规则约束,不能拿“已上令牌”当结案理由。

六、五条必测回归与多入口清点

下面五条应当进回归用例集,而不是等上线前的安全测试补做。

  1. 删除提交里的令牌字段,请求应被拒绝。
  2. 同一令牌重复提交,第二次应被拒绝(或按绑定的有效期规则被拒绝)。
  3. 篡改令牌值为另一个合法样式的字符串,请求应被拒绝。
  4. 并发打开两个页面各提交一次,两次都应成功,用于验证多表单不互相覆盖。
  5. 后退后重新提交,处置必须明确:要么引导重新取令牌,要么给出可理解的错误,且不能出现半执行状态。

每条用例都要有可断言的东西:响应状态、是否落库、返回的错误标识。只看“页面没报错”不算断言。

多入口系统的清点靠人记是记不住的。做法是先列出所有会改状态的接口,包括常见的写方法与那些名义上取数据但实际会改状态的入口,再逐个确认是否走同一套校验中间件,并把“新增写接口必须挂校验”写进代码评审清单。洹拓在应用侧交付时也吃过这个亏:同一个接口既有表单入口又有 JSON 入口,评审时只看了前者。下面是四段错法的对照件,我们把它和五条用例一起作为随代码交付的验收件。

生命周期段 常见错法 现场表现 判定动作
生成 用时间或唯一标识派生 候选值可枚举 检查熵源接口与输出是否定长
存放 内联在表单输出里反复刷新 每次渲染值都变,提交必失败 断言同一会话两次渲染取到同值
比对 弱类型比较、先副作用后校验 畸形值被判等、状态已改 检查比较方式与校验位置
销毁 通过后不移除,或一律一次性删除 重放可用或后退必失败 按操作标识加有效期绑定并测两种场景