会话固定攻击:登录前后那个没换掉的 Session ID
如果你只在这一年里做过一件和登录安全有关的事,那应该是确认「登录成功之后,会话 ID 换了没有」。这一条比密码复杂度、比验证码、比登录日志告警都更基础,也更容易被忽略——因为它不是一个功能,而是一个「有没有做」的开关,做完之后你再也看不到它,于是很容易在下一次重构登录逻辑时悄悄丢掉。
这篇文章讲三件事:会话固定到底怎么被利用、Typecho / 自建 PHP 站点怎么检查、以及我们自己那台小草根站的落地改法。文中所有代码都在 Debian 12 + PHP 8.2 + Nginx 的环境里实际跑过。
一、攻击长什么样:攻击者先把「钥匙」塞给你
正常的登录流程是:用户访问站点 → 服务端发一个 Session ID → 用户提交账号密码 → 服务端校验通过 → 这个 Session ID 被标记为「已登录」。
会话固定的问题在于第四步:如果服务端在校验通过后继续沿用登录前的那个 Session ID,那么攻击者只要能让受害者的浏览器使用一个「攻击者自己知道的 Session ID」,等受害者登录之后,攻击者拿着同一个 ID 就能直接进入受害者的账号,不需要知道密码。
怎么让受害者的浏览器用上指定的 Session ID?常见的有三种:
- URL 里带 ID:老式 PHP 站点支持
?PHPSESSID=xxxx传递会话,攻击者把带参数的链接发给受害者,受害者点开、登录,ID 就被「固定」了。 - 子域写 Cookie:攻击者控制
evil.example.com或者一个 XSS 点,往.example.com写入PHPSESSID,浏览器访问主站时会带上这个 Cookie。 - 物理/中间人场景:公共电脑、共享网络里预先设置好的浏览器状态,或者 HTTP 明文被中间人注入 Set-Cookie。
注意这三种的共同点:攻击者不需要破解任何密码,也不需要偷到受害者的 Cookie。他只需要「提前把 ID 定好」。这就是为什么这类漏洞在 CVSS 里经常被评到中高危——利用门槛极低。
二、判断标准只有一条:登录成功时 ID 必须变
防护措施本身很简单,PHP 里就一个函数:session_regenerate_id(true)。参数 true 表示删除旧的会话文件。只改 ID 不删旧文件是不够的——旧 ID 在服务端仍然有效,攻击者拿旧 ID 一样能进。
判断一个站点有没有做对,方法也很直接:
# 1) 取登录前的 Session ID
curl -s -c /tmp/ck1.txt https://www.example.com/login.php -o /dev/null
grep PHPSESSID /tmp/ck1.txt
# 2) 用这个 Cookie 提交登录(换成你自己的登录接口)
curl -s -b /tmp/ck1.txt -c /tmp/ck2.txt \
-d 'name=admin&password=xxx&do=login' \
https://www.example.com/action/login?_=1 -o /dev/null
# 3) 比较两次的 ID
grep PHPSESSID /tmp/ck2.txt两次的 PHPSESSID 值必须不同。如果一模一样,说明站点没有在登录成功后轮换会话,属于典型的会话固定缺口。这一条检查我建议做成上线前的固定动作,和「首页能不能打开」放在同一个检查清单里——因为它复查的成本只有十秒。
三、Typecho 站点怎么查:三个位置
Typecho 的核心登录逻辑在 var/Typecho/Cookie.php 和 var/Widget/Login.php。Typecho 用的是自己实现的 Cookie 认证(authCode + 签名),不依赖 PHP 原生 session,所以「会话固定」在 Typecho 上的表现形式是登录态的 authCode 是否重新生成。
检查点一:var/Widget/Login.php 里 action() 方法,登录成功后调用 $this->user->commitLogin() 与 Typecho_Cookie::set('__typecho_uid', ...)。如果这些调用发生在「校验密码之前」,或者复用了请求里带来的 uid,就有问题。
检查点二:config.inc.php 里的 __TYPECHO_SECURE__ 与 __TYPECHO_SITE_URL__。如果站点同时能通过 http 和 https 访问,而 Cookie 没有带 Secure 标记,攻击者可以强制受害者走 http,再用中间人注入 Cookie。这条不是会话固定本身,但会放大影响。
检查点三:反向代理层。如果你在 Nginx 上做了 Cookie 的透传改写,务必确认没有把上游的 Set-Cookie 整个覆盖掉。我们踩过一次:为了让全站共享一个 _ga,在 proxy_hide_header Set-Cookie 之后手写了 add_header Set-Cookie,结果把 Typecho 的登录 Cookie 一起吞了,表现为「登录成功但刷新又变游客」。这类问题是配置事故,不是漏洞,但同样值得写进检查项。
四、自建 PHP 站点的正确写法
下面是我们现在用的登录处理骨架,可以直接对照改。关键是三个「必须」:必须重新生成 ID、必须删旧会话、必须重置会话内容。
session_start();
// 1) 固定死 Cookie 参数,别依赖 php.ini 的默认值
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '', // 不写 .example.com,避免子域注入
'secure' => true, // 只走 https
'httponly' => true, // JS 读不到
'samesite' => 'Lax', // 拦掉跨站 POST 携带
]);
// 2) 校验账号密码(略),成功后立刻换 ID 并清空旧数据
if ($ok) {
session_regenerate_id(true); // true = 删除旧会话文件
$_SESSION = []; // 清掉登录前写入的任何内容
$_SESSION['uid'] = $uid;
$_SESSION['ip'] = $_SERVER['REMOTE_ADDR'];
$_SESSION['ua'] = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 120);
$_SESSION['born'] = time();
}几个常见的错法,我在别人的代码里都见过:
session_regenerate_id()不带参数 —— 旧会话文件留在/var/lib/php/sessions/里,仍然可用,等于没防护。- 先
session_regenerate_id()再校验密码 —— 顺序反了,攻击者依然能在校验前把 ID 定好。 - 用
$_SESSION['uid']判断登录,但把$_SESSION['cart']之类的内容在换 ID 后保留了——这些内容可能来自未登录状态的用户输入,属于会话内容的污染。 - 在
php.ini里开了session.use_trans_sid=1—— 允许 URL 传 Session ID,直接给会话固定开了后门。这个值必须是 0,且session.use_only_cookies=1。
五、会话本身的生命周期也要收紧
换 ID 只是堵住了「固定」,还有「会话一直不失效」这个方向。我们的配置是:
; /etc/php/8.2/fpm/php.ini
session.use_strict_mode = 1 ; 拒绝客户端自带的未知 ID
session.use_only_cookies = 1
session.use_trans_sid = 0
session.cookie_httponly = 1
session.cookie_samesite = Lax
session.gc_maxlifetime = 7200 ; 服务端 2 小时回收
session.cookie_lifetime = 0 ; 浏览器关闭即失效
session.sid_length = 48
session.sid_bits_per_character = 5use_strict_mode 这一项经常被忽略,但它其实是最省事的一层防护:开启之后,如果客户端带来的 Session ID 在服务端不存在(攻击者自己编的),PHP 会直接丢弃并生成新的,不会「先接受再创建」。这基本把「预先设定 ID」这条路堵死了一半。
另外两块补充:
闲置超时和绝对超时分开算。gc_maxlifetime 只管「文件多久没被访问就删」,用户如果一直在操作,会话就永远不过期。我们自己在 $_SESSION 里存了 last_touch 和 born,超过 2 小时无操作、或超过 12 小时绝对超时就 session_destroy()。后台管理员的阈值更短:30 分钟 / 4 小时。
退出登录必须彻底。session_destroy() 之后还要 setcookie(session_name(), '', time()-3600, '/') 把浏览器端 Cookie 删掉,否则下次请求会带着一个已失效的 ID 上来,配合 use_strict_mode=0 的旧配置就会「复活」出空会话。这类细节不影响主流程,但会在渗透测试报告里被当成问题。
六、会话存储位置也是一个安全边界
PHP 默认把会话文件放在 /var/lib/php/sessions/,权限 1733(drwx-wx-wt),任何 www-data 进程都能写,但读不到别人的——这个默认值是合理的,不要为了「方便调试」改成 1777。我们见过一次事故:运维为了让另一个用户跑的 CLI 脚本能读会话,把目录权限放成了 drwxrwxrwt,结果同机器上一个被上传的 webshell 直接遍历了所有会话文件,等于一次拿到全站用户。
如果站点是多机部署,会话要走共享存储,方案按复杂度排:Redis(最快,但要考虑持久化和内存上限)→ 共享 NFS 或 GlusterFS(PHP 自带 files 处理器,改 session.save_path 即可,但要注意文件锁和 inode 数)→ 数据库会话(最慢,但能和业务数据一起备份)。我们用 Redis,配置如下:
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379?auth=PASS&database=2&timeout=1"Redis 会话一定要设 timeout 和 database 隔离,并且和业务缓存分开库或者加前缀。原因很实际:我们在一次 FLUSHDB 清理缓存时,顺手把会话库也清了,全站用户瞬间被踢下线——不是安全事件,但比安全事件更难看。
七、上线前的最小检查清单
把上面这些压缩成可执行的动作,一共六条,做完打勾就行:
- 用
curl -c / -b两次抓 Cookie,确认登录前后 PHPSESSID 不同(必须)。 - 确认
session.use_trans_sid=0、use_only_cookies=1、use_strict_mode=1(php -i | grep session一眼看完)。 - 确认 Cookie 带
Secure+HttpOnly+SameSite=Lax,且 domain 不写通配子域。 - 在浏览器里手动改 Session ID 为一个随机串,刷新页面应被当成未登录,而不是报错或沿用。
- 退出登录后,用旧的 Cookie 再请求一次个人中心,必须跳登录页。
- 后台超时阈值单独设,且比前台短。
六条全部做完大概需要半小时,但它挡掉的是「攻击者不需要密码就能进你后台」这一类最难看的事故。对我们这种一天发十几篇文章、后台只有一个管理员账号的小站来说,会话固定是少数几个「一旦被利用就没有任何挽回余地」的风险点——因为攻击者进来时用的是合法登录态,日志里看起来就是一个正常的访问。