在Go中降低权限

Lobsters Hottest 新闻

摘要

一篇讨论在Go程序中降低权限以实现最小权限原则的技术博客文章,包括chroot和用户切换。

<p><a href="https://lobste.rs/s/8k4omy/dropping_privileges_go">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/05/23 16:47

# Go 语言中的权限降级 来源: https://log.0x21.biz/posts/go-privdrop/ 计算机程序可能做很多事情,无论是有意还是无意的。它们能做多少事受限于它们的权限。由于大多数操作系统以某个用户身份执行程序,因此程序拥有该用户的所有宝贵权限。举个具体的例子,如果一个用户手头有一个 SSH 私钥,并运行了一个聊天程序,那么即使这个程序与私钥毫无关系,它也能够读取该私钥。假设这个聊天程序存在可被利用的漏洞,那么攻击者可能通过精心构造的消息指示该聊天程序外泄私钥。也许这不是问题的核心,但损害的根源在于程序能够访问原本不应访问的资源。由于编写安全软件超出了范围,如果以某种方式强制执行了最小权限原则 (https://en.wikipedia.org/wiki/Principle_of_least_privilege),私钥本可以被保护起来。简而言之,该原则指出,每个组件(例如聊天软件)应该只拥有必要的权限,而不是更多。达到这种状态有很多方法,例如:不将私钥交互和聊天的用户设为同一个,或者对聊天应用进行沙盒隔离。在开发软件时,开发者应该知道他们的工具应该能够做什么。因此,他们能够划出允许的范围,借助系统功能拒绝其他一切。打个比方,想象一只狼人在满月前把自己锁起来。如果你现在正在问自己为什么要对代码这样做,因为它永远不会出错,那么尤其应该这样做。对于大多数现存的应用程序来说,问题不是 *是否* 会被攻破,而是 *何时* 会被攻破。由于我多年来写了无数个 bug,也见过无法理解的漏洞,我正试图为我未来的所有狼人预先拴上锁链。 ## 软件架构的变化 自我限制软件的理念在于,放弃的权限无法重新获得。例如,一旦程序拒绝了自己的文件系统访问权限,就无法再打开任何文件。软件以某个用户身份启动,有时是 `root` 用户,例如为了使用受限网络端口。因此,在开始监听该端口后,可以放弃这个特权,例如通过切换到一个非特权用户,同时保留文件描述符。软件会继续接受之前绑定的端口上的连接,但无法再开始监听其他受限端口。在设计软件时必须考虑到这种限制。软件无法在需要时访问所有资源,而必须在自我限制之前的初始阶段获取它们。再引入一个有问题的比喻:想象一个漏斗或者倒置的圆锥体:你的程序从拥有所有权限开始,随着运行逐步放弃它们,直到只保留最低限度的权限继续运行。 ## 古老的 Chroot 和用户切换 我们从 chroot 和切换用户/组的经典方法开始。我称其为经典,因为这种方法可以追溯到 1990 年代初期 (https://www.usenix.org/legacy/publications/library/proceedings/sec4/carson.html),并且适用于任何 POSIX 类系统(比如 BSD、Linux 及其同类系统)。不幸的是,这种方法有一个烦恼:必要的系统调用保留给 `root` 用户使用。虽然对于守护进程来说,这在大多数情况下不是问题,但对于终端用户应用程序(如图形界面程序)来说,这将是一个障碍。这里不推荐使用 `SUID` 文件标志的疯狂做法 (https://en.wikipedia.org/wiki/Setuid#Security),后续将介绍适用于非 root 场景的安全替代方案。 ### `chroot(2)` 首先,`chroot(2)` 将进程的根目录更改为指定的目录。例如,将 `/` 变为 `/var/empty`,访问 `/etc/passwd` 时实际上会尝试打开 `/var/empty/etc/passwd`。要激活 `chroot(2)`,进程需要 `chdir(2)` 进入该目录。除非采取进一步措施,否则攻击者可以突破 chroot 限制。实际上,chroot 本身并不是一个安全特性,但可以用来构建一个安全特性——正如本文所尝试的。不过,请务必了解其局限性 (https://github.com/earthquake/chw00t)。Chroot 直接影响进程。如果进程不应与任何文件交互,将根目录改为 `/var/empty` 或刚创建的空目录是有意义的。如果只有一个目录,可以考虑 chroot 到该目录。但是,如果需要访问不同位置的文件,严格的 `chroot` 可能会成为负担。这需要根据具体情况来决定。引入 `golang.org/x/sys/unix` (https://pkg.go.dev/golang.org/x/sys/unix) 后,以下代码片段就足以将进程 `chroot(2)` 到 `/var/empty`,根据 OpenBSD 的 `hier(2)` (https://man.openbsd.org/hier),这是一个“通用 chroot(2) 目录”。 ```go if err := unix.Chroot("/var/empty"); err != nil { log.Fatalf("chroot: %v", err) } if err := unix.Chdir("/"); err != nil { log.Fatalf("chdir: %v", err) } ``` ### `setuid(2)` 或 `setresuid(2)` 进程现在可能被 chroot 到一个空目录,但除此之外仍然以 `root` 身份运行。要放弃 `root` 权限,让进程将用户权限切换到一个没有特殊权限的非特权用户。历史上,出现了多种用于切换用户的系统调用,从 `setuid(2)` 开始。虽然 `setresuid(2)` 系统调用并非严格属于 POSIX,但大多数操作系统都支持它。它允许设置真实用户 ID、有效用户 ID 和已保存用户 ID,它们之间存在细微差别。在开发或使用 `SUID` 应用程序时,这些可能有所不同,因为此时真实用户 ID 是你的用户 ID,而有效用户 ID 是 `root` 用户的 ID。然而,在我们的场景中,我们只想将所有权限降级到我们的无特权用户,将这三个用户 ID 都设置为同一个用户。对于组,同样可以使用 `setresgid(2)`。另外,由于一个进程可能有多个组,可以通过 `setgroups(2)` 来缩短这个列表。这样做之后,只会拥有给定组的权限,而不会拥有所有其他用户组的权限。为了举例,创建一个没有特权的 worker 用户。在 Linux 上可以这样做: ```bash $ sudo useradd \ --home-dir /var/empty \ --system \ --shell /run/current-system/sw/bin/nologin \ --user-group \ demoworker $ id demoworker uid=992(demoworker) gid=987(demoworker) groups=987(demoworker) ``` 接着使用下面这段简短的代码: ```go // Prior chroot code uid, gid := 992, 987 if err := unix.Setgroups([]int{gid}); err != nil { log.Fatalf("setgroups: %v", err) } if err := unix.Setresgid(gid, gid, gid); err != nil { log.Fatalf("setresgid: %v", err) } if err := unix.Setresuid(uid, uid, uid); err != nil { log.Fatalf("setresuid: %v", err) } ``` 虽然这个片段可以作为演示,但实际需要配置用户和组 ID 还是有点麻烦。因此,我们编写一个围绕 `os/user` (https://pkg.go.dev/os/user) 的简短辅助函数,让代码自行查找。关于 `os/user` 包需要注意一点:它会缓存当前用户,并且不能简单地使缓存失效。因此,在使用下面的辅助函数后,`user.Current()` 将始终返回首先执行此函数的那个用户。 ```go // uidGidForUserGroup fetches an UID and GID for the given user and group. func uidGidForUserGroup(username, groupname string) (uid, gid int, err error) { userStruct, err := user.Lookup(username) if err != nil { return } userId, err := strconv.ParseInt(userStruct.Uid, 10, 64) if err != nil { return } groupStruct, err := user.LookupGroup(groupname) if err != nil { return } groupId, err := strconv.ParseInt(groupStruct.Gid, 10, 64) if err != nil { return } uid, gid = int(userId), int(groupId) return } ``` 使用此函数时需要注意,因为这可能是第一次遇到 chroot 会拖累我们的情况。查找需要访问 `/etc/passwd` 和 `/etc/group` 文件。因此,如果已经 chroot 到 `/var/empty`,显然会失败: > open /etc/passwd: no such file or directory 首先进行查找,然后执行 `chroot(2)`,最后执行用户/组切换。 ```go // Start with root privileges, do necessary lookups. uid, gid, err := uidGidForUserGroup("demoworker", "demoworker") if err != nil { log.Fatalf("user/group lookup: %v", err) } // Drop into chroot if err := unix.Chroot("/var/empty"); err != nil { log.Fatalf("chroot: %v", err) } if err := unix.Chdir("/"); err != nil { log.Fatalf("chdir: %v", err) } // Switch to an unprivileged user unable to escape chroot if err := unix.Setgroups([]int{gid}); err != nil { log.Fatalf("setgroups: %v", err) } if err := unix.Setresgid(gid, gid, gid); err != nil { log.Fatalf("setresgid: %v", err) } if err := unix.Setresuid(uid, uid, uid); err != nil { log.Fatalf("setresuid: %v", err) } // Application code follows ``` ## 以 POSIX 方式限制资源 在 chroot 和放弃 `root` 用户权限之后,代码可能无法再访问特权系统 API 或某些文件,但仍然可以运行循环。例如,一个解析器可能容易受到十亿笑攻击 (https://en.wikipedia.org/wiki/Billion_laughs_attack),导致 100% 的 CPU 负载甚至内存耗尽。一种古老的 POSIX 方式限制不同资源的方法是 `setrlimit(2)`。根据目标操作系统,定义了不同的资源。上面例子中的两种恐怖场景可以通过 `RLIMIT_CPU`(限制 CPU 时间)或 `RLIMIT_DATA`(限制数据段)来解决。 ### `RLIMIT_CPU` CPU 时间或进程时间 (https://en.wikipedia.org/wiki/CPU_time) 是单个进程主动消耗的 CPU 周期数。如果进程不间断地计算某些东西,这个计数器会上升。然而,如果进程等待某些事件,计数器也会空闲。例如,先空闲一会儿,然后通过无用的哈希计算消耗 CPU 周期,同时将 CPU 时间限制为一秒。 ```go if err := unix.Setrlimit( unix.RLIMIT_CPU, &unix.Rlimit{Max: 1}, ); err != nil { log.Fatal("setrlimit: %v", err) } log.Println("CPU time != execution time, hanging low") time.Sleep(5 * time.Second) log.Println("STRESS!") buff := make([]byte, 32) for i := uint64(1); ; i++ { _, _ = rand.Read(buff) _ = sha256.Sum256(buff) if i%100_000 == 0 { log.Printf("Run %d", i) } } ``` 将这个例子放入 `main` 函数中,可以看到进程在消耗过多 CPU 时间后被终止。 ``` 2025/01/26 20:17:18 CPU time != execution time, hanging low 2025/01/26 20:17:23 STRESS! 2025/01/26 20:17:23 Run 100000 [ . . . ] 2025/01/26 20:17:24 Run 800000 [1] 190379 killed ./02-01-setrlimit-cpu ``` ### `RLIMIT_DATA` 第二个例子将最大数据段限制为 10 MiB,换句话说,将可用内存限制为 10 MiB。代码会在一个无限循环中分配内存,导致内存溢出。然而,由于 `setrlimit(2)` 调用,进程被终止。 ```go if err := unix.Setrlimit( unix.RLIMIT_DATA, &unix.Rlimit{Max: 10 * 1024 * 1024}, ); err != nil { log.Fatal("setrlimit: %v", err) } var blobs [][]byte for i := uint64(1); ; i++ { buff := make([]byte, 1024) _, _ = rand.Read(buff) blobs = append(blobs, buff) if i%1_000 == 0 { log.Printf("Allocated %dK", i) } } ``` 由于内存饥饿,进程会被终止。 ``` 2025/01/26 20:17:44 Allocated 1000K 2025/01/26 20:17:44 Allocated 2000K fatal error: runtime: out of memory [ . . . ] ``` ### 良好的硬限制? 这两个例子设置了硬限制,最终导致进程被终止。尤其是 `RLIMIT_CPU`,作为一个递增计数器,必然会被达到。那么,什么是好的值呢?当然是取决于具体情况。不过,如果决定设置任何限制,请确保它们足够高,在正常操作下不会被达到。如果出了问题,它们仍然可以作为安全网。那么软限制呢?留给读者作为练习。 ## 利用操作系统特定功能加倍限制 到目前为止,所有内容应该适用于大多数 POSIX 类操作系统。好处是,在你的程序中使用这些模式可能能够在那些你甚至不知道其存在的平台上工作。但也有操作系统特定的机制允许放弃权限,即使不以 `root` 用户而是普通用户启动。由于我个人的经验仅限于 Linux 和 OpenBSD,我将介绍它们的一些特性。OpenBSD 的 API 更简单,因此我从它开始。 ## 在 OpenBSD 上限制系统调用 操作系统的内核会验证用户权限是否足够访问某个资源。例如,当尝试 `open(2)` 一个文件时,操作系统可能会拒绝。这个检查发生在 `open(2)` 内部。但如果程序本身甚至不能使用 `open(2)`,因为开发者知道它从一开始就不需要打开任何文件呢?欢迎来到系统调用过滤,它允许程序限制以后可以使用的系统调用。如果有什么喜欢的东西,我的最爱可能是 OpenBSD 的 `pledge(2)`。它提供了一个简单的基于字符串的 API,通过空格分隔的关键字(称为承诺)来限制可用的系统调用。这些承诺是系统调用组的名称,例如 `rpath` 代表与文件系统相关的只读系统调用。通过将 `exec` 添加到列表中,将允许执行其他程序,它们会以第二个参数中给出的自己的承诺启动。 ```c int pledge(const char *promises, const char *execpromises); ``` 一旦做出了 `pledge(2)`,就无法撤销,只能收紧。收紧意味着用更短的承诺列表再次调用 `pledge(2)`。如果违反了系统调用承诺,进程要么被杀死,要么(如果承诺中包含 `error`)被拒绝的系统调用返回一个错误。作为一个系统调用,它可以通过 `golang.org/x/sys/unix` 在 Go 中使用。遗憾的是,Go Packages 网页只渲染了部分平台的文档,不包括 OpenBSD。因此,我冒昧地将文档粘贴在下面。顺便说一句,设置 `GOOS` 或 `GOARCH` 环境变量也适用于 `go doc`,例如 `GOOS=openbsd go doc -all golang.org/x/sys/unix` 在 Linux 上也可以工作。 ``` func Pledge(promises, execpromises string) error Pledge implements the pledge syscall. This changes both the promises and execpromises; use PledgePromises or PledgeExecpromises to only change the promises or execpromises respectively. For more information see pledge(2). func PledgeExecpromises(execpromises string) error PledgeExecpromises implements the pledge syscall. This changes the execpromises and leaves the promises untouched. For more information see pledge(2). func PledgePromises(promises string) error PledgePromises implements the pledge syscall. This changes the promises and leaves the execpromises untouched. For more information see pledge(2). ``` 现在,`pledge(2)` 有三个 Go 函数:一个用于设置两个参数,一个用于只设置第一个或第二个参数。让我们创建一个使用输入文件的简单程序示例,先做出只允许读取文件的承诺,然后在读取文件后做出更严格的承诺。发挥你的想象力,这个程序可以做什么,比如:将图像转换为另一种格式并输出到标准输出。 ```go // Start with limited privileges if err := unix.PledgePromises("stdio rpath error"); err != nil { log.Fatalf("pledge: %v", err) } // Read input file f, err := os.Open("input") if err != nil { log.Fatalf("cannot open input: %v", err) } inputFile, err := io.ReadAll(f) if err != nil { log.Fatalf("cannot read input: %v", err) } if err := f.Close(); err != nil { log.Fatalf("cannot close input: %v", err) } // Drop further, reading files is no longer necessary if err := unix.PledgePromises("stdio error"); err != nil { log.Fatalf("pledge: %v", err) } // Do some computation based o ```

相似文章

调试挂起的Go程序的技巧

Michael Stapelberg

一份实用指南,涵盖了调试挂起的Go程序的三种方法:使用SIGQUIT打印堆栈跟踪、附加delve调试器以及保存核心转储供后续分析。

Golang 代码审查笔记 II

Lobsters Hottest

来自 elttam 的后续博客文章,介绍了提高安全性的新 Go 语言特性、在代码审计中发现的有问题的编码模式(footguns),以及用于捕获这些模式的 Semgrep 规则。

Go 中过度的空指针检查

Lobsters Hottest

一篇博客文章讨论了 Go 中过度的空指针检查如何可能表明代码不清晰和错误处理实践不佳,主张早期失败并显式建模不可用的依赖。