登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go 解包后符号链接越界的安全检查

来源:17golang原创

时间:2026-10-16 22:13:54 363浏览 收藏

Go 解包遇到符号链接越界,不能只检查归档条目的文件名是否含有“..”。条目名看似位于输出目录内,父目录中的链接仍可能让实际文件操作离开该目录。若业务不需要链接,优先拒绝符号链接和硬链接;同时使用独立私有输出目录,并把文件操作限制在同一个 os.Root 内。

官方入口:https://pkg.go.dev/os。os.Root 自 Go 1.24 提供,下面用到的 Root.MkdirAll 需要 Go 1.25 或更新版本。示例仅讨论未压缩 tar 的防御性写入策略,不是完整的任意格式解包服务,也没有经过本文所述环境中的实际运行。

先保护输出目录以外的文件

需要保护的是输出目录之外的配置、其他任务的数据以及应用自己的文件。归档中的 Header.Name 是“条目放在哪里”,符号链接的 Header.Linkname 是“链接指向哪里”,两者不是同一个字段。普通路径的前缀判断也不是实际文件系统访问范围的证明。

把文件名 Join 到输出目录后做字符串前缀比较,容易混淆相似目录名,也不解决符号链接。即便改为 Rel 或 IsLocal,得到的仍主要是词法结论:它们不负责保证后续磁盘操作不会跟随链接。

先 EvalSymlinks 再用普通 os.OpenFile 写入,也存在检查与使用之间的时间间隙。若目录结构可被其他主体修改,之前看到的关系不一定保持。防护应进入实际文件操作,而不只停留在解包前或解包后的扫描报告中。Go 官方关于遍历防护的说明明确讨论了这一差别。

归档条目名、链接目标与输出目录的静态边界关系
图1:结构说明图。左组区分 Header.Name 与 Header.Linkname,右组表示输出范围;静态关系线不是攻击执行流程或运行证据。

不需要链接时,明确拒绝而不是跳过

不少导入功能只需要目录、图片、文本或普通数据文件。这种场景没有必要先创建链接,再检查它是否“看起来安全”。采用类型白名单更容易说明,也更容易保证失败不会被误当成完整导入。

tar 条目类型本文策略原因
普通文件受限写入只接收局部路径,禁止覆盖
目录在根目录内创建采用服务设定的权限
符号链接拒绝整个任务业务不需要链接目标语义
硬链接拒绝整个任务不能把它误当普通文件复制
设备、FIFO 与其他类型拒绝整个任务超出数据导入契约

“跳过链接后继续成功”会留下不完整结果:后续普通文件也许依赖那个链接,消费者却以为归档已经完整展开。若业务确实允许部分导入,应另设明确状态和报告,不能把跳过操作隐藏在成功返回值里。

把所有写入限制在同一个 Root 内

下面的库函数接收已经打开的 Root 和 tar 输入流,只创建目录与普通文件。调用者应在受信任父目录下为每个任务创建全新私有目录,确认没有其他主体可写,再对该目录调用 os.OpenRoot;结束时关闭 Root。不要把已有业务目录直接交进来,也不要把归档提供的名字当作 OpenRoot 的根路径。

package unpack

import (
    "archive/tar"
    "fmt"
    "io"
    "os"
    "path/filepath"
    "strings"
)

