注入不是一类漏洞,是同一个错误在不同解析器里的复现:把用户提供的字节,交给了一个会区分"结构"和"数据"的解释器,却没有告诉它哪部分是数据。SQL 解析器、shell、模板引擎、XPath、LDAP 过滤器、日志格式化器,全都遵循同一条规则。
防护也不是一个函数。它是一组位置明确、职责互不重叠的校验点:参数化负责结构,白名单负责标识符,转义负责输出上下文,业务校验负责语义。搞混位置,就会出现"明明过滤了还是被打穿"。
1. 同一根因,不同宿主
| 注入类型 | 用户输入被当成了什么 | 边界符 | 典型入口 |
|---|---|---|---|
| SQL 注入 | SQL 语法片段(OR 1=1、UNION) |
引号、括号、-- |
查询参数、排序字段、LIKE |
| 命令注入 | shell 命令与参数 | ; ` |
& $( )` 反引号 |
| 模板注入(SSTI) | 模板表达式(${7*7}) |
${ } {{ }} <% %> |
邮件模板、页面片段、报表 |
| XPath / LDAP 注入 | 查询谓词 | ' or '1'='1 `)( |
(uid=*` |
| 日志注入 | 日志行结构(换行、假时间戳) | \n \r |
任何被拼进日志的字段 |
| 响应头注入 | 头部行结构 | \r\n |
重定向地址、下载文件名 |
后两类最容易被忽略,因为"日志和响应头不是数据库",但危害实打实:日志伪造可让操作看起来来自别人的 IP;\r\n 注入能凭空插入一个 Set-Cookie。
2. 参数化查询:为什么它真的有效
关键在编译顺序。参数化不是"把值转义后拼进字符串",而是让数据库先固定语法树,再把值填进占位符——值进入的是数据槽位,永远不会被重新解析成语法。
// 错误:字符串拼接,输入 ' OR '1'='1 直接改变语法
String sql = "SELECT * FROM users WHERE name = '" + name + "'";
stmt.executeQuery(sql);
// 正确:占位符 + 绑定,语法在绑定时已固化
String sql = "SELECT id, name FROM users WHERE name = ? AND status = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, name);
ps.setInt(2, 1);
ps.executeQuery();
}
参数化管不到的三类位置,必须换手段:
| 位置 | 为什么参数化无效 | 正确做法 |
|---|---|---|
| 表名、列名 | 占位符只能绑值,不能绑标识符 | 白名单枚举映射,不接受原始输入 |
ORDER BY 字段与方向 |
同上,且方向是关键字 | 枚举 + 拼接固定字符串 |
LIKE 的通配符 |
% _ 在值里是元字符 |
转义 % _ \ 并显式声明 ESCAPE |
IN (...) 的个数 |
占位符数量需静态确定 | 按元素个数生成 ?,?,?,值仍绑定 |
// LIKE 的正确写法:转义元字符 + 声明 ESCAPE
String kw = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
ps = conn.prepareStatement("SELECT id FROM doc WHERE title LIKE ? ESCAPE '\\\\'");
ps.setString(1, "%" + kw + "%");
3. ORM 不是免死金牌
ORM 的模板语法里,#{} 与 ${} 的区别就是参数化与字符串拼接的区别:
<!-- #{} → 生成 ? 占位符,走 PreparedStatement,安全 -->
<select id="findByName" resultType="User">
SELECT * FROM users WHERE name = #{name}
</select>
<!-- ${} → 直接文本替换,等价于手写拼接,可注入 -->
<select id="findByColumn" resultType="User">
SELECT * FROM users ORDER BY ${sortColumn}
</select>
ORDER BY ${sortColumn} 是最常见的"合理"滥用——因为参数化确实绑不了列名。唯一正确的解法是白名单映射,让用户输入只是一个 key:
private static final Map<String, String> SORTABLE = Map.of(
"created", "created_at",
"price", "price_cents",
"score", "score");
public String resolveOrder(String input, String direction) {
String col = SORTABLE.get(input == null ? "" : input.toLowerCase());
if (col == null) col = "created_at";
String dir = "asc".equalsIgnoreCase(direction) ? "ASC" : "DESC";
return col + " " + dir; // 拼接的两段都来自代码,不来自输入
}
同理,Hibernate 的 HQL 拼接、JPA 的 @Query 里用 + 拼参数、MyBatis 的 ${} 套在 LIKE 里,都属于同一类退化。搜索一遍代码库里的 ${ 与 + 拼 SQL,比任何扫描器都直接。
4. 命令执行:三层防护,逐层收紧
| 层级 | 做法 | 挡住什么 | 挡不住什么 |
|---|---|---|---|
| 第一层 | 不用 shell,参数数组直接传给 exec |
; ` |
$()` 之类的 shell 语法 |
| 第二层 | 参数白名单 / 路径校验 | 意外参数、越权路径 | 合法但危险的组合 |
| 第三层 | 不调用外部命令,改用库实现 | 整个攻击面 | 无 |
// 错误:字符串交给 shell,用户输入里的 ; rm -rf 会被执行
Runtime.getRuntime().exec("convert " + userFile + " out.png");
// 正确:数组传参,不经过 shell 解析
ProcessBuilder pb = new ProcessBuilder("convert", userFile, "out.png");
pb.redirectErrorStream(true);
Process p = pb.start();
注意 ProcessBuilder 也不解决"参数在语义上是否合法":convert 的参数可以是 -write /etc/cron.d/x。所以第二层必须存在——对允许的操作建立枚举,而不是对用户输入做黑名单正则。
private static final Set<String> ALLOWED_OP = Set.of("resize", "crop", "thumbnail");
if (!ALLOWED_OP.contains(op)) throw new IllegalArgumentException("unsupported op");
5. 模板注入:用户内容不能参与编译
模板引擎的分界线很清晰:模板文本由开发者写,用户数据只作为上下文变量传入。把用户输入拼进模板源码,就是把表达式求值权交出去了。
// 错误:用户输入成为模板源码,${7*7} 会被求值
String tpl = "Hello, " + name + "!";
engine.process(tpl, ctx);
// 正确:模板固定,变量走上下文
Template t = engine.parse("Hello, {{name}}!");
t.execute(ctx.setVariable("name", name));
前置校验只能作辅助(长度、字符集、拒绝表达式符号),不能当主防线——各引擎的表达式语法差异太大,黑名单永远漏。真要让用户自定义模板,只能上沙箱化引擎并限制可访问的方法与类型。
6. 输出转义:按上下文分别处理
转义不是"统一转一遍 HTML",而是按值最终出现的上下文选择规则。
| 输出位置 | 转义方式 | 常见错误 |
|---|---|---|
| HTML 文本/属性 | HTML 实体(< " ') |
只替换 <script>,漏掉属性闭合 |
| URL 参数 | URLEncoder(百分号编码) |
拼进 href 前没编码 & ? |
| JS 字符串 | 转义 \ ' " < /,或 JSON.stringify |
直接 var x = '${value}' |
| JSON 响应 | 序列化库 + 正确的 Content-Type |
手拼字符串,漏掉引号与控制字符 |
| CSS / 富文本 | 专用过滤器(如 OWASP Sanitizer) | 自定义正则白名单 |
// 错误:字段值里带 ' 就能闭合字符串
el.innerHTML = "<a href='/u/" + id + "'>x</a>";
// 正确:用 DOM API 赋值,让浏览器负责转义
const a = document.createElement("a");
a.href = "/u/" + encodeURIComponent(id);
a.textContent = name;
转义必须发生在输出时,不能发生在入库时。入库时转义会导致二次转义(&lt;),且同一份数据被 API、导出、日志三种消费方式读出时,只有 HTML 那一种需要 HTML 转义——存的时候就转,等于污染了数据源。
7. 输入校验的定位与路径穿越
校验的职责是语义,不是安全。白名单优先于黑名单,因为黑名单永远在追新语法:
| 校验项 | 位置 | 目的 |
|---|---|---|
| 类型、长度、字符集、格式 | 接入层(DTO + @Valid) |
拒绝明显畸形输入,减小后续攻击面 |
| 枚举/白名单 | 接入层 | 把标识符类参数锁死 |
| 业务规则(余额、状态机) | 服务层 | 与数据一致性绑定,前端校验不算数 |
| 授权(谁能改哪条) | 服务层 | 越权不是输入问题,是主体问题 |
public record TransferReq(
@NotBlank @Pattern(regexp = "^[0-9]{1,19}$") String toAccount,
@NotNull @Min(1) @Max(1_000_000) Long amountCents) {}
路径穿越的判据只有一条:规范化之后,目标路径必须仍在基准目录内。字符串层面拒绝 .. 是不够的(%2e%2e%2f、符号链接、Windows 短名都能绕过)。
Path base = Paths.get("/data/uploads").toRealPath();
Path target = base.resolve(userPath).normalize().toRealPath();
if (!target.startsWith(base)) throw new SecurityException("path escape");
8. 排错:六个反复出现的坑
| 现象 | 根因 | 处理 |
|---|---|---|
| 前端加了校验就被打穿 | 校验只在浏览器,攻击者直接构造请求 | 服务端重复校验,前端校验只是体验 |
过滤了 <script> 仍有 XSS |
只黑名单一个标签,onerror/javascript: 全通 |
改为输出时按上下文转义 |
页面上出现 &lt; |
入库时转义,输出时又转义 | 存原始值,只在渲染时转义 |
| 排序参数报错被当成低危 | ${} 直接进 SQL |
白名单映射列名 |
| 日志里出现伪造的整行记录 | 直接拼接用户输入,未处理 \n |
结构化日志(键值对)+ 换行替换 |
LIKE 查询结果异常放大 |
% 未转义,用户输入 % 全表匹配 |
转义元字符 + 显式 ESCAPE |
9. 边界:这些情况防护手段不一样
| 场景 | 说明 |
|---|---|
| 富文本编辑器 | HTML 转义会破坏格式,只能用白名单 sanitizer,且要限定标签与属性 |
| 用户自定义 SQL/报表 | 参数化帮不上,必须上行级权限与只读账号,并限制可查表 |
| 文件上传后由外部工具处理 | 命令层防护 + 隔离进程 + 资源限制,缺一不可 |
| 定时任务里执行脚本 | 与在线接口同等对待,别假设"内部调用就安全" |
| 与老系统对接只能拼 SQL | 至少做长度与字符集收敛,并记录完整审计 |
一句话收口:结构由代码决定,数据只能进槽位;输出按上下文转义;标识符用白名单,不要指望转义。
10. 自测
- 参数化查询为什么能阻止注入,它保护的是语法的哪一部分?
ORDER BY为什么不能用占位符,正确做法是什么?#{}和${}在生成 SQL 时分别发生了什么?- 用
ProcessBuilder数组传参就完全安全了吗,还缺哪一层? - 为什么转义必须放在输出时而不是入库时?
- 判断路径穿越的唯一可靠判据是什么?