会话固定攻击防御(Session Fixation)针对的是登录环节一处极容易被忽略的缺陷:认证成功后会话标识没有轮换。攻击者只需提前把一个已知的会话 ID 塞给受害者,待其登录成功,这个 ID 就自动升级为已认证会话,攻击者无需破解任何凭据即可接管账号。本文给出从检测、修复到验证的完整配置方案。
适用场景
- 自研登录体系的 Web 应用:登录逻辑在应用层自行实现,未使用成熟框架的会话管理;
- 存在匿名会话的应用:未登录时也分配 session(如购物车、游客态),为攻击者提供了「预先种下 ID」的入口;
- 客户端可能提交会话 ID 的场景:URL 中带有
;jsessionid=、?PHPSESSID=参数,或允许通过 POST 参数传入会话标识。
需要与两类相邻问题区分:会话固定是「ID 在认证前后未变」,会话劫持是「ID 在传输中被窃取」,CSRF 是「浏览器自动携带凭据发起非意愿请求」。三者修复手段有交集但不等同,Cookie 安全属性只是共同基础。
前置条件
- 可修改应用代码或框架配置,能控制登录成功后的会话处理逻辑;
- 可调整运行时的会话配置(PHP 的 php.ini、Tomcat 的 context.xml、Node 的 session 中间件等);
- 有 Nginx 或同类反向代理可做前置的请求净化;
- 具备 curl 或浏览器开发者工具,用于验证会话 ID 是否轮换;
- 站点已启用 HTTPS。
Secure属性在纯 HTTP 站点上不生效,且会话 ID 明文传输本身即高危。
原理说明
攻击链路
会话固定攻击分为三步,全程不需要破解口令:
- 第一步,获取一个有效的会话 ID。方式包括:直接访问应用获得匿名会话、从 URL 参数中读取,或利用应用接受客户端指定 ID 的特性自造一个;
- 第二步,把该 ID 强加给受害者。常见手法:发送形如
/?PHPSESSID=abc123或/home;jsessionid=abc123的链接;站内存在 XSS 时用脚本写入 Cookie;构造隐藏表单把会话 ID 作为字段提交; - 第三步,等待受害者完成登录。若应用在认证成功后未更换会话标识,攻击者手中的
abc123立即变成已认证会话,携带该 Cookie 访问即可获得与受害者相同的权限。
攻击成立的核心前提只有一个:登录前后会话标识不变。修复落点同样明确——在权限发生变化的那一刻强制轮换会话标识。
为什么匿名会话会放大风险
只要应用在登录前就分配了会话并写入 Cookie,攻击者就获得了一个稳定的攻击锚点:他可以先用自己的浏览器拿到一个未认证会话,再把该会话原封不动交给受害者,无需依赖 XSS 或 URL 注入。反之,若登录前完全不下发会话,攻击者就只能依赖 URL 参数等更显眼的注入方式,攻击面显著收窄,这也是「登录页不预先分配会话」被视为最佳实践的原因。
会话 ID 必须具备的两个性质
- 不可预测:足够长度 + 密码学随机源。PHP 的
session.sid_length建议 48 以上,sid_bits_per_character设为 6; - 单一有效:轮换后旧 ID 必须立即失效,不能新旧并存。旧 ID 仍可访问同一会话即等于未修复,这是很多「伪修复」失败的根本原因。
操作步骤
步骤一:检测会话 ID 是否在登录后轮换
先用一次登录流程确认现状,这是判断是否需要修复的唯一依据:
# 1) 获取登录页与匿名会话
curl -s -c jar.txt https://app.example.com/login -o /dev/null
ANON=$(grep -iE 'PHPSESSID|JSESSIONID|SESSION' jar.txt | awk '{print $NF}')
echo "匿名会话: $ANON"
# 2) 提交登录
curl -s -b jar.txt -c jar.txt -X POST https://app.example.com/login \
-d 'username=testuser&password=TestPassw0rd!' -o /dev/null -L
# 3) 读取登录后的会话
AUTH=$(grep -iE 'PHPSESSID|JSESSIONID|SESSION' jar.txt | awk '{print $NF}')
echo "登录后会话: $AUTH"
# 4) 判定
[ "$ANON" = "$AUTH" ] && echo "!! 存在会话固定风险:登录前后 ID 未变" \
|| echo "OK: 登录后已轮换会话"
再检查旧会话是否已失效(比比对 ID 更关键):
# 用登录前的旧 ID 直接访问需认证的页面
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Cookie: PHPSESSID=$ANON" https://app.example.com/dashboard
# 预期 302 或 401;若返回 200,说明旧 ID 仍然有效
步骤二:PHP 应用的配置修正
php.ini 中的会话安全基线:
; /etc/php/8.2/fpm/php.ini
session.use_strict_mode = 1 ; 拒绝客户端提交的未初始化会话 ID
session.use_only_cookies = 1 ; 只接受 Cookie 传递
session.use_trans_sid = 0 ; 关闭 URL 重写,杜绝 ID 出现在链接中
session.use_cookies = 1
session.cookie_httponly = 1 ; 禁止 JS 读取
session.cookie_secure = 1 ; 仅 HTTPS 传输
session.cookie_samesite = "Strict" ; 阻断跨站请求携带
session.sid_length = 48 ; 默认 26,建议 48 以上
session.sid_bits_per_character = 6
session.gc_maxlifetime = 1800 ; 空闲 30 分钟失效
session.cookie_lifetime = 0 ; 关闭浏览器即失效
session.save_path = "/var/lib/php/sessions" ; 目录需 0700 且属主为 web 用户
session.name = "APPSESSID" ; 避免默认名,降低批量指纹攻击命中率
应用层的登录成功分支必须主动轮换,php.ini 无法替代这一步:
<?php
// login.php —— 认证通过后的标准处理
declare(strict_types=1);
session_start();
if (verify_credentials($username, $password)) {
// 关键一:轮换会话 ID,并删除旧会话文件
session_regenerate_id(true);
// 关键二:写入认证态,并绑定客户端指纹
$_SESSION['uid'] = $user['id'];
$_SESSION['auth_time'] = time();
$_SESSION['ua_hash'] = hash('sha256', $_SERVER['HTTP_USER_AGENT'] . '|' . SALT);
// 关键三:会话内 CSRF 令牌同步更新,防止旧令牌被复用
$_SESSION['csrf'] = bin2hex(random_bytes(32));
session_write_close();
header('Location: /dashboard', true, 303);
exit;
}
?>
已登录用户的敏感操作(改密、改邮箱、支付)应再次轮换,属于「权限变更即轮换」的延伸:
<?php
// 改密成功后
session_regenerate_id(true);
$_SESSION['csrf'] = bin2hex(random_bytes(32));
// 同时使该账号在其他设备上的会话失效:记录 session_id 版本号并比对
$_SESSION['sid_version'] = ++$user['sid_version'];
?>
步骤三:Java / Tomcat 应用的配置修正
// Servlet 3.1 及以上:认证成功后直接轮换
protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
if (authenticate(req)) {
// 关键:changeSessionId() 会保留会话属性并生成新 ID,
// 旧 ID 立即失效,比 invalidate() + 新建更安全(不丢失属性)
req.changeSessionId();
HttpSession s = req.getSession(false);
s.setAttribute("uid", user.getId());
s.setAttribute("authTime", System.currentTimeMillis());
resp.sendRedirect("/dashboard");
}
}
// 若使用 Servlet 3.0 及以下(无 changeSessionId):
// 必须自行迁移属性后再失效旧会话,顺序不能颠倒:
// Map<String,Object> attrs = copyAttributes(oldSession);
// oldSession.invalidate();
// HttpSession fresh = req.getSession(true);
// attrs.forEach(fresh::setAttribute);
Tomcat 侧的 Cookie 与超时配置:
<!-- conf/context.xml -->
<Context>
<CookieProcessor sameSiteCookies="strict" />
<SessionCookieConfig
name="APPSESSIONID"
httpOnly="true"
secure="true"
path="/" />
<Manager pathname="" /> <!-- 禁用文件型会话持久化,防止重启后旧 ID 仍有效 -->
</Context>
<!-- conf/web.xml 中的会话超时 -->
<session-config>
<session-timeout>30</session-timeout>
<cookie-config>
<http-only>true</http-only>
<secure>true</secure>
</cookie-config>
<tracking-mode>COOKIE</tracking-mode> <!-- 关键:仅允许 Cookie,禁用 URL 跟踪 -->
</session-config>
tracking-mode 只保留 COOKIE 是必要项:默认配置会接受 URL 中的 ;jsessionid=,正是攻击者最常用的注入通道。
步骤四:Node.js / Express 应用的配置修正
// app.js
const session = require('express-session');
const crypto = require('crypto');
app.set('trust proxy', 1);
app.use(session({
name: 'app.sid',
secret: process.env.SESSION_SECRET, // 必须为 32 字节以上随机值,禁止硬编码
resave: false,
saveUninitialized: false, // 关键:未登录不下发会话,缩小固定攻击面
rolling: true, // 每次请求刷新有效期,缓解长期有效会话
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 60 * 1000, // 30 分钟空闲超时
path: '/'
}
}));
// 登录成功路由:显式轮换
app.post('/login', async (req, res, next) => {
if (!await verify(req.body.username, req.body.password)) {
return res.status(401).render('login', { error: '凭据无效' });
}
req.session.regenerate(err => { // 关键:regenerate 后旧 ID 失效
if (err) return next(err);
req.session.uid = user.id;
req.session.authTime = Date.now();
req.session.csrf = crypto.randomBytes(32).toString('hex');
req.session.sidVer = user.sidVersion; // 用于全局登出
req.session.save(() => res.redirect('/dashboard'));
});
});
// 会话失效的中间件(改密 / 强制下线时使用)
function assertSidVersion(req, res, next) {
if (req.session.uid && req.session.sidVer !== currentSidVersion(req.session.uid)) {
return req.session.destroy(() => res.status(401).json({ error: '会话已失效' }));
}
next();
}
自定义会话存储时,需确保 destroy 真正删除服务端记录,而不是只清空 Cookie。仅清客户端 Cookie 的「伪登出」会让旧会话在服务端继续有效,与未修复等同。
步骤五:Nginx 前置净化,剥离客户端可控的会话标识
# /etc/nginx/conf.d/session-guard.conf
server {
# 1) 拒绝任何 URL 中携带会话 ID 的请求(含 /home;jsessionid=xxx 形式)
if ($request_uri ~* "[;?&](jsessionid|phpsessid|aspsessionid|sid)=") {
return 403;
}
location / {
proxy_pass http://app_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 2) 限制登录接口速率,压制自动化枚举与批量尝试
location = /login {
limit_req zone=login_zone burst=5 nodelay;
limit_req_status 429;
proxy_pass http://app_backend;
proxy_set_header Host $host;
}
}
# http 段中定义限频区
# limit_req_zone $binary_remote_addr zone=login_zone:10m rate=10r/m;
注意:if 指令在 location 之外使用需谨慎,此处为简单返回语句,不涉及复杂逻辑判断,可安全使用。若站点存在通过 URL 传递会话的合规需求(极少数遗留系统),应在应用层完成迁移后再启用 403,避免直接中断业务。
配置验证
第一步,复跑步骤一的检测脚本,确认登录前后 ID 已不同:
curl -s -c jar2.txt https://app.example.com/login -o /dev/null
ANON=$(grep -iE 'APPSESSIONID|app.sid' jar2.txt | awk '{print $NF}')
curl -s -b jar2.txt -c jar2.txt -X POST https://app.example.com/login \
-d 'username=testuser&password=TestPassw0rd!' -o /dev/null -L
AUTH=$(grep -iE 'APPSESSIONID|app.sid' jar2.txt | awk '{print $NF}')
echo "anon=$ANON"; echo "auth=$AUTH"
[ "$ANON" != "$AUTH" ] && echo "PASS: 会话已轮换" || echo "FAIL: 仍未轮换"
第二步,确认旧会话确实失效(这一项决定修复是否真正有效):
curl -s -o /dev/null -w "旧ID访问 dashboard: %{http_code}\n" \
-H "Cookie: APPSESSIONID=$ANON" https://app.example.com/dashboard
# 预期 302(跳登录页)或 401;返回 200 即为修复失败
第三步,验证 URL 会话注入通道已被关闭:
for u in \
"https://app.example.com/home;jsessionid=ABC123" \
"https://app.example.com/login?PHPSESSID=ABC123" \
"https://app.example.com/?sid=ABC123" ; do
printf "%s -> " "$u"
curl -s -o /dev/null -w "%{http_code}\n" "$u"
done
# 预期全部 403
第四步,检查响应头中的 Cookie 属性是否齐全:
curl -sI -X POST https://app.example.com/login \
-d 'username=testuser&password=TestPassw0rd!' \
| grep -i '^set-cookie' \
| tr ',' '\n' | grep -iE 'httponly|secure|samesite'
# 预期输出包含:
# Path=/
# HttpOnly
# Secure
# SameSite=Strict 或 SameSite=Lax
第五步,验证严格模式拒绝伪造 ID(PHP 的 use_strict_mode 生效时,客户端自造的 ID 会被重新生成):
curl -sI -H "Cookie: APPSESSID=00000000000000000000000000000000" \
https://app.example.com/ | grep -i '^set-cookie'
# 预期:返回一个全新的会话 ID,而不是接受该伪造值继续使用
第六步,确认空闲超时生效(等待超过 gc_maxlifetime / session-timeout 后观察会话文件被回收):
ls -l --time-style=+%F_%T /var/lib/php/sessions/ | head
常见问题
FAQ 1:修复后用户反馈「切换标签页就掉登录」,如何排查?
这类现象几乎都源于轮换与并发请求的时序冲突,按以下顺序定位:
- 并发请求竞态:页面同时发起多个异步请求,其中两个请求都触发了
session_regenerate_id,后到的请求拿着已被删除的旧 ID,于是被判定未登录。修复方式是把轮换严格限制在登录成功及各权限变更分支,绝不要放在通用前置逻辑或每个请求中; - 轮换颗粒度过细:
rolling: true配合过短的maxAge会让活跃用户频繁遇到边界情况。建议保留 rolling 但把空闲超时设为 30 分钟以上,并在前端对 401 做一次性重试; - Cookie 属性冲突:Nginx 的
proxy_cookie_path与应用自身设置同时生效时,可能出现两个Set-Cookie头,浏览器取后者,导致属性被覆盖。用curl -I确认只输出一条会话 Cookie; - SameSite 过严:
Strict会导致从外部链接(邮件、搜索引擎、支付回调)进入时首次请求不带 Cookie,表现为「刚点链接就未登录」。这类场景可降级为Lax,或在入口页做一次 302 跳转完成凭证建立; - 多实例未共享会话:负载均衡后多个应用实例各自维护会话,轮换后请求落到另一实例自然读不到,需引入共享会话存储(Redis 等)。这是分布式部署下的高频原因。
FAQ 2:如何确认「旧会话已彻底失效」,而不是只换了 Cookie 值?
很多实现只做到了重新下发 Cookie,服务端旧记录仍然存活,此时攻击者依旧可用旧 ID。三个维度交叉验证才能确认:
# 维度一:旧 ID 直接访问受保护资源(应被拒)
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Cookie: APPSESSID=$ANON" https://app.example.com/dashboard
# 维度二:服务端会话记录是否已删除
# PHP:对比轮换前后的会话文件
ls -1 /var/lib/php/sessions/ | wc -l
# 维度三:日志中是否出现旧 ID 被拒的记录
grep -Ec "$ANON" /var/log/nginx/access.log
- 维度一:返回 302/401 是通过,返回 200 说明旧会话仍然有效;
- 维度二:PHP 记录数在轮换后不应增加(
session_regenerate_id(true)会删除旧文件);若持续增长,说明参数传成了false,旧文件被保留; - 维度三:若日志仍在出现旧 ID 的成功请求,需检查是否存在会话持久化(Tomcat 的
<Manager pathname="" />未配置时光盘上会留存会话,重启后旧 ID 会复活)。
另一条容易漏掉的路径是多端并存:仅靠轮换无法收回攻击者手中那份旧会话,需引入会话版本号(如 sid_version),在改密或强制下线时递增,使全部历史会话同时失效。
FAQ 3:站点使用第三方登录(OAuth / SSO 回调),修复后回调失败怎么办?
根因通常是跨站请求未携带 Cookie,按三层处理:Cookie 属性层把会话 Cookie 调整为 Lax,或采用双 Cookie 方案(Strict 承载敏感态、Lax 仅承载 state);state 参数层必须守住「先校验 state → 再轮换会话 → 写入认证态」的顺序,否则 state 会随旧会话一起失效;代理层确认已传递 proxy_set_header X-Forwarded-Proto $scheme; 并开启应用侧代理信任(Express 的 trust proxy、Spring 的 server.forward-headers-strategy),否则应用判定连接非 HTTPS 会拒绝下发 Secure Cookie,表现为登录后立刻掉线。
总结
- 权限变更即轮换:登录成功、改密、提权三类节点必须调用会话重建(PHP 的
session_regenerate_id(true)、Servlet 的changeSessionId()、Express 的req.session.regenerate()),且轮换后旧 ID 必须立即失效; - 关闭 URL 会话通道:
session.use_trans_sid = 0、Tomcat 的tracking-mode仅设 COOKIE,并在 Nginx 前置对 URL 中的会话参数直接返回 403; - 开启严格模式:
session.use_strict_mode = 1让服务端拒绝客户端自造的会话 ID,从入口处削弱固定攻击的可行性; - Cookie 属性成为基线:
HttpOnly、Secure、SameSite三项同时具备,空闲超时收敛到 30 分钟以内; - 不预先下发匿名会话:
saveUninitialized: false或登录页不创建会话,直接减少一个攻击入口; - 正向与反向同时验证:既验证新会话可用,也验证旧会话与伪造 ID 均被拒绝。只比对 Cookie 值变化不足以证明修复有效。
修复成本集中在登录逻辑一处,代码改动通常不超过 10 行,收益却覆盖全部已认证用户。建议把「登录前后会话 ID 必须不同、旧 ID 必须失效」写入测试用例,纳入发版回归清单。