【テクニカル・上級編】 モバイルアプリにおける暗号化データの不適切な保存(SharedPreferences/Plist) – 暗号理論・認証基盤 & エンドポイントセキュリティ防御ガイド

モバイルアプリにおける暗号化データの不適切な保存:SharedPreferences・Plistが暴く「安全神話」の終焉

数々のペネトレーションテストやインシデントレスポンスの現場に立ち会ってきた経験から言わせてもらうと、モバイルアプリ開発者が最も軽視し、そして攻撃者が最も好んで最初に狙うのが「ローカルストレージの不適切なデータ保存」だ。

「HTTPSで通信を暗号化しているから大丈夫だ」「OSのサンドボックスがあるから他のアプリからは読まれない」。そんなお花畑のようなセキュリティ観測が、今日も数百万単位の機密データをアジアンマーケットやダークウェブのブローカーに献上している。

Androidの SharedPreferences や iOSの UserDefaults (あるいは生のエクスポートされるPlistファイル)、そしてむき出しのSQLiteデータベース。これらは「ちょっとした設定値やトークンを保存するのに便利」という免罪符のもと、アクセストークン、セッションID、果ては生体認証のバイパスフラグや平文のパスワードまでをその腹に抱え込んでいる。

今回は、このモバイルストレージの暗号化不備という古典的でありながら今なお猛威を振るう脆弱性について、低レイヤのメモリ挙動やストレージ構造の裏側から、実務で通用する鉄壁の防衛アーキテクチャまでを徹底的に紐解いていこう。

—

1. なぜ開発者は「平文の罠」に落ちるのか:OSサンドボックスの幻想

モバイルOSのサンドボックス機構は、確かにマルチテナント環境におけるプロセス間の隔離には優れている。AndroidのUIDベースのパーミッション分離や、iOSのコンテナディレクトリ構造は、悪意ある「隣人アプリ」が勝手にデータを覗き見することを防いでいる。

しかし、このサンドボックスは「デバイスの物理的所持者(あるいはマルウェアがroot/jailbreak権限を奪取した状態)」の前では、紙細工の城に等しい。

Androidの SharedPreferences の実態

Androidにおいて、SharedPreferences は内部でXMLファイルとして /data/data/[パッケージ名]/shared_prefs/ 配下に平文で書き出される。デバイスがroot化されている、あるいはカスタムリカバリからアクセスされた場合、このディレクトリへのアクセスは容易だ。adbコマンドを通じて run-as が使えなくとも、NANDフラッシュを直接読み出すフォレンジックツールや、デバッグgableな状態を突いたバックアップ抽出(adb backup の悪用:古いAPIレベルにおいて)によって、一網打尽に抜き取られる。

iOSの NSUserDefaults と Plist の実態

iOSでも同様だ。UserDefaults や直接生成された .plist ファイルは、アプリのサンドボックス内にある Library/Preferences/ に格納される。これもまた、Jailbreak環境であれば、Filza などのファイルブラウザや、SSH経由で容易にキャプチャ可能だ。

ここに保存されたアクセストークンやJWT(JSON Web Token)は、多くの場合、そのままAPIサーバーへの再利用が可能であり、Session Hijackingの格好の餌食となる。

—

2. 脆弱性の根底にある「鍵管理のジレンマ」

「じゃあ、暗号化して保存すればいいんだろう?」
そう言って開発者がやりがちなのが、いわゆる「自前暗号化(Roll-your-own cryptography)」の罠だ。

ハードコードされた共通鍵(AESの固定秘密鍵など)をソースコード内に埋め込み、その鍵で SharedPreferences に書き込むデータを暗号化する。これは逆アセンブルやデコンパイル(JADXやGhidraを用いた解析)の前では数秒の延命措置にすぎない。バイナリから鍵が抽出された瞬間、すべての暗号化データは平文と同じ意味を持つ。

真に安全な暗号化を実現するためには、以下の2つの要素を完全に分離・確立する必要がある。
1. 暗号アルゴリズムの選定(AES-256-GCMなどのモダンでセキュアな認証暗号)
2. ハードウェア起因のエントロピーを利用した鍵の動的生成と保護(Android KeyStore / iOS Keychain)

—

3. 模範解答:Androidにおける安全なストレージ保護(EncryptedSharedPreferences)

Android Jetpackセキュリティライブラリは、この「鍵管理のジレンマ」を解決するための強力なプリミティブを提供している。それが EncryptedSharedPreferences だ。内部でAES-256-GCMを使用し、鍵自体はAndroidのハードウェアセキュリティモジュール(TEE / StrongBox)に裏打ちされた AndroidKeyStore で保護される。

以下に、実務でそのまま利用できる安全なインスタンス生成の実装コードを示す。

import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

object SecureStorageManager {

