学完这篇,你能在自己的 PHP 项目里堵住 Session 固定攻击这条常见漏洞:知道它是怎么被利用的,也知道该在哪几行代码、哪几个 php.ini 配置上下手。
第一步:先搞懂攻击是怎么发生的
Session 固定(Session Fixation)和一般的会话劫持不同——攻击者不需要偷你的 Session ID,而是先让你用上他指定的 ID。
典型流程是这样:
- 攻击者先访问站点,拿到一个合法的 Session ID,比如
abc123;
- 想办法把这个 ID 塞给受害者。常见手法包括:在链接里带上
?PHPSESSID=abc123(前提是站点开启了 URL 传参)、通过同域下的 XSS 写 Cookie、或者在子域名可控时设置父域 Cookie;
- 受害者点开链接,浏览器就用
abc123 这个 ID 访问站点,登录、发帖一切正常;
- 关键点来了:如果站点在登录成功后不更换 Session ID,那么受害者登录的仍然是
abc123 这个会话;
- 攻击者拿自己手里的
abc123 去请求站点,直接就是受害者的登录态。
问题的本质是:登录前后用的是同一个 Session ID,而这个 ID 在登录前是可被外部指定的。
注意:很多人以为"Session 是服务器端的,所以安全"。服务器端存储确实安全,但身份凭证(Session ID)本身是客户端带来的,只要客户端带来的值能被攻击者预知或指定,服务端存多少数据都没用。
第二步:打开 use_strict_mode,拒绝外部送来的 ID
PHP 默认允许客户端自带一个"看起来合法"的 Session ID,并直接采用它。这就是漏洞的入口。
在 php.ini 里改:
session.use_strict_mode = 1
session.use_only_cookies = 1
session.use_trans_sid = 0
三个配置各自的作用:
use_strict_mode = 1:PHP 收到客户端传来的 Session ID 时,会先检查服务端是否存在对应的会话文件/记录。不存在就直接作废,重新生成一个。这一条基本能单独挡掉大部分固定攻击。
use_only_cookies = 1:只接受 Cookie 传来的 Session ID,忽略 URL 里的 ?PHPSESSID=。
use_trans_sid = 0:不要在页面链接里自动追加 Session ID(这个功能老古董了,还会把 ID 泄露到 Referer、日志、分享链接里)。
没有服务器配置权限的话,也可以在代码里补:
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.use_trans_sid', '0');
session_start();
注意:session.use_strict_mode 在 PHP 5.5.2 之后才有,且必须是布尔值 1/0,写成 On 在某些环境里不生效。另外,如果项目自己实现了 Session 处理器(比如存 Redis 的自定义 SessionHandlerInterface),use_strict_mode 依赖你实现的 validateId() 方法返回 false 来拒绝未知 ID——不实现就等于没开。
第三步:登录成功那一刻,必须换 ID
即使开了 strict_mode,攻击者仍然可能诱导你"先登录再固定"。所以登录成功时必须调用:
if (password_verify($password, $user['password_hash'])) {
// 关键一行:生成新 ID,并删除旧会话数据
session_regenerate_id(true);
$_SESSION['uid'] = $user['id'];
$_SESSION['login_time'] = time();
$_SESSION['ua_hash'] = hash('sha256', $_SERVER['HTTP_USER_AGENT'] ?? '');
}
session_regenerate_id(true) 的 true 一定要加——它表示删除旧的会话文件。不加的话旧 ID 对应的数据还在,攻击者拿着旧 ID 依然可能读到点什么。
同样的处理要用在所有身份发生变化的时刻:登录、退出后重新登录、修改密码、绑定/解绑邮箱、管理员提权。这是硬规则。
注意:轮换会带来一个真实的副作用——多标签页或并发 AJAX 请求时,某个请求拿着旧 ID 过来会变成未登录。缓解办法是只在关键动作上轮换,或者在前端捕获 401 后跳一次登录页。别为了省事把这个调用删掉。
第四步:把 Cookie 属性配齐
Session ID 走的是 Cookie,那 Cookie 该有的防护一个都不能少:
session_set_cookie_params([
'lifetime' => 0, // 会话级,关浏览器即失效
'path' => '/',
'domain' => '',
'secure' => true, // 只走 HTTPS
'httponly' => true, // JS 读不到,挡 XSS 窃取
'samesite' => 'Lax', // 挡跨站请求携带
]);
session_start();
对应的 php.ini:
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = "Lax"
session.sid_length = 48
session.sid_bits_per_character = 6
sid_length 和 sid_bits_per_character 决定 ID 的随机强度,默认值偏短,调高后暴力猜测的成本会大得多。
注意:session_set_cookie_params() 必须在 session_start() 之前调用,顺序反了完全不生效。另外 secure => true 意味着纯 HTTP 环境下 Cookie 根本不会被浏览器保存,本地调试时记得区分环境。
第五步:登出要彻底,运行中也要定期换
退出登录时不能只 unset($_SESSION),要把 Cookie 也清掉:
function logout(): void {
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
长时间在线的会话建议定期轮换,降低 ID 泄露后的可用窗口:
if (time() - ($_SESSION['last_regen'] ?? 0) > 1800) {
session_regenerate_id(true);
$_SESSION['last_regen'] = time();
}
第六步:自己验证一遍
改完别凭感觉,用 curl 模拟一次固定攻击:
curl -i -c jar.txt -b "PHPSESSID=abcdefghijklmnop" \
-d "username=test&password=你的密码" \
https://你的域名/login.php
看响应头里的 Set-Cookie:如果返回了一个全新的、和你传进去完全不同的 Session ID,说明防护生效;如果 Set-Cookie 里没有 PHPSESSID,或者返回的还是 abcdefghijklmnop,就是没改动到。
顺便看看 Cookie 上有没有 HttpOnly、Secure、SameSite 这几个标记,一条一条核对。
注意:Session 固定防的是"身份凭证被预置",它不能替代 CSRF 防护和 XSS 防护——后两者恰恰是攻击者往你浏览器里塞 ID 的主要途径。三层是叠加关系,不是替代关系。
小结
- 攻击核心是登录前后共用同一个可被外部指定的 Session ID,不是偷 ID。
session.use_strict_mode = 1 是性价比最高的一道闸,拒绝客户端自带未知 ID。
- 登录、改密、提权后立刻
session_regenerate_id(true),true 不能省。
- Cookie 三件套
HttpOnly + Secure + SameSite=Lax,配合 sid_length 提升随机强度。
- 登出要清数据、清 Cookie、销毁会话三件事一起做。
- 改完用 curl 带固定 ID 实测一次,看
Set-Cookie 是否换新,别靠猜。