Slide 1

Slide 1 text

Cloud Run Sandboxes さわってみた riiimparm @3-shake テックランチ会 #1

Slide 2

Slide 2 text

riiimparm 株式会社スリーシェイク Sreake事業部 セキュリティ周りの業務支援をしてます GithubやXもこのIDでやってます 2

Slide 3

Slide 3 text

アジェンダ LLMエージェントに任意コードを実行させる基盤の代替を触ってみた。 1. Cloud Run Sandboxes ってなに? 2. 3つのユースケースと、やりがちなアンチパターン 3. 「不正操作」を試みる 4. まとめ 3

Slide 4

Slide 4 text

Cloud Run Sandboxes とは 2026-07-10、Public Preview 発表 既存のCloud Runサービスインスタンス内に、生成できる隔離実行環境 目的: LLM生成コードや信頼できないバイナリの安全実行 追加料金なし。既存インスタンスのCPU/メモリをそのまま使う 公式ブログでは1000個起動しても平均レイテンシ約500msと公表 Public Previewなのでサポート/SLAは限定的(公式記載) 4

Slide 5

Slide 5 text

セキュリティモデル(ゼロトラスト) 3つの境界がデフォルトで有効: 境界 デフォルト挙動 認証情報・環境 env変数・メタデータサーバーへアクセス不可 ネットワーク egressはデフォルト拒否、明示許可が必要 ファイルシステム コンテナFSは読み取り専用、書き込みは要オプトイン 有効化はサービスに1フラグ追加するだけ: gcloud beta run deploy my-agent \ --image=... --sandbox-launcher 有効化するとコンテナ内に sandbox バイナリが注入される。 5

Slide 6

Slide 6 text

サンドボックスの起動方法 基本形は sandbox do -- <実行したいコマンド> <引数...> sandbox do -- /usr/local/bin/python3 /tmp/script.py 6

Slide 7

Slide 7 text

sandbox コマンドとは何者か 役割: 隔離実行環境を都度生成・実行・破棄するCLI。 実測した sandbox -h のサブコマンド一覧(抜粋): サブコマンド 役割 do 使い捨てsandboxを生成→実行→破棄(今回多用) run / exec 常駐sandboxを起動 / 既存セッションに実行 fork 稼働中のsandboxを複製 tar 書き込みオーバーレイをtarで取り出す( --export-tar の実体) 誰が管理しているか: sandboxLauncher: true を付けると、Cloud Run側のインフラがデプロイ時に コンテナへ自動マウントする。 7

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

ワークアラウンド: 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

Slide 11

Slide 11 text

ファイル書き込みするには? デフォルトではfsは読み取り専用 明示的に許可するには --write フラグを付けるだけ: sandbox do --write \ -- /bin/bash -c echo 'task-complete' > /tmp/work/status.txt" 11

Slide 12

Slide 12 text

サンドボックスの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

Slide 13

Slide 13 text

特別に通信を許可するには? デフォルトでegressは拒否される(名前解決すら通らない、後述の検証3で確認)。 明示的に許可するには --allow-egress フラグを付けるだけ: sandbox do --allow-egress -- /bin/bash -c "curl -s https://example.com" 13

Slide 14

Slide 14 text

ハマりポイント: PATHが空 error finding executable "bash" in PATH []: no such file or directory sandbox do -- の 自体を解決する一段目だけ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

Slide 15

Slide 15 text

3つのユースケース ユースケース 嬉しいこと LLMコードインタープリタ 生成コードを都度隔離実行 ヘッドレスブラウザ(安全なfetch) アクセス先を都度隔離 ユーザー投稿コード実行 投稿者ごとに個別隔離 15

Slide 16

Slide 16 text

今回作った簡易システム ユーザーがポストしたコードを実行するエンドポイントを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

Slide 17

Slide 17 text

ホスト側とサンドボックス側、実際のコード ホスト側(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

Slide 18

Slide 18 text

検証1: 環境変数の遮断確認(/test/env) @app.post("/test/env") def test_env(): host_value = os.environ.get("DEMO_SECRET", "") sandbox_result = run_in_sandbox( ["--", BASH_BIN, "-c", "echo \"DEMO_SECRET=${DEMO_SECRET:-}\""] ) 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=", "stderr": ""}} → ホストは DEMO_SECRET を見えているが、sandbox内からは = 遮断 OK。 18

Slide 19

Slide 19 text

検証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

Slide 20

Slide 20 text

検証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

Slide 21

Slide 21 text

検証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

Slide 22

Slide 22 text

不正操作検証 ユーザー任意スクリプト実行で不正操作検証してみる システム: /run 22

Slide 23

Slide 23 text

不正操作検証: 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

Slide 24

Slide 24 text

不正操作検証: ネストした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

Slide 25

Slide 25 text

不正操作検証: /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

Slide 26

Slide 26 text

不正操作検証: リソース枯渇 メモリ確保と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

Slide 27

Slide 27 text

不正操作検証: タイムアウト後の生存 デタッチした子プロセスを生かす 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

Slide 28

Slide 28 text

パフォーマンス実測(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

Slide 29

Slide 29 text

3つのユースケースと、やりがちなアンチパターン Cloud Run SandboxesはCloud Runサービスインスタンス内に、生成できる隔離実行環境 ユースケース 嬉しいこと 実行環境を無駄にするアンチパターン LLMコードインタープリタ 生成コードを都度隔離実行 ヘッドレスブラウザ(安全なfetch) アクセス先を都度隔離 --allow-egress はリクエスト単位の一律スイッチ(ドメイン単位の選択許可はできない)。 付けた瞬間そのリクエストは全ドメインに疎通してしまう点を理解せず多用する ユーザー投稿コード実行 投稿者ごとに個別隔離 常時 --allow-egress にする ( --export-tar と合わせるともはやサンドボックスでやる意味はない) 出力を受け取るために全リクエストへ機械的に --export-tar を仕込み、 揮発性という安全側デフォルトを自ら崩す ( --write 単体は次呼び出しに引き継がれず無害に近い) アンチパターンを踏むと隔離の意味が消える。 29

Slide 30

Slide 30 text

余談: すべて安全ではない 今回作ったのは投稿コード実行API。ヘッドレスブラウザは未実装だが、 別ユースケースの注意点として触れておく。今回確認したセキュリティ境界は、 あくまで「実行環境の隔離」(env・metadata・egress・fs)。 防御レイヤーが別のため間接プロンプトインジェクションは対応外 ホストプロセス(LLMエージェント) ヘッドレスブラウザ) Sandbox( 悪性ページ(外部サイト) 本体 LLM sandbox do --allow-egress -- fetch URL リクエスト HTML(隠された不正指⽰を含む) HTTP 取得したテキストをそのまま返す 取得内容をコンテキストとして渡す 隠された指⽰に従って誤動作 サンドボックス境界の外側で発⽣) ( 誤った/悪意ある⾏動を指⽰ ホストプロセス(LLMエージェント) ヘッドレスブラウザ) Sandbox( 悪性ページ(外部サイト) 本体 LLM 30

Slide 31

Slide 31 text

参考リンク Cloud Run Sandboxes are in public preview (Google Cloud Blog) Cloud Run コード実行の概要 sandboxes の設定 (公式ドキュメント) terraform-provider-google issue #28426 31

Slide 32

Slide 32 text

ありがとうございました