SQL注入攻防全解析:从拼接漏洞到参数化修复的审计实战 1. 从一条报错日志说起为什么我盯上了这个注入点去年帮一个朋友做代码审计复盘他丢过来一份日志说后台某个接口偶尔返回500但前端页面看起来一切正常。我扫了一眼请求参数发现有个id字段传的是1 and 11返回内容和id1完全一致换成1 and 12页面结构没变但数据区空了。这个经典的布尔差异基本就把问题钉死在SQL注入上了。顺着这条线摸下去最终定位到的就是一套内容管理系统里比较典型的注入场景——本文要聊的这套系统我姑且按它的常见叫法用“云业系CMS”来代指。先把话说在前面这篇东西不是教你打站而是站在防守方和审计方的视角把这类注入的成因、触发路径、验证方法、修复思路完整拆一遍。适合谁看一是做PHP代码审计的同行二是维护这类老系统的运维和开发三是刚入门安全、想搞明白“一个注入点从发现到闭环到底长什么样”的朋友。全文所有环境都是本地搭的模拟靶场域名、路径、参数名做了脱敏处理别对号入座到任何线上资产。这类CMS的注入之所以值得单独拎出来讲是因为它踩中了老系统最典型的几个毛病参数过滤靠“想起来才加”、拼接SQL靠字符串、错误信息直接吐给前端、权限校验和数据处理耦合在一起。你把这几个点吃透再看同类系统基本就是换汤不换药。下面我按“先搞懂它怎么坏的、再动手验证、最后怎么修干净”的顺序往下走中间会穿插我自己踩过的坑和几个容易被忽略的细节。2. 注入点是怎么长出来的拼接逻辑与过滤缺位的双重作用2.1 一条典型查询的“犯罪现场”还原老PHP系统里最常见的写法长这样$id $_GET[id]; $sql SELECT * FROM news WHERE id . $id . LIMIT 1; $res $db-query($sql);问题一眼就能看出来$id从请求里原样取出来直接拼进SQL字符串。数据库引擎拿到的是完整语句它没法区分哪部分是“开发者写的结构”、哪部分是“用户塞进来的数据”。你传1它查id为1的记录你传1 OR 11它就把整张表都给你捞出来。这不是数据库的错是拼接这种写法本身把“代码”和“数据”的边界给抹掉了。那为什么很多系统会写成这样因为早期开发节奏快框架不成熟mysql_query那套API就是让你拼字符串的。等到系统上线跑起来了再回头改成本高、风险大于是就这么将就着。我见过不少系统新写的模块用了参数化老模块还是拼接结果老模块成了整个系统里最软的柿子。2.2 过滤函数为什么经常“看起来加了其实没加”很多开发者会说“我加了过滤啊”然后给你看这么一段$id intval($_GET[id]);如果参数确实是整型intval是有效的1 and 11会被截成1。但坑在于不是所有注入点都是整型。比如搜索、排序、翻页这类参数经常是字符串或者字段名intval一用功能就废了于是开发者只能退而求其次用addslashes或者自己写个str_replace。addslashes的问题在于它只转义引号对数字型注入完全无效——你1 and 11里根本没有引号它转义个寂寞。而自己写的str_replace黑名单更是重灾区我见过过滤select却漏了SELECT大小写的过滤了空格却没想到用/**/替代的过滤了union却没防union all的。这类“补丁式过滤”最大的问题是你永远不知道自己漏了哪一种而攻击者只需要找到一种。这里有个我反复强调的判断原则只要看到参数是直接拼进SQL的不管前面套了多少层过滤函数都要按“可能可注入”来对待逐个验证而不是看到addslashes就放心。2.3 错误回显把“盲注”变成“明注”的加速器还有一个经常被低估的放大器是错误信息。老系统里mysql_error()直接把数据库报错打到页面上攻击者传个1页面回一句You have an error in your SQL syntax near 1注入点位置、数据库类型、甚至表结构线索全暴露了。本来需要盲注慢慢猜的东西一个报错就省了一大半功夫。我在审计时有个习惯先看系统有没有关掉错误回显。display_errors开着、mysql_error直接输出的系统审计优先级直接拉满因为它的“信息泄露面”比关闭回显的系统大得多。修复的时候关回显和修注入要一起做只修一个都算不上闭环。3. 本地复现把注入点跑通需要哪几步3.1 靶场搭建的最小化配置复现这类问题我不建议上来就搞全套环境成本太高。最小化配置就够一个PHP运行环境7.x即可老系统兼容性好、一个MySQL实例、把目标系统的源码丢进web目录、导入它自带的初始化SQL。数据库账号给最小权限只给目标库的SELECT/INSERT/UPDATE别用root这是复现时的基本安全习惯——万一你手滑跑了破坏性语句损失可控。配置上有个细节要注意php.ini里把display_errors先打开方便观察报错magic_quotes_gpc在老版本里默认开新版本已经移除如果你复现的环境是PHP 5.x记得确认这个开关状态它会直接影响你看到的注入效果。我第一次复现时就因为没注意这个传了带引号的payload发现被自动转义了排查了半天才反应过来是环境配置的锅。3.2 定位参数从功能入口反推数据流复现的第一步不是急着传payload而是先搞清楚哪些参数进了数据库。我的做法是从功能入口反推列表页的翻页参数、详情页的id、搜索框的关键词、排序的字段名这几个是注入的高发区。找到入口后顺着URL参数名去源码里搜看它被哪个文件接收、经过哪些函数、最终拼进了哪条SQL。这个过程里grep是你的好朋友。比如搜$_GET[id]把所有接收点列出来再逐个看它后面几行的处理逻辑。我一般会建个表格记录长这样参数名接收文件是否过滤拼接位置初步判断idnews.php无SELECT ... WHERE id高危keywordsearch.phpaddslashesLIKE %...%待验证orderlist.php黑名单ORDER BY ...高危这张表看着土但特别管用。它逼你把“感觉有问题”变成“证据指向有问题”后面写报告、提修复建议都靠它。3.3 布尔验证与报错验证的取舍验证阶段我一般先用布尔法因为它对系统影响最小、最不容易触发告警。传id1 and 11和id1 and 12对比两次返回的数据量或页面结构差异。如果11正常、12异常基本就确认了。报错法更直接但动静大。传个单引号看报错能快速确认注入类型字符型还是数字型。我的习惯是先用布尔法确认“有没有”再用报错法确认“是什么类型”两步走比一上来就甩payload稳妥。另外提醒一句验证时别用union select这种会改变结果集的语句去试探生产环境本地靶场随便玩线上环境一个union就可能把不该出的数据带出来性质就变了。4. 从注入到影响这条链路到底能走多远4.1 数据读取的边界在哪里很多人对注入危害的理解停留在“能拖库”但实际能走多远取决于数据库账号权限和系统架构。如果账号只有目标库的读权限那最多读到这个库的数据如果账号权限大、或者数据库和业务库混在一起影响面就完全不一样了。我在审计报告里从来不写“可能导致数据泄露”这种模糊话而是明确写“当前账号权限下可读取的表范围”这样修复方才知道优先级。还有一个容易被忽略的点注入能读的不只是业务数据。information_schema里存着所有表名、字段名攻击者拿到注入点后第一步往往就是查这个把结构摸清楚再精准拖数据。所以判断影响时要把“结构泄露”和“数据泄露”分开评估前者往往被低估。4.2 写操作与二次利用的风险比读更严重的是写。如果数据库账号有FILE权限理论上可以通过注入写文件如果有INSERT/UPDATE权限可以篡改数据。这类风险在老系统里不算罕见因为很多系统图省事数据库账号直接给的高权限。不过我要泼盆冷水别把“理论可行”当成“实际一定发生”。写文件受secure_file_priv、目录权限、web路径是否可知等多重限制实际利用门槛比读数据高得多。审计时我会把这类风险标为“条件触发”而不是无脑标高危这样报告才经得起推敲。4.3 为什么“只影响一个页面”是错觉经常听到一种说法“这个注入点只在后台某个冷门页面影响不大。”这话对一半。单看这个点确实入口窄但注入点往往是“系统性问题的一个样本”同一个开发习惯会在多个模块复现。我审计时发现一个注入点后一定会拿同样的参数处理模式去搜全站十有八九能再挖出几个。所以修复时不能只堵这一个洞要把同类写法一起排查否则就是打地鼠。5. 修复这件事参数化是终点但路上还有几道坎5.1 参数化查询为什么是首选而不是“选项之一”修复注入参数化查询预处理语句是唯一能从根本上解决问题的方案。原理不复杂SQL语句的结构先发给数据库编译参数后到数据库始终知道“这部分是数据不是代码”注入的语法结构进不来。PDO和mysqli都支持写法上$stmt $pdo-prepare(SELECT * FROM news WHERE id ? LIMIT 1); $stmt-execute([$id]);对比拼接差别就在于?占位符把数据和结构彻底分开了。有人会问“那我用intval强制转整型行不行”行但那只对整型参数有效字符串参数、LIKE查询、IN列表这些场景intval要么用不了要么会改变语义最后还是得回到参数化。所以我的建议是新代码一律参数化老代码改造时优先改高危点别指望用过滤函数糊弄过去。5.2 老系统改造时的现实妥协与底线现实里很多老系统没法一次性全改这时候要有取舍。我的底线是所有直接拼接用户输入的SQL必须改改不了的至少加白名单校验。比如排序字段order它没法参数化字段名不能当参数传那就用白名单$allow [id, time, title]; $order in_array($_GET[order], $allow) ? $_GET[order] : id;这种“枚举校验”对字段名、表名这类结构性参数是有效的因为它把“任意输入”收敛成了“有限集合”。但要注意白名单必须严格匹配别用strpos这种模糊判断否则id and 11里包含id也能过等于没做。5.3 错误回显、权限收敛与日志的三件套修完注入点还有三件事要一起做否则闭环不完整。第一关掉生产环境的错误回显display_errorsOff数据库错误写日志不写页面第二收敛数据库账号权限业务账号只给必要的库表权限别用root第三加SQL异常日志记录异常语句和来源IP方便事后追溯。这三件套做完即使将来再冒出新的注入点攻击成本和暴露风险也会高很多。我见过太多系统只改了注入点错误回显照开、账号权限照给结果换个参数又被打穿。修复不是“堵一个洞”是“把洞周围的墙也砌上”。6. 审计这类系统时我踩过的坑和几条私房经验6.1 别被“参数看起来是数字”骗了我最开始做审计时看到id123这种纯数字参数下意识觉得“这肯定是整型注入不了”。结果有一次传id123 and sleep(3)页面真卡了三秒——因为代码里虽然参数长得像数字但接收时用的是$_GET[id]原样拼接根本没做类型转换。从那以后我的原则是参数长什么样不重要代码怎么处理才重要。数字型参数照样要验证别靠肉眼判断。6.2 二次注入和编码绕过是最容易漏的角落常规注入点好找难找的是二次注入用户输入先存进数据库这一步可能转义了后面某个功能从数据库读出来又拼进SQL这一步没转义。这种点用常规的“传payload看回显”很难发现得顺着数据流走一遍。还有编码绕过比如%27、宽字节、大小写混写黑名单过滤在这些面前基本形同虚设。我的经验是只要系统用了黑名单过滤就默认它可绕过直接找拼接点别在过滤函数上浪费时间。6.3 写报告时“可复现”比“写得吓人”更重要最后说个软技能。审计报告里我从来不写“系统存在严重SQL注入可导致数据泄露”这种空话而是写清楚哪个文件、哪一行、什么参数、传什么payload、返回什么差异、当前账号权限下能读到哪些表。这样开发拿着报告就能直接改运维拿着能直接验证修复效果。报告的价值在于“可复现、可验证”不在于形容词堆得多吓人。这套系统我前后跟过几个版本注入点的形态在变但根子上的问题——拼接、过滤缺位、权限过大——一直没怎么变。把这三样盯死同类系统的审计和修复基本就有章法了。