Docker 入门笔记 9 - Namespace 简介(下)

User Namespace

用户命名空间( CLONE_NEWUSER,在Linux 2.6.23中启动,并在Linux 3.8中完成 )隔离了安全相关的标识符(identifiers)和属性(attributes),包括用户ID、用户组ID、root目录、key(指密钥)以及特殊权限。。

说得通俗一点,一个普通用户的进程通过clone()创建的新进程在新user namespace中可以拥有不同的用户和用户组。这里最有趣的情况是,一个进程可以在用户名空间之外拥有普通的非特权用户ID,同时在名字空间内具有0的用户ID。这意味着该进程对用户命名空间内的操作具有完全的root权限,但是对于命名空间之外的操作没有特权。

User namespace是目前的六个namespace中最后一个支持的,并且直到Linux内核3.8版本的时候还未完全实现(还有部分文件系统不支持)。因为user namespace实际上并不算完全成熟,很多发行版担心安全问题,在编译内核的时候并未开启USER_NS。实际上目前Docker也还不支持user namespace,但是预留了相应接口,相信在不久后就会支持这一特性。所以在进行接下来的代码实验时,请确保你系统的Linux内核版本高于3.8并且内核编译时开启了USER_NS 。

从Linux 3.8开始,未经授权的进程可以创建用户名空间,这为应用程序开辟了一系列有趣的新可能性:因为另外一个没有特权的进程可以在用户名空间内保存根特权,所以非特权应用程序可以访问之前只属于root用户的功能。

Linux 如何处理权限

进一步讨论User Namespace之前,我们先简单了解一下Linux 的权限处理

文件权限

谈到权限问题,首先想到的是文件的权限。Linux一个文件的信息是保存在文件的“inode”数据结构中的。inode里面的第一组数据项叫模(mode)

   file mode
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |o o 0 0 0 0|0 0 0|0 0 0|0 0 0|0 0 0|
         |        |     |     |     |
         |        |     |     |     |-- rwx for other user
         |        |     |     |-- rwx for group
         |        |     |-- rwx for user
         |        |--suid, sgid, sticky bit
         |--type:reguar(-),dir(d),char(c),block(b),pipe(p),link(l),socket(s)

通常在UNIX下可以用ls -l 命令来看到文件权限。 用ls命令所得到的表示法的格式是类似这样的:

-rwxr-xr-x

这表示了mode里的后9个bit,每三个划分为一组,分别为用户权限,用户组权限和其他用户权限这三组。不太常见的是suid, sgid, sticky bit的使用。

如果一个文件被设置了SUID或SGID位,会分别表现在所有者或同组用户权限的可执行位上。例如

  • -rwsr-xr-x 表示SUID和所有者权限中可执行位被设置
  • -rwSr–r– 表示SUID被设置,但所有者权限中可执行位没有被设置
  • -rwxr-sr-x 表示SGID和同组用户权限中可执行位被设置
  • -rw-r-Sr– 表示SGID被设置,但同组用户权限中可执行位没有被设置

给文件加SUID和SUID的命令如下:
- chmod u+s filename 设置SUID位
- chmod u-s filename 去掉SUID设置
- chmod g+s filename 设置SGID位
- chmod g-s filename 去掉SGID设置

下面我们用一个小例子说明suid 和 sgid的作用。

//testUser.c
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>

int main(void)
{
    printf("uid = %d, gid = %d, euid = %d , egid = %d \n", getuid(), getgid(), geteuid(), getegid());
    int fd, size; 
    fd = open("testUser.c", O_WRONLY);

    if (fd < 0) { 
        perror("open file failed");
        exit(-1);
    }   

    printf("fd = %d\n", fd);
    close(fd);
}
# gcc testUser.c  -o testUser
# ls -l
-rwxr-xr-x 1 root root 9024 Jan 11 17:53 testUser*
-rw-r--r-- 1 root root  409 Jan 11 17:53 testUser.c

可以看见上面例子里的两个文件的owner都是root,只有root有写权限。 同时testUser对所有用户都是可执行的,我们分别用root用户和普通用户运行这个程序。

# ./testUser 
uid = 0, gid = 0, euid = 0 , egid = 0 
fd = 3

$ ./testUser 
uid = 1000, gid = 1000, euid = 1000 , egid = 1000 
open file failed: Permission denied

执行程序可以看出,进程的实际用户ID,实际用户组ID都是执行者的id,同时默认的有效用户ID,有效用户组ID分别等于实际用户ID和实际用户组ID。

