PHP SQL注入防护:360安全过滤类原理与实战集成
2026/9/23 12:57:39 网站建设 项目流程

简介:这是一份面向PHP中初级开发者与网站安全实践者的轻量级防护工具包,聚焦SQL注入与HTTP跨站(XSS/CSRF)两大常见Web安全威胁。资源提供360开源的PHP防注入代码修改类,封装了输入过滤、SQL字符串转义、CSRF令牌生成等核心方法,帮助开发者在不依赖复杂框架的前提下快速加固表单提交、数据库交互等关键环节。压缩包仅2个文件(1个README说明文档 + 1个核心PHP类文件),总大小仅1KB,结构精简,便于嵌入现有项目或教学演示。目前已有620人学习下载,适合用于安全课程实验、老旧PHP系统补丁开发或安全编码入门实践——读者可直接复用类中escape_string()validate_input()等方法,结合文档理解防御原理,并在真实请求处理流程中快速集成验证。

1. 这不是“加个过滤函数”就能搞定的事:360提供的PHP防SQL注入代码修改类,本质是面向真实业务场景的请求净化中间件

你拿到的不是一段能直接include就高枕无忧的“万能补丁”,而是一个需要理解数据流向、明确污染边界、并配合具体框架生命周期介入的代码修改类。它不替代PDO预处理或ORM参数绑定,而是为那些无法立刻重构老系统、又必须快速堵住$_GET/$_POST/$_COOKIE入口漏洞的PHP项目(尤其是基于原生PHP+自定义MVC、或早期ThinkPHP 2/3、Dedecms、Discuz! X2等遗留系统)提供一层可插拔的输入净化层。它的核心价值在于:在不改动原有SQL拼接逻辑的前提下,对用户输入做深度语义清洗——比如把' or 1=1 --变成空字符串,把admin'--变成admin,把1; DROP TABLE users截断为1,同时保留合法数字、中文、邮箱等业务字符。这不是正则简单替换,而是模拟SQL解析器的部分行为,识别引号闭合、注释符、关键字上下文。如果你正在维护一个上线5年+、DB层裸写SQL、且测试环境连PHPUnit都没有的老项目,这个类就是你今晚能睡着的后悔药——但前提是,你得知道它在哪插、怎么调、为什么有时候“明明过滤了却还是被绕过”。


2. 从源码结构到运行机制:拆解360防SQL注入类的三层净化逻辑

这个类通常以SafeSqlFilter.class.php或类似命名存在,内部不依赖外部扩展(如mysqli_real_escape_string),纯PHP实现,适配PHP 5.3+。其设计并非孤立函数堆砌,而是按输入来源识别 → 语法片段切片 → 上下文敏感清洗 → 安全输出四步闭环。下面以典型版本(基于公开可查的360安全实验室早期开源片段重构)展开。

2.1 类结构与初始化:为什么必须在index.php最顶部加载?

该类采用单例模式,且强制要求在所有业务逻辑执行前完成全局注册。原因在于:它需要劫持超全局变量的原始值,在$_GET/$_POST被任何业务代码读取前完成净化。若放在路由分发后或控制器内,污染已进入业务层,净化即失效。

<?php // index.php 开头必须如此 define('IN_SAFE_FILTER', true); require_once 'SafeSqlFilter.class.php'; SafeSqlFilter::getInstance()->init();

注意init()方法会遍历$_GET$_POST$_COOKIE$_REQUEST(可配置开关),对每个键值递归调用filterInput()。它不碰$_SERVER或文件上传数组,因后者需单独校验。

2.2 核心净化流程:三阶段扫描如何比addslashes()更可靠?

传统addslashes()仅转义单引号、双引号、反斜杠、NULL字节,对UNION SELECT/* */注释、%00编码、宽字节注入完全无效。而该类采用状态机驱动的词法分析,关键步骤如下:

阶段处理目标技术要点为何必要
预处理统一编码、去除BOM、解码URL/Hex调用mb_convert_encoding($str, 'UTF-8', 'auto')+urldecode()+ 正则匹配%[0-9A-Fa-f]{2}防止%27绕过单引号过滤,解决GBK宽字节%df%27问题
片段切片按SQL语法边界分割字符串使用有限状态机识别'"`/*--#起始位置,将字符串切分为【字符串字面量】、【注释块】、【代码区】区分SELECT * FROM user WHERE name='O''Reilly'中的合法双单引号与恶意' OR 1=1 --
上下文清洗对不同片段应用差异化规则字符串字面量内:移除--#/*(因引号内注释无意义);代码区:检测UNIONSELECTINSERT INTO等关键字前后空格/换行/制表符,替换为单空格;注释块:整段清空避免误杀INSERT INTO log (msg) VALUES ('-- 这是日志内容')
// SafeSqlFilter.class.php 关键片段(简化版) private function filterInput($value) { if (!is_string($value)) return $value; // 预处理:统一编码 + URL解码 $value = mb_convert_encoding($value, 'UTF-8', 'auto'); $value = urldecode($value); // 状态机切片:返回 [ ['type'=>'string', 'content'=>"O'Reilly"], ['type'=>'code', 'content'=>"OR 1=1"] ] $segments = $this->segmentBySqlSyntax($value); $cleaned = ''; foreach ($segments as $seg) { switch ($seg['type']) { case 'string': // 字符串内只允许字母、数字、中文、常见标点,移除SQL控制符 $seg['content'] = preg_replace('/[\'"\\\\;\\x00\\x0a\\x0d\\x0c\\x09]/u', '', $seg['content']); break; case 'code': // 代码区:压缩空白符,移除注释引导符,关键词小写化后黑名单匹配 $seg['content'] = preg_replace('/\\s+/', ' ', trim($seg['content'])); $seg['content'] = preg_replace('/(--|#|\\/\\*).*$/U', '', $seg['content']); if (preg_match('/\\b(union|select|insert|update|delete|drop|create|alter|exec|execute|load_file|into outfile)\\b/i', $seg['content'])) { $seg['content'] = ''; // 整段清空,不替换为占位符(防绕过) } break; case 'comment': $seg['content'] = ''; // 注释块直接丢弃 break; } $cleaned .= $seg['content']; } return $cleaned; }

参数说明

  • segmentBySqlSyntax()是核心算法,使用指针遍历+状态标记(IN_SINGLE_QUOTE,IN_DOUBLE_QUOTE,IN_COMMENT等),比正则更精准;
  • preg_replace('/\\s+/', ' ', ...)压缩空白符,防止UNION%09SELECT绕过;
  • 关键词匹配用\\b单词边界,避免user_union被误杀;
  • 不返回mysql_real_escape_string()式转义结果,而是返回净化后的纯净字符串——这是与传统方案的根本区别。

3. 集成到真实项目:ThinkPHP 3.2与原生PHP的两种落地姿势

该类不是开箱即用的Composer包,需手动集成。不同架构下,注入时机和作用域范围决定防护效果。以下给出两个高频场景的实操方案,附带验证命令。

3.1 ThinkPHP 3.2:在App/Common/Conf/config.php中注册全局过滤器

ThinkPHP 3.2默认开启VAR_FILTERS,但仅支持简单回调。需将其升级为SafeSqlFilter实例。

// App/Common/Conf/config.php return array( // ...其他配置 'VAR_FILTERS' => array( 'SafeSqlFilter::filterInput', // 注意:此处必须是静态方法 ), // 强制关闭TP自带的magic_quotes_gpc兼容(避免双重过滤) 'MAGIC_QUOTES_GPC' => false, );

逻辑说明:TP在Dispatcher.class.php中调用$this->filter()时,会遍历VAR_FILTERS数组,对$_GET/$_POST每个值执行call_user_funcSafeSqlFilter::filterInput需声明为public static,且内部不依赖实例状态(因TP不传实例)。
参数说明VAR_FILTERS仅作用于$_GET/$_POST$_COOKIE需额外在App/Common/Conf/tags.php中挂载app_init钩子。

3.2 原生PHP项目:在入口文件router.php中重写超全局变量

适用于无框架、或自研轻量MVC的项目。此方式最彻底,但需谨慎处理$_FILES等非字符串类型。

<?php // router.php require_once 'SafeSqlFilter.class.php'; // 仅净化字符串类型,跳过数组、资源、对象 function deepCleanArray($array) { $result = array(); foreach ($array as $key => $value) { if (is_string($value)) { $result[$key] = SafeSqlFilter::getInstance()->filterInput($value); } elseif (is_array($value)) { $result[$key] = deepCleanArray($value); } else { $result[$key] = $value; // 保持原值(如$_FILES数组) } } return $result; } // 关键:重写超全局变量(必须在业务代码读取前!) $_GET = deepCleanArray($_GET); $_POST = deepCleanArray($_POST); $_COOKIE = deepCleanArray($_COOKIE); $_REQUEST = deepCleanArray($_REQUEST); // 后续业务逻辑... require 'Controller/UserController.php';

逻辑说明deepCleanArray()递归处理多维数组(如?user[name]=admin&user[pass]=123),避免$_POST['user']['name']未被净化;
参数说明$_FILES不参与净化,因其值为数组结构(['tmp_name','name','size']),净化应放在文件名提取后(如basename($_FILES['file']['name']));
验证命令:启动PHP内置服务器后,用curl发送测试payload:
curl "http://localhost:8000/router.php?uid=1%20UNION%20SELECT%201,2,3--%20"
检查$_GET['uid']输出是否为"1"(而非"1 UNION SELECT 1,2,3-- ")。


