原文始发于Follow:iOSのファイル共有機能5パターンの検証とセキュリティ対策まとめ
はじめに
こんにちは。株式会社Flatt Securityセキュリティエンジニアの志賀(@Ga_ryo_)です。
iOSアプリケーションを開発する上で、メディアファイルやドキュメントファイルを他のアプリケーションと共有する機能を実装するケースがあると思います。iOSでは、ファイル共有のために様々な機能を提供していますが、OSの更新に従って機能が増え、把握が困難になってきたと感じている方もいることかと思います。
また、そういった機能が追加された際に実装方法に関する解説をしてくださる方々がいらっしゃると思いますが、細かい仕様について語られることはあまり多く無いという印象です。
そこで本稿では、iOSアプリケーション上で利用できる各種ファイル共有機能を5つのパターンに分けて検証しつつ、これらを利用する上で注意すべき点についても解説していこうと思います。
注) 本稿では度々サンプルコードを提示することがありますが、明示されない限り実機(iPhone X: iOS 15.2)での検証を行なっております。また、用いたサンプルコードは以下のrepository(https://github.com/flatt-security/ios-blog-examples)に全て上がっておりますのでご参照ください。
- はじめに
- アプリケーション間のセキュリティ
- アプリケーション間のファイル共有の仕組み
- App Groupsを用いたファイルの共有
- UIDocumentInteractionController/UIActivityViewControllerを用いてファイルを共有する (SceneDelegate編)
- UIDocumentInteractionController/UIActivityViewControllerを用いてファイルを共有する (Share/Action Extension編)
- File Provider Extensionを用いてファイルを共有する
- Document Based Appsを用いてファイルを共有する
- 共有時に気をつけること
- 最後に
アプリケーション間のセキュリティ
アプリケーション間のファイル共有を理解する上では、そもそもどのようにして他アプリケーションのファイル読み書きが禁止されているかを理解する必要があります。ここでは、iOSがそのような制限(以下、sandbox)をどう実現しているのかを簡単に説明します。本題とは少し話が逸れますので、興味がない方や知ってるよと言う方は、アプリケーション間のファイル共有の仕組みまでジャンプしてください。
概要を理解するには、まずApple社が出している以下のドキュメントを参照するのが良いでしょう。
他社製Appはすべて「サンドボックス化」されるので、ほかのAppによって保存されたファイルにアクセスしたり、デバイスに変更を加えたりすることはできません。サンドボックス化は、ほかのAppによって保存された情報が収集または変更されるのを防ぐために行われます。各Appにはファイルを保存する専用のホームディレクトリが用意されますが、これはAppがインストールされるときにランダムに割り当てられます。他社製Appが自身の情報以外の情報にアクセスする必要がある場合は、iOSおよびiPadOSによって明示的に提供されるサービスを使用したときのみアクセスできます。
システムファイルとリソースもユーザのAppから保護されます。iOSとiPadOSのほとんどのシステムファイルとリソースは、他社製Appと同様に特権のないユーザ「mobile」として実行されます。オペレーティングシステムのパーティション全体は、読み出し専用としてマウントされます。リモート・ログイン・サービスなどの不要なツールは、システムソフトウェアには含まれていません。また、AppがAPIを使って自身の権限を昇格させてほかのAppやiOSおよびiPadOSを変更することもできません。
上記の通り、iOSではという名前のユーザーが全ての3rd party アプリケーション共通で用いられています。直感的には同一ユーザーであれば他アプリケーションのファイルへアクセス出来てしまうように感じますが、ここで出てくるのがsandboxの仕組みです。mobile
iOSのsandboxの歴史はmacOSのそれ(App Sandbox)と関係性が強く、どちらもカーネル拡張に依存しています(ユーザーランドではcontainermanagerd(iOS)とsandboxd(macOS)が使用されているようです)。Sandbox.kext
そのProfileはmacOSでは容易にアクセスできて可読性も高いため、手元で読んで雰囲気を知ってみても良いでしょう。(iOSでは、これらのファイルはjailbreak対策なのかカーネル拡張自体にハードコードされているようで、Reverse Engineeringが必要です)
$ ls /System/Library/Sandbox/Profiles | wc -l 241 $ /System/Library/Sandbox/Profiles/container.sb ... (allow file-read* file-ioctl (require-all (regex "^/dev/r?disk[0-9]+") (require-any (device-conforms-to "IOBDMedia") (device-conforms-to "IODVDMedia") (device-conforms-to "IOCDMedia")))) (allow mach-lookup (global-name "com.apple.airportd") (global-name "com.apple.cfnetwork.AuthBrokerAgent") ...
このようにかなり柔軟に制御することができ、これを用いてiOSのアプリケーションのコンテナ化が行われているようです。
また、こちらに関して興味がある方は「MacOS and iOS Internals, Volume III: Security & Insecurity」を参照すると技術的により詳細な情報が得られます。概要であれば、購入せずとも、こちらを読むことで把握できるかと思います。
test1とtest2という2つのアプリで以下のコードを実行すると、それが確認できます。
//test1 let name = String(cString: getpwuid(getuid())!.pointee.pw_name!); let folder = NSHomeDirectory(); print("User is \(name)\nFolder is \(folder)");
User is mobile Folder is /var/mobile/Containers/Data/Application/BB5A4CCA-4885-4413-BE8C-2035B9CE47EA
//test2 let name = String(cString: getpwuid(getuid())!.pointee.pw_name!); let folder = NSHomeDirectory(); print("User is \(name)\nFolder is \(folder)"); let result1 = opendir(folder);//self //should be modified after installing test1 let uuidForTest1 = "BB5A4CCA-4885-4413-BE8C-2035B9CE47EA"; let basePath = URL(fileURLWithPath: folder).deletingLastPathComponent().path let result2 = opendir("\(basePath)/\(uuidForTest1)"); print("opendir(\"\(folder)\") == \(result1)\nopendir(\"\(basePath)\(uuidForTest1)\") == \(result2)");
User is mobile Folder is /var/mobile/Containers/Data/Application/5543F026-DAC9-4410-8304-60E01D1C99C0 opendir("/var/mobile/Containers/Data/Application/5543F026-DAC9-4410-8304-60E01D1C99C0") == Optional(0x00000002825bc6e0) opendir("/var/mobile/Containers/Data/Application/BB5A4CCA-4885-4413-BE8C-2035B9CE47EA") == nil
逆に、AndroidではアプリケーションごとにUIDを分けることによるアクセス制御とSELinuxによるアクセス制御を行なっています。こちらに関してはLinuxを使用したことのある開発者であればわりと直感的に理解できると思います。 (参考: アプリ サンドボックス | Android オープンソース プロジェクト | Android Open Source Project )
iOS Simulatorに関する余談
また、これは余談ですが、実はiOS Simulatorでは上記の検証は上手くいきません。
例えば、上記のtest1, test2のアプリは以下のような結果を出力します。1
Folder is /Users/garyo/Library/Developer/CoreSimulator/Devices/03AA46CE-8D22-491A-89F9-79E24908CD1B/data/Containers/Data/Application/CCB54883-932A-4E6A-87C5-FA0A048A0137
Folder is /Users/garyo/Library/Developer/CoreSimulator/Devices/03AA46CE-8D22-491A-89F9-79E24908CD1B/data/Containers/Data/Application/B53717DC-F076-4AEA-A76B-D700F2FC30B6 opendir("/Users/garyo/Library/Developer/CoreSimulator/Devices/03AA46CE-8D22-491A-89F9-79E24908CD1B/data/Containers/Data/Application/B53717DC-F076-4AEA-A76B-D700F2FC30B6") == Optional(0x00006000003885a0) opendir("/Users/garyo/Library/Developer/CoreSimulator/Devices/03AA46CE-8D22-491A-89F9-79E24908CD1B/data/Containers/Data/ApplicationCCB54883-932A-4E6A-87C5-FA0A048A0137") == Optional(0x0000600000388280) // 開けている
例えばAndroid Studioで開発を行う場合にはあちらはQEMUを用いてデバイス自体を仮想化(エミュレート)していますが、iOSはあくまでシミュレートであり、macOSの上でiOSっぽい動きをしているだけです(どちらもDarwinを基礎としているから出来ることでしょう)。
先述の通り、macOSとiOSではsandboxの機構が若干異なります。iOSのアプリケーションとしてはカーネル拡張にハードコードされたルールによって制限されるべきですが、この場合の実際のカーネルはmacOSのものであるため、そのような制約が無く、アクセス出来てしまっているのだと思います。(App Sandboxすらかかっていない無制限の一般ユーザーとしてコードが実行されているのだと思います)
アプリケーション間のファイル共有の仕組み
ようやく本題です。
アプリケーションが他のアプリケーションとファイルを共有(共同編集/閲覧)する方法として公式に用意されている方法は、調べた限りで出てくる手法としては以下のようなものが挙げらます。(技術的に少し重複していますが、ここでは無視してください)
- App Groupsを用いて、共有ディレクトリを作成する
- UIDocumentInteractionController/UIActivityViewControllerを用いてファイルを共有する (SceneDelegate編)
- UIDocumentInteractionController/UIActivityViewControllerを用いてファイルを共有する (Share/Action Extension編)
- File Provider Extension2を用いてファイルを共有する
- Document Based Appsを用いてファイルを共有する
- UIDocumentPickerViewController, UIDocumentBrowserViewControllerを用いてファイルを取得する
App Groupsを用いたファイルの共有
App groups allow multiple apps produced by a single development team to access shared containers and communicate using interprocess communication (IPC). Apps may belong to one or more app groups.
上記のドキュメントに記載の通り、App Groupsを用いると、同一開発者のアプリケーション(グループ)間で共通のコンテナ(フォルダ)にアクセスすることが出来るようになります。これはつまり、そのアプリケーション間で任意にファイルの共有が出来ることを意味します。
仕組み
まずは同一App Groupsに所属したtest3, test4という2つのアプリをで以下のコードを実行し、仕組みを見てみます。(App Groups設定の詳細は省略します)
//test3 print("Folder is \(NSHomeDirectory())"); let groupID = "group.com.garyo"; //Data sharing using userDefaults let userDefaults = UserDefaults(suiteName: groupID); userDefaults?.set("TEST", forKey: "KEY"); //File sharing let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID); print("Shared folder is \(url!.path)"); FileManager.default.createFile( atPath: url!.appendingPathComponent("/hoge.txt").path, contents: "AAAA".data(using: .utf8) ) let e = FileManager.default.enumerator(atPath: url!.path); while let file = e?.nextObject() as? String{ print(file); }
Folder is /var/mobile/Containers/Data/Application/A09DBE02-A394-4EA3-9C37-E633A4B4DC00 Shared folder is /private/var/mobile/Containers/Shared/AppGroup/847B3BD4-68FF-46BC-BA92-787E36B975DA .com.apple.mobile_container_manager.metadata.plist hoge.txt Library Library/Caches Library/Preferences Library/Preferences/group.com.garyo.plist
//test4 print("Folder is \(NSHomeDirectory())"); let groupID = "group.com.garyo"; //File sharing let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID); print("Shared folder is \(url!.path)"); let e = FileManager.default.enumerator(atPath: url!.path); while let file = e?.nextObject() as? String{ print(file); } do { let fileData = try Data(contentsOf: URL(fileURLWithPath: url!.path.appending("/hoge.txt"))) print(String(data: fileData, encoding: .utf8)!) } catch let e{ print(e); }
Folder is /var/mobile/Containers/Data/Application/8CDEFA0C-A6F7-4B6D-B8E2-F8DBD9C00186 Shared folder is /private/var/mobile/Containers/Shared/AppGroup/847B3BD4-68FF-46BC-BA92-787E36B975DA .com.apple.mobile_container_manager.metadata.plist hoge.txt Library Library/Caches Library/Preferences Library/Preferences/group.com.garyo.plist AAAA
たしかにちゃんと共有フォルダの内容の読み書きができているようです(当然ですが)。ちなみにGroup IDをに指定して利用したUserDefaultsはにバイナリ形式のplist(property list)として保存されています。suiteName
Library/Preferences/group.com.garyo.plist
.com.apple.mobile_container_manager.metadata.plist
はcontainermanagerdのために用意されたファイルで、一般には読み書きが出来ません。あまり気にする必要はないと思いますが、シミュレータ上では以下のような情報が格納されているようでした。
$ plutil -p ./.com.apple.mobile_container_manager.metadata.plist { "MCMMetadataActiveDPClass" => -1 "MCMMetadataContentClass" => 7 "MCMMetadataIdentifier" => "group.com.garyo" "MCMMetadataInfo" => { "com.apple.MobileInstallation.ContentProtectionClass" => -1 } "MCMMetadataSchemaVersion" => 1 "MCMMetadataUserIdentity" => { "posixGID" => 20 "posixUID" => 501 "type" => 2 "version" => "2" } "MCMMetadataUUID" => "BD061C60-14F8-4929-ACC3-7D5FA05262AB" "MCMMetadataVersion" => 6 }
次にApp Groupsの設定を外したtest5アプリケーションで以下のコードを実行すると、当然ながらフォルダを開くことができませんでした。
//test5 //should be modified after installing(running) test3/test4 let sharedPath = "/private/var/mobile/Containers/Shared/AppGroup/847B3BD4-68FF-46BC-BA92-787E36B975DA"; let result = opendir("\(sharedPath)"); print("opendir(\"\(sharedPath)\") == \(result)");
opendir("/private/var/mobile/Containers/Shared/AppGroup/847B3BD4-68FF-46BC-BA92-787E36B975DA") == nil
先述の通り、全てのアプリケーションはユーザーで動作しているため、該当の共有フォルダもUIDベースの制御としてはアクセスが可能なはずです。つまり、これもなんらかのsandboxルールによってアクセスが拒否されていると考えることが出来ます。mobile
そこでデバイスのログを見てみると、確かにSandbox.kextからルールによって弾かれている(deny)というメッセージが見て取れます。
$ idevicesyslog ... Feb 10 21:01:13 kernel(Sandbox)[0] <Error>: Sandbox: test5(1006) deny(1) file-read-data /private/var/mobile/Containers/Shared/AppGroup/847B3BD4-68FF-46BC-BA92-787E36B975DA ...
また、逆に言うと「UIDベースの制御としてはアクセスが可能なはず」ということは、App Groups内で他のアプリケーションから書き込み制限は出来ないということを意味します。これは、ファイル作成者の情報は一般にはUIDとして保持され、これが同一である以上は作成したアプリケーションか他のアプリケーションかの判断ができないからです。
まとめ
まとめると、以下のようなことが確認できました。
- 一般的なアプリケーション固有のフォルダーは に保存される
/var/mobile/Containers/Data/Application/{UUID}
- App Groupsで共有するためのフォルダーはグループごとに用意された に保存され、任意に読み書きができる
/private/var/mobile/Containers/Shared/AppGroup/{UUID}
UserDefaults
も同様にファイルとして保存されている
UIDocumentInteractionController/UIActivityViewControllerを用いてファイルを共有する (SceneDelegate編)
Use this class to present an appropriate user interface for previewing, opening, copying, or printing a specified file. For example, an email program might use this class to allow the user to preview attachments and open them in other apps.
上記のドキュメントに記載の通り を用いると、他のアプリケーションは指定されたファイルを閲覧する・開く・コピーするといった処理が可能になります。この指定されるファイルはアプリケーションのコンテナ内のファイルでも構わないため、ある種Sandboxを超えたアクセスであると言えます。(に関しては挙動がほぼ同じであるため、省略します)UIDocumentInteractionController
UIActivityViewController
一般的な理解として はファイルを送信するものであるといった認識が強く、結論としてはこの認識で問題がないのですが、定義上はファイルを開く(open)ことが可能と記載されているため、仕組みを詳しく追いかけて見ます。UIDocumentInteractionController
なお、ここでは受信サイドはでの受信を想定しています。(Extensionで受け取る方法については後述)scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>)
仕組み
まずはtest6, test7(こちらを先にインストール)という2つのアプリで以下のコードを実行し、test7へファイルを送信する仕組みを見てみます。(test7にはDocument Types
の設定が必要ですが、設定方法は省略します)
//test6 override func viewDidAppear(_ animated: Bool) { //create NSTemporaryDirectory()+"XXX.txt" //and write "AAAA" to it. (saved in application container) //NSTemporaryDirectory() == NSHomeDirectory()+"/tmp/" let filePath = NSTemporaryDirectory().appending("XXX.txt"); let url = URL( fileURLWithPath: filePath ); FileManager.default.createFile( atPath: url.path, contents: "AAAA".data(using: .utf8) ); print("Sharing file is \(filePath)"); controller = UIDocumentInteractionController.init(url: url); controller?.presentOpenInMenu( from: view.frame, in: view, animated: true );//ここでtest7を選択する }
Sharing file is /private/var/mobile/Containers/Data/Application/F52BFFB4-B7F0-478F-A7E5-28DB7A338233/tmp/XXX.txt
//test7 func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { print("NSHomeDirectory() == \(NSHomeDirectory())"); print(URLContexts.first!.url.path) }
NSHomeDirectory() == /var/mobile/Containers/Data/Application/40A87313-BF98-4E54-BB43-7705712312B5 /private/var/mobile/Containers/Data/Application/40A87313-BF98-4E54-BB43-7705712312B5/Documents/Inbox/XXX.txt
上記のログから、送信時のファイルパスと異なり、受信したファイルは受信先test7のアプリケーションコンテナ内に存在することが分かります。(とはsymbolic link)/private/var/
/var/
これはつまり、送信者のコンテナ内部のファイル権限を特別に外部アプリケーションに許可(Sandboxを超えたアクセス)して開いているのではなく、単純にファイルのコピーを送信先のコンテナに配置するという方式を取っていることを意味します。
一部の人は、「いやそもそもtest6はtest7のコンテナにアクセスできないのにどうやってコピーを作成するんだ」と思うかもしれません。かなり真っ当な疑問です。普通に考えると、以下のどちらかであると想像できると思います。
- IPC(XPC)の仕組み等を用いてデータとファイル名を別アプリケーションへ送信、受信者自信がコンテナ内に同名ファイルを作成
- Sandboxの影響下に無い何らかのファイル共有の仕組みが用意されている
実は、コンテナ内のフォルダはアプリケーションからは読み取り専用になっているため、1ではないことが分かります。結論として2なのですが、ログを追いかけるともう少し詳細に理解することができます。/Documents/Inbox/
$ idevicesyslog | grep XXX ... Feb 15 15:17:44 transitd[2690] <Notice>: Copy /private/var/mobile/Containers/Data/Application/F52BFFB4-B7F0-478F-A7E5-28DB7A338233/tmp/XXX.txt -> /private/var/mobile/Containers/Data/Application/40A87313-BF98-4E54-BB43-7705712312B5/Documents/Inbox "file:///private/var/mobile/Containers/Data/Application/F52BFFB4-B7F0-478F-A7E5-28DB7A338233/tmp/XXX.txt"
このように、transitdというデーモンが各アプリケーションの代わりにコピーしているという形のようです。ちなみに、macOS/iOSではApple公式のアプリケーションが特別な権限を持っていたり、Sandboxの影響を受けなかったりといったことはよくあるようで、このtransitdもその代表だと思われます。
余談ですが、上記のログには実機であればsharingd(AirDrop用のプロセス)のログも出てきます。このデーモンなんかもApple公式なのでSandboxを回避してるようなログが見えたりして面白いです。
$ idevicesyslog | grep XXX ... Feb 15 15:21:37 AirDrop(Sharing)[2693] <Notice>: Trying to issue sandbox extension for /private/var/mobile/Containers/Data/Application/F52BFFB4-B7F0-478F-A7E5-28DB7A338233/tmp/XXX.txt Feb 15 15:21:37 AirDrop(Sharing)[2693] <Notice>: Successfully issued sandbox extension for /private/var/mobile/Containers/Data/Application/F52BFFB4-B7F0-478F-A7E5-28DB7A338233/tmp/XXX.txt Feb 15 15:21:37 sharingd[66] <Notice>: Determining if conversion required for XXX.txt "file:///private/var/mobile/Containers/Data/Application/F52BFFB4-B7F0-478F-A7E5-28DB7A338233/tmp/XXX.txt" "<_PSAttachment 0x12da3a390> creationDate: (null), UTI: public.file-url, photoIdentifier: (null), contentURL: file:///private/var/mobile/Containers/Data/Application/F52BFFB4-B7F0-478F-A7E5-28DB7A338233/tmp/XXX.txt, contentText: AAAA"
まとめ
まとめると、以下のようなことが確認できました。
- 処理される前にファイルは特殊なデーモンによってコピーされる
- コピー且つ書き込み不可能なことから、送信元は元ファイルの改竄を気にする必要はない
UIDocumentInteractionController/UIActivityViewControllerを用いてファイルを共有する (Share/Action Extension編)
App Extensionは、他のアプリケーションの操作中に機能を提供する仕組みです。下記リンクに記載のように、多くの種類があります。
この中でも、iOSのファイルの共有に用いられそうなものは、Action/File Provider/Shareあたりでしょうか。これらの仕組みを2つに分け、まずはAction/Share Extensionについて追いかけてみます。(Photo Editing
あたりは今回の記事で解説したいものと性質が異なるので省略します)
注) ここで解説をするのは、Extentionと本体App間でのファイル共有(App Groupsで行うことが多い)ではなく、Extensionと外部アプリケーションがファイルを共有する際の話です。
仕組み
この2つのExtensionに関しては、外部からデータ/ファイルを受け取る側です。また、共有する側としては前述の/を起因としているため、これを用いた際の挙動/仕組みを追いかけてみます。UIDocumentInteractionController
UIActivityViewController
送信元はtest6を流用し、受信先としてtest8(Share Extentionを実装)を用意します。(一般に世のサンプルコードではバイト列自体を送ることが多いですが、ファイル共有について調査するためにtest6ではファイルのパスを送信していることに留意してください)
//test8 override func isContentValid() -> Bool { let extensionItem: NSExtensionItem = extensionContext?.inputItems.first as! NSExtensionItem; print(extensionItem.attachments!) let itemProvider = extensionItem.attachments?.first let fileUrlId = UTType.fileURL.identifier if itemProvider!.hasItemConformingToTypeIdentifier(fileUrlId){ itemProvider!.loadItem(forTypeIdentifier: fileUrlId, options: nil, completionHandler: { (data, error) in let url = (data as? URL)!; print("Shared file is \(url.path)"); print("Contents is \(NSData(contentsOf: url)!)"); }) }
Shared file is /private/var/mobile/Containers/Data/Application/559C68FF-88D7-4D93-8EDE-CAC8FFA559EF/tmp/XXX.txt Contents is {length = 4, bytes = 0x41414141}
なお、test6の出力は以下です。
Sharing file is /private/var/mobile/Containers/Data/Application/559C68FF-88D7-4D93-8EDE-CAC8FFA559EF/tmp/XXX.txt
さて、これを見て明らかに違和感があることにお気づきの方もいると思いますが、この結果はtest8がtest6のコンテナ内を指すファイルパスからコンテンツを読み取っていることを示しています。つまり、Sandboxを超えた読み取りが許可されているということのようです。
これに関していくらかコードを編集しながら検証を進めていくと、以下のような事がわかりました。
- 書き込みには失敗する(が拒否したというログが出る)
Sandbox.kext
loadItem()
関数の呼び出し後のみファイルの読み取りが可能- 恐らく後述のsecurity-scoped URLのような仕組みと類似だが、同一では無い3
- Extension自体は共有されるたびに別プロセスで立ち上がるため、NSItemProviderオブジェクトを保持し続けるのは難しそう(永続的なアクセス権を保持されない)
(なお、筆者もこの仕様を執筆中に知りました)
明らかに読み取れないファイルパスをtest6から試しに送信してみたところ、の引数にの文字列が入っていました。を発行すると言っていることから、前述のsharingd(AirDrop)が自身の管轄外のファイルのアクセス権限を取得するのと同じ仕組みが関数内部で利用されているのだと思います。completionHandler
error
"Cannot issue a sandbox extension for file \"ファイル名\": Operation not permitted"
sandbox extension
loadItem()
詳細な仕組みに関して、これ以上の調査は一定のReverse Engineeringが必要だと感じたため今回は断念しました。
まとめ
まとめると、以下のようなことが確認できました。
- コピーではなくSandboxを超えたファイルアクセスが行われる
- 書き込みは不可能且つアクセス権は永続的なものではない
File Provider Extensionを用いてファイルを共有する
このExtensionは名前の通り、まさにファイルを提供するExtensionです。
仕組み
In iOS, the extension manages a local copy of the extension’s content, including creating and managing placeholders for remote files. It also syncs the content with your remote storage.
Documentによれば、クラウド等のリモートファイルを提供するProviderのための仕組みとありますが、上記のように結局はローカルコピーを処理して共有する仕組みです。
また、こちらの共有の仕組みに関しては、security-scoped URLといったものが採用されているようです。珍しく(?)公式である程度の解説がありますので、まずはそちらを参照することを推奨します。(35:01~)
公式が解説してくれていますが、検証のため手元でもFile Provider Extensionを利用してみます。
まずは提供する側のtest9(コード量が多いので記事上には掲載しません)をインストールすると、下記画像のようにFile Providerとして追加されます。
そして、test10で以下のコードを実行して(test9が提供するファイル)を選択し、結果を取得します。BBB.txt
//test10 ... override func viewDidAppear(_ animated: Bool) { self.pickerController = UIDocumentPickerViewController(documentTypes: [UTType.image.identifier, UTType.plainText.identifier], in: .open) self.pickerController!.delegate = self self.present(self.pickerController!, animated: true) } } extension ViewController: UIDocumentPickerDelegate { public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { let documentURL = urls[0]; print("documentURL is \(documentURL)"); dump(NSData(contentsOf: documentURL)); let shouldStopAccessing = documentURL.startAccessingSecurityScopedResource() dump(NSData(contentsOf: documentURL)); defer { if shouldStopAccessing { documentURL.stopAccessingSecurityScopedResource() dump(NSData(contentsOf: documentURL)); } } }
documentURL is file:///private/var/mobile/Containers/Shared/AppGroup/657DAE6E-CDE0-4317-9FD2-9E2E4CAD1873/File%20Provider%20Storage/id2/BBB.txt - nil ▿ Optional(<43434343>) - some: <43434343> #0 - super: NSData - super: NSObject - nil
上記のログ1行目から分かるように、で選択したファイルは何らかのApp Groupsの共有フォルダ内に属しています。これは、実際にはtest9(とそのExtension)の共有フォルダUIDocumentPickerViewController
4になっており、当然test10はそのApp Groupsには属していませんので、ここでSandboxを超えたファイルアクセスが行われているということになります。
サンプルコードを見てもらえれば分かる通り、~間でしかアクセスを行うことは出来ません。このをsecurity-scoped URLと呼び、何らかのリソースが紐づいたものstartAccessingSecurityScopedResource()
stopAccessingSecurityScopedResource()
documentURL
5のようです(WWDCのセッション参照)。
ファイルの読み書き権限に関しては、各NSFileProviderItemオブジェクトのメンバー(参照: NSFileProviderItemCapabilities)で設定できるように見えます。しかし、実はこの設定における許可はUI上のものであり、実際のファイル操作権限を定義するものではありません。これに関する検証結果等は 共有時に気をつけることにて解説します。capabilities
ちなみに、archivedなドキュメントにはDocument Providerというものの情報があり、ここにFile Provider Extensionの解説も含まれているので参照すると良いと思います。(iOS 10以前ではDocument ProviderというものがDocument Provider Extensions/File Provider Extensionを含む単語として使われていたようです(参照))
まとめ
まとめると、以下のようなことが確認できました。
- security-scoped URLという仕組みでSandboxを超えたファイルアクセスが行われる
stopAccessingSecurityScopedResource()
が呼び出されるまで、長時間ファイルのアクセスが行われる可能性があるcapabilities
メンバーはアクセス制御をするものではない(後述))
Document Based Appsを用いてファイルを共有する
最後にDocument Based Appsの仕組みについて解説をします。
仕組み
実際にXcodeにテンプレートがあるので作成してみると分かりますが、Document Based AppsはUIDocumentBrowserViewControllerを初期画面として、そこに表示されるようなファイルを編集/保存するアプリケーション(3rd PartyのFileアプリ)だと言えます。つまりこれは、基本的には「共有される側」のアプリケーションです。
しかし、同時に「共有する側」のアプリケーションでもあります。ここで Document Based Apps 用のプロパティである UISupportsDocumentBrowser
6 (Xcodeから作成するとデフォルトでYES)の定義を確認してみます。
UISupportsDocumentBrowser (Boolean – iOS) Specifies that the app is a document-based app and uses the UIDocumentBrowserViewController class. If this key is set to YES, the user can set the document browser’s default save location in Settings. Additionally, the local file provider grants access to all the documents in the app’s Documents directory. These documents appear in the Files app, and in a Document Browser. Users can open and edit these document in place. https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/iPhoneOSKeys.html#//apple_ref/doc/uid/TP40009252-SW37
上記の通り、を設定するということはlocal file providerに自身のフォルダへのアクセス権を渡すということであると述べられています。UISupportsDocumentBrowser
Documents
こちらの機能は用途が似ており、File Provider Extensionと混同しがちですが、(local file provider)の中にあるのがDocument Based Appsで共有されているもの、やと並列に出てくるものがFile Provider Extensionです。このiPhone内
このiPhone内
iCloud Drive
整理すると、以下のような認識をしてもらえれば良さそうです。
- iCloud Drive : iCloudのファイルを提供するFile Provider
このiPhone内
: Document Based AppsのDocumentsフォルダを代わりに提供するFile Provider- fileprov(test9) : 何かしらのファイルを提供するFile Provider (Extension)
このとき、File Providerからファイルを共有される仕組みは既に解説しているので、以下のような仕組みになっているだろうということがわかります。
この の部分ですが、今回は詳しく調査できませんでした。該当プロパティが登録されたアプリケーションに対してlocal file providerがそもそも特権を有しているのかとも思いましたが、その場合は調査が難しそうです。?
まとめ
- Document Based Appsを作成すると、フォルダがlocal file providerに自動的に共有される
Documents
- 最終的に外部アプリケーションはFile Provider Extensionの時と同様にsecurity-scoped urlを経由して読み書き可能
共有時に気をつけること
最後に、これらの技術を用いたファイルの共有時の注意事項を説明します。
App Groupsを用いる場合
一般に同一の開発者が作成したアプリケーションは信頼することが出来るという前提があるため、特別気をつける事は少ないです。
ただし、共有フォルダでの処理であるため、編集/閲覧のRace Conditionには注意する必要があります。
また、開発をする際に信頼できるデータソースというのは検証を怠りがちですが、App Groupsの他のアプリケーションが侵害された際にも影響が受けないよう、最低限のファイル検証は行うべきでしょう。
UIDocumentInteractionController/UIActivityViewControllerを用いる場合
これらを使う際、基本的にはファイルが送信先アプリケーション内にコピーされるため、そもそも送信して良いファイルかどうか確認できていれば十分でしょう。
受信側としてもファイルの一般的なコンテンツの検証が行われれば良いです。
ただし、ファイルパスを指定して共有している場合に注意点があります。App Extensionの検証時にを使用するtest6を再利用できたように、これらのControllerは送信先としてAction/Share Extensionも表示します。UIDocumentInteractionController
ユーザー体験としては類似ですが、送信先の受信方法の違いにより、
- ファイルがコピーされるのか
- ファイルのsecurity-scoped urlが共有されるのか
といった違いが現れます。1. を想定して実装した場合でも、ユーザーがAction/Share Extensionを選択した場合は共有後のファイルの変更内容が送信先アプリケーションから観測可能です。そのため、共有後であっても漏洩してはいけない情報は同一ファイルに書き込まないよう注意が必要です。
File Provider Extensionを用いる場合
File Provider Extensionに関してはsecurity-scoped url(bookmark)の生存時間が長く、さらに注意する必要があります。
また、先述の通り、各NSFileProviderItemオブジェクトメンバーにも注意する必要があります。例えば今回使用したtest9アプリでは、以下のように設定されています。capabilities
var capabilities: NSFileProviderItemCapabilities { return [.allowsReading, .allowsRenaming] }
この状態でファイルアプリから開くと、画像のように確かに読み取りと名前の変更が許可されているように見えます。
しかし、test10の呼び出し後に以下のようにコードを追加すると、が書き込まれてしまっていることが確認できます。startAccessingSecurityScopedResource()
XXXX
let shouldStopAccessing = documentURL.startAccessingSecurityScopedResource() dump(NSData(contentsOf: documentURL)); //Check that NSFileProviderItemCapabilities.allowsReading is not for security. FileHandle(forWritingAtPath: documentURL.path)?.write("XXXX".data(using: .utf8)!); dump(NSData(contentsOf: documentURL));
... ▿ Optional(<43434343>) - some: <43434343> #0 - super: NSData - super: NSObject ▿ Optional(<58585858>) - some: <58585858> #0 - super: NSData - super: NSObject ...
ここで、公式の開発者ドキュメントを参照してみると、この設定値はドキュメントブラウザ上でのアクションを定義するものとの記載がありましたが、ファイルの権限を定義するものとは記載していませんでした。
An item’s capabilities, which define the actions that the user can perform in the document browser. https://developer.apple.com/documentation/fileprovider/nsfileprovideritemcapabilities
もちろんユーザーがファイルを明示的に選択する必要はありますが、この仕様を理解しておかないと意図せずファイルが編集されてしまう可能性があるため、注意が必要です。
Document Based Appsを用いる場合
Document Based Appsで調べると、これは編集体験等をよくするものというイメージを受けると思います。しかし、(使ってみれば分かることではありますが)そのようなアプリを作ろうとしてXcodeからを選択するととなり、自動的に自身のフォルダが共有されてしまいます。Document App
UISupportsDocumentBrowser=YES
Documents
test10アプリケーションにおいてを利用することでFile Provider (Extension)にアクセスできたように、そもそも自体はFile Providerの提供するファイルにアクセスする際には必要ありません。UIDocumentPickerViewController
UISupportsDocumentBrowser
そのため、ドキュメントファイルを操作したいだけで共有したいわけではない場合には、をにするか、フォルダに共有したくないファイルを配置しないよう注意する必要があります。UISupportsDocumentBrowser
NO
Documents
最後に
今回、さまざまな挙動を検証しつつ解説をしてみました。
普段、開発をする際にこういった細かい挙動まで調査しないという方も多いと思いますが、思わぬところでリスクが生まれてしまわないよう、我々としても引き続き検証/共有していきたいと思います。
また、今回では以下のようなことが調査しきれていない状態です。これも追々…
- Action/Share Extensionがファイルを取得する詳細な仕組み
- security-scoped URL自体の詳細な仕組み
UISupportsDocumentBrowser=YES
なアプリのフォルダへlocal file providerがアクセスする仕組みDocuments
Flatt Securityはセキュリティに関する様々な発信を行っています。 最新情報を見逃さないよう、公式Twitterのフォローをぜひお願いします!
最後に、弊社Flatt Securityのセキュリティ診断サービスの紹介です。
Flatt Securityではセキュリティエンジニアが手動で検査を行うセキュリティ診断サービスを提供しています。スマートフォンアプリに関してはクライアント側の実装もAPI側の診断も可能ですし、AWS・GCP・Azure等パブリッククラウドをセキュアに扱えているか合わせて診断することも可能です。
診断プランは柔軟に様々な形が組めますが、自動シミュレータでプロダクトごとにぴったりのプランを知ることができます。是非ご利用ください。
セキュリティ診断のサービス詳細は下記バナーよりご覧ください。
ここまでお読みいただきありがとうございました。
- macOS上のgetpwuid()は本来を用いておらず、OpenDirectoryベースでの認証となっています。ビルドしたiOS向けのは恐らくを見にいくため、501(macOSにログインしているユーザー)のpasswd構造体を取得しにいくとnilが返ってきます。そのため、ユーザー名関連のコードは削除して実行しています。↩
/etc/passwd
getpwuid()
/etc/passwd
- 昔はDocument Providerという単語がありましたが、Document Providerというものは、どうやら既にApp Extension扱いではなくなったようです。File Providerは元々はDocument Providerの一部でしたが、これが表記上独立し、Document Provider (Extension)という用語はDocument Based Appsの文脈でのみ残っているようです。また、そのViewControllerであるUIDocumentPickerViewControllerとUIDocumentBrowserViewControllerの違いは分かりづらいですが、一応公式ドキュメントに記載があります。(https://developer.apple.com/documentation/uikit/view_controllers/building_a_document_browser_app_for_custom_file_formats)↩
- まず、security-scoped URLは読み書きの両方が可能という事が違いとして上げられます。また、の代わりにを呼び出してもファイルの読み込みができなかった事から、完全に同じではないと判断しています(後述ですが、当該関数はハードコードされたURLオブジェクトから呼び出しても同様の効果が得られることが確認されています)。ただし、の内でを2度以上呼ぶとファイルが読み込めなくなるというバグ or 仕様があったため、という表記をしています。(全くの無関係な仕様であれば、このような事は起きないと考えられるため)↩
loadItem()
startAccessingSecurityScopedResource()
loadItem()
completionHandler
stopAccessingSecurityScopedResource()
恐らく
- 通常、Extensionは本体アプリのファイルを触ることが出来ないため、App Groupsを用いる(Extensionと本体アプリは開発者が同じであるため可能)。↩
- 余談ですが、ハードコードされたファイルパスから作成したURLオブジェクトからもが呼び出せるため、特殊なトークン的なものがオブジェクトに内包されているといった形ではなさそうです。↩
startAccessingSecurityScopedResource()
- 以前用いられていた、LSSupportsOpeningDocumentsInPlaceとUIFileSharingEnabledの組み合わせと効果はほぼ同じだと思います。↩