文章总结: 文章聚焦PHP工程化安全实践,涵盖依赖供应链安全、错误处理与信息泄露防护、文件上传安全、安全响应头配置及日志审计五大方面。强调代码能跑不代表安全,需通过composeraudit扫描漏洞、关闭display_errors、严格校验上传文件、配置CSP等安全头,并记录完整审计日志,提升系统整体安全性。
综合评分: 95
文章分类: 安全开发,应用安全,安全建设
写得对,更要写得稳:PHP 工程化的安全实践
原创
Sink
Sink
船山信安
2026年10月5日 12:00
湖南
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
安全开发 · SECDEV
写得对,更要写得稳:PHP 工程化的安全实践
安全开发 · 写给能跑就行、但总在半夜被叫醒的人
导语:每篇文章是用AI排版,但是每篇文章都是人工撰写,全网最用心的,但是能沉下心,认真学习的人真的太少太少。
代码能跑,不代表半夜不会被叫醒。漏洞清单背得再熟,也拦不住那些”代码本身没明显 bug、工程实践一塌糊涂”的事故。这篇不聊怎么写攻击面,聊的是怎么把系统写得稳——让它在没人盯着的时候,也不出错。
01依赖与供应链安全
代码能跑,不代表半夜不会被叫醒。
你本地一跑,页面出来了,接口通了,测试也绿了。上线。三个月后某个凌晨两点,运维把你摇起来:数据库在往外发东西。
查了一宿,根因不在你写的任何一行。在你三个月前顺手 composer require 进来的那个包里。
PHP 早就是 Composer 的世界了。现代项目几乎不再手写 autoload、不再从零造轮子,依赖通过 composer.json 拉进来,版本用 composer.lock 锁住。这是进步。但进步的另一面是:你项目的代码量里,你自己写的那部分可能只占两成。
剩下八成,是你没读过、也没打算读的第三方代码。
攻击者早就看穿了这件事。供应链投毒不挑你的主站打,它挑你依赖树里那个不起眼的小包——你从没审核过它,它的维护者可能也只有一个人。哪天这个包被劫持,或者维护者账号被盗,新版本里夹带一行”顺手把环境变量 POST 到某个陌生域名”,你 composer update 一敲,毒就进来了。
所以第一件事:知道自己装了什么。
composer audit 是 Composer 自带的命令,它会把你的依赖清单和公开的漏洞库比对,告诉你哪些包有已知问题、对应的漏洞等级和建议版本。它不是万能的,它只认”已知的”,但它免费、快、该跑就跑。CI 里加一道,提交前跑一遍,比事后救火便宜一百倍。
扫描依赖里的已知漏洞(CVE 等)
composer audit
想接进流水线,可以要 JSON 输出
composer audit –format=json
问题是很多人根本没装这步。依赖列表半年没动过,漏洞在那儿挂了几个月,没人知道。
第二个动作:composer.lock 一定要进版本库。有人觉得 lock 文件是构建产物,不该提交。错了。lock 文件锁的是每个依赖精确到 commit 的版本号,是你”今天能跑”的那份快照。不提交它,别人 composer install 拉到的可能已经是更新的、你从没测过的版本。
可复现,是工程化的底线。出了问题你能知道自己当时跑的到底是哪一份代码,而不是一句”我本地是好的”。
还有个细节:生产只装运行时真正需要的包。composer install –no-dev 会把 require-dev 里的测试框架、静态分析工具挡在线上之外。它们本来就不是给生产用的,留在线上既臃肿,又多了一层可被探测的表面积。
依赖不是装完就完了。一个新包进来前,花两分钟看一眼它的 stars、最后更新时间、最近一次发布距今多久。三个月没动静的小众包,和十年老牌库,风险不是一个量级。我不是说老的就一定安全,是说出问题有人修的概率差很多。
更新也别盲目。大版本升级意味着破坏性改动,得在测试环境先走一遍,而不是生产直接追最新。依赖该更新,但要带着脑子更新:先看 changelog,先在 staging 验证,再上生产。
供应链这事没有银弹。但你至少要做到三件不性感的小事:知道装了什么、知道它们有没有已知漏洞、让每次部署都能复现。
02错误处理与信息泄露
display_errors 开着,攻击者传个畸形参数,页面直接把 /var/www/config/db.php 的绝对路径和数据库账号打印出来。
这不是假设,这是生产环境最常见的低级事故之一。
开发时开着 display_errors=On 很方便,报错直接贴脸,调试快。问题在于,很多人上线时忘了关。PHP 一旦遇到未捕获的异常或者致命错误,默认会把整个栈、出错的文件路径、甚至出错那行附近的代码片段,原样吐到浏览器上。
攻击者要的就是这个。他不需要读你的源码,你帮他打印了。路径告诉他你的目录结构,栈告诉他你用了什么框架什么版本,偶尔还顺带把数据库连接的用户名密码甩出来——因为那行正好是 new PDO(…)。
关掉它,一句话的事:把错误从屏幕挪到日志,把用户能看到的东西切成一句没信息量的安抚。
// 生产环境入口:先把错误从屏幕上关掉
ini_set(‘display_errors’, ‘0’);
ini_set(‘log_errors’, ‘1’);
ini_set(‘error_log’, ‘/var/log/app/php-error.log’);
// 未捕获的异常交给我们自己处理,不直接抛到前端
set_exception_handler(function ($e) {
error_log($e->getMessage()); // 详情只进日志
http_response_code(500);
echo ‘系统开了小差,请稍后重试。’; // 用户看到的,没有栈、没有路径
});
关键是分工:错误进日志,不进屏幕;用户看到的是一句没信息量的安抚,不是你的内部地图。
关掉显示错误之后,记得给用户准备一个真的错误页,而不是一句裸文字。框架一般都有 error 视图,把 500 渲染成带样式的页面,既体面,也避免把异常信息当兜底文案顺手 echo 出去。顺手把 Server 响应头里的版本号也摘掉:php.ini 里 expose_php = Off,省得攻击者一眼认出你跑的是哪个 PHP 版本、对应哪批已知漏洞。暴露版本号没有任何收益,只有风险。
我见过更蠢的写法——有人为了”友好”,把异常整个 echo 出来,连 $e->getTraceAsString() 都甩前端了。这等于把家门钥匙挂在门把手上,还贴了张纸条写”钥匙在这儿”。
php.ini 里 display_errors 要设 Off,这是底层保证。但别只信配置:框架层面再兜一层 set_exception_handler,保证任何漏网的异常都不会裸奔到用户面前。
日志写到文件,路径放在 Web 根目录之外。别写到项目 public 目录下,否则攻击者直接 GET /error.log 就把你的错误历史全下载了,里面说不定还夹着上次的数据库账号。
异常里也不要夹带敏感上下文。有人习惯 throw new Exception(“登录失败,用户 {$username} 密码错误”),密码虽然没写进去,但用户名、手机号这类也算。前端该看到的是”登录失败”,至于为什么失败,查你自己的日志去。
信息泄露是沉默的。它不让你今天宕机,它让你的攻击者省了三天侦察。
03文件上传安全
文件上传是高频高危点,没有之一。
几乎所有业务都躲不开:头像、附件、证书、营业执照扫描件。它危险,是因为它同时踩中两个雷区——你能往服务器写文件,还能诱使服务器去执行它。
第一道雷:校验。最蠢的校验是只信 Content-Type。那是请求头里的一个字段,攻击者用 curl 改一下就行,image/png 改成 application/x-php,后端一脸信任地收下了。所以真实 MIME 要靠 finfo 去看文件本身的字节,而不是听请求头自报家门。
// 校验真实类型,而不是信请求头里的 Content-Type
$finfo = new finfo(FILEINFO_MIME_TYPE);
$realType = $finfo->file($_FILES[‘avatar’][‘tmp_name’]);
$allowed = [‘image/jpeg’ => ‘jpg’, ‘image/png’ => ‘png’];
if (!isset($allowed[$realType])) {
http_response_code(400);
exit(‘文件类型不被接受。’);
}
// 随机新名字,绝不用用户给的文件名
$ext = $allowed[$realType];
$newName = bin2hex(random_bytes(16)) . ‘.’ . $ext;
// 存到 Web 根目录之外,且目录不可执行
$dest = ‘/var/app/uploads/’ . $newName;
move_uploaded_file($_FILES[‘avatar’][‘tmp_name’], $dest);
第二道雷:文件名。不要保留用户给的原始文件名。它可能是 ../../config.php,路径穿越;可能是长串特殊字符,触发解析歧义;可能是和你现有文件重名,覆盖掉。
用 bin2hex(random_bytes(16)) 生成一段谁也猜不出来的新名字,扩展名只从白名单里取。随机名还有一个好处:攻击者没法通过猜文件名来访问他刚传上去的 shell。
第三道雷:存哪儿、能不能执行。上传目录不要放在 Web 根目录里。放进去,意味着用户能直接用 URL 访问到。即便你校验了扩展名,某些服务器配置下 .jpg 也可能被当脚本解析。存到根目录之外,再单独开一条鉴权过的下载接口读它,永远不要让上传目录有执行权限。
图片还要二次处理。即便 finfo 说它是 PNG,也别全信——有些包裹了恶意载荷的图片文件,类型探出来还是合法图片。用 GD 或 Imagick 重新采样一遍,再存盘,顺手把藏在外壳里的脏东西洗掉。
还有个隐藏点:上传接口本身的鉴权。很多团队把文件校验写得密不透风,却忘了这个接口要不要登录——匿名用户也能传,那前面的白名单等于在给陌生人发入场券。上传和写入一样,默认就要鉴权,除非业务明确要求公开。
上传这块没有巧劲。白名单、真实校验、随机名、根外存储、二次渲染,五件事一件都不能省。省一件,就是给攻击者留了一道门。
04安全响应头
安全头是那种”不出事你感觉不到它,出了事你才后悔没配”的东西。
它们不拦漏洞,它们在漏洞被利用时兜底。最典型的是 CSP。XSS 这东西,再小心的人也可能漏一次。你转义了输出,框架也转义了,但某个老接口用了 echo 直接吐 JSONP,或者某个富文本字段忘了过净化器——洞就来了。CSP(Content-Security-Policy)就是最后一道墙:你告诉浏览器,脚本只准从哪来加载,别的地方的一律不执行。
// CSP 是 XSS 的最后一道兜底:就算有注入,脚本也加载不出来
header(“Content-Security-Policy: default-src ‘self’; img-src ‘self’ data:; object-src ‘none'”);
// 加密访问至少撑一年
header(“Strict-Transport-Security: max-age=31536000; includeSubDomains”);
// 不让浏览器按内容猜类型
header(“X-Content-Type-Options: nosniff”);
// 防点击劫持
header(“X-Frame-Options: DENY”);
header(“Content-Type: text/html; charset=utf-8”);
逐条说。default-src ‘self’ 把脚本、样式、连接的默认来源锁成同源,外链 CDN 的脚本直接被浏览器拒绝执行——攻击者注入的 script 即便进了 DOM,也跑不起来。object-src ‘none’ 关掉插件类执行,老式 XSS 借 Flash 的路子直接堵死。
HSTS 那行管的是传输层。它命令浏览器之后一年里,对这个域名只能用 HTTPS。中间人想降级到 HTTP 窃听,浏览器不配合。前提是你的 HTTPS 本身没问题,HSTS 只是把”必须加密”这件事写死。
X-Content-Type-Options: nosniff 一句话:别自作聪明猜我的内容类型。某些浏览器看到 .jpg 里其实是 HTML,会”贴心”地按 HTML 渲染,nosniff 把这个行为关掉,避免类型混淆导致的执行。
X-Frame-Options: DENY(或更细的 CSP frame-ancestors)管点击劫持。它不让别人把你的页面塞进 iframe 里,叠一层透明按钮骗用户点。OWASP Secure Headers Project 对这些头的推荐取值有完整清单,配之前值得对着看一遍。
这些头都是用 header() 在输出最前面设的,且必须在任何正文输出之前调用,否则 PHP 会报 headers already sent。很多项目把它集中放在一个前置中间件里,统一发,省得每个控制器各写一遍漏掉。
安全头不性感。但它让一个本可能变成事故的 XSS,变成屏幕上什么都没发生。
还有两个头常被忽略。Referrer-Policy: no-referrer 控制跨站跳转时带不带来源地址,避免 URL 里的 token 跟着跳走;Permissions-Policy 关掉你用不到的浏览器能力,比如摄像头、麦克风、地理定位——用不到就别让页面能申请。它们不是主菜,但属于”顺手设了不吃亏”的那一类。
05日志与审计
很多团队日志记了一大堆,真出事要追溯时,一句有用的都掏不出。
问题不在”没记”,在”记了等于没记”。审计日志要回答三个问题:谁,在什么时间,干了什么。少一个,这条日志在追溯时就是半截话。登录、改权限、删数据,这类动作必须有专门的可追溯记录,不能混在普通 debug 日志里被滚动覆盖掉。
// 审计日志:时间、操作人、动作、来源 IP 都要有,且过滤敏感字段
function audit_log($userId, $action, $payload) {
$record = [
‘ts’ => date(‘Y-m-d H:i:s’),
‘user_id’ => $userId,
‘action’ => $action,
‘ip’ => $_SERVER[‘REMOTE_ADDR’] ?? ‘unknown’,
// 敏感字段绝不落盘
‘detail’ => json_encode(array_diff_key($payload, [‘password’ => ”, ‘token’ => ”])),
];
file_put_contents(‘/var/log/app/audit.log’, json_encode($record) . PHP_EOL, FILE_APPEND);
}
时间、操作人、动作、来源 IP,这四样缺一不可。来源 IP 很重要——同样是”删除订单”,来自管理员常用 IP 和来自一个陌生境外 IP,性质完全不同。
但日志也有红线:不能被用户污染。有人图省事,直接把用户提交的整个请求体 json_encode 写进日志。攻击者就往请求里塞一段看起来像日志、实则是另一条”伪造记录”的内容,你的审计轨迹里从此多了一条假的”管理员操作”。写日志时,字段要你自己定义,值要你自己取可信来源,绝不能把用户能控制的字符串原样塞进去当结构化字段。
另一条红线:别往日志写密码。这是个老笑话了,但真的有人把登录接口的明文密码顺手记进 debug 日志,日志文件又被广泛可读——于是密码库和日志库是同一个文件。审计里该记的是”谁在何时尝试登录、成败如何”,绝不该是那串密码本身。上面的 array_diff_key 就是在落盘前把 password、token 这类键剔掉。
日志还要防篡改。存到独立位置,权限收紧,最好带个不可篡改的归档。不然攻击者进来第一件事就是把审计日志里自己的行删了——你连”谁干的”都证明不了。
审计日志还要求留存期够长。追一个被慢慢挪用的权限,可能要翻三个月前的记录。留存太短,等你想查时那段已经滚没了。具体留多久按业务和合规要求定,但别默认一周就清。
能追溯,是工程化安全和”能跑就行”的分水岭。系统出过事不可怕,可怕的是出了事你连 replay 都 replay 不出来。
06工程化不是炫技,是规矩
工程化安全不是炫技。
它是那些看起来不性感的规矩堆起来的:依赖要审计、错误要关屏、上传要白名单、头要配齐、操作要留痕。每一件单独拿出来,都平平无奇。
组合起来,效果是两个字:没人盯的时候,它也不出错。
我见过太多项目,功能写得漂亮,上线跑得欢,然后半夜被叫醒。叫醒它的不是某个高深的 0day,是 display_errors 没关、是 composer.lock 没提交、是上传目录能执行、是安全头一行没设、是关键操作查无记录。
这些东西没有一件需要多高明的手艺。它们只需要你上线前,多花十分钟,把规矩立住。
炫技的安全会过时。规矩不会。
规矩之所以是规矩,是因为它不依赖谁当晚清醒。你状态好时写的防护,和状态差时写的防护,应该长得一样——因为那是流程逼出来的,不是灵感赏的。把防护写进流程,半夜叫醒你的概率就降一截。
ONE LINE
让系统在你睡着的时候也别出声,靠的就是这些不性感的规矩。
参考来源
· OWASP Top 10:2021(OWASP 官方十大 Web 应用安全风险分类,2021 版):A08 软件与数据完整性故障、A09 安全日志与监控失效等条目,对应本文供应链与审计两块
· OWASP Secure Headers Project(OWASP 官方安全响应头项目):给出 CSP、HSTS、X-Content-Type-Options、X-Frame-Options 等响应头的定义与推荐取值
· Composer 官方文档(getcomposer.org):composer audit 命令的用法与 composer.lock 的语义(锁定精确依赖版本、保证可复现安装)
· PHP 官方手册(php.net):Fileinfo 扩展与 finfo 类的真实 MIME 探测、random_bytes 安全随机字节生成、以及 header() 设置响应头的说明
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:船山信安 Sink
Sink《写得对,更要写得稳:PHP 工程化的安全实践》