✍️

Go製のmacOS用のシングルバイナリでApp Sandboxを有効にする

に公開

macOSやiOSにはApp Sandboxというアプリケーションをファイルシステムやネットワークなどのリソースへのアクセスから隔離できる機構が用意されています。Xcodeなどを使ってSwift/Objective-C製のアプリを作るときにはApp Sandboxを有効にする方法はよく書かれていますが、Goで作成した単体バイナリで有効にする方法はあまりないようなので結構苦戦しました。

App Sandboxを有効にすることで、依存ライブラリが突然悪意をもったライブラリになっていたとしてもネットワークアクセスやファイルアクセスを遮断しておければ、アプリ利用者の情報が盗み出されるリスクを減らすことができるかもしれません。

この記事ではGoで作った単体バイナリをXcodeを使わずにApp Sandboxを有効にする方法と、App Sandboxによりファイルアクセスやネットワークアクセスなど制限がどうかかるのかを紹介します。

動作確認環境

  • macOS 26.0
  • Xcode 26.0

macOS用シングルバイナリ製GoアプリをApp Sandbox有効にする

ここではGoで書いたmacOS用のシングルバイナリ形式のアプリケーションをApp Sandbox有効にすることを考え、通常のmacOSアプリケーションのような複数ファイルをもてるバンドル形式は考えないものとします。

シングルバイナリのApp Sandboxの有効化を次の2ステップで行います。

  1. CFBundleIdentifier をもつ Info.plist をリンカで埋め込む
  2. codesign コマンドでEntitlementsファイルを指定して署名する

Step 1. CFBundleIdentifier をもつ Info.plist をリンカで埋め込む

App Sandboxを有効にするためには最低限Bundle IdentifierをInfo.plistで定義しておく必要があります。例えばBundle Identifierが "net.mtgto.example-app-sandbox" とすると、Info.plistはこのようになります。

Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CFBundleIdentifier</key>
	<string>net.mtgto.example-app-sandbox</string>
</dict>
</plist>

バンドル形式と違ってシングルバイナリの場合はInfo.plistファイルをバンドル内に同梱することはできません。Info.plistをもつシングルバイナリを作成するにはリンカで __TEXT セグメントに __info_plist セクションという名前で埋め込めばよいらしいです。

Enabling this setting creates a section called __info_plist in the __TEXT segment of the product’s linked binary containing the processed Info.plist file for the target.
https://developer.apple.com/documentation/Xcode/build-settings-reference#Create-Infoplist-Section-in-Binary

なお、埋め込む Info.plist の形式はxmlでなくとも構わないようです。おすすめはファイルサイズがより小さいバイナリ形式です。plutilコマンドで形式を変換できるのでバイナリ形式に変換しておきましょう (plutil -convertInfo.plist ファイル自体を書き換えるので注意)。

plutil -convert binary1 Info.plist

Goの場合はgo build時に -ldflags='-extldflags "-sectcreate __TEXT __info_plist /path/to/Info.plist"' のようにリンカフラグを指定することでInfo.plistが埋め込まれたシングルバイナリを作成できます。注意としてCGOが有効でないといけないので import "C" しておきましょう。環境変数で CGO_ENABLED=1 にするだけだと埋め込んでくれないようでした。

作成したバイナリにうまく Info.plist が埋め込めているかどうかは plutil コマンドで確認できます。

❯ plutil -p foo
{
  "CFBundleIdentifier" => "net.mtgto.example-app-sandbox"
}

Step 2. codesign コマンドでEntitlementsファイルを指定して署名する

作成したバイナリにEntitlementsを設定し、App Sandboxを有効にします。

Entitlementsにはいくつかのキー値がありますが、キー com.apple.security.app-sandbox に値 true を設定することでApp Sandboxが有効になります。

https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.app-sandbox

Entitlementsは拡張子 .entitlements で作成することが多いようです。

foo.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.app-sandbox</key>
	<true/>
</dict>
</plist>

これを codesign コマンドでバイナリに書き込みます。ここでは簡単に ad hoc 署名を使っています。

❯ codesign --sign - \
--identifier net.mtgto.example-app-sandbox \
--options runtime \
--entitlements foo.entitlements \
foo
  • --sign - ad hoc署名を使う
  • --identifier <bundle identifier> Info.plist で指定したのと同じBundle Identifierを指定してください。
  • --options runtime 実行時に危険な処理がないかを検査する Hardened Runtime を有効化。Hardened RuntimeはApp Sandboxに必須ではないんですが、Apple Notarizationを受けるときには必須です。
  • --entitlements <entitlements path> 指定したEntitlementsを適用する

