☰
SQL注入攻防实战:从原理分析到参数化查询的纵深防御
2026/10/19 13:09:28 网站建设 项目流程

1. SQL注入到底是什么:从一次"看似正常"的登录说起

做安全测试和代码审计这些年,SQL注入一直是我最常碰到、也最头疼的问题。很多开发同学觉得这都2025年了,各种框架和ORM早就把SQL注入堵死了,但实际上我在代码评审和渗透测试里,依然能稳定地在各种"意想不到"的地方翻出注入点。所以我打算用这篇文章,把SQL注入从原理到实战到防御,完整地讲透一遍。

这篇文章面向三类人:一是刚入门安全、想搞懂注入原理的新手;二是写后端代码、想知道怎么从根本上防住注入的开发;三是做安全测试、需要一套完整排查思路的同行。文章会用大量可操作的示例代码和步骤,你能直接照着复现和排查。

先看一个经典的登录场景。假设某系统后端用Java处理登录请求,代码大概是这样的:

String sql = "SELECT id FROM users WHERE username='" + username + "' AND password='" + password + "'"; Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql);

如果用户在用户名输入框里填的是:

admin' --

那拼接出来的SQL就变成了:

SELECT id FROM users WHERE username='admin' -- ' AND password='任意内容'

在SQL里,--是单行注释。上面这条语句的查询条件实际只剩username='admin',后面的密码验证直接被注释掉了。攻击者连密码都不用输入,直接以admin身份登录系统。

1.1 SQL注入的本质:把用户输入拼进SQL语句

SQL注入的本质,用一句话概括:程序把用户可控的输入,当作SQL代码的一部分拼进了查询语句,导致输入中的特殊字符改变了SQL的语义。

这里有个很关键的概念需要区分:数据和代码。正常程序逻辑里,用户输入是"数据",SQL语句执行逻辑是"代码",两者是完全不同的层次。而注入问题的根源,是程序把数据当成了代码来执行,打破了这条边界。

可以拿日常生活打个比方。比如你写了一张购物清单交给助手,清单上写着"买两斤苹果,如果看到香蕉也买两斤"。正常情况下助手按清单执行。但如果助手把别人夹带进清单的纸条也当成购物指令,那这张纸条上的内容就变成了"代码"——也许上面写着"把保险柜打开",助手还真照做了。SQL注入就是类似的问题:攻击者往购物清单里(SQL语句)夹带了自己的指令,数据库这个"助手"分不清哪些是正常数据、哪些是攻击指令,全都执行了。

1.2 注入是怎么发生的:一段代码的问题现场

我们再看一个更接近真实业务的场景。某系统的商品搜索功能,支持按分类ID筛选商品,后端代码是PHP:

$categoryId = $_GET['category_id']; $sql = "SELECT * FROM products WHERE category_id = " . $categoryId . " ORDER BY price DESC"; $result = mysqli_query($conn, $sql);

正常情况下,用户访问:

/products?category_id=5

执行的SQL是:

SELECT * FROM products WHERE category_id = 5 ORDER BY price DESC

这条语句没问题。但攻击者把URL改成:

/products?category_id=5 UNION SELECT username, password, 1, 1 FROM users

拼出来的SQL就变成了:

SELECT * FROM products WHERE category_id = 5 UNION SELECT username, password, 1, 1 FROM users ORDER BY price DESC

这条语句的含义彻底变了:先查出分类ID为5的商品,然后联合查询users表里的用户名和密码,最终页面会直接把用户表的数据渲染出来。如果业务代码直接遍历结果集输出,攻击者就能在页面上看到所有账号密码。

这里需要补充一个基础概念:UNION联合查询要求前后两个SELECT的列数一致、列类型兼容。攻击者通常需要先试探出目标表的列数,用的方法是在URL后面加ORDER BY n,从ORDER BY 1开始试到ORDER BY 10,当某个数字报错"Unknown column"时,说明列数比这个数字少。

注意,注入点分两种,刚才演示的是数字型注入,参数没加引号,直接拼接进了SQL。还有一种是字符型注入,参数被单引号包裹,比如:

WHERE username = '$username'

