transcoderコンテナへの隔離
何のためのものか
Cocotは一般公開・個人情報を扱うサービスとして運用する前提で、ユーザーがアップロードした音声をサーバー側でデコード・変換する処理の脅威を洗い出した。判明した中核的な事実は次の3点。
- ffmpeg/ffprobeのデコーダ脆弱性は入力検証では原理的に防げない。 マジックバイト検証と
-f強制が制御するのは「どのパーサを動かすか」であって「そのパーサがバイト列をどう扱うか」ではない。libswresampleのCVE-2024-7272のように、全ジョブが必ず通る経路(リサンプリング)にも脆弱性は存在する。 - 旧実装では、侵害の被害範囲が全システムに及んでいた。 旧
runFfmpegはcmd.Envを設定しておらず、Goのexec.Commandは親の環境変数をそのまま子に渡していた。起動されたffmpegの環境にはDATABASE_URLと両方のS3認証情報が入っており、/proc/self/environを読むだけで取得できた。さらに同一ネットワーク名前空間からPostgresとGarageに到達できた。 - 実測で2つの穴を確認した。 (a) 攻撃者のメタデータ(
title=<script>alert(1)</script>等)が変換後の.opusにそのまま残る。(b)-vnを付けていてもffmpegは埋め込みカバーアートの画像デコーダを起動する。-map 0:aでも回避できず、安全確認用のffprobe事前検証自体も同じ経路を通る。
対策は2層に分けた。入力検証で閉じられるもの(メタデータ、再生時間、フォーマット)はフラグと検証で潰し、原理的に閉じられないもの(デコーダの未知の脆弱性)は「侵害される前提」で被害範囲を限定する。到達点は、ffmpegの実行を認証情報もネットワークも持たない使い捨てコンテナに隔離すること。これによりデコーダ脆弱性を踏んでも、攻撃者が得るのは「ネットワークなし・認証情報なし・非root・書き込み先は一時ディレクトリのみのプロセス」になり、被害はそのジョブ1件に閉じる。
アーキテクチャ
workerコンテナとtranscoderコンテナに分かれ、共有ボリューム上のファイルで受け渡す。
| worker(信頼境界の内側) | transcoder(外側) | |
|---|---|---|
| ネットワーク | あり(DB・S3) | なし(network_mode: none) |
| 認証情報 | 保持 | 一切なし(env_fileを付けない) |
| ffmpeg/ffprobe | 含めない | 実行する |
| 権限 | 非root | 非root・cap_drop: ALL・read_only |
| 役割 | ジョブ消費、DL/UL、DB書き込み | 検証・エンコードのみ |
原則: ffmpeg/ffprobeを呼ぶ処理はすべてサンドボックス側へ移す。 ffprobeも同じ攻撃面なので、事前検証もサンドボックス内で行う。appのマジックバイト判定(gabriel-vasile/mimetype)は純Goでメモリ安全なので現在地に残す。
プロトコル
共有名前付きボリュームtranscode-spoolを両コンテナの/transcodeにマウントする(Windowsでのbind mountは所有権とrename semanticsが壊れるため名前付きボリューム必須)。backend/transcodeproto(stdlib以外に依存しないワイヤフォーマット定義)が境界を規定する。
/transcode/<jobUUID>/ input.part worker: ダウンロード先 input worker: fsync後にrename(READY信号の一部) request.json worker: renameがREADY信号(最後に書く) claimed transcoder: 処理開始のマーカー output.opus transcoder result.json transcoder: renameがDONE信号request.jsonは識別情報を一切含まない(uploadID・userID・バケット名・オブジェクトキーを渡さない。侵害されても誰の音声か分からないようにする)。ジョブディレクトリは呼び出しごとの新規UUIDでキーを作る。ライフサイクルはworkerが所有し、削除もworkerのみが行う(transcodeAudioのdeferで成功・失敗を問わず必ず削除)。
戻り値の検証(この設計の要)
サンドボックスは「RCE→全システム侵害」を「RCE→result.jsonとoutput.opusの内容を制御できる」に縮小する。その残りを境界で必ず縛る。 transcodeproto.Result.Validateとinternal/transcode/handoff.goのvalidateAndReadOutputが、workerがS3アップロードとDB書き込みを行う前に以下を検証する。
result.jsonは8 KiB以下(io.ReadAllしない、ReadJSONLimited)duration_secondsが有限・正・max_duration_seconds以下output_bytesが実際のos.Statサイズと一致し、正でmax_output_bytes以下output.opusの先頭4バイトがOggS(純Goで検査、メモリ安全)error文字列は512バイトに切り詰めてから記録する(サンドボックスに無制限のテキストをDBへ書かせない)
検証失敗はFailUploadして再試行しない。
ポイズンピル対策
transcoderは起動時にclaimedがありresult.jsonがないジョブディレクトリを走査し(encoder.RecoverAbandonedClaims)、即座にtranscoder_restartedで失敗させる。決して再実行しない。 ffmpegをクラッシュさせる入力を再試行すると、クラッシュループになって同時実行中の全ジョブを巻き込み続けるため。
孤児ジョブディレクトリの掃除
worker側のクラッシュ等で残り続けるジョブディレクトリは、internal/transcode.sweepOrphanedJobsがmtimeベースで判定し削除する(「知らないディレクトリを消す」方式にしないのは、複数workerレプリカ環境で処理中のジョブを誤って消さないため)。cmd/worker/main.goが10分ごとにWorker.SweepSpoolを呼ぶ(対象年齢はRiverのJobTimeoutより長い1時間)。
ffmpeg引数
ffmpeg -nostdin -y -hide_banner -loglevel error -fflags +bitexact \ -protocol_whitelist file -threads 1 -f <format> -i <input> \ -map 0:a:0 -dn -sn -map_metadata -1 -map_chapters -1 \ -ar 48000 -c:a libopus -b:a 128k -fs <maxBytes> -bitexact <output>-protocol_whitelist file・-f <format>: ffmpeg自身の自動フォーマット判定に起因するSSRF/任意ファイル読み取り攻撃面を閉じる(CVE-2023-6601等)。-map 0:a:0・-dn -sn: 最初の音声ストリームのみを対象にする。カバーアート問題の根本対策ではない(demuxの後に画像デコーダへ到達するため)。サンドボックス隔離こそが対策で、これは衛生上のフラグ。-map_metadata -1・-map_chapters -1: 攻撃者制御のメタデータ(タイトル・アーティスト名等)を出力へ素通りさせない。-threads 1: 単一の悪意ある入力が同時実行中の他ジョブへ与えるCPU負荷の影響を絞る(コンテナのcpus制限は全体を縛るが、これは個々のジョブの影響を追加で縛る)。-fs <maxBytes>: 出力サイズの上限。上限に達しても終了コード0で切り詰められた出力を残すことがあるため、internal/encoder.runFfmpegがos.Statで実サイズを確認し、上限以上なら明示的に失敗させる。-fflags +bitexactと出力側の-bitexact: 出力のencoderタグからlibavcodecの正確なバージョン番号を消す(-fflags +bitexact単体ではencoderタグからバージョンが消えないことを実測で確認済み)。-ac(チャンネル数固定)は入れない。モノラルをステレオにアップミックスして出力サイズを倍にするだけで安全性に寄与しない。チャンネル数上限はinternal/encoder.validateProbeが拒否することで達成する。
cmd.Envはすべてのexecで明示的に設定する([]string{"TMPDIR=/tmp"})。nilは継承であり、旧実装の認証情報漏洩バグそのものだった。
タイムアウトの3層
| レイヤ | 予算 |
|---|---|
| ffprobe実行 | 30秒 |
| ffmpeg実行 | request.deadline_unix - now |
| transcoderのジョブ全体 | request.deadline_unix(サンドボックス自身がresult.json{ok:false}を書く) |
| workerの待機 | request.deadline_unix - workerDeadlineSlack(60秒) |
| River JobTimeout | 30分(最終手段。6時間音声の変換に備えて10分から延長) |
DeadlineUnixはジョブ開始時刻(transcodeAudio冒頭のjobStart)を起点に計算する。ダウンロードに時間がかかった後のtime.Now()を起点にすると、transcoder側の締め切りが実際のRiver JobTimeoutより後の時刻になり得るため。
compose上のハードニング
transcoderサービス(compose.yml): network_mode: none(networks:キーと併用不可)、read_only: true(コンテナ自身の書き込み可能レイヤのみに効く、マウントボリュームは別)、cap_drop: [ALL]、security_opt: [no-new-privileges:true]、user: "10001:10001"、pids_limit: 64、mem_limit: 1g、cpus: 4.0。mem_limit/cpus/pids_limitは非swarmのcomposeキーで、deploy.resourcesはswarm専用で無言で無視されるため使わない。devでもこのハードニングを緩めない(airのホットリロード書き込み先だけ名前付きボリュームで許可する)。
残余リスク(受容し、文書化する)
共有ボリュームは1つの名前空間なので、侵害されたtranscoderは同時処理中の別ジョブのoutput.opusを差し替えられる。1つの侵害されたtranscoderが侵害できるのは同時処理中のアップロードのみで、保存済みデータ・認証情報・ネットワークには到達しない。 完全版はジョブごとに1コンテナを起動する方式だが、Dockerソケット(root相当)かNomad/systemdが必要になるため対象外とした。
対象外・フォローアップ
- 最小構成ffmpegビルド(
--disable-everything)— カバーアート経由の画像デコーダ到達と不要コーデックの根本対策だが、Alpineのapkをやめてソースビルドすることになり、ビルド時間・Dockerfileの複雑さ・バージョン管理が一気に増える。隔離が先に入っていれば後からでも効果を保てるため見送った。 - ジョブごとに1コンテナを起動する完全分離(Dockerソケットまたは外部スーパーバイザが必要)。
- CI・依存関係の脆弱性スキャン(
govulncheck・Trivy)。
テスト方針
transcodeprotoパッケージのアトミック書き込み/読み込み・許可リスト・Result.Validate、internal/encoderのbuildFfmpegArgs/parseProbe/validateProbe/parseFfprobeDuration、internal/transcode/handoff.goのbuildRequest/waitForResult/validateAndReadOutput/sweepOrphanedJobsはいずれも純粋関数またはt.TempDir()ベースでユニットテスト済み(実ffmpeg/実コンテナ不要)。scripts/e2e-smoke.shが実際のスタックに対して、workerイメージにffmpegがないこと・transcoderコンテナに認証情報が流入していないこと・メタデータが実際に除去されること・spoolディレクトリがスイート終了後に空であることを検証する(e2e-smoke-testing参照)。