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,一次在一定程度上加強容器安全性。

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