攻击者需要先闭合前面的单引号,再用注释符把后面的引号吃掉,这就是文章开头登录案例的做法。判断是数字型还是字符型,最简单的方法是输入一个单引号看页面报错,或者输入1 AND 1=1和1 AND 1=2看页面返回是否不同——如果两次结果不一样,大概率存在注入点。

2. 注入类型拆解:从报错、盲注到堆叠注入

很多人对SQL注入的理解停留在"能联合查询出数据"这个层面,实际上注入的利用方式远不止一种。不同场景下,攻击者会选用完全不同的注入手段。我在实际测试中遇到过不少POST请求、JSON参数、排序字段里的注入点,各有各的玩法。下面逐一拆解。

2.1 报错注入:让数据库把答案念出来

报错注入的思路很有意思:既然页面会把数据库的报错信息显示出来,那就构造一条SQL,让数据库在执行时把敏感数据拼进报错信息里。

最经典的报错注入函数是MySQL里的updatexml()。这个函数本身的作用是更新XML文档,第二个参数是一个XPath表达式,如果表达式语法错误,MySQL会返回错误信息,并且错误信息里会包含这个参数的内容。于是攻击者构造如下语句:

AND updatexml(1, concat(0x7e, (SELECT password FROM users LIMIT 1)), 1)

当数据库执行到这条语句时,concat(0x7e, (SELECT password FROM users LIMIT 1))会先执行子查询,取出第一条用户的密码,拼上一个波浪号前缀,然后交给updatexml作为XPath表达式解析。这个表达式必然语法错误,于是MySQL报错,报错信息中会原样带出拼接后的字符串——密码就这样"念"出来了。

页面上的效果类似:

XPATH syntax error: '~e10adc3949ba59abbe56e057f20f883e'

这段看起来像随机字符的东西,就是MD5加密后的密码。虽然加密了,但攻击者可以拿去跑字典、查彩虹表,或者直接撞库,反正加密强度不够的话基本等于明文。

我在实际测试中还经常用的是extractvalue(),用法类似,原理一致。这类注入的利用条件有两个:数据库开了报错可见(开发环境常见,生产环境也有配置不当的情况)、页面直接把数据库错误信息渲染出来。

2.2 布尔盲注和时间盲注:看不见数据也能"猜"

相当多的时候,页面不会回显数据库错误,也不会显示查询结果——比如登录接口只返回"成功"或"失败",或者一个判断接口只返回"存在"跟"不存在"。这种情况叫盲注,分两种:

布尔盲注的核心思路是:把"是否能触发某条条件为真"作为信息通道。比如某接口判断用户名是否存在:

SELECT * FROM users WHERE username = '$username'

如果用户名叫admin,页面返回"存在";否则返回"不存在"。攻击者可以构造:

admin' AND SUBSTRING((SELECT password FROM users WHERE username='admin'), 1, 1) = 'a' --

如果密码第一个字符确实是a,那么整条WHERE条件为真,页面返回"存在";如果猜错了,条件为假,返回"不存在"。就这样一个字符一个字符地猜,配合二分法或者ASCII()函数,几十次请求就能把整段密码猜出来。手工测试很累,但用工具(比如sqlmap)可以自动化完成。

时间盲注更进一步,适用于页面既不回显错误、也不区分"存在/不存在"的场景——所有请求返回内容完全一样。这时候攻击者利用的是数据库执行时间这个侧信道:

admin' AND IF(ASCII(SUBSTRING((SELECT password FROM users LIMIT 1), 1, 1)) > 100, SLEEP(5), 0) --

如果第一个字符的ASCII值大于100,数据库会执行SLEEP(5),整个查询卡顿5秒再返回;否则立即返回。攻击者通过观察请求耗时,就能推断出字符范围。我在一次代码审计任务里见过一个典型的例子:某系统的导出报表接口接收一个日期参数,后端直接拼接进SQL查询,页面不管什么结果都返回"报表生成中,请稍后查看"。这个接口从界面看完全感知不到异常,但用时间盲注在几小时内就能把数据库里的敏感数据逐行拖出来。

2.3 堆叠注入与二次注入:两条容易被忽视的延展路径

堆叠注入(stacked injection)利用的是某些数据库驱动支持多条SQL语句用分号分隔连续执行这一特性。比如攻击者输入:

1'; DROP TABLE products; --

