登录了,会话 ID 却没变
一句话总结
服务器会话是状态由服务器掌握,浏览器只拿到会话 ID 这把钥匙。因此一次退出登录就能立刻使其失效;但代价是,如果这把钥匙在登录前后没有变化,别人就能借助预先种下的钥匙搭上我的登录,这就是会话固定(session fixation)。
为什么需要它
做登录表单时通常这样写:访问时创建一个会话并通过 Cookie 下发,登录成功后把 username 填入该会话。问题在于登录前后是同一个会话 ID。
攻击者可以把自己知道的会话 ID 预先种到受害者的浏览器里(比如早期的服务器接受 URL 中的 ;jsessionid=,或者从子域名覆盖 Cookie)。受害者用这个 ID 登录后,服务器只是在“早已存在的那个会话”上加上登录状态。攻击者就直接使用自己从一开始就知道的这个 ID 所对应的已登录会话。不知道密码,也拿到了别人的登录会话。
防御方法,OWASP Session Management Cheat Sheet 用一句话作了总结——“The session ID must be renewed or regenerated by the web application after any privilege level change.” 从匿名转为已认证的那一刻,正是这种权限变化。登录成功后要签发新 ID 并丢弃旧 ID。这样,攻击者种下的 ID 在登录后立刻成为失效的钥匙。
工作原理
把会话存储放在 Redis 之类的外部存储中,有两个原因。第一,Web 服务器有多台时,请求无论落到哪一台,都能看到同一个会话(放在本地内存中的话,只有当时连接的那台服务器有这个会话)。第二,由于服务器掌握状态,可以随时删除来立即使其失效——这是服务器会话相对 JWT 的决定性优势。
会话 ID 必须不可猜测。OWASP 要求“at least 64 bits of entropy”。如果用 Python,secrets.token_urlsafe(32) 能给出 256 位。存储中的键是 sess:<id>,值中至少包含用户、创建时间、最后活动时间。
过期同时设置两种,完全遵循 OWASP 的区分。
idle timeout 마지막 활동 이후 N분간 조용하면 만료. 요청마다 갱신(sliding).
자리를 비운 세션이 영원히 살아 있지 않게 한다.
absolute timeout 세션을 만든 지 M시간이 지나면, 계속 쓰고 있었더라도 만료.
탈취된 세션이 무한정 연장되지 않게 하는 상한이다.
在 Redis 中,idle 自然可以用键的 TTL 表示——每次请求用 EXPIRE 重新推迟 TTL,就成了 sliding。但仅靠 TTL 无法表示 absolute(每次请求都会推迟,永远不会过期)。所以要在值里另外存入创建时间,每次请求中只要 now - created > absolute,即使 idle TTL 还有剩余也拒绝。
Cookie 属性会在下一个模块深入讲解,会话 Cookie 的底线是:HttpOnly(脚本无法读取)、Secure(只通过 HTTPS)、SameSite=Lax 或更严格,以及在名称前加 __Host- 前缀,使子域名无法覆盖它。
在现场相遇的样子
偶尔会收到“登录是正常的,但有时会登录到别人的账号”这样的报告。通常是在没有重新签发会话 ID 的代码中,通过共享电脑或子域名 XSS 搭上了别人的会话。看日志也是正常登录,所以看不出原因——会话 ID 在登录前后相同这一事实,只有查看应用代码才会暴露。
反过来,服务器会话的优势也有大放异彩的时刻。收到账号被盗的报告时,只要从存储中删除该用户的会话键,就能立刻让所有设备退出登录。如果是 JWT,就只能等到过期,或者另外维护一份封禁列表(后面的模块会讲)。
代价是,会话存储会成为整个登录的单点故障。Redis 一停,就等于所有用户同时被退出登录。所以在生产环境中要配置复制和故障转移,并且在无法连接存储时,要避免倒向“先当作已登录”的一侧(fail-closed)。每个键都必须设置 TTL 也是同样的原因——没有 TTL 的会话键,会按“没退出登录就离开的用户数”不断堆积、占用内存,终有一天驱逐策略会开始连还活着的会话也一起清掉。
下一项实验要做什么
搭建一个把 Redis 作为会话存储的登录服务器,检查 Cookie 属性,并用真实请求确认登录瞬间会话 ID 是否被重新签发。用预先种下的会话 ID 登录,一直看到该 ID 在登录后立刻失效。最后确认超过绝对过期时间的会话,会不受 idle TTL 影响而被拒绝。