那么什么用户的实际id和有效id又是什么关系呢?

当一个二进制可执行文件被设置了setuid或setgid属性之后,在所创建的进程内部,有权限执行此文件的用户将会获得这个可执行文件的owner的用户或组权限

我们通过chmod命令修改testUser 文件的setuid bit

# chmod u+s testUser 
# chmod g+s testUser 
# ll
total 24
drwxrwxr-x 2 chic chic 4096 111 17:53 ./
drwxrwxr-x 5 chic chic 4096 15 10:35 ../
-rwsr-sr-x 1 root root 9024 111 17:53 testUser*
-rw-r--r-- 1 root root  409 111 17:53 testUser.c

查看文件可以看到用户的第三位和所属组第三位显示为s。suid 和 sgid bit被设置了。再次以普通用户运行程序,可以看到进程的有效id被设为了 0,并同时拥有了root用户的权限。

$ ./testUser 
uid = 1000, gid = 1000, euid = 0 , egid = 0 
fd = 3

setuid的用途十分广泛,我们常用的切换到其他用户执行命令的su,它所使用的就是setuid系统调用方法将当前执行的具有root权限的用户降级为非root用户。

最后顺便说一下粘住位sticky(t) bit。它是让进程启动后滞留内存不被回收的标志位。在很早之前,这个策略
能够带来很高的效率,但是现在硬件配置都很高了,意义不大。所以它很少在普通文件上使用,反而是使用在了目录文件上。如果一个目录设置了粘住位,则只有对该目录具有写权限的用户在满足下面的条件之一时,才能
删除或则更名该目录下的文件:
1. 拥有此文件
2. 拥有此目录
3. 是超级用户

系统调用权限

以上介绍的权限集中在文件权限上,根据用户的id,系统决定用户有权运行哪写程序,进程启动后系统根据进程的有效用户id决定这个进程的权限。那么系统如何决定一个进程的系统调用权限呢?

传统UNIX的信任状模型非常简单,就是“超级用户对普通用户”模型。在这种模型中,一个进程要么什么都能做,要么几乎什么也不能做,这取决于进程的UID。根据UID系统把进程分为两类:特权进程(有效用户ID是0)和非特权进程(有效用户ID是非0)。特权进程可以通过内核所有的权限检查,而非特权进程的检查则是基于进程的身份(有效ID,有效组及补充组信息)进行。

Linux Capability

从linux内核2.2开始,Linux把超级用户不同单元的权限分开,可以单独的开启和禁止,称为能力(capability)。可以将能力赋给普通的进程,使其可以做root用户可以做的事情。

此时内核在检查进程是否具有某项权限的时候,不再检查该进程的是特权进程还是非特权进程,而是检查该进程是否具有其进行该操作的能力。例如当进程设置系统时间,内核会检查该进程是否有设置系统时间(CAP_SYS_TIME)的能力,而不是检查进程的用户ID是否为0;

当前Linux系统中共有37项特权,可在 “linux/capability.h” 文件中查看

以前专门连接到UID 0的所有特殊访问权限豁免现在都与一个功能相关联。这些例子是:CAP_FOWNER(绕过文件权限检查),CAP_KILL(绕过权限检查发送信号),CAP_NET_RAW(使用原始套接字),CAP_NET_BIND_SERVICE(绑定套接字到Internet域特权端口)。

执行时可以赋予功能(类似于SUID的操作方式),也可以从父进程继承。因此,理论上讲,如果可以提供CAP_NET_BIND_SERVICE功能,则应该有可能以普通用户身份启动端口80上的Apache Web服务器,而该用户根本无权访问。

另一个例子:Wireshark只需要CAP_NET_RAW和CAP_NET_ADMIN功能。以root身份运行主用户界面和协议解析器是非常不可取的,并且作为root用户运行作为Wireshark实际用于嗅探流量的帮助工具的dumpcap稍微不太合适。相反,Debian系统上的首选安装方法是设置dumpcap二进制文件,以便自动获得执行所需的特权,然后将二进制文件的执行限制在某个用户组中。

User Namespace 测试

创建 User namespace

编译运行以下程序

// demo_userns.c
#define _GNU_SOURCE
#include <sys/capability.h>
#include <sys/wait.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define errExit(msg)    do { perror(msg); exit(EXIT_FAILURE); \
                        } while (0)

