Linux Kernel Capability(Linuxカーネルケーパビリティ)に関して(2020年版) - Part2

こんにちは。SIOS OSSエバンジェリスト/セキュリティ担当の面 和毅です。

前回に引き続き、Linux Kernel Capability(Linux カーネル ケーパビリティ)について最新の情報を合わせて動作を見ていきたいと思います。

今回はサンプルプログラムを使って、より実践的にLinux Kernel Capabilityがどの様に変えられるのか・遷移するのかを見ていきたいと思います。


Linux Kernel Capability(Linux カーネル ケーパビリティ)を試す

前回の説明で、ともかく

  1. Linux Kernel Capability(Linux カーネルケーパビリティ)とは何か
  2. Linux Kernel Capabilityを用いて何が出来るか
  3. Linux Kernel Capabilityの値は、Linux Kernel内でどの様なフローで評価されているのか

に関してはわかったかと思います(わからない場合には、再度今回の説明と合わせて見て下さい)。

一方で、前回の説明でLinux Kernel Capabilityのケーパビリティセットに関しては、特に継承/許可/実効の箇所とファイルケーパビリティが加わっているためにわかりにくかったと思います。今回は、この辺のことをもう少し掘り下げて動作を見ていきたいと思います。

Linux Kernel Capabilityを試すには色々な方法が有ります。一般的にはケーパビリティを内部で使用/手放しするようなアプリケーションを自作して、動作を見ていく方法が考えられます(前回見たsystemd-系のアプリのようなやつです)。

Linux Kernel Capabilityは簡単に言うと「プログラムが実行する際に、デフォルトで与えられているケーパビリティから必要なケーパビリティ以外を手放す/最小限のケーパビリティだけを自身で付与する」事で、プログラムの最小特権という概念を実装しようというものになります。そのため、「何かしらのツールで、動作しているプログラムのケーパビリティを操作する」というのは一般的ではなく、「アプリ開発者が、本当に必要なケーパビリティだけをプログラムが持つように操作する」という考え方になります。

ここでのサンプルプログラムも、「ケーパビリティを色々減らして(可能な場合には増やして)いく行為が、そのプログラム(プロセス)にどの様な影響を与えるか」を見る為の物になっています。

Linux Kernel Capabilityを試す(サンプルプログラム)