上記ではad hoc署名を使っていますが、Mac App Storeを使わずに配布するためにApple Notarizationを受ける場合などは Mac DeveloperDeveloper ID などの専用の署名が必要になります。詳しくはNotarizing macOS software before distributionなど、Apple Notarizationについての記事を探してみてください。

シングルバイナリのEntitlementsを表示

codesign --display --entitlements - <file> を実行することで、指定したバイナリファイルに適用されているEntitlementsを表示することができます。
先ほど署名したバイナリ foo をみてみると com.apple.security.app-sandbox にtrueがセットされていることが確認できます。

❯ codesign --display --entitlements - foo
Executable=/tmp/foo
[Dict]
        [Key] com.apple.security.app-sandbox
        [Value]
                [Bool] true

これでシングルバイナリでもApp Sandboxを有効にすることができました。

App Sandboxの有無による挙動の違い

ここまでの説明でシングルバイナリ形式でApp Sandboxを有効にすることができました。次にGoアプリがApp Sandboxの有無でどのように挙動が変わるかをみてみます。

ホームディレクトリの場所

ホームディレクトリのパスは os.UserHomeDir() で取得できます。これはApp Sandboxが有効になっていると次のように変わります。

  • App Sandboxなし: /Users/<username>
  • App Sandboxあり: /Users/<username>/Library/Containers/<bundle-identifier>/Data
package main

import (
	"fmt"
	"os"
	"C" // enable Cgo to embed Info.plist
)

func main() {
	homeDir, err := os.UserHomeDir()
	if err != nil {
		panic(err)
	}
	fmt.Printf("Home directory: %v\n", homeDir)
}

/Users/<username>/Library/Containers/<bundle-identifier> というディレクトリはApp Sandboxが有効なアプリケーションが初回に起動するときに作成されます。このディレクトリの中はApp Sandboxが有効なアプリケーションであっても自由にファイルを作成・更新・読み込みができます。

カレントディレクトリの場所

os.Getwd() で取れるカレントディレクトリはどうでしょうか。これもApp Sandboxだと ~/Library/Containers/<bundle-identifier>/Data というSandbox コンテナ内に変更されます。

  • App Sandboxなし: 現在のディレクトリ
  • App Sandboxあり: /Users/<username>/Library/Containers/<bundle-identifier>/Data
cwd.go
package main

import (
	"fmt"
	"os"
	"C" // enable Cgo to embed Info.plist
)

func main() {
	cwd, err := os.Getwd()
	if err != nil {
		panic(err)
	}
	fmt.Printf("os.Getwd(): %v\n", cwd)
}

自身のデータコンテナ以外のファイルへのアクセス

前述のように、App Sandboxが有効なアプリケーションは /Users/<username>/Library/Containers/<bundle-identifier> 内のファイルは読み書きできますが、それ以外の場所にはEntitlementsファイルで許可をしておくかユーザーに許可をしてもらう必要があります。

試しに許可がないファイル、ここでは /etc/hosts を読み込んでみましょう。

open_file.go
package main

import (
	"fmt"
	"os"
	"C" // enable Cgo to embed Info.plist
)

func main() {
	bytes, err := os.ReadFile("/etc/hosts")
	if err != nil {
		panic(err)
	}
	fmt.Println(string(bytes))
}

実行結果

  • App Sandboxなし: /etc/hosts の中身が表示される
  • App Sandboxあり: プロセスがクラッシュする[1]

Console.appのクラッシュレポートを見るとpanicで死んだわけではなく、Sandboxが機能しているようで、libsystem_secinit.dylib でクラッシュしているのがわかります。

クラッシュレポート
System Integrity Protection: enabled

Triggered by Thread: 0

Exception Type:    EXC_BREAKPOINT (SIGTRAP)
Exception Codes:   0x0000000000000001, 0x00000001992200c8

Termination Reason:  Namespace SIGNAL, Code 5, Trace/BPT trap: 5
Terminating Process: exc handler [22805]


Application Specific Signatures:
Failed to create a code identity for pid 22805: 操作を完了できませんでした。(OSStatusエラー100001)

