How EKS Pods Get AWS Credentials
Pod가 AWS 자격증명을 얻는 경로를 따라가면, 노드 IAM Role 탈취라는 멀티테넌트 EKS의 급소가 보인다
Pod가 노드의 IAM Role을 훔칠 수 있다. 어떻게 막지?
Intro
여러 고객의 워크로드를 한 EKS 클러스터에 섞어 띄운다고 하자. 각 고객은 자기 Pod가 자기 리소스만 다루기를 기대한다.
그런데 아무 설정도 하지 않으면 Pod 안의 애플리케이션이 부른 aws s3 ls가 다른 고객의 버킷을 조회하고, 같은 노드에 뜬 다른 테넌트의 Secret까지 읽어낼 수 있다.
두 케이스 모두 원인은 Pod가 자기 것이 아닌 자격증명, 즉 노드에 붙은 IAM Role의 자격증명을 손에 넣을 수 있기 때문이다.
IMDS and the Node Role
IMDS(Instance Metadata Service)란 EC2 인스턴스 내부에서만 접근할 수 있는 link-local API를 말한다.
인스턴스 안에서 169.254.169.254라는 특수 IP로 HTTP 요청을 보내면, 그 인스턴스 자신에 대한 정보를 돌려준다.
# 인스턴스 안에서만 동작한다
curl http://169.254.169.254/latest/meta-data/instance-id
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name>
돌려주는 정보 중 문제의 핵심은 IAM 임시 자격증명이다.
EC2 인스턴스에 IAM Role을 붙이면, 그 Role의 임시 키(액세스 키, 시크릿 키, 세션 토큰)를 IMDS의 위 경로에서 받아 갈 수 있다.
AWS SDK와 CLI가 별도 설정 없이 자격증명을 얻는 방법이 이것이다!
EKS에서 워커 노드는 결국 EC2 인스턴스이고, 여기에도 IAM Role이 붙는다.
이 노드 Role에는 노드가 클러스터에서 제 일을 하는 데 필요한 권한이 담긴다.
ECR에서 이미지를 당겨오고(ecr:GetDownloadUrlForLayer 등), CNI가 ENI를 붙였다 떼고(ec2:*NetworkInterface), 노드 자신을 API server에 등록하는 권한이다.
문제는 이 Role이 노드 위에 뜬 모든 Pod가 공유하는 IMDS를 통해 노출된다는 데 있다.
왜 모든 Pod가 공유하게 될까?
IMDS는 요청이 어느 컨테이너, 어느 namespace, 어느 프로세스에서 왔는지 구분하지 않기 때문이다.
오직 “어느 EC2 인스턴스에서 온 요청인가”만 보고 그 인스턴스에 붙은 Role의 자격증명을 내준다.
Pod는 자기 network namespace로 격리돼 있지만, 그 안에서 169.254.169.254로 쏜 패킷은 결국 노드의 네트워크 스택을 거쳐 IMDS에 닿는다. IMDS 입장에서는 노드가 직접 보낸 요청과 Pod가 보낸 요청이 구별되지 않는다. 격리는 돼 있어도 자격증명을 나눠주는 창구가 인스턴스 단위라, 그 인스턴스 위의 누구든 같은 창구를 두드릴 수 있다.
IMDS는 인스턴스 단위로만 인증한다. 그 인스턴스에 패킷을 꽂을 수 있으면 누구든 노드 Role을 받는다.
여기서 AWS SDK가 자격증명을 찾는 방식을 알아야 한다. SDK는 credential provider chain이라는 정해진 순서로 자격증명을 탐색해, 처음 걸리는 자격증명에서 멈춘다.
Pod 안의 애플리케이션이 AWS API를 호출할 때, 앞의 경로가 아무것도 설정돼 있지 않으면 SDK는 체인을 끝까지 내려가 IMDS에 도달한다. 그리고 거기서 노드 Role의 자격증명을 받아 온다. 개발자가 의도한 게 아니라, 아무것도 설정하지 않아 폴백으로 굴러떨어진 상태다. 팀이 이 사실을 인지조차 못 하는 경우가 흔하다.
One Credential, Two Domains
훔친 게 “S3 키” 한 장이었다면 이야기는 거기서 끝난다. 하지만 Pod가 손에 넣은 건 노드 Role 그 자체다. 그리고 노드 Role은 서로 다른 두 신뢰 도메인에서 인증된다. AWS IAM과 Kubernetes RBAC이다. 자격증명 하나가 양쪽 모두에서 통한다.
AWS IAM
Role의 정책이 곧 blast radius**
훔친 자격증명으로 할 수 있는 일은 “S3 조회”가 아니라 그 Role에 붙은 IAM 정책이 허용하는 모든 액션이다.
S3 읽기는 그중 한 예일 뿐이다. 기본 노드 Role만 해도 ECR에서 다른 테넌트의 프라이빗 이미지를 당겨오고(AmazonEC2ContainerRegistryReadOnly), VPC의 ENI를 붙였다 뗐다 하며(AmazonEKS_CNI_Policy), 계정 전역의 EC2 리소스를 조회한다.
“어떤 리소스가 뚫렸나”가 문제가 아니라 Pod가 노드 Role의 권한을 통째로 물려받았다는 게 핵심이다. 그 권한으로 할 수 있는건 다 가능해진다.
Kubernetes RBAC
자격증명이 클러스터 신원이 된다.** 이쪽은 조금 덜 직관적이다.
핵심은 EKS의 API server가 IAM으로 인증한다는 사실이다.
노드의 kubelet은 자기 IAM Role로 aws eks get-token을 실행해 API server에 자신을 증명하고, 이 노드 Role ARN은 클러스터 안에서 system:node 그룹에 매핑돼 있다.
그러니 노드 Role 자격증명을 쥔 Pod는 이 과정을 그대로 흉내 내, AWS 자격증명을 곧 쿠버네티스 신원으로 바꿀 수 있다.
고객 Pod (노드 Role 자격증명 탈취)
→ aws eks get-token 으로 API server에 "나는 이 노드다" 인증
→ system:node 권한 획득
→ 같은 노드에 스케줄된 다른 테넌트 Pod의 Secret, 토큰 열람
여기서 흔한 반론이 있다. NodeRestriction admission plugin이 system:node 권한을 “그 노드에 실제로 존재하는 리소스”로 제한하지 않느냐는 것이다.
맞긴한데, 멀티테넌트에서는 이게 방어가 되지 못한다. 한 노드에 여러 고객의 Pod가 섞여 뜨면, 그 노드가 마운트한 Secret 집합에는 이미 다른 고객의 것이 포함돼 있다. 노드 단위 격리는 테넌트 단위 격리와 다르다.
Pod가 노드 Role을 쥐었다는 하나의 사실이 나비효과처럼 일파만파 퍼져나간다. 이제 이걸 틀어막아보자.
IMDSv2: Session and Hop Limit
탈취 지점을 막는 첫 수단이 IMDS 자체의 방어 기능이다. IMDS에는 두 버전이 있다.
IMDSv1은 단순 GET 요청이다. 169.254.169.254로 GET 한 번이면 자격증명이 나온다. 그래서 SSRF에 취약하다. SSRF(Server-Side Request Forgery)란 공격자가 서버로 하여금 의도치 않은 내부 주소로 요청을 보내게 만드는 취약점을 말한다. 취약한 애플리케이션에 http://169.254.169.254/...를 넣어 자격증명을 대신 긁어오게 만드는 식이다.
IMDSv2는 세션 기반으로 이 공격을 좁힌다. 먼저 PUT 요청으로 토큰을 받고, 그 토큰을 헤더에 실어야만 조회가 된다.
# 1. PUT으로 세션 토큰을 발급받는다
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
# 2. 토큰을 헤더에 실어 조회한다
curl -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/instance-id
대부분의 SSRF는 GET만 유발할 수 있고 임의 헤더를 넣기 어렵다. PUT과 커스텀 헤더를 요구하는 것만으로 공격 표면이 크게 줄어든다. 노드에서 IMDSv2를 강제하려면 HttpTokens=required로 설정한다.
그런데 IMDSv2에는 컨테이너 환경을 위한 기능이 있다. hop limit이다. 정확히는 HttpPutResponseHopLimit이고, 이 값이 IMDS가 내주는 토큰 응답 패킷의 IP TTL을 정한다.
IP 패킷은 라우팅 홉(라우터, NAT, 브리지 같은 L3 장비)을 하나 지날 때마다 TTL이 1씩 줄고, 0이 되면 폐기된다. IMDS는 이 물리적 성질을 보안 경계로 재활용한다.
- hop limit 1: 토큰 응답이 인스턴스의 네트워크 스택 밖으로 한 홉도 나가지 못한다. 노드에서 직접 실행되는 프로세스만 토큰을 받는다.
- hop limit 2: 한 홉 건너까지 전달된다. 노드 안에서 브리지나 라우팅을 한 번 거치는 Pod도 토큰을 받을 수 있다.
오해하기 쉬운 지점이 있다. 이건 host의 어떤 프로세스가 토큰을 받아 Pod에게 넘겨주는 게 아니다. 요청은 Pod 안의 그 애플리케이션 프로세스가 직접 보내고, 응답도 직접 받는다. 중간 릴레이 같은 건 없다.
홉을 소모하는 건 커널의 IP 라우팅이다. 일반 Pod는 자기 network namespace에 있고, 노드의 root netns와 veth pair로 연결된다. IMDS 응답은 먼저 root netns에 도착하는데, 이걸 Pod에게 전달하려면 커널이 root netns에서 Pod의 netns로 패킷을 포워딩(라우팅) 해야 한다.
즉 root netns가 Pod의 게이트웨이, 곧 라우터 역할을 한다. 라우터를 한 번 지나는 것이 L3 홉 하나이고, 그때 TTL이 1 깎인다. 그래서 hop limit이 2면 Pod까지 도달하고, 1이면 veth를 넘는 순간 TTL이 0이 되어 폐기된다.
반대로 root netns에서 직접 도는 프로세스, 즉 kubelet이나 노드 자신, 그리고 hostNetwork: true Pod는 응답이 도착하는 그 자리에서 바로 읽는다. 포워딩 홉이 0이라 hop limit이 1이어도 멀쩡히 받는다. 뒤에서 볼 hostNetwork 예외가 바로 이 0홉 때문이다.
문제는 EKS 관리형 노드 그룹의 기본 hop limit이 2라는 점이다. 계정 차원의 IMDS 기본값도 hop limit 2로 잡혀 있다. 컨테이너 워크로드가 IMDS를 쓰는 걸 편하게 하려는 배려지만, 멀티테넌트에서는 이 배려가 곧 앞 절에서 본 노드 Role 탈취 경로가 된다. 그래서 hop limit을 1로 낮춰 Pod의 IMDS 접근을 끊어야 한다.
다만 hop limit을 내리기 전에 반드시 선행돼야 할 것이 있다. 지금 노드 Role에 기대고 있던 Pod들에게 제 몫의 신원을 먼저 쥐여줘야 한다. 그게 IRSA와 Pod Identity다.
IRSA
IRSA(IAM Roles for Service Accounts)는 쿠버네티스 ServiceAccount에 IAM Role을 연결해, Pod가 노드 Role이 아닌 자기 전용 Role의 자격증명을 받게 하는 방식이다. 기반은 OIDC 연합 인증이다.
동작을 따라가면 이렇다. EKS 클러스터는 저마다 공개 OIDC discovery 엔드포인트를 띄우는데, 이걸 IAM에 OIDC identity provider로 등록한다. 그다음 Pod가 쓸 ServiceAccount에 Role ARN을 annotation으로 붙인다.
apiVersion: v1
kind: ServiceAccount
metadata:
name: my-app
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/my-app-role
이 ServiceAccount로 Pod가 뜨면, 클러스터의 mutating webhook이 Pod에 두 가지를 주입한다.
ServiceAccount의 서명된 JWT 토큰 파일, 그리고 AWS_WEB_IDENTITY_TOKEN_FILE, AWS_ROLE_ARN 환경 변수다. 이제 SDK는 이 JWT를 들고 sts:AssumeRoleWithWebIdentity를 호출해 임시 자격증명을 받는다.
IAM은 JWT의 서명을 클러스터의 OIDC 엔드포인트로 검증하고, 서명이 유효하면 해당 Role의 자격증명을 내준다. (OIDC 토큰 검증의 원리는 OIDC and Kubernetes Authentication에서 자세히 다뤘다.)
핵심은 이 경로가 credential chain의 2번(Web Identity) 단계라는 점이다. IRSA가 설정된 Pod의 SDK는 여기서 자격증명을 얻으므로, 5번 IMDS까지 내려갈 일이 없다.
한 가지 운영상의 무게가 있다. Role의 신뢰 정책에 클러스터의 OIDC issuer URL과 네임스페이스, ServiceAccount 이름이 조건으로 박힌다. 그래서 같은 Role을 여러 클러스터에서 쓰려면 신뢰 정책에 조건을 늘리거나 Role을 복제해야 하고, 클러스터를 재생성하면 OIDC issuer가 바뀌어 신뢰 정책을 갱신해야 한다. 클러스터가 늘어날수록 관리가 번거로워진다.
EKS Pod Identity
EKS Pod Identity는 IRSA의 그 운영 부담을 덜기 위해 나온, EKS 전용 네이티브 방식이다.
구조가 다르다. 노드마다 eks-pod-identity-agent가 DaemonSet(EKS add-on)으로 뜬다. Role 연결은 annotation이 아니라 EKS API로 association을 생성해서 한다.
aws eks create-pod-identity-association \
--cluster-name my-cluster \
--namespace default \
--service-account my-app \
--role-arn arn:aws:iam::111122223333:role/my-app-role
Pod 안의 SDK는 link-local 엔드포인트(http://169.254.170.23/v1/credentials)에서 자격증명을 받는다. 이건 credential chain의 4번(컨테이너 자격증명) 단계로, ECS가 자격증명을 얻는 방식과 같다. 이 역시 IMDS보다 앞 단계이므로, 설정되면 SDK가 IMDS까지 내려가지 않는다.
가장 큰 차이는 Role의 신뢰 정책이다. 클러스터별 정보가 들어가지 않고, 모든 클러스터에서 동일한 한 줄이다.
{
"Effect": "Allow",
"Principal": { "Service": "pods.eks.amazonaws.com" },
"Action": ["sts:AssumeRole", "sts:TagSession"]
}
신뢰 정책이 클러스터를 특정하지 않으니 Role 하나를 여러 클러스터에서 재사용할 수 있고, 어느 클러스터의 어느 ServiceAccount에 붙일지는 association이라는 EKS 리소스가 관리한다. OIDC provider를 IAM에 등록하는 사전 작업도 필요 없다. 덤으로 STS 세션에 클러스터명, 네임스페이스, ServiceAccount명이 세션 태그로 자동으로 실려서, 태그 기반 조건(ABAC)으로 정책을 짤 수 있다.
When to Use
둘의 근본 차이는 신뢰의 방향이다. IRSA는 IAM이 각 클러스터의 OIDC issuer를 개별적으로 신뢰한다. 클러스터가 N개면 신뢰 관계도 N개다. Pod Identity는 IAM이 pods.eks.amazonaws.com이라는 EKS 서비스 하나만 신뢰하고, 세부 매핑을 EKS에 위임한다.
| 구분 | IRSA | Pod Identity |
|---|---|---|
| 기반 | OIDC 연합 (STS 표준) | EKS 네이티브 (agent + association) |
| 클러스터별 사전 작업 | OIDC provider 등록 | agent add-on 설치 |
| Role 연결 | ServiceAccount annotation | EKS API association |
| 신뢰 정책 | 클러스터/SA별 조건 명시 | 전 클러스터 공통 한 줄 |
| credential chain 단계 | Web Identity (2번) | 컨테이너 자격증명 (4번) |
| Fargate | 지원 | 미지원 |
EKS에서 새로 시작한다면 Pod Identity가 기본값이다. AWS도 이쪽을 밀고 있고 운영이 확실히 가볍다.
IRSA를 계속 써야 하는 경우는 Fargate를 쓸 때(Pod Identity의 agent가 DaemonSet이라 Fargate에서 돌지 않는다), self-managed 클러스터일 때, 또는 사용하는 add-on이나 오래된 SDK가 아직 Pod Identity를 지원하지 않을 때다.
두 방식은 한 클러스터에서 공존할 수 있어서, 기존 IRSA를 걷어내지 않고 신규 워크로드만 Pod Identity로 옮기는 점진 전략이 가능하다.
어느 쪽을 고르든 목적은 같다. Pod의 SDK가 credential chain의 앞 단계에서 자격증명을 얻게 만들어, IMDS까지 내려갈 이유를 없애는 것이다. 여기까지 오면 hop limit을 1로 낮춰도 정상 워크로드는 아무것도 깨지지 않는다.
The hostNetwork Escape Hatch
hop limit 1을 “완벽한 방어”로 여기면 곤란하다. 이 방어를 그냥 통과하는 예외가 있다. hostNetwork Pod다.
hostNetwork: true란 Pod가 자체 네트워크 네임스페이스를 만들지 않고 노드의 네트워크 스택을 그대로 쓰게 하는 설정을 말한다. 이런 Pod는 자기 IP도 노드 IP와 같고, 노드에서 직접 실행되는 프로세스와 네트워크 관점에서 구분이 없다.
apiVersion: v1
kind: Pod
spec:
hostNetwork: true
containers:
- name: app
# ...
문제는 hostNetwork Pod의 IMDS 요청에는 컨테이너 경계를 건너는 홉이 없다는 것이다. veth pair를 거치지 않으니 토큰 응답이 홉을 소모하지 않고, hop limit 1이어도 그대로 받는다. 즉 hop limit은 hostNetwork Pod에 대해서는 무력하다.
이 설정이 존재하는 데는 정당한 이유가 있다. 노드의 네트워크를 구성하는 CNI 플러그인, iptables를 만지는 kube-proxy, 노드의 실제 인터페이스 지표를 봐야 하는 모니터링 에이전트, 그리고 앞서 본 Pod Identity의 agent까지, 노드 자체를 다루는 시스템 컴포넌트는 hostNetwork로 떠야 제 일을 한다. 반대로 일반 고객 애플리케이션이 hostNetwork를 쓸 이유는 거의 없다.
그래서 방어는 두 가지를 더 얹어야 완성된다.
- hostNetwork Pod 생성을 막는다. Pod Security Admission의
baseline/restricted프로파일은 hostNetwork를 금지한다. 더 세밀하게는 Kyverno나 OPA/Gatekeeper 같은 policy engine으로 “시스템 네임스페이스 외에는 hostNetwork 금지” 규칙을 강제한다. 악성 워크로드가 hostNetwork Pod로 스케줄되는 경로를 닫는 것이다. - 노드 Role의 권한 자체를 최소화한다. 노드 Role에서 S3 같은 워크로드용 권한을 걷어내고, 노드 운영에 꼭 필요한 최소 권한만 남긴다. 그러면 설령 노드 Role이 유출돼도 훔쳐 쓸 것이 줄어든다. hop limit이 탈취 경로를 막는 것이라면, 이건 탈취당했을 때의 피해를 줄이는 목적이다.
Locking It Down
전체를 순서대로 놓으면 이렇게 된다. 순서가 중요하다. 앞뒤가 바뀌면 정상 워크로드가 깨진다.
- 워크로드를 IRSA 또는 Pod Identity로 이전한다. 노드 Role에 암묵적으로 기대던 Pod마다 제 전용 Role을 붙인다. 이전 대상을 찾을 때는 코드를 뒤지는 것보다 노드 Role의 CloudTrail 이벤트를 보는 편이 빠르다. 노드 Role 자격증명으로 호출된 API 중 EKS 시스템 컴포넌트의 것이 아닌 게 있다면, 그게 노드 Role에 기대던 Pod다.
- hop limit을 1로 낮춘다. 실행 중인 노드는 재부팅 없이 즉시 바꿀 수 있다.
aws ec2 modify-instance-metadata-options \
--instance-id i-0123456789abcdef0 \
--http-tokens required \
--http-put-response-hop-limit 1
관리형 노드 그룹은 launch template의 metadataOptions에서, 계정 전체 기본값은 리전별로 modify-instance-metadata-defaults에서 잡는다. 다만 실행 중인 노드에 이미 적용된 값은 노드를 교체(rolling replace)해야 완전히 반영된다. 새 값으로 뜬 노드로 갈아끼우는 것이다.
- hostNetwork를 policy로 막고, 노드 Role을 최소 권한으로 조인다.
Pod의 SDK는 IRSA나 Pod Identity로 제 몫의 좁은 권한만 받고, IMDS로는 애초에 내려가지 않으며, 내려가려 해도 hop limit 1에 막히고, 그 예외인 hostNetwork마저 policy로 봉인된다.
References
- Retrieve instance metadata — IMDSv2 (AWS Docs)
- Configure the Instance Metadata Service options (AWS Docs)
- Restrict access to the IMDS — EKS Best Practices Guide
- IAM roles for service accounts (AWS EKS Docs)
- Learn how EKS Pod Identity grants pods access to AWS services (AWS EKS Docs)
- Amazon EKS Pod Identity: a new way for applications on EKS to obtain IAM credentials (AWS Blog)
- AWS SDK credential provider chain (AWS SDKs Reference)
- Pod Security Standards (Kubernetes Docs)