Privilege escalation / Improper file permissions leading to config tampering
Description
The commit changes the Migrate() behavior so that the destination file permissions are derived from the source file permissions, masked to remove executable bits (sourceInfo.Mode().Perm() & 0666). Previously, the destination file was always created with mode 0666, regardless of the source, which made the migrated file world-writable. A world-writable config file can be tampered with by other users on the same host, potentially allowing an attacker to inject malicious configuration content and, in some scenarios, lead to privilege escalation when a process reads and uses that config. The new behavior reduces privilege risk by aligning the destination permissions with the source (and explicitly removing executable bits) and by ensuring permissions are controlled by the source, not a fixed permissive value. Tests were added to assert that destination permissions mirror source permissions (subject to the 0666 mask).
Proof of Concept
Proof-of-Concept (exploitation scenario before fix):
Assumptions:
- A Kubernetes client config migration writes a destination file from a source config file during a migration step.
- The destination path is in a shared area and writable by multiple users on the same node (world-writable directory).
- The attacker has write access to the destination file once migrated but not necessarily to the source file.
Goal:
Demonstrate that the old behavior (destination perm 0666 regardless of source) allows an attacker to tamper with the migrated config, potentially altering cluster definitions or credentials used by a privileged process.
Steps:
1) Set up a shared temp directory and a source config file with content representing a kubeconfig (simplified for PoC).
mkdir -p /tmp/migrate_test/shared
chmod 0777 /tmp/migrate_test/shared
printf 'apiVersion: v1\nkind: Config\nclusters: []\n' > /tmp/migrate_test/shared/source.conf
chmod 0644 /tmp/migrate_test/shared/source.conf
2) Simulate the old migration behavior (destination always created with 0666):
DEST=/tmp/migrate_test/shared/dest.conf
cp /tmp/migrate_test/shared/source.conf "$DEST"
chmod 0666 "$DEST" # old behavior
echo "DEST PERMS: $(stat -c %A "$DEST")"
3) Attacker tampering:
# As attacker (same user in PoC, but imagine another unprivileged user on the same host)
echo 'malicious: true' >> "$DEST"
echo "DEST AFTER ATTACK PERMS: $(stat -c %A "$DEST")"
4) Privileged process reads the config:
# In real scenario, a privileged process would load the kubeconfig from $DEST. Since the attacker could modify it in step 3 due to 0666, the content could be altered to point to a malicious cluster or contain crafted credentials.
cat "$DEST" | head -n 5
Expected outcome pre-fix: The destination file is world-writable (0666), so non-privileged users can modify it, enabling tampering of the config used by privileged processes.
Post-fix behavior (for reference): If the source has restrictive permissions, the destination will be created with source permissions masked by 0666, reducing the risk of world-writable files. For example, if source.conf has 0644, destination will be 0644 (no executable bits and no world-writability beyond what the source allows).
Notes:
- The PoC illustrates why an always-0666 destination is risky in multi-user environments and why aligning destination permissions with the source (and stripping executables) mitigates the risk.
Commit Details
Author: Brian Pursley
Date: 2026-06-29 09:18 UTC
Message:
Migrate: use source file permission when writing destination file (#138142)
* Migrate: use source file permission when writing destination file
* Mask migrated config file with 0666 to ensure the created file is never executable.
Triage Assessment
Vulnerability Type: Privilege escalation
Confidence: MEDIUM
Reasoning:
The commit adjusts file deployment to align destination permissions with the source, masking migrated config files with 0666 to ensure they are not executable. This reduces risk of config files being unexpectedly executable, which could enable unintended code execution or privilege escalation. It's a security hardening change rather than a feature or bug fix, hence a moderate confidence.
Verification Assessment
Vulnerability Type: Privilege escalation / Improper file permissions leading to config tampering
Confidence: MEDIUM
Affected Versions: <= v1.36.0-beta.0
Code Diff
diff --git a/staging/src/k8s.io/client-go/tools/clientcmd/loader.go b/staging/src/k8s.io/client-go/tools/clientcmd/loader.go
index d1d0a82954c52..398de04f69f16 100644
--- a/staging/src/k8s.io/client-go/tools/clientcmd/loader.go
+++ b/staging/src/k8s.io/client-go/tools/clientcmd/loader.go
@@ -300,7 +300,8 @@ func (rules *ClientConfigLoadingRules) Migrate() error {
return err
}
- if sourceInfo, err := os.Stat(source); err != nil {
+ sourceInfo, err := os.Stat(source)
+ if err != nil {
if os.IsNotExist(err) || os.IsPermission(err) {
// if the source file doesn't exist or we can't access it, there's no work to do.
continue
@@ -316,8 +317,8 @@ func (rules *ClientConfigLoadingRules) Migrate() error {
if err != nil {
return err
}
- // destination is created with mode 0666 before umask
- err = os.WriteFile(destination, data, 0666)
+ // destination created with source perm, but never executable, and subject to umask
+ err = os.WriteFile(destination, data, sourceInfo.Mode().Perm()&0666)
if err != nil {
return err
}
diff --git a/staging/src/k8s.io/client-go/tools/clientcmd/loader_test.go b/staging/src/k8s.io/client-go/tools/clientcmd/loader_test.go
index 22006aa6c9b97..34fe4aa47138e 100644
--- a/staging/src/k8s.io/client-go/tools/clientcmd/loader_test.go
+++ b/staging/src/k8s.io/client-go/tools/clientcmd/loader_test.go
@@ -656,6 +656,19 @@ func TestMigratingFile(t *testing.T) {
if !reflect.DeepEqual(sourceContent, destinationContent) {
t.Errorf("source and destination do not match")
}
+
+ // destination file permissions should be the same as the source file permissions
+ sourceInfo, err := os.Stat(sourceFile.Name())
+ if err != nil {
+ t.Errorf("unexpected error %v", err)
+ }
+ destinationInfo, err := os.Stat(destinationFile.Name())
+ if err != nil {
+ t.Errorf("unexpected error %v", err)
+ }
+ if destinationInfo.Mode().Perm() != sourceInfo.Mode().Perm() {
+ t.Errorf("expected permissions %v, got %v", sourceInfo.Mode().Perm(), destinationInfo.Mode().Perm())
+ }
}
func TestMigratingFileLeaveExistingFileAlone(t *testing.T) {