static int                      /* Startup function for cloned child */
childFunc(void *arg)
{
    cap_t caps;

    for (;;) {
        printf("eUID = %ld;  eGID = %ld;  ",
                (long) geteuid(), (long) getegid());

        caps = cap_get_proc();
        printf("capabilities: %s\n", cap_to_text(caps, NULL));

        if (arg == NULL)
            break;

        sleep(5);
    }

    return 0;
}

#define STACK_SIZE (1024 * 1024)

static char child_stack[STACK_SIZE];    /* Space for child's stack */

int
main(int argc, char *argv[])
{
    pid_t pid;

    /* Create child; child commences execution in childFunc() */

    pid = clone(childFunc, child_stack + STACK_SIZE,    /* Assume stack
                                                           grows downward */
                CLONE_NEWUSER | SIGCHLD, argv[1]);
    if (pid == -1)
        errExit("clone");

    /* Parent falls through to here.  Wait for child. */

    if (waitpid(pid, NULL, 0) == -1)
        errExit("waitpid");

    exit(EXIT_SUCCESS);
}
$ gcc demo_userns.c -lcap -o demo
$ ./demo
eUID = 65534;  eGID = 65534;  capabilities: = cap_chown,...,cap_block_suspend,37+ep

根据运行结果我们发现:
- user namespace被创建后,第一个进程被赋予了该namespace中的全部权限,这样这个init进程就可以完成所有必要的初始化工作,而不会因权限不足而出现错误。

  • 第二个有意思的地方是子进程的用户和组ID。namespace内部看到的UID和GID已经与外部不同了,默认显示为65534 (根据/proc/sys/kernel/overflowuid),表示尚未与外部namespace用户映射。需要将用户名空间内的用户id映射到名称空间外的相应用户id集; 组ID也一样。这样,当名空间内的进程执行一些对名空间外的操作时(比如发送一个信号或操作某个文件),系统可以检查其权限

  • 还有一点虽然不能从输出中看出来,但是值得注意。用户在新namespace中有全部权限,但是他在创建他的父namespace中不含任何权限。就算调用和创建他的进程有全部权限也是如此。所以哪怕是root用户调用了clone()在user namespace中创建出的新用户在外部也没有任何权限。

  • 最后,命名空间可以嵌套; 也就是说,每个用户名空间(除了初始用户名空间)都有一个父用户名空间,并且可以有零个或多个子用户名空间。 用户命名空间的父亲是通过调用clone()或unshare()与CLONE_NEWUSER标志来创建用户命名空间的进程的用户命名空间。

映射user和group ID

通常,创建新用户名称空间后的第一步是定义用于用户的映射和将在该名称空间中创建的进程的组ID。 这是通过将映射信息写入对应于用户命名空间中的一个进程的/ proc/PID/uid_map 和 /proc/PID/gid_map文件来完成的。 (最初,这两个文件是空的。)该信息由一行或多行组成,每行包含三个由空格分隔的值:

ID-inside-ns   ID-outside-ns   length

ID-inside-ns和length值一起定义了名称空间内的一系列ID,这些ID将被映射到名称空间外的相同长度的ID范围。 ID-outside-ns值指定外部范围的起始点。 如何解释ID-outside-ns取决于打开文件/proc/PID/uid_map(或/proc/PID/gid_map)的进程是否与进程PID位于相同的用户名空间中:

如果两个进程位于同一个名称空间中,则ID-outside-ns将被解释为进程PID的父用户名称空间中的用户ID(组ID)。 这里常见的情况是一个进程正在写入自己的映射文件(/proc/self/uid_map或/proc/self/gid_map)。

如果两个进程位于不同的名称空间中,则ID-outside-ns将被解释为进程打开/proc/PID/uid_map(/proc/PID/gid_map)的用户名空间中的用户ID(组ID)。

写入过程然后定义相对于其自己的用户名称空间的映射。
假设我们再次调用我们的demo_userns程序,但这次只用一个命令行参数(任何字符串)。这导致程序循环,每隔几秒不断显示id和capability:

$ ./demo 0
eUID = 65534;  eGID = 65534;  capabilities: = cap_chown,...,cap_block_suspend,37+ep
eUID = 65534;  eGID = 65534;  capabilities: = cap_chown,...,cap_block_suspend,37+ep

现在我们切换到另一个终端窗口 - 运行在另一个名称空间(即运行demo的进程的父用户名空间)的shell进程,并在由demo创建的新用户名空间中为子进程创建用户标识映射:

$ ps -C demo -o 'pid uid comm'  
  PID   UID COMMAND
13582  1000 demo     #parent
13583  1000 demo     #child

$ echo '0 1000 1' > /proc/13583/uid_map
$ echo '0 1000 1' > /proc/13583/gid_map

再次查看demo程序的输出变成了

eUID = 0;  eGID = 0;  capabilities: = cap_chown,...,cap_block_suspend,37+ep

换句话说,父用户名称空间(以前映射到65534)中的用户ID和组id 1000已映射到由demo_userns创建的用户名称空间中的用户ID和组id 0。 从这一点来说,新用户名空间中处理此用户标识的所有操作将看到数字0,而父用户名空间中的对应操作将看到具有用户标识1000的相同进程。

写入 mapping文件的规则

  • 这两个文件只允许由拥有该user namespace中CAP_SETUID权限的进程写入一次,不允许修改。此外,可以写入文件的行数目前限制为五个(将来可能增加的任意限制)
  • 写入的进程必须是该user namespace的父namespace或者子namespace。
  • 第一个字段ID-inside-ns表示新建的user namespace中对应的user/group ID,第二个字段ID-outside-ns表示namespace外部映射的user/group ID。最后一个字段表示映射范围,通常填1,表示只映射一个,如果填大于1的值,则按顺序建立一一映射。

再试试 unshare

接下来我们再尝试用unshare 启动一个bash 并放入一个新的user namespace

$ unshare -U /bin/bash
$ ./testUser 
uid = 65534, gid = 65534, euid = 65534 , egid = 65534 
capabilities: =
open file failed: Permission denied
$ echo $$
13966

很奇怪我们发现运行之前的testUser程序得到的能力集为空。

问题发生在执行bash shell的execve()调用中:

当具有非零用户ID的进程执行execve()时,进程的能力集被清除。为了避免这个问题,运行testUser,有必要在用户名空间内创建一个用户ID映射。

在父user namespace的一个shell中:

# echo '0 1000 1' > /proc/13966/gid_map
# echo '0 1000 1' > /proc/13966/uid_map

回到新bash中重新运行,uid变成了0,能力都有了,但是仍然无法打开之前属主为root的文件

$ ./testUser 
uid = 0, gid = 0, euid = 0 , egid = 0 
capabilities: = cap_chown,...,cap_block_suspend,37+ep
open file failed: Permission denied

此时在父user namespace的一个shell中检查文件

$ ll
total 44
drwxrwxr-x 2 chic chic 4096 Jan 12 16:29 ./
drwxrwxr-x 5 chic chic 4096 Jan  5 10:35 ../
-rwxrwxr-x 1 chic chic 9136 Jan 12 16:29 demo*
-rw-rw-r-- 1 chic chic 1560 Jan 12 16:28 demo_userns.c
-rwxrwxr-x 1 chic chic 9112 Jan 12 16:22 testUser*
-rw-r--r-- 1 root root  409 Jan 11 17:53 testUser.c
-rw-r--r-- 1 chic chic  541 Jan 12 16:21 testUser.cpp

在新user namespace的一个bash中检查文件

# ls -l
total 36
-rwxrwxr-x 1 root   root    9136 112 16:29 demo
-rw-rw-r-- 1 root   root    1560 112 16:28 demo_userns.c
-rwxrwxr-x 1 root   root    9112 112 16:22 testUser
-rw-r--r-- 1 nobody nogroup  409 111 17:53 testUser.c
-rw-r--r-- 1 root   root     541 112 16:21 testUser.cpp

可以发现
- 首先提示符变成了 # ,表明在子空间中用户变成了root
- 从文件上可以看见在子空间里原来属于 uid=1000的文件现在owner变成了 root, 但是在空间之外仍然什么也没改变
- 名空间外属于root(uid=0)的文件,在空间内属于nobody(uid=65534)
- 用户命名空间允许一个进程(在命名空间之外是非特权的)拥有root权限,同时将该特权的范围限制在命名空间

Docker 与 User Namespace

Docker目前尚未使用user namespace,但是他用到了我们在user namespace中提及的Capabilities机制。从内核2.2版本开始,Linux把原来和超级用户相关的高级权限划分成为不同的单元,称为Capability。这样管理员就可以独立对特定的Capability进行使能或禁止。Docker虽然没有使用user namespace,但是他可以禁用容器中不需要的Capability,一次在一定程度上加强容器安全性。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章