Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Cloud Run Sandboxes さわってみた (すこしのバイパス検証)

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.
Avatar for RiiiM(りむ) RiiiM(りむ)
July 27, 2026
53

Cloud Run Sandboxes さわってみた (すこしのバイパス検証)

Avatar for RiiiM(りむ)

RiiiM(りむ)

July 27, 2026

Transcript

  1. Cloud Run Sandboxes とは 2026-07-10、Public Preview 発表 既存のCloud Runサービスインスタンス内に、生成できる隔離実行環境 目的:

    LLM生成コードや信頼できないバイナリの安全実行 追加料金なし。既存インスタンスのCPU/メモリをそのまま使う 公式ブログでは1000個起動しても平均レイテンシ約500msと公表 Public Previewなのでサポート/SLAは限定的(公式記載) 4
  2. sandbox コマンドとは何者か 役割: 隔離実行環境を都度生成・実行・破棄するCLI。 実測した sandbox -h のサブコマンド一覧(抜粋): サブコマンド 役割

    do 使い捨てsandboxを生成→実行→破棄(今回多用) run / exec 常駐sandboxを起動 / 既存セッションに実行 fork 稼働中のsandboxを複製 tar 書き込みオーバーレイをtarで取り出す( --export-tar の実体) 誰が管理しているか: sandboxLauncher: true を付けると、Cloud Run側のインフラがデプロイ時に コンテナへ自動マウントする。 7
  3. YAMLでの有効化 spec.template.spec.containers にこの1行を足す: annotations: run.googleapis.com/launch-stage: BETA ... containers: - image:

    asia-northeast1-docker.pkg.dev/PROJECT/REPO/IMAGE:v1 sandboxLauncher: true # ← これだけ run.googleapis.com/launch-stage: BETA アノテーションも必須。 第2世代実行環境が必須(第1世代では有効化不可)。 8
  4. Terraformでやる場合 google providerは sandboxes に未対応(2026-07-26時点) 対応を求めるissueは立っている (terraform-provider-google #28426) resource "google_cloud_run_v2_service"

    "default" { template { containers { image = "us-east5-docker.pkg.dev/my-project/my-repo/my-image:latest" # Injects /usr/local/gcp/bin/sandbox into the container. sandbox_launcher = true } } } あえてやるなら null_resource + local-exec でgcloudを埋め込むワークアラウンドが必要 9
  5. ワークアラウンド: null_resource resource "null_resource" "enable_sandbox_launcher" { triggers = { image

    = var.image_tag # ← plan時点で確定する値を使うのがコツ } provisioner "local-exec" { command = "gcloud beta run services update ${google_cloud_run_v2_service.sandbox_demo.name} --project=${var.project_id} --region=${var.region} --sandbox-launcher" } depends_on = [google_cloud_run_v2_service.sandbox_demo] } 今回の検証は素直に gcloud run services replace (YAML)で構築。 10
  6. サンドボックスのoutputを受け取るには? 経路1: stdout/stderr(デフォルト) with open("/tmp/generated_script.py", "w") as f: f.write(llm_code) result

    = subprocess.run(["sandbox", "do", "--", "/usr/local/bin/python3", "/tmp/generated_script.py"], capture_output=True, text=True, timeout=10) 経路2: ファイルは --export-tar # サンドボックス内のデータをアーカイブファイルへ書き出す sandbox do --write --export-tar=/tmp/work.tar \ -- /bin/bash -c "mkdir -p /tmp/work && echo 'task-complete' > /tmp/work/status.txt" # 別のサンドボックスでそのアーカイブを取り込む sandbox do --write --import-tar=/tmp/work.tar \ -- /bin/bash -c "cat /tmp/work/status.txt" 12
  7. ハマりポイント: PATHが空 error finding executable "bash" in PATH []: no

    such file or directory sandbox do -- <cmd> の <cmd> 自体を解決する一段目だけPATHが空 → 絶対パス必須 ( /bin/bash , /usr/local/bin/python3 など) 公式ブログの例 sandbox do -- python3 ... も実際は同じエラーで失敗した 一度 bash -c などで中に入れば、内部のPATHはごく普通 ( /usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin:. ) 14
  8. 今回作った簡易システム ユーザーがポストしたコードを実行するエンドポイントをCloud Runに用意した 実行環境はサンドボックス Cloud Run 3: gcloud run services

    proxy (allUsers ) インスタンス(Gen2, sandboxLauncher: true) にはしない 開発者 ホストプロセス(FastAPI) 2: gcloud run services replace service.yaml 1: build & push Artifact Registry /run /test/env /test/egress ... sandbox do -- ... 使い捨てSandbox(sandbox do) env不可・egress既定拒否・ fs読取専⽤ image pull gcloud run services proxy でローカルトンネル。 16
  9. ホスト側とサンドボックス側、実際のコード ホスト側(Cloud Runコンテナ本体、FastAPI): SANDBOX_BIN = "sandbox" PYTHON_BIN = "/usr/local/bin/python3" #

    sandbox内はPATHが空なので絶対パス必須(前述) def run_in_sandbox(args, timeout=10): proc = subprocess.run([SANDBOX_BIN, "do", *args], capture_output=True, text=True, timeout=timeout) return {"returncode": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr} @app.post("/run") def run_code(req: CodeRequest): script_path = f"/tmp/{uuid.uuid4().hex}.py" with open(script_path, "w") as f: f.write(req.code) # ← リクエストで届いたコードをホスト側fsに書く return run_in_sandbox(["--", PYTHON_BIN, script_path], timeout=req.timeout) 17
  10. 検証1: 環境変数の遮断確認(/test/env) @app.post("/test/env") def test_env(): host_value = os.environ.get("DEMO_SECRET", "<unset>") sandbox_result

    = run_in_sandbox( ["--", BASH_BIN, "-c", "echo \"DEMO_SECRET=${DEMO_SECRET:-<not visible>}\""] ) return {"host_process_sees": host_value, "sandbox_sees": sandbox_result} 実際に返ってきたレスポンス(そのまま): {"host_process_sees": "this-should-not-leak-into-sandbox", "sandbox_sees": {"returncode": 0, "stdout": "DEMO_SECRET=<not visible>", "stderr": ""}} → ホストは DEMO_SECRET を見えているが、sandbox内からは <not visible> = 遮断 OK。 18
  11. 検証2: metadataサーバーの遮断確認(/test/metadata) @app.post("/test/metadata") def test_metadata(): result = run_in_sandbox( ["--", BASH_BIN,

    "-c", "curl -s -m 3 -H 'Metadata-Flavor: Google' " "http://metadata.google.internal/computeMetadata/v1/instance/ ; echo EXIT:$?"], timeout=8, ) return {"sandbox_result": result} 実際に返ってきたレスポンス(そのまま): {"sandbox_result": {"returncode": 0, "stdout": "EXIT:7", "stderr": ""}} → EXIT:7 (接続不可)。metadataサーバーから資格情報を盗む試みは遮断 OK。 19
  12. 検証3: egressのデフォルト拒否(/test/egress) @app.post("/test/egress") def test_egress(): denied = run_in_sandbox( ["--", BASH_BIN,

    "-c", "curl -s -m 3 -o /dev/null -w 'HTTP:%{http_code}' https://example.com ; echo ' EXIT:'$?"], timeout=8, ) allowed = run_in_sandbox( ["--allow-egress", "--", BASH_BIN, "-c", "curl -s -m 5 -o /dev/null -w 'HTTP:%{http_code}' https://example.com ; echo ' EXIT:'$?"], timeout=10, ) return {"egress_default_denied": denied, "egress_allowed": allowed} 実際に返ってきたレスポンス(そのまま): {"egress_default_denied": {"returncode": 0, "stdout": "HTTP:000 EXIT:6", "stderr": ""}, "egress_allowed": {"returncode": 0, "stdout": "HTTP:200 EXIT:0", "stderr": ""}} → デフォルトは EXIT:6 (名前解決不可)で遮断 OK、 --allow-egress を付けると HTTP:200 でオプトインで疎通 OK。 20
  13. 検証4: ファイルシステム(--writeが必須+揮発性) # フラグなし → 書き込み不可(不正な永続化を試みる) sandbox do -- /bin/bash

    -c "echo hi > /tmp/sandbox_test.txt" # --write を付けると書ける sandbox do --write -- /bin/bash -c \ "echo hi > /tmp/sandbox_test.txt && cat /tmp/sandbox_test.txt" # 別のsandbox do呼び出し(--write付き)で読むと… sandbox do --write -- /bin/bash -c "cat /tmp/sandbox_test.txt" 操作 結果 フラグ無しで書き込み Read-only file system → 拒否 OK --write ありで書き込み written-in-sandbox → 成功(想定通り) 別呼び出しから読み取り No such file or directory → 揮発性を確認 sandbox do --write を明示しないとそもそも書き込み不可。付けても別呼び出しには 引き継がれない(想定より厳しい="意外と安全側")。 21
  14. 不正操作検証: DNS/生ソケットでのegressバイパス curlのEXIT:6(名前解決失敗)だけでは「resolverが失敗した」のか「ネットワーク層自体が塞がれている」のか区別できない。 s.sendto(build_dns_query(), ("8.8.8.8", 53)) s2.connect(("8.8.8.8", 53)) # 直接DNSクエリ送信

    レスポンス { "returncode": 0, "stdout": "{\"gethostbyname\": {\"ok\": false, \"error\": \"[Errno -3] Temporary failure in name resolution\"}, \"raw_udp_53\": {\"ok\": false, \"error\": \"[Errno 101] Network is unreachable\"}, \"raw_tcp_53\": {\"ok\": false, \"error\": \"[Errno 101] Network is unreachable\"}, \"verdict\": \"DNS(UDP/TCP 53番含む)は完全にブロックされている\"}", "stderr": "" } 挙動予測: Network is unreachable はresolverより手前、OSのルーティング層で 弾かれた合図。DNS/UDP/TCPとも完全ブロック。 23
  15. 不正操作検証: ネストしたsandboxで迂回できるか --allow-egress 無しの外側から、コード内でサブプロセスとして sandbox バイナリ自体を再度呼び出し、egressを再取得しようと試みる nested = subprocess.run( [sandbox_bin,

    "do", "--allow-egress", "--", curl_bin, "-s", "-m", "5", "https://example.com"], capture_output=True, text=True, timeout=15, ) Error: failed to create netns file in /var/run/netns: read-only file system 結果: outer側の --allow-egress 有無に関わらず、ネストした sandbox do は 自分のネットワーク名前空間を作るための書き込みが read-only fs で弾かれて 失敗(再現性あり、2回確認)。egressバイパスの試みは失敗した。 24
  16. 不正操作検証: /proc覗き見による環境変数漏洩 proc読み取り with open(f"/proc/{pid}/environ", "rb") as f: content =

    f.read().decode(errors="replace") レスポンス { "returncode": 0, "stdout": "{\"proc_environ_leaks\": {\"self\": \"HOME=/root\\u0000\", \"1\": \"HOME=/root\\u0000\"}, \"visible_pids\": [\"1\", \"9\"], \"verdict\": \"他プロセスのenvironが読めてしまう(PID namespace分離が不十分な可能性)\"}", "stderr": "" } 挙動予測: 見えるPIDが 1 と 9 の2つだけで中身も自分自身と全く同じ HOME=/root のみ。sandbox自身が持つごく小さな独立PID namespace(自プロセス+内部監視用と思われるPID9)が見えているだけ 25
  17. 不正操作検証: リソース枯渇 メモリ確保とfork連発 chunks.append(bytearray(chunk_mb * 1024 * 1024)) pid =

    os.fork() # 700MBまでメモリ確保を試みる 実際に返ってきたレスポンス(そのまま): { "returncode": 1, "stdout": "", "stderr": "waiting on pid 9: waiting on PID 9 in sandbox \"4be552db-d4f0-43ee-a3d3-e7c821523b05\": urpc method \"containerManager.WaitPID\" failed: EOF\nError: failed to exec in container: cmd.Wait(exec) failed: exit status 128" } 挙動予測: containerManager.WaitPID はgVisorがsandbox内プロセスを監視する ための内部RPC。 EOF で切れたのは監視対象プロセスが突然消滅した合図で、 メモリ確保でOOM Killされた可能性が濃厚。ホストは無傷だった。 26
  18. 不正操作検証: タイムアウト後の生存 デタッチした子プロセスを生かす pid = os.fork() if pid == 0:

    os.setsid(); time.sleep(5) 実際に返ってきたレスポンス(そのまま): { "returncode": 0, "stdout": "{\"parent_pid\": 10, \"forked_child_pid\": 11, \"parent_elapsed_sec\": 0.03, \"note\": \"親はここで即終了。子は5秒後にマーカーを書こうとするが、このAPI応答には反映されない(fire-and-forgetのため)。Cloud Run側ログのelapsed=を確認すること。\"}", "stderr": "" } Cloud Run側ログ: [end] exit_code=0 elapsed=581ms (子は5秒スリープの想定) 挙動予測: 道連れで消失 親は30msで終了したのに、sandbox全体の所要時間も581msと短く、子プロセスの5秒スリープを待っていない。 sandbox do は「フォアグラウンドコマンドの終了」で即sandboxを畳んでいる可能性が高いが、fsが呼び出しごとに揮発する ため裏で生き残ったのかは断定できない。ただしgVisorの設計上、サンドボックス内のプロセスはSentry(ユーザー空間カーネル)に内包された内部状態にすぎず、ホストの独立プロセスとして生き残る(=initに養子縁組される)経路が無いた め、子プロセスは道連れで即座に消滅する可能性が高い(推論、直接の実証ではない)。 27
  19. パフォーマンス実測(N=100、Cloud Runログより) /run に軽量コードを投げ、Cloud Run構造化ログの elapsed= を集計 (1インスタンス、 containerConcurrency: 80

    固定)。 条件 件数 成功率 min mean p50 p90 max 直列・ cpu:1 20 100% 570ms 618ms 608ms 685ms 725ms 並行8・ cpu:1 100 96% 2108ms 3496ms 3491ms 4102ms 4795ms cpu:8 100 100% 439ms 633ms 638ms 676ms 710ms 並行8・ → cpu:1 のまま並行8を投げると平均5.7倍(618→3496ms)に悪化+4%失敗。 だが cpu を1→8に上げて同じ並行8を再実行すると、直列とほぼ同じ数値 CPU割り当てを想定並行数に対して過小にしないこと 28
  20. 3つのユースケースと、やりがちなアンチパターン Cloud Run SandboxesはCloud Runサービスインスタンス内に、生成できる隔離実行環境 ユースケース 嬉しいこと 実行環境を無駄にするアンチパターン LLMコードインタープリタ 生成コードを都度隔離実行

    ヘッドレスブラウザ(安全なfetch) アクセス先を都度隔離 --allow-egress はリクエスト単位の一律スイッチ(ドメイン単位の選択許可はできない)。 付けた瞬間そのリクエストは全ドメインに疎通してしまう点を理解せず多用する ユーザー投稿コード実行 投稿者ごとに個別隔離 常時 --allow-egress にする ( --export-tar と合わせるともはやサンドボックスでやる意味はない) 出力を受け取るために全リクエストへ機械的に --export-tar を仕込み、 揮発性という安全側デフォルトを自ら崩す ( --write 単体は次呼び出しに引き継がれず無害に近い) アンチパターンを踏むと隔離の意味が消える。 29
  21. 余談: すべて安全ではない 今回作ったのは投稿コード実行API。ヘッドレスブラウザは未実装だが、 別ユースケースの注意点として触れておく。今回確認したセキュリティ境界は、 あくまで「実行環境の隔離」(env・metadata・egress・fs)。 防御レイヤーが別のため間接プロンプトインジェクションは対応外 ホストプロセス(LLMエージェント) ヘッドレスブラウザ) Sandbox( 悪性ページ(外部サイト)

    本体 LLM sandbox do --allow-egress -- fetch URL リクエスト HTML(隠された不正指⽰を含む) HTTP 取得したテキストをそのまま返す 取得内容をコンテキストとして渡す 隠された指⽰に従って誤動作 サンドボックス境界の外側で発⽣) ( 誤った/悪意ある⾏動を指⽰ ホストプロセス(LLMエージェント) ヘッドレスブラウザ) Sandbox( 悪性ページ(外部サイト) 本体 LLM 30
  22. 参考リンク Cloud Run Sandboxes are in public preview (Google Cloud

    Blog) Cloud Run コード実行の概要 sandboxes の設定 (公式ドキュメント) terraform-provider-google issue #28426 31