Kubernetes Default Service Account - What you need to know

Managing access to Kube-API via service accounts

In the world of Kubernetes, we have two types of accounts that can interact with the Kube-API. We have the user accounts, these are us humans that will authenticate using a kube-config file, and then we have service accounts which are processes, and these guys run inside our pods. In the case where your application, which is running on a Kubernetes cluster, needs to interact with other Kubernetes objects, then our application can use this service account and its accompanying token, to retrieve the required information about the objects from the API. More on this token later.

But why would you need to interact with other Kubernetes objects in the clusters; what would be the use case? Well, there are a few that come to mind. Suppose you have a config-reloader application that needs to monitor when a configMap is updated, for it to reload the main application. In that case, the config-reloader application will need a service account (and the appropriate RBAC rules) which will allow it to monitor the relevant configMap. Another use-case will be pod monitoring or logging tools that need to retrieve the current running pods, for example. However, the bottom line is, that the majority of our apps that run on Kubernetes don’t need to interact with the Kube-API.

Here’s the interesting part

It would seem that Kubernetes assumes otherwise. Kubernetes has an AdmissionController, quite appropriately named, The ServiceAccount Admission Controller - which injects some additional parameters into the pod spec before finally creating the object. If you do not specify a service account for your pod, this ServiceAccount AdmissionController will assign it the default service account. Any service account is also issued a token, which the admission controller mounts inside your pod. This token can be used to access the Kubernetes API. Now, the access of this default token is quite limited, but still, it is considered best practice only to provide access where required.

To see this in action; if we export the pod specification of any running pod in our cluster, we’ll notice a volume mount that we never specified:

   volumeMounts:  
   - mountPath: /var/run/secrets/kubernetes.io/serviceaccount  
     name: kube-api-access-cchkp  
     readOnly: true

And its accompanying volume; looks something like this:

 volumes:  
 - name: kube-api-access-cchkp  
   projected:  
     defaultMode: 420  
     sources:  
     - serviceAccountToken:  
         expirationSeconds: 3607  
         path: token  
     - configMap:  
         items:  
         - key: ca.crt  
           path: ca.crt  
         name: kube-root-ca.crt  
     - downwardAPI:  
         items:  
         - fieldRef:  
             apiVersion: v1  
             fieldPath: metadata.namespace  
           path: namespace

As mentioned, the access might be limited, which we’ll cover shortly, and on top of that, the token does expire. In newer Kubernetes versions (1.20) the token now has a field expirationSeconds, and the Kubelet will rotate this token periodically. So this is not all bad news. Built-in security in Kubernetes is improving.

The Token

In the event of your application being compromised, the first thing the attacker will probably do is look for the service account token to try to do more damage to the cluster.

Here’s a Dockerfile that we’ll be using for our investigation.

> cat Dockerfile.h2c  
FROM ubuntu:16.04  
RUN apt-get update && apt-get install -y curl  
CMD /bin/bash

Once we’ve built and pushed that to a repo that our cluster can reach, let’s start it up in our cluster and start digging around.

> kubectl -n h2c run -it --rm testpod --image=<accountNum>.dkr.ecr.<region>.amazonaws.com/ubuntu16.04:test01 -- /bin/bash

Let’s take a look at that token and cert that Kubernetes mounted on our behalf:

root@testpod:/# ls -lah /var/run/secrets/kubernetes.io/serviceaccount/  
total 0  
drwxrwxrwt    3 root     root         140 Jul  8 08:03 .  
drwxr-xr-x    3 root     root          28 Jul  8 08:03 ..  
drwxr-xr-x    2 root     root         100 Jul  8 08:03 ..2022_07_08_08_03_13.561282802  
lrwxrwxrwx    1 root     root          31 Jul  8 08:03 ..data -> ..2022_07_08_08_03_13.561282802  
lrwxrwxrwx    1 root     root          13 Jul  8 08:03 ca.crt -> ..data/ca.crt  
lrwxrwxrwx    1 root     root          16 Jul  8 08:03 namespace -> ..data/namespace  
lrwxrwxrwx    1 root     root          12 Jul  8 08:03 token -> ..data/token

The Kubernetes API is exposed internally to the cluster via a service, called kubernetes in the default namespace, and its parameters are injected into each running pod, via environment variables

# printenv | grep KUBERNETES  
KUBERNETES_PORT=tcp://192.168.0.1:443  
KUBERNETES_PORT_443_TCP_PORT=443  
KUBERNETES_SERVICE_PORT=443  
KUBERNETES_SERVICE_HOST=192.168.0.1  
KUBERNETES_PORT_443_TCP_PROTO=tcp  
KUBERNETES_SERVICE_PORT_HTTPS=443  
KUBERNETES_PORT_443_TCP_ADDR=192.168.0.1  
KUBERNETES_PORT_443_TCP=tcp://192.168.0.1:443

