Published: 18/09/2026
7 minute read
Experience of Reporting a Privilege Escalation Vulnerability for IONOS managed Kubernetes
While dissecting Kubernetes internals regarding node-join process and in-cluster endpoints, I looked at various managed Kubernetes offerings, one of which was IONOS, a German cloud-provider. They offer managed Kubernetes, which includes a free managed controlplane; users only pay for the compute nodes that get automatically updated (replaced) every other week.
I wanted to know how they join their worker nodes on upgrades or scaling events. To investigate, I launched a debug pod on a node and started looking for cloud-init scripts.
kubectl debug node/<my-node> --image debian -it --profile sysadmin -- bash
chroot /host
journalctl -u cloud-init -bI found that IONOS Cloud does not seem to have a METADATA-Service which cloud-init discovers and fetches instance, vendor and user-data from. Instead, they rely on the nocloud provider and inject the user-data directly into the VMs filesystem at /var/lib/cloud/instances/nocloud/user-data.txt. The configuration is discovered by cloud-init and processed. The user-data.txt wrote multiple files to the root filesystem of that VM and executed some scripts.
A file called kubeconfig written to a user directory caught my interest. Why does a worker node need a kubeconfig? Only the kubelet requires direct communication with the Kubernetes-apiserver and this is mostly done via bootstrap-token or a webhook flow.

I looked at the destination filepath specified in the user-data.txt and did not find the file. Nevertheless, the content was still there in the user-data.txt so I extracted it and a quick kubectl auth command later I knew I was admin.
> kubectl --kubeconfig extracted-kubeconfig.yaml auth whoami
ATTRIBUTE VALUE
Username kubernetes-admin
Groups [system:masters system:authenticated]Analyzing the base64ed certificate with openssl showed the same information just as well as the lifetime of that credential - 10 years.