// WriteTar仅写入调用者创建的全新私有目录;失败目录不得交给消费者。
// 需要Go 1.25+及支持相应Root防护语义的平台,不能替代操作系统隔离。
func WriteTar(root *os.Root, input io.Reader) error {
    const maxEntries = 256
    const maxFile = int64(8 = maxEntries {
            return fmt.Errorf("too many entries")
        }
        // 类型白名单:硬链接、符号链接、设备与其他类型全部拒绝。
        if h.Typeflag != tar.TypeDir &&
            h.Typeflag != tar.TypeReg && h.Typeflag != tar.TypeRegA {
            return fmt.Errorf("unsupported entry type: %d", h.Typeflag)
        }
        name := h.Name
        if h.Typeflag == tar.TypeDir {
            name = strings.TrimSuffix(name, "/")
        }
        // 不把不合规名字清洗成另一个名字;反斜杠也按本策略拒绝。
        if strings.Contains(name, "\\") || len(name) > 1024 {
            return fmt.Errorf("invalid entry name")
        }
        local, err := filepath.Localize(name)
        if err != nil || local == "." {
            return fmt.Errorf("non-local entry name")
        }
        if h.Typeflag == tar.TypeDir {
            if h.Size != 0 {
                return fmt.Errorf("directory has payload")
            }
            if err := root.MkdirAll(local, 0o700); err != nil {
                return err
            }
            continue
        }
        if h.Size  maxFile || h.Size > maxTotal-total {
            return fmt.Errorf("payload limit exceeded")
        }
        total += h.Size
        if err := root.MkdirAll(filepath.Dir(local), 0o700); err != nil {
            return err
        }
        // 拒绝覆盖已有文件,不恢复归档里的所有者、特殊权限或链接。
        f, err := root.OpenFile(local, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
        if err != nil {
            return err
        }
        _, copyErr := io.CopyN(f, tr, h.Size)
        closeErr := f.Close() // 每个文件及时关闭,不在循环里积累defer。
        if copyErr != nil {
            return copyErr
        }
        if closeErr != nil {
            return closeErr
        }
    }
}

这里没有普通文件系统写入的拼接路径:父目录创建与文件创建都经过 Root。Localize 用来把归档的斜杠路径转换为平台路径,拒绝不符合局部路径格式的名字;代码没有先 Clean 再把不合规输入悄悄改成另一个文件名。为简化跨平台约定,反斜杠和根目录占位条目也明确拒绝,因而不是所有合法 tar 都会被接受。

限制值是演示策略,不是推荐容量。条目数、单文件与累计载荷上限只提供部分资源控制;服务仍需输入大小、读取时限、磁盘配额等约束。本文不恢复归档所有者、原始权限或扩展属性;稀疏文件等格式特性也不能据此宣称已实现完整兼容。

函数失败时可能留下部分文件,调用方应把该目录标记为不可用,停止发布并按任务目录的生命周期处理。仅返回 nil 的任务才能进入下一阶段,消费者也不能通过普通路径重新绕开约定的访问范围。

需要保留链接时,还要约束后续读取

保留符号链接是另一种产品契约,不能简单地把上面的类型拒绝改成 Symlink。相对链接目标通常以链接所在目录为基点,不是统一以解包根目录为基点;目标还可能暂时不存在,或者由其他链接组成,因此不能只比较 Linkname 字符串前缀。

更容易遗漏的是后续消费者:解包器受限写入,并不自动保证预览器、索引器和打包器安全读取。允许保留链接时,它们也必须遵守同一范围约束。当前官方文档还特别说明,Root.Symlink 并不会验证目标 oldname 是否在根内;创建了链接不代表该链接已经通过了范围检查。

os.Root 也不是完整沙箱。它允许符合约束的根内链接,并非“禁止所有链接”;文件系统挂载、已有设备文件等另有边界。GOOS=js 的链接竞态防护存在文档列出的限制,因此本文方案不承诺跨所有 Go 平台等价。具体支持范围应对照项目所用 Go 版本的官方文档。

解包类型白名单与后续消费者范围约束的结构关系
图2:策略结构说明图。链接拒绝与输出消费者的范围约束分属不同职责,不表示自动获得完整沙箱能力。

为拒绝理由和隔离目录留下记录

审计记录应能说明任务为何停止:条目类型不受支持、路径格式不合规、重复文件、载荷超限或根目录内操作失败。条目名属于不可信输入,写日志时需要转义并限制长度;不要把原始名字直接拼成多行日志,也不要记录文件内容。

读者可以在隔离测试目录中为自己的实现准备以下用例:普通文件和嵌套目录应按契约写入;符号链接、硬链接和特殊类型应明确失败;重复文件不得覆盖;失败结果不得交给消费者。若要检查根外内容是否未改变,应仅使用测试专用哨兵文件,不对真实系统目录做实验。本文没有执行这些用例或展示测试通过证明。

解包完成后扫描一次链接够不够?不够。如果之前的写入已经离开目录,事后扫描不能撤销影响;如果后来有人能改目录,扫描结果也不能保证未来操作。

只拒绝符号链接就能放心复用旧目录吗?不能。旧目录可能已有链接或其他对象,所以本文同时要求全新私有目录、禁止覆盖以及受限文件操作。

os.Root 会自动拒绝所有根内链接吗?不会。根内可解析的链接属于它支持的正常行为;业务若禁止任何链接,应另外保留明确的类型拒绝策略。

参考:Go 官方:遍历防护与文件访问;os.Root、OpenRoot 与平台限制;archive/tar 的 Header 与条目类型;filepath.Localize 路径约定。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>