今回の実験環境としては、前回と引き続きDebian 10(buster)を使用します。

  1. サンプルプログラムの準備
  2. 今回テスト用のサンプルプログラム(cap_test.c)として、cap_set_procのマニュアルページcap_set_flagのマニュアルページを参考にして、下記のものを作成しました。

    
          1 #include <stdio.h>
          2 #include <stdlib.h>
          3 #include <string.h>
          4 #include <sys/capability.h>
          5 
          6 
          7 int main()
          8 {
          9         cap_t caps;
         10         cap_value_t cap_list[1];
         11 
         12         caps = cap_get_proc(); /* Fetch process capabilities. */
         13         cap_list[0] = CAP_NET_BIND_SERVICE;
         14         cap_list[1] = CAP_SYS_TIME;
         15         cap_set_flag(caps, CAP_EFFECTIVE, 2, cap_list, CAP_CLEAR);
         16         cap_set_flag(caps, CAP_PERMITTED, 2, cap_list, CAP_CLEAR);
         17         cap_set_flag(caps, CAP_INHERITABLE, 2, cap_list, CAP_SET);
         18 
         19         if (cap_set_proc(caps) == -1) {
         20                 printf("cap_free() Failed.\n");
         21                 exit(1);
         22         }
         23 
         24         if (cap_free(caps) == -1) {
         25                 printf("cap_free() Failed.\n");
         26                 exit(1);
         27         }
         28 
         29         /* Fork Process */
         30         pid_t pid=fork();
         31         if (pid==0) {
         32                 static char *argv[]={NULL};
         33                 printf("Sleeping 120 second.\n");
         34                 sleep(120);
         35                 /* Exec /bin/bash */
         36                 printf("Exec /bin/bash.\n");
         37                 execv("/bin/bash",argv);
         38                 exit;
         39                 }
         40         else {
         41                 waitpid(pid,0,0);
         42         }
         43         return 0;
         44 }
    

    このプログラムの簡単な解説ですが

    1. 10行目でcap_value_tとして操作するケーパビリティの数を配列で定義しています。
    2. 13行目から14行目までで、cap_listの配列の中に、操作したいケーパビリティ(CAP_NET_BIND_SERVICE, CAP_SYS_TIME)を定義します。

    3. 15行目から17行目までで、それぞれ
      • CAP_EFFECTIVEセット(実効ケーパビリティセット)から削除
      • CAP_PERMITTEDセット(許可ケーパビリティセット)から削除
      • CAP_INHERITABLEセット(継承ケーパビリティセット)に追加

      のケーパビリティセットに対して、上で代入したcap_listの配列に入っているケーパビリティを操作しています。

    4. 30行目で自身をFork(フォーク)して、34行目で120秒待ちます。
    5. その後、37行目で/bin/bashをexecvしています。

  3. 実験 1
  4. このプログラムのケーパビリティがどの様に遷移しているかを、実際にコンパイルして見てみましょう。

    以下、まずはrootアカウントでプログラムをコンパイルして実行してみます。

    1. まず、rootアカウントになってbashのターミナルを開いてみて下さい。ここで、現在のシェル(bash)のケーパビリティセットがどうなっているかを確認してみましょう。/proc/self/status中の"Cap**"を見てみます。

      
      root@local:~/work# grep Cap /proc/self/status 
      CapInh:	0000000000000000
      CapPrm:	0000003fffffffff
      CapEff:	0000003fffffffff
      CapBnd:	0000003fffffffff
      CapAmb:	0000000000000000
      root@local:~/work# 
      

      "0000003fffffffff"は、前回見た値ですので、ここでは許可セット・実効セット・バウンディングセットのいずれも、全てのケーパビリティを持っていることがわかります。

    2. 次に、今回のテスト用のプログラムを"gcc -o cap_test cap_test.c -ldap"としてコンパイルします。"-lcap"オプションをgccに付ける所がポイントです。
      
      root@local:~/work# gcc -o cap_test cap_test.c -lcap
      cap_test.c: In function ‘main’:
      cap_test.c:30:12: warning: implicit declaration of function ‘fork’ [-Wimplicit-function-declaration]
        pid_t pid=fork();
                  ^~~~
      --途中略--
      root@local:~/work#
      

      となり、幾つかワーニングが出ますが、gccでコンパイル出来ました(バイナリはcap_test)。

    3. 上でコンパイルできたバイナリ"cap_test"を実行します。120秒間スリープします。
    4. 
      root@local:~/work# ./cap_test 
      Sleeping 120 second.
      
      
    5. プロセスがどう立ち上がっているかを確認したいので「pstree -p」でプロセスのツリー構造をPIDと同時に表示させてみます。
      
      root@local:~# pstree -p
      systemd(1)─┬─agetty(381)
                 ├─dbus-daemon(375)
      --省略--
                 ├─sshd(385)─┬─sshd(596)───sshd(615)───bash(616)───sudo(630)───su(632)───bash(633)───cap_test(945)───cap_test(946)
                 │           └─sshd(837)───sshd(843)───bash(844)───sudo(853)───su(854)───bash(855)───pstree(948)
      

      となり、bash(PID=633)がキックしたプログラムcap_test(PID=945)がプロセスとなり常駐、更にcap_test(946)とforkしていることがわかります。

    6. PID=945のcap_testが持っているケーパビリティセットですが
      
      root@local:~# grep Cap /proc/945/status
      CapInh:	0000000002000400
      CapPrm:	0000003ffdfffbff
      CapEff:	0000003ffdfffbff
      CapBnd:	0000003fffffffff
      CapAmb:	0000000000000000
      

      となっています。

    7. cap_test(PID=945)からforkしたcap_test(PID=946)の持っているケーパビリティセットですが
      
      root@local:~# grep Cap /proc/946/status
      CapInh:	0000000002000400
      CapPrm:	0000003ffdfffbff
      CapEff:	0000003ffdfffbff
      CapBnd:	0000003fffffffff
      CapAmb:	0000000000000000
      

      となっており、親のプロセス(cap_test(PID=945))が持っているケーパビリティセットをforkした子も引継いでいることが解かります。

    8. 120秒立つと、/bin/bashをexecします。この時に、同じ様に"pstree -p"を実施すると
      
                 ├─sshd(385)─┬─sshd(596)───sshd(615)───bash(616)───sudo(630)───su(632)───bash(633)───cap_test(945)───bash(946)
                 │           └─sshd(837)───sshd(843)───bash(844)───sudo(853)───su(854)───bash(855)
      

      となり、cap_test(PID=945)からforkしたcap_test(PID=946)が/bin/bashをexecveしてbash(PID=946)となったことがわかります。

    9. この時、execveしたbash(PID=946)のケーパビリティセットは
      
      root@local:~/work# grep Cap /proc/946/status
      CapInh:	0000000002000400
      CapPrm:	0000003fffffffff
      CapEff:	0000003fffffffff
      CapBnd:	0000003fffffffff
      CapAmb:	0000000000000000
      

      となっており、CapInh(継承)は引き継いでいるものの、CapPrm(許可)、CapEff(実効)がforkしているときと変わっていることがわかります。

  5. 実験1. 考察
  6. 前章の結果を考察してみましょう。

    1. プロセスがForkした時には、ケーパビリティセットはそのまま引き継いでいます。
    2. プロセスがexecveした時です。前回の「ケーパビリティセットと、実効ケーパビリティの計算方法」を参考にしてみてみましょう。
      1. 「継承可能(inheritable): execve()前後で保持されるケーパビリティセット」となっていますので、execve()前後で"CapInh: 0000000002000400"として保持されています。
      2. 条件演算子に関してはMicrosoftのC#に関する条件演算子の説明がわかりやすいです。
        
                   P'(ambient)     = (file is privileged) ? 0 : P(ambient)
        
                   P'(permitted)   = (P(inheritable) & F(inheritable)) |
                                     (F(permitted) & P(bounding)) | P'(ambient)
        
                   P'(effective)   = F(effective) ? P'(permitted) : P'(ambient)
        
                   P'(inheritable) = P(inheritable)    [i.e., unchanged] [つまり、変更されない]
        
                   P'(bounding)    = P(bounding)       [i.e., unchanged] [つまり、変更されない]
        
        に関しては、
        
        P'(ambient)     = 実行ファイルが特権か?そうなら0,そうでなければP(ambient)
        
        P'(permitted)   = (P(inheritable) & F(inheritable)) | (F(permitted) & P(bounding)) | P'(ambient) になるので、P(inheritable)とF(inheritable)が共に1の時か、F(permitted)とP(bounding)が共に1か、P'(ambient) で許可されている時は1
        
        P'(effective)   = F(effective)がセットされているか? そうならP'(permitted)、そうでなければP'(ambient)
        

        となります。

      3. /bin/bashに関しては、
        
        root@local:~# getcap /bin/bash
        root@local:~# 
        

        となり、F(inheritable)及びF(permitted)に関しては設定されていません。

      4. 今回の場合、rootでcap_testを起動していて、それがexecveしてbash(PID=946)になります。そのため、前回説明した「プログラムをrootで起動した場合のケーパビリティ」により、それぞれのファイルケーパビリティは以下のようになります。
        • F(Inheritable) = 1(全て有効)
        • F(Permitted) = 1(全て有効)
        • F(Effective) = 1(有効)

        従って、execveされたbash(PID=946)に関しては

        
        P'(ambient)   = 0
        P'(permitted) = (P(inheritable) & F(inheritable)) | (F(permitted) & P(bounding)) | P'(ambient)
                      = P(inheritable) | P(bounding) | 0 = 3fffffffff (CapBndが全てのビットが立っているため)
        P(effective)  = "F(effective)=TrueならP'(permitted), そうでなければP'(ambient)"
                      = P'(permitted) = 3fffffffff
        P'(inheritable) = P(inheritable) = 2000400
        P'(bounding) = P(bounding) = 3fffffffff
        
        

        と計算され、その様なケーパビリティセットがそれぞれ付加されていることがわかります。

      5. 
        

  7. 実験2
  8. 次に、PermittedとEffectiveの関係を見てみたいので、cap_test.cを下記のように少々変更します。

    
          1 #include <stdio.h>
          2 #include <stdlib.h>
          3 #include <string.h>
          4 #include <sys/capability.h>
          5 
          6 
          7 int main()
          8 {
          9         cap_t caps;
         10         cap_value_t cap_add_list[2], cap_remove_list[2];
         11 
         12         caps = cap_get_proc(); /* Fetch process capabilities. */
         13         cap_remove_list[0] = CAP_NET_BIND_SERVICE;
         14         cap_remove_list[1] = CAP_SYS_TIME;
         15         cap_remove_list[2] = CAP_CHOWN;
         16 
         17         cap_add_list[0] = CAP_NET_BIND_SERVICE;
         18 
         19         cap_set_flag(caps, CAP_EFFECTIVE, 2, cap_remove_list, CAP_CLEAR);
         20         cap_set_flag(caps, CAP_PERMITTED, 2, cap_remove_list, CAP_CLEAR);
         21         cap_set_flag(caps, CAP_INHERITABLE, 1, cap_add_list, CAP_SET);
         22 
         23         cap_set_flag(caps, CAP_EFFECTIVE, 1, cap_add_list, CAP_SET);
         24 
         25         if (cap_set_proc(caps) == -1) {
         26                 printf("cap_set_proc() Failed.\n");
         27                 exit(1);
         28         }
         29 
         30         if (cap_free(caps) == -1) {
         31                 printf("cap_free() Failed.\n");
         32                 exit(1);
         33         }
         34 
         35         /* Fork Process */
         36         pid_t pid=fork();
         37         if (pid==0) {
         38                 static char *argv[]={NULL};
         39                 printf("Sleeping 120 second.\n");
         40                 sleep(120);
         41                 /* Exec /bin/bash */
         42                 printf("Exec /bin/bash.\n");
         43                 execv("/bin/bash",argv);
         44                 exit;
         45                 }
         46         else {
         47                 waitpid(pid,0,0);
         48         }
         49         return 0;
         50 }
    

    まず、cap_remove_list[] = {CAP_NET_BIND_SERVICE, CAP_SYS_TIME, CAP_CHOWN}をcap_testプロセスのCAP_EFFECTIVE, CAP_PERMITTEDから消し去り、

    cap_add_list[] = {CAP_NET_BIND_SERVICE} のみをCAP_EFFECTIVEに加えようというものです。

    イメージにすると、下記のような絵になります。

  9. 実験2. 考察
  10. こちらのプログラムを"gcc -o cap_test cap_test.c -lcap"としてコンパイルして実行すると、

    
    root@local:~/work# ./cap_test 
    cap_set_proc() Failed.
    root@local:~/work# 
    

    となり、cap_remove_list[] = {CAP_NET_BIND_SERVICE, CAP_SYS_TIME, CAP_CHOWN}をcap_testプロセスのCAP_EFFECTIVE, CAP_PERMITTEDから消し去ることまでは出来ますが、新たにcap_add_list[] = {CAP_NET_BIND_SERVICE} のみをCAP_EFFECTIVEに加えるのには失敗します。

    これは当たり前のことで、CAP_PERMITTEDで許可されているケーパビリティのみがCAP_EFFECTIVEに追加できる、というものです。

    逆に、先程のソースコードで23行目を

    
         23         cap_set_flag(caps, CAP_EFFECTIVE, 1, cap_add_list, CAP_SET);
    ->
         23         cap_set_flag(caps, CAP_PERMITTED, 1, cap_add_list, CAP_SET);
    
    

    としてCAP_PERMITTEDの方に追加する場合には実行でき、結果は

    
    root@local:~/work# grep Cap /proc/3929/status
    CapInh:	0000000000000400
    CapPrm:	0000003ffdffffff
    CapEff:	0000003ffdfffbff
    CapBnd:	0000003fffffffff
    CapAmb:	0000000000000000
    root@local:~/work# 
    

    となります。つまり、CAP_PERMITTED > CAP_EFFECTIVEが常に成り立つというわけです。

  11. 実験3(ファイルケーパビリティ)
  12. 今度は趣を変えて、ファイルケーパビリティの方を見てみましょう。

    ファイルケーパビリティは、ファイルにケーパビリティを与えることで、そのプログラムを実行した際のプロセスにもケーパビリティを与えようというものになります。

    例えば、/usr/bin/vim.tinyを/var/tmp以下にコピーして、/var/tmp/vim.tinyとしてケーパビリティを与えてみたいと思います。ファイルケーパビリティを操作するには、rootアカウントでsetcapコマンドを使用します。

    
    root@local:~/work# setcap 'cap_dac_override+p' /var/tmp/vim.tiny 
    

    として/var/tmp/vim.tinyに"CAP_DAC_OVERRIDE"をファイルケーパビリティのPermitted(許可)として与えることが出来ます。

    この/var/tmp/vim.tinyを一般ユーザが実行すると

    
    root@local:~/work# ps aux|grep vim
    sios       4186  0.0  0.1   7924  3944 pts/1    S+   17:59   0:00 /var/tmp/vim.tiny
    root       4195  0.0  0.0   4264   884 pts/0    S+   18:00   0:00 grep vim
    root@local:~/work# grep Cap /proc/4186/status
    CapInh:	0000000000000000
    CapPrm:	0000000000000002
    CapEff:	0000000000000000
    CapBnd:	0000003fffffffff
    CapAmb:	0000000000000000
    

    と、一般ユーザのプロセスケーパビリティのPermitted(許可)セットに、CAP_DAC_OVERRIDEを加えることが可能です。

    この際、プロセスのケーパビリティの計算ですが、まず一般ユーザは

    
    sios       4207  0.0  0.1   7924  3836 pts/1    S+   18:04   0:00 /bin/bash
    root@local:~/work# cat /proc/4207/status |grep Cap
    CapInh:	0000000000000000
    CapPrm:	0000000000000000
    CapEff:	0000000000000000
    CapBnd:	0000003fffffffff
    CapAmb:	0000000000000000
    

    となっており、ケーパビリティバウンディングセット(3fffffffff: つまり全部1)以外のセットは0であることが前提です。

    従って、プロセスのケーパビリティの計算は

    
    P'(ambient)     = 実行ファイルが特権か?-> P(ambient): 変わらずに00000
    
    P'(permitted)   = P(inheritable)とF(inheritable)が共に1の時か、F(permitted)とP(bounding)が共に1か、P'(ambient) で許可されている時は1。今回は、F(permitted)とP(bounding)のANDを取った値(0000000002)がセットされる
    
    P'(effective)   = F(effective)がセットされていないため、P'(ambient): 00000
    

    となっています。

  13. 実験4(一般ユーザでのケーパビリティ操作)
  14. 実験2のプログラムを、今度は一般ユーザで(/home/sios/work/cap_testとして)動作させてみます。するとすぐに

    
    sios@local:~/work$ /home/sios/work/cap_test
    cap_set_proc() Failed.
    sios@local:~/work$ 
    

    としてフェイルしてしまいます。これは、一般ユーザにはcap_set_proc()の権限がないためです。

    これは、以下のようにCAP_SETPCAPのケーパビリティを一般ユーザが動作させるプログラムに与えれば解決します。

    
    setcap 'cap_setpcap+ep' /home/sios/work/cap_test
    

    ちなみにこの際に、/home/sios/work/cap_testの"ファイルの実効ケーパビリティセット"だけを与えようとすると

    
    root@local:~# setcap 'cap_setpcap+e' /home/sios/work/cap_test
    root@local:~# getcap /home/sios/work/cap_test
    /home/sios/work/cap_test =
    

    となります。これは、ファイルの許可ケーパビリティに入ってないケーパビリティを与えようとしたためです。

    "cap_setpcap"のep(ファイルケーパビリティのEffecitve, Permitted)に「CAP_SETPCAP」を与えて実行すると

    
    root@local:~# ps aux|grep cap_test
    sios       4327  0.0  0.0   2320   760 pts/0    S+   18:18   0:00 /home/sios/work/cap_test
    sios       4328  0.0  0.0   2320    92 pts/0    S+   18:18   0:00 /home/sios/work/cap_test
    root       4330  0.0  0.0   4264   888 pts/1    S+   18:18   0:00 grep cap_test
    root@local:~# grep Cap /proc/4327/status
    CapInh:	0000000000000400
    CapPrm:	0000000000000100
    CapEff:	0000000000000100
    CapBnd:	0000003fffffffff
    CapAmb:	0000000000000000
    root@local:~# 
    

    の様に、ケーパビリティがセットされていることがわかります。この際、一般ユーザはプロセスの許可ケーパビリティセット、実効ケーパビリティセット両方共0なため、CAP_CLEARでケーパビリティをクリアしても0のままで、CAP_SETをされたケーパビリティが加わっていることに注意して下さい。

