Apple

scopeを外したbookmarkで子プロセスにサブツリーを渡してみる

  • App Sandbox
  • macOS
  • Swift

サンドボックス下の子プロセスへディレクトリのサブツリーを渡す手段として、security-scoped bookmarkとsandbox extensionを検証しました。

結果は、security-scoped bookmarkがプロセスの境界を越えられず、逆にsecurity scopeを外したplain bookmarkだけがサブツリーを渡せるという、想定と逆の経路でした。

ブログJavaScriptCoreをプラグインごと子プロセスに切り出すJavaScriptCoreのJSValue.call()は同期呼び出しで、プラグインがwhile(true)に入ると呼び出しスレッドが戻ってきません。試験的に開発しているターミナルアプリのプラグインをposix_spawnとsocketpair(AF_UNIX)で子プロセスへ切り出し、往復レイテンシp95 0.27msなど7項目を実測しました。

前回の振り返り

自社で試験的に開発しているターミナルアプリのプラグインシステムでは、JavaScriptCoreを子プロセスに切り出し、ホストとヘルパーをsocketpair(AF_UNIX)でつないでいます。前回の検証では、ホストが開いたディレクトリのfdをSCM_RIGHTS経由でヘルパーに渡しても、openat(dirfd, "inside.txt")がApp Sandbox下でEPERMになることが分かりました。

fdopendirによる列挙とファイル自体の読み取りはできるのに、そこから先のパス解決だけが拒否される、という中間状態です。openatは呼ばれるたびにパス解決をやり直すため、サンドボックスは渡された記述子ではなく解決後のパスの方を検査します。

この結果を受けて、ファイルを1つ開くたびにプラグインからホストへRPCで問い合わせる形を、現行の設計にしていました。サブツリーをまとめて渡す正規の手段には security-scoped bookmark と sandbox extension があり、前回は検証範囲に入れていませんでした。

この記事はその続きです。

security-scoped bookmarkは

確かめたい問いは5つありました。

  • bookmarkをホストで生成してヘルパーへ渡し、解決してアクセスできるか
  • 通るならopenatが通るか
  • app-scopedとdocument-scopedのどちらが要るか
  • 公開APIのみで成立するか
  • ヘルパーをkill・respawnした後も権限が残るか

検証はmacOS 26.6、Apple Siliconで、署名はすべてad-hocです。対照を取ると、bookmarkを一切渡さない状態では、サンドボックスされたヘルパーはshare/inside.txtshare/nested/deep.txtcanary.txtも全部EPERMで拒否されました。前回と同じ状態です。

その上でsecurity-scoped bookmark(bookmarkData(options: .withSecurityScope))を試しました。

ホスト自身は問題なく生成でき(824バイト)、startAccessingSecurityScopedResource()もtrueを返し、bookmarkが指すファイルも読めます。ところが同じバイト列をヘルパーに渡すと、素のサンドボックス済みヘルパーではNSCocoaErrorDomain 256com.apple.security.files.bookmarks.app-scope.document-scopeの両entitlementを持たせたヘルパーでは259で、どちらも解決に失敗しました。

両方とも失敗するのでentitlement不足が原因ではありません。ホスト自身は同じバイト列を問題なく解決できているので、blobが壊れているわけでもありません。つまり、このbookmarkは生成したプロセスか署名identityのどちらかに紐づいている、ということになります。

scopeを外してみると同じopenatが通る

security scopeを付けずに生成したbookmark(bookmarkData(options: []))を同じ経路でヘルパーに渡すと、解決に成功しました。ヘルパー側でopen(inside.txt)が成功し、openat(dirfd, "inside.txt")も成功します。前回EPERMで拒否されていた、まさにその呼び出しです。openat(dirfd, "nested/deep.txt")も一段深いところまで届くので、1つのファイルではなくサブツリーごと開いています。

一方で../canary.txtへの脱出は、パス経由でもdirfd経由でも拒否されたままでした。bookmarkした範囲の外には出られません。

