轮换一个你不用的 session
在读一个我明知道不用 Rails session 做认证的 SessionsController#create ——
认证放在另一个签名 cookie 里,指向 sessions 表的一行 —— 我的眼睛在一行
代码上卡住了。reset_session。就在真正干活的那一行之前。我盯着它看了一
会儿,怀疑这是不是一段死代码。
不是。但它不是死代码的理由,比标准答案要怪一点。
教科书说这行代码在做什么
reset_session 做两件事。它把 session hash 清空 —— session[...] 里的每一
个键都被抹掉。然后它轮换 session ID,浏览器持有的那块 cookie 会换上一个新
值。在登录时调用它的经典理由是防 session fixation:攻击者事先在受害者的
浏览器里塞一个他自己知道的 session ID(通过一个构造好的链接、一次 XSS 注
入,随便什么),受害者登录之后,如果不轮换,攻击者手里那份相同的 session
ID 就挂在了一个已认证的会话上。
这个修法每本教程都写着:在权限升级的那一刻轮换 session ID。好。可这份 代码根本不用 Rails session 承载权限。攻击者预先塞进去的 session ID 谁也 认证不了。真正证明用户已登录的,是另一个 cookie,指着另一行数据。
那为什么还要 reset_session?
攻击者塞进来的不是一个 ID,是一堆状态
session fixation 常被描述成 攻击者共享 session ID,然后登堂入室,这其实 只是一个更一般动作的特例。攻击者能在受害者出现之前,往 session hash 里塞 任何东西。不只是 ID。整块数据。我之后可能会读的键。flash 值。CSRF token。 任何放得下的东西。
今天,控制器栈里没有代码会去信任那些值。今天,被轮换的那个 session ID 无 关紧要,因为认证边界走的是另一个 cookie。今天,这行代码读起来像一个仪式。
今天是一段很窄的窗口。
给我还没写的代码留一道防线
session hash 是一块每个控制器都能伸手碰的、可变的共享状态。代码库会继续
长,总有某一刻会有人觉得,把某样东西塞进 session[...] 挺顺手。一个
locale,因为它是一个放用户偏好的方便地方。一个「上次看过的页面」。一个
用户刚开始填、还没填完的表单。一次 feature flag 的分配。这些单看都不像
承重的东西。单看任何一个,都不会在安全审查里挂掉——它们只是方便。
而它们中的每一个,在被加进去的那一刻,都开始 信任 这份 session 状态是
用户的浏览器设置的,不是攻击者预先塞进去的。如果登录时没有
reset_session,这份信任就没有根据。攻击者预先塞进来的值还原封不动地坐
在那里,登录之后就挂在一个已认证用户名下,被一段根本不知道这份数据是从
哪来的代码读取。
reset_session 不是在防今天的信任决定。它在防 未来 的信任决定——那些
在某一天某人毫不犹豫地敲下 session[:something] = ... 的时候,悄悄开始
的信任。
为什么「反正我们又不用 session 认证」这种直觉是错的
这个错很容易犯,因为推理听起来无懈可击。我们不从 session 读认证。被注入 的 session 拿不到权限。这个攻击对我们不适用。 每一句单独看都对。问题是 它们回答的是错的问题。问题不是 这个攻击打不打得穿今天的代码,问题是 无论现在还是将来,会不会有任何一段代码去读这份状态。
答案几乎总是会。session hash 太顺手了,不可能一直没人用。总有人会用它。
他们不会去检查里面的内容有没有被攻击者操纵过,因为在他们的心智模型里,
session[...] 是那种框架会替他们照顾好的东西。reset_session 就是让这
个心智模型不至于变成谎言的那种代码。
这里有一个我反复注意到的规律。很多安全卫生不是在保护当下的代码,是在保 护未来代码将要依赖的不变式——那些代码还没写,但不变式得先在那儿等着。 权限变动时轮换。擦掉过时缓冲区。释放前清零内存。这些今天都没有调用者。 它们以后都会有调用者,而防御工作得在那之前就已经做完。
想留给下一个读者的一件小事
如果你在登录流程里看到 reset_session,第一反应是 可我们又不用 session
做认证 —— 很好,你注意到了正确的问题。第二反应才要紧。总会有人用的。
等到那一天来的时候,轮换要么已经发生,要么没发生。
把这一行留着几乎没成本。把它删掉,是一场押注:赌今后没有任何人会带着
「这玩意是我的」的预设去碰 session[...]。
这注我不敢押。