会话安全是个人站长最容易忽略的一块
大部分个人站长对安全的理解停留在「装个防火墙」「SSH 改端口」「WordPress 装个安全插件」。但有一个攻击面几乎从不被提及,却在真实入侵事件里出现频率极高:PHP 会话(Session)。后台登录状态、购物车、验证码校验结果、甚至某些程序的权限判断,全都依赖 Session。Session 一旦被劫持或伪造,攻击者不需要撞库、不需要爆破,直接就是一个「已登录」的管理员。
这篇文章不讲抽象的密码学,只讲在 Typecho、WordPress、以及自写 PHP 后台里真正会遇到的问题:Session 存放在哪里、Cookie 参数怎么配、会话固定攻击怎么防、以及 PHP 默认配置里那几个必须改掉的坑。
先看清楚你的 Session 到底存在哪
PHP 会话有三种常见存储方式,安全性和可扩展性差别很大,先搞清楚自己用的是哪一种:
php -i | grep -E 'session.save_handler|session.save_path|session.gc_maxlifetime'
三种方式的取舍:
- files(默认):Session 文件存在
/tmp或自定义目录。优点是零配置;缺点是文件堆积后会拖慢目录扫描,多台服务器无法共享,而且如果目录权限配错,同服务器上的其他站点可以直接读走你的 Session 文件。 - redis:Session 存在 Redis 里。个人站推荐方案,速度快、可设 TTL 自动过期、多机共享。需要装
php-redis扩展并改配置。 - memcached:类似 Redis,但不支持持久化,重启即丢会话。个人站不推荐,因为一旦 memcached 重启,所有用户被登出。
如果你的站已经装了 Redis(很多 Typecho 加速教程都会装),把 Session 挪过去几乎是零成本的收益:
session.save_handler = redis session.save_path = "tcp://127.0.0.1:6379?auth=你的密码&database=1&timeout=2"
注意把 database 单独分一个编号(比如 1),不要和业务缓存混在同一个库,否则某次 FLUSHDB 清理缓存会把所有人的登录状态一起清掉——这是真实的血泪教训。
Cookie 参数:三个必须改的默认值
PHP 的 Session ID 默认通过 Cookie 传递,Cookie 的安全属性由这几个 ini 项控制。默认值安全性是不够的,必须手动加固:
session.cookie_httponly = 1 session.cookie_secure = 1 session.cookie_samesite = Lax session.use_strict_mode = 1 session.use_only_cookies = 1 session.cookie_lifetime = 0
逐条解释为什么:
cookie_httponly = 1:禁止 JavaScript 读取这个 Cookie。默认是关的,意味着任何一处 XSS 都能document.cookie直接把 Session ID 偷走。这一项是必开的。cookie_secure = 1:只在 HTTPS 下发送。你的站已经上了 HTTPS,那这个 Cookie 没有任何理由走明文。cookie_samesite = Lax:跨站请求时不发送 Cookie,能挡掉大部分 CSRF。用Strict更安全,但会导致从外部链接点进站点时显示未登录状态,体验较差。Lax是个人站的最佳平衡点。use_strict_mode = 1:这个最容易被忽略,也最关键,下面单独讲。use_only_cookies = 1:禁止通过 URL 参数传 Session ID。默认虽然是 1,但有些老旧程序会手动开成 0,那 URL 里就会出现?PHPSESSID=xxx,一旦被分享出去或者被 Referer 泄露,会话就等于公开了。
改完 ini 之后,别忘了一个 PHP 的历史坑:session.cookie_secure 在某些面板环境下被设置成 0,但代码里又用了 session_set_cookie_params() 覆盖,两处不一致时以代码为准。排查时建议直接 var_dump(session_get_cookie_params()) 看实际生效值,而不是只看 php.ini。
会话固定攻击:为什么 use_strict_mode 必须开
会话固定(Session Fixation)是最经典也最容易被忽视的攻击。攻击流程是这样的:攻击者先访问你的站,拿到一个 Session ID(比如 abc123),然后想办法让受害者用这个 ID 去登录——常见手段是发一个带 ?PHPSESSID=abc123 的链接,或者利用子域名写 Cookie 的能力。受害者一旦用这个 ID 登录成功,服务器就把它标记为「已认证」;此时攻击者手里那个 abc123 就是一张有效的管理员通行证。
防御的核心是:登录成功后必须换一个新的 Session ID。正确写法是:
// 登录校验通过之后 session_regenerate_id(true); $_SESSION['uid'] = $user['uid']; $_SESSION['login_time'] = time();
参数 true 表示删除旧的 Session 文件,一定要带上,否则旧 ID 依然有效,等于没换。
而 session.use_strict_mode = 1 是在更底层堵住这个口子:开启后,如果客户端传来一个服务器从未生成过的 Session ID,PHP 会直接拒绝并生成新的,不会「接受」这个伪造 ID。两者配合使用才是完整的防御。很多自写后台的程序员只做了前者,忘了后者,结果攻击者用一个自己编造的 ID 就绕过了 regenerate 的逻辑(因为如果程序某条分支没走到 regenerate,伪造 ID 就被接纳了)。
会话过期与超时控制
默认的 session.gc_maxlifetime 是 1440 秒(24 分钟),但这个值经常被误解。它并不是「24 分钟不活动就登出」,而是「Session 文件超过 24 分钟没被访问就可能被垃圾回收删掉」。真正的登出时机取决于垃圾回收的触发概率(gc_probability/gc_divisor,默认 1/100),所以行为是不确定的。
对于后台管理,正确的做法是叠加一个「空闲超时」判断,不完全依赖 PHP 机制:
// 后台入口统一检查
$idle = 1800; // 30 分钟
if (isset($_SESSION['uid'])) {
if (time() - $_SESSION['last_active'] > $idle) {
session_unset();
session_destroy();
header('Location: /admin/login.php?timeout=1');
exit;
}
$_SESSION['last_active'] = time();
}用 Redis 存 Session 时,超时控制会更可靠:Redis 的 TTL 是精确过期的,把 session.gc_maxlifetime 和 Redis 的 TTL 对齐即可。注意 PHP 的 Redis session handler 会把 gc_maxlifetime 作为 key 的过期时间传入,所以这个 ini 值实际上变成了 TTL 的设定源,调它是有真实效果的。
Session 目录权限:一个真实存在的横向越权风险
如果你用默认的 files 存储,必须检查会话目录的权限。默认 session.save_path 往往是 /tmp,权限是 1777(所有用户可写,但只有属主可删)。问题在于:同一个 PHP 进程属主(比如 www)下的所有站点,都能读到彼此的 Session 文件。
在共享主机或者「一台服务器放多个站」的场景里,这意味着 A 站被入侵后,攻击者可以直接读取 B 站所有用户的 Session 文件,拿到 B 站的管理员会话。修复方式是给每个站点分配独立的会话目录:
mkdir -p /www/php_sessions/example.com chown www:www /www/php_sessions/example.com chmod 700 /www/php_sessions/example.com
然后在对应的 PHP-FPM pool 里设置:
php_admin_value[session.save_path] = /www/php_sessions/example.com
注意是 php_admin_value 而不是 php_value——后者可以被 ini_set() 覆盖,等于没设。php_admin_value 才是硬性的。
登出必须做干净
很多程序的「退出登录」只做了一件事:删除 $_SESSION['uid']。这是不够的。完整的登出应该是:
$_SESSION = array();
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();这个顺序不能乱:先清空 $_SESSION 数组,再让浏览器把 Cookie 过期掉,最后销毁服务端存储。只删 $_SESSION['uid'] 的话,Session 文件还在,ID 还有效,如果程序里还有别的依赖 Session 的逻辑(比如 $_SESSION['role']),就可能出现「已登出但仍能访问部分接口」的诡异状态。
并发请求下的会话锁:一个让人抓狂的性能陷阱
讲完安全再讲一个性能问题,因为它和 Session 存储方式强相关。PHP 的 files 存储默认会对同一个 Session ID 加排他锁:同一个用户的两个请求不能同时处理,第二个必须等第一个结束。这个设计是为了防止写冲突,但后果是——如果你的页面里有一个慢接口(比如调用外部 API 花了 3 秒),而同一页面又异步发了几个 AJAX 请求,它们会全部排队,用户体感就是「页面卡住了」。
诊断方法:在代码里加一行看是否被阻塞:
// 放在耗时操作之前
$t0 = microtime(true);
session_write_close(); // 提前释放锁
// ... 后续不需要写 Session 的耗时逻辑
error_log('released lock at ' . (microtime(true) - $t0));session_write_close() 的核心作用是「我读完了 Session,现在就把锁放掉,后面的请求不用等我」。只要后续代码不再写 $_SESSION,这个优化是安全的。很多 Typecho 插件、WordPress 插件在渲染大量内容前忘了这一句,就直接造成了全站级别的串行化瓶颈。用 Redis 存储时这个问题不存在(Redis session handler 默认不锁),这也是推荐 Redis 的一个附带理由。
验证码与一次性令牌不要只放 Session
验证码校验结果、密码重置令牌、支付确认标记这类「一次性」状态,很多程序的写法是 $_SESSION['captcha'] = $code,然后校验通过后忘记销毁。后果是同一个验证码可以重复使用,攻击者可以拿一次通过校验的结果去爆破接口。
正确模式是「校验即销毁」:
if (isset($_SESSION['captcha']) && strcasecmp($_SESSION['captcha'], $input) === 0) {
unset($_SESSION['captcha']); // 立刻销毁
// 继续业务逻辑
} else {
unset($_SESSION['captcha']); // 校验失败也销毁,防暴力尝试
exit('验证码错误');
}注意失败分支也必须销毁,否则攻击者可以拿同一个验证码无限次尝试,等于验证码形同虚设。同理,密码重置的 token 不要只放在 Session 里,应该落库并在使用后立即标记失效,因为 Session 是跟着浏览器走的,用户在手机上点了链接但用电脑打开,Session 对不上就会失效,体验很差。
检查清单
- 确认
session.save_handler,files 存储要检查目录权限是否可被同机其他站点读取 session.cookie_httponly、cookie_secure必须为 1session.use_strict_mode = 1,且登录成功后调用session_regenerate_id(true)session.use_only_cookies = 1,URL 中不出现 PHPSESSID- 后台叠加独立的空闲超时判断,不只依赖
gc_maxlifetime - Session 目录用
php_admin_value按站点隔离 - 登出逻辑三步走:清数组、过期 Cookie、销毁存储
小结
Session 安全的特点是「配置一次、长期生效」,而且几乎不影响性能——唯一需要注意的是 Redis 存储方案会多一个依赖,要保证 Redis 挂了的时候站点至少能降级而不是白屏。上面这些改动加起来不到一小时,但挡掉的是「攻击者拿到你后台」这个最坏的结果。个人站长没有专职安全团队,能做的就是把这些默认不安全的配置逐个改对,然后就不用天天惦记了。