このbookmarkはホスト側で1080バイトになりました。plain bookmarkという呼び方をしていますが、実体はsecurity scopeを付けていないだけのbookmarkDataです。

// ホスト側 — security scope なしで生成する
let data = try shareDir.bookmarkData(options: [])
// ヘルパー側 — 受け取ったバイト列を解決する
var stale = false
let url = try URL(
  resolvingBookmarkData: data,
  options: [.withoutUI, .withoutMounting],
  relativeTo: nil,
  bookmarkDataIsStale: &stale)
let fd = open(url.appendingPathComponent("inside.txt").path, O_RDONLY)

grantの現れ方にも気づいた点があります。アクセスはstartAccessingSecurityScopedResource()を呼ぶ前から既にあり、stopAccessingの後も残っていました。scopeの呼び出しが権限を与えているのではなく、bookmarkを解決した時点で権限が付いています。

SIGKILLで権限を握ったままヘルパーをkillし、同じバイト列を新しいヘルパーに渡しても、同じアクセスがそのまま通りました。killのたびに権限を渡し直す必要はありません。

このgrantが何をしているのかは、この計測だけでは分かりませんでした。

  • 裏でsandbox extensionが発行・消費されているのか
  • それとも別の仕組みなのか

観測できたのは順序(resolve時点でアクセスが現れる)までです。未文書化の挙動に依存したcapability設計になることを注意が必要です。

想定していたのはsecurity-scoped bookmarkがホストからヘルパーへ渡り、resolveを経てサブツリーが開く経路。実際に成立したのは、security scopeを外したplain bookmarkが同じ経路を通り、open・openat・fdopendirすべてが成功してサブツリーが開く経路だった。security-scoped bookmarkはヘルパー側のresolveでNSCocoaErrorDomain 256または259により止まる

3つのbookmarkのうち、越えられたのは2つ

ここまででplain bookmarkとsecurity-scoped bookmarkの明暗が分かれました。残るのはdocument-scopedです。3つを並べると次のようになります。

plain bookmarkはプロセスの境界を越え、サブツリーごと開きます。ただし挙動は未文書化で、読み取りだけを計測した結果です。

app-scoped(security-scoped bookmarkのうちアプリ単位のもの)はプロセスの境界を越えません。ホストは自分の分は解決できるのに、ヘルパーに渡すと必ず失敗します。

document-scopedは境界を越えますが、1回に渡せるのは1ファイルだけです。しかも越えるには条件が2つ要ります。

plain bookmarkはホストからヘルパーへ越え、サブツリーを開く。app-scoped bookmarkはヘルパーの手前で止まりNSCocoaErrorDomain 256または259になる。document-scoped bookmarkは越えるが1ファイルのみで、entitlementと、documentを先に開けることの2つの条件が要る

document-scopedを雑に試してもエラーになる

document-scopedを最初に試したときは、一時ディレクトリの中にあるディレクトリを対象にして、NSCocoaErrorDomain 256で失敗しました。この結果だけを見ると「document-scopedは使えない」で終わってしまいます。

document-scoped bookmarkの対象には、Appleの旧App Sandbox Design Guideに書かれた条件が2つあります。対象が単一ファイルであること、そして/private/Libraryのようなシステムが使う場所にないことです。一時ディレクトリの実体は/private/var/...なので、最初の試行は、対象がディレクトリで、しかもシステム領域にあり、2つの条件を同時に破っていました。1つの256に2つの原因が重なっているので、外から見ただけでは切り分けられません。

条件を1つずつ変えて切り分けました。

#変えた条件結果
D1対象はホーム配下の単一ファイル、documentは同じホーム配下の別ディレクトリ成功、864バイト
D1adocumentだけ一時ディレクトリへ移動成功、816バイト
D1a’同じ構成で、documentのパスをシンボリックリンクを解決せずに/var/...のまま指定成功、816バイト
D1b対象をディレクトリに変更失敗、256
D1c対象を一時ディレクトリの中のファイルに変更失敗、256
D1d最初の構成をそのまま再現失敗、256

