ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SQL注入防御实战:从拼接原理到参数化查询与最小权限

SQL注入防御实战:从拼接原理到参数化查询与最小权限

1. 先把话说清楚:SQL注入到底在“注入”什么

在聊防御和修复之前,我建议你真的去理解一下SQL注入的本质。网上很多文章一上来就堆payload,什么' or 1=1--,看是看懂了,但换个场景就不会了。我这几年带过不少刚入行的开发,发现大多数人卡在一个点上:没搞懂为什么用户输入能变成数据库命令。

其实SQL注入没有多玄乎,就一句话:程序把用户输入的字符串,直接“拼接”到了SQL语句里,输入内容变成了代码的一部分。

打个比方,你去面馆点餐,本来流程是你说“来碗牛肉面”,老板听完后告诉后厨“一份牛肉面”。结果有个顾客不但说“来碗牛肉面”,还补了一句“再加二两牛肉,记我账上”。收银员如果偷懒,不核对就把原话原封不动写进后厨单子,后厨一看,真就多做了二两牛肉。

SQL注入就是这种场景。只不过收银员不是人,而是一段代码,后厨单子不是纸片,是数据库要执行的SQL语句。用户输入的那些内容,本该被当成“数据”处理,结果却被当成了“命令”的一部分执行了。区别在于:面馆多损失二两牛肉,网站可能直接丢了整个数据库。

所以你要记住一句话:SQL注入不是数据库的漏洞,是代码写的漏洞。数据库本身是忠实的,坏就坏在有人把不可信的内容直接拼进了命令里。

2. 最常见的三类注入玩法,拆开看就明白了

2.1 字符型注入:一个单引号捅破一切

先看一段最典型的漏洞代码,用伪代码写,避免偏向某种语言:

$sql = "SELECT * FROM users WHERE username = '" . $username . "' AND password = '" . $password . "'";

假设后端表单接收两个参数,$username和$password,然后直接拼进SQL。这时攻击者在用户名输入框里敲一行“魔法字符串”:

admin' or '1'='1' --

注意最后的--后面要跟一个空格,这是SQL里的单行注释写法(MySQL里--后面有空格才生效,Oracle也行)。拼接出来的SQL就变成了:

SELECT * FROM users WHERE username = 'admin' or '1'='1' -- ' AND password = 'xxx'

看破案了吧:username = 'admin' or '1'='1'这个条件永远成立,后面用注释符--把原本的AND password = 'xxx'直接“闭嘴”了。最终登录验证被绕过,攻击者成功以第一个匹配用户——通常是管理员,因为admin一般排最前——的身份进入系统。

拆解一下这段payload的三个关键部件:

  • admin':想办法提前闭合前面那个左引号。
  • or '1'='1':构造一个恒为真的判断,骗过where条件。
  • --:注释掉后面多余的SQL片段,避免语法错误。

原理都不复杂,但很多人自己写渗透测试报告的时候,只会复制payload,讲不清这三个部件各自的作用。前几年我帮某金融项目做代码审计,产品代码里居然还能翻出这种直接拼接SQL的老古董,而且那条SQL查的还是交易流水表。这类漏洞一旦被人打进去,数据泄露的量级不是几百条,是整套核心账务数据。

2.2 数字型与搜索型注入:换汤不换药

字符型注入要处理引号闭合,数字型注入会更“偷懒”。比如一个商品列表页,URL长这样:

/product?id=102

后端代码可能写成:

$sql = "SELECT * FROM products WHERE id = " . $_GET["id"];

这样的场景,攻击者连单引号都省了,直接试:

/product?id=102 OR 1=1

如果后端没做数字校验,最终SQL变成:

SELECT * FROM products WHERE id = 102 OR 1=1

结果就是整个商品表全被查出来,接口直接变成拖库通道。所以我在做开发规范的时候总强调:参数只要声明是int,就先过一遍过滤或类型强制转换,不是靠数据库去防,而是在进代码逻辑之前就把门槛立起来。

搜索型注入则是另一种常见的坑。搜索功能通常会写成LIKE模糊匹配:

