尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

老项目SQL注入防护改造:从黑名单过滤到PDO预处理

老项目SQL注入防护改造:从黑名单过滤到PDO预处理 简介面向PHP开发者的安全防护代码包聚焦SQL注入与HTTP跨站攻击XSS/CSRF两类常见威胁提供一套可直接参考的输入过滤、转义、预处理及CSRF令牌等防护方法。压缩包仅2个文件包含1份使用说明文档和1个PHP核心实现类整体大小约1KB轻量易读适合快速集成至现有PHP项目中。目前已有618人学习浏览。说明文档会解释类的初始化与调用流程核心实现类则针对SQL注入提供预处理语句、参数化查询及特殊字符过滤方案并包含类似 validate_input()、escape_string()、generate_csrf_token() 的方法帮助开发者理解XSS与CSRF防护的落地方式。对于希望提升PHP应用安全性的初中级开发者是一份可直接借鉴的轻量工具。1. 这个类还在被大量老项目引用但用法早该改了在 phpStudy 和 Dedecms 主导中小站建设的那几年360 防 SQL 注入代码几乎是每个模板包里都会出现的一小段 PHP 类。它做的事情很机械在脚本入口把$_GET、$_POST、$_COOKIE全部扫一遍遇到union select、0x、sleep(这类关键字就拦截或替换。直到现在我还能收到接手老项目的同事来问这个类到底能不能直接用改成什么样才算真防住我的答复是思路可以借但照搬原代码在 PHP 7/8 下既有一堆兼容性报错黑名单机制本身也拦不住真正的 SQL 注入。正确做法是把它改造成「关键字校验 参数绑定」的双层组件一层挡扫描器一层保业务数据完整。这篇文章按原理、改造代码、接入老项目、排错、验证的顺序讲适合正在维护 PHP 5/7 时代遗留系统的工程师。2. 为什么老的 360 防注入类防不住真正的注入2.1 SQL 注入的本质数据和 SQL 结构没有分开SQL 注入能成立只有一个前提用户输入被当作 SQL 语句的一部分直接拼接执行。看这段最典型的伤口代码$id $_GET[id]; $sql SELECT * FROM news WHERE id {$id}; mysql_query($sql);当请求变成?id1 union select 1,user(),3时SQL 语句被改写成两条逻辑先查出 id1 的记录再执行union select user()。问题的根源不是用户传了什么而是开发者把数据写进了 SQL 文本里。只要这个拼接方式不换任何过滤都只是在给伤口贴创可贴。理解了这一点就明白防护手段的优先级先换执行方式用预处理把数据和 SQL 结构分开再做输入过滤拦掉明显恶意的扫描流量。顺序反了就会把过滤当成唯一防线这也是老项目被注入后反复整改又反复出事的常见原因。2.2 老类的工作机制递归遍历超全局数组做黑名单命中市面上流传的所谓360 防注入类核心逻辑基本是这套简化版本class Safe360 { private $bad [select, union, sleep, benchmark, 0x]; private function filterArray($data) { if (!is_array($data)) { return str_ireplace($this-bad, , $data); } foreach ($data as $v) { $v $this-filterArray($v); } return $data; } public function run() { $_GET $this-filterArray($_GET); $_POST $this-filterArray($_POST); $_COOKIE $this-filterArray($_COOKIE); } }这段代码有两个问题。第一它把命中的关键字直接替换成空字符串select变成空但业务数据里完全合法的 select_box 这类词会被改坏第二它假设恶意的写法一定包含黑名单里那几个词而现实是在这个假设上绕过手段多到写不完。这套机制在 2008 年左右还有一定威慑力因为当时的注入工具大多直接拼关键字拦掉就断了。但现在的主流光语是报错注入、时间盲注、二次注入很少再出现大段连写的union select黑名单的覆盖面和响应速度都跟不上。2.3 黑名单为什么能被绕过六类典型手法实际渗透测试里绕过黑名单常用这六类写法每一类都能让简单的str_ireplace失效大小写混写UnIoN SeLeCt老类里用str_ireplace能接住但用str_replace的版本全部被绕过。内联注释/*!50000 UNION SELECT*/MySQL 会把注释里的内容当成真实语句执行而正则只匹配字面关键字时容易漏。注释切分uni/**/on sel/**/ectMySQL 解析时自动忽略注释符黑名单匹配时字符串并不连续。十六进制与函数编码0x756e696f6e或char(117,110,105,111,110)把关键字转成编码后不额外解码就比不出来。报错函数updatexml(1,concat(0x7e,user(),0x7e),1)核心特征在updatexml老类往往没收录。宽字节注入GBK 字符集下%bf%27转义后变成%bf%5c%27其中0xbf5c被当作一个汉字后面的单引号成功逃逸。所以如果只是把老类的黑名单加长本质是在和攻击者赛跑追不上的。2.4 三类防护方式的对比与选型防护方式处理粒度可绕过性数据完整性推荐使用场景关键字黑名单过滤字符串高依赖写法覆盖可能误伤只能做入口流量拦截层转义mysql_real_escape_string等字符串中宽字节仍有风险较好PHP 5.4 时代的过渡方案PDO 预处理 绑定参数SQL 结构级低完整现代 PHP 项目底线方案表格里最后一行才是真正的防线。黑名单类适合放在最前面挡自动化扫描器因为它响应快、好解释而防止数据被当成 SQL 执行必须靠预处理。顺序不能颠倒也不能互相替代。2.5 老代码在 PHP 7/8 下的兼容性硬伤接老项目时最常踩的兼容性问题有三个。一是magic_quotes_gpc在 PHP 5.4 被移除老类里get_magic_quotes_gpc()恒为false转义逻辑直接失效二是大量老类用的是mysql_real_escape_string而这个函数随ext/mysql在 PHP 7.0 被彻底移除跑起来就是Call to undefined function致命错误很多项目因此直接把类注释掉等于裸奔三是部分老类用了ereg/eregiPHP 5.3 就删了在高版本下连文件加载都过不去。所以第一步不是改逻辑是先确认这个类在当前 PHP 版本下能不能跑起来。跑不起来的类谈不上防注入。3. 改造后的防注入修改类拦截层和预处理层分开3.1 类结构设计只做输入扫描不碰 SQL 拼接改造方向是把类从过滤后返回数据改成发现恶意直接拦截。前者会在入口处修改$_GET导致业务侧拿到的数据可能被替换过后者保留原始输入业务侧用预处理去取数数据完整性不受影响。类的基本结构如下final class AntiSqlInject { private static $instance null; private $logFile; private $hexPattern /0x[0-9a-f]{4,}/i; private $filters [ union_select /\bunion\b\s\bselect\b/i, sleep /\bsleep\s*\(\s*[\]?\d[\]?\s*\)/i, benchmark /\bbenchmark\s*\(/i, updatexml /\bupdatexml\s*\(/i, extractvalue /\bextractvalue\s*\(/i, load_file /\bload_file\s*\(/i, information_schema /\binformation_schema\b/i, into_outfile /\binto\soutfile\b/i, null_bytes /\x00|\x1a|%00/i, ]; private function __construct(string $logFile /tmp/anti_sql_inject.log) { $this-logFile $logFile; } public static function getInstance(string $logFile /tmp/anti_sql_inject.log): self { if (self::$instance null) { self::$instance new self($logFile); } return self::$instance; } public function run(): void { foreach ([GET $_GET, POST $_POST, COOKIE $_COOKIE] as $name $data) { $this-scan($data, $name); } } private function scan($data, string $path): void { foreach ($data as $key $value) { $currentPath $path . [ . $key . ]; if (is_array($value)) { $this-scan($value, $currentPath); continue; } if ($this-isMalicious($value)) { $this-block($currentPath, $value); } } } private function isMalicious(string $value): bool { if (preg_match($this-hexPattern, $value)) { return true; } foreach ($this-filters as $pattern) { if (preg_match($pattern, $value)) { return true; } } return false; } }关键点逐一说明isMalicious先做十六进制字面量检查再走关键字正则0x[0-9a-f]{4,}匹配长度为 4 位以上的十六进制串是因为0xbf这类短串可能出现在真实内容里而注入构造的十六进制一般不会太短union规则写成union\sselect不单独匹配union避免把英文单词误杀。final关键字禁止继承子类覆盖安全逻辑这种坑直接堵死。3.2 拦截动作返回 403 并记录现场block方法负责中断请求同时把原始参数落盘方便事后分析攻击路径private function block(string $path, string $value): void { $record [ time date(Y-m-d H:i:s), ip $_SERVER[REMOTE_ADDR] ?? cli, method $_SERVER[REQUEST_METHOD] ?? , uri $_SERVER[REQUEST_URI] ?? , path $path, value $value, ua $_SERVER[HTTP_USER_AGENT] ?? , ]; error_log(json_encode($record, JSON_UNESCAPED_UNICODE) . \n, 3, $this-logFile); http_response_code(403); exit(request blocked); }这里用error_log的第三个参数追加写文件每条记录是独立的 JSON 行后续用grep按 IP、按接口都能查。json_encode保留原始值能直接看到攻击语句。注意不要把整个$_COOKIE写进日志里面可能带会话标识落盘后就是敏感信息泄露点。3.3 PDO 预处理兜底类只是第一道闸过滤类再完善也只是降低了攻击面真正的底线是让 SQL 语句里只有占位符没有变量$pdo new PDO( mysql:host127.0.0.1;dbnamenews;charsetutf8mb4, user, pass, [ PDO::ATTR_EMULATE_PREPARES false, PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, ] ); $stmt $pdo-prepare(SELECT id, title FROM news WHERE id ?); $stmt-execute([$_GET[id]]);ATTR_EMULATE_PREPARES false让 PDO 走 MySQL 原生预处理而不是在客户端把参数拼接成 SQL 再发给服务端从协议层面保证数据和语句分离。有些老项目的 MySQL 版本过低原生预处理会报错这时可以退回模拟预处理但必须同时保证连接字符集是utf8mb4否则宽字节注入仍然有机会。3.4 白名单校验把输入变成它应该长成的样子关键字过滤是不知道坏人长什么样只能记黑名单白名单是知道这个字段本来应该是什么样。public function validateInt($key, $value, int $min 0, int $max 2147483647): int { $value filter_var($value, FILTER_VALIDATE_INT, [ options [min_range $min, max_range $max], ]); if ($value false) { $this-block(int_check[ . $key . ], (string)$value); } return $value; }整数、日期、手机号、邮箱这类强格式字段用filter_var做类型约束比任何关键字过滤都干净。改造老项目时优先给 id、分页、状态这几个高频参数套白名单收益最大改动最小。4. 接入老项目的 5 个高频踩坑与修正4.1 入口怎么挂auto_prepend_file 还是框架引导文件老项目没有统一入口文件时最省事的做法是改php.iniauto_prepend_file /www/site/init_safe.phpinit_safe.php里写AntiSqlInject::getInstance()-run();PHP 会在执行任何脚本前先加载这个文件对整站生效。缺点是改配置要重启 PHP-FPM而且如果同服务器跑多个站点所有站点都会被这个文件影响。有框架的项目一般放在前端控制器里比如index.php的第一行、thinkphp的App初始化之后。这样只对当前应用生效调试时也方便用注解临时关闭某个接口。4.2 只拦截不要替换避免二次处理损坏数据网络上很多改版类会把select替换成空字符串。假设请求里有selselectect替换一次后剩select如果链路上还有第二层过滤就会把正常内容改坏。更实际的问题是搜索场景用户搜 C 或 union of flags替换后搜索词变了搜索结果对不上业务方排查半天找不到原因。改造原则只有一条命中即 403不做 str_replace。原样保留$_GET和$_POST数据完整性交给 PDO。4.3 JSON 请求体不在超全局数组里$_POST只在application/x-www-form-urlencoded和multipart/form-data时被填充。现代前后端分离项目发的是application/json$_POST是空的所有参数都在php://input里类扫了半天等于没扫。补一段读取逻辑$rawBody file_get_contents(php://input); if ($rawBody ! ) { $json json_decode($rawBody, true); if (is_array($json)) { $this-scan($json, RAW_JSON); } }注意file_get_contents(php://input)在 CLI 模式下会读标准输入接入时判断一下运行环境避免命令行脚本被阻塞。4.4 字符集与宽字节注入残留风险旧系统还在用 GBK 编码的话即使有addslashes宽字节注入依然能奏效。原理是攻击者提交%bf%27转义时插入\0x5c变成了%bf%5c%27而0xbf5c在 GBK 码表中是一个合法汉字后面的单引号因此逃逸成功。修正方法是数据库连接统一走utf8mb4并加载PDO::MYSQL_ATTR_INIT_COMMAND$pdo new PDO($dsn, $user, $pass, [ PDO::MYSQL_ATTR_INIT_COMMAND SET NAMES utf8mb4, PDO::ATTR_EMULATE_PREPARES false, ]);如果实在动不了老库的字符集至少要在每次连接后执行SET NAMES utf8mb4别用SET CHARACTER_SET那套老命令。4.5 拦截日志的格式与后续分析踩坑点现象修正方式日志写在 Web 根目录攻击者直接访问日志文件日志目录移到 Web 根之外用file_put_contents不带锁并发请求丢日志加LOCK_EX或走error_log记录完整 Cookie会话信息落盘泄露只记 Cookie 名称不记值无时间字段无法关联攻击时段JSON 行内加统一时间戳$logLine json_encode($record, JSON_UNESCAPED_UNICODE) . \n; file_put_contents($this-logFile, $logLine, FILE_APPEND | LOCK_EX);正常运营时这个日志要周期性轮转比如按天切文件配合 crontab 清理 30 天前的记录否则一个被盯上的站点一天能写几百 MB。5. 用真实 payload 验证改造结果5.1 起一个临时环境跑一组覆盖绕过手法的用例用 PHP 内置服务器起测试环境避免污染线上php -S 127.0.0.1:8080 index.php然后依次请求BASEhttp://127.0.0.1:8080/index.php?id # 经典联合注入 curl -i $BASE}1%20union%20select%201,2,3 # 大小写绕过 curl -i $BASE}1%20UnIoN%20SeLeCt%20user(),2,3 # 报错注入 curl -i $BASE}1%20and%20updatexml(1,concat(0x7e,user(),0x7e),1) # 十六进制字面量 curl -i $BASE}1%20and%2010x756e696f6e # 写文件 curl -i $BASE}1%20into%20outfile%20/tmp/a.txt预期行为全部返回 403且日志文件里能查到 5 条记录。这里用拼接的方式写 URL 变量是为了让你看清 payload 原文实际测试建议直接复制完整地址。5.2 反方向验证正常业务不能被误伤测试完攻击数据回头测正常请求。搜索场景里带 union 的英文短语、带 select 的下拉框字段名、JSON body 里的普通文本评论都必须能正常通过。误伤率是这类类能不能留在生产环境的唯一硬指标拦截了攻击但把下单流程弄断了运营第一个找的就是你。5.3 验证预处理层真的生效把类临时注释掉直接向接口提交?id1 union select user(),2,3观察返回结果里是否出现数据库用户名。如果出现说明该接口原本就存在注入截关注入类只是在挡流量没在修漏洞此时需要抓紧把该接口改成 PDO 预处理。这步验证的意义是提醒你任何过滤类都不能让已有漏洞消失它只是给修复争取时间。最后补一个生产实用配置日志按天切割并且用 crontab 每 10 分钟统计一次 403 事件同一 IP 半小时内触发 3 次就写入防火墙黑名单。这一步做完这个改造后的类才算真正落到了运维体系里而不是每次安全扫描前手动开一下。本文还有配套的精品资源点击获取
返回列表