news 2026/10/9 3:02:50

PHP老项目防SQL注入:360防注入修改类的原理与改造实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PHP老项目防SQL注入:360防注入修改类的原理与改造实践

简介:面向PHP开发者的Web安全防护改造包,针对SQL注入、XSS跨站脚本和CSRF跨站请求伪造等常见攻击,基于360安全思路提供代码修改类,适合初中级开发者在现有项目中快速套用。压缩包仅1KB,包含2个文件:readme.md说明文档和params.php核心实现,前者介绍引入方式与方法调用,后者实现过滤、转义、校验等关键逻辑,结构清晰便于直接阅读。资源已有620人学习下载。该修改类涵盖预处理语句、输入验证、错误处理、CSRF令牌生成等防护要点,能帮助开发者理解如何将安全原则转化为具体PHP代码,并同步学会XSS与CSRF的检测思路。通过阅读示例和源码,可快速掌握在SQL查询与HTTP请求中集成防护机制的方法,为网站数据安全提供有力支撑。

1. 360 这套 php 防注入“修改类”,到底改的是什么

“我给你上了 360 的防注入代码,你还是一句万能密码绕过就登进去了。”这是我帮朋友看一个老 PHP 后台时听到的原话。说这话的人以为把网上下载的 360 防注入类黏进 config.php 就算完工,实际上那套代码的定位根本不是“开箱即用”,它是一份需要你自己动刀子的“修改类”。它做的是输入归一化、关键字黑名单、addslashes 转义这一套过滤型防御,而 SQL 注入的根子在“用户输入直接拼进了 SQL”,所以它的价值是给老项目做兜底,不是替代预处理。这篇就顺着这个标题,把原理、修改方法、参数取舍和 5 个高频翻车点讲透。适合正在维护 PHP 老项目、做代码审计、或者被领导要求“必须加上注入防护”的从业者。改对方向,它能挡住一大片自动化扫描;改错方向,它就是给登录接口挂了一块心理安慰牌。

2. 防注入思路拆解:为什么过滤型代码容易出事,又为什么还要改它

2.1 从一次登录绕过看注入点的工作原理

要理解 360 这套代码能干什么,先看它要防的东西长什么样。老项目里最常见的写法是把$_POST直接拼进 SQL:

// login.php 里的原始写法(反面教材,不要照抄) $username = $_POST['username']; $sql = "SELECT * FROM users WHERE username='$username' AND pwd='" . md5($_POST['pwd']) . "'"; $result = mysql_query($sql);

当用户在用户名框里输入admin' or '1'='1时,拼接出来的语句就变成:

SELECT * FROM users WHERE username='admin' or '1'='1' AND pwd='5f4dcc3b5aa765d61d8327deb882cf99'

or后面的恒真条件让整条 where 无条件成立,攻击者根本不需要知道密码。这就是防注入代码要解决的第一个问题:在数据进入数据库之前,把这类“能改变 SQL 结构”的输入识别出来。

真正治本的做法是参数化查询,也就是预处理。现在任何新代码,第一选择都应该是 PDO:

$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? AND pwd = ?"); $stmt->execute([$username, md5($_POST['pwd'])]); $user = $stmt->fetch();

预处理会把输入当成“数据”而不是“SQL 片段”传给数据库,这也是你读后面章节时心里要一直绷着的那根弦:过滤类只是第二道防线。它是给那些改不动 SQL 的老代码做补偿的,不是用来替代正确写法的。

2.2 360 这类过滤类的基本工作流

网上流传的 360 PHP 防注入代码,核心流程其实特别朴素,一共四步:

  • 收集所有外部输入:$_GET、$_POST、$_COOKIE、$_REQUEST,更完整一点的版本还会带上$_SERVER里的 HTTP 头;
  • 对数组做递归遍历,把每个字符串取出来;
  • 先做“归一化”,把%27、%23、%20这类 URL 编码还原成明文字符,否则union%20select就能轻松躲过关键字检查;
  • 再用一组正则或者关键字表去匹配危险特征,命中的字符串要么被替换成安全占位符,要么直接中断请求返回 403。