$sql = "SELECT * FROM articles WHERE title LIKE '%" . $keyword . "%'";

攻击者在搜索框里填入:

%' UNION SELECT username, password FROM users --

闭合掉前面那个%',再用UNION把用户表的数据“拼接”到结果集中。这个手法比or 1=1更隐蔽,因为查询结果会直接回显到搜索列表页面,等于把整个用户表的账号密码字段摆到页面上。

2.3 盲注、报错注入和堆叠注入:SQL注入的变种家族

上面的例子都是“有回显”的,即查询结果能直接显示到页面上。但很多系统做了输出过滤,页面上看不到任何异常数据。这时攻击者就会改用“盲注”。

盲注的核心思想是:执行SQL后,根据页面是否正常返回、响应时间长短,来判断数据库里的某个条件是真还是假。比如猜密码长度:

SELECT * FROM users WHERE id=1 AND LENGTH(password)=8 -- 页面正常 SELECT * FROM users WHERE id=1 AND LENGTH(password)=9 -- 页面报错或空白

逐字符逼供,几十个请求就能把密码“拆”出来。这种方式自动化脚本特别擅长。你如果去翻某一次安全演练的日志,经常能看到一批批这种短小精悍的请求,就是扫描器在跑盲注。

报错注入则是利用数据库本身的报错信息来回传数据,比较经典的姿势是让数据库“报错”时把查询结果带出来。这类攻击比较依赖数据库版本,不同版本有不同的报错函数,只要前端没把报错内容屏蔽掉,攻击者就能通过一条条报错信息逐段拼出整个数据库内容。

堆叠注入更狠一点,用分号把多条SQL串在一起一次执行。字符型注入通常只能执行“一条”查询,分号结尾被很多数据库API拦住了,但如果你用的数据访问层直接执行多条语句,攻击者就可以:

1'; DROP TABLE users; --

这条命令直接删表。2019年前后某开源建站系统爆出过一个后台漏洞,就是登录接口没做参数化,导致管理员后台可以任意执行堆叠SQL。很多站点被挂马,就是这类攻击打出来的。

3. 为什么受害者总是“看起来防住了,其实没防住”

3.1 只堵“单引号”和“select”的下场

见过太多团队的安全策略,是给系统塞一个“黑名单过滤器”,把'、"、select、union、and这些词全部过滤掉。听起来好像很严谨,实际上一击就碎。

原因不复杂:过滤规则写不完整,而绕过手段是无限的。比如黑名单过滤了select,攻击者就写:

SeLeCt * FROM users

大小写混写,如果数据库大小写不敏感,过滤器只匹配小写就白写了。如果过滤了空格,那就用%0a换行符、%09制表符、括号等方式代替空格。如果过滤了=,可以用LIKE代替:

admin' OR '1' LIKE '1' --

我再给你看一个经典的“编码绕过”:攻击者把payload做一次URL编码、Unicode编码或者十六进制拼包,如果过滤器写在解析URL参数之前或之后的位置不对,就能整个跳过去。

所以我做安全培训的时候经常讲:黑名单永远防不住白名单,因为你是在猜坏人会怎么想,而坏人只需要找到你没猜到的那个角落。

3.2 报错信息太详细,等于给攻击者送地图

我在不少项目里看到过这种情况:系统页面一报错,直接把异常堆栈、执行的SQL原文、数据库版本信息全部渲染到浏览器上。这种做法等于在门口贴了一张“内部结构图”——攻击者根本不需要猜,打开报错页就知道数据库是MySQL 5.7还是SQL Server,知道表名前缀是什么,知道执行的SQL长什么样。

最离谱的是,有次我接手一个老系统的安全整改,发现线上页面的404错误页里居然包含完整SQL语句。开发当时图省事,把开发的调试习惯直接带上线了。

翻出SQL语句意味着什么?攻击者可以据此精准构造payload,不用一个个试,直接一步到位。所以我会强制要求:生产环境绝不返回详细的异常信息,日志里记录归记录,页面上一律显示模糊的通用错误。