The implication of this finding is that EVERY worker node on IONOS managed Kubernetes has Kubernetes admin-credentials lying around on the node filesystem.
Every user that can create pods, either directly or via a higher-level API, can spawn pods, mount the node filesystem via hostPath and elevate themselves to kubernetes-admin.
Any developer can become just as powerful as the cluster-admin, supply chain compromised operators hit the jackpot and any attacker that has gained pod create rights is celebrating. This substantially undermines workload and access isolation in multi-tenant clusters.
Affected Users and Mitigations
Since the vulnerability is not in the upstream Kubernetes code, only IONOS Kubernetes customers are affected. The privilege escalation was tested successfully on the managed Kubernetes versions v1.32 and v1.33.
Users should check the user-data.txt on their worker nodes for the credential and might want to set up a DaemonSet to clean up the user-data.txt file.
I reported the finding via IONOS responsible disclosure process with a full breakdown and evidence of my finding.
After five days I received confirmation that IONOS has received my report and started investigating. More than a month later I was notified that they reproduced the issue.
While they were concerned by the long-lived credential, they classified a problem as not critical since an equivalent kubeconfig.yaml is presented to users via the IONOS Data Center Designer (DCD) offers the same level of access.
NOTE: The IONOS DCD user identities are not bound to the provided
kubeconfig.yamlfor managing the Kubernetes APIs. Every user has the samekubeconfig.yaml, the same rights and shows up as the same user in the audit log. According to my knowledge, it is not documented on how admins can revoke a user’s access to a cluster.
I responded with my objection to that statement, explaining the attack vector again, and pointed out CVE-2025-5187 (6.7 rating) which only allowed a node to delete itself.
The Kubernetes project even separated the admin.conf and super-admin.conf kubeconfig on controlplanes for more fine grained control. Here every worker has such a file lying around.
Since then, I have not heard back from IONOS.
But while preparing this public disclosure post (according to my requested disclosure terms,) I rechecked an IONOS Kubernetes node and did not find a kubeconfig in the user-data.txt anymore.
Since IONOS replaces the nodes every week, I conclude that they implemented a fix in the first week of September. They did not inform me or publicly acknowledge a finding yet.
EDIT: I informed IONOS about this post; they acknowledged it and confirmed that they implemented a fix.
But I found an other interesting decision:
Kubernetes JWTs used for service accounts have a maximum lifetime duration set via the --service-account-max-token-expiration ARG on the Kubernetes-apiserver. The default value is one hour. While looking at the rootfs of the node again, I found that IONOS must have set this to one year, because every JWT is valid for that long. (BTW: OpenShift does the same 🤔)
I do not know why this was actively set to such a high value. The good thing is that the Kubernetes project is actively working on safeguards for such misconfigurations with e.g Node and Pod bound service-account-tokens. Nevertheless, they still can be misused, now it is at least audible.
Communication during the disclosure process was slow, and follow-up was limited. I do not intend to pressure further investigations on IONOS products.
But I know that the disclosure process does not always have to look like this. We’ve recently seen an excellent behavior from Baseten to a Vulnerability reported from strix.ai.
What was the Kubeconfig used for?
One may ask why there was an admin kubeconfig.yaml on workers in the first place. Legitimate!
IONOS uses an old script from cluster-api that they extended for node joining. The extended part included some kubectl commands to label the node with metadata information regarding region, zone and DCD-ID. Later the script deletes the kubeconfig but leaves the user-data.txt behind.
Labeling is normally a task is done by the cloud-controller-manager, but since I did not find a service-account for it and due to the fact that the LoadBalancer-Service is just a second IP bound to one of the nodes, I suspect that IONOS does not run one.
They now seem to use the kubeadm.k8s.io/v1beta3 JoinConfiguration for labeling.
I included the current (redacted) user-data.txt (Gist) document that IONOS uses for node-join below. Even with the kubectl label node commands gone, there still is weird stuff in there. I do not know why they proxy all kubelet traffic through a local static nginx pod, what the root-password is or whose SSH-key that is. But after the last exchange with IONOS I did not pursue any investigations further.
Disclosure Timeline
| Date | Action |
|---|---|
| [2026-07-14] | Vulnerability discovered |
| [2026-07-16] | Initial report sent to vendor |
| [2026-07-21] | Vendor acknowledgment received |
| [2026-08-26] | Vendor confirms/reproduces issue |
| [2026-09-04] | Patch/fix released |
| [2026-09-18] | Public disclosure |
Appendix
user-data.txt
#cloud-config
hostname: my-node
users:
- name: root
lock_passwd: false
hashed_passwd: $6$e0Uel8yf/6KFZaCK$xxx/xxx/
ntp:
enabled: true
ntp_client: systemd-timesyncd
servers:
- ntp.ubuntu.com
write_files:
# Manage networks
# Manage public network (used by K8s), set DNS resolvers and route metric
- content: |
[Match]
Name=ens6
[Network]
LinkLocalAddressing=no
IPv6AcceptRA=no
DHCP=ipv4
[DHCP]
RouteMetric=10
[Route]
Destination=100.64.0.0/18
Scope=link
[Route]
Destination=100.96.0.0/12
Scope=link
owner: root:root
path: /etc/systemd/network/10-ens6.network
permissions: '0644'
# Manage DNS resolver for cluster.local, adds CoreDNS IP as resolver
- content: |
[Match]
Name=tunl0
[Network]
DNS=100.64.3.114
Domains=cluster.local
owner: root:root
path: /etc/systemd/network/12-cluster-local-dns.network
permissions: '0644'
# Managed authorized keys
- content: |
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJebNzmlXyAAc7qFiUbbpNBnzRJIYMiDGClDZDNO7GUM
owner: root:root
path: /etc/ssh/authorized_keys_managed
permissions: '0644'
# CSI Metadata config
- content: |
{
"datacenter-id": "xxx"
}
owner: root:root
path: /etc/ie-csi/cfg.json
permissions: '0644'
# Configure nginx
- content: |
error_log stderr notice;
worker_processes 2;
worker_rlimit_nofile 130048;
worker_shutdown_timeout 10s;
events {
multi_accept on;
use epoll;
worker_connections 16384;
}
stream {
server {
listen 127.0.0.1:6443;
resolver 127.0.0.53;
set $kube_apiserver cp-xxx.k8s.de-fra.ionos.com:101010;
proxy_pass $kube_apiserver;
proxy_timeout 10m;
proxy_connect_timeout 1s;
}
}
http {
aio threads;
aio_write on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 75s;
keepalive_requests 100;
reset_timedout_connection on;
server_tokens off;
autoindex off;
server {
listen 8081;
location /healthz {
access_log off;
return 200;
}
}
}
owner: root:root
path: /etc/nginx/nginx.conf
permissions: '0644'
# Configure nginx proxy
- content: |
---
apiVersion: v1
kind: Pod
metadata:
name: nginx-proxy
namespace: kube-system
labels:
addonmanager.kubernetes.io/mode: Reconcile
k8s-app: kube-nginx
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
dnsConfig:
nameservers:
- 212.227.123.17
- 212.227.123.16
nodeSelector:
kubernetes.io/os: linux
priorityClassName: system-node-critical
containers:
- name: nginx-proxy
image: registry.infra.cluster.ionos.com/mk8s-public/images/docker.io/library/nginx:1.31.2-clean.1
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: 25m
memory: 32M
securityContext:
privileged: true
livenessProbe:
httpGet:
path: /healthz
port: http
readinessProbe:
httpGet:
path: /healthz
port: http
startupProbe:
httpGet:
path: /healthz
port: http
failureThreshold: 30
periodSeconds: 10
ports:
- containerPort: 8081
hostPort: 8081
name: http
protocol: TCP
volumeMounts:
- mountPath: /etc/nginx
name: etc-nginx
readOnly: true
volumes:
- name: etc-nginx
hostPath:
path: /etc/nginx
owner: root:root
path: /etc/kubernetes/manifests/nginx-proxy.yml
permissions: '0644'
# Configure kubeadm
- content: |
apiVersion: kubeadm.k8s.io/v1beta3
kind: JoinConfiguration
caCertPath: /etc/kubernetes/pki/ca.crt
patches:
directory: /etc/kubernetes/patches
discovery:
bootstrapToken:
apiServerEndpoint: cp-xxx.k8s.de-fra.ionos.com:101010
token: iy4wa1.xxx
caCertHashes:
- sha256:xxx
timeout: 5m0s
nodeRegistration:
name: my-node
criSocket: unix:///run/containerd/containerd.sock
taints: null
kubeletExtraArgs:
runtime-cgroups: /systemd/system.slice
kubelet-cgroups: /systemd/system.slice
system-reserved: "memory=1024Mi"
v: "2"
cloud-provider: external
resolv-conf: /etc/kubernetes/resolv.conf
node-labels: >-
cloud.ionos.com/node-id=NODE_ID_PLACEHOLDER,
cloud.ionos.com/datacenter-id=xxx,
cloud.ionos.com/nodepool-id=xxx,
cloud.ionos.com/nodepool-name=xxx,
pool=primary,project=xxx
owner: root:root
path: /etc/kubernetes/kubeadm.conf
permissions: '0600'
# Create manage script for resolve.conf for kubelet
- content: |
#!/bin/bash
set -x
SRC=/run/systemd/resolve/resolv.conf
if ! grep -q '^nameserver ' "$SRC"; then
echo "manage-k8s-resolvconf: no nameservers found in $SRC, not overwriting /etc/kubernetes/resolv.conf" >&2
exit 0
fi
RESOLV_CONF_TMP=$(mktemp)
cp "$SRC" $RESOLV_CONF_TMP
sed -i '/nameserver 100.64.3.114/d' $RESOLV_CONF_TMP
cp $RESOLV_CONF_TMP /etc/kubernetes/resolv.conf
rm $RESOLV_CONF_TMP
exit 0
owner: root:root
path: /usr/bin/manage-k8s-resolvconf.sh
permissions: '0755'
# /run/systemd/resolve/resolv.conf systemd path unit
- content: |
[Unit]
Description=Watcher service for /run/systemd/resolve/resolv.conf
Before=systemd-networkd.service
StartLimitIntervalSec=0
[Path]
PathChanged=/run/systemd/resolve/
Unit=k8s.resolvconf.manager.service
[Install]
WantedBy=network-online.target
owner: root:root
path: /etc/systemd/system/k8s.resolvconf.manager.path
permissions: '0755'
# /run/systemd/resolve/resolv.conf systemd service unit
- content: |
[Unit]
Description=Calls the k8s resolvconf manager script
[Service]
Type=simple
ExecStart=/usr/bin/manage-k8s-resolvconf.sh
[Install]
WantedBy=network-online.target
owner: root:root
path: /etc/systemd/system/k8s.resolvconf.manager.service
permissions: '0755'
# configure Containerd
- content: |
[plugins."io.containerd.grpc.v1.cri"]
sandbox_image = "registry.infra.cluster.ionos.com/mk8s-public/images/registry.k8s.io/pause:3.10"
owner: root:root
path: /etc/containerd/config.toml
append: true
permissions: '0644'
# kubeadm kubelet-config patch (per node-pool version strip)
- content: |
{"crashLoopBackOff":null,"imagePullCredentialsVerificationPolicy":null}
owner: root:root
path: /etc/kubernetes/patches/kubeletconfiguration+merge.json
permissions: '0644'
# Node bootstrap script
- content: |
#!/bin/bash
# shellcheck disable=SC1083 # Disable warning caused by template markers
# Helper functions taken from
# Log an error and exit.
# Args:
# $1 Message to log with the error
# $2 The error code to return
log::error_exit() {
local message="${1}"
local code="${2}"
log::error "${message}"
log::info "Resetting kubeadm"
kubeadm reset -f || true
log::error "bootstrap script $0 exiting with status ${code}"
exit "${code}"
}
# Log an error but keep going.
log::error() {
local message="${1}"
timestamp=$(date --iso-8601=seconds)
echo "!!! [${timestamp}] ${1}" >&2
shift
for message; do
echo " ${message}" >&2
done
}
# Print a status line. Formatted to show up in a stream of output.
log::info() {
timestamp=$(date --iso-8601=seconds)
echo "+++ [${timestamp}] ${1}"
shift
for message; do
echo " ${message}"
done
}
check_kubeadm_command() {
local command="${1}"
local code="${2}"
case ${code} in
"0")
log::info "kubeadm reported successful execution for ${command}"
;;
"1")
log::error "kubeadm reported failed action(s) for ${command}"
;;
"2")
log::error "kubeadm reported preflight check error during ${command}"
;;
"3")
log::error_exit "kubeadm reported validation error for ${command}" "${code}"
;;
*)
log::error "kubeadm reported unknown error ${code} for ${command}"
;;
esac
}
function retry-command() {
n=0
local kubeadm_return
until [ $n -ge 10 ]; do
log::info "running '$*'"
# shellcheck disable=SC1083
"$@" --config=/etc/kubernetes/kubeadm.conf --v=2
kubeadm_return=$?
check_kubeadm_command "'$*'" "${kubeadm_return}"
if [ ${kubeadm_return} -eq 0 ]; then
break
fi
# We allow preflight errors to pass
if [ ${kubeadm_return} -eq 2 ]; then
break
fi
n=$((n + 1))
sleep_jitter 10
done
if [ ${kubeadm_return} -ne 0 ]; then
log::error_exit "too many errors, exiting" "${kubeadm_return}"
fi
}
sleep_jitter() {
local value="${1}"
sleep $((value + RANDOM % 10))
}
log::info "Restart network services"
systemctl restart systemd-resolved
log::info "Install and enable resolv.conf services"
systemctl daemon-reload
systemctl enable --now k8s.resolvconf.manager.path
systemctl enable --now k8s.resolvconf.manager.service
log::info "Starting kubernetes node join"
systemctl stop kubelet
#
while ! curl -sk --max-time 10 https://cp-xxx.k8s.de-fra.ionos.com:101010/healthz >/dev/null; do
log::info "Waiting for cluster"
sleep_jitter 1;
done
sed -i "s/NODE_ID_PLACEHOLDER/$(cat /sys/class/dmi/id/product_uuid)/g" /etc/kubernetes/kubeadm.conf
rm -f /etc/kubernetes/kubelet.conf
retry-command kubeadm join phase kubelet-start
chmod 0600 /var/lib/kubelet/config.yaml
n=0
until [ -f /etc/kubernetes/kubelet.conf ]; do
if [ $n -ge 15 ]; then
log::error_exit "/etc/kubernetes/kubelet.conf did not appear after 30 seconds" 1
fi
log::info "Waiting for /etc/kubernetes/kubelet.conf"
sleep 2
n=$((n + 1))
done
if ! grep -q 'server: https://127.0.0.1:6443' /etc/kubernetes/kubelet.conf; then
sed -i 's/server: https:\/\/.*$/server: https:\/\/127.0.0.1:6443/g' /etc/kubernetes/kubelet.conf
systemctl restart kubelet.service
fi
#
if kubectl wait --kubeconfig /etc/kubernetes/kubelet.conf nodes my-node --for=condition=Ready --timeout 3m; then
log::info "Node is Ready"
else
log::error "Node did not get READY yet, rebooting"
reboot
fi
log::info "Script $0 finished"
exit 0
owner: root:root
path: /usr/local/bin/node-bootstrap-script
permissions: '0755'
bootcmd:
- echo my-node > /etc/hostname
- hostname my-node
runcmd:
- /usr/local/bin/node-bootstrap-script