PHP安全加固:改造360防注入类,用PDO预编译替代转义
简介一套由360提供的PHP防SQL注入代码修改类面向PHP开发者和网站安全初学者用于抵御SQL注入及XSS、CSRF等HTTP跨站攻击。压缩包仅2个文件包含1份readme.md说明文档和1个params.php示例文件整体大小1KB结构精简便于直接阅读和嵌入现有项目。已有618人学习下载。文件中演示了输入过滤、SQL转义、预处理语句思维以及CSRF令牌生成等关键防御手法readme对类的初始化、调用方式和实际集成步骤给出了说明可帮助开发者快速掌握在数据库操作中阻断恶意SQL、在HTTP层防范跨站请求的安全实践。适合希望提升代码安全性、快速应用防护逻辑的PHP使用者。1. 360 的 PHP 防 SQL 注入类先看清楚它能挡住什么很多人把网上流传的 360 防 SQL 注入类直接拖进项目结果要么报get_magic_quotes_gpc不存在要么把正常表单里的全部洗成实体。这里先说结论这套老代码的价值不在那些addslashes和关键字拦截上而在“入口统一清洗”这个思想。它适合接手老项目、需要对历史代码做安全加固、又不想把业务逻辑全部重写的场景不适合当成现成扩展一样拉下来就用。真正可靠的方案是把它的入口过滤逻辑保留下来去掉 SQL 转义职责改造成一个配合 PDO 预编译使用的参数清洗类。下面把改法、参数和验证方法一次说清。2. 360 防注入类的核心原理把入口数据改写成“不危险”的形态2.1 网上流传的 360 类代码骨架包含三层过滤网上能见到的 360 防注入类大多是同一个思路不针对单个变量做过滤而是在入口处把$_GET、$_POST、$_COOKIE整体交给一个递归方法清洗。还原后的骨架通常长这样注意这是根据常见版本总结的写法不是某份官方原文。?php class Safe_360 { public $is_filter false; // 防止重复执行过滤 public function __construct() { $this-check(); } public function check() { if ($this-is_filter) { return; } if (! function_exists(filter_init)) { $this-filter_init(); } $this-clean($_GET); $this-clean($_POST); $this-clean($_COOKIE); $this-is_filter true; } // 递归清洗数组和字符串 public function clean($arr) { if (is_array($arr)) { foreach ($arr as $key $value) { if (is_array($value)) { $this-clean($arr[$key]); } else { $arr[$key] trim($value); $arr[$key] stripslashes($arr[$key]); if (!get_magic_quotes_gpc()) { $arr[$key] addslashes($arr[$key]); } $arr[$key] htmlspecialchars($arr[$key], ENT_QUOTES); } } } } protected function filter_init() { // 旧代码里的全局防护开关常用于拦截 select、union、sleep 等关键字 foreach ($_REQUEST as $k $v) { if (strpos(strtolower($v), select) ! false) { exit(sql inject); } } } }这段代码包含三个层面filter_init做关键字探测clean做递归 trim、去斜杠、转义最后用htmlspecialchars把输出型转义也塞进输入侧。它在 PHP 5.4 之前的环境里确实能挡住一批入门级注入但放到现代 PHP 里问题很明显get_magic_quotes_gpc()在 PHP 7.4 起就不存在了addslashes对数字型注入没有约束力输入侧做htmlspecialchars会让存进数据库的 JSON 或富文本全部变形。2.2 三个边界魔术引号、宽字节、数字型注入老 360 类把addslashes当保底这在数据库连接还停留在 GBK、且系统默认开启magic_quotes_gpc的年代是可以理解的。但现在再这样写会撞上三个边界条件。第一个边界是魔术引号函数已被移除PHP 7.4 之后get_magic_quotes_gpc直接报致命错误类在构造阶段就挂掉。第二个边界是宽字节注入当连接字符集是GBK时%bf%27这类载荷会让数据库把反斜杠当成宽字节的一部分单引号就此逃逸。addslashes只处理 ASCII 反斜杠对这类变长字符集无能为力。第三个边界是数字型参数where id$id这种场景不经过任何引号拼接addslashes等于没加只有intval或严格正则才能保证值是纯整数。旧类的关键字拦截在这里也失效因为id1 or 11完全可以不带引号。2.3 一张表看明白过滤层级与代价过滤层级处理方式适合场景误用代价L1 输入侧递归 trim、stripslashes统一去掉无关空白无法覆盖宽字节L2 转义侧addslashes / mysql_real_escape_string老项目兼容 MySQL 驱动有字符集绕过风险L3 检测侧关键字拦截 select/union/sleep应急入口拦截误杀用户输入的真实文本L4 强校验intval、ctype_digit、白名单数字型参数、枚举参数需要逐个字段声明类型L5 预编译PDO prepare bindValue所有 SQL 拼接仅适用于用到数据库的场景这张表里的关键在 L2 和 L5L2 是老 360 类的主战场L5 是最终归宿。修改的方向不是把旧类删掉而是让它回到 L1、L4 的位置把执行层的防护交给 PDO。3. 修改 360 类保留入口清洗去掉 SQL 转义职责3.1 改造后的版本一个不碰字符串转义的“入口清洗类”修改思路可以压缩成一句话把旧类里的addslashes、关键字拦截、htmlspecialchars全部拿走换成一个可配置的规则表。SQL 注入防护交给 PDO 绑定这个类只回答一个问题这个参数是不是该有的样子。?php class InputGuard360 { protected array $config; public function __construct(array $config []) { $this-config array_merge([max_len 255], $config); } // 从 $_POST 或 $_GET 按规则取值不接受 $_REQUEST避免 Cookie 混入 public function get(string $key, string $type string) { $source $_POST[$key] ?? $_GET[$key] ?? null; return $this-validate($source, $type); } protected function validate($value, string $type) { if ($type int) { return filter_var($value, FILTER_VALIDATE_INT) ? (int)$value : 0; } if ($type email) { return filter_var($value, FILTER_VALIDATE_EMAIL) ? $value : ; } if ($type array:int) { if (!is_array($value)) { return []; } return array_values(array_filter($value, is_numeric)); } if ($type deny) { return ; } return mb_substr((string)$value, 0, (int)$this-config[max_len]); } }调用方不再拿$_GET[id]直接拼 SQL改用$guard-get(id, int)拿值。validate按类型返回整数参数给不了合法数字就落到 0数组参数逐项过滤后重新索引deny类型直接清空。这个类不做任何转义也不改字符串内容max_len唯一要防的是超长参数拖垮日志或存储。注意它刻意不读$_REQUEST因为$_REQUEST默认包含$_COOKIE同名 key 会带来难以排查的覆盖问题。新旧职责对比如下职责旧 360 类修改后的 InputGuard360字符串转义addslashes不做输出转义htmlspecialchars交给输出层类型声明无每字段显式声明SQL 防注入关键字拦截交给 PDO 绑定入口入口清洗递归全量清洗按规则字段级取值3.2 改造后与 PDO 预编译的配合方式改造类本身不负责拼 SQL它只生产参数。下面是接入 PDO 的典型用法$guard new InputGuard360([max_len 64]); $id $guard-get(id, int); $tag $guard-get(tag, string); $stmt $pdo-prepare( SELECT * FROM article WHERE id :id AND tag :tag ); $stmt-bindValue(:id, $id, PDO::PARAM_INT); $stmt-bindValue(:tag, $tag, PDO::PARAM_STR); $stmt-execute();这里:id和:tag由 PDO 驱动在协议层做转义跟输入清洗互不干扰。bindValue显式写了PARAM_INT和PARAM_STR即使$id内容被篡改成1;drop tablePDO 也会按整数类型传递到不了数据库解析器。这正是“数据形态校验”和“数据执行安全”分开的意义InputGuard360管前者PDO 管后者。3.3 修改时最容易改坏的三个点第一把get()用到$_SESSION。旧类会递归清洗会话数组但$_SESSION里存对象时递归会把对象属性拆散调用__toString时得到的是被改过的值。所以新类默认只读$_GET和$_POST。第二数组参数的类型校验不能跳过。线上常见的报错来自strpos(strtolower($v), select)接到数组参数PHP 直接抛类型错误因为strtolower不接受数组。新类里array:int规则要先is_array再逐项过滤。第三配置被全局覆盖。框架中间件里如果把max_len设成 0所有字符串参数会全部被截断为空串这类问题在接入章的参数表里会再展开。4. 接入后端时给 360 防注入类配 5 个开关避开 3 个误用场景4.1 用配置数组一次性设定过滤开关改造类接入项目时常见做法是写一份规则数组每个字段声明自己的类型再统一从入口拉取值$guard new InputGuard360([max_len 128]); $rules [ page int, page_size int, q string, sort in:asc,desc, callback deny, ]; foreach ($rules as $field $type) { $GLOBALS[safe_input][$field] $guard-get($field, $type); }sort这种枚举字段不是直接交给validate的而是先在get()里做白名单比较只有asc或desc会被放行。callback字段直接拒绝防止 JSONP 跨域回调名里塞入alert(1)这种可执行片段。上传文件的校验不在这个类里跑$_FILES走的是 MIME 与文件头一致性检查跟 SQL 注入不是同一条防线。下面是 5 个最常见的配置开关配置项类型默认值作用max_lenint255字符串最大长度避免超长参数撑爆日志或存储trim_whitespacebooltrue自动去掉首尾空白方便后续比较use_sessionboolfalse是否让get()读取$_SESSION开启前确认无对象allowed_hoststring/nullnull校验 Referer 或 Origin跨域场景下作为辅助json_bodyboolfalse是否解析application/json请求体并应用同样规则json_body这个开关容易被忽略。PHP 的$_POST不会解析 JSON 请求体很多项目从php://input拉流后json_decode结果攻击者传一段 JSON 字符串照样能拼进 SQL。开启后要把解析出的数组交给与$_POST相同的校验规则再吐给业务层。4.2 常见误用场景一把关键字拦截当作注入防护的全部旧代码喜欢维护一份deny_words列表包含select、union、sleep、concat等。问题是数据库方言一直在变MySQL 支持/*!50000union*/注释变形PostgreSQL 支持pg_sleep(5)关键字列表很难跟全。在 PDO 预编译协议里参数若全部走绑定流程参数内容写成union select sleep(3)也只是一段普通字符串不会被解释成运算符。所以改造后的类里不保留任何关键字黑名单除非外网入口还有独立的 WAF 需要这一层做纵深防御。4.3 常见误用场景二把输出转义当成输入过滤的补丁旧类的clean()里做htmlspecialchars把输入输出两个动作混在一个函数里。对 AJAX 接口来说后端返回到前端 JSON 的数据被提前转成实体前端拿到后又得反转一次反而增加出错路径。正确做法是输入侧只做数据形态校验输出侧按上下文单独处理HTML 渲染时做htmlspecialcharsJSON 返回时用json_encode原始数据。两头混在一个类里容易出现“明明存的是lt;展示时却变成”的问题这是典型的二次数据污染。4.4 常见误用场景三队列消费端也去清洗入库数据现在不少项目把写库操作放到php redis 消费组或php队列中消费端从 Redis 拿到的是一个已经被上层序列化过的数组或对象再丢进InputGuard360清洗一遍往往会出诡异问题布尔值被mb_substr转成字符串、JSON 字段里的空格被trim_whitespace改掉导致签名校验失败。队列数据属于内部可信数据入库前只需要做与写入端一致的类型断言不需要再走一遍对外入口的过滤规则。这里的边界是过滤只面对不可信输入面对可信输入时加上类型检查就够了。5. 用 100 个攻击样本验证 360 防注入类的改造结果5.1 一个不依赖测试框架的自测脚本改造完之后需要一份能反复执行的验证脚本。常见做法是不引入 PHPUnit用一个独立 PHP 脚本把攻击载荷和期望值放进数组逐个交给InputGuard360处理再比对?php $loads [ [id1 or 11, id, int, 0], [id1;drop table users, id, int, 0], [nameadmin--, name, string, admin--], [tags[]1tags[]abc, tags, array:int, [1]], [callbackalert(1), callback, deny, ], ]; $guard new InputGuard360([max_len 64]); foreach ($loads as [$payload, $key, $type, $expected]) { parse_str($payload, $input); $_GET $input; $_POST []; // 避免测试环境残留数据 $result $guard-get($key, $type); if ($result ! $expected) { echo fail: {$payload}\n; exit(1); } } echo pass\n;这里的断言规则是数字型参数无论被塞入or 11还是drop table最终都落到安全的整数 0字符串参数允许单引号和双减号原样存在因为真正执行时走 PDO 绑定tags数组逐项过滤后只剩数字callback类型永远返回空串。五个样本覆盖了最典型的注入入口实际项目里可以扩充到一百条以上。5.2 断言背后要锁住的行为扩充样本时重点加宽字节载荷\xbf、waitfor delay 0:0:1、#注释符这三种变体。宽字节载荷要注意不能用parse_str模拟因为parse_str会做字符解码真实浏览器提交的原始字节流要靠硬编码字符串来喂否则测试脚本无法复现宽字节绕过场景。把这份脚本和php -l放在同一条部署前检查命令里比如php -l src php tests/sql_filter_test.php回归成本几乎为零。这样每次改动InputGuard360时至少能确定老 360 类改造后的入口清洗行为没有退化PDO 绑定的防护也不会因为在输入层多绕一圈而被意外破坏。本文还有配套的精品资源点击获取