
前言防注入这三个字在 PHP 社区里被讲了二十年但线上事故依然层出不穷。原因不是大家不知道要防而是很多人照着一条错误的规则在做——那条规则叫过滤所有输入。看这段极其常见的代码?php // ❌ 这段代码在很多老项目里都能找到 foreach ($_POST as $k $v) { $_POST[$k] htmlspecialchars(addslashes(trim($v))); }它一次性踩了三个坑addslashes()对 MySQL 的注入防御是无效的尤其是GBK编码下的宽字节注入htmlspecialchars()用在输入侧会导致数据库里存着amp;#39;这样的双重转义内容前端展示时全是乱码符号而真正危险的$_GET、$_COOKIE、$_FILES、php://input一个都没过滤。正确的思路只有一句话输入不做过滤只在使用点按该位置的上下文做转义。数据进数据库 → 用预处理语句prepared statement让驱动去转义数据进 HTML → 输出时用htmlspecialchars()数据进 shell 命令 → 用escapeshellarg()数据进 HTTP 头 → 拒绝所有换行符。同一个值在不同的使用点需要不同的转义方式所以不存在在入口统一过滤一次就安全了这回事。本文按注入有哪几类 → 每一类在 PHP 里怎么防 → 常见坑展开所有代码基于 PHP 8.x主要示例标注了所需的最低版本。一、注入的六个面先分清楚敌人在哪注入不是一个漏洞而是一类漏洞的共同模式把不可信数据当成了代码或结构来解析。按解析器不同分六类类型解析器典型 payload防御手段SQL 注入数据库 OR 11 --预处理语句 参数绑定XSS跨站脚本浏览器 HTML 解析器script.../script输出时htmlspecialchars()命令注入shell; rm -rf /escapeshellarg() 参数数组路径穿越文件系统../../etc/passwd白名单 realpath()校验头注入HTTP 响应头\r\nSet-Cookie: ...过滤 CR/LF反序列化unserialize()构造的对象链改用 JSON禁用unserialize关键认知表单提交的数据会流向所有这些位置。一个$_POST[keyword]可能同时被拿去查数据库、拼进日志、显示在页面上、作为文件名保存上传文件。这四处需要四种不同的处理所以统一过滤从原理上就不可能成立。下面按最常见的两种SQL 注入和 XSS讲透其余几种给出现成写法。二、SQL 注入唯一正确的方式是参数绑定2.1 为什么addslashes()和mysqli_real_escape_string()不够addslashes()只是把\和 NUL 前面加反斜杠。它不知道数据库连接的字符集所以在多字节编码下会被绕过数据库使用 GBK 编码时攻击者提交 %bf%27即字节 0xBF 0x27 addslashes 看到 0x27在前面插入 0x5C\ 变成 0xBF 0x5C 0x27 但在 GBK 里0xBF 0x5C 是一个合法的双字节汉字縗 于是那个转义反斜杠被吃掉了0x27 依然是裸露的单引号 → 注入成功mysqli_real_escape_string()会读取连接的字符集所以在连接建立之后、且字符集被正确设置为utf8mb4时它是安全的。但它的安全性依赖于三个前提同时成立连接已建立、字符集已正确设置、且所有字符串都经过了它。只要有一处漏了就前功尽弃。而参数绑定没有这个依赖记忆力的问题。2.2 PDO 预处理正确姿势与三个必须改的默认值?php declare(strict_types1); /** * PDO 连接与安全查询 * 运行环境PHP 8.1 */ function makePdo(): PDO { $dsn mysql:host127.0.0.1;port3306;dbnameshop;charsetutf8mb4; $pdo new PDO($dsn, app, secret, [ // 1) 让 PDO 抛异常而不是返回 false —— 否则出错时会静默继续 PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, // 2) 关闭模拟预处理见下文详解使用 MySQL 服务端的真预处理 PDO::ATTR_EMULATE_PREPARES false, // 3) 关闭字符串化的取回结果保证类型正确 PDO::ATTR_STRINGIFY_FETCHES false, // 关闭持久连接避免连接状态在请求间泄漏 PDO::ATTR_PERSISTENT false, ]); return $pdo; } $pdo makePdo(); // ✅ 位置占位符 $stmt $pdo-prepare(SELECT id, name FROM users WHERE email ? AND status ?); $stmt-execute([$email, 1]); $rows $stmt-fetchAll(PDO::FETCH_ASSOC); // ✅ 命名占位符可读性更好推荐 $stmt $pdo-prepare( SELECT id, name FROM users WHERE email :email AND status :status ); $stmt-execute([:email $email, :status 1]); // ✅ 需要整数时显式绑定类型避免驱动把它当字符串 $stmt $pdo-prepare(SELECT * FROM orders WHERE user_id :uid LIMIT :limit OFFSET :offset); $stmt-bindValue(:uid, $userId, PDO::PARAM_INT); $stmt-bindValue(:limit, $pageSize, PDO::PARAM_INT); $stmt-bindValue(:offset, $offset, PDO::PARAM_INT); $stmt-execute();关于PDO::ATTR_EMULATE_PREPARES这是本文最重要的一条。MySQL 驱动的默认值是true也就是模拟预处理——PDO 在 PHP 侧把参数拼进 SQL 字符串再做转义而不是交给 MySQL 服务端去处理。这带来两个后果LIMIT ?会失败。模拟模式下绑定的值被当作文本来拼SQL 变成LIMIT 10MySQL 直接报语法错误。这是明明代码没错但就是报错的经典来源。多字节编码下的转义理论上仍有被绕过的风险。关闭模拟预处理参数根本不会进入 SQL 文本而是走 MySQL 的二进制协议单独传输从原理上消除了这一类风险。2.3 什么位置不能绑定参数表名、列名、ORDER BY的字段、ASC/DESC这些是 SQL 的结构不是数据。参数绑定只能绑定值不能绑定结构。这是很多人的知识盲区于是写出了这样的代码?php // ❌ 列名不能用占位符绑定执行时必然报语法错误 $stmt $pdo-prepare(SELECT * FROM orders ORDER BY :column :direction); $stmt-execute([:column $_GET[sort], :direction $_GET[dir]]);而且更危险的是有些人发现绑定不成就改成直接拼接?php // ❌❌ 这是货真价实的 SQL 注入漏洞 $sql SELECT * FROM orders ORDER BY {$_GET[sort]} {$_GET[dir]};正确做法是白名单映射?php declare(strict_types1); // 运行环境PHP 8.1 /** * 把用户输入的排序字段映射到真实列名。 * 用户输入永远只作为数组的键出现绝不进入 SQL 字符串。 */ final class OrderQuery { /** 允许排序的字段白名单对外名 真实列名 */ private const array SORTABLE [ created o.created_at, amount o.total_amount, status o.status, ]; private const array DIRECTIONS [ asc ASC, desc DESC, ]; public function __construct(private readonly PDO $pdo) {} /** * return arrayint, arraystring, mixed */ public function list( int $userId, string $sort, string $direction, int $pageSize, int $offset, ): array { // 命中白名单就用映射后的列名没命中用默认值 $column self::SORTABLE[$sort] ?? self::SORTABLE[created]; $dir self::DIRECTIONS[strtolower($direction)] ?? DESC; // 到这里 $column 和 $dir 一定是代码里写死的常量 // 用户输入已经不可能影响 SQL 结构 $sql SELECT o.id, o.total_amount, o.created_at FROM orders o WHERE o.user_id :uid ORDER BY {$column} {$dir} LIMIT :limit OFFSET :offset; $stmt $this-pdo-prepare($sql); $stmt-bindValue(:uid, $userId, PDO::PARAM_INT); $stmt-bindValue(:limit, $pageSize, PDO::PARAM_INT); $stmt-bindValue(:offset, $offset, PDO::PARAM_INT); $stmt-execute(); return $stmt-fetchAll(PDO::FETCH_ASSOC); } }判断标准很简单拼接进 SQL 字符串的那个变量它的取值是否只能来自你代码里写死的列表只要答案是否就是注入点。三、XSS在输出点转义不是输入点3.1htmlspecialchars()的参数必须写全?php declare(strict_types1); // 运行环境PHP 8.1 /** * HTML 上下文转义 */ function e(?string $value): string { return htmlspecialchars( $value ?? , ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, UTF-8 ); } // 使用 echo p, e($user[nickname]), /p; echo input value, e($user[nickname]), ;三个参数各自的意义参数作用不写会怎样ENT_QUOTES同时转义单引号和双引号单引号包裹的属性可以被闭合ENT_SUBSTITUTE非法 UTF-8 序列替换成UFFFD遇到非法字节会返回空字符串页面内容凭空消失UTF-8指定字符集默认值依赖default_charset改了 ini 就可能出现不一致ENT_HTML5按 HTML5 规则转义某些实体名输出不符合 HTML5关于默认值的版本变化这件事必须点明在 PHP 8.1 之前htmlspecialchars()的默认 flags 是ENT_COMPAT不转义单引号PHP 8.1 起默认值改成了ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401。这意味着同一段htmlspecialchars($v)的代码在 7.4 和 8.1 上行为不同——升级 PHP 后页面里突然出现#039;就是这件事造成的。从 7.4 升级到 8.1 的项目务必把 flags 显式写出来不要让默认值决定安全属性。3.2strip_tags()不是 XSS 防御?php // ❌ 这些都不是 XSS 防御 strip_tags($html); // 用黑名单思路绕过手法极多 preg_replace(/script.*?\/script/is, , $html); // 正则过 HTML必被打穿 htmlentities($html); // 只转义有对应实体的字符需配合 flags 才完整strip_tags()的绕过手法包括但不限于img srcx onerroralert(1)这类不带尖括号闭合的事件属性、属性里的javascript:协议、以及被切断后重新拼合的标签。只要你不允许富文本就一律在输出时htmlspecialchars()不要做先清理再输出的两段式处理。如果业务确实需要允许部分 HTML比如评论区的加粗和链接正确的做法是白名单 成熟库先用DOMDocument解析成 DOM 树遍历节点只保留白名单内的标签和属性再序列化回去。这比任何正则都可靠。3.3 靠Content-Type而不是靠过滤来防脚本执行上传文件的目录、用户可控的响应体都要配好响应头?php // 让浏览器不要猜测 MIME 类型 header(X-Content-Type-Options: nosniff); // 给页面设置 CSP是 XSS 的最后一道防线 header(Content-Security-Policy: default-src self; script-src self);nosniff这一行尤其重要上传一个内容是 HTML 的.jpg如果没这个头老版本浏览器可能把它当 HTML 执行直接就是存储型 XSS。四、其余四类现成写法?php declare(strict_types1); /** * 各类使用点的安全处理 * 运行环境PHP 8.1 */ // ---------- 1) 命令执行用数组形式不用字符串拼接 ---------- // ✅ 最安全参数数组PHP 不做 shell 解析 $result shell_exec_safe(/usr/bin/convert, [ $userFile, -resize, 800x600, -quality, (string) $quality, $outputFile, ]); /** * param liststring $args */ function shell_exec_safe(string $binary, array $args): string { // escapeshellarg 会处理每个参数包裹引号并转义其中的引号 $cmd escapeshellarg($binary); foreach ($args as $arg) { $cmd . . escapeshellarg($arg); } return (string) shell_exec($cmd); } // ❌ 绝对不要这样写 // shell_exec(/usr/bin/convert {$userFile} -resize 800x600 out.jpg); // ---------- 2) 文件路径防目录穿越 ---------- /** * 校验用户提交的文件名是否落在允许的目录内 */ function safePath(string $baseDir, string $userInput): string { // 先确认基目录本身的真实路径 $base realpath($baseDir); if ($base false) { throw new RuntimeException(基目录不存在); } // 关键点一basename() 剥掉所有目录部分 // ../../etc/passwd 会变成 passwd $name basename($userInput); // 关键点二拒绝隐藏文件和空名 if ($name || $name . || $name .. || str_starts_with($name, .)) { throw new InvalidArgumentException(非法文件名); } // 关键点三解析后必须仍在基目录内防符号链接绕过 $full realpath($base . DIRECTORY_SEPARATOR . $name); if ($full false || !str_starts_with($full, $base . DIRECTORY_SEPARATOR)) { throw new InvalidArgumentException(路径越界); } return $full; } // ---------- 3) HTTP 响应头防头注入 ---------- function safeHeaderValue(string $value): string { // CR/LF 是头注入的核心没有它们就无法伪造新的一行头 if (preg_match(/[\r\n\0]/, $value) 1) { throw new InvalidArgumentException(头字段包含非法字符); } return $value; } // ---------- 4) 反序列化能不用就不用 ---------- // ❌ $data unserialize($_COOKIE[cart]); // 可构造对象链触发 __destruct // ✅ $data json_decode($_COOKIE[cart], true, 512, JSON_THROW_ON_ERROR); // 万不得已要用 unserialize 时必须限制可实例化的类 $data unserialize($raw, [allowed_classes false]); // 只允许标量和数组 // 或者指定白名单 $data unserialize($raw, [allowed_classes [Money::class, CartItem::class]]);unserialize()的第二个参数$options里allowed_classes是必须显式设置的。默认值true意味着攻击者可以实例化任意类配合代码里已有的__destruct()/__wakeup()就能触发POP 链Property-Oriented Programming面向属性编程实现任意代码执行。allowed_classes false能挡住 99% 的利用。五、filter_var()的正确用法与陷阱filter_var()是校验工具不是转义工具。它判断这个值是否合法不负责把这个值变安全。?php declare(strict_types1); // 运行环境PHP 8.1 // ✅ 校验邮箱格式 $email filter_var($_POST[email] ?? , FILTER_VALIDATE_EMAIL); if ($email false) { // 注意这里必须用 false 判断不能写 !$email throw new InvalidArgumentException(邮箱格式不正确); } // ✅ 校验整数并同时限定范围第三个参数是选项 $age filter_var($_POST[age] ?? , FILTER_VALIDATE_INT, [ options [min_range 1, max_range 120], ]); if ($age false) { throw new InvalidArgumentException(年龄不合法); } // ✅ 校验 URL注意FILTER_VALIDATE_URL 会接受 javascript: 协议 // 如果要防 XSS 必须额外检查协议白名单 $url filter_var($_POST[site] ?? , FILTER_VALIDATE_URL); if ($url false || !in_array( strtolower((string) parse_url($url, PHP_URL_SCHEME)), [http, https], true )) { throw new InvalidArgumentException(URL 不合法); } // ❌ 这个用法是错的FILTER_SANITIZE_* 系列不能当 XSS 防御 // $clean filter_var($input, FILTER_SANITIZE_STRING); // 该过滤器已在 PHP 8.1 弃用FILTER_VALIDATE_INT的返回值有三种可能合法的整数、false不合法、以及合法值0。所以if (!$age)会把合法的0当成失败——必须用 false。常见坑点1. 用addslashes()防 SQL 注入❌$name addslashes($_POST[name]); $sql SELECT * FROM u WHERE n$name;—— 数据库若是 GBK 等多字节编码0xBF 0x27这类字节序列能吃掉转义反斜杠实现宽字节注入。 ✅$stmt $pdo-prepare(... WHERE n ?); $stmt-execute([$name]);。2. 把htmlspecialchars()用在输入侧❌$_POST array_map(htmlspecialchars, $_POST);然后在页面里再htmlspecialchars()输出一次 —— 数据库里存的是amp;#39;页面上用户看到的是字面的#39;而且一旦要做全文搜索就完全对不上。 ✅输入原样存输出时转义。转义的次数必须恰好等于跨越信任边界的次数。3. 忘了PDO::ATTR_EMULATE_PREPARES的默认值是true❌ 用默认配置的 PDO写LIMIT :limit并绑定整数 —— 报SQLSTATE[42000]: Syntax error因为模拟模式下参数被拼成了字符串LIMIT 10。很多人以为是LIMIT不支持绑定。 ✅ 连接时设置PDO::ATTR_EMULATE_PREPARES false。这是 PDO 最值得改的一个默认值。4. 只过滤$_POST漏了其他输入源❌ 只处理$_POST$_GET、$_COOKIE、$_FILES[name]、php://inputJSON 请求体、以及$_SERVER[HTTP_*]全都没处理。 ✅ 把所有外部输入统一收敛到一个入口再分发。特别注意$_SERVER[HTTP_X_FORWARDED_FOR]、$_SERVER[REQUEST_URI]这类看起来是服务端变量、实际上完全由客户端控制的值。5. 用$_REQUEST取值❌$_REQUEST[id]—— 它合并了 GET、POST、COOKIE 三者的内容具体合并顺序取决于request_order这个 ini 配置不同环境可能不同。攻击者只要在 Cookie 里塞一个id就可能覆盖掉你期望的 GET 参数参数污染Parameter Pollution。 ✅ 明确写$_GET[id]或$_POST[id]永不用$_REQUEST。6.filter_var的返回值用!判断❌if (!filter_var($age, FILTER_VALIDATE_INT)) { 报错 }—— 用户输入0时filter_var返回合法值0!0为true于是合法的 0 被拒绝了。 ✅ 一律 false判断。这个错误在数量/页码/余额可以为 0的字段上一定会出问题且报错信息具有迷惑性。7. 用比较 CSRF Token 和密码哈希❌if ($_POST[csrf] $_SESSION[csrf])——会短路比较响应时间随匹配的前缀长度变化可被时间侧信道逐字节猜出。 ✅hash_equals($_SESSION[csrf], $_POST[csrf] ?? )。同理密码校验必须用password_verify()不要自己md5后比。8. 相信$_FILES[type]❌if ($_FILES[file][type] image/jpeg) { 允许上传 }—— 这个字段是客户端在 multipart 请求里自己填的随便改改成一个字符串就能绕过。 ✅ 用finfo读文件的真实类型(new finfo(FILEINFO_MIME_TYPE))-file($_FILES[file][tmp_name])并且重命名文件不要用用户提供的文件名、把上传目录设为不可执行。9. 认为用了 ORM 就安全了❌ 框架用 Eloquent / Doctrine于是放心地写$query-whereRaw(name {$name})—— 原生 SQL 片段完全绕过了 ORM 的参数绑定注入照旧。 ✅ 用 ORM 的查询构造器-where(name, $name)确需原生 SQL 时用它的绑定接口whereRaw(name ?, [$name])。注入点永远在字符串拼接那一行与用不用框架无关。总结数据流向正确做法绝不使用进 SQL 的值PDO 预处理 execute([...])addslashes、字符串拼接进 SQL 的结构列名/方向白名单映射任何形式的拼接进 HTMLhtmlspecialchars($v, ENT_QUOTES \ENT_SUBSTITUTE, UTF-8)进 shellescapeshellarg() 参数数组字符串插值进文件系统basename()realpath() 前缀校验直接用用户提供的路径进 HTTP 头过滤 CR/LF直接header($userInput)反序列化json_decode()必要时allowed_classes false默认参数的unserialize()令牌/哈希比较hash_equals()、密码存储password_hash()/password_verify()md5、sha1防注入的核心原则只有一条在数据跨越信任边界的那一刻按目标解析器的规则做转义同一个数据在不同边界处要分别处理绝不可以在入口处一次性搞定。把这句话落成可执行的工程约束就是三件事数据库访问层只暴露接受参数的接口禁止拼接 SQL 的写法通过代码审查视图层提供一个统一的e()函数作为唯一的输出出口所有 shell 调用一律走参数数组。只要这三条被强制执行忘记转义就从一个可能发生的疏漏变成了一个编译期就能被发现的错误。