1つだけ変えて失敗したのはD1bとD1c、つまり対象がディレクトリのときと、対象がシステム領域にあるときです。D1dはその両方を同時に破った最初の構成の再現です。制約があるのは対象の側で、documentの場所やパスの書き方(D1a、D1a’)は、今回試した範囲では結果に影響しませんでした。

D1からD1dまでの6つの構成。D1・D1a・D1a'はdocumentの場所やパスの書き方だけを変えて成功が続き、D1b・D1c・D1dは対象をディレクトリに変えるか、システム領域の中に置いて失敗する。documentの場所は今回試した範囲では関係なく、制約は対象が単一ファイルかつシステム領域外であることの2条件に絞り込める

document-scopedは、documentを先に開けないと越えられない

対象を正しく指定できても、それだけでは別のプロセスで解決できません。ヘルパー側にも条件が2つありました。

1つ目は、ヘルパーが2つのbookmark entitlementを持っていることです。素のサンドボックス済みヘルパーは、documentを開ける状態にしても解決が256で失敗します。

2つ目は、ヘルパーがdocument自体を開けることです。

document-scoped bookmarkは、relativeTo:に渡したdocumentのURLを起点に解決するものです。ヘルパーがdocumentを開けない状態で解決しようとすると、エラーにdocument自身のパスが出て失敗します。documentが入っているディレクトリのplain bookmarkを先に渡して、ヘルパーがそのディレクトリにアクセスできるようにしてから、先ほど失敗したdocument-scoped bookmarkのバイト列をもう一度解決させると、documentが開き、対象も開きました。

対象のファイルは、このplain bookmarkを解決した後もdocument-scoped bookmarkを解決するまでは開けないままです。対象を開いているのは、先に渡したplain bookmarkではなくdocument-scoped bookmarkの方です。

この検証では、対象を選ぶのにNSOpenPanelのようなユーザー操作を一度も挟んでいません。bookmarkはすべて、プロセスが自分で決めたパスから生成しています。プラグインのマニフェストが宣言したパスを起点にbookmarkを作れるか、という点はYesです。

ただし、これはbookmarkを生成する側がサンドボックスされていない場合の話です。サンドボックスされたプロセスで生成すると、どの構成でも256で失敗し、生成の時点で先へ進めませんでした。原因として疑っているのは、検証用のファイルがそのプロセスのコンテナ、つまり~/Library/Containers配下というシステム領域の内側に置かれることです。この点はまだ動かして確かめていません。

document-scopedで渡せるのは1ファイルだけなので、サブツリーをまとめて渡す用途には使えません。選択肢は、plain bookmarkか、ファイル単位でホストに問い合わせるRPCかの2つに戻ります。

サンドボックスされたホストは、別コンテナの子を直接には持てない

ここまでの結果は、ホスト自身はサンドボックスせず、サンドボックス済みのヘルパーを直接の子として起動する構成で取ったものです。ホストもサンドボックスするとどうなるかを確かめるため、同じ構成のままホストにApp Sandboxを付けて署名し直したところ、ヘルパーがmain()に入る前に_libsecinit_appsandboxの中でSIGTRAPで落ちました。検証スクリプトの出力にはpeer closed the socketとしか出ず、原因は~/Library/Logs/DiagnosticReports/のクラッシュレポートを見て初めて分かりました。

原因はAppleがドキュメントに書いている制約でした。サンドボックスを継承する子は、entitlementをapp-sandboxinherit2つだけにしなければならず、それ以外のApp Sandbox entitlementを指定すると、システムが起動時にその子をabortします。ヘルパーはinheritを持たず、自分のコンテナを作るためのapp-sandboxを持ったまま子として起動しようとしたので、この制約に引っかかりました。bookmark用のentitlementを持たない変種でも同じです。

