Skip to content

Commit c0d1ec1

Browse files
garciadiaspre-commit-ci[bot]ericspod
authored
Harden nnUNetV2Runner against code execution (Project-MONAI#9086)
### Description Harden `nnUNetV2Runner` against two code-execution vectors: - `predict_ensemble_postprocessing` unpickles the `postprocessing_file` read from `inference_information.json` with raw pickle and no allow list. Emit a trust-boundary warning before unpickling (GHSA-8f32-8649-rv87). - `train_parallel` joined each training command with `shlex.join` (POSIX quoting) into a `shell=True` string; on Windows `cmd.exe` ignores single quotes, so a training kwarg could inject commands. Run each command as an argv list with `shell=False` instead (GHSA-qv7x-wq36-2cm7). ### Types of changes - [x] Non-breaking change - [x] New tests added to cover the changes. --------- Signed-off-by: R. Garcia-Dias <rafaelagd@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com>
1 parent aff7aad commit c0d1ec1

2 files changed

Lines changed: 167 additions & 13 deletions

File tree

monai/apps/nnunet/nnunetv2_runner.py

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import re
1818
import shlex
1919
import subprocess
20+
import warnings
21+
from concurrent.futures import ThreadPoolExecutor
2022
from typing import Any
2123

2224
import monai
@@ -710,7 +712,11 @@ def train_parallel(
710712
**kwargs: Any,
711713
) -> None:
712714
"""
713-
Create the line command for subprocess call for parallel training.
715+
Launch subprocesses for parallel training.
716+
717+
The commands for each GPU run sequentially on that device, while different devices run in
718+
parallel. Each stage waits for all of its devices to finish before the next stage starts.
719+
714720
Note: to set the number of GPUs to use, use ``gpu_id_for_all`` instead of the `CUDA_VISIBLE_DEVICES`
715721
environment variable.
716722
@@ -741,17 +747,19 @@ def train_parallel(
741747
f"log '.txt' inside '{os.path.join(self.nnunet_results, self.dataset_name)}'"
742748
)
743749
for stage in all_cmds:
744-
processes = []
745-
for device_id in stage:
746-
if not stage[device_id]:
747-
continue
748-
cmd_str = "; ".join(shlex.join(cmd) for cmd, _ in stage[device_id])
749-
env = stage[device_id][0][1]
750-
logger.info(f"Current running command on GPU device {device_id}:\n{cmd_str}\n")
751-
processes.append(subprocess.Popen(cmd_str, shell=True, env=env, stdout=subprocess.DEVNULL))
752-
# finish this stage first
753-
for p in processes:
754-
p.wait()
750+
device_cmds = [(device_id, gpu_cmds) for device_id, gpu_cmds in stage.items() if gpu_cmds]
751+
if not device_cmds:
752+
continue
753+
754+
def _run_device_commands(item):
755+
device_id, gpu_cmds = item
756+
for cmd, env in gpu_cmds:
757+
cmd_str = shlex.join(cmd)
758+
logger.info(f"Current running command on GPU device {device_id}:\n{cmd_str}\n")
759+
subprocess.Popen(cmd, shell=False, env=env, stdout=subprocess.DEVNULL).wait()
760+
761+
with ThreadPoolExecutor(max_workers=len(device_cmds)) as executor:
762+
list(executor.map(_run_device_commands, device_cmds))
755763

756764
def validate_single_model(self, config: str, fold: int, **kwargs: Any) -> None:
757765
"""
@@ -996,7 +1004,16 @@ def predict_ensemble_postprocessing(
9961004

9971005
# apply postprocessing
9981006
if run_postprocessing:
999-
pp_fns, pp_fn_kwargs = load_pickle(self.best_configuration["best_model_or_ensemble"]["postprocessing_file"])
1007+
postprocessing_file = self.best_configuration["best_model_or_ensemble"]["postprocessing_file"]
1008+
warnings.warn(
1009+
f"unpickling postprocessing_file {postprocessing_file}: this path is read from "
1010+
"inference_information.json and is loaded with Python pickle without any allow list, "
1011+
"which gives whoever controls that file arbitrary code execution. Only proceed if the "
1012+
"inference_information.json is from a source you trust "
1013+
"(see https://github.com/Project-MONAI/MONAI/security/advisories/GHSA-8f32-8649-rv87).",
1014+
stacklevel=2,
1015+
)
1016+
pp_fns, pp_fn_kwargs = load_pickle(postprocessing_file)
10001017
apply_postprocessing_to_folder(
10011018
folder_for_pp,
10021019
join(target_dir_base, "ensemble_predictions_postprocessed"),

tests/apps/nnunet/test_nnunetv2_runner_command.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,16 @@
1111

1212
from __future__ import annotations
1313

14+
import os
15+
import sys
16+
import threading
17+
import types
1418
import unittest
1519
from unittest import mock
1620

21+
from monai.apps.nnunet import nnunetv2_runner
1722
from monai.apps.nnunet.nnunetv2_runner import nnUNetV2Runner
23+
from monai.bundle import ConfigParser
1824

1925

2026
def _make_runner(export_validation_probabilities=False):
@@ -74,5 +80,136 @@ def test_validate_emits_bare_val_flag(self):
7480
self.assertNotIn("True", cmd)
7581

7682

83+
class TestTrainParallelCommand(unittest.TestCase):
84+
def test_train_parallel_uses_argv_list_without_shell(self):
85+
runner = _make_runner()
86+
runner.dataset_name = "Dataset001_Test"
87+
runner.nnunet_results = "/tmp/nnunet_results"
88+
89+
all_cmds = [
90+
{
91+
0: [
92+
(["python", "-m", "train", "--fold", "0"], {"CUDA_VISIBLE_DEVICES": "0"}),
93+
(["python", "-m", "train", "--fold", "1"], {"CUDA_VISIBLE_DEVICES": "0"}),
94+
],
95+
1: [(["python", "-m", "train", "--fold", "2"], {"CUDA_VISIBLE_DEVICES": "1"})],
96+
}
97+
]
98+
99+
with mock.patch.object(runner, "train_parallel_cmd", return_value=all_cmds):
100+
with mock.patch("monai.apps.nnunet.nnunetv2_runner.subprocess.Popen") as popen:
101+
popen.return_value.wait.return_value = None
102+
runner.train_parallel()
103+
104+
self.assertEqual(popen.call_count, 3)
105+
for call in popen.call_args_list:
106+
self.assertIsInstance(call.args[0], list)
107+
self.assertFalse(call.kwargs["shell"])
108+
109+
def test_commands_run_sequentially_per_device(self):
110+
runner = _make_runner()
111+
runner.dataset_name = "Dataset001_Test"
112+
runner.nnunet_results = "/tmp/nnunet_results"
113+
114+
all_cmds = [
115+
{0: [(["python", "-m", "train", "--fold", "0"], {}), (["python", "-m", "train", "--fold", "1"], {})]}
116+
]
117+
118+
events = []
119+
lock = threading.Lock()
120+
121+
class _FakeProcess:
122+
def __init__(self, cmd):
123+
self.cmd = cmd
124+
125+
def wait(self):
126+
with lock:
127+
events.append(("wait", self.cmd))
128+
return 0
129+
130+
def _fake_popen(cmd, *args, **kwargs):
131+
with lock:
132+
events.append(("popen", cmd))
133+
return _FakeProcess(cmd)
134+
135+
with mock.patch.object(runner, "train_parallel_cmd", return_value=all_cmds):
136+
with mock.patch("monai.apps.nnunet.nnunetv2_runner.subprocess.Popen", side_effect=_fake_popen):
137+
runner.train_parallel()
138+
139+
self.assertEqual(
140+
events,
141+
[
142+
("popen", ["python", "-m", "train", "--fold", "0"]),
143+
("wait", ["python", "-m", "train", "--fold", "0"]),
144+
("popen", ["python", "-m", "train", "--fold", "1"]),
145+
("wait", ["python", "-m", "train", "--fold", "1"]),
146+
],
147+
)
148+
149+
150+
class TestPredictEnsemblePostprocessingWarnings(unittest.TestCase):
151+
def test_postprocessing_pickle_warns_on_untrusted_file(self):
152+
runner = _make_runner()
153+
runner.dataset_name = "Dataset001_Test"
154+
runner.nnunet_raw = "/tmp/nnunet_raw"
155+
runner.nnunet_results = "/tmp/nnunet_results"
156+
runner.best_configuration = {
157+
"best_model_or_ensemble": {
158+
"selected_model_or_models": [{"configuration": "3d_fullres"}],
159+
"postprocessing_file": "/tmp/attacker_controlled_postprocessing.pkl",
160+
"some_plans_file": "/tmp/plans.json",
161+
}
162+
}
163+
164+
ensemble_mod = types.ModuleType("nnunetv2.ensembling.ensemble")
165+
ensemble_mod.ensemble_folders = mock.MagicMock()
166+
pp_mod = types.ModuleType("nnunetv2.postprocessing.remove_connected_components")
167+
pp_mod.apply_postprocessing_to_folder = mock.MagicMock()
168+
fp_mod = types.ModuleType("nnunetv2.utilities.file_path_utilities")
169+
fp_mod.get_output_folder = mock.MagicMock(return_value="/tmp/model_folder")
170+
171+
fake_modules = {
172+
"nnunetv2.ensembling.ensemble": ensemble_mod,
173+
"nnunetv2.postprocessing.remove_connected_components": pp_mod,
174+
"nnunetv2.utilities.file_path_utilities": fp_mod,
175+
}
176+
177+
events = []
178+
179+
def _load_pickle(path):
180+
"""Record a ``load_pickle`` call and return an empty postprocessing pipeline.
181+
182+
Args:
183+
path: path to the pickle file (unused).
184+
185+
Returns:
186+
A tuple of ``(postprocessing_fns, postprocessing_kwargs)``.
187+
"""
188+
events.append("load_pickle")
189+
return [], {}
190+
191+
def _warn(*args, **kwargs):
192+
"""Record a ``warnings.warn`` call.
193+
194+
Args:
195+
*args: positional arguments passed to ``warnings.warn``.
196+
**kwargs: keyword arguments passed to ``warnings.warn``.
197+
"""
198+
events.append("warn")
199+
200+
load_pickle = mock.MagicMock(side_effect=_load_pickle)
201+
with mock.patch.dict(sys.modules, fake_modules):
202+
with mock.patch.object(ConfigParser, "load_config_file", return_value=runner.best_configuration):
203+
with mock.patch.object(nnunetv2_runner, "join", os.path.join):
204+
with mock.patch.object(nnunetv2_runner, "load_pickle", load_pickle):
205+
with mock.patch.object(nnunetv2_runner.warnings, "warn", side_effect=_warn):
206+
runner.predict_ensemble_postprocessing(
207+
run_predict=False, run_ensemble=False, run_postprocessing=True
208+
)
209+
210+
load_pickle.assert_called_once_with("/tmp/attacker_controlled_postprocessing.pkl")
211+
self.assertEqual(events, ["warn", "load_pickle"])
212+
213+
77214
if __name__ == "__main__":
78215
unittest.main()

0 commit comments

Comments
 (0)