会话固定防护:登录前后那个没换的Session

会话固定攻击:登录前后那个没换掉的 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 = 5

use_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 再请求一次个人中心,必须跳登录页。
  • 后台超时阈值单独设,且比前台短。

六条全部做完大概需要半小时,但它挡掉的是「攻击者不需要密码就能进你后台」这一类最难看的事故。对我们这种一天发十几篇文章、后台只有一个管理员账号的小站来说,会话固定是少数几个「一旦被利用就没有任何挽回余地」的风险点——因为攻击者进来时用的是合法登录态,日志里看起来就是一个正常的访问。

Last modification:October 27th, 2026 at 08:23 pm

Leave a Comment