4つ目のpickle: SGLang分散Diffusionサーバにおける認証不要のリモートコード実行
この記事では、私が SGLang に報告した認証不要のリモートコード実行(RCE)脆弱性について解説します。
端的に言うと、DiffusionServer ヘッドノードの ZeroMQ フロントエンドに TCP 接続できさえすれば、誰でもそのプロセス内で任意コードを実行できるというものです。受信メッセージの最終フレームが、あらゆる検証より先に、一切の認証なしで pickle.loads() に直接渡されることが原因です。
この問題について注目すべき点として、コードが追加された時期が挙げられます。SGLang では同様のバグクラス(未認証の ZMQ ソケットによる pickle デシリアライズ)に関する脆弱性がすでに 2026 年 3 月に 3 件報告されていました。しかし、今回報告した脆弱なファイルは、その約 5 週間後に新機能(分散 diffusion 処理)の一部として生 pickle.loads シンクを含む形で追加され、執筆時点でも最新リリースおよび main ブランチに残っています。
また、この問題を起点に調査を進めたところ、同じコードベース内で関連する問題が複数見つかり、最終的に 10 件の発見に至りました。これらはすべて実際の upstream コードに対して動的に検証を行っています。
背景
SGLang は大規模モデル向けのサービングフレームワークとして広く使われています。マルチモーダル生成ランタイム(sglang.multimodal_gen)は text-to-video や text-to-image のパイプラインを実行し、内部的には pickle 化した Python オブジェクトを ZeroMQ ソケット経由でプロセス間に転送しています。
Python の pickle は単純なデータシリアライズ形式ではなく、オブジェクト再構築のための命令列を実行する仕組みです。そのため、認証されていないネットワーク入力をそのまま pickle.loads でデシリアライズすると、攻撃者が指定したコードが受信側プロセスでそのまま実行されることになります。
この問題は、過去に同リポジトリ内で以下の通り報告されていました:
- CVE-2026-3059(GHSA-rgq9-fqf5-fv58、 公開):
scheduler_client.py経由の未認証 RCE(≤ 0.5.9) - CVE-2026-3060(GHSA-jx93-g359-86wm、 公開): エンコーダ分散モジュール
encode_receiver.py経由の未認証 RCE(≤ 0.5.9) - CVE-2026-7301(VU#777338): スケジューラ経路における ROUTER ソケットシンク
プロジェクト側の対策としては、生 pickle.loads を制限付きアンピックラである safe_pickle_loads に置き換える改修が行われていました(例: encode_receiver.py)。
しかし 、コミット 9da998a8(PR #21701)にて、パイプラインを Encoder/Denoiser/Decoder に分割してヘッドノード(DiffusionServer)が統括する新機能が追加され、orchestrator.py という新しいファイルが導入されました。このヘッドノードは ZMQ ROUTER ソケットでクライアントからのリクエストを受け付ける設計になっています。
調査の経緯
過去に同種の脆弱性が複数報告されていたことから、まだ未修正の箇所が残っていないか調査を行いました。2026 年 7 月上旬、当時の最新リリース(0.5.14)を対象にネットワーク面からの pickle.loads 呼び出しを検索したところ、新しく追加された分散オーケストレータ内に生 pickle.loads の呼び出しが確認されました。
実際のリクエスト処理の流れを追ってみます。
リクエスト処理と脆弱性の発生箇所
分散モードは --disagg-role フラグで指定します。ヘッドノードの起動コマンドは次の通りです:
sglang serve --model-path Wan-AI/Wan2.1-T2V-14B-Diffusers \
--disagg-role server \
--encoder-urls "tcp://10.0.0.1:19000" \
--denoiser-urls "tcp://10.0.0.2:19001" \
--decoder-urls "tcp://10.0.0.3:19002" \
--host 0.0.0.0 --port 30000 \
--scheduler-port 19655
この処理は python/sglang/multimodal_gen/runtime/launch_server.py の launch_disagg_server() を経由し、フロントエンドのエンドポイント文字列が次のように組み立てられます:
host = server_args.host or "127.0.0.1"
base_port = server_args.scheduler_port # default: 5555
...
frontend_endpoint = f"tcp://{host}:{base_port}"
ここで host には --host 引数の値がそのまま使われます。
その後、orchestrator.py の DiffusionServer._event_loop() でソケットが生成されます:
frontend, _ = get_zmq_socket(
self._context, zmq.ROUTER, self._frontend_endpoint, bind=True
)
get_zmq_socket()(runtime/utils/common.py)はソケットのオプション設定とバインドを行うヘルパー関数ですが、CURVE 暗号化や ZAP 認証などの設定は含まれておらず、アプリケーション層でのトークン検証もありません。そのため、TCP レベルで接続可能なクライアントであれば誰でもメッセージを送信できます。
ソケットが読み取り可能になると、_handle_client_request(frontend) が呼ばれます:
def _handle_client_request(self, frontend: zmq.Socket) -> None:
try:
parts = frontend.recv_multipart(zmq.NOBLOCK)
except zmq.Again:
return
if len(parts) < 3:
return
client_identity = parts[0]
payload = parts[-1]
try:
reqs = pickle.loads(payload) # <-- the sink
except (pickle.UnpicklingError, EOFError):
...
受信メッセージのフレーム長のみをチェックした後、最終フレームの内容が直接 pickle.loads() に渡されています。メッセージの型やスキーマの検証はデシリアライズ後に行われるため、この時点で細工されたオブジェクトによるコード実行が成立します。
--host オプションとバインド設定
--host を省略した場合はデフォルト値の 127.0.0.1 が使用され、アクセス可能な範囲はローカルホスト内に限定されます。しかし、--host フラグのヘルプには次のように記載されています:
--host Host for the HTTP API server.
説明文上は HTTP API サーバ向けとなっていますが、実際には内部通信用の ZMQ フロントエンド(および結果受信用の PULL ソケット)のバインド先アドレスとしても共用されています。ZMQ バインド先を個別に指定するオプションは用意されていません。
さらに、公式ドキュメント(docs/docs/sglang-diffusion/disaggregation.mdx)に記載されているデプロイ例では --host 0.0.0.0 が指定されています。ドキュメントの手順に従って設定を行うと、HTTP API と同時に ZMQ ソケットもすべてのインターフェースに向けて公開されることになります。
実パッケージでの検証
ソースコード上の確認に加えて、実際のパッケージ上で動作を検証しました。依存関係を最小限に抑えた環境(sglang==0.5.17、pyzmq、周辺ライブラリ)を用意し、実モジュールのコードを直接実行してソケットの状態を確認しました:
zmq_type ROUTER
CURVE_SERVER 0
PLAIN_SERVER 0
ZAP_DOMAIN ''
ソケット上で認証が無効になっていることを確認後、無害なマーカー(サーバプロセス内で print を実行するオブジェクト)を送信するクライアントを作成してテストを行いました:
class Gadget:
def __reduce__(self):
return (print, ("!!! ARBITRARY CODE EXECUTED VIA pickle.loads() ON THE ROUTER SOCKET !!!",))
サーバ側の標準出力に以下の通りマーカーが出力され、コード実行を確認しました:
!!! ARBITRARY CODE EXECUTED VIA pickle.loads() ON THE ROUTER SOCKET !!!
PoC marker: this process ran attacker-supplied code.
この処理経路は、機能が追加された v0.5.11 から v0.5.20 および執筆時点の main ブランチまで同一のコードが残っています。
追加調査: 他のコンポーネントにおける影響
ヘッドノードの調査と並行して他の通信経路についても確認を行ったところ、同様の問題が複数見つかりました。これらについても upstream の最新コード(main @ 790551c)を対象に動的な検証を行っています。
エンコーダワーカーにおけるワイルドカードバインドと未認証 pickle
各ワーカー(encoder, denoiser, decoder)は処理要求を受け取るための PULL ソケットをバインドします。このエンドポイントは DisaggServerArgsMixin で次のように定義されています:
def derive_pool_work_endpoint(self) -> str:
return format_tcp_endpoint("0.0.0.0", self.scheduler_port, "pool_work_endpoint")
バインド先アドレスとして 0.0.0.0 が固定されており、--host の設定に関わらず全インターフェースで待ち受けが行われます。
そしてエンコーダワーカーの処理(disaggregation/scheduler_mixin.py の _disagg_encoder_step)では、このソケットから受信したデータをそのまま pickle.loads しています:
frames = self._pool_work_pull.recv_multipart()
pickled_req = frames[-1]
reqs = pickle.loads(pickled_req)
このため、ヘッドノードをローカルホストに限定して運用していた場合でも、エンコーダワーカーが動作しているノードでは外部からの直接アクセスによるコード実行が可能となっています(実機検証にて再現確認済み)。なお、denoiser や decoder ワーカーでは JSON 形式の転送制御メッセージが使われており、エンコーダのみが生 pickle.loads を使用している状態でした。
モノリシックスケジューラにおける既報シンクの残存
通常のサービング(単一ノード構成)で使用されるスケジューラ(managers/scheduler.py の Scheduler.recv_reqs())についても確認したところ、ROUTER ソケットからのデータを受信する箇所で依然として生の pickle.loads(payload) が使用されていました。この箇所は CVE-2026-7301(VU#777338)として報告されているものですが、現行バージョンでも同様に動作することが確認できました。
分散通信ファブリック全体の認証状況
pickle 以外の通信経路についても調査を行いました。結果 PULL ソケットや転送制御チャンネルなど、分散処理に関わる各種ソケットには認証機構が存在しません。データのデシリアライズには JSON が使われているため直接のコード実行には至りませんが、transfer_push ハンドラでのアドレス情報(dest_session_id, dest_addr)の無検証受け渡しや、transfer_ready ハンドラにおける setattr(req, key, value) を通じたリクエストオブジェクトへの任意属性注入など、データの整合性や内部状態に影響を与える操作が可能です。
また、ブローカーモジュール(scheduler_client.py)については、ローカルホストにバインドされているものの内部で raw pickle を使用しており、サーバからの応答メッセージをデシリアライズするクライアント側でも同様にコード実行の経路が存在することを確認しました。
HTTP API における認証欠如と追加の脆弱性
FastAPI による HTTP API についても確認を行いました。マルチモーダル生成ランタイムの HTTP サーバには API キー等の認証機構が用意されておらず、重みの更新(POST /update_weights_from_disk)やメモリ解放(POST /release_memory_occupation)などの管理系エンドポイントが初期設定のまま公開されます(LLM 側の srt ランタイムには --api-key 等のオプションが用意されていますが、multimodal_gen には実装されていません)。
また、画像や動画の URL パラメータ(image_url, reference_url)を取得する処理において、ドメインやプライベート IP の制限なくリダイレクトに追従してサーバ側から通信を行うため、ブラインド SSRF が成立することを確認しました。
さらに、以下の問題も確認されています:
1. 未認証の任意ファイル書き込み(CWE-22)
POST /v1/videos や /v1/actions などのアップロード処理(multimodal_gen/runtime/entrypoints/openai/utils.py)において、マルチパートのファイル名をサニタイズせずに os.path.join(uploads_dir, f"{request_id}_{filename}") でパスを構築し、受信バイトをそのまま書き込んでいます。ファイル名に ../ を含めることでアップロード先ディレクトリを脱出でき、デフォルト構成(/tmp 配下)においてサーバプロセスの権限で任意の位置にファイルを作成可能です(動的検証により再現確認済み)。
2. WebSocket ハンドラにおける NumPy オブジェクト配列の構築
リアルタイム通信用の WebSocket エンドポイント(/v1/actions/realtime 等)において、msgpack ペイロードから受け取ったパラメータを基に np.ndarray を構築しています。ここで dtype="O" を指定することで、受信データをもとに object 配列が生成される挙動を確認しました。
LLM ランタイム(srt)における同様の構造
マルチモーダルランタイムだけでなく、LLM サービング本体(srt)についても調査を行いました。--enable-dp-attention を指定したマルチノード構成において、内部 ZMQ 通信が TCP に切り替わった際、環境変数のデフォルト設定(SGLang_USE_PICKLE_IPC=True)により、トークナイザマネージャの結果受信用ソケット等で生の recv_pyobj()(pickle デシリアライズ)が実行されます。また、マルチノード間の状態同期(TCPStore)においても、ストアから取得した値をそのままデシリアライズする箇所が存在することを確認しました。
影響範囲
DiffusionServer は分散処理全体の司令塔として動作し、リクエストキューや各ワーカーへの通信を管理しています。そのため、このプロセスが侵害された場合、ホスト上の情報(モデル重み、API キー、環境変数など)へのアクセスのほか、各ワーカーノードへの指示送信など、システム全体への影響が及びます。また、前述の通りエンコーダワーカー側にも直接の RCE 経路が存在し、HTTP API 側にも任意ファイル書き込みの経路が存在します。
報告の経緯と対策
本件は に GitHub Private Vulnerability Reporting(GHSA-8374-wrr5-7q7f)を通じて報告しました。その後、迅速かつ円滑な解決を図るためメンテナとの調整を CERT/CC(VU#727584)に委ね、CERT/CC による独自検証と CVE 採番(CVE-2026-93088)を経て、今回の公開手順に至りました。
推奨される恒久的な対策は以下の通りです:
- 信頼できない通信境界を越えて pickle を送受信する設計を見直し、JSON やスキーマ検証付き msgpack などの安全な形式に移行する。
- 暫定処置として、既存の
safe_pickle_loads(SafeUnpickler)をすべてのデシリアライズ箇所に適用する。 - ZMQ 通信において CURVE 暗号化および ZAP による相互認証を導入する。
- HTTP サーバ用の
--host設定と内部通信用 ZMQ のバインド先設定を分離し、デフォルトではローカルホスト(127.0.0.1)のみにバインドする。 - ワーカーの受信ソケットで
0.0.0.0が固定されている箇所を修正し、バインド先アドレスを指定できるようにする。 - HTTP API において認証レイヤーを導入し、ファイルアップロード時のパスサニタイズおよびメディア取得時の URL 検証を適用する。
正式な修正パッチが提供されるまでの運用上の緩和策としては、ネットワーク層でのアクセス制限が有効です。ヘッドノードおよびワーカーノードの ZMQ ポート(scheduler_port および関連ポート)への通信を、信頼できる特定の IP アドレスからのみに制限することを推奨します。
タイムライン
| 日付 | 出来事 |
|---|---|
| CVE-2026-3059 / CVE-2026-3060 公開(ZMQ 経由の pickle RCE、≤ 0.5.9) | |
コミット 9da998a8(PR #21701)にて orchestrator.py が追加される | |
| v0.5.11 リリース(本機能が含まれる最初のバージョン) | |
| GitHub Private Vulnerability Reporting 経由で報告(GHSA-8374-wrr5-7q7f) | |
| CERT/CC に報告、VU#727584 として受け付け | |
v0.5.17 および main ブランチで再現確認、検証パッケージを提出 | |
| CERT/CC が脆弱性を確認し、CVE-2026-93088 を採番 | |
| v0.5.20 リリース(本件の脆弱性が残存) | |
| 追加の検証結果を整理し CERT/CC に提出 |
報告者: Keita Sode(SYZD Research)。調整を行っていただいた CERT/CC に感謝申し上げます。なお、作成した検証用コード(PoC)は安全性を考慮し、プロセス内で print によるマーカー出力を行うのみで、シェルの起動や外部通信、ファイルの改変等は行いません。