    fun getEncryptedPreferences(context: Context): android.content.SharedPreferences {
        // MasterKeyの生成:AndroidKeyStoreを使用してハードウェアレベルで鍵を保護する
        // API Level 23 (Marshmallow) 以上ではTEE (Trusted Execution Environment) が活用される
        val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
            .build()

        // EncryptedSharedPreferencesのインスタンス化
        // キーと値の両方がAES-256-GCMによって暗号化され、XMLに書き出される
        return EncryptedSharedPreferences.create(
            context,
            "secure_user_prefs", // ストレージのファイル名
            masterKey,
            EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_GCM,
            EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
        )
    }
}

この実装により、仮にデバイスがroot化され shared_prefs 内のXMLファイルが流出したとしても、中身は完全な暗号文(Ciphertext)であり、ハードウェアの鍵ストアから切り離された鍵データなしには復号不能となる。

—

4. 大規模データとリレーショナル構造の防衛:SQLCipherによるデータベース暗号化

キー・バリュー型のストレージ(SharedPreferences や Plist)の範疇を超え、ローカルにSQLiteデータベースを構築して複雑な構造体を保持するアプリケーションも多い。特にオフラインファーストを謳うモダンなアプリでは、ローカルDBの保護は生死を分ける。

標準のSQLiteはデータを平文で保持するため、DBファイルそのものが抽出された瞬間にすべての機密情報が露見する。ここで投入すべき決定打が SQLCipher だ。

SQLCipherは、SQLiteデータベース全体を透明に(Transparently)256ビットAESで暗号化するオープンソースの拡張ライブラリである。

iOS/AndroidにおけるSQLCipherの実装アプローチ

データベースへの書き込み・読み込みのすべてのレイヤで、オンザフライ(リアルタイム)で暗号化と復号が行われるため、開発者はアプリ側のロジックを大きく変更することなく、強固なデータ保護を手に入れることができる。

以下は、Android環境(Room Persistence LibraryとSQLCipherの統合)における設定の概念コードだ。

import android.content.Context
import androidx.room.Room
import net.sqlcipher.database.SupportFactory

class SecureDatabaseManager {

    fun provideSecureDatabase(context: Context, passphrase: ByteArray): AppDatabase {
        // パスフレーズ(鍵)をSupportFactoryに渡す
        // 注意: パスフレーズ自体もコード内にハードコードせず、AndroidKeyStore等から動的に生成・取得すること
        val factory = SupportFactory(passphrase)

        return Room.databaseBuilder(context, AppDatabase::class.java, "secure_app_database.db")
            .openHelperFactory(factory) // SQLCipherのファクトリを指定して暗号化を有効化
            .build()
    }
}

ここで最も重要なのは、「SQLCipherに渡すパスフレーズ(鍵)をどこから調達するか」という点だ。鍵を平文でソースコードに書いたり、固定の文字列をアセットに含めたりしては本末転倒である。必ずOSのセキュアストレージ(Androidなら EncryptedSharedPreferences や KeyStore、iOSなら Keychain)に動的に生成したランダムバイト列を保存し、起動時にそれを取り出して SupportFactory に引き渡すアーキテクチャを構築しなければならない。

—

5. チーフホワイトハッカーの視点:監査とペネトレーションテストのチェックリスト

私たちがペネトレーションテストを行う際、クライアントのモバイルアプリに対して最初に実行するコマンドや手順のいくつかを共有しておこう。これらは、開発チームがリリース前のセルフ監査(セキュアコードレビュー)としてもそのまま活用できる。

1. 静的解析によるハードコード探索

  • バイナリやソースコード内から、固定のAES秘密鍵、IV(初期化ベクトル)、APIシークレットがハードコードされていないかを正規表現(secret, password, key などのキーワード)でスキャンする。

2. 動的ストレージインスペクション(Root/Jailbreak環境)

  • アプリを実機またはエミュレーター上で動作させ、データ保存領域(/data/data/[pkg]/ または Documents/ / Library/)にリアルタイムでアクセスし、ファイル作成直後の内容を目視および自動スクリプトで検証する。

3. メモリダンプとキーロガー的攻撃への耐性

  • 復号された機密データや鍵が、不要に長い時間ヒープメモリ上に平文で存在し続けていないか(Garbage CollectionのタイミングやString型の不適切な利用によるメモリ上の残留)を確認する。特にパスワードや秘密鍵は char[] や ByteArray で扱い、使用後は速やかにゼロクリア(Zeroing out)する実装が望ましい。

—

結びにかえて

セキュリティは「点」ではなく「面」だ。どれほど堅牢なHTTPS通信(TLSピンニングの導入など)やOAuth2.0の認可フローを実装していこうとも、最終的なデータの終着点であるモバイル端末のローカルストレージが「平文の王国」であれば、それは頑丈な玄関のドアを開けっ放しにして金庫をリビングに放置しているようなものである。

開発者各位には、SharedPreferences や Plist のような手軽なAPIを使うその指を一度止め、「このデータがデバイスのNANDフラッシュから直接抜き取られたとき、自社とユーザーはどうなるか」を今一度深く想像してほしい。

セキュアなアーキテクチャの選択は、技術的なコストではなく、プロダクトの信頼そのものに対する最小限の投資なのだから。

コメント

タイトルとURLをコピーしました