1. 为什么SQL注入屡禁不止——先理解攻击者的视角
1.1 SQL注入的本质:从“万能密码”说起
我经常在技术群里看到新手问“SQL注入是不是已经过时了”。实际上,每一年的漏洞报告里,SQL注入依然稳定地占据OWASP Top 10的一席之地。很多程序员觉得只要写了参数化查询就万事大吉,但现实中被打穿的系统,往往就栽在几个看起来不起眼的“小坑”上。
先从一个最经典的场景说起——万能密码。假设后端有这样一段逻辑:
String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";如果username输入的是admin' --,那么整条SQL就变成了:
SELECT * FROM users WHERE username = 'admin' -- ' AND password = 'xxx'在MySQL中,--是注释符,后面所有的内容都被忽略。攻击者只需要知道一个用户名,甚至用户名都可以猜(比如admin),就能直接绕过密码校验登入系统。这就是所谓的“万能密码绕过”,本质上是攻击者把输入当成了SQL代码的一部分来执行。
这个例子虽然老,但它说明了一个底层逻辑:SQL注入不是“某种特定的攻击技巧”,而是程序把不可信数据拼接进了SQL语句结构本身。拼接点是所有注入问题的根源,参数化查询解决的也正是这个问题。
1.2 一个最简单的注入例子,以及它为什么能奏效
再来看一个更“贴近业务”的例子。一个新闻网站的文章详情页,URL长这样:
https://example.com/news?id=12后端代码可能是:
$id = $_GET['id']; $sql = "SELECT title, content FROM news WHERE id = " . $id;攻击者把id改成1 UNION SELECT username, password FROM users --,如果id=1本身存在,查询结果的第一条还是正常新闻,但后面会拼接出所有用户账号密码。页面渲染的时候,这些数据就会直接泄露出来。
为什么这类问题这么多年了还是反复出现?我个人的观察是三个原因:
- 很多老系统的代码是历史遗留,最早的开发者用字符串拼接写业务,后来的人只敢加补丁不敢重构。
- 一些程序员对“参数化查询能防什么、不能防什么”边界模糊,遇到动态表名、动态排序字段时图省事,又退回拼接。
- 安全测试往往在项目上线前才做,发现问题时排期已定,修复只能打补丁,补丁本身又可能引入新问题。
理解了攻击者的视角,再回头看防护手段,很多选择就顺理成章了。参数化查询是地基,但地基之上还要有输入校验、最小权限、日志监控这些楼层,否则房子依然可能被从别的地方突破。
2. 五类参数化查询的正确姿势——从基础到进阶
2.1 第一类:JDBC PreparedStatement(Java)
Java后端最常见的数据库访问方式就是JDBC。正确写法是使用PreparedStatement,把参数通过占位符传进去:
String sql = "SELECT * FROM users WHERE username = ? AND password = ?"; try (PreparedStatement pstmt = connection.prepareStatement(sql)) { pstmt.setString(1, username); pstmt.setString(2, password); ResultSet rs = pstmt.executeQuery(); }关键点在于:?占位符的位置,参数永远作为“数据”传给数据库,数据库在解析阶段就把语句结构和参数值分开了。哪怕参数值里写了' OR 1=1 --,它也只是被当成一个普通的字符串字面量,不会参与SQL结构解析。
我给初学者的建议是:写JDBC的时候,凡是SQL里有动态值,一律用?,不要为了“看起来直观”去拼字符串。这属于肌肉记忆,没有例外。
2.2 第二类:Python DB-API参数占位符
Python这边,以最常用的pymysql和psycopg2为例,写法其实是类似的原则。注意不同数据库驱动的占位符风格不一样:
# pymysql 使用 %s cursor.execute("SELECT * FROM users WHERE username = %s AND password = %s", (username, password)) # psycopg2 也是 %s cursor.execute("SELECT * FROM users WHERE email = %s", (email,))新手最容易踩的坑是:自己手动做了一次字符串格式化,再把结果传给execute。比如:
# 错误示范 cursor.execute("SELECT * FROM users WHERE username = '%s'" % username)这么写等于还是拼接,参数化完全失效。正确做法是把参数作为第二个参数传入,让驱动库去处理转义和类型转换。另外记住一点:%s只是占位符,它不区分数据类型,数字、字符串、日期都可以用它,由驱动根据字段类型处理。
2.3 第三类:PHP PDO预处理
PHP在Web开发中的存量非常大,很多老代码用的是mysqli或mysql_*系列函数(后者早已废弃)。如果你新写项目,直接用PDO:
$stmt = $pdo->prepare("SELECT * FROM products WHERE category = :category AND price <= :max_price"); $stmt->execute([ ':category' => $category, ':max_price' => $maxPrice ]);PDO支持两种占位符风格:命名占位符(:name)和问号占位符(?)。我个人更推荐命名占位符,因为参数多了之后可读性更好,不容易搞错顺序。
这里要额外强调一个PDO的配置细节。很多教程会让你在创建连接时加一行:
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);这行的作用是关闭PDO的“模拟预处理”。PHP默认在某些驱动下会先在客户端做参数替换再发给数据库,虽然它也会做转义,但和真正的服务端预编译相比,防御强度和心理安全感都不在一个量级。我在生产项目里遇到过模拟预处理模式下的边界情况,后来统一关掉了事。
2.4 第四类:ORM框架的“伪参数化”陷阱
现在很多项目用ORM,比如Java的MyBatis、Python的SQLAlchemy、Node的Sequelize。用ORM是不是就自动安全了?不一定。
以MyBatis为例,有两个核心符号:#{}和${}。#{}是预编译参数占位,安全;${}是字符串直接拼接,危险。很多开发者在Mapper XML里图方便:
<select id="getUser" resultType="User"> SELECT * FROM users WHERE username = '${username}' </select>一旦username来自前端,这就是一个标准注入点。正确姿势:
<select id="getUser" resultType="User"> SELECT * FROM users WHERE username = #{username} </select>再来看SQLAlchemy,如果你用text()写原生SQL,必须显式绑定参数:
from sqlalchemy import text result = db.session.execute( text("SELECT * FROM users WHERE username = :username"), {"username": username} )注意:text()内部的SQL字符串本身是静态的,值通过字典传入,所以是安全的。但如果你这样写:
# 错误示范 result = db.session.execute(text(f"SELECT * FROM users WHERE username = '{username}'"))那就不安全了,f-string会把值直接带进SQL文本。
ORM框架的安全边界不在框架本身,而在使用方式。我给团队的规范就一句话:原则上禁止在业务代码中出现任何动态拼接SQL的行为,唯一例外是下文要讲的动态表名等结构化参数场景,且必须走白名单机制。
2.5 第五类:动态SQL拼接的正确包裹方式
严格来说,这一节讲的不是参数化查询本身,而是参数化覆盖不到的边界怎么处理。这是我在代码评审时最常被问到的问题。
动态表名/列名的白名单方案
有些业务场景下表名是外部传入的,比如管理后台的导出功能,用户选了要导出的表。这种情况下?占位符无能为力,因为表名属于SQL结构而非数据。我的做法是维护一个白名单Map:
private static final Map<String, String> TABLE_WHITELIST = Map.of( "users", "users", "orders", "orders", "products", "products" ); public void query(String tableName) { String safeTable = TABLE_WHITELIST.getOrDefault(tableName, null); if (safeTable == null) { throw new IllegalArgumentException("非法表名"); } String sql = "SELECT * FROM " + safeTable; // 继续执行 }白名单的价值在于:表名根本不会到达数据库,在代码层就被限定死了。无论用户传什么,映射不到白名单里就直接拒绝。
动态排序字段的处理
ORDER BY后面的字段同样无法参数化。攻击者有时会利用排序字段做布尔盲注。我的建议也是白名单,或者用枚举限制:
ALLOWED_SORT_FIELDS = {"id", "created_at", "updated_at", "title"} def list_items(request): sort_field = request.args.get("sort", "id") if sort_field not in ALLOWED_SORT_FIELDS: sort_field = "id" direction = request.args.get("dir", "asc") direction = "desc" if direction == "desc" else "asc" sql = f"SELECT * FROM items ORDER BY {sort_field} {direction}"排序方向这里也值得注意:asc/desc虽然只有两个值,但依然不能直接拼接用户输入,也要做一次映射或校验,因为攻击者可以在排序方向上做堆叠注入测试。
3. 实战踩坑:参数化查询解决不了的五个场景
3.1 LIKE模糊查询,通配符和转义的纠葛
先说一个我见过无数次的错误:模糊查询的参数传参方式不对。很多人写搜索时会习惯性地在业务代码里拼接%:
String sql = "SELECT * FROM products WHERE name LIKE ?"; pstmt.setString(1, "%" + keyword + "%");这个写法本身是安全的,参数化依然有效,%只是LIKE模式里的通配符,和SQL注入无关。但问题出在另一个方向:如果用户搜索的关键词本身就包含%或_,那查询结果会和自己预期的完全不一样。比如搜“100%纯棉”,%会被LIKE当作通配符匹配任意字符串。
更麻烦的是_,它匹配任意单个字符。用户搜“苹果_手机”,如果数据库里恰好有“苹果X手机”,也会被查出来。这里需要转义处理:
SELECT * FROM products WHERE name LIKE ? ESCAPE '/'Java侧这样写:
String escapedKeyword = keyword .replace("/", "//") .replace("%", "/%") .replace("_", "/_"); String likePattern = "%" + escapedKeyword + "%"; pstmt.setString(1, likePattern);这个坑属于“不算安全问题但体验问题”,但安全视角下它有个隐藏风险:如果搜索接口被攻击者用来做注入探测,转义处理不干净会让探测结果更“干净”,防护层更加严密,在这个前提下业务也更符合用户预期。
3.2 IN子句的展开,别把数组拼成字符串
IN查询是另一个高频场景。前端传一个ID数组,比如ids = [1, 2, 3],后端要查出这些ID的数据。很多人的第一反应是:
String idsStr = String.join(",", ids); String sql = "SELECT * FROM products WHERE id IN (" + idsStr + ")";如果ids里的元素能被用户控制且没有强校验类型,这里就又可以做注入了。比如传入1) UNION SELECT username, password FROM users --,拼接后变成:
SELECT * FROM products WHERE id IN (1) UNION SELECT username, password FROM users -- )正确做法是动态生成占位符:
StringBuilder placeholders = new StringBuilder(); for (int i = 0; i < ids.size(); i++) { if (i > 0) placeholders.append(","); placeholders.append("?"); } String sql = "SELECT * FROM products WHERE id IN (" + placeholders + ")"; // 逐个setInt注意这里动态拼接的是占位符数量,不是参数值。占位符本身是SQL结构的一部分,但?不携带任何攻击载荷,所以安全。另外还要强调:ids列表里每个元素都要强制类型转换,比如Integer.valueOf(String),如果转换失败直接抛异常,这样即使有漏网之鱼也进不了SQL。
3.3 存储过程内部的动态SQL
存储过程是个容易被忽略的角落。很多人以为“用存储过程就安全了”,其实存储过程内部的EXEC动态拼接同样存在注入风险。
举个例子,假设有一个存储过程接收表名参数:
CREATE PROCEDURE GetData @TableName NVARCHAR(128) AS BEGIN DECLARE @Sql NVARCHAR(MAX) SET @Sql = 'SELECT * FROM ' + @TableName EXEC (@Sql) END这种写法里,参数还是被拼进了SQL文本。更隐蔽的方式是在存储过程内部使用sp_executesql,但动态拼接的那部分字符串如果不是参数化的,依然有风险。正确的存储过程写法应该是:
CREATE PROCEDURE GetUser @UserID INT AS BEGIN SELECT * FROM users WHERE id = @UserID END如果必须动态执行,用sp_executesql加上参数定义:
DECLARE @Sql NVARCHAR(MAX) SET @Sql = N'SELECT * FROM users WHERE id = @UserID' EXEC sp_executesql @Sql, N'@UserID INT', @UserID = @UserID这里的关键是让存储过程内部也遵循“结构固定、参数独立”的原则,而不是把外部输入原样贴进SQL文本。
3.4 批量插入和批量更新
批量操作场景下,参数化查询的写法容易走样。比如批量插入100条记录,有人图省事:
String sql = "INSERT INTO logs (msg) VALUES ('" + msg + "')"; for (String m : messages) { statement.execute(sql); }正确做法是循环里复用同一个预编译语句,每次只重置参数:
String sql = "INSERT INTO logs (msg) VALUES (?)"; try (PreparedStatement pstmt = connection.prepareStatement(sql)) { for (String m : messages) { pstmt.setString(1, m); pstmt.addBatch(); } pstmt.executeBatch(); }批处理的好处不只是安全,性能上也更好,减少了语句解析次数。BA,这里补充一点:很多数据库驱动对batch的支持需要配置rewriteBatchedStatements之类的参数(MySQL Connector/J里是rewriteBatchedStatements=true),开了之后性能提升非常明显,但这个参数不影响安全性,只影响性能。
3.5 分页排序的“合法”注入面
分页查询通常也有参数,比如page、pageSize、sortField、sortOrder。常见的错误是只对page做了类型转换,而sortField和sortOrder直接用拼接。这个场景我在2.5节已经提过白名单方案,这里再补充一个更隐蔽的注意点:LIMIT和OFFSET虽然是数值,但在某些数据库方言里,它们也不能直接拼接。比如在PostgreSQL的某些版本,LIMIT后面跟表达式可能有边界风险。
我的建议是,所有分页参数统一走参数化:
String sql = "SELECT * FROM products ORDER BY id LIMIT ? OFFSET ?"; pstmt.setInt(1, pageSize); pstmt.setInt(2, (page - 1) * pageSize);4. 常规加固:输入校验、权限与纵深防御
4.1 输入校验怎么设计才不矫枉过正
参数化查询不是万能钥匙,它解决了“SQL结构被篡改”的问题,但解决不了“业务逻辑被滥用”的问题。输入校验是第二道防线,但设计不好会伤到正常业务。
我见过一些团队用正则严格限制用户名只能包含字母数字,理由是防注入。结果用户的真实姓名里带个点、带个撇号就注册不了,天天被投诉。实际上,在参数化查询到位的前提下,你完全不需要这么严格的字符过滤,真正需要做的是:
- 类型强制:数字就用
int解析,日期就解析成日期对象,类型对了,大部分注入载荷自动失效。 - 长度限制:数据库里字段长度是50,你可以在代码里限制50,避免超长字符串进入后续环节,也能挡掉一部分探测载荷。
- 枚举校验:凡是取值集合有限的字段(如状态、角色、排序方向),一律用枚举或白名单,天然免疫注入。
- 语义校验:比如邮箱要符合邮箱格式,手机号要符合手机号格式,这是业务规则,不是安全规则。
校验的粒度做到“该是什么类型就是什么类型”,比任何关键词黑名单都可靠。我个人不推荐用正则黑名单去屏蔽OR、UNION之类的关键字,因为攻击者用注释、大小写、编码变体就能绕过,还容易误伤正常用户输入。黑名单是“治标不治本”,白名单和类型校验才是“治本”。
4.2 最小权限原则:数据库账号分级
即使代码里真的出现了一个漏网的拼接SQL,最小权限也能让损失降到最低。很多小团队只有一个数据库账号,从建表到DELETE权限全开,应用连库用的也是这个超管账号。一旦注入,攻击者可以直接DROP TABLE或者写入WebShell,灾难性后果。
我建议至少分三档账号:
| 账号 | 用途 | 权限 |
|---|---|---|
| ddl_admin | 建表、变更结构 | DDL权限,仅DBA持有 |
| app_rw | 业务读写 | SELECT/INSERT/UPDATE/DELETE,仅限业务表 |
| app_readonly | 报表、导出 | 仅SELECT,必要时再加行级限制 |
应用层连接池按需选择账号。大部分业务用app_rw就够了,只有特定接口才切换app_readonly。这样即使某个读接口被注入,最多只能读数据,不能改数据;而写接口被注入,也不能删表。DCL(授权)操作权限永远不给应用账号。
除了账号分级,视图也是一个好选择。只暴露业务所需的字段,把底层表结构隐藏起来,减少信息泄露面。
4.3 从靶场到实战:用dvwa/sqlilab/pikachu练习时应该注意什么
这是很多安全学习者的共同路径。DVWA、SQLi-Labs、Pikachu、CTFHub技能树这几个靶场各有侧重,训练价值不太一样。
- DVWA(Damn Vulnerable Web Application):难度分等级。Low级别就是最原始的拼接注入,适合入门理解原理;Medium级别加了简单的输入过滤,能让你体会“过滤绕过”的思路;High级别用了部分安全函数,开始接近真实世界。
- SQLi-Labs:这是专门为SQL注入设计的靶场,关卡覆盖了联合查询、盲注、报错注入、堆叠注入、绕过WAF等几乎所有注入类型,每一关都对应一种真实世界的场景。缺点是环境搭建稍麻烦,建议用Docker。
- Pikachu靶场:它不只是SQL注入,还覆盖了XSS、CSRF、文件上传、RCE等,适合做综合练习。而且界面是中文的,新手友好。
- CTFHub技能树:把题目按技能标签分类,SQL注入在“Web”分支下,直接在线做题,不需要搭环境,适合碎片时间刷题。
用靶场练习的时候,我建议你反过来做一件事:每通过一关,尝试用参数化查询去重写那个原本脆弱的接口,再测试一下原本的注入Payload是不是失效了。这个“从攻击切换到防御”的过程,比单纯刷关卡更能建立肌肉记忆。不要只当攻击者,你要知道每个攻击点对应的修复方案是什么。
另外提醒一句:在靶场之外的任何真实网站上去测试注入行为,都是违法的。靶场存在的意义,就是让你在合法环境下把错误犯够。
5. 常见问题速查与排错实录
5.1 参数化查询为什么不生效的5个常见原因
我在Code Review和日常答疑中,经常碰到“我明明用了参数化,为什么还是被注入了”的反馈。排查下来,绝大多数是下面这几种情况:
原因一:占位符只用了“半套”
比如写MyBatis时查了一个字段用#{},另一个字段因为省事用了${}。或者JDBC里SQL字符串本身是拼接的,只有一半参数走了?。这种混合模式下,注入面依然存在。
排查方法:全局搜索${}出现的位置,以及Java/JavaScript/Go代码里+、${}、%s这种拼接SQL中出现变量的地方。
原因二:参数化之后又做了二次拼接
有些人在业务代码里先执行了一次pstmt.setString(),然后把结果拼进另一条SQL。这种情况其实不多,但确实遇到过。比如把PreparedStatement里的参数值取出来再拼到日志SQL里。日志场景的SQL虽然看似不敏感,但日志内容会进入数据库,如果拼接不当,注入可以从日志接口进来。
原因三:存储过程内部重新拼接
应用层参数化没问题,但参数传进存储过程后,存储过程内部用EXEC做了动态拼接。这个问题我在3.3节详细说过,本质是“外层安全、内层裸奔”。
原因四:连接层或中间件把预处理退化了
有个别连接池或数据库中间件为了兼容性,默认关闭了服务端预编译,导致?在客户端就完成了替换。这种情况虽然少见,但一旦发生,参数化就形同虚设。排查办法是抓数据库端日志,看实际收到的SQL是带?的还是已经被替换过的。
原因五:同一条SQL在不同分支走了不同逻辑
有些代码写了很多分支,比如“当参数为空时走A查询,当参数非空时走B查询”。A分支用了参数化,B分支是拼接的。测试时只测了A分支,上线实战中被从B分支打了。排查方法是检查所有数据库访问入口,一个都不能漏。
5.2 线上排查SQL注入嫌疑的排查思路
如果你怀疑线上有注入行为,但又不确定,可以按这个顺序排查:
第一步,看数据库慢查询日志。注入查询往往是异常SQL,比如UNION SELECT、OR 1=1、大量SLEEP()调用(盲注常用时间延迟)。慢查询日志里如果频繁出现执行时间异常的长查询,值得关注。
第二步,看应用错误日志。很多注入Payload会在数据库端引发语法错误,比如少了个引号、注释符没闭合。数据库驱动的报错信息如果直接堆在日志里,说明代码里可能没有统一异常处理,这本身就是问题。
第三步,排查Web中间件访问日志。正常的参数值不会包含'、--、/*、UNION、SELECT等特征字符,如果某个参数大量出现这类内容且来源IP分散,基本上可以确定有人在扫注入点。
第四步,用数据库审计插件或者自建日志钩子。把应用连接数据库的所有SQL记录到独立日志里,关键的写操作可以设置告警。
排查的核心思路是“找异常”,不是“找完美”。“正在被攻击”的特征往往很显眼,重要的是不要一来就想着怎么快速封IP,而是先复现问题,确认漏洞点,再决定修复方案。封IP只能临时止血,代码上的洞还在,换一批IP又来了。
5.3 使用工具扫描的结果怎么判断?
项目里用安全扫描工具(比如常见的AWVS、Xray这类)扫出SQL注入漏洞,很多人会机械地把所有“漏洞”都提交开发修复。但我的经验是:工具结果需要人工二次确认。
扫描工具常报的疑似注入点有以下几种情况需要单独判断:
第一种,参数化已经生效但工具误报。有些工具会发送数据库方言特有的函数(比如WAITFOR DELAY)来探测注入,如果后端数据库是MySQL而工具默认发的是MSSQL语法,可能产生假阳性。这时候要看数据库日志里有没有真正执行到可疑SQL。
第二种,工具报的是“盲注时间差”,但实际是正常业务里的慢查询。比如一个接口本身就因为数据量大、没走索引而耗时超过3秒,工具可能把延迟归因于SLEEP()注入。
第三种,漏洞确实存在,但业务代码是基础框架生成的,修复点在公共组件而非单个接口。这种情况需要统一修,而不是一个接口一个接口打补丁。
我自己的处理流是:扫描报告出来,先筛掉已知误报类型,然后把剩余条目按照“是否可绕过参数化直接拼SQL”和“是否可到达敏感数据”两个维度排优先级。修的时候,优先处理能被利用且数据敏感度高的接口,其余排期跟进。
5.4 最后分享一个自查脚本的思路
为了减少人工排查遗漏,我给团队写过一个简单的静态检查脚本,思路是扫描代码里所有SQL字符串,看有没有同时出现“拼接变量”和“SQL关键字”。伪代码大致如下:
import re import os # 需要检查的文件类型 patterns = [ r".*\.(java|py|php|js|go)$" ] # 危险拼接特征:变量插入SQL字符串 danger_ops = [ r"SELECT.*\\+\\s*\\w+", r"WHERE\\s+\\w+\\s*=\\s*\\+", r"execute\\s*\\(.*\\+", r"\\$\\{.*\\}.*(SELECT|UPDATE|DELETE|INSERT)", r"%s.*(SELECT|UPDATE|DELETE|INSERT)", ] def scan_file(filepath): with open(filepath, "r", encoding="utf-8", errors="ignore") as f: lines = f.readlines() for idx, line in enumerate(lines, 1): for p in danger_ops: if re.search(p, line, re.IGNORECASE): print(f"[!] {filepath}:{idx}: {line.strip()}") def main(root): for dirpath, _, filenames in os.walk(root): for fn in filenames: if any(re.match(p, fn) for p in patterns): scan_file(os.path.join(dirpath, fn)) if __name__ == "__main__": main("src/")这个脚本很粗糙,只能作为第一轮筛查,它一定会有漏报和误报,但好处是能在一两分钟内跑完全项目,把可疑点全部捞出来,把时间花在人工判断上,而不是手工翻代码。有需要的话可以在CI里加一个类似的job,扫描到拼接SQL直接挂CI,这样新代码上库之前就能挡住一部分问题。
写在最后的体会
做安全防护这些年,我最大的感受是:SQL注入防护不是一个“做完就结束”的项目,而是每个写SQL的人都需要长期建立的编码习惯。参数化查询是基石,但基石之上,类型校验、白名单、最小权限、日志监控、定期扫描,缺一不可。防御层做得越深,攻击者利用单个漏洞的代价就越高,这本质上是在拉高攻击成本。
最后分享一个我自己的习惯:每次写完一段数据库操作代码,我会问自己三个问题——这段SQL的结构是不是固定的?如果结构是动态的,动态部分的取值集合是不是被限制死了?假如我是一个攻击者,这段代码里最值得试探的输入点在哪里?这三问花不了30秒,但能拦住绝大多数低级的坑。