半夜两点被电话叫醒,客户的登录接口被人用一串特殊字符打穿了,数据库里所有用户手机号和密码哈希被拖走,业务群直接炸锅。参与过安全加固和应急响应的朋友,应该对这种感觉不陌生——事后排查时发现,问题不在中间件、不在服务器,而是出在一个几乎所有项目里都存在的坏习惯上:把用户输入直接拼进SQL语句。这就是已经存在了二十多年、至今仍在各类漏洞榜单里霸榜前列的SQL注入攻击。
这篇文章不打算教你任何攻击技巧,而是纯粹从防御者视角,把“预防SQL注入攻击”这件事拆开揉碎,从最基础的原理讲到参数化查询的正确姿势,从ORM框架的常见误区谈到数据库最小权限落地,最后再给出一套老项目排查改造的实操流程。适合正在写业务代码的开发工程师、需要带团队做Code Review的技术负责人,以及负责安全巡检的运维同学参考。
1. SQL注入是怎么发生的:先看清攻击的本质
1.1 一段被拼接出来的SQL
先还原一个最常见的登录场景。很多祖传代码里,登录查询是这么写的:
java复制String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
写这段代码的人本意很简单:把用户填的用户名和密码拼进去,查一下数据库里有没有匹配的记录。问题是,如果攻击者在用户名字段里输入的是 admin' -- ,拼出来的SQL就变成了这样:
sql复制SELECT * FROM users WHERE username = 'admin' -- ' AND password = 'xxx'
-- 在SQL里是注释符,后面那段 AND password = 'xxx' 被直接注释掉了。数据库执行这条语句时,只校验了用户名是不是 admin,密码这一环彻底失效。攻击者根本不知道密码,照样能登录进去。
这种例子在安全教程里已经讲烂了,但它确实是理解SQL注入最好的起点。你会发现,整个问题的关键,不在数据库,而在于代码里“拼接”这个动作本身。
1.2 SQL注入的本质:数据被当成了代码执行
SQL注入之所以被归类为“注入类”漏洞,是因为它打破了一个最基本的边界:数据和代码必须分离。
一条SQL语句由两部分组成,一部分是动词、表名、字段名这些结构,另一部分是传进去的值。在正确的执行模型里,值是数据,数据库不会把值当成SQL结构的一部分去解析。可一旦你用字符串拼接的方式把用户的输入塞进SQL语句,用户输入的东西就获得了和SQL语句一样的“代码”地位。攻击者输入的不再是数据,而是新的SQL指令。
打个比方,这就像你让快递员送一件包裹,地址栏里却写着“把包裹转交给门口穿红衣服那个人,再把仓库钥匙带回来”。快递员不仅看了这句话,还真的照着执行了。更可怕的是,SQL注入的影响范围不止于查询。如果数据库账号权限够大,攻击者可以追加 DELETE、UPDATE、INSERT 甚至 DROP TABLE 之类的语句,这就是常说的“堆叠注入”和“拖库”的由来。
1.3 常见的注入类型与影响范围
在防御视角下,了解攻击的手型不是为了复现它,而是知道该在哪些节点防守。根据攻击者的手法和回显方式,SQL注入通常分为这么几类:
- 联合查询注入(UNION-based):攻击者利用
UNION关键字把自己的查询结果合并到原查询里,如果页面上有数据回显,就能直接看到数据库里的其他内容。 - 报错注入:攻击者故意构造会让数据库报错的语句,从报错信息里截取数据片段。常见于报错信息完整返回给浏览器的场景。
- 布尔盲注:页面没有数据回显,攻击者通过
AND 1=1和AND 1=2这种真假条件,观察页面响应差异,一个字符一个字符地把数据“猜”出来。 - 时间盲注:页面连真假差异都没有,攻击者只能用
SLEEP或WAITFOR DELAY这类函数,靠数据库响应时间判断条件真假。 - 堆叠注入:有些数据库驱动支持一次执行多条语句,攻击者可以在分号后追加自己的SQL语句,危害极大。
影响范围一句话就能说清楚:只要有用户输入进入SQL、且拼接方式不当,小到用户密码泄露,大到整个数据库被拖走、业务数据被篡改、服务器被进一步控制。对一个企业来说,这意味着用户隐私泄露、业务中断、信任崩塌,以及后续一连串的整改和审计成本。所以“预防SQL注入攻击”从来不是一个可做可不做的加分项,而是写代码的基本底线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一道防线:全链路参数化查询
2.1 为什么参数化能根治SQL注入
如果你只能记住一个预防SQL注入的方法,那就是:所有SQL查询必须使用参数化查询。这是目前公认的从根上解决问题的手段,没有之一。
参数化查询的核心思路,是把SQL语句的结构和参数值分开传送到数据库。数据库这边先对SQL骨架做预编译,结构确定之后,再把你传进来的值当作纯数据绑定进去,整个过程不会对值再做一次SQL语义解析。也就是说,即使用户输入的是 ' OR '1'='1,数据库也只认为那是字符串内容,而不是新的SQL条件。
用生活经验来类比就是:你去银行填汇款单,单子的格式是固定的——“收款人”“账号”“金额”这些栏位都已经印好了,你只需要往格子里填内容。格子里的字再奇怪,也不会让单子本身多出一行“请把柜员保险柜也打开”。参数化查询就是这张固定格式的汇款单,而字符串拼接是自己找张白纸手写汇款信息,写什么都可能被照着执行。
很多人担心参数化查询会拖慢性能,这其实是个误解。数据库对常见SQL本来就有缓存计划,使用参数化反而更利于预编译和执行计划的复用。真正拖慢性能的往往是缺少索引、查询逻辑不合理,而不是参数化本身。
2.2 主流语言与框架下的正确写法
参数化查询在各种语言里都有标准实现,关键是你得在项目里真正用起来,而不是停留在“知道”层面。
Java JDBC 的写法是使用 PreparedStatement:
java复制String sql = "SELECT * FROM users WHERE username = ? AND password = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, username);
ps.setString(2, password);
try (ResultSet rs = ps.executeQuery()) {
// 处理结果
}
}
这里的问号是占位符,参数通过 setString、setInt 这些方法绑进去,而不是拼进SQL字符串。
Python 用 psycopg2 或 mysql-connector 时,占位符的写法略有区别:
python复制cursor.execute(
"SELECT * FROM users WHERE username = %s AND password = %s",
(username, password)
)
注意,%s 只是个占位符记号,它并不是Python字符串格式化里的 %。千万别手滑写成 cursor.execute(sql % (username, password)),一旦走了格式化,参数又会被拼进SQL,注入漏洞原地复活。
PHP 的 PDO 是这么写的:
php复制$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? AND password = ?");
$stmt->execute([$username, $password]);
Node.js 操作 PostgreSQL 时用 $1、$2 这种带编号的占位符:
js复制const result = await client.query(
"SELECT * FROM users WHERE username = $1 AND password = $2",
[username, password]
);
Go 的标准库 database/sql 也是基于占位符:
go复制row := db.QueryRow(
"SELECT * FROM users WHERE username = $1 AND password = $2",
username, password,
)
写代码这件事,规规矩矩用参数化可能比拼字符串多几行,但这几十秒钟的事情,到了出问题时就是救命的距离。
2.3 参数化覆盖不到的场景怎么办
参数化查询也不是万能的,它只能安全地处理“值”,处理不了“结构”。最常见的就是动态表名、动态列名和排序字段。
比如用户点了表格的某列排序,前端传过来一个 orderBy=create_time,后端如果直接拼进 ORDER BY,参数化就没法帮你。你不能写 ORDER BY ? 让数据库动态决定按哪列排序,因为占位符只能替代值的位置,不能替代列名或表名。
正确的做法是白名单。后端维护一张允许排序的字段映射表,orderBy 传进来之后先查映射,查不到就用默认字段:
java复制private static final Set<String> ALLOWED_ORDER_COLUMNS =
Set.of("create_time", "update_time", "username");
String orderColumn = ALLOWED_ORDER_COLUMNS.contains(orderBy) ? orderBy : "create_time";
String sql = "SELECT * FROM users ORDER BY " + orderColumn + " DESC";
虽然这行代码里依然出现了字符串拼接,但拼接的内容已经不在用户控制范围内,攻击者传任何恶意值都只会落到默认字段上。这里的核心原则是:用户输入永远不直接进入SQL结构,必须先经过白名单映射。
另外两个容易忽略的点,一个是 IN 查询,一个是 LIKE 模糊查询。很多旧代码遇到 IN 条件就放弃参数化,直接用拼接。其实参数化也能处理,只是数据驱动的差异比较大,有的支持直接传数组,有的需要手动展开成多个占位符:
java复制List<Integer> ids = List.of(1, 2, 3);
String placeholders = ids.stream().map(id -> "?").collect(Collectors.joining(","));
String sql = "SELECT * FROM users WHERE id IN (" + placeholders + ")";
// 然后为每个 id 调用 setInt
占位符的数量是代码根据列表长度动态生成的,但每个值仍然通过参数绑定。至于 LIKE,模糊查询里的 % 和 _ 在参数化下依然会被当作通配符,这属于业务语义,而不是注入问题;你只要确保用户输入被当作值处理即可。
3. 第二道防线:ORM与数据访问层的规范约束
3.1 别把ORM当成免死金牌
现在大部分新项目都在用ORM框架,比如Java的MyBatis、Hibernate、Spring Data JPA,Python的SQLAlchemy,Node.js的Sequelize、TypeORM。很多团队觉得“我们用了ORM,SQL注入风险已经不存在了”。
这个想法很危险。ORM框架确实在内部帮你做了参数绑定,但它只能覆盖“标准操作”。一旦你遇到复杂查询、动态条件、性能优化等场景,大概率还是会写原生SQL,这时候框架的保护就失效了。更糟糕的是,ORM框架往往让人对SQL语句的流向失去敏感,漏洞藏在框架的“高级用法”里,比手写JDBC更难发现。
我在实际项目里就见过不止一次:功能逻辑全部用JPA的 findByXxx 接口,突然有一天报表需要动态查询,开发图省事直接写了一段 EntityManager.createQuery("from User where name = '" + name + "'")。JPA不会拦你,数据库更不会拦你,于是SQL注入这个大坑就悄无声息地回到了项目里。
3.2 MyBatis的#{}与${}:只差一个符号
在所有ORM框架里,MyBatis的注入风险是最容易出现的,因为它同时提供了 #{} 和 ${} 两种取值方式。
#{} 走的是预编译参数,安全;${} 是字符串替换,直接把内容替换进SQL,危险。看这段Mapper配置:
xml复制<select id="getUser" resultType="User">
SELECT * FROM users WHERE id = #{id}
</select>
这段没有问题,传进来的 id 是预编译参数。但如果有人图省事写成:
xml复制<select id="getUser" resultType="User">
SELECT * FROM users WHERE id = ${id}
</select>
那 id 变量就会被原样替换进SQL语句。攻击者传个 1 OR 1=1,整张表就出来了。
为什么MyBatis要保留 ${}?因为它确实有存在的价值,比如前面提到的动态排序字段、动态表名。但使用它必须九死一生——不,是必须加上白名单。我的建议是,项目里定一条铁律:${} 只允许出现在经过白名单校验的动态表名、列名场景,任何直接输入的值一律走 #{}。这条规则要写进团队开发规范,也要写进Code Review的Checklist。
3.3 从编码规范和扫描工具上拦住隐患
光有规范还不够,人总会犯懒,所以一定要在工具层面加一道闸。每一行代码进仓库前,都值得被自动化工具扫一遍。下面这些我在不同项目里都用过,实测有效:
- SonarQube:老牌静态扫描平台,对Java、Python、C#等几十种语言的SQL注入风险都有内置规则,能直接定位到拼接SQL的位置。
- SpotBugs / Find Security Bugs:适合Java项目,集成到构建流程里,能在
mvn package时中断构建。 - Semgrep:支持自定义规则,搜索效率高,适合大型代码库批量排查。
- gosec:Go项目的安全扫描器,规则里有SQL注入检查。
- CodeQL:代码语义分析能力很强,能跨函数追踪污点数据流,误报率相对更低。
工具的意义是兜底,不是替代码质量背书。我最推荐的做法是,把安全扫描接入CI流程,扫描出高危问题直接阻断合并请求。只有让开发者在提交代码时就收到反馈,而不是等到上线后被安全团队打回,才能真正把SQL注入的存量降下来。
除了工具,架构层也要有一点约束感。比如强制规定Service层禁止拼接SQL,所有数据库访问必须收口到Repository层;再比如定义统一的SQL执行入口,统一封装参数绑定逻辑,让团队成员没有机会绕过参数化。这些东西不是为了一时一次的漏洞修复,而是为了让项目长期保持在安全轨道上。
4. 第三道防线:输入校验、最小权限与外围防护
4.1 输入校验的白名单思路
有一类老派的防御思路是“过滤危险字符”,比如把单引号、双引号、分号全部转义或删掉。这套思路在新手教程里很常见,但它有三个硬伤:第一,黑名单永远列不全,数据库方言那么多,函数、编码、注释符五花八门,总有一种写法能绕过;第二,过度过滤会破坏正常业务数据,比如把一个英文名字 O'Brien 变成 OBrien;第三,它会给人虚假的安全感,以为“过滤了就没事”,结果真正该做的参数化反而没做。
比黑名单可靠的是白名单。白名单的思路不是“什么不能输入”,而是“什么允许输入”。具体做起来有三个层次:
- 类型校验:数字字段用
Integer.parseInt或int强转,数字都没有,注入语句自然进不去。 - 枚举校验:状态、类型、排序字段这些有限集合的输入,直接限定在枚举里,不匹配就抛参数异常。
- 格式校验:邮箱、手机号、身份证号这些有明确格式的字段,用正则或专门库校验格式,对不上就拒绝。
再次强调,输入校验只是第二道保险,它不能替代参数化。真正安全的系统是“参数化打底,白名单做补充”,层层设防才靠谱。
4.2 数据库账号最小权限怎么落
预防SQL注入还有一层非常关键、但经常被忽略的防线:数据库账号权限。很多项目为了省事,应用连数据库用的都是root或sa这种超级账号。一旦某个查询被注入,攻击者面对的权限就是“数据库里的神”,想读哪个表读哪个表,想删哪张表删哪张表。这不是防御,是裸奔。
最小权限原则在这里的落地很直接:
- 应用使用的数据库账号,只授予业务必需的权限,通常就是
SELECT、INSERT、UPDATE、DELETE,按模块拆分。 - 绝不能给应用账号
DROP、ALTER、CREATE这类DDL权限。运维做表结构变更应该走单独的变更流程,而不是让应用自己拥有改表的能力。 - 报表、导出等只读场景,单独建只读账号,授
SELECT权限即可。 - 一个业务一个账号,相互隔离。即使A业务的账号被攻破,攻击者也无法直接碰B业务的数据。
权限收敛之后,即使真的发生了注入,攻击者也像是进了银行、手里却只有一张“查询余额”的卡,取钱、转账都做不了。这一层防线看似不起眼,实际价值极大。
4.3 错误信息与外围防护的正确姿势
还有两个不太起眼但经常出问题的地方。第一个是数据库报错信息直接返回给浏览器。生产环境里一旦SQL语句出错,框架默认的报错页会把完整的SQL语句、数据库类型、表结构信息都吐出来。这些信息对攻击者来说是极其宝贵的情报。正确做法是:生产环境关闭详细错误回显,统一换成不暴露内部细节的错误码和提示信息;详细日志只写到后端文件,日志里的敏感数据要脱敏。
第二个是WAF、数据库防火墙等外围防护设备的定位。很多企业觉得上了WAF就万事大吉,这个想法要修正。WAF确实能拦截一部分常见的注入特征,但它依赖规则匹配,攻击者换一种编码方式、拆词写、绕个绕法,规则就失效了。所以我始终强调:WAF只是纵深防御的一层补充,可以上,但别依赖。它在攻击者和数据库之间加了一道“减速带”,真正的“拦路墙”还是应用代码里的参数化查询。
定期的代码审计和渗透测试也应该纳入研发流程。不需要每个迭代都做,但版本大更新、核心模块重构之后,安排一次授权范围内的安全测试,往往能发现不少平时注意不到的盲区。
5. 常见问题与排查技巧实录
5.1 三个最容易踩的认知误区
误区一:我用了ORM,绝对安全。前面已经说透了,ORM只保护框架内的标准用法,保护不了框架外的原生SQL、动态拼接。
误区二:我过滤了单引号,就能挡住注入。单引号过滤只对一部分注入手法有效,而且攻击者可以借助编码转换、宽字节等手段绕过。更离谱的是,很多系统里单引号是业务数据的正常组成部分,你无法一刀切过滤。如果你还在用“过滤特殊字符”作为主要防御手段,请立刻转到参数化查询上。
误区三:参数化查询只适合简单查询。实际上,参数化对于带复杂条件的查询完全适用。你可以把多个占位符和动态条件组合起来,只要保证值的位置全部用 ? 或 %s 绑定,SQL结构本身保持不变,就没有注入空间。真正不能参数化的只有表名、列名这类结构位置,那部分交给白名单。
5.2 存量项目SQL注入漏洞排查流程
手上有一个老项目,代码已经跑了三五年,怎么排查有没有SQL注入漏洞?我推荐一套从粗到细的流程,大家可以照着做。
第一步,代码仓库全文搜索可疑模式。主力是搜索字符串拼接加SQL关键字的组合,比如包含 "SELECT"、"UPDATE"、"DELETE" 等关键词,同时又出现了 +、concat、format、StringBuilder.append 这类拼接操作的行。这一步能快速定位到大量“目标区域”。如果用Semgrep或CodeQL,还可以做更精准的污点分析,直接从入口参数追踪到SQL执行点。
第二步,重点优先排查对外暴露的接口。登录、注册、查询列表、搜索、导入导出、报表、Webhook,凡是能接收外部输入的地方都是高优先级。内部管理系统排在后面,但不代表不需要修。
第三步,逐条人工确认拼接点。静态扫描出来的结果不一定全是漏洞,但每一条都要看一遍。确认时关注三个问题:输入来源是不是用户可控?SQL语句是不是用了字符串拼接?拼接的内容有没有经过白名单或强校验?如果三个问题的答案都是“是、是、否”,那基本可以确定是中招了。
第四步,分批改造。建议按风险高低分批次推进,先修登录等认证类接口,再修核心业务查询,最后处理低频后台功能。每修完一条,对应的代码必须切换到参数化或白名单方案。
如果项目里有大量历史包袱,短期内改不完,可以考虑先用数据库防火墙做临时防护,在数据层面把可疑的注入特征挡住,给自己争取改造时间。但务必记住,这只是缓兵之计,最终还是要靠代码修复。
5.3 修复后的验证与回归检查
代码改完不是结束,还要验证两个方向:漏洞确实堵住了,业务功能没有受影响。
验证漏洞有没有堵住,最直接的方式是让安全和测试同学在测试环境做针对性的注入测试,用扫描工具或手工构造输入来验证接口。我特别强调“测试环境”,生产环境跑注入测试是给自己找麻烦。另外,代码层重点复查修改过的SQL是不是全部走了参数化,动态标识符是不是全走了白名单。
验证业务功能不受影响,就要做回归测试。参数化改造虽然语义上等价,但有些细节容易出差错,比如时间字段的格式、布尔值的绑定、 null 值的处理,都可能和原来的字符串拼接行为不一致。常见的一个坑是,原来代码里给日期类型拼接的是 '2024-01-01 00:00:00' 这种带引号的字符串,改造后用 setTimestamp 传时间对象,如果底层时区配置不一致,查出来的数据可能跟旧逻辑有偏差。所以核心业务模块一定要在测试环境里把正常流程完整走一遍,别等上线了再让用户帮你踩坑。
最后说一点我的个人体会:预防SQL注入这件事,其实没有什么高深莫测的技巧,翻来覆去就是一招“参数化打底、白名单补漏、最小权限兜底”。真正难的不是技术,而是让团队每个人都把这条底线刻在肌肉记忆里。我改造过的老项目里,最危险的一次漏洞就藏在别人都默认安全的MyBatis XML里,一个 ${} 用了半年没人发现。自那以后,我再也不相信“这个项目应该没问题”这种话,所有SQL必须逐条过目,扫描工具必须进CI,权限必须收敛。这些看似繁琐的坚持,关键时刻是真的能救命的。
