TL;DR 概要
- Sonarの脆弱性調査により、Ollama(LLMをローカルで実行するための人気ツール)にリモートコード実行の脆弱性が発見され、AIインフラストラクチャソフトウェアが他のネットワークアプリケーションと同様のセキュリティリスクを持つことが示されました。
- この脆弱性はOllamaのモデル管理機能に存在し、ユーザーが提供する入力の検証が不十分であるため、攻撃者がホストシステム上で任意のコマンドを実行できるようになっています。
- OllamaのようなLLM提供ツールが企業環境で広く展開されるにつれ、そのセキュリティ姿勢は重要なサプライチェーンの懸念事項となります。AIモデルを実行するコードは、プロダクションアプリケーションコードと同じセキュリティ基準を満たす必要があります。
- Ollamaユーザーはすぐにパッチを適用し、Ollamaサーバーへのネットワークアクセスを制限するべきです。AIインフラストラクチャを展開する組織は、評価および展開プロセスに静的解析を統合するべきです。
OllamaはGitHubで最も人気のあるオープンソースプロジェクトの1つで、155k以上のスターを持っています。多くのAI愛好家や開発者が、データを外部ベンダー(OpenAIなど)に送信して支払う必要なく、自分のインフラストラクチャ上でLLMをローカルに実行するために使用しています。Ollamaは、gpt-oss、DeepSeek-R1、MetaのLlama4、GoogleのGemma3など、さまざまなオープンソースモデルをサポートしています。
オープンソースエコシステムのセキュリティを確保するための取り組みの一環として、Ollamaのコードベースを脆弱性のために監査しました。悪意のあるモデルファイルの解析中に発生し、任意のコード実行につながる可能性のある重大なアウトオブバウンズ書き込みの脆弱性を発見しました。
このブログ投稿では、この脆弱性の技術的詳細を説明し、エクスプロイト可能性を判断するための概念実証を案内し、Ollamaのメンテナによってバグがどのように修正されたかを示します。この内容はHack.lu 2025での講演としても発表されました:
影響
OllamaのAPIにアクセスできる攻撃者は、悪意のあるモデルをロードして実行することができ、リモートコード実行につながります。この脆弱性はOllamaのバージョン0.7.0以前に存在します。PIE(位置独立実行可能ファイル)構成なしのビルドでエクスプロイト可能性を確認しましたが、公式リリースのようなPIE対応ビルドでもエクスプロイト可能である可能性があります。Ollamaを最新バージョンに更新することを強くお勧めします。
技術的詳細
Ollamaは主にGoで書かれていますが、llama.cppライブラリとのインターフェースなど、内部ではCおよびC++を使用しています。特に推論のような計算負荷の高いタスクはC/C++コードによって実行されます。より高いレベルでは、Ollamaはクライアントサーバーアーキテクチャを実装しており、サーバーはローカルまたはクラウドで実行でき、クライアントはサーバーと対話するためにのみ使用されます。たとえば、プロンプトを送信するためです。サーバーはモデルごとにランナープロセスを生成して推論を実行し、結果をクライアントに返します:

Ollamaの大きな強みの1つは、さまざまなモデルタイプを実行できることです。ユーザーはインターネットにモデルを公開し、他のユーザーがそれを取得できます。これは、レジストリからプッシュおよびプルできるコンテナイメージに非常に似ています。公式のモデルレジストリはregistry.ollama.aiにありますが、ユーザーは自分のレジストリをホストすることもできます。
モデルを実行するために、Ollamaは最初にランナープロセスをインスタンス化し、ディスクからモデルを解析してロードする必要があります。各モデルはGGUFファイルからロードされ、モデルのメタデータと重みを格納するバイナリファイル形式です。モデルのメタデータは、名前や説明などのキーと値のペア形式で格納されますが、内部構造に関する詳細、たとえばレイヤーの数なども含まれます。モデルの重みはテンソルと呼ばれるもので格納され、多次元配列を表す大きなバイナリブロブです。モデルのメタデータの一部は、モデルのメモリ内表現を構築するために使用され、モデルタイプによって大きく異なります。
野生のstrcpy
ターゲットにアプローチする際、まずSonarQubeでスキャンし、検出されたコードの問題をトリアージします。Ollamaの場合、SonarQubeは危険なstrcpy()関数の使用を指摘しました:

