Skip to content

Commit f037c19

Browse files
authored
jazzy: Backport Patch CVE-2024-42002 (#998)
Signed-off-by: Florencia <[email protected]> Signed-off-by: Michael Carroll <[email protected]>
1 parent 567b488 commit f037c19

File tree

4 files changed

+236
-12
lines changed

4 files changed

+236
-12
lines changed

ros2topic/package.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
<license>Apache License 2.0</license>
1414
<license>BSD-3-Clause</license> <!-- ros2topic/verb/delay.py|hz.py|bw.py are BSD -->
15+
<license>MIT License</license> <!-- ros2topic/eval uses MIT License -->
1516

1617
<author email="[email protected]">Aditya Pande</author>
1718
<author email="[email protected]">Dirk Thomas</author>
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# MIT License
2+
3+
# Copyright (c) 2022 Yaroslav Polyakov
4+
5+
# Permission is hereby granted, free of charge, to any person obtaining a copy
6+
# of this software and associated documentation files (the "Software"), to deal
7+
# in the Software without restriction, including without limitation the rights
8+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
# copies of the Software, and to permit persons to whom the Software is
10+
# furnished to do so, subject to the following conditions:
11+
12+
# The above copyright notice and this permission notice shall be included in all
13+
# copies or substantial portions of the Software.
14+
15+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
# SOFTWARE.
22+
23+
"""Safe user-supplied python expression evaluation."""
24+
25+
import ast
26+
import dataclasses
27+
28+
__version__ = '2.0.3'
29+
30+
31+
class EvalException(Exception):
32+
pass
33+
34+
35+
class ValidationException(EvalException):
36+
pass
37+
38+
39+
class CompilationException(EvalException):
40+
exc = None
41+
42+
def __init__(self, exc):
43+
super().__init__(exc)
44+
self.exc = exc
45+
46+
47+
class ExecutionException(EvalException):
48+
exc = None
49+
50+
def __init__(self, exc):
51+
super().__init__(exc)
52+
self.exc = exc
53+
54+
55+
@dataclasses.dataclass
56+
class EvalModel:
57+
"""eval security model."""
58+
59+
nodes: list = dataclasses.field(default_factory=list)
60+
allowed_functions: list = dataclasses.field(default_factory=list)
61+
imported_functions: dict = dataclasses.field(default_factory=dict)
62+
attributes: list = dataclasses.field(default_factory=list)
63+
64+
def clone(self):
65+
return EvalModel(**dataclasses.asdict(self))
66+
67+
68+
class SafeAST(ast.NodeVisitor):
69+
"""AST-tree walker class."""
70+
71+
def __init__(self, model: EvalModel):
72+
self.model = model
73+
74+
def generic_visit(self, node):
75+
"""Check node, raise exception if node is not in whitelist."""
76+
if type(node).__name__ in self.model.nodes:
77+
78+
if isinstance(node, ast.Attribute):
79+
if node.attr not in self.model.attributes:
80+
raise ValidationException(
81+
'Attribute {aname} is not allowed'.format(
82+
aname=node.attr))
83+
84+
if isinstance(node, ast.Call):
85+
if isinstance(node.func, ast.Name):
86+
if node.func.id not in self.model.allowed_functions and \
87+
node.func.id not in self.model.imported_functions:
88+
raise ValidationException(
89+
'Call to function {fname}() is not allowed'.format(
90+
fname=node.func.id))
91+
else:
92+
# Call to allowed function. good. No exception
93+
pass
94+
elif isinstance(node.func, ast.Attribute):
95+
pass
96+
# print("attr:", node.func.attr)
97+
else:
98+
raise ValidationException('Indirect function call')
99+
100+
ast.NodeVisitor.generic_visit(self, node)
101+
else:
102+
raise ValidationException(
103+
'Node type {optype!r} is not allowed. (whitelist it manually)'.format(
104+
optype=type(node).__name__))
105+
106+
107+
base_eval_model = EvalModel(
108+
nodes=[
109+
# 123, 'asdf'
110+
'Num', 'Str',
111+
# any expression or constant
112+
'Expression', 'Constant',
113+
# == ...
114+
'Compare', 'Eq', 'NotEq', 'Gt', 'GtE', 'Lt', 'LtE',
115+
# variable name
116+
'Name', 'Load',
117+
'BinOp',
118+
'Add', 'Sub', 'USub',
119+
'Subscript', 'Index', # person['name']
120+
'BoolOp', 'And', 'Or', 'UnaryOp', 'Not', # True and True
121+
'In', 'NotIn', # "aaa" in i['list']
122+
'IfExp', # for if expressions, like: expr1 if expr2 else expr3
123+
'NameConstant', # for True and False constants
124+
'Div', 'Mod'
125+
],
126+
)
127+
128+
129+
mult_eval_model = base_eval_model.clone()
130+
mult_eval_model.nodes.append('Mul')
131+
132+
133+
class Expr():
134+
def __init__(self, expr, model=None, filename=None):
135+
136+
self.expr = expr
137+
self.model = model or base_eval_model
138+
139+
try:
140+
self.node = ast.parse(self.expr, '<usercode>', 'eval')
141+
except SyntaxError as e:
142+
raise CompilationException(e)
143+
144+
v = SafeAST(model=self.model)
145+
v.visit(self.node)
146+
147+
self.code = compile(self.node, filename or '<usercode>', 'eval')
148+
149+
def safe_eval(self, ctx=None):
150+
151+
try:
152+
result = eval(self.code, self.model.imported_functions, ctx)
153+
except Exception as e:
154+
raise ExecutionException(e)
155+
156+
return result
157+
158+
def __str__(self):
159+
return ('Expr(expr={expr!r})'.format(expr=self.expr))

ros2topic/ros2topic/verb/hz.py

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from ros2topic.api import get_msg_class
4646
from ros2topic.api import positive_int
4747
from ros2topic.api import TopicNameCompleter
48+
from ros2topic.eval import base_eval_model, Expr
4849
from ros2topic.verb import VerbExtension
4950

5051
DEFAULT_WINDOW_SIZE = 10000
@@ -86,20 +87,70 @@ def main(self, *, args):
8687
return main(args)
8788

8889

89-
def main(args):
90-
topic = args.topic_name
91-
if args.filter_expr:
92-
def expr_eval(expr):
93-
def eval_fn(m):
94-
return eval(expr)
95-
return eval_fn
96-
filter_expr = expr_eval(args.filter_expr)
97-
else:
98-
filter_expr = None
90+
def _get_nested_messages(msg_class):
91+
all_attributes = list(msg_class.__slots__)
92+
for attr in msg_class.__slots__:
93+
value = getattr(msg_class, attr)
94+
if hasattr(value, '__slots__'):
95+
nested_messages = _get_nested_messages(value)
96+
all_attributes.extend(nested_messages)
97+
return all_attributes
98+
99+
def _setup_base_safe_eval():
100+
safe_eval_model = base_eval_model.clone()
101+
102+
# extend base_eval_model
103+
safe_eval_model.nodes.extend(['Call', 'Attribute', 'List', 'Tuple', 'Dict', 'Set',
104+
'ListComp', 'DictComp', 'SetComp', 'comprehension',
105+
'Mult', 'Pow', 'boolop', 'mod', 'Invert',
106+
'Is', 'IsNot', 'FloorDiv', 'If', 'For'])
107+
108+
# allow-list safe Python built-in functions
109+
safe_builtins = [
110+
'abs', 'all', 'any', 'bin', 'bool', 'chr', 'cmp', 'divmod', 'enumerate',
111+
'float', 'format', 'hex', 'id', 'int', 'isinstance', 'issubclass',
112+
'len', 'list', 'long', 'max', 'min', 'ord', 'pow', 'range', 'reversed',
113+
'round', 'slice', 'sorted', 'str', 'sum', 'tuple', 'type', 'unichr',
114+
'unicode', 'xrange', 'zip', 'filter', 'dict', 'set', 'next'
115+
]
116+
117+
safe_eval_model.allowed_functions.extend(safe_builtins)
118+
return safe_eval_model
119+
120+
def _setup_safe_eval(safe_eval_model, msg_class, topic):
121+
# allow-list topic builtins, msg attributes
122+
topic_builtins = [i for i in dir(topic) if not i.startswith('_')]
123+
safe_eval_model.attributes.extend(topic_builtins)
124+
# recursively get all nested message attributes
125+
msg_attributes = _get_nested_messages(msg_class)
126+
safe_eval_model.attributes.extend(msg_attributes)
127+
return safe_eval_model
128+
99129

130+
def main(args):
100131
with DirectNode(args) as node:
101-
_rostopic_hz(node.node, topic, window_size=args.window_size, filter_expr=filter_expr,
102-
use_wtime=args.use_wtime)
132+
topics = args.topic_name
133+
filter_expr = None
134+
# set up custom safe eval model for filter expression
135+
if args.filter_expr:
136+
safe_eval_model = _setup_base_safe_eval()
137+
for topic in topics:
138+
msg_class = get_msg_class(
139+
node, topic, blocking=True, include_hidden_topics=True)
140+
if msg_class is None:
141+
continue
142+
143+
safe_eval_model = _setup_safe_eval(safe_eval_model, msg_class, topic)
144+
145+
def expr_eval(expr):
146+
def eval_fn(m):
147+
safe_expression = Expr(expr, model=safe_eval_model)
148+
return eval(safe_expression.code)
149+
return eval_fn
150+
filter_expr = expr_eval(args.filter_expr)
151+
152+
_rostopic_hz(node.node, topics, qos_args=args, window_size=args.window_size,
153+
filter_expr=filter_expr, use_wtime=args.use_wtime)
103154

104155

105156
class ROSTopicHz(object):

ros2topic/test/test_cli.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,19 @@ def test_filtered_topic_hz(self):
839839
average_rate = float(average_rate_line_pattern.match(head_line).group(1))
840840
assert math.isclose(average_rate, 0.5, rel_tol=1e-2)
841841

842+
# check that use of eval() on hz verb cannot be exploited
843+
try:
844+
self.launch_topic_command(
845+
arguments=[
846+
'hz',
847+
'--filter',
848+
'__import__("os").system("cat /etc/passwd")',
849+
'/chatter'
850+
]
851+
)
852+
except ValueError as e:
853+
self.assertIn('Attribute system is not allowed', str(e))
854+
842855
@launch_testing.markers.retry_on_failure(times=5, delay=1)
843856
def test_topic_bw(self):
844857
with self.launch_topic_command(arguments=['bw', '/defaults']) as topic_command:

0 commit comments

Comments
 (0)