资讯动态

PHP安全实践:从输入校验到会话认证的落地指南

发布时间:2026/10/15 6:16:17 来源:尧图企业网站定制
《PHP安全之道》我翻了两遍第一遍是走马观花第二遍才开始在真实项目里对照落地。这篇是系列笔记的第三篇前两篇主要讲了安全的核心原则——永远不要信任用户输入也梳理了常见攻击面比如XSS、越权这类逻辑漏洞。第三篇我想把实践里的高频区域集中扒一遍输入校验、注入防御、文件操作、会话认证这四个地方在真实代码里最容易出问题而且一出就是高危。内容不是目录式的知识点搬运我会把书里强调的思路和我实际改过的代码结合起来尽量给出可以直接抄回去用的写法。适合正在维护老项目的人适合刚转安全方向的PHP开发者也适合那些写完代码总觉得哪里不对但说不上来的同学。如果只是会写功能、没认真想过数据从哪来、到哪去、中间被谁碰过那这篇文章值得慢慢看。1. 先搞清楚PHP应用的安全边界在哪1.1 信任边界不是“用户输入”四个字这么简单很多人一说安全就只防$_GET和$_POST这是一个很容易踩的盲区。安全边界不应该按变量名划分而应该按“数据是否跨越了信任域”来划分。比如一个后台管理系统管理员录入的文章内容进数据库再到前端展示。这条链路里数据库结果对前端页面来说算不算可信在我看来不算。因为数据可能来自投稿接口、爬虫采集、导入工具任何写库的通道都可能被污染。只要数据从数据库加载到内存再被拼进SQL、HTML、命令、文件路径它就重新变成了“输入”边界必须重新过一遍。再举个例子缓存里的数据。如果往Redis里存了一段从用户侧提交的JSON之后某个定时任务把它读取出来拼接成命令那攻击者等于找到了一个间接入口。这比直接打主入口更难发现。所以我在实际做代码审计的时候不管项目动不动手重构都会先画一张数据流图每个入口接收什么、存到哪、之后在哪里被读取使用。这张图里标出来的每一个跨域点都是校验和过滤的位置。别只在用户请求入口做事那样等于只在小区大门口查了一次身份证后面每个楼栋都不设防。1.2 用一张表列出常见入口点与处理时机我整理了一张比较通用的表做项目安全评估时可以直接拿来对照输入来源典型风险处理原则$_GET/$_POSTSQL注入、XSS、参数污染入口白名单校验SQL一律预处理$_COOKIE会话固定、反序列化不信任其内容只作为会话标识$_FILES恶意文件上传、路径穿越扩展名白名单、随机文件名、目录隔离$_SERVER请求头伪造、路径伪造只取必要字段校验格式数据库/缓存结果存储型XSS、二次注入输出前编码重要操作重新校验外部API响应SSRF、数据投毒严格校验响应结构和类型注意这里的“处理原则”不是让你写一个全球通用的过滤器。htmlspecialchars不能替代SQL绑定htmlspecialchars也不能用在你校验身份证号的地方。正确的做法是分清时机输入校验看着“进入业务逻辑前”输出编码管着“进入HTML/JS/SQL前”。两者必须分开缺一不可。我在项目里见过最乱的代码就是有人把addslashes、htmlspecialchars、trim全都堆在入口似乎很安全但后面拼接时照样乱来。与其把过滤集中成一坨混合酱不如在每一个跨域点各设一道清晰的关卡。2. 输入校验不要写“看起来能用”的代码2.1 PHP弱类型下最容易被坑的校验逻辑PHP弱类型给开发带来便利也给安全埋了很多神坑。最经典的是比较运算。比如下面的判断if ($_GET[id] 1) { // do something }如果$_GET[id]传的是1abc在PHP 7里这个比较仍然成立。在PHP 8里字符串和数字比较的规则改了相对安全一些但如果你维护的老代码是PHP 5/7这种写法就是漏洞温床。更离谱的还有哈希比较陷阱当年一些人拿0e123456这类字符串去和md5结果做松散比较结果两边都被转成浮点数0等于直接绕过。我现在的习惯是凡是进入逻辑分支的标量参数先做严格的类型判断再决定是否转型$id $_GET[id] ?? null; if ($id null || !ctype_digit($id)) { throw new InvalidArgumentException(非法参数); } $id (int) $id;ctype_digit比is_numeric严格因为它只认十进制数字字符串1e3、 12这种都会被拒绝。这里强调“验证后再转型”而不是直接(int)$_GET[id]因为转型会把不可预期的输入强行变成可以继续往下走的数值反而掩盖了异常。宁可报错也不要给后续代码一个“看起来正常”的假数据。还有一个常见坑把数组值直接传给字符串函数。某些情况下trim($_GET[name])在传入数组时会触发警告甚至返回null在PHP 5时期这种问题经常造成逻辑绕过。处理办法是先检查类型if (!is_string($_GET[name])) { throw new RuntimeException(参数类型错误); }2.2 过滤器不是万能药白名单才是PHP的filter_var函数族很方便但别把它当万能药。FILTER_VALIDATE_EMAIL只能验证格式不代表邮箱真实存在FILTER_VALIDATE_URL对某些包含特殊字符的URL仍然会放行而且URL的语义安全比如是否是内网地址需要另外判断。对于业务上可控的枚举类型最好的校验方式是写死白名单const ALLOWED_TYPES [article, page, link]; $type $_GET[type] ?? ; if (!in_array($type, ALLOWED_TYPES, true)) { http_response_code(400); exit(Bad Request); }白名单校验的好处是你不需要穷举所有非法值只需要定义合法的集合。黑名单则相反你永远猜不全攻击者的变体。我见过不少人用黑名单过滤SQL关键字比如把select、union替换成空字符串结果攻击者输入selselectect过滤后反而变成了select。这就是黑名单的典型死法。所以遇到这类需求第一反应不该是“过滤掉什么”而是“只允许什么”。另外再强调一点htmlspecialchars是用来做输出编码的不是输入过滤器。把它用在入口处理用户输入会导致业务内层拿到的还是原始数据彻底失去保护如果数据已经入库将来在别的场景用别的方式输出照样可能出问题。过滤器选型必须先明确拦截点在哪里。2.3 校验失败的默认动作拒绝而不是“修正”很多刚接触安全的开发者在做校验时会陷入一个误区用户传了一个不合法的值我帮他改成合法的这样体验更友好。比如用户传了sortasc11就自动改成asc传了pageabc就改用第一页。这种“修正”反而制造了绕过空间因为你改完之后的逻辑是不可预知的。我自己在项目里的默认动作就两个字拒绝。非法的输入直接返回400记录日志然后结束请求。这样攻击者得不到任何反馈也不能通过试错找到过滤规则。以下这个模式很好用try { $data validateInput($_GET); } catch (InvalidInputException $e) { error_log(invalid input: . $e-getMessage()); http_response_code(400); exit; }当然对于用户误输入的场合做友好提示可以但底层逻辑必须是“拒绝”而不是“猜测用户的意图”。3. 注入防御预处理和参数绑定才是正道3.1 SQL注入的根因不是缺一个转义函数SQL注入本质上是用户输入被当成SQL语法结构执行了。所有“字符串拼接SQL”的方式都在制造这个风险哪怕变量前面加了一堆转义函数也不保险。老项目里常看到addslashes或者mysql_real_escape_string它们有一定的防护价值但受制于数据库字符集问题。比如历史上著名的宽字节注入攻击者传入%bf%27来绕过转义就是因为addslashes是不知道目标字符集和数据库如何解码的。只要编码链路出现一个不一致转义就失效。所以正确的选择是预处理语句。预处理会把SQL结构先编译好参数只是数据不再参与语法解析$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([$email]);这里把ATTR_EMULATE_PREPARES设成false让PDO尽可能使用MySQL原生预处理减少本地模拟的解析面。不过这条不建议无脑配置如果老项目用的字符集特殊或者数据库驱动不支持可能需要权衡。至少你要知道这两个模式的区别别一直用默认配置不知道自己组装了什么。还要注意如果你用的是查询构造器或者ORM别以为万事大吉。很多ORM只是把参数绑定封装了但如果你在数组条件里手动拼了类似field_raw xxx的表达式ORM的过滤链就不会保护你。所以一定要记得看ORM有没有提供“原生SQL”入口凡是用了原生入口的地方都必须走绑定参数的方式。3.2 命令注入和代码执行的边界命令注入比SQL注入更直接。反例到处都是比如这种$ip $_GET[ip]; system(ping -c 1 . $ip);用户传一个127.0.0.1; cat /etc/passwd命令就被改写了。PHP里提供了一堆转义工具escapeshellarg和escapeshellcmd但它们不是银弹。简单说下区别escapeshellarg是给单个参数加引号并转义引号适合包住一个参数escapeshellcmd是转义命令里的特殊字符。如果你对整条命令字符串用escapeshellcmd可能破坏业务参数中的正常引号escapeshellarg也不能防止参数内部的注入因为它只是让这个参数变成一个带引号的串如果命令拼接方式错了照样出问题。我的建议是三层叠加第一尽量不用系统命令能用PHP内置函数解决的就不要调用exec族第二必须调用时把参数用严格白名单校验而不是把原样字符串丢进去第三使用成熟的进程组件通过数组形式传参避免手工拼接命令字符串。另外老项目里要留意这几个危险点eval、assert、preg_replace配合废弃的/e修饰符、以及动态调用函数的写法。它们都可能导致代码注入。我习惯在审计时先全局搜索这些高危函数逐个确认参数来源是否是用户可控路径。尤其是项目开发时间跨越PHP 5/7的系统这类隐患一般都不少。3.3 其他注入类型LDAP、XPath、NoSQLSQL注入被讨论最多但注入并不只在SQL里存在。LDAP查询、XPath表达式、NoSQL查询、日志记录模板凡是“用户输入被拼接进某种查询指令/表达式结构”的地方都可能有同类问题。我总结了一个简单的判断方法问自己一句用户输入是作为“数据位”还是“指令位”被处理比如一个LDAP搜索里用户输入的登录名如果直接拼进(uid$user)那它就成了指令的一部分攻击者可以用一个合并的过滤器绕过认证。正确的做法是把用户输入和查询结构彻底分开用SDK提供的转义或绑定机制。NoSQL也不能幸免。MongoDB的查询接口如果接收的是JSON用户输入攻击者传入{$ne: }这种操作符就能绕过等值判断。所以在接口层要先限制字段集合不允许用户任意指定查询操作符。安全工作的一个很大部分就是把“指令位”的权限牢牢关起来不给外部数据一点机会。4. 文件上传与文件操作最容易被玩出花的入口4.1 文件上传校验的迷思扩展名黑名单为什么挡不住文件上传几乎是每个Web应用都有的功能也是被攻击者重点关注的地方。常见的错误写法是把防御建立在黑名单上比如禁止.php、.exe。但黑名单永远是不完整的.php3、.phtml、.pht、.phar还有Windows下的大小写、末尾空格、点号等都能绕过部分配置不当的服务器解析规则。在老的Apache环境里一个文件名叫evil.php.png都可能被某种配置解析成PHP。如果上传目录又是可执行脚本的目录攻击者只需要上传一个包含一句话木马的图片就能直接拿到webshell。我现在的做法是四条腿走路第一扩展名白名单。只允许图片、PDF、压缩包等业务真实需要的类型不要为了“万一用户要上传别的”就把开关打开。白名单用数组写死$ext strtolower(pathinfo($filename, PATHINFO_EXTENSION)); if (!in_array($ext, [jpg, jpeg, png, gif, pdf], true)) { throw new RuntimeException(不支持的文件类型); }第二生成随机文件名。不要沿用用户的原始文件名因为它可能包含路径分隔符、Unicode特殊字符或者被攻击者用来触发路径穿越。用bin2hex(random_bytes(16)) . . . $ext重新生成放在专门的存储目录不直接把用户文件名写入数据库和磁盘。第三上传目录禁止执行脚本。在Apache里可以配置RemoveHandler在Nginx里用location ~* \.(php|php3|phtml)$ { deny all; }或者干脆把上传文件放到Web根目录之外的目录由另一个下载脚本统一读出来。第四对图片类文件尽量再做一次二次渲染。用GD或Imagick重新生成图片这样文件里夹带的非图像代码会被清除。此外$_FILES[file][type]是客户端可以伪造的不要拿它做判断依据getimagesize可以防一部分伪造但也不能百分百保证。多层校验的目的是让攻击者很难同时绕过所有层。4.2 路径穿越与文件包含的坑文件操作另一个高频漏洞是路径穿越。用户传入文件名后直接拼接路径比如$path /data/files/ . $_GET[file]; file_get_contents($path);如果传入../../etc/passwd就能读到不该读的文件。对这种场景我习惯先做一次路径规范化再检查最终路径是否落在允许目录内$baseDir realpath(/data/files/); $path realpath(/data/files/ . basename($_GET[file])); if ($path false || strpos($path, $baseDir) ! 0) { http_response_code(400); exit; }注意realpath要求文件必须存在否则返回false。对于不存在的文件你还要先处理目录解析。另外basename能去掉路径中的目录部分但这是第一层不能完全替代realpath。文件包含漏洞也很常见。include $_GET[page];这种写法直接给了攻击者读取文件甚至执行代码的能力。PHP里还有落后的远程文件包含一旦服务器开了allow_url_include攻击者就可以通过一个远程URL直接包含恶意代码。所以第一步是确认配置allow_url_include Off而且尽量让allow_url_fopen也保持收紧状态。但如果业务确实需要动态加载页面应该用白名单映射而不是接收文件名$pageMap [ home /srv/www/pages/home.php, about /srv/www/pages/about.php, ]; $page $_GET[page] ?? home; if (!isset($pageMap[$page])) { http_response_code(404); exit; } include $pageMap[$page];这种做法的核心是业务代码只认识逻辑名不认识文件系统路径。即使攻击者知道服务器上有某个敏感文件他也无法通过输入路径来指名加载。4.3 Phar反序列化看起来是文件操作实际是代码执行入口还有一个容易被忽略的坑是Phar反序列化。很多开发者知道不能对用户输入调用unserialize但不知道phar://协议在文件操作函数里也能触发对象反序列化。攻击者如果能让目标应用对某个恶意构造的.phar文件执行file_exists、is_file、getimagesize、fopen等操作这个.phar文件里打包的metadata就会被反序列化从而有机会形成PHP对象注入攻击。为了利用该特性攻击者通常还需要利用一个文件上传点或者某个能控制路径的参数。防御的思路比技巧更简单不要接受用户控制的phar://路径。在业务代码里对所有文件路径做前缀白名单禁止协议包装器渗透到文件操作函数。另外如果你确实需要反序列化数据一定不要直接反序列化整个用户输入设置allowed_classes白名单能大大降低攻击面$data unserialize($serialized, [allowed_classes [App\\Models\\User]]);即使这样也必须要求该序列化串来自可信来源而不是HTTP参数。5. 会话与认证别把大门钥匙放在脚垫下5.1 Cookie与会话配置是底线工作一个Web应用就算逻辑全部正确Cookie配置乱设也会毁掉整条防线。会话ID是身份的重要凭证如果Cookie可以被JavaScript读取一个存储型XSS就能偷走会话如果Cookie没有Secure标记在HTTP流量里传输时也可能被截获如果SameSite不设置跨站请求伪造就会更容易得手。PHP里可以通过session_set_cookie_params在启动会话前统一设置session_name(SESSID); session_set_cookie_params([ lifetime 0, path /, domain , secure true, httponly true, samesite Lax, ]); session_start();SameSite有Lax和Strict两个主要选项。对绝大多数应用Lax足够它允许用户从外站点击链接进入站点时保持会话但跨站POST请求不会自动携带Cookie对CSRF有不错的缓解效果。如果业务场景更敏感可以上Strict不过要注意这会略微影响从外链跳转时的登录状态。还需要特别注意会话固定攻击。登录成功后必须重新生成会话IDsession_regenerate_id(true);很多老项目登录前后不重置会话ID攻击者可以先拿一个未登录的会话ID诱导用户使用并登录随后再利用这个会话ID冒充用户。重置ID这一步省不得否则等于把大门钥匙直接放在脚垫底下。5.2 密码哈希与安全比较的艺术密码存储是认证体系里最容易被嘲笑的点因为至今仍能见到用md5($password)存密码的项目。这种哈希无盐、速度极快GPU撞库轻而易举。正确做法是使用PHP内置的password_hash和password_verify$hash password_hash($password, PASSWORD_DEFAULT); // 验证 if (password_verify($password, $hash)) { // success } elseif (password_needs_rehash($hash, PASSWORD_DEFAULT)) { // 顺手升级哈希 }password_hash自动生成随机盐并且把算法、盐、哈希值全部封装在返回串里不需要你操心盐的存储和拼装。password_verify会自行从哈希串里读取这些信息完成校验。对于老系统里的md5哈希可以设计一个迁移流程登录时先验证旧哈希通过后用password_hash重新生成哈希并更新数据库。登录提示也要注意账号不存在和密码错误应该返回同一个提示别告诉攻击者“这个用户存在”。否则等于帮别人做账号枚举。我见过很多登录页会把“用户名错误”和“密码错误”分开写这种“友好”设计在安全上是灾难。验证密码时如果用其他散列算法做签名比较要使用hash_equals而不是避免时序攻击if (hash_equals($expectedSignature, $givenSignature)) { // pass }hash_equals会保证两条字符串比较耗时基本一致攻击者很难通过响应时间推断正确签名。5.3 CSRF令牌随手写但别写错跨站请求伪造最容易发生在“登录用户本不想发起却被迫发起”的场景。一个经典的例子用户在其他页面嵌入了img srchttp://example.com/transfer?toattackeramount1000浏览器自动发起GET请求把转账请求带上了登录Cookie。现在很多框架默认做了CSRF防护但如果你自己写代码或者维护老项目还是要清楚地知道怎么落地。一个简单可用的做法会话里保存一个随机令牌表单里输出令牌提交时校验令牌。if (empty($_SESSION[csrf_token])) { $_SESSION[csrf_token] bin2hex(random_bytes(32)); }输出到表单input typehidden namecsrf_token value? htmlspecialchars($_SESSION[csrf_token], ENT_QUOTES, UTF-8) ?提交时校验$token $_POST[csrf_token] ?? ; if (!hash_equals($_SESSION[csrf_token], $token)) { http_response_code(403); exit; }几个容易写错的地方不能用比较令牌令牌必须和用户会话绑定不能一个全局令牌所有人通用AJAX请求可以通过读取页面上meta标签里的令牌来获取不能因此就不设校验如果校验失败不要静默忽略要直接返回403。还有一个低级错误把“校验CSRF令牌”写在“登录状态更新”之后等于先改了会话再校验此时攻击者可以利用会话内包含的新令牌数据绕过部分逻辑。另外不要把登录密码本身当作CSRF令牌因为密码会出现在请求体里如果Web服务器日志或者第三方插件记录了请求体密码就泄漏了。CSRF令牌应该是一个独立的随机值。6. 写在最后几个我花了很久才养成的小习惯这本书读到第三遍我越读越觉得安全不是某个节点的补丁而是一整套贯穿开发过程的习惯。分享几个我后来在代码审计和项目里反复用到的习惯都不复杂但真的能救命。第一个习惯是拿到一个旧项目时先别急着看业务逻辑全局搜一次危险函数。我一般会搜$_GET、$_POST、$_COOKIE、$_REQUEST、eval、exec、system、include、require、unserialize、file_get_contents把结果逐条过一遍凡是参数直接传给危险函数的地方标记为高危。这样一次全貌扫描能让你快速找到所有可能被攻破的点而不至于陷在某个功能里出不来。第二个习惯是在新项目的开发脚手架上把安全约束前置。不要等代码写完了再回头检查而是在请求入口封装好统一的参数校验器、在模板引擎里默认开启输出转义、在数据库操作层强制使用预处理。这些基础设施如果一开始不搭后面想补的时候成本极高。前两次我修的漏洞绝大多数都是在“早期图方便、后期忘补漏”的代码里。第三个习惯是安全修复之后一定要留测试和记录。修完一个SQL注入我会写一个“攻击用例”跑一遍确认它不再生效修完一个CSRF漏洞我会写一个模拟跨站请求的测试确保后续迭代不会把修复给带崩。安全问题是会回归的没有测试守着三周之后很可能又是一个一模一样的洞。最后再分享一个小技巧在浏览器的开发者工具里养成看“发送出去的请求到底是什么样”的习惯。很多开发者在F12里只关心响应结果不关心请求行和请求体。而当你能一眼看出某个请求里藏着可控参数时你就已经站在安全视角看代码了。希望这篇笔记能给你的PHP开发或者审计工作带来一点实在的参考。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