如果后端用的数据库连接支持堆叠查询,那么这条输入会变成两句SQL:第一句正常查询分类为1的商品,第二句直接删掉整个products表。MariaDB、SQL Server默认支持,MySQL原本不支持,但通过某些连接参数可以开启,PostgreSQL要看驱动配置。

堆叠注入的破坏力远大于普通的注入,因为它能执行任意SQL,而不仅仅局限于查询语句。但它有个前提:数据库驱动和连接配置必须允许多语句执行。很多框架默认不开启这个能力,所以实际利用门槛会比普通注入高。

二次注入则是另外一个套路,它的特点在于:攻击者恶意数据被安全地存进了数据库,但在后续某个环节被不安全地取出来拼接SQL。最典型的场景是注册功能:用户注册时输入用户名admin' --,注册逻辑本身用了参数化查询,恶意字符被当作普通字符串存进数据库,没有触发任何问题。但过了几天,用户修改密码时,后端有一段代码用了字符串拼接:

String sql = "UPDATE users SET password='" + newPassword + "' WHERE username='" + username + "'";

如果这个username是从数据库里取出来的,那原本存进去的admin' --就被拼进了新的SQL语句——注入点此时才会被触发。二次注入隐蔽性极高,常规的输入校验和参数化查询在"入口处"已经放行了,漏洞却爆发在完全不同的"出口处"。我做代码审计时,对这类"数据入库后再被取出拼接"的路径会格外关注,因为WAF和输入校验都对这种跨请求的注入无能为力。

3. 实操视角:一次完整的注入过程还原

理论上讲了一堆,不如跑一遍完整的流程。下面我模拟一个实际渗透测试中遇到的场景:某后台管理系统的"公告管理"功能,支持按标题搜索公告,请求形如:

GET /admin/announcement/search?kw=标题关键字

后端代码逻辑简化后为:

$kw = $_GET['kw']; $sql = "SELECT id, title, content FROM announcements WHERE title LIKE '%" . $kw . "%'";

这个接口有登录鉴权,但某次测试中我发现其中的子账号权限配置不当,低权限账号也能访问公告搜索接口。于是开始测试注入。

3.1 第一步:探测注入点

先在正常参数后加一个单引号:

GET /admin/announcement/search?kw=test'

页面返回500错误,部分错误信息泄露在响应体中,提示You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version。有报错、有MySQL版本信息,这说明参数没有经过任何过滤直接进入了SQL语句。

为了确认是字符型注入,再测试:

GET /admin/announcement/search?kw=test' AND '1'='1

页面正常返回公告列表。

GET /admin/announcement/search?kw=test' AND '1'='2

页面返回空列表,没有结果。

两条请求返回内容完全不同,布尔条件成立与否能直接反映在页面上——这是一个典型的字符型布尔盲注,也可以用报错注入直接出数据。两者结合测试下来,最终确定走报错注入路径,效率更高。

3.2 第二步:爆库名、爆表名、爆字段

先用updatexml报错注入拿库名:

kw=test' AND updatexml(1, concat(0x7e, (SELECT database())), 1) AND '1'='1

页面报错信息显示:

XPATH syntax error: '~admin_db'

当前数据库叫admin_db。随后爆表名,用information_schema.tables:

kw=test' AND updatexml(1, concat(0x7e, (SELECT table_name FROM information_schema.tables WHERE table_schema='admin_db' LIMIT 1)), 1) AND '1'='1

数据表一个个暴露出来,其中admin_user表吸引了我的注意。接下来爆字段:

kw=test' AND updatexml(1, concat(0x7e, (SELECT column_name FROM information_schema.columns WHERE table_name='admin_user' LIMIT 1)), 1) AND '1'='1

字段名逐个爆出,包括username、password、role等。最后直接查数据:

kw=test' AND updatexml(1, concat(0x7e, (SELECT concat(username, ':', password) FROM admin_user LIMIT 1)), 1) AND '1'='1

拿到了管理员账号和密码哈希。整个过程大约耗时20分钟,全部通过浏览器手工完成,不需要任何扫描工具。

3.3 注入的破坏力到底有多大

很多人以为SQL注入最多就是"泄露点数据",这种想法过于乐观。配合数据库的高权限和系统配置,注入的破坏力可以层层放大:

读取任意文件。MySQL的LOAD_FILE()函数可以读取服务器本地文件,只要数据库进程有权限、文件在允许路径内,就能直接读取web目录下的源码文件(比如.env配置文件),里面可能有数据库密码、API密钥。

写入webshell。如果数据库目录对web目录可写,SELECT ... INTO OUTFILE可以把内容写到web根目录,相当于直接在目标服务器上植入了一个网页后门,后续可以完全控制服务器。这个利用条件较苛刻,但一旦成立就是灾难级别。

提权到操作系统。利用数据库的扩展能力(比如UDF自定义函数)在特定配置下可以执行操作系统命令。我见过某次攻击中,数据库连接配置用的竟然是root账号,攻击者直接通过数据库执行了whoami,拿到的还是系统管理员权限。

企业资源耗尽。盲注场景下,攻击者为了让响应产生时间差,构造SLEEP(10)这样的语句,频繁发起请求,数据库连接池会被慢查询打满,正常业务直接不可用,造成事实上的拒绝服务。

所以我在给客户做安全评估时,对SQL注入的评级从来不低于"高危",一旦确认某个参数可注入但暂时无法进一步利用,我也会明确告知:这个漏洞只是暂时没被放大利用,修复优先级必须拉满。

4. 防御方案:为什么参数化查询能扛住95%以上的注入

讲完攻,接下来讲防。防御SQL注入的手段很多,但优先级非常明确:第一道防线永远是参数化查询(预编译语句),其他所有手段都是辅助。我见过不少团队折腾WAF、折腾过滤函数,却连最基础的参数化都没做好,这属于本末倒置。

4.1 参数化查询的原理:把数据和代码彻底分开

参数化查询的原理是:SQL语句的骨架先编译好,用户输入只作为参数传给已经编译好的语句,不再参与SQL语句的语法解析。用Java的PreparedStatement举例:

String sql = "SELECT id, title, content FROM announcements WHERE title LIKE ?"; PreparedStatement pstmt = conn.prepareStatement(sql); pstmt.setString(1, "%" + kw + "%"); ResultSet rs = pstmt.executeQuery();

注意这里的?是占位符,它只表示"这里会有一个值",在预编译阶段就已经确定了这个位置是数据,不是代码。即使攻击者输入test' AND updatexml(...),这段内容也只会被当作字符串常量传给SQL语句,数据库不会对它做语法解析。也就是说,'、--、AND这些字符完全丧失了改变SQL语义的能力。

一开始代码里的拼接方式,之所以危险,是因为用户输入在编译前就进入了SQL语句的文本里,数据库不得不把它当作语句的一部分来解析。参数化把编译和赋值两个阶段分离,从根上解决了这个问题。

一个常见的误区:以为用了ORM框架就自动免疫SQL注入。实际上ORM只是帮你封装了参数化查询,如果你在ORM里写了原生查询、或者用了String.format去拼SQL片段,注入该有还是有。我见过某系统用MyBatis,结果XML里全是${}拼接,效果跟直接拼字符串一模一样。MyBatis里#{}是参数化占位符,${}是直接拼接字符串,这两者的区别是安全边界,代码评审时必须一字一字看。

另一个误区是:"我做了输入过滤,单引号、关键字都拦掉了"。这种思路大方向没错,实操上很容易出问题。比如过滤了'和--,但攻击者可以用宽字节绕过、URL编码绕过、Unicode兼容性绕过;过滤了SELECT,还有sElEcT这种大小写混写(部分数据库不区分大小写);就算拦了常见关键字,还有无引号十六进制编码的方式绕。黑名单过滤的逻辑天然有漏洞,因为你永远无法穷举所有绕过姿势。这也是为什么安全界共识是"白名单参数化优先,过滤只做辅助"。

4.2 白名单校验与最小权限:哪怕注入成功也拿不到东西

参数化查询解决了大部分注入,但还有一类场景很难用参数化覆盖:表名、列名、排序字段、SQL关键字这些数据库结构层面的内容,不属于"数据",不能用占位符替代。比如用户可以选择列表按哪个字段排序、升序还是降序,代码可能写出:

String sortCol = request.getParameter("sort"); String sql = "SELECT * FROM products ORDER BY " + sortCol + " " + direction;

