本連載では、エンタープライズシステムでコンテナ/Kubernetesを活用した業務システムを開発・運用するエンジニアに向けて、知っておくべきKubernetesセキュリティの基礎知識、Microsoftが提供するパブリッククラウド「Azure」を使ったクラウドでのKubernetesセキュリティ対策のポイント、気を付けておきたい注意点などの実践的なノウハウを紹介します。

今回より、コンテナセキュリティの基礎となる、以下のコンテナ技術について3回に分けて説明します。

カーネルの分離 namespace

リソースの分離 cgroup

namespaceとcgroupで下から見るKubernetes

初回となる今回は、カーネルの分離を実現するために利用されているnamespaceを取り上げます。

コンテナはOSを論理的に分割し、隔離された空間でアプリケーションを動作させる技術です。したがって、よく言われるように仮想マシンの場合はハイパーバイザーが仮想的なハードウェアを提供していて、その上で仮想マシンのOSがさらにその上でアプリケーションが動きますが、コンテナの場合はカーネルの機能によってプロセスを隔離しているだけという違いがあります。

コンテナ内でアプリケーションのプロセスだけを動かすことができ、ホストのOSからはプロセスを通常どおり起動するのと違わないため、仮想マシンに比べると圧倒的に速く起動します。

namespaceとは

「OSを論理的に分割し隔離された空間でアプリケーションを動作させる」際に中核となっているのがnamespaceです。ここではUbuntu 20.04でnamespaceの挙動を確認していくことにします。

namespaceには以下の種類があります。

PID プロセス群

Mount マウントポイント

Network ネットワーク関連

UTS ホスト名など

IPC プロセス間通信

User ユーザー/グループ、権限

PID namespaceの例


では、namespaceがどのような動きをするのかを確認していきましょう。主に使うのは以下の4つのコマンドです。

unshareコマンド: Namespaceを作成し指定したコマンドを実行

nsenterコマンド: 既存のNamespaceに関連付けて指定したコマンドを実行

ip netnsコマンド: Network Namespaceの操作

lsnsコマンド: namespaceの一覧を表示

まずは、DockerやKubernetesを動かしていない素の状態を確認します。使うのはlsnsコマンドです。

# lsns

NS TYPE NPROCS PID USER COMMAND

4026531835 cgroup 97 1 root /sbin/init

4026531836 pid 97 1 root /sbin/init

4026531837 user 97 1 root /sbin/init

4026531838 uts 94 1 root /sbin/init

4026531839 ipc 97 1 root /sbin/init

4026531840 mnt 91 1 root /sbin/init

4026531860 mnt 1 15 root kdevtmpfs

4026531992 net 97 1 root /sbin/init

4026532193 mnt 1 367 root /lib/systemd/systemd-udevd

4026532194 uts 1 367 root /lib/systemd/systemd-udevd

4026532195 mnt 1 494 systemd-timesync /lib/systemd/systemd-timesyncd

4026532196 uts 1 494 systemd-timesync /lib/systemd/systemd-timesyncd

4026532197 mnt 1 641 systemd-network /lib/systemd/systemd-networkd

4026532198 mnt 1 643 systemd-resolve /lib/systemd/systemd-resolved

4026532254 mnt 1 675 root /lib/systemd/systemd-logind

4026532255 uts 1 675 root /lib/systemd/systemd-logind

initやsystemdでnamespaceが使われているのが分かります。initで使われているnamespaceを調べる場合は、以下のように/proc/initのPID以下を見ます。

# ll /proc/1/ns

total 0

dr-x--x--x 2 root root 0 Nov 7 01:05 ./

dr-xr-xr-x 9 root root 0 Nov 7 01:05 ../

lrwxrwxrwx 1 root root 0 Nov 7 01:10 cgroup -> 'cgroup:[4026531835]'

lrwxrwxrwx 1 root root 0 Nov 7 01:10 ipc -> 'ipc:[4026531839]'

lrwxrwxrwx 1 root root 0 Nov 7 01:05 mnt -> 'mnt:[4026531840]'