Incorporating all of this info into a CURL command and query the API server from within the pod.

root@testpod:/# export TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
root@testpod:/# export CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
 
root@testpod:/# curl -s https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT/apis --header "Authorization: Bearer $TOKEN" --cacert $CACERT
{  
 "kind": "APIGroupList",  
 "apiVersion": "v1",  
 "groups": [  
   {  
     "name": "apiregistration.k8s.io",  
     "versions": [  
       {  
         "groupVersion": "apiregistration.k8s.io/v1",  
         "version": "v1"  
       }  
     ],  
     "preferredVersion": {  
       "groupVersion": "apiregistration.k8s.io/v1",  
       "version": "v1"  
     }  
   },
...

Keep in mind that it’s the token that can be used to access the Kube-API server, however, RBAC policies (roles and rolebinding) dictate what can be accessed via the API. And our service account isn’t able to do much at this point. Here we can see we’re trying to access the events API endpoint which we don’t have access to, and we’re getting a 403 Forbidden error.

root@testpod:/# curl -s https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT/events.k8s.io/v1 --header "Authorization: Bearer $TOKEN" --cacert $CACERT 
{  
 "kind": "Status",  
 "apiVersion": "v1",  
 "metadata": {  
      
 },  
 "status": "Failure",  
 "message": "forbidden: User \"system:serviceaccount:h2c:default\" cannot get path \"/events.k8s.io/v1\"",  
 "reason": "Forbidden",  
 "details": {  
      
 },  
 "code": 403  
}
[...]

The Solution

Of course, we don’t want our Kube-API accessed unnecessarily, and why have credentials for our API lying around inside our pods for no reason. To prevent access tokens from being mounted in our pods that don’t need them we can define a custom service account and set the automountServiceAccountToken field to false on our deployment or in the service account yaml.

automountServiceAccountToken

We can specify the following parameter in the pod spec of all our workloads that don’t need to access the API.

apiVersion: apps/v1  
kind: Deployment  
metadata:  
 labels:  
   app: testpod  
 name: testpod  
 namespace: h2c  
spec:  
 replicas: 1  
 selector:  
   matchLabels:  
     app: testpod  
 template:  
   metadata:  
     labels:  
       app: testpod  
   spec:  
     automountServiceAccountToken: false        <<-----
     containers:  
     - command:  
       - sleep  
       - "30000"  
       image: <accountNum>.dkr.ecr.<region>.amazonaws.com/testpod:001  
       name: test-app

Once we’ve started our testpod, and exec into the pod, we can see if we can find the API token again.

root@testpod-7df66fb447-d82tm:/# ls -lah /var/run/secrets/kubernetes.io/serviceaccount/  
ls: cannot access '/var/run/secrets/kubernetes.io/serviceaccount/': No such file or directory

We can see that we don’t have a service account folder created inside our pod. If we look at the pod spec you’ll notice that the volumeMount and volume weren’t injected into our pod. Progress!

[...]
spec:  
 automountServiceAccountToken: false        <<--- token not mounted
 containers:  
 - command:  
   - sleep  
   - "30000"  
   image: awsAccountID.dkr.ecr.af-south-1.amazonaws.com/testpod:001 
   imagePullPolicy: IfNotPresent  
   name: test-app  
   resources: {}  
   terminationMessagePath: /dev/termination-log  
   terminationMessagePolicy: File  
 dnsPolicy: ClusterFirst  
 enableServiceLinks: true  
 [...]
 preemptionPolicy: PreemptLowerPriority  
 priority: 0  
 restartPolicy: Always  
 schedulerName: default-scheduler  
 securityContext: {}  
 serviceAccount: testpod                      <<--- non-default SA
 serviceAccountName: testpod
 terminationGracePeriodSeconds: 30  
 tolerations:  
 - effect: NoExecute  
   key: node.kubernetes.io/not-ready  
   operator: Exists  
   tolerationSeconds: 300  
 - effect: NoExecute  
   key: node.kubernetes.io/unreachable  
   operator: Exists  
   tolerationSeconds: 300  
status:  
 conditions:  
 - lastProbeTime: null
[...]

Alternatively, we can also specify the same parameter in the service account definition.

apiVersion: v1  
kind: ServiceAccount  
metadata:  
 name: testpod  
 namespace: h2c  
 automountServiceAccountToken: false
Conclusion

Ensuring we follow the principle of least privilege, we should prevent unnecessary access to the Kube-API server, and thus prevent access tokens from being injected into our pods.

It is a small, yet important improvement to our overall application security stance. Improving our security even marginally will put us in a better position than what we were in yesterday, and constantly making even tiny security improvements will ensure our cluster and the workloads that run within are kept as secure as possible.

Keep in mind that nothing in security is of a “set-it-and-forget-it” nature, and there is no silver bullet either, you need to monitor constantly, and fill the gaps where needed, with the right tool for the job.