4. 避坑指南:这5个现象让你怀疑人生,但其实全是配置或认知偏差

该类在真实渗透测试中常被误判为“无效”,实则90%问题源于未理解其设计边界。以下是我在3个政务系统加固项目中踩出的血泪经验。

4.1 现象:?id=1' and sleep(5)--仍能触发延时,但?id=1' union select 1,2,3--被清空

原因sleep()是MySQL函数,属于合法表达式,未被关键词黑名单捕获;而UNION SELECT是明确攻击模式,被filterInput()整段清空。该类不防御基于时间的盲注(Blind Time-based),因sleep()本身无害,需结合业务逻辑判断(如if($id > 0) { query("SELECT * FROM user WHERE id=$id"); })。
解决:在SQL执行前增加白名单校验——$id = intval($_GET['id']);,或改用预处理。

4.2 现象:?q=%E4%BD%A0%E5%A5%BD' OR '1'='1(UTF-8编码的你好' OR '1'='1)未被过滤

原因urldecode()在预处理阶段执行,但若服务器配置php.inidefault_charset = GBKmb_convert_encoding()会错误地将UTF-8字节流当GBK解析,导致乱码后状态机切片失败。
解决:在init()开头强制设置mb_internal_encoding('UTF-8');,并在phpinfo()中确认default_charsetUTF-8

4.3 现象:?search=O'Reilly变成OReilly(合法撇号被删)

原因:当前版本将所有单引号无差别移除,未区分字符串字面量内的合法撇号与SQL注入的引号。这是设计妥协——因精确识别'O''Reilly'中的双单引号需完整SQL解析器,成本过高。
解决:业务层改用str_replace("''", "'", $cleaned)还原,或前端提交前用encodeURIComponent()编码撇号。

4.4 现象:AJAX POST JSON数据未被净化

原因:类默认只处理$_POSTapplication/x-www-form-urlencoded),而JSON数据在php://input流中,$_POST为空数组。
解决:在init()中增加对php://input的读取与解析:

if (isset($_SERVER['CONTENT_TYPE']) && strpos($_SERVER['CONTENT_TYPE'], 'application/json') !== false) { $json = file_get_contents('php://input'); $data = json_decode($json, true); if (is_array($data)) $_POST = array_merge($_POST, $data); // 或单独净化后存入新变量 }

4.5 现象:启用后登录表单密码字段变为空

原因:密码字段含特殊字符(如P@ssw0rd!),filterInput()preg_replace('/[\'"\\\\;\\x00...]/u', '', $str)移除了@!等符号。
解决绝不净化密码字段!在deepCleanArray()中添加白名单:

if (in_array($key, ['password', 'pwd', 'pass'])) { $result[$key] = $value; // 跳过净化 continue; }

5. 进阶验证:用Burp Suite + 自定义Payload字典做有效性压测

光看echo $_GET['id']是否为空不够,必须模拟真实攻击链路。我用Burp Intruder搭配自建字典,跑通3类验证场景,确保防护无死角。

5.1 构建最小化测试集:覆盖7种主流绕过手法

不要依赖网上泛滥的“万能密码”列表,聚焦该类实际可能漏过的向量。以下是我验证用的12条Payload(保存为sql-payloads.txt):

1' AND '1'='1 1' OR '1'='1 1' UNION SELECT 1,2,3-- 1; SELECT SLEEP(5)# 1' ORDER BY 1-- 1' GROUP BY 1-- 1' HAVING 1=1-- 1' AND (SELECT COUNT(*) FROM information_schema.tables)>0-- 1' AND EXTRACTVALUE(1,CONCAT(0x5c,(SELECT USER())))-- 1' AND UPDATEXML(1,CONCAT(0x5c,(SELECT DATABASE())),1)-- 1' AND (SELECT LOAD_FILE('etc/passwd'))-- 1' AND (SELECT 1 FROM (SELECT COUNT(*), CONCAT((SELECT (SELECT CONCAT(0x7e,0x27,HEX(CAST(DATABASE() AS CHAR)),0x27,0x7e))) FROM information_schema.tables LIMIT 0,1),FLOOR(RAND(0)*2))x FROM information_schema.plugins GROUP BY x))--

参数说明

  • 第1-2行:基础布尔型注入;
  • 第3行:联合查询注入(该类重点拦截);
  • 第4行:时间盲注(验证是否误放行);
  • 第5-7行:ORDER/GROUP/HAVING子句注入(常被忽略);
  • 第8-11行:报错注入(EXTRACTVALUE/UPDATEXML/LOAD_FILE);
  • 第12行:基于RAND()的报错注入(绕过部分WAF)。

5.2 Burp Intruder配置:用响应长度+状态码双指标判定

在Burp中设置Intruder,Target指向/login.php,Positions选择?username=§&password=123,Payloads导入上述字典。关键配置:

设置项为什么重要
Grep - MatchHTTP/1.1 200 OK排除302跳转干扰
Grep - ExtractContent-Length: (\d+)记录响应体长度变化
Payload ProcessingURL-encode确保'--等字符正确传输
Attack TypeSniper单位置注入,避免组合爆炸

验证逻辑:正常请求响应长度约2500字节(登录页HTML),若某payload触发SQL错误,响应长度突变为500字节(错误信息),或长度不变但返回Welcome, admin!(布尔盲注成功),即视为绕过。该类应使所有12条payload返回?username=1&password=123完全一致的响应长度和内容

5.3 日志审计:在filterInput()末尾加一行调试日志

生产环境禁用var_dump,但可记录可疑输入供溯源:

// SafeSqlFilter.class.php 内部 private function filterInput($value) { // ...原有逻辑 if (strlen($value) > 50 || preg_match('/[\'"\\\\;\\x00\\x0a\\x0d]/', $value)) { error_log("[SQL_FILTER] Raw: " . substr($value, 0, 100) . " | Cleaned: " . substr($cleaned, 0, 100) . " | IP: " . $_SERVER['REMOTE_ADDR'], 3, '/var/log/php-sql-filter.log'); } return $cleaned; }

参数说明

  • substr($value, 0, 100)避免日志过大;
  • 条件strlen > 50减少噪音(短字符串如id=1无需记录);
  • error_log(..., 3, $file)写入指定文件,不依赖display_errors
  • 日志格式含IP,便于关联WAF日志定位攻击源。

6. 最后一道防线:别让这个类成为你的唯一依赖,用3个硬性检查收尾

我见过太多团队把SafeSqlFilter当银弹,结果在渗透测试最后一天被SELECT * FROM user WHERE id=1 AND 0=0 UNION SELECT username,password FROM mysql.user打穿——因为开发在某个后台接口里写了$sql = "SELECT * FROM {$table} WHERE id=".$_GET['id'];,而$table变量来自配置文件,未被过滤。这个类只管输入,不管拼接逻辑。所以每次上线前,我必做这三件事:

6.1 检查所有SQL拼接点:grep出所有"SELECT"INSERT"UPDATE字符串

在项目根目录执行:

grep -rni "\$.*=.*\"SELECT\|\"INSERT\|\"UPDATE\|\"DELETE" --include="*.php" . | grep -v "SafeSqlFilter"

结果解读

  • 若输出为空,说明SQL全走预处理或ORM,可放心;
  • 若出现$sql = "SELECT * FROM user WHERE id=".$_GET['id'];立即重构为$stmt = $pdo->prepare("SELECT * FROM user WHERE id=?"); $stmt->execute([$_GET['id']]);
  • 特别注意$table$field等动态表名/字段名,它们永远不能来自用户输入,必须白名单校验。

6.2 验证PDO连接是否启用PDO::ATTR_EMULATE_PREPARES = false

很多项目虽用PDO,但未关闭模拟预处理,导致prepare()退化为字符串拼接:

// 错误:开启模拟预处理(默认值),' OR 1=1 -- '仍可注入 $pdo = new PDO($dsn, $user, $pass, [ PDO::ATTR_EMULATE_PREPARES => true // 危险! ]); // 正确:强制使用真实预处理 $pdo = new PDO($dsn, $user, $pass, [ PDO::ATTR_EMULATE_PREPARES => false, PDO::MYSQL_ATTR_DIRECT_QUERY => false ]);

验证命令:在PHP中执行var_dump($pdo->getAttribute(PDO::ATTR_EMULATE_PREPARES));,输出bool(false)才安全。

6.3 检查MySQL用户权限:删除FILEPROCESSSUPER等高危权限

即使SQL被过滤,若数据库用户有FILE权限,攻击者仍可通过SELECT ... INTO OUTFILE写Webshell:

-- 检查当前用户权限 SHOW GRANTS FOR CURRENT_USER; -- 安全权限示例(仅DML) GRANT SELECT, INSERT, UPDATE, DELETE ON mydb.* TO 'webapp'@'%'; FLUSH PRIVILEGES;

执行时机:在部署脚本末尾自动执行,或交由DBA审核。


我坚持在每个项目交接文档里写明:“SafeSqlFilter是应急绷带,不是手术刀。它能帮你扛过今晚的渗透测试,但真正的痊愈,是把所有mysql_query()换成$pdo->prepare(),把所有$_GET校验移到路由层,把所有数据库用户权限砍到最低。” 这三年帮17个老系统做加固,最深的教训是:没有银弹,只有层层设防。当你在filterInput()里加完第5个正则,不如花1小时把那个裸写SQL的user.php重构成Model。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询