1. 先搞清楚SQL注入到底是怎么发生的
1.1 从一条“加了料的”SQL语句说起
很多刚接触安全的同学一听到“SQL注入”就觉得高深莫测,其实它的本质特别简单:你把用户输入的内容当成了一段可执行的指令,而不是普通的数据。
我拿最常见的登录功能给你举个例子。很多人早期写代码都干过这种事:
java复制String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";
这条语句本身没毛病,问题是 username 和 password 是用户传来的。如果用户在用户名那个输入框里填的是:
text复制admin' --
那拼接出来的SQL就变成了:
sql复制SELECT * FROM users WHERE username = 'admin' -- ' AND password = 'xxx'
在MySQL、SQL Server这些数据库里,-- 是注释符号,后面所有内容都会被忽略。这一下,密码验证直接被跳过了,用户不需要知道任何密码就能以 admin 身份登录进去。这还只是最基础的玩法,更狠的攻击者可以直接用 ' OR '1'='1 绕过所有账户,甚至用 UNION SELECT 把整张用户表的密码哈希拖出来。
SQL注入问题的核心,是语法边界混淆。 你本意是让用户输入的内容只做“值”,结果却被拼接进了SQL的“结构”里,变成了代码的一部分。这就像你给快递员写地址时,对方把你备注里的“放门口”也当成了正式地址,结果包裹送错了地方。
1.2 为什么会“边界混淆”:把数据和代码混在一起
我用一个生活化类比帮你彻底理解这件事。
想象你在填一张纸质表单,有一栏是“姓名”,你写了个名字进去。这个位置的设计意图很明确:这里只放文字数据,不会被执行。但SQL拼接方式下的情况完全不一样——你等于在表单上开了一个口子,让填表的内容可以跑到表格结构里,变成了查询命令的一部分。
传统拼接字符串的方式,本质上就是同一个通道里既传数据又传代码,数据库根本分不清哪些是数据、哪些是逻辑。参数化查询之所以能防注入,并不是它加密了啥,而是它让数据库从一开始就明白“这个位置只接收数据,永远不被解释成SQL指令”。
这个认知特别重要。因为很多防御方案(比如过滤关键字、加反斜杠转义)看着像是在“清理数据”,其实是在跟攻击者玩文字游戏,永远有绕过空间。只有从根源上把数据和代码分开,才是真正的解决之道。
1.3 常见误区:很多人以为防住了,其实没有
我在日常渗透测试里经常碰到一些团队,跟我说“我们的系统已经做了防注入处理”,结果几分钟就被打穿了。这些“伪防御”非常典型,我列出来你对照看看自己有没有中招:
- 只过滤单引号:攻击者换成宽字节注入、
%27编码绕过、\转义绕过,或者用数字型字段不需要引号的场景,直接就穿透了。 - 用JS前端做校验:这最多是提升用户体验,攻击者根本不需要经过你的页面,直接拿工具去请求后端接口就行,前端校验形同虚设。
- 写了ORM就说安全:ORM框架确实默认用参数化,但你要是用了它提供的
createNativeQuery、rawQuery这类支持原生SQL的方法,然后又手痒去拼字符串,框架也救不了你。 - 对输入做黑名单过滤:过滤
or、union、select这些关键字,稍微变形一下,比如SeLeCt、UNION/**/SELECT,或者用十六进制编码,就能轻松绕过。
说实话,我见过太多系统上线前自查时打了勾,结果被安全测试一轮就打回原形的例子。防SQL注入不是靠某个单一技巧,而是一套分层的防御体系。 接下来我按优先级,从最核心的防线开始一层层说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 预防SQL注入的第一道防线:参数化查询
2.1 参数化查询的原理:把数据和代码彻底分开
参数化查询(Prepared Statement)是目前防SQL注入最有效、最推荐的手段。它的核心思路是:先把SQL语句的结构骨架发给数据库做预编译,之后再把用户输入作为纯参数传过去。
举个例子,同样是上面的登录查询,用参数化写法是这样的:
java复制String sql = "SELECT * FROM users WHERE username = ? AND password = ?";
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setString(1, username);
stmt.setString(2, password);
ResultSet rs = stmt.executeQuery();
数据库在执行前,已经明确知道 ? 这个位置只会被当作“值”来处理。哪怕用户输入的是 admin' --,数据库也会把它当做一个普通的字符串“admin' --”去和字段比较,永远不会把它变成SQL语法的一部分。
你可以把参数化查询理解成你提前给快递公司定好了单据模板:“第一个空填姓名,第二个空填电话。”然后不管用户往里面填什么奇怪字符,快递员都只会把它抄写到“姓名”那一栏,绝不会把它当成“配送指令”来执行。这就是“预编译”的真正含义——结构先定好,内容后填进来,结构永远不会因为内容发生改变。
2.2 主流语言和框架里的正确姿势
参数化查询不是什么新东西,几乎每种主流语言都支持。关键是你要用对写法。我给几个最常用的场景示例:
Java JDBC:
java复制String sql = "INSERT INTO users (username, email) VALUES (?, ?)";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, username);
ps.setString(2, email);
ps.executeUpdate();
}
PHP PDO:
php复制$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username");
$stmt->bindValue(':username', $username);
$stmt->execute();
Python(sqlite3 / psycopg2):
python复制cursor.execute("SELECT * FROM users WHERE username = %s", (username,))
Go(database/sql):
go复制row := db.QueryRow("SELECT * FROM users WHERE username = ?", username)
MyBatis(注意别用 ${}):
xml复制<select id="getUser" resultType="User">
SELECT * FROM users WHERE username = #{username}
</select>
这里要特别强调,MyBatis里 #{username} 是预编译参数,安全;但 ${username} 是字符串替换,和拼接SQL没区别,能用 #{} 就别用 ${}。我见过太多因为图省事用 ${} 导致注入漏洞的案例了,这一条踩坑率极高。
2.3 参数化查询解决不了的问题
虽然参数化是主力,但它不是万能的。有一些场景参数化查询“管不到”,你必须用其他手段补上:
- 表名、列名无法参数化:比如用户要按某个字段排序,
ORDER BY ?在某些数据库里根本不能直接绑定表名或列名。这种只能靠白名单映射来解决。 - 动态拼接场景:比如一些报表平台要动态生成SQL、要支持灵活的筛选条件组合,这种情况下很难完全避免拼接。
- 存储过程内部的动态SQL:存储过程内部如果用了
EXECUTE IMMEDIATE去拼SQL,参数化查询同样救不了你,问题只是从应用层转移到了数据库层。
这几个场景我下面会逐一展开,给出对应的处理方案。总之你要明白:参数化查询是一条必做的主防线,但完整的安全体系还需要配合其他措施一起上。
3. 第二道防线:输入校验与白名单机制
3.1 输入校验的正确思路:白名单优先
很多团队喜欢做黑名单:拦截 '、拦截 --、拦截 UNION。这种思路看似省事,实际上面对变种攻击会让你疲于奔命。正确的姿势是反过来,用白名单思路来校验输入。
白名单的意思不是“我很干净,放我进来”,而是“只有符合明确规则的才放行”。举个例子:
- 用户ID必须是纯数字:
^[0-9]+$ - 用户名只允许字母、数字和下划线:
^[A-Za-z0-9_]+$ - 邮箱格式必须符合标准
- 手机号必须是11位数字,且是合法的号段
这样做的逻辑很简单:如果业务上这个字段本来就只能填数字,那任何带引号、括号、分号的字符串都不应该进入到数据库层。 攻击者根本不需要你来“拦截”,他在入口就被规则挡死了。
我在实际项目中强烈建议做两层校验:前端JS校验是给正常用户提示用的,后端是真正的安全防线。后端校验一定要写,别把安全托付给前端。
3.2 特殊场景:排序字段、表名、IN条件怎么处理
前面说了,有些SQL片段无法参数化,这时候白名单映射就是最佳方案。
排序字段场景:
用户想按 created_at、price、sales 排序,前端传一个 sort=price。后端不要直接拼这个值进 ORDER BY,而是维护一个映射表:
java复制private static final Map<String, String> SORT_COLUMN_MAP = new HashMap<>() {{
put("new", "created_at");
put("price", "price");
put("sales", "sales_count");
put("hot", "heat_score");
}};
String sortColumn = SORT_COLUMN_MAP.getOrDefault(request.getParameter("sort"), "created_at");
排序方向 asc/desc 同样只允许这两个值,其他的直接拒绝。这里连参数化都不需要了,因为你的代码里压根没有拿用户输入去拼SQL,用户输入只是一个“键”,用来查映射表里的“值”。
IN条件场景:
比如 WHERE id IN (1,2,3),有些ORM框架可以用 ANY(array) 来传数组参数。如果实在要用原生SQL,也不要直接拼接字符串,先把每个值校验成数字,再拼进去:
java复制List<Integer> ids = Arrays.stream(rawIds.split(","))
.map(String::trim)
.map(Integer::parseInt) // 解析失败会抛异常,直接拦截
.collect(Collectors.toList());
String placeholders = ids.stream().map(id -> "?").collect(Collectors.joining(","));
String sql = "SELECT * FROM products WHERE id IN (" + placeholders + ")";
注意,id 本身经过强校验了,拼接进去的也是 ? 占位符,值仍然走参数化。这是实践中处理IN条件最稳的做法。
3.3 存储过程和ORM的误用:用了不等于安全
我还得专门辟个谣。有些研发同学觉得“我用了存储过程,那肯定安全了吧”,或者“我全程用ORM,字符串拼接应该不存在”。这两个认知都有坑。
存储过程内部如果这样写,依然存在SQL注入:
sql复制CREATE PROCEDURE get_user @username varchar(50)
AS
BEGIN
EXECUTE('SELECT * FROM users WHERE username = ''' + @username + '''')
END
正确做法是存储过程内部也用参数化查询参数:
sql复制CREATE PROCEDURE get_user @username varchar(50)
AS
BEGIN
SELECT * FROM users WHERE username = @username
END
ORM这块,MyBatis、JPA(Hibernate)默认用预编译机制,确实安全。但很多ORM框架都留有执行原生SQL的“后门”,比如JPA的 EntityManager.createNativeQuery()、MyBatis的 ${}、Entity Framework 的 FromSqlRaw。只要用了这些,并且里面拼了用户输入,那就跟裸写JDBC拼接没区别。
我的经验是:ORM的安全底线很依赖开发者的自律。 你可以在团队规范里定死一条规则——能用框架标准查询接口就别碰原生SQL接口;必须用的,一律参数化,绝不允许字符串拼接。
4. 纵深防御:权限、加密、WAF与审计
4.1 数据库账户最小权限原则
代码层面防住了,不代表整个系统就固若金汤。我常说,防线要一层层铺,哪怕某一层被打穿了,下一层还能挡住,这样攻击者的单次突破就无法直接升级成大规模数据泄露。
最小权限原则是这层防御里的基础。很多系统为了省事,应用连接的数据库账户直接给了 root 或者 sa 权限。一旦应用有注入点被利用,攻击者拿到的是什么权限?是超级管理员权限。这意味着他可以读全库数据、写文件、执行系统命令,想干嘛干嘛。
最小权限原则做起来并不复杂:
- 普通业务读写,创建一个独立账户,只授权它需要的库和表。
- SELECT、INSERT、UPDATE、DELETE分开授权,用不到的就不给。
- 严禁给应用账户开放
FILE、SUPER、GRANT OPTION这类高权限。 - 如果系统里存在报表统计这种只读模块,给它单独建一个只读账户。
这样就算攻击者真的找到注入点,他能做的事情也非常有限。很多攻防演练里,攻击者拿到数据库权限却一无所获,就是因为账户权限被卡得很死。
4.2 错误信息处理与日志审计
第二个被我反复念叨的要点是:别把数据库的报错信息原样抛给用户。
数据库报错信息对攻击者来说是绝佳的“地图”。通过在参数里构造不同的SQL片段,攻击者可以从报错信息中判断出数据库类型、版本、表结构、甚至具体字段名。比如经典的 updatexml() 报错注入,就是故意构造报错,把想要的数据带出来。
防这个很简单,但需要全局配置配合:
- 生产环境关闭详细错误输出,统一返回一个友好的“系统繁忙”提示。
- 错误日志只记录在服务端文件里,不输出到HTTP响应。
- 日志中记录SQL语句时,注意打码敏感参数值,避免把用户隐私漏进日志。
日志审计的另一层价值是事后的发现与追溯。当安全事件已经发生时,一份清晰的日志能帮你还原攻击者的行为路径,找到注入点,确认影响范围。很多公司出事之后查了半天日志,才发现日志里压根没有记录完整的操作来源,那种无力感我体会过太多次了。
推荐在应用层加一个简单的审计日志,至少记录:时间、来源IP、操作人、操作类型、请求的关键参数(脱敏后)。平时看没什么用,出了事这就是你最快的破案线索。
4.3 WAF与网关层的辅助作用
WAF(Web应用防火墙)属于“辅助防空网”,它部署在应用前面,用规则匹配来拦截可疑请求。常见的WAF规则能识别 UNION SELECT、information_schema、/etc/passwd 这类典型的攻击特征。
但我要强调一个观点:WAF是辅助,不是主力。 因为WAF的规则是“已知攻击模式”的匹配,而攻击者的绕过手法几乎是无止境的。我见过不少甲方以为上了WAF就万事大吉,结果绕过编码变形、分块传输、JSON嵌套等方式就把规则给绕了。
以我的项目经验来看,WAF适合用来拦截“脚本小子的随意扫描”,减少日志里的噪音,给攻击增加成本,但真正的安全必须建立在代码层不引入漏洞的基础上。WAF可以当做一个烟雾报警器,但如果屋子里到处是汽油,报警器再灵敏也救不了火。
如果你用云厂商的WAF,记得开“监控模式”跑一段时间,观察误报情况,再切“拦截模式”。直接上拦截模式容易把正常用户的合法请求给误杀了,这种事情我也踩过。
5. 实战复盘:一个典型漏洞的发现与修复全过程
5.1 漏洞发现:从日志里看到异常
我挑一个我经手过的真实案例来讲讲完整流程。
那是一个后台权限管理系统,技术栈是Spring Boot + MyBatis,上线快三年了。当时是合作方做了一次安全评估,报告里提到某个统计接口存在疑似SQL注入。我第一反应是不太信,因为代码里百分之八十的逻辑都用了 #{} 参数绑定。但拿到报告后我翻了那个接口的XML,发现问题出得挺微妙。
接口的功能是查询某个时间段内的操作记录,支持按“操作人”“操作类型”两个条件筛选,其中操作人是用户手工输入的关键词。XML里写的是:
xml复制<select id="searchLogs" resultType="OperationLog">
SELECT * FROM operation_log
WHERE operate_time BETWEEN #{startTime} AND #{endTime}
AND operator LIKE CONCAT('%', '${keyword}', '%')
</select>
看到 DATA ${keyword} 我基本就确定了,这是一个典型的搜索框SQL注入。问题在于别人接手这块代码时,可能觉得 LIKE 查询用 #{} 绑不上 % 通配符,就用 ${} 图了个方便。实际上 LIKE 查询完全可以参数化,只是需要把通配符拼进参数里,而不是拼进SQL里。
5.2 修复过程:从拼接SQL改为参数化
我的修复方案很简单,把 ${keyword} 改成 #{keyword},通配符在Java代码里拼:
java复制String keyword = request.getParameter("keyword");
// 注意这里对通配符做转义,防止用户输入 % 和 _ 影响查询范围
String escaped = keyword.replaceAll("([%_])", "\\\\$1");
String likeKeyword = "%" + escaped + "%";
XML里对应改成:
xml复制<select id="searchLogs" resultType="OperationLog">
SELECT * FROM operation_log
WHERE operate_time BETWEEN #{startTime} AND #{endTime}
AND operator LIKE #{keyword}
</select>
likeKeyword 作为参数传入后,MyBatis会把它当作文本值来绑定,数据库不会再把它解析成语法结构。攻击者就算输入 ' OR '1'='1,它在 LIKE 子句里也只是个普通字符串,构造不出任何注入效果。
5.3 验证与回归:确认修复有效
修复完之后,验证环节不能省。我的标配流程是“三件套”:
第一步:回归注入Payload。 用SQLMap跑一遍那个接口,把所有常见Payload打一遍,确认不再报错、不再返回异常数据。这里要注意用SQLMap跑之前先确认目标授权,别在没授权的系统上测试。
第二步:功能回归。 确认正常用户的搜索行为没有受影响。搜索“张三”能出结果,搜索包含 %、_ 的关键词也能正常工作。
第三步:代码审查常态化。 修复完这一个点不代表整个系统都安全了。我把整个项目的XML全量扫了一遍,用一个简单的正则搜索 \$\{ 找出所有 ${} 用法,逐个排查是否涉及用户输入。同时建议团队在Code Review阶段把这条作为强制检查项。
这套流程走下来,类似的问题基本能做到早发现早处理。我更想强调的是,修复本身不难,难的是建立起一套机制让同类问题不再反复出现。 如果你没有持续审查和预防机制,今天修一个,下个月又会出现另一个。
6. 存量系统的排查与审计建议
6.1 如何快速定位可疑代码
已经线上运行的老系统,没有安全评估报告的话,你也可以自己做一轮自查。我分享一个比较高效的排查路径:
- 全局搜索字符串拼接关键词。搜索
"SELECT"、"UPDATE"、"DELETE"与其他变量的+拼接。Java代码重点看+、String.format、StringBuilder;PHP看.和"${var}";Python看%s和f-string。 - MyBatis重点搜
${。这是Java系列框架特有的风险点,基本每次都能搜出几处。 - 检查排序、筛选等动态条件接口。这些地方最容易在后期迭代时被加进动态拼接逻辑。
- 翻JSON接口的日志。看看线上有没有异常请求,比如参数总带上单引号、
UNION、SLEEP等特征,通过日志反推疑似注入点。 - 用扫描工具做辅助验证。比如ZAP、AppScan、SQLMap都可以。但记住工具跑出来的结果要人工复核,工具误报率并不低。
6.2 常见问题与排查实录速查表
| 问题现象 | 可能原因 | 处理办法 |
|---|---|---|
搜索框输入带 ' 就报数据库错误 |
存在SQL拼接 | 改为参数化查询 |
| 排序字段可传入任意字符串 | 动态排序未做白名单 | 建立排序字段白名单映射 |
| 修改密码接口被绕过 | 拼接了token或旧密码 | 参数化,禁止拼接 |
| WAF拦截了正常业务请求 | 规则误判 | 先监控模式,再调整规则 |
| 报错信息暴露了SQL语句 | 生产环境未关闭详细错误输出 | 统一错误提示,将完整异常写日志脱敏 |
| 数据库被拖库但应用代码没找到注入点 | 排查后台低版本框架漏洞、弱口令、运维入口 | 补丁升级,加固运维入口权限 |
| SQLMap能跑出数据但代码里找不到拼接点 | 检查存储过程、日志类函数、批量导入模块 | 存储过程内部SQL也要参数化 |
这张表只是最常见的几种场景。真正排查起来你会发现,每个系统都有自己的历史包袱和“特色”写法,但整体思路是一致的——从“代码生成SQL的路径”反向追,永远能找到源头。
6.3 关于预防SQL注入的几点体会
说实话,我在这个领域待了这么多年,SQL注入依然能长期霸占OWASP Top 10的榜单,原因不是技术难度高,而是重复造轮子的人太多、安全意识落地太难。
我见过刚入行的开发以为“加个加密就行了”,我也见过资深架构师在紧急需求下为了赶工直接拼了条SQL。技术方案就摆在那里,参数化查询是最成熟的答案,但能不能在每一个团队里真正落地,靠的还是规范和习惯。
我现在带团队会坚持做三件事,分享给你参考:
- 代码审查清单里固定有一条“不允许SQL拼接用户输入”,提测前必须自查。
- 每个新项目启动时,把数据库账户的最小权限先设置好,别等上线后再补。
- 不管工期多紧,涉及动态SQL的地方都要写注释,说明为什么这样写、怎么防注入,避免后人接手后当成“可以模仿的写法”来复制。
最后再分享一个小技巧:把SQL注入的攻击样本收集起来,放到你自己项目的自动化测试用例里,作为安全回归用例。每次代码变更后跑一遍,如果某个接口开始拼接SQL导致测试失败,你第一时间就会知道。这个习惯帮我在多次迭代中避免了安全回归,成本极低,效果极好。
