【译】使用rust来写一个容器(六)

  1. 1. User namespace与 Linux Capabilities
    1. 1.1. User namespaces
      1. 1.1.1. 是否支持user namespace?
      2. 1.1.2. 添加到配置流程
      3. 1.1.3. 映射UID / GID
      4. 1.1.4. 关于user namespace 安全性
      5. 1.1.5. 测试
        1. 1.1.5.1. Patch for this step
    2. 1.2. Linux Capabilties
      1. 1.2.1. 什么是Linux Capabilties
        1. 1.2.1.1. 划分管理员权限
        2. 1.2.1.2. 获取capabilities
        3. 1.2.1.3. 更多信息
      2. 1.2.2. 选择需要限制的内容
      3. 1.2.3. 限制进程的capabilities
      4. 1.2.4. 测试
        1. 1.2.4.1. Patch for this step

本文章为对 Litchi Pi《Writing a container in Rust》的翻译转载,不享受任何著作权利,不用于任何商业目的,不以任何许可证进行授权,不对任何转载行为尤其是商业转载行为负责。一切权利均由原作者 Litchi Pi 保有。个人翻译能力有限,如有疑问可查看原文。

User namespace与 Linux Capabilities

User namespaces

User namespace允许进程在其自身的命名空间内模拟root用户运行,而在宿主机中这个用户并不是root用户。它还允许子命名空间拥有自己的用户/组配置。

User namespace的开发始于 Linux 2.6.23,随后在 Linux 3.8 中发布,并且在 Linux 内核社区中仍然引发了关于其效率、安全问题等讨论。

请查看这篇文章(链接来自原教程的脚注)

一般的User namespace配置如下:

  • 子进程试图取消共享其用户资源
    • 如果成功,那么支持user namespace 隔离
    • 如果失败,那么不支持user namespace 隔离
  • 子进程告知父进程是否支持user namespace隔离
  • 如果支持user namespace隔离,父进程映射user namespace的 UID / GID
  • 父进程告知子进程继续
  • 然后,子进程将其 UID/GID切换为用户作为参数提供的UID/GID。

这部分代码写在src/namespaces.rs文件中,我们把它作为模块添加到src/main.rs中:

1
2
3

// ...
mod namespaces;

并在src/errors.rs中添加一个特定的命名空间配置相关错误:

1
2
3
4
pub enum Errcode {
// ...
NamespacesError(u8),
}

是否支持user namespace?

在我们的src/namespaces.rs文件中,我们创建了两个函数,一个是userns,它将在子进程配置期间执行;另一个是handle_child_uid_map,它将在容器执行 UID / GID 映射时被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
use std::os::unix::io::RawFd;
use nix::sched::{unshare, CloneFlags};

use crate::errors::Errcode;
use crate::ipc::{send_boolean, recv_boolean};

pub fn userns(fd: RawFd, uid: u32) -> Result<(), Errcode> {
log::debug!("Setting up user namespace with UID {}", uid);
let has_userns = match unshare(CloneFlags::CLONE_NEWUSER) {
Ok(_) => true,
Err(_) => false,
};
send_boolean(fd, has_userns)?;

if recv_boolean(fd)? {
return Err(Errcode::NamespacesError(0));
}

if has_userns {
log::info!("User namespaces set up");
} else {
log::info!("User namespaces not supported, continuing...");
}

// Switch UID / GID with the one provided by the user

Ok(())
}


use nix::unistd::Pid;
pub fn handle_child_uid_map(pid: Pid, fd: RawFd) -> Result<(), Errcode> {
if recv_boolean(fd)? {
// Perform UID / GID map here
} else {
log::info!("No user namespace set up from child process");
}

log::debug!("Child UID/GID map done, sending signal to child to continue...");
send_boolean(fd, false)
}

子容器使用CLONE_NEWUSER标志调用unshare函数,执行以下操作:

解除user namespace的共享,使调用进程移动到一个新的user namespace中,该命名空间与任何先前存在的进程都不共享。

更多细节请参阅 Linux 手册

如果这次调用成功,那么代表系统支持user namespace。我们使用之前定义的send_booleanrecv_boolean函数,将这些信息从子进程传输到父进程(在创建子进程后,父进程将在handle_child_uid_map中等待这些信息)。

父进程执行(或不执行)UID / GID 映射后,它会发送一个成功的信号,子进程可以继续执行,将其 UID 和 GID 切换到用户提供的值。

添加到配置流程

userns是子进程配置过程中的一部分,我们把它添加到src/child.rs文件中的setup_container_configurations函数中:

1
2
3
4
5
6
7
use crate::namespaces::userns;

fn setup_container_configurations(config: &ContainerOpts) -> Result<(), Errcode> {
// ...
userns(config.fd, config.uid)?;
// ...
}

请注意,配置的顺序非常重要。当我们允许或限制进程的操作权限时,我们必须限制我们知道不会使用的权限。

在我们创建子进程之后,容器必须调用handle_child_uid_map函数,并等待它的信号以执行操作
参见src/container.rs:

1
2
3
4
5
6
7
8
9
use crate::namespaces::handle_child_uid_map;

impl Container {
// ...
pub fn create(&mut self) -> Result<(), Errcode> {
let pid = generate_child_process(self.config.clone())?;
handle_child_uid_map(pid, self.sockets.0)?;
// ...
}

最后我们需要关闭不再使用的socket,让我们把这部分逻辑添加到src/child.rs中:

1
2
3
4
5
6
7
8
9
10
11
12
fn child(config: ContainerOpts) -> isize {
match setup_container_configurations(&config) {
// ...
}

if let Err(_) = close(config.fd){
log::error!("Error while closing socket ...");
return -1;
}

// ...
}

映射UID / GID

文件/proc/<pid>/uidmap被Linux内核用来映射进程命名空间内外的用户ID。其格式如下

1
ID-inside-ns   ID-outside-ns   length

因此,如果文件/proc/<pid>/uidmap包含0 1000 5,那么在容器内部UID为0的用户,在容器外部的UID会是1000。同样地,容器内部的UID为1对应到外部的UID为1001,但是内部的UID为6并不会映射到外部的UID为1006,因为只有5个UID被允许映射(长度限制为5)。

这部分解释来自此文章

对于GID也是一样的,因为我们只设置了使用完全相同的数字的用户组。

如果想了解更多UID/GID 以及它们之间的关系,可以查阅此文章

由于我们的子进程已经改变了根目录,我们希望父进程能够正确地写入文件。现在,让我们来填充handle_child_uid_map函数中的空白部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
use std::fs::File;
use std::io::Write;
use nix::unistd::Pid;

const USERNS_OFFSET: u64 = 10000;
const USERNS_COUNT: u64 = 2000;

pub fn handle_child_uid_map(pid: Pid, fd: RawFd) -> Result<(), Errcode> {
if recv_boolean(fd)? {
if let Ok(mut uid_map) = File::create(format!("/proc/{}/{}", pid.as_raw(), "uid_map")) {
if let Err(_) = uid_map.write_all(format!("0 {} {}", USERNS_OFFSET, USERNS_COUNT).as_bytes()) {
return Err(Errcode::NamespacesError(4));
}
} else {
return Err(Errcode::NamespacesError(5));
}

if let Ok(mut gid_map) = File::create(format!("/proc/{}/{}", pid.as_raw(), "gid_map")) {
if let Err(_) = gid_map.write_all(format!("0 {} {}", USERNS_OFFSET, USERNS_COUNT).as_bytes()) {
return Err(Errcode::NamespacesError(6));
}
} else {
return Err(Errcode::NamespacesError(7));
}
} else {
// ...
}
// ...
}

注意,我们将UID和GID映射到超过10000,以确保我们不会与任何现有的标识符冲突。我们映射了多达2000个UID,但这并不是很重要。

总结一下,从现在开始,无论何时我们的容器进程(通过其PID匹配)声明他拥有的(或者他自己设置的)UID为0,内核得到的它的UID会变为10000。对于GID也将发生同样的情况。

如果容器进程请求的UID为0,那么在其隔离的执行环境范围内,该进程会作为根用户来运行。

现在,让我们指示容器进程把它的UID切换为用户在userns函数参数中请求的UID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
use crate::errors::Errcode;

use nix::unistd::{Gid, Uid};
use nix::unistd::{setgroups, setresuid, setresgid};
use std::os::unix::io::RawFd;

pub fn userns(fd: RawFd, uid: u32) -> Result<(), Errcode> {
// ...
log::debug!("Switching to uid {} / gid {}...", uid, uid);
let gid = Gid::from_raw(uid);
let uid = Uid::from_raw(uid);

if let Err(_) = setgroups(&[gid]){
return Err(Errcode::NamespacesError(1));
}

if let Err(_) = setresgid(gid, gid, gid){
return Err(Errcode::NamespacesError(2));
}

if let Err(_) = setresuid(uid, uid, uid){
return Err(Errcode::NamespacesError(3));
}

Ok(())
}

我们使用setgroups函数(参见Linux手册](https://man7.org/linux/man-pages/man2/getgroups.2.html))来设置进程所属的群组列表,对于这个简单的例子来说,我们只添加进程GID。

我们使用setresuid和setresgid来分别设置进程的UID和GID。这将设置real user ID、effective user ID以及saved set-user-ID。

real user ID代表你是谁(你登录为哪个用户),
effective user ID代表你声名你是谁(用于sudo的临时权限,或者用su模拟用户),
saved set-user-ID是你之前是谁(在链式模拟的情况下…)。

细节的解释可以查看stack overflow相关回答

现在,容器化的进程可以在其隔离的环境中作为root用户,由系统映射到一个实际的UID >10000,它可以管理其用户和组,而不会污染宿主机系统。

译者注

关于real user id/ effective user id / saved set user id:
可以通过 cat /proc/<Pid>/status 查看
截取相关信息部分:

1
2
Uid:    0       0       0       0
Gid: 0 0 0 0

以user id为例, 四个数字分别为real user id, effective user id, saved set user id, filesystem UID,GID以此类推。

  • real user id(ruid) 是执行进程者的uid,一般情况下就是用户登录时的uid。子进程的ruid从父进程继承。如果我以hadoop用户登录系统,那么我后面运行的进程的ruid一般都为hadoop用户的ruid
  • effective user id(euid) 大部分情况下与你的uid一样, 如果你运行的进程对应的可执行文件的setuid属性为true时(简单解释:运行chmod u+s xxxx[设置stick bit粘滞位]时,这个属性就会变为true),即运行的程序为SetUID程序时,euid为该文件的所属用户uid,你运行的进程的权限会变成所属用户的权限,而uid不会发生变化,su以及sudo都是典型的SetUID程序
  • saved set user id(ssuid) 在启动exec函数后,这个id会从euid拷贝,它存在的意义是,你可以用setuid()函数将euid变为ruid或者ssuid
  • filesystem UID(fsuid):用于进行文件访问的用户,通过euid来决定。
    使用ps aux列出的进程中的USER部分实际上指的就是euid

关于user namespace 安全性

原始教程的脚注中(你真的应该去看看。),你可以找到更多关于user namespaces的信息、实验和文档。

我发现当我们讨论加固容器安全性和隔离性时,这个PDF文档非常有用,它涵盖了许多本教程范围之外的方面。
(关于用户命名空间安全性的内容在第39页的5.5节中有所涉及)

测试

运行测试后,我们应该可以得到如下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[2022-01-05T07:42:54Z INFO  crabcan] Args { debug: true, command: "/bin/bash", uid: 0, mount_dir: "./mountdir/" }
[2022-01-05T07:42:54Z DEBUG crabcan::container] Linux release: 5.13.0-22-generic
[2022-01-05T07:42:54Z DEBUG crabcan::container] Container sockets: (3, 4)
[2022-01-05T07:42:54Z DEBUG crabcan::hostname] Container hostname is now weird-moon-93
[2022-01-05T07:42:54Z DEBUG crabcan::mounts] Setting mount points ...
[2022-01-05T07:42:54Z DEBUG crabcan::mounts] Mounting temp directory /tmp/crabcan.SmpKbLNhLuJo
[2022-01-05T07:42:54Z DEBUG crabcan::mounts] Pivoting root
[2022-01-05T07:42:54Z DEBUG crabcan::mounts] Unmounting old root
[2022-01-05T07:42:54Z DEBUG crabcan::namespaces] Setting up user namespace with UID 0
[2022-01-05T07:42:54Z DEBUG crabcan::namespaces] Child UID/GID map done, sending signal to child to continue...
[2022-01-05T07:42:54Z DEBUG crabcan::container] Creation finished
[2022-01-05T07:42:54Z INFO crabcan::namespaces] User namespaces set up
[2022-01-05T07:42:54Z DEBUG crabcan::namespaces] Switching to uid 0 / gid 0...
[2022-01-05T07:42:54Z DEBUG crabcan::container] Container child PID: Some(Pid(40182))
[2022-01-05T07:42:54Z DEBUG crabcan::container] Waiting for child (pid 40182) to finish
[2022-01-05T07:42:54Z INFO crabcan::child] Container set up successfully
[2022-01-05T07:42:54Z INFO crabcan::child] Starting container with command /bin/bash and args ["/bin/bash"]
[2022-01-05T07:42:54Z DEBUG crabcan::container] Finished, cleaning & exit
[2022-01-05T07:42:54Z DEBUG crabcan::container] Cleaning container
[2022-01-05T07:42:54Z DEBUG crabcan::errors] Exit without any error, returning 0

看起来一切正常,我们得到了UID0,并且它可以以不同的UID运行,看起来非常完美。

Patch for this step

这一步的代码可以在github litchipi/crabcan branch “step11”中找到.
前一步到这一步的原始补丁可以在此处找到

Linux Capabilties

什么是Linux Capabilties

划分管理员权限

Linux Capabilties 是一种将拥有机器最高权限的超级管理员角色划分为一组有限权限的方式,这些权限控制着各自独立的系统和进程。这些”隔离的权限”包括:

  • 修改文件所有者的权限(CAP_CHOWN
  • 允许改变系统时钟(CAP_SYS_TIME
  • 广泛使用系统资源/忽略资源限制(CAP_SYS_RESOURCE
  • 允许直接访问 /devport、/dev/mem、/dev/kmem 及原始块设备(CAP_SYS_RAWIO
  • 允许提升优先级及设置其他进程的优先级CAP_SYS_NICE
  • 重启系统(CAP_SYS_BOOT
  • 修改另一个进程的功能(CAP_SETPCAP

如果我们不限制容器化的进程的权限,它可能会在配置期间更改我们刚刚设置的所有设置。

如需更多信息、详情、解释以及Capabilties的列表,请查阅Linux手册

获取capabilities

Linux内核从四类capabilities中派生进程的capabilities :

  • Ambient授予进程的Capabilities,可以被任何它创建的子进程继承。简而言之,Ambient Capabilities兼具了Permitted以及Inheritable的功能。

  • Permitted授予进程的Capabilities。任何从permitted丢弃的capabilities 永远不能被重新获取

  • Effective:在给定上下文中线程实际应用的权限。

  • Inheritable:在执行execve时保留的capabilities集。当从文件启动新的进程时,只有在这些capabilities不受文件权限限制的情况下,才将父进程的Inheritable集添加到子进程的Permitted集。

  • Bounding:用于定义在执行execve时要丢弃的功能。

要了解新创建的进程拥有哪种类型的功能,Linux内核使用以下公式:

1
2
3
4
5
6
7
P'(ambient)     = (file is privileged) ? 0 : P(ambient)

P'(permitted) = (P(inheritable) & F(inheritable)) | (F(permitted) & cap_bset) | P'(ambient)

P'(effective) = F(effective) ? P'(permitted) : P'(ambient)

P'(inheritable) = P(inheritable) [i.e., unchanged]

其中:

P表示在执行execve之前线程capability 集的值
P' 表示在执行execve之后线程capability 集的值
F表示文件capability 集
cap_bsetBoundingcapability集的值。

更多信息

Linux的功能手册对这部分内容有非常详细的解释,当寻找关于它的信息时,它是最好的参考资源。

LWN网站在这里列出了所有关于功能的文章。

译者注

关于linux capabilities的内容
这部分内容相当复杂晦涩,想深入了解的话也可以参考这篇博客

选择需要限制的内容

为了处理这些capabilities,需要使用capctl这个库。接下来把它添加到Cargo.toml中:

1
2
3
[dependencies]
# ...
capctl = "0.2.0"

接下来创建一个名为src/capabilities.rs的文件,用于处理和这些capabilitie有关的一切。
首先,我们需要明确我们不希望拥有的东西。
对于我们的容器进程来说,以下是我们将要限制的capabilitie列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use capctl::caps::Cap;
const CAPABILITIES_DROP: [Cap; 21] = [
Cap::AUDIT_CONTROL,
Cap::AUDIT_READ,
Cap::AUDIT_WRITE,
Cap::BLOCK_SUSPEND,
Cap::DAC_READ_SEARCH,
Cap::DAC_OVERRIDE,
Cap::FSETID,
Cap::IPC_LOCK,
Cap::MAC_ADMIN,
Cap::MAC_OVERRIDE,
Cap::MKNOD,
Cap::SETFCAP,
Cap::SYSLOG,
Cap::SYS_ADMIN,
Cap::SYS_BOOT,
Cap::SYS_MODULE,
Cap::SYS_NICE,
Cap::SYS_RAWIO,
Cap::SYS_RESOURCE,
Cap::SYS_TIME,
Cap::WAKE_ALARM
];

关于为什么要放弃这些capabilities的详细解释,可以在原始教程的这个部分找到。

虽然看起来我们放弃了所有的capabilities,但实际上我们仍然保留了一部分。
只要容器进程仍在其命名空间内,这些capabilities仍然可以赋予它一些权力,尽管它们可能对系统构成真正的威胁(例如CAP_DAC_OVERRIDECAP_FOWNER)。

原始教程的这个部分提供了保留capabilities的详细列表,以及为什么可以保留这些capabilities的解释。

限制进程的capabilities

首先在src/errors.rs中创建一种新的错误类型:

1
2
3
4
pub enum Errcode {
// ...
CapabilitiesError(u8),
}

接下来,让我们创建一个名为setcapabilities的函数,用于执行这些限制。
src/capabilities.rs文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
use crate::errors::Errcode;
use capctl::caps::FullCapState;

pub fn setcapabilities() -> Result<(), Errcode> {
log::debug!("Clearing unwanted capabilities ...");
if let Ok(mut caps) = FullCapState::get_current() {
caps.bounding.drop_all(CAPABILITIES_DROP.iter().map(|&cap| cap));
caps.inheritable.drop_all(CAPABILITIES_DROP.iter().map(|&cap| cap));
Ok(())
} else {
Err(Errcode::CapabilitiesError(0))
}
}

我们只需要在配置被器化进程的函数末尾添加这个capabilities限制就可以了。
src/child.rs文件中:

1
2
3
4
5
6
7
use crate::capabilities::setcapabilities;

pub fn setup_container_configurations(config: &ContainerOpts) -> Result<(), Errcode> {
// ...
setcapabilities()?;
Ok(())
}

最后我们吧这个模块引入到src/main,rs

1
2
// ...
mod capabilities;

测试

运行测试后,我们应该可以得到如下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[2022-01-06T07:16:31Z INFO  crabcan] Args { debug: true, command: "/bin/bash", uid: 0, mount_dir: "./mountdir/" }
[2022-01-06T07:16:31Z DEBUG crabcan::container] Linux release: 5.13.0-22-generic
[2022-01-06T07:16:31Z DEBUG crabcan::container] Container sockets: (3, 4)
[2022-01-06T07:16:31Z DEBUG crabcan::hostname] Container hostname is now silent-book-0
[2022-01-06T07:16:31Z DEBUG crabcan::mounts] Setting mount points ...
[2022-01-06T07:16:31Z DEBUG crabcan::mounts] Mounting temp directory /tmp/crabcan.i1L9Dl0nU5ef
[2022-01-06T07:16:31Z DEBUG crabcan::mounts] Pivoting root
[2022-01-06T07:16:31Z DEBUG crabcan::mounts] Unmounting old root
[2022-01-06T07:16:31Z DEBUG crabcan::namespaces] Setting up user namespace with UID 0
[2022-01-06T07:16:31Z DEBUG crabcan::namespaces] Child UID/GID map done, sending signal to child to continue...
[2022-01-06T07:16:31Z DEBUG crabcan::container] Creation finished
[2022-01-06T07:16:31Z DEBUG crabcan::container] Container child PID: Some(Pid(2276734))
[2022-01-06T07:16:31Z DEBUG crabcan::container] Waiting for child (pid 2276734) to finish
[2022-01-06T07:16:31Z INFO crabcan::namespaces] User namespaces set up
[2022-01-06T07:16:31Z DEBUG crabcan::namespaces] Switching to uid 0 / gid 0...
[2022-01-06T07:16:31Z DEBUG crabcan::capabilities] Clearing unwanted capabilities ...
[2022-01-06T07:16:31Z INFO crabcan::child] Container set up successfully
[2022-01-06T07:16:31Z INFO crabcan::child] Starting container with command /bin/bash and args ["/bin/bash"]
[2022-01-06T07:16:31Z DEBUG crabcan::container] Finished, cleaning & exit
[2022-01-06T07:16:31Z DEBUG crabcan::container] Cleaning container
[2022-01-06T07:16:31Z DEBUG crabcan::errors] Exit without any error, returning 0

虽然现在还不能完全确定它是否能正常工作,但是至少没有出现错误。稍后我们将进行一系列的测试,以确保它能按预期工作。

Patch for this step

这一步的代码可以在github litchipi/crabcan branch “step12”中找到.
前一步到这一步的原始补丁可以在此处找到