表名不能用 ? 占位符是因为sql标准和驱动层仅支持对值参数化,不支持对语法结构(如表名)参数化;${} 是纯文本替换易致注入,#{} 则将表名当字符串值处理而失效;白名单校验须在sql构建入口严格执行。

为什么表名不能像参数一样用 ? 占位符
因为 JDBC、PDO 等驱动层的预编译机制只支持对「值」做参数化,不支持对 SQL 语法结构(如 FROM 后的表名、ORDER BY 后的字段名、GROUP BY 的列)做占位。一旦你写 SELECT * FROM ?,数据库会直接报错:「near "?": syntax error」或类似提示——这不是框架没实现,是 SQL 标准本身禁止。
动态拼接表名时,${} 和 #{} 的区别到底在哪
${tableName} 是 MyBatis 在 SQL 字符串生成阶段做的**纯文本替换**,不做任何转义或类型检查;#{tableName} 则会被当成一个「值」传给 PreparedStatement,最终变成 SELECT * FROM 'user'(带引号),查的是字面量字符串,不是表。
- 错误示例:
SELECT * FROM ${tableName}→ 输入users; DROP TABLE admins --直接执行两条语句 - 更隐蔽的错误:
SELECT * FROM ${tableName} WHERE id = #{id}→id安全,但tableName仍是注入入口 - 常见误解:以为加了
WHERE条件就安全,其实表名拼接完成时,整个 SQL 已构造完毕,后续参数绑定已无法约束结构
白名单校验必须在 SQL 执行前完成,且不能只靠 Java 层
仅在 Service 方法里写 if (!allowedTables.contains(tableName)) throw ... 是不够的:如果调用链路中存在缓存绕过、反射调用、或日志脱敏遗漏,依然可能漏检。MyBatis 的 <if test="tableName == 'users' || tableName == 'orders'"></if> 才是真正卡在 SQL 构建入口的硬控制。
- 正则校验必须严格:
^[a-zA-Z_][a-zA-Z0-9_]*$,拒绝数字开头、点号、分号、空格、反引号 - 大小写敏感需统一处理:MySQL 默认忽略大小写,但开启
lower_case_table_names=0时,USERS和users是不同表,白名单必须显式声明大小写 - 不要用
String.replace()过滤单引号——攻击者可用users` UNION SELECT ...绕过
存储过程里拼表名同样危险,PREPARE 也救不了你
MySQL 存储过程中用 SET @sql = CONCAT('SELECT * FROM ', @table_name) + PREPARE stmt FROM @sql,看起来“用了 PREPARE”,但只要 @table_name 来源不可信,就和应用层拼接无异。PREPARE 不校验表是否存在,也不过滤内容,它只是把字符串当 SQL 执行。
- 危险写法:
SET @table_name = in_user_input;→ 直接进 CONCAT - 正确做法:先校验再赋值:
IF in_user_input NOT IN ('logs', 'events') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Invalid table'; END IF; - 注意:MySQL 的
INFORMATION_SCHEMA.TABLES查询本身可被利用探测库结构,不应作为校验依据
真正难防的不是技术细节,而是把「表名是用户输入」当成合理需求——它本就不该由前端或 API 参数决定。多数场景下,动态表名背后是设计缺陷:比如用一张表存多租户数据却按租户名分表,不如用 tenant_id 字段 + 索引;或者报表模块硬编码几十个表却用字符串路由,不如用配置中心映射。安全边界要划在架构层,而不是等拼到 SQL 里再补漏。











