SPIRE workload attestation 내부 동작, Spooffe 리서치를 읽고 다시 뜯어봤다
Unit 42가 이달 초 "The Machine With Many Faces"라는 리서치를 냈다. SPIFFE/SPIRE의 워크로드 attestation을 우회해 co-located 파드의 SVID를 갈취하는 시나리오와, Spooffe라는 오픈소스 PoC 도구까지 함께 공개했다. 제목이 다소 자극적이라 처음엔 "설마 SPIRE가?" 싶었는데, 리서치를 정독하고 우리 클러스터의 SPIRE 에이전트 로그를 다시 본 결과, 이건 SPIRE의 버그가 아니라 attestation이 어떤 신호를 신뢰하는가에 대한 근본적인 이야기였다. 팀 내부에서도 관련해서 논의가 좀 있었고, 정리하는 김에 워크로드 attestation이 실제로 어떻게 동작하는지 파헤쳐 봤다.
이 글은 SPIRE의 소스와 실제 로그를 따라가면서 SVID가 파드에 도달하기까지의 흐름을 훑는다. 짧진 않다. 공격 벡터는 마지막에 다시 다룬다.
Workload API에서 SVID 요청이 시작될 때 실제로 벌어지는 일
파드 안에서 grpcurl -unix /run/spire/agent-sockets/api.sock SpiffeWorkloadAPI/FetchX509SVID 같은 걸 호출한다고 해보자. 사실 내부적으로는 이게 호출자를 특정할 정보가 요청에 하나도 없다. RPC 페이로드에 "저는 payment 서비스입니다" 같은 필드가 없다. 그럼 SPIRE 에이전트는 이 호출자가 누구인지 어떻게 아는가?
정답은 Unix Domain Socket의 SO_PEERCRED다. 에이전트는 gRPC 스트림이 열린 순간 소켓의 peer credentials를 뽑아서 호출자의 PID를 얻는다. getsockopt(fd, SOL_SOCKET, SO_PEERCRED, ...) 한 방이다. SPIRE 에이전트 코드에서는 peertracker 패키지가 이 일을 한다. 여기까지는 커널이 보증해주는 값이라 신뢰할 수 있다. PID를 속일 수는 없다. 소켓을 연 쪽의 커널이 그 PID를 fd에 붙여주기 때문이다.
문제는 PID 이후다. 에이전트는 PID로부터 "이 프로세스가 어느 파드에 속하는가"를 알아내야 하는데, 여기서부터 커널의 보증이 끊긴다.
PID → 파드 매핑, cgroup을 읽는 그 순간
에이전트는 /proc/<pid>/cgroup을 읽는다. cgroup v1이면 여러 줄로, v2면 한 줄로, 이 프로세스가 어느 cgroup에 속해 있는지 문자열이 나온다. Kubernetes 환경에서는 이 문자열에 파드 UID가 박혀 있다. 예를 들어 이런 식이다.
0::/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod3f9c2a1e_....slice/cri-containerd-....scope
여기서 파드 UID를 추출해서, kubelet API(정확히는 kubelet의 /pods 엔드포인트나 CRI API)에 붙어 이 파드의 metadata를 가져온다. 네임스페이스, 파드 이름, 서비스 어카운트, 이미지 SHA, 라벨. 이 정보들이 selector로 만들어져서 SPIRE 서버에 등록된 registration entry와 매칭된다. 매칭에 성공하면 SVID가 발급된다.
여기서 attestation의 핵심 신뢰 사슬은 이렇다.
- 커널이 보증하는 값: 호출자의 PID
- 프로세스 자신이 볼 수 있는 파일 시스템 상의 값:
/proc/<pid>/cgroup - kubelet이 응답하는 값: 파드 metadata
우리는 흔히 "SPIRE는 커널을 신뢰한다"고 말하지만, 사실 SPIRE가 커널에게서 받는 건 PID 하나뿐이다. 나머지는 procfs 문자열이다.
kubelet selector가 selectors 트리로 확장되는 방식
로그를 켜놓고 SVID 발급 과정을 보면, workload attestor가 한 번에 selector 하나만 뱉는 게 아니라 여러 개를 세트로 뱉는다. k8s:ns:payment, k8s:sa:payment-svc, k8s:pod-label:app:payment, k8s:container-image:registry.example.com/payment@sha256:abcd... 이런 식이다.
registration entry는 이 selector들의 AND 조건으로 매칭된다. 즉 entry가 k8s:ns:payment AND k8s:sa:payment-svc AND k8s:container-image-sha:... 세 개를 요구하면, workload가 이 셋을 모두 제시해야 SVID를 받는다. selector가 엄격할수록 갈취가 어려워진다. 그런데 실제 운영에서는 "일단 되게" 하려고 k8s:ns:payment만 걸어두는 팀도 꽤 있다. 이게 다음 장의 문제로 연결된다.
한 가지 더. attestor는 여러 개를 병렬로 돌린다. k8s attestor 외에 unix attestor(호출자 uid/gid), docker/containerd attestor도 함께 selector를 뱉는다. 모든 attestor의 selector 합집합이 최종 selector 집합이 된다.
Spooffe가 노린 지점, 그리고 왜 이게 "SPIRE의 버그가 아닌가"
Spooffe의 아이디어를 한 문장으로 요약하면, 침해된 노드에서 root 권한을 잡은 뒤 자기 프로세스의 cgroup 정보를 다른 파드의 것으로 위조해서 SPIRE 에이전트가 그 파드의 SVID를 발급하도록 유도하는 것이다. 노드가 이미 뚫린 상태를 가정하기 때문에, 공격자가 얻는 건 "그 노드 위에 co-located된 다른 파드들의 SVID"이지, 클러스터 전체나 신뢰 도메인의 private key가 아니다. 그래도 마이크로서비스 간 mTLS가 SPIRE로 걷혀 있다면 영향이 작지 않다.
핵심은 SPIRE 에이전트가 /proc/<pid>/cgroup을 그 pid의 관점에서 읽는다는 점이다. Linux에서 root는 다른 프로세스의 procfs 엔트리를 만들 수는 없지만, 자기 자신이 실행되는 cgroup 경로는 조작할 수 있다. cgroup 네임스페이스 자체를 rebind하거나, 프로세스를 다른 cgroup으로 이관하거나, 또는 좀 더 세련되게 컨테이너 런타임 소켓을 흉내내면서 kubelet 응답을 위조하는 식이다. SPIRE의 attestor는 커널이 알려주는 PID는 신뢰할 수 있어도, 그 PID가 "진짜 어느 파드 안에 있는지"는 결국 procfs와 kubelet의 응답에 기대고 있다.
그래서 이건 SPIRE의 로직 버그라기보다는 workload attestation 모델의 가정 자체에 대한 이야기다. 모델은 "노드는 신뢰할 수 있다"를 전제로 한다. 노드가 뚫리면 그 위의 워크로드 아이덴티티도 뚫린다. 리서치 저자도 SPIFFE 트러스트 도메인 자체가 함락된 게 아니라는 점을 명시하고 있고, 이건 원래 위협 모델대로다. 다만 실제 운영자들이 이 위협 모델을 얼마나 진지하게 받아들이고 있었는지가 문제다.
그러면 실무에서 뭘 해야 하나
솔직히 이번 리서치를 보고 우리 팀이 바로 할 수 있는 건 그리 많지 않았다. 그래도 몇 가지는 정리하고 넘어갔다.
첫째, selector를 최대한 엄격하게 걸어라. 최소한 네임스페이스 + 서비스 어카운트 + container image SHA 세트를 걸어야 한다. image SHA가 걸려 있으면 공격자가 다른 이미지의 selector를 위조해도 매칭이 안 된다. k8s:ns:... 하나만 걸어둔 entry가 있는지 감사부터 돌리는 게 첫 번째다.
둘째, 노드 격리를 신뢰 경계로 다시 그린다. 민감한 워크로드는 taint/toleration이나 node affinity로 전용 노드 풀에 밀어넣어서 "이 노드가 뚫리면 이 워크로드가 뚫린다"가 성립하도록 만든다. 어차피 원래 위협 모델이 그렇게 되어 있다. 저비용으로 co-located 파드 갈취를 막을 수 있다.
셋째, SVID TTL을 짧게 유지한다. 기본 1시간이지만, 우리 팀은 이번 기회에 30분으로 낮췄다. 갈취된 SVID의 활용 창을 좁히는 것이 목적이다. 대신 재발급 부하가 올라가니 SPIRE 서버 쪽 부하 모니터링을 잊지 말 것.
넷째, 노드 무결성 자체를 노드 attestation과 EDR로 보강한다. SPIRE의 node attestor는 TPM, AWS instance identity document, GCP IIT 같은 하드웨어/클라우드 기반 증명을 지원한다. 노드에 root가 잡히기 전에 EDR이 이상 행동을 잡아채는 게 사실 가장 근본적인 방어다.
리서치를 읽으면서 흥미로웠던 건, SPIRE 팀이 "취약점"으로 다루기보다 "위협 모델의 문서화 개선"으로 대응한 방향이다. 코드 레벨에서 수정할 지점이 없는 건 아니지만(공격자가 조작하기 어려운 커널 신호를 더 쓰는 방향), 근본은 attestation이 무엇을 신뢰하는가에 대한 명확한 문서화라는 이야기다. 사실 이 지점이 zero-trust 구현의 가장 어려운 부분이기도 하다. "trust nothing"이 슬로건이지만, 어딘가에서는 신뢰의 뿌리를 심어야 한다. SPIRE는 그 뿌리를 노드에 심었고, 그 결정은 지금도 유효하다. 다만 그 뿌리를 우리가 얼마나 튼튼하게 관리하고 있느냐는 별개의 문제다.
혹시 팀에서 SPIRE를 운영 중이라면, entry들의 selector 엄격도부터 한번 감사해 보시길. 아마 놀랄 것이다.