会话固定攻击防御实战:Session 安全配置指南

会话固定攻击防御(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 属性成为基线HttpOnlySecureSameSite 三项同时具备,空闲超时收敛到 30 分钟以内;
  • 不预先下发匿名会话saveUninitialized: false 或登录页不创建会话,直接减少一个攻击入口;
  • 正向与反向同时验证:既验证新会话可用,也验证旧会话与伪造 ID 均被拒绝。只比对 Cookie 值变化不足以证明修复有效。

修复成本集中在登录逻辑一处,代码改动通常不超过 10 行,收益却覆盖全部已认证用户。建议把「登录前后会话 ID 必须不同、旧 ID 必须失效」写入测试用例,纳入发版回归清单。