lrwxrwxrwx 1 root root 0 Nov 7 01:10 net -> 'net:[4026531992]'

lrwxrwxrwx 1 root root 0 Nov 7 01:10 pid -> 'pid:[4026531836]'

lrwxrwxrwx 1 root root 0 Nov 7 01:11 pid_for_children -> 'pid:[4026531836]'

lrwxrwxrwx 1 root root 0 Nov 7 01:10 user -> 'user:[4026531837]'

lrwxrwxrwx 1 root root 0 Nov 7 01:10 uts -> 'uts:[4026531838]'

nsenterを使うと、特定のnamespaceの中に入ることができます。以下のような感じで使います。1は対象にするプロセスのPIDを指定していて、ここでは1なのでinitです。--allは/proc/[pid]/ns/*以下のすべてという意味です。種類を指定したい場合は「--net」や「--pid」を指定します。

nsenter --target 1 --all bash

中に入って何かを確認していくのは少し後回しにして、それぞれのNamespaceを一つずつ確認していきましょう。



PID プロセス群

基本となるプロセスの分離から確認していきます。PIDを分離してps auxを実行する場合のコマンドラインは以下のようになります。

sudo su -l

mkdir -p /opt/container

cd /opt/container

cp -r /usr/bin /usr/lib /usr/lib64 .

unshare -pf chroot . /bin/bash

mkdir -p /proc /dev/pts

mount -t proc proc /proc

mount -t devpts devpts /dev/pts

ps aux

実行結果は以下です。

USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND

0 1 0.0 0.0 4332 3696 ? S 07:22 0:00 bash

0 6 0.0 0.0 6120 3080 ? R+ 07:24 0:00 ps aux

unshare -p bashだけの場合は、bash: fork: Cannot allocate memoryというメッセージが表示されます。このエラーの一番簡単な解決策は、-fオプションをつけることです。

chroot .がないとps auxと打つとホストのプロセスが出てきてしまいます。psが/proc/から読み取った情報を表示するものだからです。chroot .を付けることで、/opt/containerがルート扱いになり、ホストのパスが見えなくなります。psも含めてコマンドが使えなくなってしまうので、cp -r /usr/bin /usr/lib /usr/lib64 .でコマンドをコピーしています。

mkdir -p /proc /dev/ptsとmount -t proc proc /procとmount -t devpts devpts /dev/ptsですが、procはプロセスに関するカーネル情報にアクセスする手段を提供する疑似ファイルシステムで、chroot先のために用意しています。devptsも疑似ファイルシステムでこちらはターミナルを提供するためのものです。

このやり方ではホスト側でmountしてしまっているので、ホストに影響が出てしまいます。--mount-procを付けると、mount名前空間を分離しつつ /proc を再マウントしてくれるのでお手軽です。

unshare -pf --mount-proc /bin/bash

ps aux

USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND

root 1 0.0 0.1 7208 3620 pts/0 S 02:22 0:00 /bin/bash

root 9 0.0 0.1 8864 3076 pts/0 R+ 02:22 0:00 ps aux

Mount マウントポイント

次にマウントポイントの分離について確認していきましょう。以下のコマンドを実行してみてください。

unshare -m /bin/bash

mount --bind /etc/hosts /root/hosts

mount | grep hosts

結果は以下のようになります。unshareの外では影響を受けないので何も表示されません。

/dev/vda1 on /root/hosts type ext4 (rw,relatime)

これだけではあまり楽しくないので、ルートファイルシステムを入れ替えてみましょう。

ルートファイルシステムの中身を用意する

まずは、ルートファイルシステムの中身を用意します。中身を用意する方法は以下のようにいくつかあります。

mmdebstrapやyum install -y --releasever=7 --installroot="https://news.mynavi.jp/installpath" @base @coreを使って書き出す

alpineなどのイメージのダウンロードしてきて展開する

DockerのImageを展開する

ここではmmdebstrapを使ってDebian GNU/Linuxのイメージの中身を書き出す方法をやってみます。mmdebstrapはdebootstrapを高速化し、rootでなくても実行できるように改良されるなど、多様化したニーズに対応しています。

mmdebstrapで指定できるのはDebian GNU/Linuxのパッケージですので、Ubuntuから実行する場合はキーリストをダウンロードして--aptopt='Dir::Etc::Trusted "/usr/share/keyrings/debian-archive-keyring.gpg"'のように指定する必要があります。

apt-get update

apt-get install mmdebstrap

apt-get install debian-archive-keyring

mkdir ./images

cd ./images

mmdebstrap stretch ./ --aptopt='Dir::Etc::Trusted "/usr/share/keyrings/debian-archive-keyring.gpg"'

中身をみてみましょう。一通りそろっていることが分かります。

# ll

total 84

drwxr-xr-x 21 root root 4096 Nov 7 08:26 ./

drwx------ 5 root root 4096 Nov 7 08:23 ../

drwxr-xr-x 2 root root 4096 Nov 7 08:27 bin/

drwxr-xr-x 2 root root 4096 Jul 10 2020 boot/

drwxr-xr-x 4 root root 4096 Jul 10 2020 dev/

drwxr-xr-x 50 root root 4096 Nov 7 08:27 etc/

drwxr-xr-x 2 root root 4096 Jul 10 2020 home/

drwxr-xr-x 10 root root 4096 Nov 7 08:27 lib/

drwxr-xr-x 2 root root 4096 Nov 7 08:26 lib64/

drwxr-xr-x 2 root root 4096 Nov 7 08:26 media/

drwxr-xr-x 2 root root 4096 Nov 7 08:26 mnt/

drwxr-xr-x 2 root root 4096 Nov 7 08:26 opt/

drwxr-xr-x 2 root root 4096 Jul 10 2020 proc/

drwx------ 2 root root 4096 Nov 7 08:26 root/

drwxr-xr-x 3 root root 4096 Nov 7 08:26 run/

drwxr-xr-x 2 root root 4096 Nov 7 08:27 sbin/

drwxr-xr-x 2 root root 4096 Nov 7 08:26 srv/

drwxr-xr-x 2 root root 4096 Jul 10 2020 sys/

drwxrwxrwt 2 root root 4096 Nov 7 08:27 tmp/

drwxr-xr-x 10 root root 4096 Nov 7 08:26 usr/

drwxr-xr-x 11 root root 4096 Nov 7 08:26 var/



unshareとpivot_rootでルートファイルシステムを入れ替える

PIDのnamespaceを確認する中で、ルートを変更する方法としてchrootを使いました。chrootはchrootするCapability(cap_sys_chroot)を奪わないとchrootの外に出ることができてしまいます。

ここではpivot_root を試してみます。pivot_rootを使うとプロセスのルートファイルシステムを入れ替えることができます。pivot_rootは引数に「新しいルートファイルシステム」と「古いルートファイルシステムのマウント先」を指定するのですが、以下の制約があります。

「新しいルートファイルシステム」と「古いルートファイルシステムのマウント先」は「変更前のルートファイルシステム」と同じマウントポイントにあってはいけない

「古いルートファイルシステムのマウント先」は 「新しいルートファイルシステム」の配下になければならない

他のファイルシステムを 「古いルートファイルシステムのマウント先」 にマウントできない

これらの制約を満たしつつ、ファイルシステムを入れ替えるために以下の操作をします。

1の条件を満たすためにmount --bind $NEW_ROOT $NEW_ROOTでマウントポイントを分離する

2の条件を満たすために「古いルートファイルシステムのマウント先」を$NEW_ROOT/.put_oldにする

pivot_root後に「古いルートファイルシステム」を外す

「古いルートファイルシステム」で起動した/bin/shがいなくなってしまったので、「新しいルートファイルシステム」の/bin/shを起動します。コマンドで表すと、以下のようになります。

NEW_ROOT=/root/images

mkdir $NEW_ROOT/.put_old

mkdir $NEW_ROOT/proc

unshare -mpfr /bin/sh -c " \

mount --bind $NEW_ROOT $NEW_ROOT && \

mount -t proc proc $NEW_ROOT/proc && \

pivot_root $NEW_ROOT $NEW_ROOT/.put_old && \

umount -l /.put_old && \

cd / && \

exec /bin/sh

"

"

UTS ホスト名

UTSの分離は特にややこしいことはありません。以下のように操作してみると独自のホスト名を持たせることができます。

unshare -u /bin/bash

hostname # 名前空間を切る前のホスト名がそのまま表示されます

hostname container

hostname # container

exit

hostname # 名前空間内の操作の影響は受けていません

IPC プロセス間通信

System V プロセス間通信オブジェクトとPOSIXメッセージキューが対象になっていています。POSIXメッセージキューを試してみましょう。試すためのPythonのコードは以下を使います。

まず、posix_ipcを入れます。

pip install posix_ipc

送信側です。1秒スリープしながらキューへの送信を続けます。

import posix_ipc as ipc

from time import sleep

mq = ipc.MessageQueue('/testq', ipc.O_CREAT)

cnt = 0

while True:

print(cnt)

mes = mq.send(str(cnt))

sleep(1)

cnt+=1

次に受信側です。

import posix_ipc as ipc

mq = ipc.MessageQueue('/testq')

while True:

mes = mq.receive()

print(mes[0])

screenなどを使って以下を同時に動かしてみましょう。

送信1:namespaceなし。

python3 ./send.py

受信1:namespaceなし。送信側から送られてくる数字が順に表示されます。

python3 ./receive.py

受信2:namespaceを作成。何も表示されません。

unshare --ipc

python3 ./receive.py

POSIXメッセージキューがnamespaceによって分離されていることを確認できました。



Network ネットワーク関連

Networkのnamespaceを試す場合は、ip netnsを使うほうが分かりやすいです。vethはL2の仮想ネットワークインタフェースで、2つのインタフェースのペアを作成してくれるものでnamespaceをまたがって通信できるようになります。

操作とコマンド実行例は以下のようになります。

ip以外の関連するコマンドとしては、以下があります。IPマスカレードを有効にする方法はiptablesでもかまいません。

testns1とtestns2の2つのnamespaceを作って動きを確認してみます。

# ip netns add testns1 # 名前空間の作成

# ip netns add testns2

# ip netns list # 名前空間の一覧

testns2

testns1

次に、vethを作ってnamespaceにひもづけます。m1とs1のペアを作ってs1をtestns1に、m2とs2のペアを作ってs2をtestns2にひもづけています。

# ip link add name m1 type veth peer name s1 # vethの作成

# ip link add name m2 type veth peer name s2

# ip -d link show s1

3: s1@m1: mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000

link/ether 46:56:02:e4:d7:b9 brd ff:ff:ff:ff:ff:ff promiscuity 0 minmtu 68 maxmtu 65535

veth addrgenmode eui64 numtxqueues 1 numrxqueues 1 gso_max_size 65536 gso_max_segs 65535

# ip -d link show m1

4: m1@s1: mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000

link/ether d2:32:b1:dc:bf:be brd ff:ff:ff:ff:ff:ff promiscuity 0 minmtu 68 maxmtu 65535

veth addrgenmode eui64 numtxqueues 1 numrxqueues 1 gso_max_size 65536 gso_max_segs 65535

# ip link set dev s1 netns testns1 # s1を名前空間testns1にひもづける

# ip link set dev s2 netns testns2 # s2を名前空間testns1にひもづける

m1とs1がnamespaceをまたいでつながったままになっていることを確認してみましょう。

# ip netns exec testns1 ip link

1: lo: mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000

link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

3: s1@if4: mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000

link/ether 46:56:02:e4:d7:b9 brd ff:ff:ff:ff:ff:ff link-netnsid 0

# ethtool -S m1

NIC statistics:

peer_ifindex: 3

rx_queue_0_xdp_packets: 0

rx_queue_0_xdp_bytes: 0

rx_queue_0_xdp_drops: 0

IPアドレスを割り当てていきます。

ip netns exec testns1 ip addr add 192.168.20.102/24 dev s1

ip netns exec testns1 ip link set dev s1 up

ip netns exec testns1 ip link set dev lo up

ip netns exec testns1 ip addr show dev s1

ip addr add 192.168.20.101/24 dev m1

ip link set dev m1 up

ip addr show dev m1

ip netns exec testns1 ip route add default via 192.168.20.101 dev s1

ip netns exec testns2 ip addr add 192.168.10.102/24 dev s2

ip netns exec testns2 ip link set dev s2 up

ip netns exec testns2 ip link set dev lo up

ip netns exec testns2 ip addr show dev s2

ip addr add 192.168.10.101/24 dev m2

ip link set dev m2 up

ip addr show dev m2

ip netns exec testns2 ip route add default via 192.168.10.101 dev s2

作成したnamespace testns1からip netns exec testns1 ping 宛先を打って通信可能かを確認します。この段階では、testns1からtestns2のs2のIPには通信できないという結果になるはずです。

OK from testns1 ping to 192.168.20.101

OK from testns1 ping to 192.168.20.102

OK from testns1 ping to 192.168.10.101

NG from testns1 ping to 192.168.10.102

OK from host ping to 192.168.20.101

OK from host ping to 192.168.20.102

OK from host ping to 192.168.10.101

OK from host ping to 192.168.10.102

ホスト側にはtestns1とtestns2の双方の経路情報があります。

# ip route

default via 116.80.89.1 dev ens10 proto static

116.80.89.0/24 dev ens10 proto kernel scope link src 116.80.89.195

192.168.10.0/24 dev m2 proto kernel scope link src 192.168.10.101

192.168.20.0/24 dev m1 proto kernel scope link src 192.168.20.101

そのため、IPフォワードを有効にすると、testns1とtestns2の通信ができるようになります。sysctl -w net.ipv4.i_forward=1を実行してから再度、pingを打つと以下の結果になります。

OK from testns1 ping to 192.168.20.101

OK from testns1 ping to 192.168.20.102

OK from testns1 ping to 192.168.10.101

OK from testns1 ping to 192.168.10.102

インターネットに出るために、IPマスカレードを設定します。これでtestns1からインターネットに出ることができるようになります。

# iptables --table nat --append POSTROUTING --source 192.168.20.102/24 --jump MASQUERADE

# iptables --table nat --list

Chain PREROUTING (policy ACCEPT)

target prot opt source destination

Chain INPUT (policy ACCEPT)

target prot opt source destination

Chain OUTPUT (policy ACCEPT)

target prot opt source destination

Chain POSTROUTING (policy ACCEPT)

target prot opt source destination

MASQUERADE all -- 192.168.20.0/24 anywhere

# ip netns exec testns1 curl "http://142.251.42.131" # Googleから応答が返る

# ip netns exec testns2 curl "http://142.251.42.131" # Googleから応答が返らない

User ユーザー/グループ、権限

Userのnamespaceは、後発のnamespaceで利用できるようになり始めたという段階のものです。試してみる場合のお手軽な使い方は、unshare -U -rを指定する方法です。unshareの外からはunshareの実行ユーザーの操作のように見えるものが、unshareの中からはrootの操作に見えます。

mkdir /home/ubuntu/container

cd /home/ubuntu/container

unshare -U -r /bin/bash

id # uid=0(root) gid=0(root) groups=0(root),65534(nogroup)

touch hoge

ll # -rw-rw-r-- 1 root root 0 Oct 17 07:23 hoge

exit

cd /home/ubuntu/container

ll # -rw-rw-r-- 1 ubuntu ubuntu 0 Oct 17 07:23 hoge

uidとgidを割り当てる場合は、/proc/unshareのPID/uid_mapと/proc/unshareのPID/gid_mapにプロセス内のマッピングルールを記載します。

(名前空間内の最初のID) (名前空間外の最初のID) (名前空間内で使用するIDの個数)

initのuid_mapとgid_mapは、以下のように定義されています。

# cat /proc/1/uid_map

0 0 4294967295

# cat /proc/1/gid_map

0 0 4294967295



Capability

Userのnamespaceは後発なので権限を制限する仕組みの中核にいるのはCapabilityです。Capabilityはrootが持っている権限をバラして、rootの持つすべての権限を付与せず必要な権限のみを付与できるようにしたものです。37種類あります。

Capabilityはファイルに対して指定する方法と、スレッドに対して指定する方法があります。

ファイルに指定する方法は従来からあるSet-User-IDやSet-Group-IDに似たイメージで実行ファイルに権限を付与します。Set-User-IDが付与されている実行ファイルの例はpasswdで、この実行ファイルはrootユーザーが実行しなくてもパスワードの変更の際に必要なファイルアクセスができます。

# ls -l /bin/passwd

-rwsr-xr-x. 1 root root 27856 Apr 1 2020 /bin/passwd

ファイルにCapabilityが指定されている例はpingでCAP_NET_RAWが付与されています。

# getcap /bin/ping

/bin/ping = cap_net_raw+ep

一般ユーザーが実行しても/etc/shadowをみれるcatを作ってみましょう。DAC(任意アクセス制御)をバイパスしてアクセスするためのCapabilityはCAP_DAC_READ_SEARCHです。

$ sudo mkdir /ubuntu

$ sudo cp /bin/cat /ubuntu/cat

$ /ubuntu/cat /etc/shadow # /ubuntu/cat: /etc/shadow: Permission denied

$ sudo setcap cap_dac_read_search=+ep /ubuntu/cat

$ /ubuntu/cat /etc/shadow # こっちは成功する

前述したようにファイル Capabilityではそのファイルを実行するユーザーが誰であっても権限を与えた状態で利用することができてしまいますこの状況を回避するためにファイルではなくプロセスにCapabilityを付与できます。systemdにはそのための仕組みがあるので、これを使って確認していきましょう。

作業場所とログの書き出し先を作り、Capabilityの動きを確認するのに使うcatコマンドをコピーします。

$ sudo rm /ubuntu/cat

$ sudo cp /bin/cat /ubuntu/cat

$ sudo chmod a+w /ubuntu/cat.log

systemdから実行するスクリプト/ubuntu/cat.shを用意します。

#!/bin/bash

/ubuntu/cat /etc/shadow > /ubuntu/cat.log

まずはCapabilityの設定をしないで動かすとどうなるのか確認してみます。

/etc/systemd/system/cat.service

[Unit]

Description=cat shadow

[Service]

ExecStart=/ubuntu/cat.sh

User=ubuntu

Group=ubuntu

[Install]

WantedBy=multi-user.target

権限がないので、Permission deniedがsyslogに出力されました。

Nov 28 03:46:32 i-17100000215267 systemd[1]: Started cat shadow.

Nov 28 03:46:32 i-17100000215267 cat.sh[1530]: /ubuntu/cat: /etc/shadow: Permission denied

今度は、AmbientCapabilitiesにCAP_DAC_READ_SEARCHを追加してみます。

/etc/systemd/system/cat.service

[Unit]

Description=cat shadow

[Service]

ExecStart=/ubuntu/cat.sh

User=ubuntu

Group=ubuntu

AmbientCapabilities=CAP_DAC_READ_SEARCH

[Install]

WantedBy=multi-user.target

エラーにならずに成功しました。/ubuntu/cat.logの中身が/etc/shadowと同じものになっていることも確認できます。

Nov 28 03:45:02 i-17100000215267 systemd[1]: Started cat shadow.

Nov 28 03:45:02 i-17100000215267 systemd[1]: cat.service: Succeeded.

○野村総合研究所(NRI) 湯川勇太(ゆかわ ゆうた)

前職で大手家電量販店のECモールの立ち上げとECサイトの刷新プロジェクトを経験。NRI入社後は製造業の商品検索システムを担当した。現在は大手物流企業向けの基幹システムの方式設計や技術検証、トラブル対応を行っている。