简介:一套由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或严格正则才能保证值是纯整数。旧类的关键字拦截在这里也失效,因为id=1 or 1=1完全可以不带引号。
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 和 L5:L2 是老 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 table,PDO 也会按整数类型传递,到不了数据库解析器。这正是“数据形态校验”和“数据执行安全”分开的意义: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_len | int | 255 | 字符串最大长度,避免超长参数撑爆日志或存储 |
trim_whitespace | bool | true | 自动去掉首尾空白,方便后续比较 |
use_session | bool | false | 是否让get()读取$_SESSION,开启前确认无对象 |
allowed_host | string/null | null | 校验 Referer 或 Origin,跨域场景下作为辅助 |
json_body | bool | false | 是否解析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 渲染时做htmlspecialchars,JSON 返回时用json_encode原始数据。两头混在一个类里,容易出现“明明存的是<,展示时却变成<”的问题,这是典型的二次数据污染。
4.4 常见误用场景三:队列消费端也去清洗入库数据
现在不少项目把写库操作放到php redis 消费组或php队列中,消费端从 Redis 拿到的是一个已经被上层序列化过的数组或对象,再丢进InputGuard360清洗一遍,往往会出诡异问题:布尔值被mb_substr转成字符串、JSON 字段里的空格被trim_whitespace改掉导致签名校验失败。队列数据属于内部可信数据,入库前只需要做与写入端一致的类型断言,不需要再走一遍对外入口的过滤规则。这里的边界是:过滤只面对不可信输入,面对可信输入时加上类型检查就够了。
5. 用 100 个攻击样本验证 360 防注入类的改造结果
5.1 一个不依赖测试框架的自测脚本
改造完之后,需要一份能反复执行的验证脚本。常见做法是不引入 PHPUnit,用一个独立 PHP 脚本把攻击载荷和期望值放进数组,逐个交给InputGuard360处理再比对:
<?php $loads = [ ['id=1 or 1=1', 'id', 'int', 0], ['id=1;drop table users', 'id', 'int', 0], ["name=admin'--", 'name', 'string', "admin'--"], ['tags[]=1&tags[]=abc', 'tags', 'array:int', [1]], ['callback=alert(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 1=1还是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 绑定的防护也不会因为在输入层多绕一圈而被意外破坏。
本文还有配套的精品资源,点击获取