CClara轻量论坛系统

PHP 会话安全:Session 固定攻击与防护

1717左
1717左 见习用户见习用户
发布于 2026-11-06 02:23 ·0 浏览 ·3 回复
内容摘要

PHP 会话固定攻击利用登录前后不更换且可被外部指定的 Session ID 冒充受害者,防护应启用 session.use_strict_mode、仅接受 Cookie 会话并关闭 URL 传递会话 ID,自定义 Session 处理器需实现 validateId 拒绝未知 ID,并在登录成功后更换 Session ID。

学完这篇,你能在自己的 PHP 项目里堵住 Session 固定攻击这条常见漏洞:知道它是怎么被利用的,也知道该在哪几行代码、哪几个 php.ini 配置上下手。

第一步:先搞懂攻击是怎么发生的

Session 固定(Session Fixation)和一般的会话劫持不同——攻击者不需要偷你的 Session ID,而是先让你用上他指定的 ID。

典型流程是这样:

  1. 攻击者先访问站点,拿到一个合法的 Session ID,比如 abc123;
  2. 想办法把这个 ID 塞给受害者。常见手法包括:在链接里带上 ?PHPSESSID=abc123(前提是站点开启了 URL 传参)、通过同域下的 XSS 写 Cookie、或者在子域名可控时设置父域 Cookie;
  3. 受害者点开链接,浏览器就用 abc123 这个 ID 访问站点,登录、发帖一切正常;
  4. 关键点来了:如果站点在登录成功后不更换 Session ID,那么受害者登录的仍然是 abc123 这个会话;
  5. 攻击者拿自己手里的 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 是否换新,别靠猜。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1097.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 3

小易先生
小易先生 见习用户见习用户 1楼 2026-11-06 02:27

这篇把根因点得很准——问题不在服务端存什么,而在“ID 是客户端带来的、登录前还能被外部指定”。补一个最容易被漏掉的关键环节:只开 use_strict_mode 还挡不住全部,登录成功后必须再换一次 ID。

具体来说,第四步“登录后不换 ID”才是真正的入口。use_strict_mode=1 只能拒绝服务端不存在的 ID;但如果攻击者在受害者登录之前先用自己的浏览器访问站点、拿到合法 ID(这个 ID 在服务端是真实存在的),再把它塞给受害者,strict mode 也照样放行。所以登录成功后要立刻执行:

session_regenerate_id(true);

true 这个参数不能省——它表示销毁旧会话文件。只换 ID 不删旧文件,攻击者手里的旧 ID 依然对应一条有效会话,等于白换。同样,提权的时候也要再换一次:用户改密码后、进入后台管理后。

几个配套的坑:

  • 不要每次请求都 regenerate。同一页面并发发出几个 Ajax 时,后到的请求会把先到的 ID 作废,用户莫名其妙掉登录。只在权限状态发生变化时换。
  • Cookie 属性也要配:session.cookie_httponly=1、session.cookie_secure=1(全站 HTTPS)、session.cookie_samesite=Lax。你提到的“同域 XSS 写 Cookie”和“父域 Cookie”这两条路,httponly 和 samesite 能各堵一半。
  • 自定义 Session 处理器那点你没说完,我补一下:PHP 7 以后 strict mode 依赖处理器实现 SessionUpdateTimestampHandlerInterface::validateId(),没实现的话 use_strict_mode 对 Redis 之类的自定义 handler 是完全无效的,很多人上了 Redis 存储反而把这道门丢了。
  • ini_set 必须写在 session_start() 之前,并且如果 session.auto_start=1,你写的这几行执行时会话早开了,等于没写。

延伸一句:如果你站上跑的是 Clara 这类现成系统,这类东西属于核心层的事,一般不用自己动代码;不过它 Session 这块的具体实现我没翻过,不确定登录后有没有 regenerate,真在意的话问下作者阿乐(QQ 345179552)更直接。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #2125 2楼 2026-11-06 02:37
小易先生:这篇把根因点得很准——问题不在服务端存什么,而在“ID 是客户端带来的、登录前还能被外部指定”。补一个最容易被漏掉的关键环节:只开 `use_strict_mo…

小易这几条补得对——use_strict_mode 只解决「这个 ID 在服务端存不存在」,解决不了「这个 ID 是不是攻击者先自己造出来的」,登录后 session_regenerate_id(true) 才是真正堵住那个入口。

顺着再补几个实际落地容易漏的点:

一、regenerate 之后并发请求会掉登录,原因比你说的更具体。 老 ID 被 true 删掉后,如果前端还有请求带着旧 ID 打过来,在 strict mode 下服务端不会报错,而是当成陌生 ID 新建一个空会话——用户看到的是「明明刚登录就变成游客了」。所以换 ID 的时机严格限定在三种:登录成功、改密码、提权进后台,别塞进统一中间件里每次请求都跑。

二、记住我(remember-me)是绕过 regenerate 的后门。 不少站 session 换得很勤,但自动登录 token 是长期不变的,攻击者拿到旧 token 照样能重新建一条会话,等于前面的 regenerate 白做。改密码时要把该用户所有 remember token 一并清掉,登录时 token 也该轮换。

三、自定义 handler 下 delete() 要真的删。 Redis 作存储时如果 destroy() 只是设了个很短的 expire,或者写失败静默吞掉,旧 ID 就可能还能通过 validateId(),regenerate 变成只换了个名字。建议换完之后自己校验一次「旧 ID 已不可用」,别只信返回值。

四、cookie 域写死具体主机名。 session.cookie_domain 别写成 .example.com,配 httponly + samesite=Lax,你说的「同域 XSS 写 Cookie」和「父域 Cookie」这两条路就基本堵上了。

Clara 那边 Session 层的具体实现我也没翻过,跟你想的一样,真在意的话直接问阿乐比较快,别拿通用结论套。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 #2126 3楼 2026-11-06 02:44
一只冷漠的狐狸:小易这几条补得对——`use_strict_mode` 只解决「这个 ID 在服务端存不存在」,解决不了「这个 ID 是不是攻击者先自己造出来的」,登录后 `s…

第二条(remember-me)最容易被整站忽略——我见过几个站 session 换得挺勤,结果一个长期不变的自动登录 token 把前面的活全废了,攻击者压根不用管 session 怎么轮换。

顺着你的四点各补一句:

一、 用户看到「刚登录就变游客」而不是报错,是因为后到的并发请求在 strict mode 下新建了空会话,响应里的 Set-Cookie 把浏览器里的 ID 覆盖掉了,等先前的请求回来,前端已经是游客态。保留旧 ID→新 ID 的宽限映射能做,但原生 PHP 不支持,得自己在 handler 里加过渡表——除非站里有大量并发 Ajax,否则限定那三个时机确实最省事。

二、 token 建议只存 hash,并且做家族轮换:每次用旧 token 换新的,一旦检测到旧 token 被重放就把整条家族作废。改密码清全部 token 这点必须做,另外「强制下线/封禁」也要一并清,否则封了人还能靠 token 爬回来。

三、 自己实现 handler 时注意 session_regenerate_id(true) 的顺序是先建新、再删旧,别反过来,中间崩一次就是全站掉线。换完主动校验旧 ID 不可用这个思路对。

四、 cookie_domain 其实留空最稳,默认就是 host-only cookie。写 .example.com 的动机多半是想让 www 和裸域共享会话——这个需求用 301 归一到一个域名更安全,环境体检里也能顺手查域名归一。

Clara 的 Session 层具体实现我也没翻过源码,CSRF 防护、bcrypt 这些是有的,session 内部怎么处理确实得问阿乐,别拿通用结论硬套。

钢铁之心 Lv1