3.3 给数据库账号“高权限”,等于把整个家底交出去

SQL注入的破坏力很大程度上取决于数据库账号的权限。如果应用连接数据库用的是超级管理员账号,攻击者通过注入就能:拖库、改数据、删表、写文件、甚至调用数据库的一些存储过程反弹一条系统命令出来。

很多小团队图省事,开发上线一把梭,数据库连接串里就是user=root,密码直接写在配置常量里。普通站点的注入攻击,最后变成整个数据库服务器沦陷,这种情况不在少数。

正确的习惯是按需分配权限:普通业务查询用只读账号,写入操作用只有INSERT/UPDATE权限的账号,删除操作放到单独的高权限通道并走审批。数据库层面再限制这个账号只能访问指定的库、指定的表,禁止调用文件函数,严格禁止跨库访问。权限收窄之后就算注入成功,攻击者能碰到的数据也只是一个子集,不至于一锅端。

4. 防御实操:把SQL注入挡在门外的六个关键动作

4.1 参数化查询:所有防御体系的地基

参数化查询是公认的、最有效的SQL注入防御手段,没有之一。它的思路是:SQL的结构预先写好,用户输入以参数形式传入,数据库执行时把它当纯数据,而不是SQL指令。

以当前主流开发语言为例,简单过一遍写法:

Java / JDBC / PreparedStatement

String sql = "SELECT * FROM users WHERE username = ? AND password = ?"; PreparedStatement pstmt = conn.prepareStatement(sql); pstmt.setString(1, username); pstmt.setString(2, password); ResultSet rs = pstmt.executeQuery();

PHP / PDO

$sql = "SELECT * FROM users WHERE username = :username AND password = :password"; $stmt = $pdo->prepare($sql); $stmt->execute(['username' => $username, 'password' => $password]);

Python / sqlite3

sql = "SELECT * FROM users WHERE username = ? AND password = ?" cursor.execute(sql, (username, password))

看到规律没有:SQL模板里用?或:name占位,用户输入通过绑定参数传过去,数据库接收到的数据只是普通字符串,再也不会被解释成SQL语法。即使攻击者传入admin' or '1'='1' --这类字符串,数据库也会按原样当成一个字符串值去比较,压根不会和SQL的引号逻辑纠缠。

4.2 白名单校验:能挡掉大部分无脑扫描

参数化查询解决的是“SQL结构被篡改”的问题,白名单校验解决的是“业务层面的非法输入”问题。两件事不冲突,一前一后配合使用。

我在项目中推荐的做法是四层校验:

  • 类型校验:声明是int的参数,进来先强制转成int,不是就丢400。
  • 长度校验:用户名限定20个字符,超出就拒绝。很多攻击payload动辄上百字符,这层就能杀掉一批不擅长压缩payload的攻击者。
  • 格式校验:邮箱、手机号、日期,用正则做白名单匹配,比如邮箱只允许字母数字和@._-,日期只允许\d{4}-\d{2}-\d{2}。
  • 枚举校验:对于有限取值范围的状态字段,比如订单状态只允许PENDING、PAID、SHIPPED三个值,直接做字典匹配,不属于这三个值就拒绝。

校验要写在服务端,不要只依赖前端的JS校验——那东西用浏览器控制台随手就能绕过。你后端接收到的任何输入都不能假设“用户是好人”,登录名、搜索词、排序字段、分页页码、上传文件名,全部纳入校验范围。

4.3 最小权限:给应用账号“锁门”

这个话题前面提过,这里给出落地标准。我在公司内部的安全基线文档里是这样写的:

  • 数据库账号名与应用一一对应,不混用。
  • 业务账号只具备当前业务库的增删改查权限,跨库一概没有。
  • 涉及删除语句的地方,如果不是核心业务必需,账号甚至不给DELETE权限。
  • 存储过程能封装的权限操作就封装掉,应用只调存储过程,不直接操作表。
  • 禁止数据库账号执行文件写入、加载本地文件的函数。
  • 定期核查账号权限,离职人员、服务下线账号一律回收。

