4. 注入类漏洞防御编码

注入类漏洞的本质,是程序把“数据”错误地当成了“代码”或“查询结构”来执行。SQL注入、命令注入、LDAP注入虽然发生在不同层面,但根因高度一致:用户可控输入未经结构化处理,直接拼接进解释器可识别的语句中。防御的核心思路不是“过滤坏字符”,而是让数据永远停留在数据的位置,无法改变语句结构。本章围绕参数化查询、安全API封装、最小权限账户和危险函数替代四条主线展开。

一、SQL注入:参数化查询是首选方案

SQL注入的典型场景是字符串拼接。例如下面这段伪代码:

String sql = "SELECT id, name FROM users WHERE name = '" + userName + "'";
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery(sql);

当 userName 传入 ' OR '1'='1 时,查询条件被改写,返回全部用户。参数化查询(Prepared Statement)通过预编译语句模板,把 SQL 结构与参数值分离,数据库先解析语句结构,再绑定参数,参数内容不会参与语法解析。改写如下:

String sql = "SELECT id, name FROM users WHERE name = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, userName);
ResultSet rs = ps.executeQuery();

需要特别注意的是,参数化只能用于值的位置,不能用于表名、列名、ORDER BY 字段等结构位置。若业务确实需要动态排序,应使用白名单映射,例如只允许 name、created_at 两个字段,其余一律拒绝,而不是把用户输入直接拼进 ORDER BY。

对于 LIKE 查询,参数化同样适用,但通配符需要由程序控制:WHERE name LIKE ?,绑定值为 "%" + escapeLike(userInput) + "%",其中 escapeLike 负责转义 %、_ 和转义符本身。这样既保留模糊匹配能力,又不破坏参数化边界。

二、命令注入:避免拼接,优先使用参数数组

命令注入常出现在调用系统命令的场景。危险写法如 Runtime.getRuntime().exec("ping -c 1 " + ip),当 ip 为 127.0.0.1; rm -rf / 时,第二条命令会被执行。防御方式是使用支持参数数组的 API,让每个参数独立传递,不经过 shell 解析:

ProcessBuilder pb = new ProcessBuilder("ping", "-c", "1", ip);
pb.redirectErrorStream(true);
Process p = pb.start();

如果必须使用 shell,则应对输入做严格白名单校验,例如 IP 地址只允许数字和点,长度不超过 15,且用正则 ^(\d{1,3}\.){3}\d{1,3}$ 校验每一段范围。更稳妥的做法是彻底避免调用外部命令,改用语言内置库或受控的服务接口。

三、LDAP注入:转义与过滤器构造

LDAP 查询过滤器同样存在注入风险。例如 (uid=" + user + "),当 user 为 *)(uid=*))(|(uid=* 时,过滤器逻辑被篡改。防御方式是对特殊字符进行转义,包括 * ( ) \ NUL 等,并使用框架提供的过滤器构造方法,而不是手工拼接。例如 Java 中可使用 Filter.encodeValue(),或采用白名单限制 uid 只允许字母数字。

注入类漏洞防御:数据流与拦截点 用户输入 name / ip / uid 字符串拼接 结构被篡改 参数化 / 数组 结构与数据分离 SQL / Shell LDAP 解释器 执行结果 数据 / 命令 白名单校验 表名 / 排序字段 最小权限账户 仅 SELECT / 限定库表 危险函数替代 exec → ProcessBuilder 防御要点对照 SQL:PreparedStatement 绑定参数;LIKE 通配符由程序转义;ORDER BY 用白名单映射。 命令:ProcessBuilder 参数数组;避免 shell 拼接;输入正则白名单校验。 LDAP:转义 * ( ) \ NUL;使用框架过滤器构造;uid 限制字母数字。

四、最小权限数据库账户

即使代码存在疏漏,最小权限账户也能限制攻击后果。应用连接数据库的账户不应使用 root 或 sa,而应只授予业务必需权限。例如一个只读查询服务,账户权限可配置为:

GRANT SELECT ON app_db.users TO 'app_read'@'10.0.0.%';
GRANT SELECT ON app_db.orders TO 'app_read'@'10.0.0.%';
-- 不授予 INSERT、UPDATE、DELETE、DROP、FILE 等权限

对于需要写入的模块,单独使用另一个账户,并限制可操作的表和列。这样即使发生注入,攻击者也无法通过 LOAD_FILE、INTO OUTFILE 或跨库查询扩大战果。同时应关闭数据库的 multiStatements 选项,避免一次注入执行多条语句。

五、安全API封装与危险函数替代

把安全操作封装成统一 API,可以降低团队犯错概率。例如封装一个 SafeQuery 类,内部强制使用 PreparedStatement,对外只暴露“SQL 模板 + 参数列表”的接口,禁止传入完整 SQL 字符串。对于命令执行,封装 SafeCommand,只接受命令名和参数数组,并在内部做白名单校验。

危险函数替代清单可参考:

  • Statement.execute → PreparedStatement.execute
  • Runtime.exec(String) → ProcessBuilder(List<String>)
  • InitialDirContext.search 手工拼接 → 使用过滤器编码方法
  • String.format 拼 SQL → 参数占位符
参数化查询不是“可选优化”,而是防御 SQL 注入的基线要求。任何仍然使用字符串拼接构造 SQL 的代码,都应视为高风险缺陷,在代码评审和 SAST 扫描中优先修复。
白名单校验必须对完整输入做匹配,而不是只检查是否包含某些字符。例如用 contains(";") 判断命令注入,攻击者可用换行、管道、反引号等多种方式绕过。正确做法是“只允许已知安全字符”,而不是“拒绝已知危险字符”。

综合来看,注入防御的落地顺序建议是:先全面替换为参数化查询和安全 API,再收紧数据库账户权限,最后补充输入白名单和运行时监控。三层叠加后,即使某一层被绕过,其余层仍能限制影响范围。编码阶段就应把“数据与结构分离”作为默认习惯,而不是等到渗透测试报告出来再补救。