单看设计思路,这套东西在上古 PHP 时代确实有效,因为那时候没有 PDO,也没有 mysqli 预处理可写,大家全靠addslashes和黑名单活着。但它的命门也很明显:

  • addslashes对宽字节注入没用,GBK 下%bf%27能被解析成合法字符再加单引号;
  • 关键字黑名单永远可以绕过,换大小写、加注释/*!50000union*/、嵌套编码,都能让简单的正则失明;
  • 这套代码是从 PHP 5 时代传下来的,很多函数在 PHP 7.4 后被标记为废弃,PHP 8.0 干脆直接移除。

所以标题里的“修改类”三个字才是重点。它是给你做二次开发的原材料,不是终稿。

2.3 老代码在 PHP 8 下还能不能跑:三个动作检查

我拿到一份这种历史代码,第一步不是看正则表,而是先确认它能不能在当前 PHP 版本里跑起来。PHP 8 移除了一批老函数,最常见的三个坑是mysql_real_escape_string、eregi和each。mysql_real_escape_string是 MySQL 扩展的函数,PHP 7 就没了;eregi是 POSIX 正则函数,PHP 5.3 起被弃用;each用于数组遍历,PHP 8.0 移除。

遇到这种情况,我一般会在类文件顶部放一个兼容层:

// 兼容层:为 PHP 8 补上老函数,避免 fatal error if (!function_exists('mysql_real_escape_string')) { function mysql_real_escape_string($str) { return str_replace( ["\\", "\0", "\n", "\r", "'", '"', "\x1a"], ["\\\\", "\\0", "\\n", "\\r", "\\'", '\\"', "\\Z"], (string) $str ); } } if (!function_exists('eregi')) { function eregi($pattern, $string) { return preg_match('/' . $pattern . '/i', $string) ? 1 : 0; } }

这段代码的逻辑是把老函数映射到现代 PHP 的等价操作。mysql_real_escape_string的模拟实现依赖str_replace,转义了反斜杠、单引号、双引号和换行符;eregi直接用preg_match加/i修饰符模拟大小写不敏感匹配。

注意,这个兼容层只是让脚本不报错,不代表安全行为完全等价。比如说真正的mysql_real_escape_string在旧版 MySQL 下还受连接字符集影响,模拟函数做不到这一点。所以兼容层过了之后,还要跑一遍完整登录、注册、搜索流程,看有没有数据被转义得面目全非。

3. 把 360 防注入代码接进自己的项目:最小改法与扩展点

3.1 第一步:先做输入归一化,再做正则匹配

拿到这类代码后,我习惯把“过滤”和“转义”两个职责拆开。很多老版本把两件事揉在一个函数里,导致你想单独调整某一条规则时,牵一发动全身。改造后的最小骨架长这样:

