首页 / 帮助文档 / 网站漏洞防护之会话固定攻击与登出后会话销毁

网站漏洞防护之会话固定攻击与登出后会话销毁

网站运维人员常有一个认知误区:登录页面加了HTTPS、密码做了哈希加盐,用户账户就安全了。实际上,攻击者根本不需要破解密码,他们可以直接“借用”用户的登录状态。会话固定攻击(Session Fixation)就是这种劫持手法的典型代表,它不窃取凭证,而是让受害者使用攻击者预设的会话ID登录,从而接管账户。与此同时,用户点击“退出登录”后,如果服务端只是象征性地跳转页面而没真正销毁会话,那个看似失效的Cookie依然是攻击者手里的万能钥匙。

会话固定攻击到底是怎么运作的

理解这个攻击,先要明白Web会话的机制。用户访问一个需要登录的网站时,服务端会生成一个唯一的会话ID,通过Set-Cookie指令写入浏览器。后续每一次请求,浏览器自动带上这个Cookie,服务端就能识别“这是同一个人”。问题出在,很多应用允许URL传参会话ID,或者登录前后会话ID不更换。

攻击者首先访问目标网站,获取一个有效的未认证会话ID,比如JSESSIONID=ABC123。然后,他通过钓鱼邮件、恶意链接或XSS漏洞,把这个URL连带会话ID发给受害者:https://example.com/login?JSESSIONID=ABC123。受害者点击链接,输入自己的账号密码完成登录。如果服务端在用户认证成功后没有生成新的会话ID,那么攻击者手里那个ABC123现在就绑定到了受害者的账户上。攻击者刷新自己的浏览器,直接以受害者身份进入后台,查看订单、修改资料甚至转移资金。

这种攻击的隐蔽性极高。登录日志显示的是正常IP、正常时间、正确密码,安全审计很难发现异常。攻击链条里没有木马,没有键盘记录,只有一个精心构造的URL。国内大量早期Java应用使用JSESSIONID作为URL参数传递,Tomcat默认配置下允许URL重写会话,这为固定攻击提供了天然土壤。

登录前后不更换会话ID是根本原因

很多开发人员为了方便,在用户访问网站时就分配一个会话ID,登录成功后只在这个会话对象里加一个“已认证”标记,ID本身原封不动。这种设计省了一行代码,却埋下致命隐患。正确的做法是:登录成功的那一刻,立即调用会话再生函数,废弃旧ID,生成全新ID,同时把旧会话数据迁移过来。

以PHP为例,核心函数是session_regenerate_id(true)。参数true表示删除旧会话文件,彻底断绝关联。代码实现如下:

// 用户密码验证成功后
if (password_verify($inputPassword, $user['password'])) {
    // 再生会话ID,销毁旧会话
    session_regenerate_id(true);
    // 设置登录状态
    $_SESSION['user_id'] = $user['id'];
    $_SESSION['login_time'] = time();
}

Java Servlet环境下,HttpServletRequest提供了changeSessionId()方法,从Servlet 3.1规范开始原生支持。Spring Security框架则默认开启会话固定防护,登录成功自动更换会话ID。如果用的是Shiro,需要显式配置SessionFixationProtection。关键不在于用什么语言,而在于是否意识到“登录是一次身份跃迁,会话凭证必须同步更新”。

还有一个容易被忽略的场景:即使登录时更换了会话ID,如果应用在“记住我”功能中使用了持久化Token,攻击者同样可以固定这个Token。防护策略是,每次自动登录时也执行会话再生,并且服务端存储的Remember-me Token与客户端Cookie值做一次哈希比对,而非明文匹配。

URL传参会话ID必须彻底禁用

会话固定攻击的另一个帮凶是URL重写。当浏览器禁用Cookie时,某些框架会自动把会话ID拼接到所有URL后面,形成http://example.com/index;jsessionid=XXX这样的链接。攻击者只需要复制完整URL发给受害者,就能完成固定。禁用URL会话传递,是从传播途径上掐断攻击。

Tomcat配置里,修改server.xml中Context节点的cookies属性,设置为true,同时确保trackingMode仅保留COOKIE:

<Context cookies="true">
    <CookieProcessor sameSiteCookies="strict" />
    <SessionCookie secure="true" httpOnly="true" />
    <TrackingMode>COOKIE</TrackingMode>
</Context>

PHP在php.ini中设置session.use_only_cookies=1,session.use_trans_sid=0,彻底关闭URL传递会话ID的能力。同时给Cookie加上HttpOnly、Secure和SameSite属性,防止JavaScript读取和跨站携带。这些配置虽然不能直接阻止会话固定,但极大压缩了攻击面。