这种场景下,推荐的做法是白名单校验:预先定义好允许的字段名集合,用户传过来的值必须精确匹配集合中的某一项,否则就用默认值。

List<String> allowedSortCols = Arrays.asList("price", "sales", "rating", "create_time"); if (!allowedSortCols.contains(sortCol)) { sortCol = "create_time"; } String direction = "DESC".equalsIgnoreCase(dir) ? "DESC" : "ASC";

这样用户能够影响的只有"从集合里挑哪个",拼接进去的内容完全受控。

最小权限原则也是必须强调的防御纵深。很多系统为了图省事,数据库连接直接给root或DBA权限。刚才演示的LOAD_FILE、INTO OUTFILE、SLEEP这些高危操作,几乎都依赖高权限。如果应用账号只有SELECT、INSERT、UPDATE、DELETE这四类操作权限(按业务需求裁剪),而且能访问的库表被限制在业务必需的范围内,那么即使注入成功,攻击者也会发现:读不了文件、写不了文件、删不了其他库的表、甚至查不了information_schema之外的表,利用成本就大大增加。

我在一次客户系统加固中,发现某旧系统用的是DBA权限连接池,后来改成按业务拆分成3个不同权限的账号:查询账号、业务写入账号、管理维护账号。上线后模拟注入测试,原来能直接读文件的操作,现在报错ACCESS DENIED。权限最小化不会让注入消失,但它把漏洞的破坏半径牢牢困在了一个小圈子里。

4.3 纵深防御的其他手段:WAF、编码处理、错误治理

参数化是主防线,但实战中我还会叠加以下几层:

WAF(Web应用防火墙)放在最外层做第一波拦截,常见的注入特征(UNION SELECT、updatexml(、SLEEP(等)会被挡掉一批脚本小子的试探。但WAF不是万能钥匙——特征库总有滞后,攻击者可以fuzz绕过规则、可以利用二次注入绕过检测、可以用分块传输等手法绕过流量检测。所以WAF的角色定位是"降低攻击者效率、增加利用成本",而不是"一夫当关万夫莫开"。

数据库连接配置上,尽量关闭不必要的能力:比如MySQL的local_infile如果不必要就关掉(防止LOAD DATA LOCAL INFILE读取服务器文件);SQL Server如果不需要xp_cmdshell就禁用它;连接字符串里不开启多语句支持(防范堆叠注入)。这些配置项是很多人忽略的防御点,实际效果很好。

应用层编码:对于传进SQL之前涉及URL解码、JSON解析、XML解析的内容,要特别注意多层解码导致的二次编码绕过。我建议在接入层统一做一次输入解码规整,避免同一条数据在多个环节被不同规则解码,导致黑名单过滤被绕过。

错误处理与信息泄露:数据库异常必须统一捕获,返回给前端的只能是一句"系统繁忙,请稍后再试"。我见过太多生产系统把完整SQL语句、堆栈信息、甚至数据库版本号直接打到页面上,这给攻击者提供了极佳的探测辅助。线上环境务必关闭debug模式、关闭数据库错误回显。

数据库审计日志:开启对敏感表的操作审计,虽然不能阻止攻击,但能在事发后提供溯源线索。实战中很多安全事件追溯失败,就是因为日志不全或者根本没开审计。退一步说,即使注入成功,有完整的日志也能定位到攻击者用的时间和IP,对应急响应至关重要。

这几层叠加起来,就是常说的纵深防御:每一层都可能被绕过,但多层叠加之后,攻破所有层的成本和难度会成倍上升。

5. 常见问题与排查技巧实录:安全测试中的那些坑

这部分是我自己在做代码审计和应急响应中总结的经验,很多细节在网上教程里翻不到,写出来给同行参考。

5.1 常见问题速查表

问题现象可能原因排查思路推荐做法
翻遍代码没找到拼接SQL,但测试还是能注入使用了MyBatis的${}或原生Statement全局搜索${、Statement、createQuery等关键字统一改为#{}或PreparedStatement
输入'没报错、AND 1=1也没反应框架自带参数化,或参数并非直接进入SQL检查是否有ORM层,观察完整请求参数名确认无误即可放行,重点排查自定义拼接处
WAF拦截了测试,但手动测试能过WAF规则存在绕过尝试编码、大小写、注释符拆分等绕过技巧别过度依赖WAF,主防线仍然靠参数化
盲注请求导致数据库CPU飙高攻击者用时间盲注制造慢查询查看慢查询日志,定位请求来源IP及参数限流+封禁,同时排查并修复注入点
生产环境突然报"Unknown column"可能有人正在探测列数查数据库错误日志,看是否有ORDER BY报错确认注入点并紧急修复
代码里过滤了'但注入依然成功过滤思路没覆盖宽字节或二次编码场景测试%bf%27、URL二次编码等绕过姿势放弃黑名单思路,改用参数化查询

这里重点说一个高频问题:框架到底是否自动防注入。很多用Spring Boot的同学以为自己用了JPA就万事大吉,但如果代码里写的是@Query原生SQL并且用了+拼接参数,那JPA也挡不住。框架层会自动参数化的只有"通过ORM接口传入的条件参数",任何绕过ORM接口、直接拼SQL的行为都需要人工审查。我的评审经验是:凡是代码里出现String sql = "SELECT ..."这种以字符串形式拼接的语句,一律优先怀疑并在测试环境实测注入。

5.2 一份可以直接抄的代码评审SQL检查清单

每次做代码评审,我都会按下而这份清单逐项过一遍。这些内容适用于Java、PHP、Python、Go等所有后端语言,核心逻辑是通用的:

  1. 全局搜索executeQuery、executeUpdate、query、rawQuery等方法调用,逐一确认SQL语句是参数化还是字符串拼接。
  2. 全局搜索${符号(MyBatis场景下直接用变量拼接),确认是否出现在SQL相关位置。
  3. 检查排序字段、动态表名、动态列名相关的拼接逻辑,看是否走了白名单。
  4. 检查所有LIKE '%xxx%'查询,确保参数化写法是like ?而不是拼接进%。
  5. 确认数据库账号权限是否严格按业务最小化,禁止生产环境使用DBA账号跑应用。
  6. 检查错误处理逻辑,确认数据库异常不会回显详细信息到前端。
  7. 检查数据库连接串配置,关闭不必要的高危特性(多语句支持、本地文件读取等)。
  8. 关注批量导入、数据迁移脚本中的SQL拼接——这类脚本往往不上线评审但实现了全部注入风险。
  9. 检查第三方组件是否使用了老旧的数据库驱动或ORM版本,这类组件往往存在已知CVE。
  10. 对一个疑似安全的功能,跑至少两轮注入验证:一轮报错注入测试,一轮布尔盲注测试。

这份清单看起来简单,但实际执行中最大的障碍是检查者的"经验盲区"——很多开发者自己写的拼接代码,因为业务逻辑简单,根本无法想象输入能变成多语句或者报错注入。所以我建议把它作为标准审查步骤固化到CI流程里,配合SonarQube等静态扫描工具,至少能拦掉60%的常规注入。

5.3 侥幸心理的代价:一个埋在"小接口"里的漏洞

最后讲一个我印象深刻的真实案例。某次应急响应中,客户报告"数据库被异常删除",排查后发现是一个"内部工具类"接口出了问题——数据导出功能,开发同学图方便直接在接口里拼了SQL,测试的时候用正常参数验证过功能,没做过任何恶意输入测试。结果上线后,某内部账号异常操作,直接通过注入把一张核心业务表删了。事件调查下来发现,这个接口在系统里存在了两年多,开发可能觉得"内部接口,外网访问不到",但这个系统实际上有内网穿透配置,攻击者已经通过其他入口渗透进了内网,顺手就把这个接口当成跳板了。

这个案例给我最深的教训是:没有"不值得防护的接口"。只要接口接收了用户可控输入、并且输入进入了SQL,就一定要做防护。那些看起来人畜无害的"内部接口""管理后台""导出功能",往往是实战中最容易出问题的地方。攻击者的耐心和手段远超想象,侥幸心理是安全工作中最大的敌人。

文章写到这里,SQL注入的攻防体系基本完整了。做安全久了会发现,真正稳妥的思路永远是回到"数据和代码分离"这个原则上——所有把用户输入当代码执行的行为,都会出问题。希望这篇文章能帮你把SQL注入看透,不管是写代码还是做测试,都有自己的判断力。

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

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

立即咨询