final class InputFilter { // 统一入口:递归处理数组和字符串 public static function clean(&$value, array $options = []): void { if (is_array($value)) { foreach ($value as $key => $item) { self::clean($value[$key], $options); // 引用传递,原数组原地修改 } return; } if (!is_string($value)) { return; // 非字符串不处理,避免破坏数字、布尔值 } // 1. 归一化:还原一次 URL 编码,让 %27 %23 现出原形 $value = self::normalize($value); // 2. 关键字检查:命中就替换或拦截 $value = self::checkKeywords($value, $options['mode'] ?? 'replace'); } protected static function normalize(string $str): string { $temp = urldecode($str); // 只解码一次;重复解码会把 %2527 这种场景搞复杂,见第 5 章 return $temp; } }

这段代码做了三件事:递归遍历数组、还原一次 URL 编码、执行关键字检查。递归处理是为了覆盖$_GET['ids[]']这类数组参数——注入载荷不总是出现在平铺字符串里,也可能是多维数组。引用传递保证了调用方拿到的$_POST已经是清洗后的结果,不需要二次赋值。

这里有个容易被忽略的参数:mode。我推荐默认用replace,也就是把危险关键字替换成[blocked]占位符。选replace而不是直接中断请求,是因为有些攻击样本会出现在正常业务数据里,比如用户填写的备注内容是“select 语句学习笔记”。直接 403 会伤及无辜,替换成占位符则能保证业务不中断,同时让 SQL 语句失去攻击能力。后台管理接口可以单独设置mode => 'block',对管理端要求严格一点是可以接受的。

3.2 第二步:在入口统一接管

类写好了,剩下就是挂载。挂载位置比过滤规则本身更重要,因为插晚了等于没插。常见的正确做法是在框架 bootstrap 的最前端、session_start()之前挂上:

// bootstrap.php 最顶部 require_once __DIR__ . '/src/InputFilter.php'; if (PHP_SAPI !== 'cli') { foreach (['_GET', '_POST', '_COOKIE', '_REQUEST'] as $globalName) { if (!empty($GLOBALS[$globalName])) { InputFilter::clean($GLOBALS[$globalName], ['mode' => 'replace']); } } // _SERVER 里的 HTTP 头也要过一遍,但只检查不替换,防构造头 foreach ($_SERVER as $key => $value) { if (strpos($key, 'HTTP_') === 0 && is_string($value)) { InputFilter::clean($value, ['mode' => 'replace']); } } }

这段逻辑先处理四个超全局变量,再处理$_SERVER中的 HTTP 头。为什么要处理 HTTP 头?因为有些老代码会把User-Agent写进数据库做访问日志,攻击者完全可以把注入载荷塞进 UA 字符串里。只检查不替换是留给扩展点——如果你后续要基于日志做安全审计,替换后的占位符会让攻击样本失真,建议这里保持原始值,只走检查逻辑。

挂载时间点要注意:必须在任何业务模块调用$_POST之前完成。如果你用 ThinkPHP、Laravel 这类框架,不要去改框架核心文件,而是在public/index.php的自动加载之后、路由分发之前插入这段代码。别放在框架中间件里,因为有些框架中间件执行时,路由参数已经被解析过了。

3.3 第三步:为接口设计白名单放行表

全局限滤最大的副作用是误杀。尤其是富文本编辑器提交的内容、JSON 请求体、搜索关键词,里面经常包含字母union、单词select的普通文本。我的做法是加一张“放行表”:

接口场景放行字段放行理由
富文本公告发布content编辑器会生成带select的 HTML 标签片段
用户搜索建议keyword用户可能搜索 “select 语句” 这类技术词
JSON API 订单提交remark备注字段是自由文本,且 SQL 已走预处理
登录接口全部字段登录是注入重灾区,任何字段都不过滤

实现时不要改全局逻辑,而是在入口处为特定路由设置独立参数:

// 白名单放行示例:仅对 announce 路由放行 content 字段 if (($_GET['r'] ?? '') === 'admin/announce/save') { $tempContent = $_POST['content']; InputFilter::clean($_POST, ['mode' => 'replace']); $_POST['content'] = $tempContent; // 覆盖回原值,保留富文本 } else { InputFilter::clean($_POST, ['mode' => 'replace']); }

这段代码的关键是先整体过滤,再手动还原放行字段。顺序不能反过来,否则攻击者如果同时在其他字段里塞了注入载荷,就不受限制了。还原content之前要确认 SQL 语句里的content字段使用的是参数化绑定,否则这个白名单就是在给注入开绿灯。

放行表一定要写成代码注释留在项目里,不要只存在聊天记录里。后接手的人看到放行逻辑,能第一时间明白为什么这个字段可以不过滤,而不是当 bug 删掉。

4. 关键参数调优:哪些正则该松、哪些该紧、误杀怎么处理

4.1 关键字黑名单的取舍:union、sleep、information_schema 该不该拦

网上那套 360 代码默认的关键字表很长,从union到load_file到into outfile全都拦。但你把这份黑名单原封不动搬到生产环境,会发现误杀率高得离谱。用户评论里出现“Union 是个好球队”都会被替换成[blocked]。

我实际生产环境里维护的关键字表长这样:

// 关键字表:按业务风险分级 $rules = [ // 高危,几乎只可能出现在攻击样本里 'load_file' => ['action' => 'block'], 'into outfile' => ['action' => 'block'], 'information_schema' => ['action' => 'block'], 'sleep(' => ['action' => 'block'], // 中危,可能出现在正常文本中,先替换再放行 'union select' => ['action' => 'replace'], '/*!50000union' => ['action' => 'replace'], // 内联注释绕过 '0x' => ['action' => 'log'], // 十六进制编码,只记录 ];

分级的逻辑是这样的:sleep(这类时间盲注特征在正常业务里几乎不会出现,直接拦截;union select是正常用户在文章里也可能写的词组,所以替换成占位符;0x单独出现时太容易误伤,比如0x1234是正常颜色值,所以只写日志不处理,等日志积攒到一定量级再判断要不要收紧。

注意,正则匹配前最好把原始输入统一转成小写后再匹配,否则UNION SELECT直接绕过小写敏感的正则。常见做法是用strtolower生成一个“副本”做检查,不回写原值,这样业务数据的大小写不受影响。

4.2 处理顺序:解码、递归、匹配,一步都不能乱

注入防御的绕过手段都集中在“编码不一致”上。同一个载荷,攻击者可以原样提交、也可以 URL 编码、还可以二次编码。如果代码先做正则匹配再做解码,那%75nion就直接漏过去了,因为正则匹配时它还是%75开头。

正确的处理顺序是固定的:解码 → 递归拆分 → 匹配。解码深度要控制好,我一般默认只解一次,特殊情况在接口层单独处理:

// 归一化函数:只还原一层,避免 %2527 场景失控 protected function normalize(string $input): string { $decoded = urldecode($input); // 检查解码后是否又出现了 % 开头的可疑序列,记录二次编码日志 if (preg_match('/%[0-9a-fA-F]{2}/', $decoded)) { self::log('double_encode', $input); } return $decoded; }

这段代码在处理“双层编码”问题时只显式解码一次,但如果发现解码后还有编码痕迹,就记日志,而不是继续解。为什么不继续解?因为%2527这种输入,正常业务几乎不会出现,一旦出现大概率是扫描器在踩点,记日志比直接拦截更有价值——你可以通过日志看到攻击者尝试的完整链路,对后续规则调整有参考意义。继续解码反而可能把合法数据里的字符串%25破坏,造成数据丢失。

字符集相关的坑也在这一步冒出来。如果你的数据库连接用 GBK,addslashes对宽字节是防不住的,常见处理方案是强制把所有输入按 UTF-8 接收,入口处加一行转换:

// 让所有输入统一进 UTF-8,避免 GBK 宽字节注入 mb_convert_encoding($value, 'UTF-8', mb_detect_encoding($value, ['UTF-8', 'GBK', 'GB2312'], true));

mb_detect_encoding的true参数是严格模式,避免一个字符串既像 GBK 又像 UTF-8 时被误判。这一行做下来,你的过滤类在编码层面才算是闭环。

4.3 把拦截日志变成校准依据

修改过的过滤类上线后,最怕的不是被绕过,而是误杀了一堆正常请求,业务方天天来找你“为什么我这句话发不出去”。所以我在类里加了拦截日志,字段很简单:时间、来源 IP、原始输入、命中的规则、命中的动作。

// 拦截日志记录 protected static function log(string $rule, string $input): void { $line = sprintf( "[%s] [%s] rule=%s input=%s\n", date('Y-m-d H:i:s'), $_SERVER['REMOTE_ADDR'] ?? 'cli', $rule, addcslashes(substr($input, 0, 200), "\0..\37") ); file_put_contents( self::$logFile, $line, FILE_APPEND | LOCK_EX ); }

addcslashes在这里是为了把换行、控制字符转义成可见形式,避免日志被特殊字符污染。substr截断到 200 字符,防止攻击者用超长载荷把日志文件撑爆。这段日志不需要写进数据库,因为过滤类本身可能早于数据库连接初始化执行,写文件是最稳的。

每周看一次日志的命中分布:如果某条规则命中量异常升高,先查是不是爬虫在扫描,再查是不是业务字段产生了新数据样本。误杀率高的规则,就把动作从block降到replace;绕过尝试集中的规则,就加一条变体到关键字表。这样过滤器才不是一潭死水,而是跟着真实流量持续收缩误伤半径。

5. 避坑实录:5 个改完必翻车的现场

5.1 评论区原文含“union”被误杀

现象:用户在商品评论里写“这款产品集合了 union 和 join 两种用法”,提交后评论内容变成“这款产品集合了 [blocked] 和 join 两种用法”,用户投诉数据被篡改。

原因:全局过滤规则把union当成了攻击特征,普通文本直接命中替换逻辑。这种情况在技术交流类社区特别常见。

解决:把黑名单里的union单独改成union select再匹配;同时加上白名单字段,富文本评论内容可以整体跳过替换,只做日志。或者更彻底一点,对已明确走预处理的接口,干脆不做关键字替换,只保留基础转义和编码归一化。

5.2 JSON 请求体被递归过滤后反序列化报错

现象:小程序端提交的 JSON 里有一个字段值是"a\"b",PHP 端用json_decode解析后取不到a"b,反而拿到一长串带反斜杠的乱码。

原因:过滤类对$_POST里取到的 JSON 原始字符串先做了整体清洗,把双引号和反斜杠都转义了一遍。JSON 结构被破坏,json_decode直接返回null。

解决:对 JSON 接口不能走“字符串整体清洗”这条路。先json_decode,然后遍历解码后的数组,只对字符串类型的叶子值做过滤,保留 JSON 结构:

$raw = file_get_contents('php://input'); $data = json_decode($raw, true); if (json_last_error() !== JSON_ERROR_NONE) { http_response_code(400); exit('invalid json'); } array_walk_recursive($data, function (&$item) { if (is_string($item)) { $item = InputFilter::cleanString($item); // 只清字符串,不破坏结构 } });

5.3%2525双层编码绕过导致拦截失效

现象:安全测试时发现输入%2527能绕过过滤,数据库里最终落了一个单引号,SQL 语句又被改变了。

原因:过滤类只做一次urldecode,%2527第一次解码后变成%27,但过滤逻辑认为已经解码过了,不再往下处理。而数据库连接或者框架在后续环节又解了一次%27,最终变成单引号落地。

解决:归一化时记录“解码后是否仍包含编码特征”,发现疑似二次编码就直接拦截:

protected function detectDoubleEncoding(string $input): bool { $once = urldecode($input); return preg_match('/%[0-9a-fA-F]{2}/', $once) === 1; }

生产环境里这个函数可以替换默认的normalize,把检测结果作为block动作的触发条件。二次编码样本在正常业务里几乎不存在,宁可拦截也不给绕过机会。

5.4 过滤后 SQL 语句直接失效,业务静默出错

现象:用户注册时填写的昵称是O'Neil,过滤后变成了O\'Neil,写入数据库正常,但下一次读取时,用相同条件查询却查不到这个人。

原因:addslashes的转义行为依赖 SQL 语句最终如何被处理。如果使用的是mysqli的real_escape_string,插入时会再做一次转义,等于双转义;如果用的是预处理绑定,输入根本不需要转义,转义后的反斜杠反而成了数据的一部分。

解决:我在改造这个类时发现最稳的组合是“预处理 + 不转义”。也就是说,如果项目已经用 PDO 预处理,过滤类里应该把addslashes关掉,只做关键字替换和编码归一化。不要指望同一个过滤类同时适配预处理和字符串拼接两种用法,那只会两头不讨好。

5.5 Cookie 里的加密串被过滤截断,登录态反复失效

现象:用户登录后 Cookie 没过期,但每次请求都被判定为未登录,刷新几次又好了,线上反馈“登录状态像玄学一样飘”。

原因:有些框架会把用户信息序列化后加密放进 Cookie,密文里可能含有%号或特定字符。过滤类对$_COOKIE做全局urldecode和转义时,把密文结构破坏了,服务端解不出原始值,只能当成无效会话踢下线。

解决:_COOKIE不参与关键字过滤,只保留基本安全动作。常见的做法是只检查 Cookie 名,不检查 Cookie 值;或者把 Cookie 的过滤逻辑改为白名单模式——只检查框架定义的那几个会话 Cookie,其他全部跳过。登录态相关代码最怕“多管闲事”,少动为妙。

6. 最后一步:用样本集验证你的修改类是否合格

过滤器改完,很多人直接上线,等出问题再修。我习惯先写一个样本集做回归,把攻击特征和正常业务样本混在一起,确保过滤规则不误伤、不漏报。

// 验证脚本:构造一批样本,检查过滤结果是否符合预期 $samples = [ ["admin' or '1'='1' --", true], ["union select 1,2,3", true], ["sleep(5)", true], ["%2527 or 1=1", true], ["这是一段普通文本:union 这个词只是普通英文", false], ["O'Neil 是人名", false], ["php 8.0 的 phpstorm 很好用", false], ["0x1234 是十六进制颜色值", false], ]; foreach ($samples as [$input, $shouldBlock]) { $origin = $input; InputFilter::clean($input, ['mode' => 'replace']); $result = $input !== $origin; $status = ($result === $shouldBlock) ? 'PASS' : 'FAIL'; printf("[%s] %s\n", $status, $origin); }

这个脚本每跑一次,就是一次回归测试。PASS率能维持在 95% 以上,过滤器才具备上线资格。剩下 5% 的失败样本,逐一分析是漏报还是误杀,再回头改第 4 章那几张规则表。把这套脚本放进 CI 流程里,以后每次改黑名单都能自动验证,不再靠手感做事。

最后说一个我的习惯:过滤器永远只当兜底,不当主力。凡是新写的 SQL,老老实实走 PDO 预处理;凡是老代码里那段字符串拼接,能重写就重写,不能重写才交给过滤类兜着。这套 360 防注入修改类不是万能钥匙,但理解它、改造它、验证它,你至少能把历史代码里那些裸奔的 SQL 拼接口拉回到及格线。我见过太多项目把防注入代码当成心理安慰剂,也见过真改对了方向、把自动化扫描拦下九成的案例,差别就在前面这几章的细节里。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 3:02:38

Codex CLI接入国产开源模型:OpenAI兼容API切换指南

从去年 OpeniAI 的 Codex CLI 正式发布之后,终端里“用自然语言驱动编程”的玩法就开始被越来越多的人接受。社区里经常调侃它就是 OpenAI 的“亲儿子”,因为新模型的能力总是优先在它身上落地。可在实际项目里,很多团队并不愿意把整份代码上…

作者头像 李华
网站建设 2026/10/9 3:02:38

从OpenAI迁到开源模型:用本地推理拿回自主权

OpenAI 的“亲儿子”,想要用中国开源模型拿回自主权这次我们聊的不是某个一键包,而是一个偏选型的话题:如果一个 AI 应用从诞生起就把核心能力挂在 OpenAI API 上,被圈内人叫成“OpenAI 亲儿子”,它现在想换一条路——…

作者头像 李华
网站建设 2026/10/9 3:02:29

AI辅助求职实战:从简历关键词匹配到模拟面试的效率工具箱

又是一年毕业季,社交平台上关于“毕业生找工作难”的讨论热度居高不下。很多求职者把原因归结为“岗位太少”“竞争太激烈”,也有不少人认为问题出在人口结构上。但如果把视角切换到技术层面,你会发现一个常被低估的变量——AI。它正在以两种…

作者头像 李华
网站建设 2026/10/9 3:02:14

大模型应用落地:从Demo到生产的AI工程化关键与实践

过去两年,AI行业最像的不是技术发布会,而是电影发行:先放几分钟预告片,再定档期,然后所有人都在等正片。预告片阶段,我们看到了大量惊艳的demo:多模态对话、AI自动写代码、Agent自己规划任务并调…

作者头像 李华
网站建设 2026/10/9 3:02:02

Modbus转OPC UA:工业协议转换网关的完整实现指南

做工业信息化的朋友应该都有过这种经历:现场一水儿的Modbus设备,电表、温控器、变频器、PLC,个个都挺老实的,但真要把数据送到上层系统,麻烦就来了。上位机要的是OPC UA,SAP/MES要的是OPC UA,云…

作者头像 李华