【仓颉语言入门 · 第二篇 第 12 课】预编译与防 SQL 注入
前三课把 SQL 写得越来越熟,但有个隐患一直在代码里躺着:SQL 语句是字符串 ,而字符串是能拼的。这一课专门拆这颗雷:先弄懂
prepareStatement这个名字里"预编译"三个字的含义,再亲眼复现一次完整的 SQL 注入(放心,只做只读演示),最后用占位符把它防得明明白白。
目录(第二篇导航)
第一篇《仓颉语言入门》共 30 课已完结(环境搭建 → 语法 → 函数 → 类型系统 → 工程化 → 并发 → 两场实战)。第二篇在更高的起点上继续,共 26 课、4 个模块:
| 模块 | 课次 | 内容 |
|---|---|---|
| 一、补遗篇 | 01~06 | 正则、时间日期、命令行参数、随机数、加密哈希、二进制处理 |
| 二、数据库篇 | 07~15 | 入门与连接、建表、查询、插入、更新删除、预编译防注入、事务、手写 DAO、综合小实战 |
| 三、进阶硬核篇 | 16~22 | 反射、宏基础、deriving 实战、FFI 基础、FFI 进阶、并发容器、对象池 |
| 四、实战项目篇 | 23~26 | HTTP 基础、HTTP 进阶、多线程日志分析器、简易爬虫 |
- 正则表达式(std.regex)
- 时间日期处理(std.time)
- 命令行参数解析(std.argopt)
- 随机数与概率(std.random)
- 加密与哈希(std.crypto)
- 二进制数据处理(std.binary)
- 数据库入门:认识 std.database.sql,连上 MySQL
- 建表:用代码定义表结构
- 查询:SELECT 与结果集遍历
- 插入:INSERT、占位符与自增主键
- 更新与删除:UPDATE / DELETE
- 预编译与防 SQL 注入(本文)
- 事务:批量操作的成败一致
- 手写 DAO 数据访问层
- 数据库综合小实战
- 反射:在运行时打量类型
- 宏基础:std.ast 与 quote
- deriving 宏实战
- 与 C 互操作基础:FFI 调用 C 函数
- FFI 进阶:结构体、指针与编译配置
- 并发容器:并发安全的集合
- 对象池与资源复用
- 实战三:HTTP 服务端基础
- 实战四:HTTP 进阶与 JSON 接口
- 实战五:多线程日志分析器
- 实战六:简易爬虫
一、什么是预编译
回想前几课的写法:conn.prepareStatement(sql) 拿到语句,set() 填值,query() 或 update() 执行。这个流程其实就是预编译,只是我们一直在"用而不自知":
- prepare(预备)阶段 :把带
?的 SQL 模板先发给数据库服务器,服务器解析、编译 好这条语句的执行计划,空着?等值进来; - execute(执行)阶段 :
set()把值填进预留的位置,query()/update()让服务器拿"编译好的模板 + 填进来的值"去执行。
关键区别在于值和模板是分开发的 。服务器拿到值时,只把它当作"一个数据"填进位置里,绝不会再解析它的内容------这句话就是本课全部安全性的来源。
反例是我们接下来要自己犯的错:把用户输入拼进 SQL 字符串再整句发出。拼接出来的语句里,用户的输入和 SQL 关键字平起平坐,服务器无法分辨哪部分是"你的意图"、哪部分是"用户的内容"------它只能全都执行。
先记住结论,下一节亲手制造一次"事故"。
二、先看真实事故:客户端里手拼 SQL
场景:登录功能要按用户名查人,检查这个人存不存在。SQL 本来长这样(第 9 课的老朋友):
sql
USE tumo_governance;
SELECT COUNT(*) FROM sys_user WHERE user_name = 'zhangsan';
text
+----------+
| COUNT(*) |
+----------+
| 1 |
+----------+
查到 1 个,说明 zhangsan 存在。程序里最直白的写法是把用户输入拼进引号里:
text
"SELECT COUNT(*) FROM sys_user WHERE user_name = '" + 输入 + "'"
输入正常时一切安好。但假如有人在登录框里输入的不是用户名,而是这一串:
text
' OR '1'='1
拼出来的完整 SQL 变成了:
sql
SELECT COUNT(*) FROM sys_user WHERE user_name = '' OR '1'='1';
注意看引号:输入开头的 ' 把原来的起始引号提前闭合 了,于是 WHERE user_name = '' 先成了一个恒假条件,紧接着用户塞进来的 OR '1'='1' 变成了 SQL 的组成部分------'1'='1' 恒真,整个 WHERE 恒真。客户端里跑一下:
sql
SELECT COUNT(*) FROM sys_user WHERE user_name = '' OR '1'='1';
text
+----------+
| COUNT(*) |
+----------+
| 9 |
+----------+
9 个用户全部命中------** WHERE 条件被输入改写了,查询查出了全表**。这就是 SQL 注入:输入不再是数据,而是代码。
登录检查查到人就能过,那么输入这一串的攻击者就"登录"成功了。真实事故里,注入还能偷数据(UNION SELECT 拖库)、改数据、甚至删库。本节只在只读的 SELECT 上演示危害,拼接过 UPDATE / DELETE 的注入是真的能删库的。
三、程序里的拼接三连
把同样的拼接写法搬进仓颉程序。三个场景连着跑:正常输入、注入输入、带单引号的输入:
cangjie
package cj_mysql_demo
import std.database.sql.*
import mariadb.cdbc;
main(): Int64 {
let driver = DriverManager.getDriver("mariadb").getOrThrow()
let ds = driver.open("mariadb://127.0.0.1:3306", [
("username", "root"),
("password", "wangpeng"),
("database", "tumo_governance")
])
let conn = ds.connect()
let input1 = "zhangsan"
let stmt1 = conn.prepareStatement(
"SELECT COUNT(*) FROM sys_user WHERE user_name = '" + input1 + "'")
let r1 = stmt1.query()
if (r1.next()) {
println("场景1(zhangsan)匹配到 ${r1.get<Int64>(1)} 个用户")
}
r1.close()
stmt1.close()
let input2 = "' OR '1'='1"
let stmt2 = conn.prepareStatement(
"SELECT COUNT(*) FROM sys_user WHERE user_name = '" + input2 + "'")
let r2 = stmt2.query()
if (r2.next()) {
println("场景2(' OR '1'='1)匹配到 ${r2.get<Int64>(1)} 个用户")
}
r2.close()
stmt2.close()
let input3 = "zhangsan'"
let stmt3 = conn.prepareStatement(
"SELECT COUNT(*) FROM sys_user WHERE user_name = '" + input3 + "'")
let r3 = stmt3.query()
if (r3.next()) {
println("场景3 匹配到 ${r3.get<Int64>(1)} 个用户")
}
r3.close()
stmt3.close()
conn.close()
0
}
运行输出:
text
场景1(zhangsan)匹配到 1 个用户
场景2(' OR '1'='1)匹配到 9 个用户
An exception has occurred:
SqlException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''zhangsan''' at line 1, errorCode: 0
三个场景三个下场,拼接口的账单一次结清:
- 场景 1:正常输入,一切正常------这也是拼接能"活"那么久的原因,正常路径看起来毫无破绽;
- 场景 2:注入输入,恒真条件改写了 WHERE,9 个用户全部命中;
- 场景 3 :输入里只是多了一个单引号(用户名
O'Brien就长这样),引号数量失衡,整句 SQL 语法崩塌------拼接代码连"用户输入了奇怪的名字"都扛不住。
四、防住它:占位符
现在把同一条查询改成占位符版,三个场景原样重放:
cangjie
let stmt = conn.prepareStatement(
"SELECT COUNT(*) FROM sys_user WHERE user_name = ?")
let input1 = "zhangsan"
stmt.set(1, input1)
let r1 = stmt.query()
if (r1.next()) {
println("占位符版(zhangsan)匹配到 ${r1.get<Int64>(1)} 个用户")
}
r1.close()
let input2 = "' OR '1'='1"
stmt.set(1, input2)
let r2 = stmt.query()
if (r2.next()) {
println("占位符版(' OR '1'='1)匹配到 ${r2.get<Int64>(1)} 个用户")
}
r2.close()
let input3 = "zhangsan'"
stmt.set(1, input3)
let r3 = stmt.query()
if (r3.next()) {
println("占位符版(zhangsan')匹配到 ${r3.get<Int64>(1)} 个用户")
}
r3.close()
stmt.close()
运行输出:
text
占位符版(zhangsan)匹配到 1 个用户
占位符版(' OR '1'='1)匹配到 0 个用户
占位符版(zhangsan')匹配到 0 个用户
对比一下两组结果:
| 输入 | 拼接版 | 占位符版 |
|---|---|---|
zhangsan |
1 个 | 1 个 |
' OR '1'='1 |
9 个 | 0 个 |
zhangsan' |
语法崩,抛异常 | 0 个 |
占位符版的三个结果全部正确,而且连异常都没有 ------注入串和带引号的名字都老老实实变成了"要查找的那个字符串",查无此人就是 0。原理就是第一节那句话:预编译的模板先到服务器,? 的位置已经焊死了,set() 送来的值只当数据填进去,永远不会被当成 SQL 再解析。
顺带一个本课实测的细节:同一个 stmt 可以反复 set + query()(上面的代码就是这么写的),而且下一次 query 如果不重新 set,参数会沿用上一次的值 ------这和第 10 课说的"update() 执行后参数被清空"不一样。别去背哪种沿用哪种清空,养成习惯:每次执行前都重新 set,两种语句通吃。
五、常见报错速览
拼接引发语法崩(第三节场景 3 的原文):
text
SqlException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''zhangsan''' at line 1, errorCode: 0
near '...' 里那串就是"用户输入 + 你的引号"的残骸。真实项目里看到 near 后面出现不像 SQL 的内容,第一反应就该是:有用户输入被拼进来了。
六、常用 API 速查(防注入篇)
| 场景 | 危险写法(拼接) | 安全写法(占位符) |
|---|---|---|
| 等值查询 | "WHERE user_name = '" + 输入 + "'" |
"WHERE user_name = ?" + set |
| INSERT | 值拼进 VALUES |
VALUES (?, ?, ?) + set |
| UPDATE | 值拼进 SET / WHERE |
SET login_ip = ? + set |
| 模糊查询 | "LIKE '%" + 关键字 + "%'" |
"LIKE ?" + set(1, "%关键字%") |
| 原则 | 值不进 SQL,SQL 只留 ? |
七、常见问题 FAQ
Q1:模糊查询 LIKE 也用占位符吗?通配符写在哪?
用,而且通配符写在参数值里,不写在 SQL 里:
cangjie
let stmt = conn.prepareStatement("SELECT user_name FROM sys_user WHERE user_name LIKE ?")
stmt.set(1, "zhang%")
let result = stmt.query()
while (result.next()) {
println("LIKE 命中:${result.get<String>(1)}")
}
result.close()
stmt.close()
text
LIKE 命中:zhangsan
LIKE 命中:zhangsan1
千万别写成 LIKE '%?%'------? 被引号包住后就不再是占位符 ,只是普通字符。本课实测过这个坑:set(1, "zhang") 不报错,但参数根本没被用上,语句在字面查找"名字里带问号的用户",永远命中 0 行。不报错的 bug 才是最难查的 bug ,? 永远裸着写在 SQL 里,通配符进 set 的值。
Q2:数字就没有注入风险了吧?WHERE id = 拼个数而已。
风险照样有,只是形式不同。字符串有引号挡着,攻击者还得先"跳出引号";数字拼在引号外面,输入 1 OR 1=1 不用任何引号就能改写条件。别赌输入的内容和长度------数字、字符串、时间,一律占位符,规则越简单越不容易被绕过。
Q3:预编译除了安全还有别的好处吗?
有。同一条模板反复执行时,服务器不需要每次重新解析编译 SQL,只需要换参数执行------这也是它名字里"预"字的另一个含义。性能差异日常练习感受不到,本课不展开,你只要记住:安全上它是必选项,性能上它顺带不亏。
Q4:既然占位符这么好,还有哪些地方容易松懈?
最常见的是"这段输入是内部的,拼一下没关系"------内部输入迟早变成外部输入(配置、上游服务、数据库里存的旧数据都可能被污染)。守住一条线:SQL 里只出现 ? 和 SQL 关键字,一切值走 set()。写这条 SQL 的时候没有例外,比事后审查每一处拼接可靠得多。
八、课后练习
前 3 题必做,第 4 题选做。全部在只读的
sys_user表上操作,不用改任何数据。
第 1 题(必做)复现一次注入
把第三节的拼接版程序敲进 cj_mysql_demo,输入用 ' OR '1'='1,运行确认输出"匹配到 9 个用户"。
提示:完整代码就是第三节,亲手敲一遍比看十遍记得牢------重点感受"输入怎么变成 SQL 的一部分"。
期望输出:
text
场景2(' OR '1'='1)匹配到 9 个用户
第 2 题(必做)改成占位符防住它
在第 1 题的基础上改成第四节占位符版,同样的输入,确认输出"匹配到 0 个用户",再试一个正常用户名确认能查到。
提示:SQL 里只留 ?;stmt.set(1, input);同一个 stmt 可以 set + query() 反复用,每次执行前重新 set。
期望输出:
text
占位符版(' OR '1'='1)匹配到 0 个用户
占位符版(admin)匹配到 1 个用户
第 3 题(必做)模糊查询的占位符
用 LIKE ? 查用户名以 l 开头的用户,逐行打印。
提示:SQL 为 SELECT user_name FROM sys_user WHERE user_name LIKE ?;set(1, "l%");遍历写法照搬第 9 课。
期望输出:
text
LIKE 命中:lisi
LIKE 命中:liang
第 4 题(选做)复现"静默失效"
把 SQL 写成 SELECT COUNT(*) FROM sys_user WHERE user_name LIKE '%?%',set(1, "zhang"),运行并解释为什么输出是 0、而且不报任何错。
提示:对照 Q1 的答案------引号里的 ? 不是占位符;这题的收获是"以后看到永远查不出结果的模糊查询,先检查占位符有没有被引号包住"。
期望输出:
text
命中 0 个
下节预告
一条业务操作往往要动好几张表:转账要一扣一加,缺一半都不算数。可我们现在的每条 SQL 都是独立执行的------第二条失败时,第一条已经落库,数据就烂在一半。下一课 事务:批量操作的成败一致------把一组 SQL 打包成"要么全成、要么全不算"的整体,给批量操作上个保险。
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写。本文全部代码、运行输出与报错文案均已在 SDK 1.2.0 + MySQL 5.7 环境下逐块编译运行、逐字实测;如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。
💬 遇到问题?扫码联系作者
跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信 直接咨询(请备注"仓颉课程"):

离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。
📥 工具下载
本系列全程使用的仓颉 IDE ------ CIDE(免费开源、社区版):
- GitCode 仓库 / 安装包下载 :https://gitcode.com/wp_upala/cide
- 打开页面后进入 发行版(Releases) ,两种包任选其一:
- 安装版 :下载
CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用; - 免安装版(Portable) :下载
CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
- 安装版 :下载
- 仓颉 SDK 请前往仓颉编程语言官网下载:https://cangjie-lang.cn