entitlementを持たない素のヘルパーを子にすると起動はしますが、親のサンドボックスをそのまま引き継ぎます。実際に試すと、bookmarkを何も渡していない状態でもcanaryまで読めてしまいました。inheritを付けた子も親のコンテナを共有するので、この点は変わりません。確かめたいのは「別コンテナに封じ込めた状態でbookmarkが越えるか」なので、親とコンテナを共有する子では、そもそも封じ込めが成立していません。

別コンテナを持つ子を作る正規の手段はXPC serviceだけです。これは前回の検証時点で、実行時にインストールするプラグインと噛み合わないという理由で採用していません。

そこで親子をやめ、兄弟にしました。bookmarkはただのバイト列なので、効くかどうかは生成したプロセスと解決するプロセスの組み合わせで決まり、どちらが親でどちらが子かは関係ないはずです。bookmarkを生成する役をminter、それを読み出してヘルパーへ渡す役をorchestratorと呼びます。サンドボックスされたminterは、検証用のファイル一式とbookmarkを自分のコンテナの中に書いて終了します。非サンドボックスのorchestratorがそれを読み出して、別コンテナを持つサンドボックス済みヘルパーへ渡します。

親子構成では、サンドボックスされたホストがヘルパーを直接の子として起動すると、ヘルパーがentitlementの制約によりSIGTRAPで落ちる。兄弟構成では、非サンドボックスの側からサンドボックスされたminterとヘルパーを別々に起動し、両者は親子ではなく兄弟になる。minterが自分のコンテナに書いたbookmarkをorchestratorが読み出してヘルパーへ渡し、サブツリーが開く

この構成でbookmarkを渡さない対照を取ると、share配下もcanaryも全部拒否されました。親のサンドボックスを引き継いだ子が何もかも読めていたのとは逆で、ここでは封じ込めが効いていることの裏付けになります。

plain bookmarkの結果もすべて変わりませんでした。サブツリーが開き、..は拒否されたままで、killしても権限は残ります。app-scoped bookmarkがヘルパー側で失敗する点も同じです。この2種類について変わったのは2点だけです。サンドボックスされたminterが生成するapp-scoped bookmarkは824バイトではなく1000バイトになり、orchestrator経由で渡したplain bookmarkは、解決時にbookmarkDataIsStaleがtrueになりました(非サンドボックスのminterではfalse)。どちらも動作には影響していません。

capabilityの粒度はファイル単位のままにした

5つの問いのうち、公開APIのみで成立するかという問いの答えはYesです。sandbox extensionのPrivate API(sandbox_extension_issue_filesandbox_extension_consume)は今回の検証でも一度も呼んでおらず、ここまでの結果はどれもPrivate APIに依存していません。サブツリーを渡せるかという問いも、plain bookmarkという経路でYesになりました。

それでも、capabilityの粒度を「サブツリーをまとめて一度だけ渡す」形に変える判断はしていません。理由は3つです。

1つ目は、このgrantの仕組みが分かっていないことです。アクセスが現れるタイミングは計測できても、その裏で何が起きているかは分かりません。未文書化の挙動を前提に、capabilityの境界を引くことになります。

2つ目は、計測したのが読み取りだけだということです。検証コードが呼んでいるのはopen(O_RDONLY)opendiropenat(O_RDONLY)だけで、write・create・unlinkは試していません。サブツリー単位で渡せると言えるのは、読み取りに限った話です。

3つ目は、ホスト自身をサンドボックスする場合に、この経路がまだ塞がっていることです。兄弟構成に組み替えれば計測はできましたが、それは検証のための回り道で、実際のホストがサンドボックス済みヘルパーを直接の子として持てるようになったわけではありません。ホストをサンドボックスする前提に立つなら、ここは別途解決しなければなりません。

現行のファイル単位のRPCは、前回の計測で1回あたりの上乗せが0.020msでした。呼び出し頻度が上がっても支障のない値です。この3つを踏まえると、粒度を変えるのは割に合いません。