Thread 0 Crashed:
0   libsystem_secinit.dylib       	       0x1992200c8 _libsecinit_appsandbox.cold.6 + 60
1   libsystem_secinit.dylib       	       0x19921f2b4 _libsecinit_appsandbox + 1968
2   libsystem_trace.dylib         	       0x18965cf80 _os_activity_initiate_impl + 64
3   libsystem_secinit.dylib       	       0x19921eab0 _libsecinit_initializer + 80
4   libSystem.B.dylib             	       0x199236374 libSystem_initializer + 280
5   dyld                          	       0x18958acb0 invocation function for block in dyld4::Loader::findAndRunAllInitializers(dyld4::RuntimeState&) const + 444
6   dyld                          	       0x1895c8730 invocation function for block in dyld3::MachOAnalyzer::forEachInitializer(Diagnostics&, dyld3::MachOAnalyzer::VMAddrConverter const&, void (unsigned int) block_pointer, void const*) const + 324
7   dyld                          	       0x1895e7540 invocation function for block in mach_o::Header::forEachSection(void (mach_o::Header::SectionInfo const&, bool&) block_pointer) const + 312
8   dyld                          	       0x1895e4164 mach_o::Header::forEachLoadCommand(void (load_command const*, bool&) block_pointer) const + 208
9   dyld                          	       0x1895e59fc mach_o::Header::forEachSection(void (mach_o::Header::SectionInfo const&, bool&) block_pointer) const + 124
10  dyld                          	       0x1895c8220 dyld3::MachOAnalyzer::forEachInitializer(Diagnostics&, dyld3::MachOAnalyzer::VMAddrConverter const&, void (unsigned int) block_pointer, void const*) const + 516
11  dyld                          	       0x18958aa68 dyld4::Loader::findAndRunAllInitializers(dyld4::RuntimeState&) const + 172
12  dyld                          	       0x1895966a8 dyld4::PrebuiltLoader::runInitializers(dyld4::RuntimeState&) const + 44
13  dyld                          	       0x1895acf14 dyld4::APIs::runAllInitializersForMain() + 88
14  dyld                          	       0x18956f158 dyld4::prepare(dyld4::APIs&, mach_o::Header const*) + 3112
15  dyld                          	       0x18956dd04 start + 7104

外部プロセス起動

外部プロセスの起動はApp Sandboxが有効だとプロセスがクラッシュします。

spawn.go
package main

import (
	"fmt"
	"os/exec"
	"C" // enable Cgo to embed Info.plist
)

func main() {
	cmd := exec.Command("date")
	out, err := cmd.Output()
	if err!= nil {
		panic(err)
	}
	fmt.Println(string(out))
}

実行結果

  • App Sandboxなし: date の実行結果が表示される
  • App Sandboxあり: プロセスがクラッシュする[2]

ファイルオープンと同様に、libsystem_secinit.dylib でクラッシュしていました。

クラッシュレポート
System Integrity Protection: enabled

Triggered by Thread: 0

Exception Type:    EXC_BREAKPOINT (SIGTRAP)
Exception Codes:   0x0000000000000001, 0x00000001992200c8

Termination Reason:  Namespace SIGNAL, Code 5, Trace/BPT trap: 5
Terminating Process: exc handler [97723]


Application Specific Signatures:
Failed to create a code identity for pid 97723: 操作を完了できませんでした。(OSStatusエラー100001)

Thread 0 Crashed:
0   libsystem_secinit.dylib       	       0x1992200c8 _libsecinit_appsandbox.cold.6 + 60
1   libsystem_secinit.dylib       	       0x19921f2b4 _libsecinit_appsandbox + 1968
2   libsystem_trace.dylib         	       0x18965cf80 _os_activity_initiate_impl + 64
3   libsystem_secinit.dylib       	       0x19921eab0 _libsecinit_initializer + 80
4   libSystem.B.dylib             	       0x199236374 libSystem_initializer + 280
5   dyld                          	       0x18958acb0 invocation function for block in dyld4::Loader::findAndRunAllInitializers(dyld4::RuntimeState&) const + 444
6   dyld                          	       0x1895c8730 invocation function for block in dyld3::MachOAnalyzer::forEachInitializer(Diagnostics&, dyld3::MachOAnalyzer::VMAddrConverter const&, void (unsigned int) block_pointer, void const*) const + 324
7   dyld                          	       0x1895e7540 invocation function for block in mach_o::Header::forEachSection(void (mach_o::Header::SectionInfo const&, bool&) block_pointer) const + 312
8   dyld                          	       0x1895e4164 mach_o::Header::forEachLoadCommand(void (load_command const*, bool&) block_pointer) const + 208
9   dyld                          	       0x1895e59fc mach_o::Header::forEachSection(void (mach_o::Header::SectionInfo const&, bool&) block_pointer) const + 124
10  dyld                          	       0x1895c8220 dyld3::MachOAnalyzer::forEachInitializer(Diagnostics&, dyld3::MachOAnalyzer::VMAddrConverter const&, void (unsigned int) block_pointer, void const*) const + 516
11  dyld                          	       0x18958aa68 dyld4::Loader::findAndRunAllInitializers(dyld4::RuntimeState&) const + 172
12  dyld                          	       0x1895966a8 dyld4::PrebuiltLoader::runInitializers(dyld4::RuntimeState&) const + 44
13  dyld                          	       0x1895acf14 dyld4::APIs::runAllInitializersForMain() + 88
14  dyld                          	       0x18956f158 dyld4::prepare(dyld4::APIs&, mach_o::Header const*) + 3112
15  dyld                          	       0x18956dd04 start + 7104