Ambientケーパビリティ

Ambientケーパビリティですが、簡単に言うと「一般ユーザに対してでも、子プロセスに継承したいケーパビリティをセットしておいてexecするだけで、ファイルケーパビリティ等を設定していなくても継承できてしまう」ケーパビ>リティセットになります。

簡単にテスト用のプログラムを見てみましょう。ambient_test.c がAmbientをテストする際のサンプルプログラムになります。

このプログラムですが、一般ユーザで保存して"gcc -o ambient_test ambient_test.o -lcap-ng"と"-lcap-ng"オプションを付けてコンパイルする必要があります。また、CAP_NET_RAW, CAP_NET_ADMIN, CAP_SYS_NICEを必要とします>ので、rootアカウントで


setcap cap_net_raw,cap_net_admin,cap_sys_nice+p ambient_test

とファイルケーパビリティを設定してあげる必要があります(このプログラムのテストのためです、念の為)。

コンパイル後に一般ユーザで


sios@localhost:~/work$ ./ambient_test /bin/bash
Ambient_test forking shell
sios@localhost:~/work$

と実行してあげると、引数で指定したプログラム(/bin/bash)をexecします。この際に、ambient_test自体に"803000"のAmbientケーパビリティをセットします。

一般ユーザで実行された/bin/bashは


sios       2143  0.1  0.2   6352  4988 pts/1    S+   21:39   0:00 /bin/bash
root@localhost:~/work# cat /proc/2143/status |grep Cap
CapInh: 0000000000803000
CapPrm: 0000000000803000
CapEff: 0000000000803000
CapBnd: 0000003fffffffff
CapAmb: 0000000000803000

と、CapAmbと同じケーパビリティをPrm/Eff/Inhに持つことがわかります。

まとめ

今回で、一通りLinux Kernel Capability(Linux カーネル ケーパビリティ)の事は説明が終わりました。Linuxカーネルケーパビリティを用いると、本来特権の全ての権限を必要としないプログラムに対して、制限された(本当に必要な)権限だけを分割して与えることが出来ます。ケーパビリティはOSレベルでの不要な権限を排除するという意味で、非常に使えるものですので、ご存じない方は是非この機会に触れて頂ければと思います。


セキュリティ系連載案内

セミナー情報1

コンピュータセキュリティシンポジウム(CSS)2020併設のワークショップ、 OSSセキュリティ技術ワークショップ(OWS) 2020の企画講演セッション及び、 一般論文セッションの発表募集をさせていただきます。

今年度はオンラインでの開催となります。奮ってのご投稿、お待ちしております。

https://www.iwsec.org/ows/2020/


OSSに関するお困りごとは サイオス OSSよろず相談室まで

サイオスOSSよろず相談室 では、OSSを利用する中で発生する問題に対し、長年培ってきた技術力・サポート力をもって企業のOSS活用を強力に支援します。Red Hat Enterprise Linux のほか、CentOS をご利用されている環境でのサポートも提供いたします。

前へ

Linux Kernel Capability(Linuxカーネルケーパビリティ)に関して(2020年版) - Part1

次へ

Linux Kernel Capability(Linuxカーネルケーパビリティ)に関して(2020年版)