这套基线看起来琐碎,但每一条都对应着真实攻击场景。最小权限的意义不在于阻止注入本身,而在于当注入已经发生时,把爆炸半径压到最小。攻击者拿到了SELECT权限,最多拖走业务表;拿到FILE权限,就有机会往服务器上写马——这两者的破坏力差距,是一个数量级的。

4.4 错误信息脱敏:别再做“活地图”

生产环境关闭详细报错,这件事要写进上线检查清单。在我见过的系统里,比较典型的问题有:默认框架的DBException直接渲染到前端、全局异常拦截器没处理好就把堆栈打在页面上、日志文件里随手打印SQL拼参语句并且丢失日志权限控制。

安全整改时我会这么做:

  • 框架层面开启生产模式,关闭debug。
  • 全局异常拦截器统一输出服务器繁忙,请稍后重试这样的脱敏文案,真实异常详情只进日志文件。
  • 日志文件禁止明文记录用户密码、Cookie、银行卡号等敏感字段。
  • 如果是API系统,联调环境与生产环境走完全不同的配置,避免生产配置里残留SQL打印开关。

4.5 纵深防御:WAF不是万能药,但必须有

WAF(Web应用防火墙)的作用是在流量层拦截攻击特征。它能识别常见注入载荷,比如union select、or 1=1、sleep(这类模式,在请求到达业务代码之前就拦下来。

但我得说句实在话:WAF是最后一道保险,不是第一道防线。原因很简单,WAF的规则库永远是滞后的,高级攻击者手动构造的payload可以轻松绕过规则匹配。而且WAF误报率也不低,比如你系统里有个正常的搜索词叫union select coin,就可能被WAF拦成注入攻击,这个误伤处理起来也烦。

线上部署WAF时,建议做三件事:一是开启实时拦截的同时保留审计日志,方便后期回溯;二是每周检查一次拦截报表,把误报规则加白名单;三是WAF的拦截规则要跟业务版本同步更新,不能上线后三五年不维护。

除了WAF,数据库防火墙和入侵检测也可以纳入考虑。比如在数据库侧设置“访问来源IP白名单”,业务服务器之外的IP一律拒绝连接数据库,这个动作能挡掉一大半通过数据库端口直接打进来的尝试。

4.6 ORM与存储过程:降低手工拼接的出错概率

现在的开发框架普遍推荐ORM,因为ORM自动生成参数化查询,天然免疫很大一部分SQL注入。但使用ORM不代表你可以完全放手,我在实战中发现OR M的“后门”也不少:

  • 部分ORM支持原生SQL查询接口,开发图省事直接写原生SQL,或者连续使用拼接方式构造复杂查询条件。
  • ORM的排序字段、表名字段如果直接透传,还是会留下注入缝。
  • 动态条件查询构建时,有些框架允许外部传入排序名,最后直接拼到ORDER BY后面,这块很多参数化方案覆盖不到。

所以我的经验是:ORM挡得住90%的场景,但那只剩的10%,往往就是出大事故的那10%。核心业务查询尽量全部走ORM的参数化API;如果必须用原生SQL,走公司统一封装的数据访问层,并且强制Code Review;排序字段白名单化,只在预设好的几个字段里选。

存储过程也存在同样的情况:有些存储过程内部还是会用动态SQL拼接。封装的目的是降低暴露面,不是自动安全,里面代码写得不干净同样会被注入。

5. 实操排查:发现疑似SQL注入后的处理流程

5.1 第一步:怎么判断系统是不是已经中了招

如果你接手一个老旧系统,心里没底,可以从这几个地方快速判断:

  • 翻数据库慢查询日志,看有没有大量异常的OR 1=1、UNION SELECT、SLEEP(、EXTRACTVALUE这类特征。
  • 翻Web访问日志,看有没有针对.php?id=、.jsp?id=这类带参数接口的密集探测请求,特别是同一个IP短时间内几十上百次请求。
  • 看业务数据表里有没有莫名多出来的用户、被修改的字段、异常的账号权限。
  • 看数据库有没有被创建了奇怪的备份表,攻击者拖库时经常先建临时表再慢慢取数。

建议你在发现可疑痕迹时,先做只读分析,不要急着清理现场。很多安全事件最后查不清源头,就是因为运维一上来就删了日志、改了密码、杀了进程,把证据全毁了。正确顺序是:先对日志、数据库表、敏感文件做快照,再开展排查。

5.2 第二步:止血与隔离

确认被注入后,按这个顺序操作:

  1. 切断外网对业务入口的访问,或者先用WAF把相关接口设为完全拦截。
  2. 在数据库层把应用账号口令改掉,防止攻击者留着后门慢慢用。
  3. 对关键表做一次逻辑备份,注意是备份当前受污染状态,不是直接恢复,这个快照要给后续分析用。
  4. 检查服务器有没有被植入恶意文件,重点看/tmp、上传目录、缓存目录。
  5. 如果确认数据被篡改,用历史备份做差异比对,找出被改的行和字段,再评估恢复方案。

这里要特别提醒:先备份再处理。我见过有人发现被注入了,立刻把库恢复成昨天的备份,把今天所有的正常业务数据也一起丢了,赔得比黑客还惨。现场保存的意义是:你不仅要修复,还要知道攻击者拿到过什么,才能决定要不要通知用户改密码、要不要上报。

5.3 第三步:从根上修复漏洞,而不是仅仅删掉日志

止血之后的修复动作,很多人会犯一个错误:以为删掉恶意数据、改个密码、重启服务就算完事了。实际上,只要注入点还在,攻击者改天换一种姿势照样进来。

修复的优先级我帮你排一下:

  • 第一步,全量排查所有SQL拼接点。可以用静态代码扫描工具扫一遍,把可疑的查询语句全部列出来,人工复核。
  • 第二步,把所有查询改造成参数化查询或ORM API,禁止任何字符串拼接。
  • 第三步,收窄数据库账号权限,同步清理配置里的明文口令和长期不用的高权限账号。
  • 第四步,上线前做一轮渗透测试,重点测登录、搜索、排序、分页、文件上传这几个高风险入口。
  • 第五步,复查日志策略,确保审计日志保留至少180天,方便后再有事能做回溯。

6. 常见问题与速查表

把我在项目里被问到最多的几个问题整理一下,直接按表自查:

问题现象可能原因排查与修复建议
过滤了单引号仍被注入攻击者使用编码绕过或大小写混写废除黑名单,全面改参数化查询
登录接口限制了失败次数仍被拖库注入点不在登录,可能在搜索/分页接口全量扫描所有SQL入口,别只盯登录
WAF已开启但攻击成功规则库过期或攻击者手写变体绕过WAF只能做辅助,重点修代码层漏洞
盲注测不出来攻击请求没有明显特征,响应时差微弱审计慢查询日志与响应时间异常记录
用了ORM还是被注入存在原生SQL直连或排序字段白名单缺失禁止ORM中混写原生SQL,排序字段强制白名单
攻击者拿到数据后数据库被删应用账号权限过大最小权限改造,删除FILE/PROCESS等高危权限

7. 末尾补几句掏心窝的话

我做了这么多年代码审计,SQL注入是我最不愿意看见的漏洞类型。不是因为多难修,而是它的成因太低级:大部分情况下就是开发的时候少写了一行参数绑定。”

但反过来说,SQL注入也是新人最容易学会防的一种。你不需要成为安全专家,只需要养成一个习惯:凡是进到程序里的用户输入,代码层面先按“不可信”处理,数据库访问一律参数化。

这几年我帮不少项目做安全整改,最深的体会是:SQL注入的攻击手法在变,但防御动作始终是那三板斧——参数化查询、最小权限、错误信息脱敏。把这三件事做扎实,你就能挡住至少九成以上的注入攻击。剩下的那些,交给定期的代码审计和渗透测试去兜底。

最后再分享一个小技巧:如果你是在维护一个老项目,暂时没有时间做大规模改造,至少先把数据库账号权限收窄,再把异常报错关掉。半小时的改动,往往就能把一次潜在的大事故降级成一场虚惊。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进