跳到内容

第 8 卷 / 第 4 章 / 第 2 课

4.2 服务端输入边界:注入、路径与文件处理 ​

注入发生在一个解释器无法区分“应用编写的语法”和“外部提供的数据”时。SQL 只是最熟悉的一种;shell、模板、LDAP、日志查询和表达式语言都有相同结构。

参数化查询把值与语法分开 ​

危险代码:

text
"SELECT * FROM users WHERE email = '" + email + "'"

正确方向是使用数据库 driver 的 bind parameter:

sql
SELECT id, password_hash
FROM users
WHERE email = ?

参数只代表值,不能改变查询结构。参数化通常不能绑定表名、列名和排序方向;这些结构性选项应由服务端 enum 映射:

text
sort=recent → ORDER BY created_at DESC
sort=name   → ORDER BY display_name ASC

不要对用户传入的列名做“转义后拼接”。存储过程内部若需要动态 SQL,也必须绑定数据值并限制结构选项;ORM 也可能通过 raw query、字符串 filter 或不安全 escape hatch 重新引入注入。

数据库账户还应最小权限:读接口不使用 schema owner,报表任务不拥有写权限,只服务单一租户的数据库身份不应读取其他租户 schema;共享身份服务多租户时,还需要查询约束、行级策略等相应隔离。WAF 可以挡住部分已知 payload,但无法替代码与数据分离。

下面只在 SQLite 内存库验证参数绑定,不涉及真实账户。带引号的合法地址和注入样式字符串都按完整 email 值查询,不能改变 WHERE 条件:

python
import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users (id INTEGER, email TEXT)")
db.execute("INSERT INTO users VALUES (?, ?)", (1, "alice@example.com"))

def find_user(email):
    return db.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchall()

assert find_user("alice@example.com") == [(1,)]
assert find_user("' OR 1=1 --") == []
assert find_user("o'connor@example.com") == []
db.close()

Shell 命令优先不经过 Shell ​

危险:

text
shell("convert " + uploadedPath + " output.png")

优先使用库 API;必须调用进程时,用参数数组并关闭 shell 解析:

text
execve("/usr/bin/convert", ["convert", validatedInput, outputPath], cleanEnv)

参数数组降低命令分隔符注入,却不自动保证目标程序的参数安全。以 - 开头的文件名可能被当选项,某些工具支持从 URL、配置或响应文件读取更多输入。目标工具支持时使用 -- 终止选项,配合服务端生成路径、严格 allowlist 和低权限 sandbox。-- 不是所有程序都支持的约定,不能给任意工具盲目加上。

模板要区分展示模板与代码模板 ​

把用户输入作为模板数据通常可以安全转义;让用户控制模板源码则可能形成 Server-Side Template Injection,读取进程对象、环境变量甚至执行命令。

text
render(trustedTemplate, { displayName: untrustedValue })  ✓
render(untrustedTemplateSource, model)                    高风险

确需用户模板时,使用为不可信模板设计的受限语言、固定函数集合、资源限制与进程隔离。仅删除几个危险关键字不能建立沙箱。

路径规范化不能靠字符串前缀 ​

下载接口常见问题:

text
/download?file=../../etc/passwd

安全设计优先让客户端提交不透明对象 ID,由服务端数据库映射到存储对象,而不是接受文件系统路径。若必须处理相对路径,应:

  1. 对输入做一次明确解码,拒绝 NUL 和非法形式;
  2. 在固定根目录下解析 canonical path;
  3. 确认结果仍在根目录内,注意路径分隔符与大小写规则;
  4. 防止符号链接与检查后替换的 TOCTOU;
  5. 使用目录 fd 与能够约束整个解析过程的平台 API;例如 Linux openat2 的 RESOLVE_BENEATH/RESOLVE_NO_SYMLINKS。仅对最后一段使用 O_NOFOLLOW,不能阻止中间目录的符号链接或替换。

简单的字符串 startsWith(root) 会把 /srv/uploads-evil 误当成 /srv/uploads 内部路径。

文件上传是内容处理管线 ​

扩展名和浏览器 Content-Type 都由客户端控制。上传服务应组合:

  • 限制大小、数量、压缩展开比和处理时间;
  • 检查允许类型的 magic bytes 与完整解析;
  • 服务端生成对象名,存到 Web root 之外或独立对象存储;
  • 下载时设置安全 Content-Type、Content-Disposition 与 nosniff;
  • 图片、文档和媒体转码放入低权限、有限资源的 sandbox;
  • 恶意软件扫描作为附加信号,不作为唯一安全边界;
  • 对公开内容使用独立 origin,避免上传 HTML 获得主站权限。

Zip Slip、解压炸弹和解析器漏洞说明“只存文件不执行”并不等于安全。

不可信反序列化会恢复行为 ​

某些对象序列化格式可以指定类型、构造器或 gadget chain。不要反序列化来自 Cookie、队列、缓存或上传文件的不可信原生对象图。优先使用只表达数据的 schema 格式,显式允许字段与类型,并限制深度、长度和重复结构。

签名只能证明数据来自持有密钥的一方;如果多个低信任服务共享签名密钥,其中任一服务仍可能构造危险对象。安全格式和最小解析能力不能由签名替代。

异常路径也要保持边界 ​

解析失败时不要回退到“兼容模式”执行原字符串,也不要把数据库错误、命令 stderr、绝对路径和 stack trace 返回给外部用户。记录内部 correlation ID,向客户端提供稳定错误模型,并对异常率告警。

下一课继续检查浏览器和服务器替谁发送了请求。

OWASP Top 10:2025 把 Mishandling of Exceptional Conditions 单列为 A10,提醒安全设计不能只覆盖成功路径。

参考资料 ​

Built with VitePress | Software Systems Atlas