ServiceAccount và Bound Token

K
Kai··5 min read

Hai bài trước dùng ServiceAccount như một danh tính có sẵn, nhưng chưa nói nó hoạt động bên trong ra sao: token ở đâu ra, pod nhận bằng cách nào, sống được bao lâu. Bài này mổ phần đó, và chứng minh bằng tay rằng token tiêm vào pod bị vô hiệu ngay khi pod biến mất.

Default SA và token tự tiêm

Mỗi namespace, khi tạo ra, tự có một ServiceAccount tên default. Pod nào không khai serviceAccountName thì dùng default:

kubectl create namespace sa-demo
kubectl -n sa-demo get sa
NAME      AGE
default   0s

Tạo một pod (không khai SA, nên nhận default) và xem trong pod có gì ở đường dẫn token quy ước:

kubectl -n sa-demo run app --image=registry.k8s.io/e2e-test-images/agnhost:2.45 --command -- sleep 100000
kubectl -n sa-demo exec app -- ls /var/run/secrets/kubernetes.io/serviceaccount/
kubectl -n sa-demo exec app -- cat /var/run/secrets/kubernetes.io/serviceaccount/namespace
ca.crt
namespace
token
sa-demo

Ba file: token (JWT để gọi API), ca.crt (CA để tin API server), namespace (namespace của pod). Không ai tạo Secret cho cái này — kubelet tiêm chúng qua một projected volume mà nó tự thêm vào pod. Xem volume đó trong spec:

kubectl -n sa-demo get pod app -o jsonpath='{.spec.volumes[?(@.projected)].projected.sources}'
[ {"serviceAccountToken": {"expirationSeconds": 3607, "path": "token"}},
  {"configMap": {"name": "kube-root-ca.crt", "items": [{"key":"ca.crt","path":"ca.crt"}]}},
  {"downwardAPI": {"items": [{"fieldRef": {"fieldPath":"metadata.namespace"}, ...}]}} ]

Đó là projected volume từ Bài 41, lần này do kubelet dựng sẵn: nguồn serviceAccountToken xin token sống 3607 giây, nguồn configMap lấy CA, nguồn downwardAPI ghi namespace. Token là loại bound hiện đại — kubelet xin qua TokenRequest API và làm mới file trước khi hết hạn, khác hẳn token vĩnh viễn lưu trong Secret kiểu cũ (Kubernetes không còn tự tạo từ 1.24).

Token bound vào pod và node

Giải payload của token trong pod cho thấy nó ràng buộc tới đâu:

kubectl -n sa-demo exec app -- cat /var/run/secrets/kubernetes.io/serviceaccount/token \
  | cut -d. -f2 | base64 -d | python3 -m json.tool
"aud": ["https://10.0.1.10:6443"],
"sub": "system:serviceaccount:sa-demo:default",
"iat": 1779578851,
"exp": 1811114851,
"kubernetes.io": {
  "namespace": "sa-demo",
  "node": {"name": "worker-0", "uid": "b5766c61-..."},
  "pod":  {"name": "app", "uid": "a6ab2be4-..."},
  "serviceaccount": {"name": "default", "uid": "7da7eed9-..."},
  "warnafter": 1779582458
}

Khác token rời ở Bài 51, claim kubernetes.io ở đây ghi đích danh podnode kèm UID. Token này thuộc về pod app trên worker-0. warnafter (= iat + 3607, tức ~1 giờ) là mốc kubelet làm mới file; còn exp đặt xa hơn nhiều là khoảng ân hạn để client cũ chưa kịp đọc lại file không gãy ngay. Điều quan trọng không nằm ở con số exp: vì token gắn UID pod, API server còn kiểm pod đó còn tồn tại hay không.

Chứng minh: xóa pod, token chết theo

Lấy token trong pod đang chạy rồi gọi API server bằng nó:

TOK=$(kubectl -n sa-demo exec app -- cat /var/run/secrets/kubernetes.io/serviceaccount/token)
curl -s -o /dev/null -w "HTTP %{http_code}\n" --cacert ca.pem \
  -H "Authorization: Bearer $TOK" https://<apiserver>/api/v1/namespaces/sa-demo/pods
kubectl --kubeconfig=/dev/null --token="$TOK" --server=https://<apiserver> \
  --certificate-authority=ca.pem auth whoami | grep Username
HTTP 403
Username   system:serviceaccount:sa-demo:default

403 chứ không 401: token hợp lệ, API server xác thực được nó là system:serviceaccount:sa-demo:default, chỉ là SA default không có RBAC nên không list được pod. Giờ xóa pod rồi dùng lại đúng token đó:

kubectl -n sa-demo delete pod app
curl -s --cacert ca.pem -H "Authorization: Bearer $TOK" \
  https://<apiserver>/api/v1/namespaces/sa-demo/pods
{ "code": 401, "message": "Unauthorized" }

401 — token vừa nãy còn dùng được giờ bị từ chối ngay ở authentication, vì pod nó bound vào đã biến mất. Đây là khác biệt lớn so với token vĩnh viễn kiểu cũ: token cũ bị lộ thì dùng được mãi tới khi xóa Secret; token bound chết theo pod, nên cửa sổ rủi ro khi lộ chỉ còn bằng vòng đời pod.

Tắt auto-mount

Không phải pod nào cũng cần gọi API. Nếu không cần, tốt nhất là không tiêm token vào — bớt một thứ có thể bị lộ. Đặt automountServiceAccountToken: false (trên SA hoặc trên pod):

kubectl -n sa-demo run app-notoken --image=registry.k8s.io/e2e-test-images/agnhost:2.45 \
  --overrides='{"spec":{"automountServiceAccountToken":false}}' --command -- sleep 100000
kubectl -n sa-demo exec app-notoken -- ls /var/run/secrets/kubernetes.io/serviceaccount/
ls: /var/run/secrets/kubernetes.io/serviceaccount/: No such file or directory

Không còn thư mục token. Một workload không gọi API mà vẫn mang token là bề mặt tấn công thừa, nên tắt auto-mount cho những pod đó là một bước siết đáng làm.

🧹 Dọn dẹp

kubectl delete namespace sa-demo

Xóa namespace cuốn theo pod và SA default (control plane sẽ tạo lại default nếu namespace còn). Lệnh dùng trong bài ở github.com/nghiadaulau/kubernetes-from-scratch, thư mục 53-serviceaccounts.

Tổng kết

Mỗi namespace có một SA default, và pod không khai SA thì dùng nó. Kubelet tiêm token vào pod qua một projected volume tự dựng (serviceAccountToken + kube-root-ca.crt + downwardAPI namespace), đặt ở /var/run/secrets/kubernetes.io/serviceaccount/. Token là loại bound: claim kubernetes.io ghi UID của pod và node, warnafter đánh mốc kubelet làm mới, và API server kiểm pod còn tồn tại không. Ta chứng minh bằng tay: token trong pod gọi API ra 403 (hợp lệ, chỉ thiếu RBAC), xóa pod rồi dùng lại đúng token thì ra 401 — token chết theo pod. So với token Secret vĩnh viễn kiểu cũ, token bound thu hẹp cửa sổ rủi ro xuống bằng vòng đời pod. Với pod không cần gọi API, đặt automountServiceAccountToken: false để khỏi tiêm token thừa.

Tới đây cụm đã có authentication (Bài 51), authorization (Bài 52) và danh tính workload (Bài 53), tất cả ở mức API. Bài 54 chuyển xuống chính pod: Pod Security Standards và admission chặn ngay từ lúc tạo những pod xin quyền nguy hiểm — chạy root, privileged, mượn namespace của host.

Related Posts