From 6a82587b67dd70a406b27a4fddae19a9b9309ca2 Mon Sep 17 00:00:00 2001 From: tritsystem Date: Thu, 5 Nov 2026 08:32:33 -0800 Subject: [PATCH 1/3] Parse .prb with ast instead of exec in read_prb --- src/probeinterface/io.py | 47 +++++++++++++++++++++++++++++++++++-- tests/test_read_prb_safe.py | 23 ++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 tests/test_read_prb_safe.py diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index 2dbe20e3..add2c33a 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -415,6 +415,10 @@ def read_prb(file: str | Path) -> ProbeGroup: PRB format does not contain any information about the channel of the probe Only the channel index on device is given. + The file is parsed without executing it: only literal assignments are read + (numbers/strings/bools/None/list/tuple/dict, plus range()/list(range()) with + integer arguments), so a .prb file can no longer run arbitrary code. + Parameters ---------- file : Path or str @@ -424,14 +428,53 @@ def read_prb(file: str | Path) -> ProbeGroup: -------- probegroup : ProbeGroup object """ + import ast + + def _literal(node): + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, (ast.List, ast.Tuple, ast.Set)): + values = [_literal(e) for e in node.elts] + if isinstance(node, ast.Tuple): + return tuple(values) + if isinstance(node, ast.Set): + return set(values) + return values + if isinstance(node, ast.Dict): + return {_literal(k): _literal(v) for k, v in zip(node.keys, node.values)} + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)): + value = _literal(node.operand) + return -value if isinstance(node.op, ast.USub) else +value + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ("range", "list"): + if ( + node.func.id == "list" + and node.args + and isinstance(node.args[0], ast.Call) + and isinstance(node.args[0].func, ast.Name) + and node.args[0].func.id == "range" + ): + return list(_literal(node.args[0])) + args = [_literal(a) for a in node.args] + if not all(isinstance(a, int) for a in args): + raise ValueError("range() arguments must be integer literals") + return range(*args) + raise ValueError("unsupported value in PRB file") file = Path(file).absolute() assert file.is_file(), "'file given is not of type file" with file.open("r") as f: contents = f.read() - contents = re.sub(r"range\(([\d,]*)\)", r"list(range(\1))", contents) + prb = {} - exec(contents, None, prb) + for stmt in ast.parse(contents).body: + if isinstance(stmt, ast.Assign): + try: + value = _literal(stmt.value) + except ValueError: + continue + for target in stmt.targets: + if isinstance(target, ast.Name): + prb[target.id] = value prb = {k.lower(): v for (k, v) in prb.items()} if "channel_groups" not in prb: diff --git a/tests/test_read_prb_safe.py b/tests/test_read_prb_safe.py new file mode 100644 index 00000000..1a5d289a --- /dev/null +++ b/tests/test_read_prb_safe.py @@ -0,0 +1,23 @@ +"""Class guard for the probeinterface read_prb() exec RCE (CWE-95).""" +import os, re, inspect +from probeinterface import read_prb +import probeinterface.io as pio + + +def test_read_prb_does_not_execute(tmp_path): + marker = tmp_path / "pwned.txt" + p = tmp_path / "x.prb" + p.write_text( + "import os\n" + f"os.system('echo x> {marker.as_posix()}')\n" + "channel_groups = {0: {'channels': [0], 'geometry': {0: [0.0, 0.0]}}}\n" + ) + pg = read_prb(str(p)) + assert not marker.exists(), "read_prb executed code from the .prb file (CWE-95)" + assert pg is not None and len(pg.probes) == 1 # channel_groups still parsed + + +def test_read_prb_source_has_no_exec_eval(): + src = inspect.getsource(pio.read_prb) + assert not re.search(r"(? Date: Thu, 5 Nov 2026 16:33:01 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_read_prb_safe.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_read_prb_safe.py b/tests/test_read_prb_safe.py index 1a5d289a..26b92593 100644 --- a/tests/test_read_prb_safe.py +++ b/tests/test_read_prb_safe.py @@ -1,4 +1,5 @@ """Class guard for the probeinterface read_prb() exec RCE (CWE-95).""" + import os, re, inspect from probeinterface import read_prb import probeinterface.io as pio From fc46521bb0c9fcf526f44809875bd3cfc726e926 Mon Sep 17 00:00:00 2001 From: tritsystem Date: Fri, 6 Nov 2026 00:48:52 -0800 Subject: [PATCH 3/3] read_prb: accept numpy scalar wrappers written by write_prb write_prb emits np.int64(...)/np.float64(...) under numpy>=2 (scalar repr change); the literal parser now unwraps numpy scalar/array constructors to plain Python values without executing the call, fixing the test_prb round-trip while keeping arbitrary code refused. --- src/probeinterface/io.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index add2c33a..aa8dbee9 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -458,6 +458,18 @@ def _literal(node): if not all(isinstance(a, int) for a in args): raise ValueError("range() arguments must be integer literals") return range(*args) + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id in ("np", "numpy") + and node.args + ): + # numpy scalar/array wrapper on literal data, e.g. np.int64(0) or + # np.float64(1.5) written by write_prb under numpy >= 2. Unwrap to a + # plain Python value from the literal argument; the numpy call is + # never executed, so no code runs. + return _literal(node.args[0]) raise ValueError("unsupported value in PRB file") file = Path(file).absolute()