参数化查询的盲区:SQL注入的残余战场与AI时代的攻防位移
原创
今木安全
今木安全
今木信息安全
2026年10月13日 11:30
上海
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
很多团队的 SQL 注入防御停在一条经验上:用预编译,别拼字符串。这条经验对,但不完整。它覆盖的是\"值\"的注入——' or '1'='1 这类往 WHERE 条件里塞的东西。真实业务里,SQL 的结构本身经常要由外部参数决定:排序字段、模糊匹配、in 列表、分页。这些位置预编译帮不上忙,因为参数化查询只能替换\"值\",不能替换\"结构\"。SQL 注入没有消失,它退到了这几处残余战场。
一、注入的本质:指令和数据共用一条通道
SQL 注入的根因只有一个:开发者把用户输入和 SQL 指令拼在同一条字符串里,数据库无法区分哪部分是\"要执行的指令\"、哪部分是\"要处理的数据\"。
String sql = \"SELECT \* FROM users WHERE id = \" + userId;
这行代码里,userId 一旦是 1 OR 1=1,语义就变了——数据库收到的是一条新 SQL,而不是\"一条 SQL 加一个值\"。这个区分本该由数据库引擎保证,而不是靠开发者自觉转义。
1.1 注入的常见形态
注入不只是\"登录框里敲 ' or 1=1\"这一种。按攻击者拿数据的方式,可以分成几类,各有各的检测难度:
| 类型 | 原理 | 典型场景 | 检测难度 |
|---|---|---|---|
| 联合查询(union) | 用 UNION SELECT 把攻击者构造的结果集拼到正常结果后面 |
有回显的查询 | 低 |
| 报错注入 | 故意触发数据库报错,让报错信息带出数据 | extractvalue、updatexml | 低 |
| 布尔盲注 | 用\"页面是否正常\"这个真假差异逐位猜数据 | 无回显但页面有差异 | 中 |
| 时间盲注 | 用 sleep() 的延迟差异逐位猜 |
完全无页面差异 | 高 |
| 堆叠注入 | 用分号追加第二条语句 | 支持多语句的数据库 | 中 |
| 二次注入 | 先存入、后取出,第二次拼接时才触发 | 注册→查询链路 | 高 |
| 宽字节注入 | GBK 编码吃掉转义符反斜杠 | 老 PHP + GBK 环境 | 中 |
有回显时,联合查询注入的步骤固定三步——先定列数,再找哪个位置能回显,最后把要读的数据塞进那个位置:
?id=1 ORDER BY 3-- -- 第1步:试列数,报错说明列数不对
?id=-1 UNION SELECT 1,2,3-- -- 第2步:-1 让原查询无结果,看 1/2/3 哪个回显
?id=-1 UNION SELECT 1,database(),3-- -- 第3步:把 database() 塞进回显位
报错注入更省事,不用猜列数,直接让数据库把数据吐进报错信息:
?id=1 AND extractvalue(1, concat(0x7e, (SELECT database()), 0x7e))
-- 报错回显:XPATH syntax error: '~dbname~'
盲注和二次注入是最容易被漏掉的两类。盲注不产生报错、不改变页面内容,只靠时间或布尔差异;二次注入的注入点不在\"接收输入\"的接口,而在\"后来取出数据再拼 SQL\"的那一步,静态扫描和人工 review 都难定位。
盲注虽然慢,但原理直观,两段 payload 就能说清。布尔盲注靠\"恒真/恒假\"先确认注入点:
?id=1 AND 1=1 -- 页面正常
?id=1 AND 1=2 -- 页面异常 → 存在注入
?id=1 AND length(database())=5 -- 猜库名长度
?id=1 AND substr(database(),1,1)='a' -- 逐位猜字符
时间盲注把\"真假\"翻译成\"延迟\",sleep() 返回前页面会卡住:
?id=1 AND IF(length(database())=5, sleep(5), 0) -- 延迟5秒=猜对
二次注入的隐蔽性在\"注入点不在第一现场\":注册时用户名填 admin'--,应用做了转义存进库,此时它看似无害;后来某个功能把用户名取出来拼进 UPDATE ... WHERE username='$name',admin'-- 把后面的条件注释掉,注入才生效。第一道防线拦不住它,因为危险发生在第二次取用的时候。
1.2 为什么转义靠不住
转义要求开发者预判攻击者所有可能的编码方式,而编码方式无穷无尽:大小写、十六进制、双重 URL 编码、注释符插花、DBMS 特有函数。攻击者只要找到一种你没想到的写法,转义就失效。真正的边界不能交给\"猜\",要交给数据库引擎。
二、预编译:先定骨架,后填参数
参数化查询把\"指令\"和\"数据\"拆成两步。以 JDBC 的 PreparedStatement 为例:
String sql = \"SELECT \* FROM users WHERE id = ?\";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setInt(1, userId); // 参数此刻才绑定,且以\"数据\"身份进入
ResultSet rs = ps.executeQuery();
2.1 三段执行流程
数据库端的执行流程不是\"拼好字符串再解析\",而是三段:
| 阶段 | 发生什么 | 参数参与吗 |
|---|---|---|
| parse(解析) | 把 SQL 文本解析成语法树,? 是占位符 |
否 |
| bind(绑定) | 把参数值填进占位符,此时值已是叶子节点,不会再被当语法解析 | 是,但身份是\"数据\" |
| execute(执行) | 按语法树执行 | — |
关键在 bind 阶段:参数值进入的是语法树的叶子节点,而不是重新走一遍词法/语法解析。所以 1 OR 1=1 被当成一个字符串值,而不是一个逻辑表达式。这就是预编译能防注入的全部原理。
2.2 Statement 与 PreparedStatement 的对照
| Statement | PreparedStatement | |
|---|---|---|
| 编译 | 每次执行都重新 parse | 骨架只编译一次,复用 |
| 参数 | 拼进字符串,参与语法解析 | 走 bind,不参与语法解析 |
| 注入风险 | 高,字符串拼成什么样语法树就是什么样 | 值注入被彻底堵死 |
| 批量性能 | 每条都编译,慢 | 批量只 bind 一次,快 |
Statement 既慢又危险,生产代码里没有理由再用它。
三、预编译的盲区:动态 SQL 的残余战场
预编译能生效的前提是:SQL 的语法结构在执行前就固定了。一旦结构本身要由外部输入决定,占位符就无处安放。业务里最常见的三处:模糊查询、in 列表、order by。
3.1 模糊查询:通配符的位置
模糊查询的坑在于通配符和用户输入混在一起。错误写法是把 name 直接拼进 SQL:
// 错误:拼字符串,注入点就在 name 上
String sql = \"SELECT \* FROM user WHERE name LIKE '%\" + name + \"%'\";
正确写法:SQL 骨架固定,通配符放进参数值里,name 走占位符:
// 正确:% 留在参数里,name 不参与拼 SQL
String sql = \"SELECT \* FROM user WHERE name LIKE ?\";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, \"%\" + name + \"%\");
要分清一点:% 是通配符不是注入符,把它放进参数值没问题。真正的风险是 name 本身被拼进 SQL 文本。
3.2 in 列表:占位符数量不固定
in 的参数个数是动态的,没法写死 IN (?, ?, ?)。MyBatis 的正确做法是用 foreach 动态生成占位符:
SELECT \* FROM user WHERE id IN
#{id}
#{id} 会被 MyBatis 转成 ? 占位符,foreach 只负责生成\"逗号分隔的一串占位符\",用户的值仍然走参数绑定。反过来,一旦把 # 换成 $,写成 ${id},MyBatis 就直接做字符串拼接,注入立刻回来。#{} 和 ${} 的这一字之差,就是预编译和拼接的分界线。
3.3 order by:结构本身就是参数
排序字段是最典型的\"结构注入\"。因为排序字段天然不是\"值\",没法用 ? 替换——ORDER BY ? 是合法语法,但那个 ? 绑定的是字符串,等价于 ORDER BY 'name',排序永远无效。于是很多人被迫写:
// 错误:order by 字段直接拼进去
String sql = \"SELECT \* FROM user ORDER BY \" + orderField;
orderField 传 name; DROP TABLE user-- 就是完整注入。这里的正解不是\"想办法参数化\",而是白名单映射——把外部传入的排序键映射到受控的枚举:
private static final Map
\"name\", \"name\",
\"age\", \"age\",
\"createTime\", \"create\_time\"
);
public List
String column = ORDER\_MAP.get(orderKey);
if (column == null) throw new IllegalArgumentException(\"illegal order key\");
return jdbc.query(\"SELECT \* FROM user ORDER BY \" + column, rowMapper);
}
外部传进来的只是一个 key,真正进 SQL 的列名来自受控映射表。攻击者能传的 key 只有 name/age/createTime,传别的直接抛异常。
3.4 其它结构参数
表名、列名、group by、limit 这些\"结构参数\"同理,全部走白名单,不信任任何外部字符串。判断口诀是:凡是没法放进 ? 的,一律白名单映射,不允许任何外部字符串直接进 SQL 文本。
3.5 框架不等于安全
用了 ORM 不等于用了预编译,这两个经常被混为一谈。ORM 只是把 SQL 生成藏进了框架,框架内部仍然可能拼字符串。两个最常见的漏点:
// 1. 原生 SQL 里手写拼接
@Query(\"SELECT u FROM User u WHERE \" + whereClause)
List
// 2. 排序字段动态化:绝大多数 ORM 不支持 order by 参数化
em.createQuery(\"SELECT u FROM User u ORDER BY \" + orderField);
只要最后落到数据库的 SQL 里,还有一段是\"外部字符串拼进去的\",注入点就还在。判断标准不看\"用了什么框架\",只看最终 SQL 里有没有外部字符串参与拼装。
四、研发期的防线排序:谁主谁辅
4.1 谁主谁辅
防御手段常被列成三样:参数预编译、正则过滤、字符串过滤。这里要纠正一个错误认知——后两样不能当主力。正则/字符串过滤是黑名单,本质是\"猜攻击者会用什么编码\",而编码方式无穷。黑名单永远晚一步,它的正确位置是纵深里的一道附加过滤,不是\"能拦住注入\"的防线。研发期防线的正确排序:
| 优先级 | 手段 | 作用 | 能不能单独扛 |
|---|---|---|---|
| 1 | 参数化查询 | 从根上切断\"值注入\" | 能(对值注入) |
| 2 | 白名单映射 | 兜住\"结构注入\"(order by / 表名 / 列名) | 能(对结构注入) |
| 3 | 输入过滤(正则/字符串) | 附加收敛,拦明显的攻击特征 | 不能单独扛 |
| 4 | 最小权限 + 关闭错误回显 | 限制注入成功后的破坏半径 | 不能防,只能降损 |
4.2 最小权限与错误回显
第 4 条经常被漏掉。应用连数据库的账号不该是 root/sa,只授业务需要的权限:
-- 只给业务需要的权限,禁掉高危操作
GRANT SELECT, INSERT, UPDATE ON appdb.\* TO 'app\_user'@'%';
-- 回收高危权限
REVOKE DROP, ALTER, FILE ON \.\ FROM 'app\_user'@'%';
同时生产环境把数据库报错回显关掉,换成通用提示——报错注入的前提就是数据库愿意把结构信息吐出来。这两条不防注入,但能把注入成功后的破坏半径压到最小。
五、感知与扫描:从语义分析到数据库防火墙
5.1 语义分析这条路的工程化终点:数据库防火墙
对执行 SQL 做语义分析,工程化之后就是数据库审计网关(数据库防火墙):在应用和数据库之间串一个代理,实时解析每一条 SQL 的语法树,判断它是否偏离了业务该有的形态。判断标准不是\"命中某个关键词\",而是\"这条 SQL 的语义不该由这个接口、这个账号、这个时间产生\"。威胁等级分级后,按等级差异化处理:
| 等级 | 判定依据 | 动作 |
|---|---|---|
| 低危 | 命中敏感函数,但业务语境模糊 | 记录告警 |
| 中危 | 偏离该接口的正常 SQL 基线 | 记录 + 人工复核 |
| 高危 | 访问蜜罐表 / 多语句 / 越权读 | 实时阻断 + 封禁来源 |
它和 WAF 不是一回事,两者看的东西不一样:
| WAF | 数据库防火墙 | |
|---|---|---|
| 位置 | 应用前(HTTP 层) | 应用与数据库之间(SQL 层) |
| 看什么 | HTTP 请求特征 | SQL 语义、语法树 |
| 拦什么 | 已知攻击特征 | 偏离业务形态的 SQL |
| 短板 | 拦不住编码绕过 | 需摸清正常 SQL 基线 |
两者是互补关系:WAF 在入口拦已知特征,数据库防火墙在出口拦\"不该出现的 SQL\"。
5.2 蜜罐数据库
在业务库里混入几张\"看着像真、实则没人会用\"的蜜罐表,比如 user_credit、admin_token,字段造得有诱惑力。正常业务代码永远不碰它们,一旦有 SQL 访问到,基本可以断定是攻击者在探路,直接触发告警并封禁来源 IP。成本极低,误报也极低,因为正常流量根本不落在那几张表上。
5.3 高效扫描:速度与准确度分开解决
扫描要同时解决速度和准确度两个问题。速度靠多线程、分布式;准确度靠\"指纹识别 + POC 结合\"——先识别目标 DBMS 版本、中间件、WAF 类型,再针对性投 POC,比无脑盲注快一个量级。sqlmap 就是这套思路的成熟实现:
sqlmap -u \"http://target/item?id=1\" --batch \
--level 3 --risk 2 --threads 10 \
--dbs
常用参数对照:
| 参数 | 作用 |
|---|---|
--dbs / --tables / --columns / --dump |
逐层枚举库、表、列、数据 |
--level 1-5 |
探测深度,越高越激进 |
--risk 1-3 |
风险等级,越高越可能触发危险操作 |
--os-shell |
尝试拿到系统 shell |
工程化上,把这些扫描能力接进 CI/CD,让每次发版前自动跑一轮,属于左移的一部分——把安全检查从\"上线后\"挪到\"开发中\"。代码还没进生产,注入点就已经在流水线里被扫出来,此时修复成本最低。
六、AI 的介入:SQL 注入换了个入口
6.1 攻侧:AI 把注入带回了代码里
AI 编码助手普及后,出现了一个讽刺的回归:字符串拼接重新变多了。因为参数化查询在公开代码里占比低,拼接写法占比高,模型学到的\"常见写法\"就是拼接。有研究统计,AI 生成的数据库相关代码里,约 18% 用的是字符串拼接而非参数化查询。一个用 AI 写 SQL 的新工程师,比手写的老工程师更容易踩回注入的坑——模型不会主动提醒他\"这里该用预编译\"。
6.2 攻侧:text-to-SQL 的间接注入
更大的变化在 text-to-SQL 这条链路上。当应用让 LLM 直接生成 SQL 去查库,注入的入口从\"用户输入拼进字符串\"变成了\"用户输入混进 prompt\"。攻击者把恶意指令藏进 LLM 会读到的文档、邮件、网页里,诱导它生成 ; DROP TABLE orders;-- 这种 SQL,应用再原样执行。这条链路在 OWASP LLM Top 10 里单列了一条(不当输出处理,LLM05),本质和 SQL 注入一模一样——指令和数据共用一条通道,只是通道从 SQL 字符串换成了 prompt。间接注入比直接注入更难防:攻击面无边,任何 LLM 会读的数据源都可能藏注入,而应用无法逐个校验自己控制不了的内容。
6.3 防侧:检测转向模型
传统 WAF 的正则规则已经拦不住现在的注入——多重编码、SQL 注释插花、DBMS 特有变种,都是专门为绕正则设计的。常见的绕过手法:
| 手法 | 例子 |
|---|---|
| 大小写混合 | sElEcT |
| 注释插花 | s/**/elect |
| 双重 URL 编码 | %2527 |
| 十六进制编码 | 0x61646d696e (=admin) |
| 空白符替换 | %0a 、%0b 换行、制表符 |
| 等价函数 | substr 换成 mid、substring |
正则规则每堵一个写法,攻击者就换一种等价写法,这是无休止的猫鼠游戏。新一代做法是让模型看语义和统计特征,而不是看关键词。基于 SVM、XGBoost、LightGBM,乃至 BERT、LSTM 的检测器,在公开数据集上的准确率能到 93%–99.9%,对没见过的新变种明显比规则强。落地上,这类模型可以作为 WAF 的附加判定层,或者跑在数据库审计网关里。
6.4 防侧:LLM 生成 SQL 的四层防护
针对 LLM 生成 SQL 的链路,有一套比\"写死关键词\"更有效的组合:
| 层 | 做法 | 拦什么 |
|---|---|---|
| 输入盾 | 词法过滤,拦明显高危 token(DROP/SLEEP) | 直接注入 |
| 语义检测 | 小模型算 embedding + 异常分,识别被诱导的 prompt | 混淆、间接注入 |
| 查询签名控制 | 对生成的 SQL 做词法/指纹检查,禁掉禁止操作 | LLM 输出里的恶意 SQL |
| 数据库兜底 | 账号最小权限(只读)、SQL 重写、行级过滤 | 前面全失效时的最后一道 |
多层叠加的实测 F1 能到 95% 以上,代价是几毫秒到零点几秒的延迟。最后一条\"数据库兜底\"最关键:不管 LLM 被诱导生成什么,一个只读账号加行级过滤,天然封死了 DROP 和越权读。
七、防御重构:把\"值\"和\"结构\"分开治理
落到底,SQL 注入的治理可以收敛成两句话:值用参数化,结构用白名单。剩下的都是这两条的延伸。
研发侧,代码里出现 ${}(MyBatis 拼接)就该在 code review 里亮红灯;CI 里跑带规则集的静态扫描,把拼 SQL 的模式直接标记;order by、表名、列名一律走枚举映射,不接受外部字符串。
数据库侧,账号最小权限、关闭错误回显、审计网关三件套,把注入成功后的破坏半径压到最小。
检测侧,语义引擎、ML 模型、蜜罐表,覆盖\"已知特征 + 未知变种 + 正在发生的利用\"三个层次。
AI 侧,把 LLM 的输出当成和用户输入一样不可信的东西处理——生成 SQL 要过签名控制,执行账号要只读,别再默认\"模型吐出来的就是安全的\"。
* * *
文中涉及的技术手段仅用于授权范围内的安全测试与防护建设,请严格遵守《网络安全法》及相关法律法规。
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论