预处理语句不能防止表名、列名等sql结构注入,必须通过白名单校验;动态标识符需严格映射到预定义数组,禁用正则过滤和转义函数;orm和存储过程拼接同样危险,安全方案是服务层收口分表逻辑或case分支跳转。

用预处理语句时,SELECT 里不能拼接表名或列名
预处理语句(如 mysqli_prepare 或 pdo->prepare)只对**参数值**做占位符绑定,不处理 SQL 结构本身。把用户输入直接插进 FROM users 或 ORDER BY ? 里,等于没防注入。
常见错误现象:SQLSTATE[42000]: Syntax error or access violation 或查出不该查的表数据。
- 表名、列名、排序方向(
ASC/DESC)、GROUP BY字段这些都属于 SQL 语法结构,必须在拼接前白名单校验 - 别试图用
addslashes或mysql_real_escape_string处理它们——这些函数对标识符无效 - 如果业务真需要动态列(比如导出字段可选),用预定义映射:把前端传来的
sort=created_at映射到白名单数组['created_at' => 'created_at', 'status' => 'status'],再取值拼接
用白名单硬限制动态标识符,别信正则过滤
正则匹配字母数字下划线(/^[a-zA-Z0-9_]+$/)看似安全,但绕过方式太多:MySQL 支持反引号包裹标识符,`user``name` 可能被解析为两个标识符;PostgreSQL 还支持双引号和 Unicode 标识符。
更稳妥的做法是完全放弃“过滤”,改用静态映射:
- 建一个配置数组,比如
$allowed_tables = ['users', 'orders', 'products'],用户传table=users才允许进入FROM users - 列名同理:
$allowed_columns = ['id', 'email', 'created_at'],用in_array($input_column, $allowed_columns, true)严格校验 - 避免用
strtolower()或trim()后再比对——大小写敏感性取决于数据库配置,白名单应与实际表结构完全一致
ORM 查询构建器也救不了动态表名
像 Laravel 的 DB::table($table)->where(...) 或 Django 的 Model.objects.using(table_name),底层仍是字符串拼接表名。ORM 不会自动校验你传进去的 $table 是否合法。
使用场景常见于多租户分表、日志按月分表等,但风险极高:
- Laravel 中
DB::table($request->input('table'))是典型高危写法,哪怕开了strict模式也拦不住 - Django 的
using参数控制数据库路由,不是表名;想切表得靠db_table元选项或自定义 Manager,仍需提前定义好范围 - 若必须支持分表,把分表逻辑收口到服务层:用户只传
tenant_id=123,后端根据规则算出对应表名users_t123,不暴露任何用户可控的表名字段
存储过程里拼接表名?同样危险,且更难审计
有人觉得“SQL 在数据库里执行就安全”,但 CONCAT('SELECT * FROM ', @table_name) + PREPARE 一样会执行任意表。而且这类逻辑藏在数据库里,应用层日志不记录完整 SQL,出问题极难定位。
性能与兼容性影响明显:
- MySQL 的
PREPARE语句无法缓存含动态表名的执行计划,每次都要重新解析,QPS 高时容易卡住 - PostgreSQL 的
EXECUTE 'SELECT * FROM ' || quote_ident($1)虽有quote_ident,但它只防 SQL 注入,不防权限越界——如果数据库用户有跨表查询权限,照样能读其他租户数据 - 真正安全的做法是:存储过程只接受整型 ID 或枚举值,内部用
CASE WHEN分支跳转到不同表,杜绝任何形式的字符串拼接
最麻烦的地方不是写不出白名单,而是业务方总想留个“灵活接口”——比如“以后可能要支持自定义字段”。这种需求一旦落地,防护边界就从代码层下沉到数据库权限层,而多数团队连最小权限原则都没落实。防不住的从来不是技术,是妥协的次数。










