接手过不少被入侵的 PHP 站点,有个很扎心的规律:大部分出事的项目,不是因为攻击者用了多高深的手法,而是最基础的安全设置压根没做对。比如服务器开着 display_errors,SQL 报错直接甩给访客,数据库表名、字段结构一目了然;再比如 allow_url_include 是 On,配合一处理不当的文件包含,等于把服务器大门敞开。
所以这篇文章我不打算讲什么"从零构建安全体系"的宏大叙事,就聚焦在 PHP 开发里那些你大概率已经踩到、或者很快就会踩到的安全设置上。涵盖错误处理、php.ini 关键开关、注入与跨站防护、文件上传与包含、老框架止损、部署层面的权限控制,以及源码和密钥管理。每一节都是实操中能直接落地的检查项,适合正在维护 PHP 项目的开发者,也适合准备上线前做一轮自检的团队。
1. 先把"泄露"这条路堵死:错误处理与调试开关
1.1 display_errors 在生产环境必须关掉
很多人上线后第一件事就是把 php.ini 原封不动从开发机搬过去,display_errors = On 这个选项也跟着过去了。结果就是,用户访问到某个报错页面时,浏览器上直接渲染出类似这样的内容:
code复制SQLSTATE[HY000] [1045] Access denied for user 'root'@'localhost' (using password: YES)
Fatal error: Uncaught PDOException: SQLSTATE[HY000] [2002] Connection refused
攻击者看到 Access denied for user 'root',至少拿到了三样东西:数据库账号是 root、数据库地址是 localhost、用了密码认证。如果报错信息再友好一点,把完整的 SQL 语句也打出来,那表名、字段名、甚至 WHERE 条件里的参数结构全都暴露了。对于后续注入攻击的构造来说,这等于官方直接发了地图。
所以生产环境的 php.ini 里,这几个值必须是这样的:
ini复制display_errors = Off
log_errors = On
error_reporting = E_ALL
display_errors = Off 的意思是"不要往标准输出里打印错误",但错误不会消失,你要靠 log_errors = On 把它写进日志文件。error_reporting = E_ALL 保持记录所有级别的错误,包括警告和弃用通知,因为很多安全问题在早期就是以一个 Notice 或 Deprecated 的形式出现的。
1.2 日志文件的存放和权限同样敏感
关掉了页面输出,如果日志文件本身也出事,那就白关了。常见的翻车场景是:项目根目录下放了一个 log/ 文件夹,里面存着框架的运行时日志,然后 Nginx 直接把这个目录暴露在 web 访问路径下。攻击者访问 https://your-site.com/log/app.log,里面可能就有 SQL 语句、堆栈信息、甚至调试时打印出来的敏感变量。
我的习惯是:错误日志单独放到 web 根目录之外,比如 /var/log/php-fpm/error.log,由 php-fpm 统一写;如果框架自身的日志无法避免写在项目内,那就通过 Nginx/Apache 规则把日志目录彻底屏蔽掉外部访问,同时把日志文件权限压到 600。
顺带提一句,浏览器弹窗里的"安全设置"和 PHP 服务端的安全设置是两码事。搜资料的时候经常看到"不能装载 NTKO 大文件上传控件""请检查浏览器的安全设置""ActiveX 控件加载失败"这类问题,那是 IE 浏览器客户端的 Internet 安全设置,需要用户去调整浏览器对 ActiveX 的加载策略,跟你服务器上的 PHP 配置没有任何关系。别一看到"安全设置"四个字就往 php.ini 里折腾。
1.3 用统一异常处理器兜底
生产环境关了 display_errors 之后,用户看到的是空白页或 500,这对于体验来说是不可接受的。更稳的做法是在框架入口或公共文件中注册统一异常处理器,把异常信息格式化之后写进日志,然后返回一个友好的错误提示。
php复制set_error_handler(function ($severity, $message, $file, $line) {
$log = sprintf("[%s] %s in %s:%d\n", date('Y-m-d H:i:s'), $message, $file, $line);
error_log($log, 3, '/var/log/php-fpm/app-error.log');
return true;
});
set_exception_handler(function (Throwable $e) {
error_log(
'[' . date('Y-m-d H:i:s') . '] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine(),
3,
'/var/log/php-fpm/app-exception.log'
);
// 回给用户一个不太具体的错误页
http_response_code(500);
echo '服务器开小差了,请稍后再试。';
});
这套兜底逻辑可以在不依赖框架的情况下保住最基本的底线:错误信息全部进日志,用户看到的是一个无害的提示。开发时把 display_errors 打开,上线时关掉,再配合这套统一处理器,基本能覆盖绝大多数错误场景。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. php.ini 里真正决定安全底线的几个开关
2.1 open_basedir:给 PHP 的文件操作划个边界
PHP 脚本里只要出现 file_get_contents、include、fopen 这类操作,攻击者就有机会通过路径穿越或其他方式读取服务器上的任意文件,前提是 PHP 进程的运行用户对这些文件有读取权限。open_basedir 的作用就是把 PHP 能访问的目录限制在一个范围内,超出范围一律拒绝。
ini复制open_basedir = /var/www/html:/tmp
这里踩坑的点在于:如果项目还用到了 session,而 session 文件的保存路径不在你设定的范围内,PHP 就会报错。所以要先确认 session.save_path 指向哪里。很多发行版的默认路径是 /var/lib/php/sessions,那就要写成:
ini复制open_basedir = /var/www/html:/tmp:/var/lib/php/sessions
如果你用 Docker 跑 php-fpm,可以在镜像里通过 PHP_ADMIN_VALUE 按站点维度去设置:
nginx复制fastcgi_param PHP_ADMIN_VALUE "open_basedir=/var/www/html:/tmp";
这样即使多个站点跑在同一个 php-fpm 进程池上,也能做到目录隔离,一个站点被攻破,不至于顺手读走另一个站点的源码。
2.2 disable_functions:能禁掉的就别留
对于大部分业务系统来说,PHP 脚本根本不需要调用 exec、shell_exec 这类函数。但攻击者一旦通过文件上传、反序列化或者老框架漏洞拿到了代码执行能力,第一件事往往就是调用系统命令反弹 shell。把这类函数提前禁用,可以大幅提高攻击成本。
常见需要禁用的函数列表:
| 函数 | 风险说明 |
|---|---|
exec |
直接执行外部命令 |
shell_exec |
执行 shell 命令并返回完整输出 |
system |
执行外部命令并输出结果 |
passthru |
执行外部命令并直接输出原始返回值 |
popen / proc_open |
打开进程文件指针/执行命令 |
pcntl_exec |
在当前进程空间执行指定程序(如果装了 pcntl) |
curl_exec |
根据业务需求,不需要外呼时可以禁 |
file_put_contents 等写入类函数 |
看前端是否有文件写入需求,没有就禁 |
ini复制disable_functions = exec,shell_exec,system,passthru,popen,proc_open,pcntl_exec
这里有个容易误会的点:eval 是语言构造器,不是函数,disable_functions 对它无效。市面上各种"用 disable_functions 禁 eval"的说法都是错的。真想限制 eval,只能通过 Suhosin 这类扩展,或者干脆从代码规范上杜绝,比如在 CI 流程里加一个静态扫描,发现 eval 直接打回。
2.3 allow_url_include:远程文件包含的总闸门
allow_url_include 默认是 Off,这个千万别去改成 On。一旦打开,配合代码里类似 include $_GET['page']; 的写法,攻击者可以传入 http://evil.com/shell.txt 直接远程加载恶意代码执行。
除了远程 HTTP(S) 包含,本地文件包含配合伪协议也是高危组合。比如:
php复制include $_GET['page']; // 反面示例,千万别这么写
攻击者传入:
code复制index.php?page=php://filter/convert.base64-encode/resource=config.php
就能把数据库配置文件的内容以 base64 形式读出来。所以 allow_url_include = Off 是必须的,同时代码层面还不能信任任何用户输入去拼接文件路径。
2.4 expose_php 与 CGI 相关配置
expose_php = On 时,PHP 会在 HTTP 响应头里加上 X-Powered-By: PHP/7.4.33。这个头对普通用户没有意义,但对扫描器来说就是明确的版本提示:哦,这个站跑的是 PHP 7.4,那我去打 7.4 的已知漏洞。改成 expose_php = Off 虽然拦不住专业攻击者,但可以减少大量自动化扫描的打扰。
另外还有一个 Nginx 解析漏洞相关的配置:cgi.fix_pathinfo。旧版本 Nginx 的配置如果写得不好,访问 /uploads/evil.jpg/x.php 时,PHP-FPM 可能会把 evil.jpg 当作 PHP 解释执行。这个漏洞的修复,一方面靠 Nginx 配置,另一方面可以在 php.ini 设置:
ini复制cgi.fix_pathinfo = 0
这样即使请求到了 PHP-FPM,它也只会尝试解析真实的脚本路径,不再去猜路径信息,隐患自然就没了。
2.5 session 配置:防会话固定和 Cookie 泄露
PHP 默认的 session 机制有几个薄弱点,靠配置就能加固一部分:
ini复制session.use_strict_mode = 1
session.use_only_cookies = 1
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax
use_strict_mode = 1:不接受未初始化生成的 session ID,防会话固定攻击。use_only_cookies = 1:禁止 URL 参数携带 session ID,防止通过链接把 session ID 带出去。cookie_httponly = 1:JavaScript 读取不到 session cookie,降低 XSS 窃取会话的概率。cookie_secure = 1:只在 HTTPS 连接下发送 cookie。samesite = Lax:部分场景下对 CSRF 也有缓解作用。
如果项目已经全站 HTTPS,cookie_secure = 1 直接开起来没毛病。
3. 用户输入处理:SQL 注入、XSS 与 CSRF 的常规死法
3.1 SQL 注入:预处理语句是底线,不是高要求
很多老项目的 SQL 注入漏洞来自一种写法,把用户输入直接拼进 SQL 字符串:
php复制$sql = "SELECT * FROM users WHERE id = " . $_GET['id'];
只要 $_GET['id'] 被传入 1 OR 1=1,整张表就出去了。这种问题在 ThinkPHP 3.2 这类老框架的项目里尤其常见,因为框架早期版本查询构造器在某些写法下会自动拼接,开发者根本意识不到。
正确的做法只有一个:预处理语句,参数绑定。现代 PHP 用 PDO 就能做到:
php复制$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$_POST['email']]);
$user = $stmt->fetch();
注意 PDO::ATTR_EMULATE_PREPARES => false 这个选项。默认情况下 PDO 可能是模拟预处理,也就是字节码层仍然做了拼接,只是由驱动做转义。把模拟预处理关掉,强制走 MySQL 服务端的原生预处理,安全和性能都更有保障。
3.2 XSS:输出编码别偷懒
XSS 的根子在于"用户提供的数据被当成 HTML/JavaScript 解析了"。最常见的修复是输出时做 HTML 实体编码:
php复制echo htmlspecialchars($user['nickname'], ENT_QUOTES, 'UTF-8');
这个函数有三个参数,第一个字符串,第二个 ENT_QUOTES 表示把单引号和双引号都转义,第三个字符集明确为 UTF-8。很多人只写第一个参数,默认值 ENT_COMPAT 只转双引号不转单引号,在部分场景下就会漏。
但 HTML 上下文不止一种。用户数据出现在 <script> 标签里、出现在 HTML 属性里、出现在 URL 请求参数里,编码规则都不一样。比如:
html复制<img src="javascript:alert(1)" />
这个属性值是开发者拼出来的,如果数据源来自用户输入且只做了 HTML 实体编码,攻击者仍然能通过属性劫持的方式执行脚本。对付这种情况,最省心的方案是引入模板引擎的自动转义机制(比如 Twig、Blade 的 {{ }} 自动转义),并且明确告诉前端:"所有用户产生的内容一律当字符串处理,绝不要拼接成 HTML。"
3.3 CSRF:成本最低但最容易被忽略
CSRF 的经典场景:用户登录了后台,攻击者构造一个页面,里面有一个隐藏表单或者图片标签,自动发起 POST 请求到后台的删除接口。因为浏览器会自动带上用户的 Cookie,后端只看 Cookie 认不出这个请求是不是用户真实操作的。
防护手段不复杂,核心就是"校验请求来源的令牌"。用一个 Session 令牌:
php复制session_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// 生成表单时输出
echo '<input type="hidden" name="csrf_token" value="' . $_SESSION['csrf_token'] . '">';
// 提交时校验
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
http_response_code(403);
exit('CSRF 校验失败');
}
关键点是用 hash_equals 做恒定时间比较,不要用 ==,避免时序侧信道。random_bytes(32) 生成令牌也比 mt_rand、uniqid 这类可预测的随机源靠谱得多。
3.4 跨域与 JSONP 的坑
做过前后端分离的应该都遇到过跨域问题。最省事的方案是在后端加 CORS 头:
php复制header('Access-Control-Allow-Origin: https://your-frontend.com');
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
风险在于有人图省事写成 Access-Control-Allow-Origin: *,还开了 Allow-Credentials: true。这等于允许任意网站的 JavaScript 带着用户 Cookie 跨域调用你的接口。如果接口里还有修改数据的操作,那 CSRF 瞬间就复活了。正确的做法是把 Allow-Origin 限定到你自己的前端域名白名单上。
至于 JSONP,它是老一代跨域方案,回调函数名直接出现在 URL 参数里,如果没有过滤,攻击者可以传 callback=<script>...</script> 变成反射型 XSS。如果项目还在用 JSONP,建议在返回前对回调函数名做严格的正则校验,比如只允许 ^[a-zA-Z_][a-zA-Z0-9_]*$,更推荐的方式是直接迁移到 CORS。
4. 文件上传与文件包含:两个常年出事的入口
4.1 文件上传的正确校验流程
文件上传是命令执行漏洞的重灾区。攻击者传一个 evil.php,如果上传目录还能解析 PHP,那就直接拿下一个 webshell。所以正确流程里的每一步都不能省。
首先,文件扩展名必须走白名单,而不是黑名单。黑名单永远有漏:php3、phtml、php5 这些变体分分钟绕过。
然后用 finfo 检查真实文件类型,因为客户端传来的 Content-Type 是可以随意伪造的:
php复制$allowed = [
'jpg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
];
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!isset($allowed[$ext])) {
exit('不允许的文件类型');
}
$realMime = (new finfo(FILEINFO_MIME_TYPE))->file($_FILES['file']['tmp_name']);
if ($allowed[$ext] !== $realMime) {
exit('文件内容与扩展名不符');
}
最后,文件名不能沿用用户传过来的,要重新随机生成:
php复制$newName = bin2hex(random_bytes(16)) . '.' . $ext;
move_uploaded_file($_FILES['file']['tmp_name'], '/data/uploads/' . $newName);
move_uploaded_file 这个函数本身就是安全的,它会确认文件确实是本次 POST 上传的临时文件,避免开发者误用 rename 造成任意文件移动。文件保存的目录,最好放在 web 根目录之外;如果必须放在 web 内,那下面的"禁止解析"配置就少不了。
4.2 上传绕过的高发姿势
即便上面的代码都写了,还是有几个绕过路子容易翻车:
- 图片马:在图片文件末尾追加一段 PHP 代码,然后配合文件包含漏洞执行。所以对图片类上传,最好用
getimagesize再做一次校验,必要时用 GD/Imagick 重新采样压一遍图,彻底打散嵌入的代码。 - 双重扩展名:
shell.php.jpg。如果后端只取了最后一个扩展名做白名单,判断jpg合法,但 Nginx 因为配置缺陷可能仍然把它当 PHP 执行。所以前面的pathinfo判断不是万能的,还得靠服务端禁止上传目录解析 PHP。 - MIME 伪造:
Content-Type: image/png在客户端就能改,所以只看$_FILES['file']['type']没有任何意义,必须服务端用finfo检测真实内容。
4.3 文件包含漏洞与伪协议
文件包含漏洞最常见的写法:
php复制$page = $_GET['page'];
include $page . '.php';
攻击者只要控制 page 参数,就能结合各种伪协议搞事情。PHP 里这几个伪协议是攻击者的好朋友:
php://filter/convert.base64-encode/resource=config.php:读取任意文件源码,文件内容以 base64 编码返回,用来偷配置文件。data://text/plain;base64,XXXX:直接在参数里写 PHP 代码,base64解码后执行。前提是打开了allow_url_include。phar://:配合反序列化漏洞,触发任意文件读取或代码执行。这也是近年来研究热度很高的方向,很多反序列化攻击都是通过phar://触发的。
防这一连串问题的核心原则:include / require 的参数不能由用户控制。如果业务上确实需要根据参数切换模板或模块,那就用白名单映射:
php复制$allowedPages = ['home' => 'home.php', 'about' => 'about.php'];
$page = $allowedPages[$_GET['page']] ?? 'home.php';
include __DIR__ . '/../pages/' . $page;
不要直接拿用户输入拼接文件路径。同时把 allow_url_include 关掉,open_basedir 设好,三重保险下来,这类漏洞的生存空间就很小了。
4.4 已经被上传了 webshell 怎么办
发现站点被传了 webshell,先别急着删文件。保留现场,去翻访问日志,找到上传时间窗口,看看攻击者是通过哪个接口进来的。常见的入口有:老框架的公开漏洞、文件上传接口校验不严、备份文件泄露。先堵住入口,再清理 webshell。清理时要特别注意,PHP 一句话木马经常被编码隐藏在正常文件里,比如把 <?php @eval($_POST['x']);?> 藏在图片文件的二进制末尾,或者藏在日志目录里。所以清理完还要全盘扫一遍文件变动,最稳的做法是先做完整备份,然后在干净环境里重建服务。
5. 老框架怎么自救:ThinkPHP 3.2.3 的幸存者方案
5.1 为什么 ThinkPHP 3.2.3 至今还在"刷屏"
搜索"PHP 安全设置"相关的热词,thinkphp3.2.3 频繁出现,连框架默认错误页的标题 thinkphp3.2.3 { fast & simple oop php framework } 都被人拿来搜。原因是这个版本发布于很多年前,早已停止维护,但存量的老项目实在太多,很多公司到现在还跑着它。如果你在页面上能看到这个框架版本号的错误页,就说明两件事:一是报错信息没有被妥善拦截,二是框架版本信息直接暴露给了攻击者。
3.2.x 分支历史上出现过不少公开的漏洞利用链,包括 SQL 注入、缓存文件写入导致的 RCE、反序列化等。搜索行为本身就是攻击者在扫描幸存者的信号。
5.2 暂时升不了级,先做这几步止损
如果业务压着升不了框架,至少要完成下面这些操作,能挡掉绝大多数批量扫描攻击:
第一,把 APP_DEBUG 关死。很多 ThinkPHP 3.2 项目是在 Application/Common/Conf/config.php 里配置,或者入口文件里定义 APP_DEBUG 常量。调试模式开着,页面会把数据库配置、SQL 日志、文件路径全吐出来。这是第一优先级的漏洞。
php复制define('APP_DEBUG', false);
第二,升级到 3.2 分支最后一版补丁。虽然框架整体不维护了,但官方仓库里对应分支可能有安全修复提交,尽量把版本号提到分支内最新。
第三,在 Nginx 层拦截已知攻击载荷。比如对常见的关键词做禁止访问:
nginx复制if ($query_string ~* "(eval\(|base64_decode\(|php://filter|gzinflate\(|thinkphp|/index.php.*select.*from)") {
return 403;
}
粗暴但有效,误伤需要逐步调整。
第四,禁止外部直接访问 Application/Runtime 目录。很多攻击会尝试写入缓存文件到 Runtime 目录,然后通过某种方式包含执行。在 Nginx 配置里加:
nginx复制location ~* ^/Application/Runtime/ {
deny all;
return 403;
}
第五,disable_functions 里补上 exec、shell_exec、system、passthru、popen、proc_open。就算攻击者真的拿到了代码执行入口,没有系统命令可调用,下一步的成本也会高很多。
5.3 长期方案:重写比维护更省钱
止损是缓兵之计,老框架的存量漏洞是挖不完的。比较务实的路线是:新功能一律不再用老框架写,对外接口慢慢迁移到新框架,老接口留一层网关做转发,逐步替换。我见过一个内部系统这么做:先封装 API 网关,把旧的 ThinkPHP 接口隐藏在内网,前端只跟网关通信,网关再转发到旧服务。这样即使老框架被打穿,外网也够不着,安全边界一下子清晰了。
5.4 一个真实案例
有个客户找我排查,服务器被人拖库了。翻代码发现 index.php?m=Home&c=Index&a=read&id=1 这种典型的老框架路由,id 参数直接拼进了查询条件。扫描器遍历 ID 顺便拼了注入 payload,把用户表整个导走。事后整改就是前面说的那套:关调试、禁危险函数、加 WAF 拦截、改复杂后台路径。但数据已经泄露,这个教训的代价是实打实的。
6. 部署与权限:Docker、Nginx 和文件权限的最后一道墙
6.1 不该解析 PHP 的目录,就别让它解析
很多文件上传漏洞最终能变成代码执行,就是栽在"Nginx 把所有 .php 结尾的请求都转发给了 PHP-FPM"这一件事上。合理的配置是区分"静态资源目录"和"PHP 脚本目录"。
比如上传目录 /uploads/ 下的文件只当作静态资源,任何 PHP 执行请求直接拒绝:
nginx复制location /uploads/ {
location ~ \.php$ {
deny all;
}
}
主站点的 PHP 解析只匹配到入口文件所在的目录:
nginx复制location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
如果是 Apache,同样可以在上传目录下的 .htaccess 里关闭 PHP 引擎:
apache复制<FilesMatch "\.(php|phtml)$">
Require all denied
</FilesMatch>
这是"即使攻击者成功传了一个带 PHP 代码的文件,也没法让它运行"的兜底手段。
6.2 文件权限:别让 PHP 进程有过多写权限
PHP-FPM 的运行用户默认可能是 www-data 或 www。很多项目图省事,把整个项目目录都 chmod -R 777 或者把属主直接设成 www-data,这等于告诉攻击者:你只要能写一个文件,就能写在任何地方。
合理的权限分配是:
bash复制# 目录 755,文件 644
find /var/www/html -type d -exec chmod 755 {} \;
find /var/www/html -type f -exec chmod 644 {} \;
# 需要 PHP 运行时写入的目录单独控制,比如 Runtime、upload 目录
chown -R www-data:www-data /var/www/html/Application/Runtime
chmod -R 755 /var/www/html/Application/Runtime
# 敏感配置文件独立收紧
chown root:root /var/www/html/conf/config.php
chmod 600 /var/www/html/conf/config.php
这样设计的意义是:大部分代码文件对 PHP 进程是只读的,即使注入点想写文件,也没有写权限;真正可写的目录就 Runtime 和上传目录那几个,攻击面被压缩到一个很小的范围。
6.3 Docker 部署时容易忽略的安全点
用 Docker 跑 PHP 项目已经是常态了,但镜像层面的安全设置往往被忽略。几个基本但重要的点:
- 使用官方 PHP 镜像,比如
php:8.2-fpm,不要用奇奇怪怪的第三方整合包。官方镜像里默认的php.ini-production比php.ini-development的安全设置更保守,所以构建时直接复制生产版配置:
dockerfile复制FROM php:8.2-fpm
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY ./php-extras.ini /usr/local/etc/php/conf.d/
RUN docker-php-ext-install pdo_mysql mysqli
RUN useradd -r -u 1000 app && mkdir -p /var/www/html && chown -R app:app /var/www/html
USER app
EXPOSE 9000
php-extras.ini 里面就是前面讲的那些安全开关:
ini复制display_errors = Off
log_errors = On
error_log = /var/log/php-fpm.log
expose_php = Off
allow_url_include = Off
open_basedir = /var/www/html:/tmp
disable_functions = exec,shell_exec,system,passthru,popen,proc_open
session.cookie_httponly = 1
session.use_strict_mode = 1
-
容器内不要以 root 用户跑 php-fpm。root 权限意味着即使 PHP 代码被攻破,攻击者拿到的也是容器内 root,虽然容器有隔离,但对 Docker 套接字挂载、目录映射配置不严谨的场景,还是很容易蔓延到宿主机。
-
数据库容器不要暴露到宿主机公网端口。很多 Compose 文件里写了
"3306:3306",等于把数据库直接裸露到外面。如果一定要宿主机访问,改成只绑定内网 IP 或者直接不映射端口,让 PHP 容器通过 Docker 内网网络访问数据库。
yaml复制services:
mysql:
image: mysql:8.0
# 不写 ports,只让内网连接
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
6.4 顺手处理:上传大小和超时限制
安全设置里还有个容易被忽略的点,是 post_max_size 和 upload_max_filesize。如果不做限制,攻击者可以往你的接口灌入超大 POST 包,配合 PHP-FPM 的请求处理线程,瞬间把 CPU 和内存打满,这属于成本极低的拒绝服务。生产环境根据业务规模设置一个合理上限,比如:
ini复制post_max_size = 8M
upload_max_filesize = 4M
Nginx 层对应的是 client_max_body_size 10m;,几层限制一起设,别让客户端直接打穿到 PHP。
7. 源码、密钥与日志:这些平时不起眼的细节最要命
7.1 源码加密与混淆:别期望它能防住黑客
搜索词里有个"swoole loader 加密php文件 怎么解密",这说明很多商业交付项目用到了源码加密。我对于源码加密的态度很直接:它解决的是"防止用户直接拿走代码"的商业交付问题,不是安全防线。市面上常见的 PHP 加密扩展,比如 Swoole Loader、ionCube、Zend Guard,加出来的文件照样能被 dump 下来分析,何况运行时最终还是会被解读成 opcode 执行。与其花精力研究加密怎么不被解,不如把服务端权限、网络隔离、数据库口令这些基础安全做好。
如果你确实要选加密方案,注意两点:一是加密扩展和 PHP 版本必须严格匹配,我见过不少项目部署完才发现扩展没装对,PHP 直接白屏;二是加密后的文件对 opcache 不友好,性能会有所下降,需要提前压测评估。
7.2 密钥管理:不要把支付密钥提交到 Git
搜索词里频繁出现"微信支付""支付宝支付"相关的内容,这类项目最常见的泄露途径就是:开发者把 config.php 里的 app_secret、商户密钥 提交到了公开的 Git 仓库,或者是打包源码交付时没清理历史记录。密钥一旦泄露,攻击者可以直接调用支付回调接口伪造通知,造成资金损失。
正确的姿势是:配置文件不进版本库,用 .env 单独管理,并且只允许 PHP 进程读取:
code复制DB_HOST=127.0.0.1
DB_NAME=app
DB_USER=app_user
DB_PASS=xxxxxxxx
WECHAT_APPID=xxx
WECHAT_SECRET=xxx
代码里用 getenv 读取,而不是硬编码。同时 Nginx 要屏蔽点文件访问:
nginx复制location ~ /\. {
deny all;
return 404;
}
防止 .env、.git 目录这类文件被直接 HTTP 访问到。
7.3 日志记录:别把密码和 token 写进去
错误日志是排查问题的重要依据,但很多框架的默认日志会把整个 $_POST 数组打进去,如果里面有登录密码、支付密钥、第三方回调 token,那日志文件本身就是一枚定时炸弹。建议日志记录的字段做脱敏,明文密码一律不记;如果确实需要排查请求参数,只记录参数名或哈希值,并且日志文件定期轮转、定期清理。
备份也有讲究。数据库备份文件通常包含全量用户数据和部分密钥,如果直接放在 web 目录下,等于把资料库送给攻击者。备份文件放到独立目录,并设置专用权限,最好做加密后再归档。
7.4 关于"后量子加密"这类热点,冷静看待
网络热词里出现了"后量子加密"相关搜索,我看了一下,PHP 生态在这一块离实用阶段还很远。现阶段真正值得投入的,是把 AES-256、TLS 1.3、密码哈希加盐这些基础密码学能力用对。加密算法本身不是多数 PHP 项目的短板,密钥管理、随机数质量、协议实现才是。先把这些做扎实,比追热点有意义得多。
安全设置这件事,我的个人体会是:不要追求一次到位,而是把每个入口当成"迟早会被攻击"来设计。错误信息少吐一个字、上传目录多一道拦截、敏感文件权限收紧一档,这些看起来不起眼的设置,在真正的攻击发生时,就是决定系统沦陷与否的关键分界线。