应用层面还需要做一层防御:如果检测到请求参数中带有会话ID,直接拒绝并重定向到干净URL。Nginx反向代理可以写规则过滤:

if ($args ~* "JSESSIONID|PHPSESSID|sessionid") {
    return 403;
}

这行配置拦截所有通过查询字符串传递会话标识的请求,从入口处切断固定攻击的传播链。

登出后会话销毁不彻底等于留后门

用户点击退出,前端跳回首页,很多人以为万事大吉。实际上,如果服务端只调用了session.invalidate()但没清除客户端Cookie,或者只清除了Cookie但服务端会话文件还在,这个会话就处于“假死”状态。攻击者如果提前拿到了这个会话ID,等用户退出后,他依然可以用这个ID访问系统,甚至因为用户已退出,异常行为更不易被察觉。

彻底的登出操作必须同时完成三件事:服务端销毁会话数据、客户端Cookie立即过期、如果有关联的Remember-me Token一并失效。PHP的完整登出代码:

// 启动会话
session_start();
// 清空会话数组
$_SESSION = array();
// 删除会话Cookie
if (ini_get("session.use_cookies")) {
    $params = session_get_cookie_params();
    setcookie(session_name(), '', time() - 42000,
        $params["path"], $params["domain"],
        $params["secure"], $params["httponly"]
    );
}
// 销毁服务端会话
session_destroy();
// 如果使用了记住我功能,同时清除数据库中的Token
// DELETE FROM user_tokens WHERE user_id = ?

Java环境同样需要多管齐下。调用session.invalidate()后,遍历Cookie数组找到JSESSIONID,设置Max-Age为0,通过response.addCookie()写回客户端覆盖。Spring Security的logout配置可以简化这个过程:

http.logout()
    .logoutUrl("/logout")
    .logoutSuccessUrl("/login")
    .invalidateHttpSession(true)
    .deleteCookies("JSESSIONID", "remember-me")
    .clearAuthentication(true);

这里deleteCookies指定了要清除的Cookie名称列表,invalidateHttpSession确保服务端会话对象销毁,clearAuthentication清理SecurityContextHolder中的认证信息。三层清理,不留残留。

分布式环境下的会话管理陷阱

单体架构下,会话存在本地内存或单台Redis里,销毁操作即时生效。一旦上了微服务或集群,会话共享通过Redis、Memcached实现,问题就复杂了。某个节点执行了session_destroy(),删除了Redis中的Key,但如果其他节点本地缓存了会话快照,或者Redis主从同步有延迟,攻击者拿着旧会话ID打到从库,依然可能通过验证。

解决方案不是技术层面的小修小补,而是架构设计上引入“会话版本号”机制。用户每次登录,服务端生成一个全局唯一的session_version,存入Redis并下发给客户端。网关层校验时,不仅比对会话ID,还要比对版本号。登出操作将版本号递增,旧版本号对应的会话即使ID相同也立即失效。这样避免了分布式缓存延迟带来的窗口期风险。

还有一种更彻底的思路:将认证状态从服务端会话剥离,改用无状态的JWT Token。登出时,把当前Token加入Redis黑名单,设置过期时间等于Token剩余有效期。网关每次验签后查黑名单,命中则拒绝。这种方式天然免疫会话固定攻击,因为根本没有会话ID可以被固定。代价是增加了每次请求的Redis查询开销,需要根据业务场景权衡。

安全审计与监控的落地建议

防护措施部署后,需要持续的监控验证有效性。日志记录应包含每次会话创建、再生、销毁的事件,关键字段包括:旧会话ID、新会话ID、用户标识、IP地址、User-Agent、时间戳。一旦发现同一用户在短时间内频繁更换会话ID,或者不同地域IP使用相同会话ID,立即触发告警。

针对会话固定攻击的专项检测,可以在登录接口埋点:记录登录请求中是否携带了URL参数形式的会话ID。正常情况下,用户通过标准登录页提交表单,URL中不应出现jsessionid这类参数。如果监控到此类请求比例异常升高,很可能正在遭受批量固定攻击。

另外,定期对线上环境做会话管理健壮性测试。用脚本模拟登录前获取会话ID、登录后检查ID是否变化、登出后验证旧ID是否失效。这三个测试用例集成到CI/CD流水线中,每次发版自动执行,防止代码回退或配置变更引入漏洞。

会话固定和登出销毁不彻底,本质上都是对“会话生命周期”管理不严的体现。从创建、认证、使用到销毁,每个环节都需要明确的安全策略。登录必须再生ID,URL禁止携带会话标识,登出必须三端同步销毁,分布式环境引入版本号或黑名单兜底。这些措施不是孤立的单点防御,而是环环相扣的纵深体系。把每一个环节做实,攻击者就找不到那个可以撬动整个账户体系的支点。