本篇教你两件事:SQL 注入在代码里到底长什么样(六种成因),以及每一类怎么修才修得干净。学完你应该能做到:随便给你一段拼 SQL 的代码,你能说出它属于哪类成因、怎么绕、怎么修。

先修知识

  • SQL:操作数据库的指令语言。类比:你写给仓库管理员的取货单,管理员(数据库)照单执行,不思考单子合不合理。
  • SQL 注入:攻击者把 SQL 语法片段混进正常输入,让数据库把”数据”当”指令”执行。类比:取货单备注栏里写”顺便把保险柜打开”,管理员照做了。
  • 拼接:用 +(Java)或 .(PHP)把变量串进 SQL 字符串。本篇里它是”万恶之源”。
  • 预编译 / 参数化查询:SQL 语句先交给数据库编译成固定骨架(值的位置用 ? 占位),数据随后单独传入。类比:先印好表格模板,再往格子里贴照片——照片内容再奇怪,也不可能变成表格的格子线。
  • 占位符:SQL 骨架里给数据预留的位置,?(JDBC/PDO)或 :name(PDO 命名占位符)。
  • 标识符:表名、列名、ORDER BY 字段这类”结构名字”,和”值”是两种东西——占位符只能占值的位置,占不了标识符的位置,这是理解很多漏洞的关键。
  • 转义:给特殊字符(如单引号)前面加反斜杠(' → \'),让它失去语法意义、退化为普通字符。
  • 宽字节注入:利用 GBK 等多字节编码,让一个汉字”吃掉”转义用的反斜杠,使单引号逃逸。后面有逐字节拆解。
  • 二次注入:恶意数据先安全地存进数据库,之后被取出再次拼进 SQL 时才引爆。
  • ORM:对象关系映射框架(MyBatis、Hibernate、Laravel Eloquent),让你用对象操作代替手写 SQL。
  • 存储过程:预存在数据库里的一段 SQL 程序,可以被调用。它内部也能写动态 SQL。
  • WAF / RASP:应用防火墙 / 运行时自保护,属于”外挂”防御,挡不住代码本身有洞。

什么是 SQL 注入

SQL 注入是指攻击者把恶意的 SQL 语法片段混入本该是”数据”的输入中,使数据库把它当作”代码”解析执行,从而越权读取、篡改、删除数据,甚至执行系统命令的漏洞。

组成部分

SELECT * FROM user WHERE name = 'admin'
|________语法结构(代码)________|    |__数据__|

数据库执行前要先解析这条语句,生成语法树(把字符串拆成”查哪张表、条件是什么”的结构化理解)。关键点在于:解析器无法区分”哪些字符是开发者写的语法、哪些是用户提交的数据”——它只看最终拼出来的完整字符串。

用生活例子理解:你让助理打印一份合同,里面留了一行”乙方:____“让他手写填名字。结果他在横线上写的是”乙方:张三,且本合同全部条款作废”。打印机(数据库)不会分辨哪部分是模板、哪部分是手写的——整页纸都按字面生效。

本质:用户提交的”数据”中包含引号、注释符、关键字,改变了语法树的形状,语义被劫持。

成因分析(代码层)

下面六种成因按”开发者怎么一步步写歪的”展开。每一种都回答三个问题:开发者为什么这么写、漏洞在哪、审计时怎么认出它。

1. 直接字符串拼接

开发者动机:最直接、最省事——“我要查 id 为 X 的新闻,把 X 拼进去不就完了”。新手教程里大量这种写法,很多人第一版代码就这么写的,不知道有危险。

$id = $_GET['id'];                                   // 用户输入,直接取自 URL 参数
$sql = "SELECT * FROM news WHERE id = " . $id;       // 用 . 拼进 SQL,连引号都没有
mysql_query($sql);                                   // 原样交给数据库执行

数字型参数(id)甚至没有引号包裹,攻击者直接写 1 AND 1=2、1 UNION SELECT ... 即可——不需要闭合任何引号,语法直接续写,是最低级也最常见的形态。

String name = request.getParameter("name");
String sql = "SELECT * FROM user WHERE name = '" + name + "'";   // 字符型,有引号包裹
Statement stmt = conn.createStatement();
stmt.executeQuery(sql);

Statement 本身不提供参数绑定能力,拼接是它唯一的使用方式——所以审计时看到 createStatement() 就要高度警惕,见到它基本等于见到注入候选,只需再确认参数可控。

审计识别特征:SQL 字符串里出现 " + 变量 + " 或 ' . $变量 . ';数字型参数裸拼无引号。

2. 动态标识符无法参数化,退回拼接

开发者动机:产品要求”用户可以按价格/时间/热度排序”,排序字段是活的。但表名、列名、ORDER BY 字段属于标识符,不能作为 ? 绑定参数(占位符只能占”值”的位置,数据库语法不允许 ORDER BY ?)。开发者不知道正确做法(白名单),图省事直接拼:

String order = request.getParameter("sort");  // 用户传: id,(select sleep(5))
String sql = "SELECT * FROM product ORDER BY " + order;   // 标识符位置直接拼接 ❌

这是”用了预编译仍然有注入”的主要来源——很多团队全部查询都参数化了,唯独排序、动态表名这类场景被迫拼接,而这里恰恰没做防护。修复只能靠白名单(见下文修复章节)。

审计识别特征:ORDER BY + 拼接、ASC/DESC 由参数控制、动态表名/列名。全局搜 ORDER BY.*\+ 和 ORDER BY ${(MyBatis)。

3. ORM / 框架的”拼接后门”

开发者动机:“我们用了 MyBatis/Laravel,ORM 天然防注入”——这是最流行的错误认知。真相是:框架提供了安全的参数绑定,但同时留了原始 SQL 出口,遇到框架表达能力不够的场景(复杂 LIKE、动态排序、原生函数),开发者顺手就用了那个危险出口。

<!-- MyBatis 的 mapper XML -->
<!-- ✅ #{} → 编译为 ? 占位符,值后传,安全 -->
WHERE name = #{name}
<!-- ❌ ${} → 文本替换,在 SQL 送达数据库前就把变量塞进去,等价于字符串拼接 -->
ORDER BY ${orderField}
WHERE name LIKE '%${keyword}%'
// Laravel
DB::select("SELECT * FROM user WHERE name = '$name'");   // ❌ 裸 SQL 拼接
User::whereRaw("name = '$name'")->get();                  // ❌ whereRaw 同样拼接

审计识别特征:MyBatis 搜 \${(每个出现点都人工确认);Laravel 搜 DB::raw、whereRaw、DB::select;Hibernate 搜 createNativeQuery + 拼接。

口诀:“用了 ORM 就安全”不成立——ORM 只保证你用它提供的绑定 API 时安全。

4. 错误的防御手段——转义被绕过

开发者动机:意识到注入危险了,但不愿意(或不会)改参数化,于是打个补丁:“把单引号转义掉不就安全了?”

// ⚠️ addslashes:把 ' 变成 \',看似堵住了
$id = addslashes($_GET['id']);
$sql = "SELECT * FROM user WHERE id = '$id'";

宽字节注入绕过它。当数据库连接编码是 GBK 这类多字节编码时,攻击者提交 %df':

  1. 输入到达 PHP 时实际是字节序列 0xdf 0x27(%27 就是单引号 ');
  2. addslashes 在单引号前插入反斜杠 \(0x5c),字节序列变成 0xdf 0x5c 0x27(即 %df%5c%27);
  3. 数据库连接按 GBK 解码时,按”双字节”规则读:%df%5c 恰好拼成一个合法 GBK 汉字(“運”),反斜杠被当作汉字的低位字节”吃掉”了;
  4. 剩下的 0x27 单引号干干净净地逃逸成功 → 注入成立。

根子在哪:转义依赖”转义函数理解的字符集”与”数据库理解的字符集”一致。PHP 按单字节理解、数据库按 GBK 双字节理解,这个前提一旦不成立,转义就出现裂缝。生产环境的编码配置链路很长(PHP 文件编码、连接编码、库编码、表编码),任何一环对不上就出事——这是转义方案被行业淘汰的根本原因。

另外注意:转义对数字型注入天然无效——id=1 and 1=2 里根本没有引号需要转义。

审计识别特征:搜 addslashes、mysql_real_escape_string、str_replace("'"),然后检查:参数是不是数字型(是 → 直接判漏洞);编码是不是 GBK 系(是 → 宽字节可绕)。

5. 二次注入(Second-Order Injection)

开发者动机:入库时规规矩矩做了转义,于是产生一个隐蔽的错误信任假设——“存进我库里的数据肯定是干净的”,之后取出来用时就不再设防。

// —— 注册:转义入库 ——
$u = addslashes($_POST['username']);          // 攻击者注册 admin'-- (注意 -- 后必须带空格)
mysql_query("INSERT INTO user(username) VALUES('$u')");
// 转义只对"这一次"查询的 SQL 解析生效,库里落盘的仍是原始值 admin'--
 
// —— 改密码:从库里取出用户名直接拼接 ❌ ——
$row = mysql_fetch_assoc(mysql_query("SELECT username FROM user WHERE id=$uid"));
$sql = "UPDATE user SET pwd='$newpwd' WHERE username = '" . $row['username'] . "'";
// 取出的 admin'-- 再次污染语法 → 实际执行 WHERE username = 'admin',改了管理员的密码

成因本质是”库里的数据是干净的”这一错误信任假设。类比:你把一瓶贴了”有毒”标签的液体存进公共冰箱(标签在入库时被撕了),别人取出来当可乐用了。

防御原则:数据只要出库再用,一律按不可信数据处理。 审计时跟踪每一个 SELECT 取出来的字段的去向——它会不会进入下一条 SQL?

6. 存储过程内的动态 SQL

开发者动机:应用层全部上了预编译,自觉得很安全;但部分逻辑写在数据库存储过程里,存储过程内部又玩起了字符串拼接——防线做在了应用层,漏洞沉到了数据库层。

-- 存储过程内部 ❌
SET @sql = CONCAT('SELECT * FROM user WHERE name = ''', p_name, '''');
PREPARE stmt FROM @sql;   -- 拼好的整串在这里编译,p_name 里的注入在此生效
EXECUTE stmt;

外层应用把 p_name 规规矩矩参数化传进存储过程也没用——存储过程内部 CONCAT 一拼、再 PREPARE,参数照样变成了语法。

审计识别特征:项目里用了存储过程时,必须把存储过程源码也要来审(它不在应用代码仓库里,容易漏);搜 CONCAT + PREPARE/EXECUTE IMMEDIATE 组合。

成因统一归因

成因共同本质
字符串拼接数据进入语法结构
动态标识符拼接标识符无绑定机制,退回拼接
ORM raw / ${} 滥用绕过框架的绑定通道
转义防御依赖字符集一致性,前提脆弱
二次注入信任库内数据
存储过程拼接拼接位置下沉到数据库层

所有成因收敛为两条根因:

  1. 数据以拼接方式参与 SQL 语法构造(而不是以绑定方式作为值传入);
  2. 对输入或中间数据做了”它不会有 SQL 语法字符”的信任假设(URL 参数、Cookie、HTTP Header、库内数据全部算不可信输入)。

记住这两条,新出现的任何花式注入变种你都能归类。

SQL 注入代码修复

注入的本质是代码与数据没有分离,防御的根本思路只有一个:让攻击者输入的内容永远被当作”数据”,而不是 SQL 语法。

1. 参数化查询 / 预编译(根治手段)

原理再讲透一点:预编译把一次查询拆成两步——

  1. 先把含占位符的 SQL 骨架发给数据库编译:SELECT * FROM user WHERE id = ?。数据库在这一步就定死了语法树:这是一个 SELECT,条件是 id 等于”某个值”;
  2. 再把数据单独传给那个 ?。此时语法树已经编译完成,数据只能进”值”的槽位——攻击者输入 1 or 1=1 也只会被当成一个字面量去比较,数据库不会再回头解析它。

类比:考试答题卡。题目和选项格子是印死的(编译好的语法树),你只能在格子里涂(填数据)。你在格子里写”此题满分”也没用——阅卷机只认格子里的涂点。

Java:

// ✅ PreparedStatement
String sql = "SELECT * FROM user WHERE id = ?";      // 骨架里没有变量
PreparedStatement ps = conn.prepareStatement(sql);   // 先编译
ps.setInt(1, userId);                                // 后传值,setInt/setString 按类型选

PHP(PDO):

// ✅ 命名占位符 + 显式类型绑定
$stmt = $pdo->prepare('SELECT id, username, email FROM users WHERE status = :status AND role = :role');
$stmt->bindValue(':status', $status, PDO::PARAM_INT);   // 明确告诉 PDO 这是整数
$stmt->bindValue(':role', $role, PDO::PARAM_STR);
$stmt->execute();
$results = $stmt->fetchAll();

PDO 必须关闭模拟预处理

PDO::ATTR_EMULATE_PREPARES => false——模拟预处理(pdo_mysql 驱动默认开启)不是真预编译:它会在 PHP 客户端把参数转义后拼进 SQL 再发送,仍存在宽字节等编码绕过风险(见案例二)。关闭后才是数据库服务端真预编译。

MyBatis:

<!-- ✅ #{} → 底层是 PreparedStatement 占位符 -->
WHERE name = #{name}
<!-- ❌ ${} → 字符串拼接,必须改;实在要动态排序走白名单 -->
ORDER BY ${orderField}

预编译防不住的场景(面试题眼):

场景原因修复
动态表名/列名/ORDER BY标识符不能作为绑定参数白名单枚举映射
LIKE 模糊查询%、_ 是 LIKE 语法内通配符,参数化只管得住语法管不住通配符语义绑定参数 + 转义参数内的 %_
二次注入出库数据再次拼接所有使用点都参数化
存储过程内动态 SQL拼接发生在数据库内部,应用层参数化够不着存储过程内同样参数化

LIKE 场景展开说一句:用户搜关键词 % 本身不破坏语法,但能把模糊查询变成全表扫描(性能攻击);正确做法是参数化绑定之后,再把参数里的 %、_ 转义为 \%、\_,并在 SQL 里声明 ESCAPE '\\'。

2. 输入校验(纵深防御,不能替代参数化)

白名单优于黑名单。黑名单是”列出坏蛋”,永远列不全(大小写、编码、注释变形……);白名单是”只放好人进来”,枚举空间有限,天然完备。

  • 数字就强制 intval() / Integer.parseInt(),不要试图”过滤掉单引号”——把输入变成数字,比把数字里的坏字符挑出去靠谱一万倍;
  • 枚举值(排序字段、状态、表名)走白名单映射表,用户传 key、代码查表换成真实标识符,不在表里就用默认值;
  • 数据长度严格限制,能在一定程度上拦截过长的注入 payload。
// ✅ 排序字段白名单:用户只能传 key,真实列名由服务端决定
String sort = request.getParameter("sort");
Map<String, String> allowed = Map.of("time", "create_time", "hot", "view_count");
String orderField = allowed.getOrDefault(sort, "id");   // 不在白名单用默认值兜底

注意这个设计的精妙处:攻击者输入的任何内容都不会出现在最终 SQL 里——SQL 里出现的只有 Map 的 value,那是开发者自己写死的字符串。这就是”输入”和”语法”的彻底隔离。

3. 转义(最后的兜底,坑很多)

  • PHP 的 addslashes() 属于上一代方案,GBK 编码下可被 %df' 宽字节绕过(成因 4 有逐字节拆解);
  • mysql_real_escape_string() 必须配合 mysql_set_charset() 使用——mysql_set_charset() 会同步修改客户端库的字符集认知;而 SET NAMES 只是一条 SQL,只改了服务器端认知,客户端转义时仍按旧字符集,两侧不一致仍可宽字节绕过;
  • 能用参数化就不要用转义;转义只应出现在无法改造的遗留代码兜底中。

相关:sql注入过滤绕过(转义/过滤被绕过的具体手法)

4. 业务层 / 数据库层防御

代码修完还不够,纵深防御每一层都能降低”修漏一处”的代价:

  1. 最小权限原则:应用账号禁止 FILE、SUPER 权限,按库/表授权,只读业务不给写权限——注入真被打穿时,攻击者拿到的也只是一个低权限会话;
  2. 错误处理:关闭 SQL 报错回显(PHP display_errors=Off,Java 全局异常兜底),防止报错注入直接泄露库结构;
  3. 统一编码:全站 UTF-8(MySQL 里用 utf8mb4,utf8 是残血版三字节实现),上下层编码不一致是宽字节注入的温床;
  4. 数据库访问收口:统一 DAO/SDK 封装,禁止业务代码裸连数据库拼接 SQL——让所有 SQL 经过一个入口,审计只需要查这一个入口;
  5. 敏感数据加密/哈希存储:即使被拖库也降低危害(缓解措施);
  6. WAF/RASP 仅作应急缓解:可拦扫描器和通用 payload,但可被绕过(编码、分块、等价语法变形),不能替代代码修复。

完整修复案例(审计 → 修复闭环)

案例一:MyBatis ${} 动态排序注入

漏洞代码(逐行注释):

<select id="listProducts" resultType="Product">
  SELECT * FROM product WHERE status = #{status}
  <!-- #{status} 走占位符,安全;但下面两个 ${} 是文本替换 -->
  ORDER BY ${orderField} ${orderDir}
</select>
// Controller:sort/dir 直接取自请求参数,零校验
List<Product> list = productMapper.listProducts(
    status, req.getParameter("sort"), req.getParameter("dir"));

数据流分析(逆向回溯):

  1. Source:req.getParameter("sort") / ("dir"),URL 参数完全可控;
  2. 中间处理:Controller 没有任何校验,原样传给 Mapper;
  3. Sink:Mapper XML 中 ${orderField} 做文本替换,拼进 ORDER BY 子句。

#{} 只保护了 status,两个 ${} 完全裸奔。

攻击怎么发生:ORDER BY 位置后面不能直接 UNION SELECT 回显结果,攻击者改用盲注,例如传:

sort=id,(select if(substr(database(),1,1)='s',sleep(3),0))

逐段拆解:

  • id, —— 合法开头,让排序语法成立;
  • (select if(...)) —— 注入的子查询表达式,作为第二个排序键;
  • substr(database(),1,1)='s' —— 取当前库名第一个字符,猜它是不是 s(一次猜一个字符);
  • sleep(3) / 0 —— 猜对了就睡 3 秒,猜错了立即返回。页面响应有没有慢 3 秒,就是攻击者的”回显”——库名就这样一个字符一个字符被磨出来。

审计结论:${} 出现在 ORDER BY 是典型高危场景;排序字段属于标识符,无法改 #{}(ORDER BY ? 语法不合法),修复只能靠白名单。

修复(白名单映射 + 方向枚举):

private static final Map<String, String> SORT_FIELDS = Map.of(
    "time", "create_time",     // 用户传 time → 实际用 create_time
    "price", "price",
    "hot", "view_count"
);
 
String field = SORT_FIELDS.getOrDefault(req.getParameter("sort"), "id");  // 非法输入兜底为 id
String dir = "desc".equalsIgnoreCase(req.getParameter("dir")) ? "DESC" : "ASC"; // 方向只许二选一
ORDER BY ${field} ${dir}   <!-- 此处 ${} 的内容已被白名单锁死:只可能是 Map 的 value 和 DESC/ASC,安全 -->

为什么这里 ${} 反而可以留:${} 本身只是”文本替换”机制,危险与否取决于替换进去的内容谁说了算。白名单之后,内容只可能是开发者写死的几个字符串,攻击者的输入根本到不了 SQL。

案例二:PDO 模拟预处理未关闭导致的宽字节注入

漏洞代码(逐行注释):

// 用了 PDO prepare,表面功夫做足 ⚠️
$pdo = new PDO('mysql:host=127.0.0.1;dbname=shop', 'root', 'root');
                                                     // ① DSN 没声明 charset → 客户端库默认 latin1
$pdo->query('SET NAMES GBK');                        // ② 只改了服务器侧认知为 GBK,
                                                     //    客户端转义函数仍以为自己在用 latin1
$id = $_GET['id'];                                   // ③ Source:用户输入
$stmt = $pdo->prepare('SELECT * FROM goods WHERE id = ?');
$stmt->execute([$id]);                               // ④ 看似参数化,实际……

数据流分析:

  1. id 确实走了 ? 占位符——但 pdo_mysql 驱动的 PDO::ATTR_EMULATE_PREPARES 默认为 true(模拟预处理):参数不是发给数据库服务端编译绑定的,而是在 PHP 客户端转义后拼进 SQL 再整串发送;
  2. 客户端转义依据的是 PDO 客户端字符集(DSN 没写,默认 latin1,按单字节转义);
  3. SET NAMES GBK 只是发给服务器的一条 SQL,改了服务器侧认知,不同步客户端库——与 mysql_real_escape_string 的坑同源;
  4. 于是宽字节条件凑齐:攻击者提交 id=%df' or 1=1%23。

payload 逐段拆解:

  • %df —— 一个”半汉字”字节,专门用来和转义反斜杠配对;
  • ' —— 注入的核心单引号,转义后它前面会被插入 \(0x5c),但 %df%5c 在 GBK 下拼成汉字”運”,反斜杠被吃掉,单引号逃逸;
  • or 1=1 —— 逃逸成功后注入的恒真条件;
  • %23 —— URL 编码的 #,MySQL 行注释符,注释掉语句尾巴。

审计结论:“用了 prepare 就没注入”不成立——模拟预处理 + 字符集不一致时,参数化退化为转义级防御,仍可宽字节绕过。审计 PDO 项目第一眼就看连接代码有没有关模拟预处理、DSN 有没有声明 charset。

修复:

// ✅ DSN 显式声明 utf8mb4(两侧字符集对齐) + 关闭模拟预处理(数据库服务端真预编译)
$pdo = new PDO(
    'mysql:host=127.0.0.1;dbname=shop;charset=utf8mb4',
    'root', 'root',
    [PDO::ATTR_EMULATE_PREPARES => false]
);

两个改动缺一不可:关模拟预处理让”拼接”这一动作本身消失(根治);DSN 声明 charset 让客户端与服务器字符集认知一致(消灭宽字节前提)。

相关:sql预编译参数化

修复 Checklist

交付修复方案时,对照这张表逐项打钩:

  • 全库搜危险 Sink:Java 搜 ${、createStatement、executeQuery( 拼接;PHP 搜 mysql_query、mysqli_query、->query(、->exec( 拼接
  • 所有拼接点改参数化;不能参数化的标识符(排序字段、表名)改白名单映射
  • PDO 关闭模拟预处理(PDO::ATTR_EMULATE_PREPARES => false)且 DSN 声明 charset=utf8mb4;MyBatis 消除所有未加白名单保护的 ${}
  • 收紧数据库账号权限(禁 FILE/SUPER,按表授权),关闭错误回显,统一 utf8mb4
  • 数据出库再用的地方全部按不可信数据处理(防二次注入)
  • 项目里有存储过程的,把存储过程源码一并审掉(搜 CONCAT + PREPARE)

面试题眼速答

问题一句话答案展开两三句
SQL 注入的本质是什么?代码与数据没有分离,数据被当语法解析数据库解析器只看最终拼出来的完整字符串,分不清哪些是开发者写的语法、哪些是用户数据。引号/注释符/关键字改变了语法树形状,语义被劫持。
为什么预编译能根治注入?语法树在数据传入前就编译定死了SQL 骨架先编译,? 位置永远只是”值”的槽位;后传的数据不再参与语法解析,输入 1 or 1=1 也只是字面量。类比答题卡:格子印死了,涂什么都只是涂点。
预编译防不住哪些场景?标识符位置、LIKE 通配符、二次注入、存储过程内拼接占位符只能占”值”的位置,ORDER BY ? 不合法,排序字段只能白名单;LIKE 要额外转义参数内 %_;出库再用的数据要重新参数化;存储过程内部也要参数化。
为什么排序字段注入不能靠参数化修?ORDER BY ? 语法不合法,标识符不能绑定修复用白名单映射:用户传 key(如 time),服务端 Map 查表换成真实列名(create_time),不在表里用默认值。攻击者输入永远不会出现在 SQL 里。
宽字节注入的原理?多字节编码把转义反斜杠”吃掉”提交 %df',addslashes 插入 \(0x5c)变成 %df%5c%27;GBK 双字节解码把 %df%5c 认作汉字”運”,反斜杠消失,单引号逃逸。根因是转义函数与数据库字符集认知不一致。
mysql_real_escape_string 和 SET NAMES 的坑?SET NAMES 只改服务器认知,不同步客户端转义字符集客户端仍按旧字符集(如 latin1)转义,服务器按 GBK 解析,宽字节前提成立。必须用 mysql_set_charset() 让客户端库同步认知。
为什么转义方案被淘汰?依赖字符集一致性这个脆弱前提,且对数字型注入天然无效编码链路长(文件/连接/库/表编码),任一环节不一致就裂缝;数字型注入根本没有引号可转义。参数化不依赖任何前提,是结构性安全。
什么是二次注入,怎么防?恶意数据入库时被转义”洗白”,出库再拼接时引爆入库转义只对当次 SQL 解析生效,落盘的是原始值。防御原则:数据出库再用一律按不可信处理,所有使用点参数化,而不是只在入库点设防。
“用了 ORM 就安全”对吗?不对,ORM 只保证绑定 API 安全MyBatis 的 ${}、Laravel 的 whereRaw/DB::raw、Hibernate 的 createNativeQuery 拼接都是框架留的原始 SQL 出口。审计要逐个搜这些关键字。
PDO 用了 prepare 为什么还可能有注入?模拟预处理开启时参数仍在客户端拼接pdo_mysql 默认 ATTR_EMULATE_PREPARES=true,是 PHP 端转义拼接的”假预编译”。配合 SET NAMES GBK 字符集不一致可宽字节绕过。修复:DSN 声明 charset=utf8mb4 + 关模拟预处理。
修复注入的纵深防御有哪些层?参数化为主,白名单补标识符,业务/数据库层兜底数据库账号最小权限、关闭错误回显(防报错注入泄结构)、统一 utf8mb4、DAO 收口统一审计入口、敏感数据加密。WAF/RASP 只是应急缓解,不能替代代码修复。
白名单为什么优于黑名单?黑名单列不全,白名单枚举空间有限天然完备黑名单要对抗大小写、编码、注释变形等无穷变种;白名单只放行已知好的值。数字强制 intval()、枚举走映射表,都是白名单思想。