この問題は実際に有効な脆弱性であり、コピーされたソース文字列はgguf_get_val_str()を介してLLMモデルファイルのメタデータセクションから取得され、ターゲットバッファーは固定サイズです:
llama/llama.cpp/examples/llava/clip.cpp:
struct clip_hparams {
// ...
char mm_patch_merge_type[32] = "flat"; // spatial_unpad or flat (default)
// ...
};攻撃者は、clip.vision.mm_patch_merge_typeメタデータエントリが32バイトを超える悪意のあるモデルファイルを作成してロードすることができます。これにより、バッファがオーバーフローし、mm_patch_merge_typeバッファの後にあるメモリ内のデータが上書きされます。
しかし、さらなる調査の結果、上書きされたデータが危険な方法で使用されていないため、この脆弱性は攻撃者にとってあまり有用ではないように見えました。したがって、Ollamaのコードベースの調査を続けました。
より多くの信頼できないメタデータ
mllamaモデル(llamaファミリーのマルチモーダルバージョン)の解析中に、どのレイヤーを「中間」と見なすべきかを指定する特別なパラメータがあります:
auto &vision_model = new_mllama->vision_model;
auto &hparams = vision_model.hparams;
// [...]
hparams.n_layer = get_u32(ctx, "mllama.vision.block_count");
// [...]
std::vector<uint32_t> intermediate_layers_indices = get_u32_array(ctx, "mllama.vision.intermediate_layers_indices");
hparams.intermediate_layers.resize(hparams.n_layer);
for (size_t i = 0; i < intermediate_layers_indices.size(); i++) {
hparams.intermediate_layers[intermediate_layers_indices[i]] = true;
}ご覧のとおり、コードはモデルのメタデータから整数を読み取り、それをn_layerに格納します。その後、ブール値のリスト(std::vector<bool>)を初期化して、n_layersアイテムのスペースを確保します。その後、コードは別のメタデータ項目mllama.vision.intermediate_layers_indicesを使用して、対応するベクターアイテムをtrueに設定して一部のレイヤーを中間としてマークします。
しかし、intermediate_layers_indicesから読み取ったインデックスが実際にintermediate_layersベクターの範囲内にあるかどうかを確認するチェックはありません。他のプログラミング言語とは異なり、C++のstd::vectorは境界チェックを行わないため、アウトオブバウンズ(OOB)書き込みの脆弱性が発生します。ロードされたモデルファイルは攻撃者によって制御可能であるため、含まれるメタデータはOllamaによって信頼できないデータとして扱われるべきです。しかし、インデックス配列はレイヤー数より小さいインデックスのみを含むようにチェックされることはありません。
OOB書き込みを確認するために、大きなインデックスを含むモデルファイルをすばやく作成し、セグメンテーションフォールトを引き起こしました:

これはエクスプロイト可能か?
一見すると、このバグは攻撃者にとってあまり有望ではないように見えます。通常、boolは0がfalse、1がtrueに対応する単一バイトとして格納されます。ベクターの後のメモリ内の任意のバイトを0x01に設定することは、攻撃者が多くを制御できるようには見えません。
しかし、std::vector<bool>には特別な実装があります。ブール値は2つの状態しか持たないため、単一ビットで表現できます。そのため、ブール値のベクターはメモリ効率の良い表現を使用し、各アイテムを単一ビットにパックします:

For the vulnerability, this means that an attacker can flip arbitrary bits from 0 to 1. We can immediately make two observations about this primitive: The attacker can create arbitrary values in memory if that memory value is already zero, since all bits can potentially be flipped. However, this also means that the attacker has very limited control over memory that already contains data. Basically, existing values can only be increased because existing 1-bits will stay, and only 0-bits can be flipped to 1.
この脆弱性により、攻撃者は任意のビットを0から1に反転させることができます。このプリミティブについて、攻撃者がメモリ内の任意の値を作成できることがすぐにわかります。すでにゼロであるメモリ値は、すべてのビットが反転する可能性があるためです。しかし、これはまた、すでにデータを含むメモリを攻撃者が非常に制限された制御しか持たないことも意味します。基本的に、既存の値は増加するだけであり、既存の1ビットはそのままで、0ビットのみが1に反転できます。
攻撃経路はあるか?
このビット設定プリミティブが攻撃者によってどのように使用されるかを確認するために、ベクターの周囲のメモリを調べてみましょう。ベクターはヒープに割り当てられているため、2つの可能なターゲットがあります:
- ヒープチャンクメタデータ
- ヒープチャンクの内容
まず後者を調べることにしましたが、実際に攻撃者にとって有望な構造体がOOB書き込みの範囲内にいくつかありました。その1つであるggml_backend構造体には、いくつかの関数ポインタが含まれており、その一部はNULLです:
ml/backend/ggml/ggml/src/ggml-cpu/ggml-cpu.cpp:
struct ggml_backend {
ggml_guid_t guid;
struct ggml_backend_i iface;
ggml_backend_dev_t device;
void * context;
};
static const struct ggml_backend_i ggml_backend_cpu_i = {
/* .get_name = */ ggml_backend_cpu_get_name,
/* .free = */ ggml_backend_cpu_free,
/* .set_tensor_async = */ NULL,
/* .get_tensor_async = */ NULL,
/* .cpy_tensor_async = */ NULL,
/* .synchronize = */ NULL,
/* .graph_plan_create = */ ggml_backend_cpu_graph_plan_create,
/* .graph_plan_free = */ ggml_backend_cpu_graph_plan_free,
/* .graph_plan_update = */ NULL,
/* .graph_plan_compute = */ ggml_backend_cpu_graph_plan_compute,
/* .graph_compute = */ ggml_backend_cpu_graph_compute,
/* .event_record = */ NULL,
/* .event_wait = */ NULL,
};これらの関数ポインタは後で推論中に呼び出されますが、1つのキャッチがあります:一部の呼び出しは、ポインタがNULLでない場合にのみ呼び出されるチェックでラップされています:
ml/backend/ggml/ggml/src/ggml-backend.cpp:
void ggml_backend_synchronize(ggml_backend_t backend) {
if (backend->iface.synchronize == NULL) {
return;
}
backend->iface.synchronize(backend);
}攻撃者にとって、これは金鉱です:彼らはNULLポインタの1つを任意のアドレスに上書きし、ポインタを呼び出すことができます。
概念実証
Ollamaに任意のアドレスを呼び出させる最初の概念実証を作成するために、メモリに書き込みたい1のオフセットを表す適切なインデックスを含むモデルを作成するだけでした。モデルはまた、解析を通過し、メモリ内で正常に構築される必要があります。なぜなら、ポインタを呼び出す機能は推論中に発生するため、モデルの解析後です。
これは言うは易く行うは難しであり、ここでかなりの時間を費やさなければなりませんでした。1つの問題は、市販のモデルが非常に大きい(数ギガバイト)ため、テストにはあまり適していなかったことです。しかし、モデルをゼロから作成することも簡単ではありませんでした。すべてのメタデータとテンソルが一致して、モデルがメモリ内で作成される際にチェックが失敗しないようにする必要がありました。
しばらくして、ついに数キロバイトの大きさのモデルを作成し、Ollamaで処理できることを確認しました。ggml_backend構造体の.synchronizeメンバーに0x4141414141414141を書き込むことで、制御された呼び出しを確認することができました:

誰を呼ぶ?
これにより、プログラムに対するかなりの制御がすでに示されていますが、攻撃者が任意のコードを実行できるかどうかを判断する必要がありました。これには、攻撃者が任意のコード実行に向けて使用できる興味深い関数を見つける必要がありました。
デバッグセットアップでは、go build .を使用してOllamaをビルドしましたが、これによりデフォルトで位置独立実行可能ファイル(PIE)セキュリティ強化が有効になりません。これは、メモリ内のプログラムのアドレスが静的であり、バイナリ内のすべての関数のアドレスが決定論的であることを意味します。攻撃者はこれを使用して、構造体の.synchronizeフィールドにOllamaバイナリ内の任意の関数のアドレスを書き込み、それを呼び出すことができます。
まず、ワンガジェットのような使いやすいガジェットがあるかどうかを確認しました。しかし、このアプローチはうまくいきませんでした。まず、libcのベースアドレスが不明であるため、攻撃者がlibcからガジェットを使用することは不可能です。第二に、Ollamaバイナリ自体はsystem()のような関数をインポートしていないため、単にそこにジャンプすることはできませんでした。
第三に、最も重要なことは、.synchronize関数ポインタはggml_backend構造体自体へのポインタという単一の引数でのみ呼び出されることです。これは、攻撃者がsystem()にリダイレクトできたとしても、攻撃者が制御するコマンドを含む文字列を渡すことができないことを意味します。他の「簡単な」ガジェットを探す価値がないと判断し、リターンオリエンテッドプログラミング(ROP)チェーンを構築するという古典的なルートに進む時が来たと判断しました。
ROPチェーンの構築
メモリ内の実行可能ファイルのベースアドレスを知っていることは、攻撃者が任意の関数にジャンプできるだけでなく、バイナリ内の任意の命令にもジャンプできることを意味します。これを使用して、攻撃者が望む動作を実行するために、既存の命令スニペットのリストを連続して実行することができます。これらの命令はガジェットと呼ばれます。
ただし、プログラムに一連のガジェットを実行させるためには、攻撃者がスタックを制御し、ガジェットを表す複数のリターンアドレスを書き込む必要があります。プログラムが最初のガジェットからリターンすると、次のガジェットのアドレスにリターンします。Ollamaのシナリオでこれを実現するために、攻撃者は最初にスタックピボットを実行し、攻撃者が制御するメモリ位置とスタックを交換する必要があります。これは通常、スタックポインタ(rsp)を上書きすることで行われます。
利用可能なROPガジェットをリストアップして調べたところ、1つの有望なガジェットが目立ちました:
mov rsp, rbx ; pop rbp ; ret
このガジェットはrbxの値でスタックポインタを上書きし、スタックから1つのアイテムを削除し、そこからROPチェーンを続行します。攻撃者が制御するジャンプのポイントでプログラムをデバッグしていると、rbxがggml_backend構造体を指していることがわかります。これが新しいスタックになります!これは攻撃者にとって素晴らしいことであり、すでにこの構造体のいくつかの値を制御する方法を知っています。ただし、攻撃を成功させるためには、いくつかの制約を満たす必要があります。
ROPチェーンをggml_backendに収める
主な問題は、攻撃者がビットフリッピングプリミティブの制限のために構造体内のすべての値を制御できないことです。要約すると、攻撃者は0を1に反転させることしかできず、その逆はできません。OOB書き込みが発生する前の構造体内の値を見ると、攻撃者がROPチェーンを制御する能力が限られていることがわかります:

赤い領域は変更できません。最初のものはバイナリのテキストセクションの後の位置を指しており、ビットフリッピングプリミティブは値を増加させることしかできないため、有効なコードポインタに変更することはできません。2番目の赤いスロットは、ROPチェーン全体を開始するスタックピボットガジェットを指しているため、変更できません。最後の2つの赤いスロットもテキストセクションの後の位置を指しています。
ピンクの領域はすでに値を含んでいますが、テキストセクションへのポインタです。これにより、わずかに変更して異なるコード位置を指すことができますが、変更は非常に制限されています。
青い領域はnullバイトのみを含んでいます。攻撃者はそれらを任意のデータで上書きできるため、任意のROPガジェットに制約なく使用できます。
攻撃者にとって最初の問題は、構造体の非常に最初にコードポインタに変更できないデータが含まれていることです。ただし、スタックピボットガジェットはrspを上書きした後にスタックから値をポップするため、最初の「スロット」はスキップされます。
次の問題は、既存の関数ポインタ(ピンク)が呼び出されたときに副作用を持つ可能性があることです。たとえば、レジスタの値を破壊したり、予期しない引数値のためにクラッシュしたりする可能性があります。ただし、少しスクリプトを使用することで、攻撃者がそれらを無害なretガジェットに変更できることを確認しました。
たとえば、.freeメンバーはアドレス0x12e59a0のggml_backend_cpu_free関数を指しています。このアドレスから0ビットを反転させることで構築できるすべての有効なコードアドレスをリストアップすると、いくつかのガジェットが見つかります:

We noticed that all the existing addresses (pink areas in the struct) can be turned into the addresses of ret instructions. These are essentially no-ops because the only thing they do is return to the next ROP gadget without causing any side effects. The attacker, therefore, does not have to worry about them and can focus on using the free slots for actual gadgets. There is still the limitation of only 6 free slots, but this already gives the attacker much more room to play with.
From free to system
Looking at the binary's protections, we can see that RelRO is only set to partial, which means that the Global Offset Table (GOT) is writable. Since Ollama imports some functions from libc, such as printf or free, the attacker can modify these GOT entries to point to a dangerous function like system instead. Investigating Ollama's code, we found a location that calls free with the address of an attacker-controlled string:
func (m *Model) Tokenize(text string, addSpecial bool, parseSpecial bool) ([]int, error) {
// [...]
cText := C.CString(text)
defer C.free(unsafe.Pointer(cText))
// [...]
}During prompt tokenization, Ollama calls from its Go code base into the C++ code of llama.cpp. For this, strings need to be converted to C-strings and allocated on the heap to avoid memory management issues. To clean up unused memory afterward, the Tokenize function defers a call to libc's free() that will happen when Tokenize finishes.
Since the prompt is attacker-controlled, the call to free() receives an attacker-controlled string as its argument. To weaponize this, the attacker can use a ROP chain that redirects the free function to libc's system function instead. This can be done by adding the distance between free and system onto the GOT entry for free. This can, for example, be achieved with the following ROP chain:


