找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2378

积分

1

好友

331

主题
发表于 昨天 18:54 | 查看: 13| 回复: 0

在月黑风高的夜晚,小贼紧跟着老贼来到老街金店的门前。四顾无人,两人悄无声息地行动起来。此时,小贼突然问道:“为什么要撬锁?”老贼一个大逼斗拍下来,引起远处一声狗吠,是夜,又恢复了平静……

这个故事告诉我们,有时候最直接的路径并非唯一选择,甚至可能绕了远路。在 SQL 安全领域,预编译是预防注入的一道重要屏障,它能防御绝大多数注入攻击。

预编译与字符串拼接的较量

让我们先通过一个经典的登录场景,看看预编译是如何工作的。

场景一:未使用预编译(高危)

// 获取用户输入
String username = request.getParameter("user");  // 恶意输入:admin' OR '1'='1
String password = request.getParameter("pass");

// 字符串拼接SQL(高危操作)
String sql = "SELECT * FROM users WHERE username='" + username + "' AND password='" + password + "'";

// 执行SQL
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql);

// 最终拼接出的高危SQL:
// SELECT * FROM users WHERE username='admin' OR '1'='1' AND password='xxx'

攻击效果:OR '1'='1' 使查询条件恒成立,从而绕过了密码验证,直接返回管理员账户数据。

场景二:使用预编译(安全)

// 获取用户输入
String username = request.getParameter("user");  // 恶意输入:admin' OR '1'='1
String password = request.getParameter("pass");

// 使用 ? 作为参数占位符(参数化查询)
String sql = "SELECT * FROM users WHERE username=? AND password=?";

// 预编译并绑定参数
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setString(1, username);  // 参数1:username
pstmt.setString(2, password);  // 参数2:password

// 执行查询
ResultSet rs = pstmt.executeQuery();

乍一看代码变化不大,但背后的安全机制截然不同。从技术上讲,SQL 在预编译阶段会生成一个语法树,此结构在执行过程中不可变。这意味着用户的输入,无论包含什么字符(比如单引号),都只会被当作纯粹的“数据值”填充到预定的位置,而无法改变 SQL 语句本身的结构。

最终,数据库引擎执行的 SQL 类似于:

SELECT * FROM users WHERE username="admin' OR '1'='1" AND password='xxx'

你看,整个可疑的字符串 admin' OR '1'='1 被当作一个完整的用户名去匹配,自然无法找到记录,攻击也就失效了。

预编译的盲区与那“1%”的风险

补完开头的故事:又过了几个“月黑风高”的夜晚,老贼带着小贼还在研究金店的锁。小贼再次忍不住开口:“为什么不从后墙根挖个盗洞?”不出所料,老贼又一个大逼斗拍下来,引起远处一声狗吠,暗骂道:“为何不早说……”

这就像安全防护,如果我们只盯着“锁”(预编译),可能会忽略其他入口。预编译并非万能,在特定场景下它会失效。

动态结构注入(表名/列名拼接)

预编译仅对值参数(如 WHERE 子句中的条件值)有效。当 SQL 语句的结构本身需要动态变化时,例如拼接表名、列名或 SQL 关键字(我们称之为结构参数),占位符 ? 就无能为力了。

尝试将表名参数化,你会发现行不通:

SELECT * FROM ? WHERE id = ?

这行 SQL 在编译阶段就会报语法错误,因为数据库规定 FROM 后面必须是一个标识符(如表名),而不能是一个字符串值。因此,开发者不得不进行字符串拼接:

// Spring Boot 中常见的风险写法
String tableName = request.getParameter("type"); // 输入:users
String column = request.getParameter("field");   // 输入:password

// 预编译失效点:表名和列名无法使用占位符
String sql = "SELECT " + column + " FROM " + tableName + " WHERE id = ?";
jdbcTemplate.query(sql, new Object[]{userId}, mapper);

如果攻击者传入恶意的 type 参数:

users; DELETE FROM admin_logs--

最终拼接出的 SQL 将是灾难性的:

SELECT password FROM users; DELETE FROM admin_logs-- WHERE id = 123

-- 注释掉了后续语句,使得一次查询变成了“查询+删除”组合拳。

预编译与拼接混用(局部安全=全盘崩溃)

有时开发者会错误地混合使用两种方式,以为部分参数用了预编译就安全了。

# 半吊子防护的示例
user_id = request.GET['id']  # 输入:123 OR 1=1
sql = "SELECT * FROM users WHERE id = " + user_id + " AND status = ?"
cursor.execute(sql, ('active',))

拼接后的高危 SQL:

SELECT * FROM users WHERE id = 123 OR 1=1 AND status = ?

结果:预编译仅保护了 status 参数,而 user_id 的注入成功绕过了所有条件限制,返回了全部数据。

ORM 框架中的错误使用

MyBatis 等 ORM 框架中,#{}${} 的区别至关重要:

  • #{}:使用预编译占位符。
  • ${}:直接进行字符串替换(相当于拼接)。

错误的混用写法:

<select id="getData">
    SELECT * FROM ${tableName} WHERE id = #{id}
</select>

如果传入 tableName="users; TRUNCATE TABLE logs--",最终参与执行的 SQL 将是:

SELECT * FROM users; TRUNCATE TABLE logs-- WHERE id = ?

又一次,结构被篡改,TRUNCATE 语句得以执行。

SQL 注入的根本原因与防控机制

归根结底,SQL 注入的发生,是因为开发者错误地将不可信的用户输入直接拼接到 SQL 语句结构中。要构建纵深防御,需要一个清晰的决策流程。

防护决策树

SQL注入防护决策树流程图

这张流程图清晰地展示了面对不同类型 SQL 参数时的安全处理路径,是构建防线的有效指南。

白名单:最后的安全阀

对于无法避免的 ${tableName} 动态拼接,唯一的办法是在应用层(如 Java 业务逻辑层)实施严格的白名单校验。切记,仅在 XML 或注解中判断是远远不够的,攻击者可能直接绕过。

错误示范(依赖XML判断,不安全):

<select id="getData">
    <if test="tableName == 'users' or tableName == 'orders'">
        SELECT * FROM ${tableName} WHERE id = #{id}
    </if>
</select>

攻击者可以传入 tableName = “users; DROP TABLE logs--”<if> 标签的简单字符串比较可能无法拦截或会被绕过。

正确做法(Java层硬编码白名单):

// ✅ 正确做法:Java层硬编码白名单
public User getData(String tableName, Long id) {
    // 定义严格的可信表名集合
    Set<String> allowedTables = Set.of(“users”, “orders”, “products”);
    if (!allowedTables.contains(tableName)) {
        throw new SecurityException(“非法表名: “ + tableName);
    }
    // 经过白名单校验后,使用 ${tableName} 相对安全
    return mapper.getData(tableName, id);
}

通过预编译处理值参数,通过白名单校验控制结构参数,双管齐下,才能最大限度地堵住 SQL 注入的漏洞。安全是一个持续的过程,需要开发者对每一行可能接触用户输入的代码保持警惕。希望本文的剖析能帮助你更牢固地掌握这道防线。想探讨更多安全与架构话题,欢迎访问云栈社区。




上一篇:Nginx核心知识:Memcached、WebSocket与gRPC反向代理场景实践
下一篇:亿级流量架构设计:从本地缓存到分布式Redis的多级缓存实践
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-11 14:48 , Processed in 0.214206 second(s), 37 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表