ネットワークアクセス (クライアント)

https://example.com へのHTTPアクセスをしてみます。

client.go
package main

import (
	"fmt"
	"net/http"
	"C" // enable Cgo to embed Info.plist
)

func main() {
    resp, err := http.Get("https://example.com")
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

	fmt.Println("Response status:", resp.Status)
}

App Sandboxなしだと "Response stauts: 200 OK" が表示されますが、App Sandboxが有効だと接続できず、エラーが発生します。
コンテナ外のファイルをオープンしたときと違ってプロセスのクラッシュはしないようです。

❯ ./foo
panic: Get "https://example.com": dial tcp: lookup example.com: no such host

goroutine 1 [running]:
main.main()
        /tmp/example-app-sandbox/client.go:12 +0xec

Entitlementsでネットワークアクセスを許可してみます。HTTPリクエストはクライアント機能なので com.apple.security.network.client をtrueにします。

client.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.app-sandbox</key>
	<true/>
	<key>com.apple.security.network.client</key>
	<true/>
</dict>
</plist>
❯ codesign --sign - \
--identifier net.mtgto.example-app-sandbox \
--options runtime \
--entitlements client.entitlements \
foo

Entitlementsでネットワークアクセスが許可されているため、App Sandboxが有効でもHTTPリクエストができるようになりました。

❯ ./foo
Response status: 200 OK

ネットワークアクセス (サーバー)

HTTPサーバーを作ってみます。

server.go
package main

import (
	"fmt"
	"net/http"
	"C" // enable Cgo to embed Info.plist
)

func hello(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "Hello, world!")
}

func main() {
	server := http.Server{
		Addr:    ":8080",
		Handler: nil,
	}

	http.HandleFunc("/hello", hello)
	err := server.ListenAndServe()
	if err != nil {
		panic(err)
	}
}

App Sandboxが無効な場合は正常に起動します。

❯ ./foo&

❯ curl localhost:8080/hello
Hello, world!

App Sandboxが有効な場合は ListenAndServe() でエラーが返ります。

❯ ./foo
panic: listen tcp :8080: bind: operation not permitted

goroutine 1 [running]:
main.main()
        /tmp/server.go:22 +0x8c

Entitlementsで com.apple.security.network.server をtrueにしてみるとエラーが発生しなくなります。

client.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.app-sandbox</key>
	<true/>
	<key>com.apple.security.network.server</key>
	<true/>
</dict>
</plist>
❯ codesign --sign - \
--identifier net.mtgto.example-app-sandbox \
--options runtime \
--entitlements server.entitlements \
foo

おまけ: Rust製のシングルバイナリをApp Sandbox有効化

なぜかcargoは使わずrustcによる例を作ってみました。 rustc -C link-args=<space-separated-args> でリンカにフラグを渡せるため、Goと同様にInfo.plistを埋め込めます。

❯ rrustc -C link-args='-sectcreate __TEXT __info_plist Info.plist' foo.rs

codesign コマンドでEntitlementsを埋め込んだ署名をする方法はGoと同様なので省略します。

まとめ

GoアプリでのApp Sandboxを有効にできる方法とApp Sandboxの有無によるホームディレクトリの場所やファイルアクセス・ネットワークアクセスを行ったときの違いを紹介しました。
近年は広く使われているライブラリへのサプライチェーン攻撃など、自分が作成したアプリが急にマルウェアに変わってしまうリスクが現実的に存在しています。App Sandboxはそのような危険を完全に防ぐものではありませんが、リスクを減らす手段として知っておいて損はないのではないでしょうか。

App Sandboxが有効になっている場合は自由に書き込みが可能なファイルシステムが /Users/<username>/Library/Containers/<bundle-identifier> の中だけになり、アプリの中から取得できるホームディレクトリも通常のホームディレクトリではなくContainer専用のディレクトリになります。App Sandboxを有効にするアプリケーションの開発時には無効のときとの違いに注意が必要かもしれません。

参考文献

脚注
  1. ビルド & 署名したバイナリをそのまま起動するときはクラッシュしないのですが、一度そのバイナリを別の場所にコピーしたりするとクラッシュが発生しました。条件はよくわかっていません。 ↩︎

  2. ファイルオープンのときと同様にビルド & 署名したバイナリをそのまま起動するときはクラッシュしないのですが、一度そのバイナリを別の場所にコピーしたりするとクラッシュが発生しました。 ↩︎

Discussion