2768 字
约 9 分钟
20
S27-X17 · 输入处理与注入防护:分层的校验点

注入不是一类漏洞,是同一个错误在不同解析器里的复现:把用户提供的字节,交给了一个会区分"结构"和"数据"的解释器,却没有告诉它哪部分是数据。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 实体(&lt; &quot; &#x27;) 只替换 <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;

转义必须发生在输出时,不能发生在入库时。入库时转义会导致二次转义(&amp;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: 全通 改为输出时按上下文转义
页面上出现 &amp;lt; 入库时转义,输出时又转义 存原始值,只在渲染时转义
排序参数报错被当成低危 ${} 直接进 SQL 白名单映射列名
日志里出现伪造的整行记录 直接拼接用户输入,未处理 \n 结构化日志(键值对)+ 换行替换
LIKE 查询结果异常放大 % 未转义,用户输入 % 全表匹配 转义元字符 + 显式 ESCAPE

9. 边界:这些情况防护手段不一样

场景 说明
富文本编辑器 HTML 转义会破坏格式,只能用白名单 sanitizer,且要限定标签与属性
用户自定义 SQL/报表 参数化帮不上,必须上行级权限与只读账号,并限制可查表
文件上传后由外部工具处理 命令层防护 + 隔离进程 + 资源限制,缺一不可
定时任务里执行脚本 与在线接口同等对待,别假设"内部调用就安全"
与老系统对接只能拼 SQL 至少做长度与字符集收敛,并记录完整审计

一句话收口:结构由代码决定,数据只能进槽位;输出按上下文转义;标识符用白名单,不要指望转义。


10. 自测

  1. 参数化查询为什么能阻止注入,它保护的是语法的哪一部分?
  2. ORDER BY 为什么不能用占位符,正确做法是什么?
  3. #{} 和 ${} 在生成 SQL 时分别发生了什么?
  4. 用 ProcessBuilder 数组传参就完全安全了吗,还缺哪一层?
  5. 为什么转义必须放在输出时而不是入库时?
  6. 判断路径穿越的唯一可靠判据是什么?
S27-X17 · 输入处理与注入防护:分层的校验点
https://www.clxhxhhr.top/posts/5921/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。