4. 输出编码与注入防御

注入类漏洞的本质,是程序把“用户可控的数据”错误地当成了“代码或指令”来执行。要真正防住它,不能只靠过滤几个关键字,而要从数据与代码的边界入手:让数据永远停留在数据的位置,让结构由程序预先固定。本节围绕 SQL 注入、命令注入、LDAP 注入三类典型场景,讲清原理、给出可落地的防御手段,并说明参数化查询、ORM 安全使用与输出编码的边界。

一、SQL 注入:拼接字符串是根源

SQL 注入发生在 SQL 语句由字符串拼接而成时。假设登录校验写成:

sql = "SELECT id FROM users WHERE name='" + name + "' AND pwd='" + pwd + "'"

当用户输入 name = admin' -- 时,语句变成 SELECT id FROM users WHERE name='admin' --' AND pwd='...',-- 之后被注释,密码校验被绕过。若输入 ' OR '1'='1,条件恒真,可能直接返回全部用户。这就是“数据越界成为语法”的典型后果。

防御的核心是参数化查询(预编译语句):SQL 结构先固定,参数以占位符传入,数据库把参数当作纯数据,不再参与语法解析。

cursor.execute(
    "SELECT id FROM users WHERE name=%s AND pwd=%s",
    (name, pwd)
)

注意几个常见误区:表名、列名、ORDER BY 的方向不能用占位符绑定,只能通过白名单映射,例如把 asc/desc 映射为固定字符串;LIKE 的通配符要由程序拼接在参数值上,而不是拼进 SQL 结构;存储过程内部若仍用字符串拼接,同样会注入。

二、命令注入:把参数交给 shell 就危险

命令注入发生在程序调用系统命令时。例如:

os.system("ping -c 1 " + ip)

若 ip = 127.0.0.1; cat /etc/passwd,分号让第二条命令被执行。防御思路有三层:第一,尽量不调用 shell,使用参数数组形式,让参数不经过 shell 解析:

subprocess.run(["ping", "-c", "1", ip], shell=False)

第二,对输入做严格白名单校验,例如 IP 只允许数字和点,或直接用 ipaddress.ip_address() 解析,解析失败即拒绝。第三,遵循最小权限,运行服务的账户不应有读取敏感文件或执行高危命令的权限。需要特别提醒:shell=True、反引号、$()、管道、重定向、换行符都是命令注入的常见入口,不能仅靠转义个别字符来兜底。

三、LDAP 注入:过滤器语法同样会被污染

LDAP 查询使用过滤器语法,如 (uid=alice)(userPassword=xxx)。若直接拼接:

filter = "(&(uid=" + uid + ")(userPassword=" + pwd + "))"

输入 uid = *)(uid=*))(|(uid=* 可改变过滤器逻辑,导致认证绕过或信息泄露。防御要点:对进入过滤器的特殊字符 * ( ) \ NUL 做转义(RFC 4515 转义规则,如 * 转 \2a),并优先使用语言提供的 LDAP 转义函数;同时限制查询返回的属性,避免把密码等敏感字段作为可查询条件。

三类注入的“数据越界”路径与防御边界 用户可控输入 name / ip / uid SQL 拼接 ' OR '1'='1 防御:参数化查询 / 预编译 结构固定,参数只作数据;表名列名走白名单 命令拼接 127.0.0.1; cat /etc/passwd 防御:参数数组 + 白名单 + 最小权限 shell=False;IP 用 ipaddress 解析校验 LDAP 过滤器拼接 *)(uid=*))(|(uid=* 防御:RFC 4515 转义 + 属性最小化 * ( ) \ NUL 转义;不把密码作为查询条件 共同原则:让数据永远停留在数据位置,结构由程序预先固定;过滤只是补充,不是替代

四、ORM 安全使用:方便不等于自动安全

ORM 默认使用参数绑定,能挡住大部分 SQL 注入,但仍有几个高危用法需要警惕。第一,原生 SQL 拼接:Model.objects.raw("... WHERE id=" + uid) 会重新引入注入,应改为 raw("... WHERE id=%s", [uid])。第二,把用户输入直接作为字段名或排序字段,例如 order_by(request.GET['sort']),应通过白名单映射。第三,extra()、filter(**{user_key: value}) 这类动态关键字参数,若键来自用户输入,可能被用来构造意外查询。第四,批量操作与 Q 对象拼接时,确保值仍走参数绑定。

一个实用的判断标准是:凡是“结构部分”(表、列、排序方向、操作符)来自用户输入,就必须白名单;凡是“值部分”来自用户输入,就必须参数绑定。两者不能混用同一种处理方式。

五、输出编码:防御的另一半

注入防御不只是“输入侧”。当数据被输出到 HTML、JS、URL、CSS 等不同上下文时,需要按上下文做输出编码,否则会形成 XSS 等注入。原则是:HTML 文本用 HTML 实体编码,属性值加引号并编码,URL 参数用 encodeURIComponent,JS 字符串用 JSON 序列化或专用编码,CSS 用 CSS 转义。不要用一套“通用过滤”应付所有上下文,也不要在编码后再做反转义。

参数化查询解决“数据被当成 SQL 语法”,输出编码解决“数据被当成 HTML/JS 语法”,两者是同一防御思想在不同阶段的落地:明确边界、按位置处理。

六、可落地的检查清单

场景危险写法安全写法
SQL 查询字符串拼接 SQL占位符参数绑定
排序/表名直接拼用户输入白名单映射
系统命令os.system 拼接参数数组 + shell=False
IP 校验正则宽松匹配ipaddress 解析
LDAP 过滤直接拼接 uidRFC 4515 转义
HTML 输出原样输出用户数据按上下文编码

最后强调工程习惯:把参数化查询、命令参数数组、LDAP 转义封装成团队统一的工具函数或代码模板,配合静态扫描(如检查 os.system、字符串拼接 SQL 的模式)和代码评审清单,才能让防御从“知道”变成“默认做到”。注入漏洞很少因为技术太难而出现,更多是因为图省事拼接了一次字符串。