preparedstatement的?占位符仅适用于值,不能用于表名、列名等sql结构部分,动态拼接这些元数据必须通过白名单校验,否则易导致元数据注入;mybatis中${}语法同理危险,应严格限制为预定义合法值。

直接拼接表名或列名时,PreparedStatement完全失效
很多人误以为只要用了 PreparedStatement 就万事大吉,但它的参数占位符 ? 只能用于**值(value)位置**,不能用于表名、列名、排序字段、GROUP BY 子句等结构部分。一旦你写成 "SELECT * FROM ? WHERE id = ?",运行时会直接抛出 SQLSyntaxErrorException 或类似错误——数据库根本不允许参数化对象名。
这意味着:如果业务需要动态指定表名(如分表查询)、列名(如动态字段导出)、或 ORDER BY 字段(如前端传参控制排序),你就必须手动拼接这部分 SQL,而这正是元数据注入的高危入口。
- 常见错误写法:
"SELECT " + columnName + " FROM users WHERE id = ?" - 攻击者输入
columnName = "name, password, (SELECT @@version)",可能触发子查询泄露数据库版本 - 更危险的是:
tableName = "users UNION SELECT 1,2,3 FROM mysql.user -- ",直接拖库
用白名单严格限制可拼接的元数据项
对动态表名、列名、排序字段等,不能靠“过滤单引号”或“转义”,而必须建立硬性白名单。这是唯一可靠的方式。
例如,支持按字段排序的接口,后端不应接受任意字符串,而应只允许预设的合法字段:
- 定义白名单:
Set<string> allowedSortFields = Set.of("id", "created_at", "status");</string> - 校验逻辑:
if (!allowedSortFields.contains(sortField)) throw new IllegalArgumentException("Invalid sort field"); - 拼接前强制转换为白名单内值:
"ORDER BY " + sortField—— 此时sortField已是可信值,无需再过滤
同理,分表场景下,表后缀(如 user_2024、user_2025)也应从预生成的分表列表中匹配,而不是直接拼接用户传入的年份字符串。
避免在 SQL 中执行元数据查询(如 INFORMATION_SCHEMA)
有些业务逻辑会动态查 INFORMATION_SCHEMA.COLUMNS 或 SHOW COLUMNS FROM ... 来获取字段列表再拼 SQL,这本身就在引入元数据注入风险——因为表名仍是用户可控的。
典型错误:
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '" + tableName + "'"- 攻击者传
tableName = "users' UNION SELECT table_name FROM INFORMATION_SCHEMA.TABLES -- "
正确做法是:元数据信息应预先加载并缓存(如启动时扫描所有业务表结构),运行时只从内存白名单中取值,绝不将用户输入带入任何 INFORMATION_SCHEMA 查询。
MyBatis 中 ${} 是元数据注入的雷区
MyBatis 的 #{} 是安全的(对应 PreparedStatement 参数),但 ${} 是纯字符串替换,不做任何转义,等价于直接拼接——它常被用来动态插入表名或字段名,也正是最常被滥用的地方。
- 危险写法:
SELECT * FROM ${tableName} WHERE id = #{id} - 安全替代方案:用
<bind></bind>+ 白名单校验,或改用<choose></choose>分支明确枚举所有合法表名 - 更稳妥的做法:把动态表名/列名逻辑提到 Java 层做白名单判断,SQL 中只用
#{}绑定值
注意:即使加了 mysql_real_escape_string 类似的转义函数,也无法防御 ${} 注入,因为转义只对值有效,对已解析的 SQL 结构无效。
真正难防的不是“怎么写 SQL”,而是“哪些地方不得不动态拼接结构”。元数据注入的破绽往往藏在看似无害的排序、分页、导出、多租户路由等逻辑里——这些地方没有 ? 可用,只能靠设计阶段就划清白名单边界。











