Outil de détection d'environnement pour Android

Fireyer est une application conçue pour valider l'intégrité de nos environnements de virtualisation, garantissant ainsi la qualité de chaque mise à jour de notre produit et améliorant l'efficacité du développement.

Le projet est open source sur GitHub.

1. Description

Le nom Fireyer est la combinaison de fire et eyer (œil de feu). Ce projet est un sous-produit interne issu du développement de notre produit de sandbox virtualisé. Son but initial est de vérifier la solidité de nos environnements virtualisés et il sert d'outil de détection noir et blanc en interne, assurant la stabilité de nos livraisons. Pour les développeurs travaillant sur des solutions de sandbox ou de virtualisation, cet outil peut également améliorer la productivité en permettant de valider rapidement la stabilité des fonctionnalités. La liste des vérifications de Fireyer est en constante évolution et sera régulièrement mise à jour.

Notre produit de virtualisation cible des appareils grand public courants. Par conséquent, Fireyer est principalement destiné à détecter, dans un environnement système normal, les scénarios tels que le re-embalage (ou re-signature) d'une application, l'exécution dans un conteneur (chargement sans installation), ou une machine virtuelle (transformant le système Android en une application standard). Fireyer n'est actuellement pas conçu pour détecter les environnements basés sur des ROM personnalisées, l'injection de Magisk, ou l'accès root (bien que certaines vérifications puissent fonctionner de manière transversale). L'intégration de ces scénarios est prévue dans notre feuille de route future.

2. Mode d'emploi

L'objectif principal de Fireyer est d'améliorer la stabilité de notre produit, non de s'engager dans une confrontation technique agressive. Il vise à garantir un comportement applicatif normal et stable.

Notre méthode de test interne se déroule comme suit :

  1. Dans un environnement applicatif standard, lancer le test unitaire pour l'environnement original. Fireyer formatte les données des cas de test terminés et les conserve dans le presse-papiers du système.
  2. Dans un environnement de test virtualisé, lancer le test unitaire pour l'environnement virtuel. Fireyer récupère les données de test depuis le presse-papiers, les compare aux résultats des cas de test en cours d'exécution, et fournit le bilan de validation.

3. Appels Système Personnalisés

Afin d'implémenter la détection des interceptions de type inline et got, il est nécessaire de coder des appels système fondamentaux. Voici des exemples de fonctions à implémenter :

int open(const char *pathname, int flags, ...);
int close(int fd);
int stat(const char* path, struct stat* buf);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
ssize_t readlink(const char *path, char *buf, size_t bufsiz);

Une méthode simple pour implémenter ces appels système consiste à extraire la bibliothèque libc.so de l'appareil (ici, la version 64 bits), puis à l'analyser avec un désassembleur comme IDA pour examiner l'implémentation des fonctions cibles. L'implémentation de openat sur une architecture 64 bits peut ressembler à ceci :

__attribute__((__naked__)) int svc_openat() { 
  __asm__ volatile("mov x15, x8\n" 
    "ldr x8, =0x38\n"
    "svc #0\n"
    "mov x8, x15\n"
    "bx lr"
  );
}

Avantage : L'implémentation personnalisée des appels système permet de comparer les chemins critiques avec les appels de fonctions standard, permettant ainsi d'identifier les interceptions, qu'elles soient basées sur la table got ou sur l'inlining (inline).

Contre-mesure : Une solution pour contrer cette détection consiste à utiliser un traceur au niveau applicatif.

4. Interception par Proxy et Détection

L'interception peut être réalisée en utilisant le module Proxy de Java, comme illustré ci-dessous :

package java.lang.reflect;

public class Proxy implements java.io.Serializable {
       public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h);
}

Après mise en place du proxy, l'instance originale est remplacée. Lorsque l'application appelle une méthode de l'interface, le callback est déclenché.

Une méthode de détection courante consiste à vérifier si une classe est un proxy :

package java.lang.reflect;

public class Proxy implements java.io.Serializable {
    public static boolean isProxyClass(Class<?> cl) {
        return Proxy.class.isAssignableFrom(cl) && proxyClassCache.containsValue(cl);
    }
}

Cependant, un attaquant peut contourner cela en utilisant des méthodes natives pour créer l'objet proxy, contournant ainsi la classe Proxy standard :

package java.lang.reflect;

public class Proxy implements java.io.Serializable {
       private static native Class<?> generateProxy(String name, Class<?>[] interfaces,
                                                 ClassLoader loader, Method[] methods,
                                                 Class<?>[][] exceptions);
}

Dans ce cas, on peut toujours identifier la classe par son nom, qui sera différente :

// Classe normale
android.view.IWindowSession$Stub$Proxy
// Classe proxifiée
android.view.IWindowSession$Stub$Proxy$Proxy

5. Interception Binder et Détection

Les communications avec les services Android peuvent souvent être interceptées. La méthode la plus simple pour intercepter la communication Binder consiste à utiliser un proxy d'interface. En effet, le framework de communication Binder pour les services Android repose sur des interfaces pour la sérialisation et la désérialisation des données :

/**
 * /frameworks/base/core/java/android/app/IActivityManager.aidl
 */
interface IActivityManager {
  // ...
}

/**
 * /frameworks/base/core/java/android/content/pm/IPackageManager.aidl
 */
interface IPackageManager {
  // ...
}

public interface Parcelable {
       public interface Creator<T> {
        public T createFromParcel(Parcel source);
        public T[] newArray(int size);
    }
}

  1. On peut obtenir l'objet Binder d'un service et vérifier s'il a été proxifié :
Object obj = ReflectUtils.getStaticFieldValue("android.app.ActivityManager", "IActivityManagerSingleton");
Object inst = ReflectUtils.getFieldValue(obj, "mInstance");
if (Proxy.isProxyClass(inst.getClass())) {
    // Détection d'une interception proxy
}

  1. Une approche plus profonde peut consister à intercepter au niveau de la couche Binder native. Cependant, cette méthode présente des inconvénients : pour des communications Binder complexes impliquant des types comme Bundle, Intent, ApplicationInfo ou PackageInfo, la logique de parsing devient extrêmement complexe. Pour une compatibilité suffisante, le code natif doit souvent faire appel à des fonctions de la couche supérieure pour effectuer la désérialisation.

6. Vérification d'Intégrité

6.1 Vérification de la Signature

  1. Via le PackageManagerService du système (méthode simple, souvent contournable).
PackageInfo pi = getContext().getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNING_CERTIFICATES);
pi.signingInfo; // Analyse à effectuer

  1. Via l'analyse du fichier APK local (méthode simple, souvent contournable).
PackageInfo pi = getContext().getPackageManager().getPackageArchiveInfo(mPackageInfo.applicationInfo.sourceDir, PackageManager.GET_SIGNING_CERTIFICATES);
pi.signingInfo; // Analyse à effectuer

Ces deux méthodes peuvent être contournées via une interception par proxy du créateur (CREATOR) de SigningInfo, permettant de falsifier la valeur de PackageInfo.signingInfo.

// Extrait du code source
public final class SigningInfo implements Parcelable {

    public static final @android.annotation.NonNull Parcelable.Creator<SigningInfo> CREATOR =
            new Parcelable.Creator<SigningInfo>() {
        @Override
        public SigningInfo createFromParcel(Parcel source) {
            return new SigningInfo(source);
        }

        @Override
        public SigningInfo[] newArray(int size) {
            return new SigningInfo[size];
        }
    };
}

6.2 Vérification des Attributs

  1. Vérification de l'intégrité du tag <application> dans le manifeste (ex. : android:debuggable, android:name, android:icon, etc.).
  2. Vérification des permissions déclarées.
  3. Vérification des quatre composants principaux : activity, activity-alias, service, provider, receiver.
  4. Vérification des métadonnées (meta-data).

7. Vérification de l'Environnement d'Exécution

7.1 Détection de la Libération des API Cachées

Beaucoup de manipulations visent à accéder à des fonctionnalités restreintes, nécessitant souvent l'appel d'API cachées (à partir d'Android 9.0). Il faut donc vérifier si les restrictions de certains modules critiques ont été levées.

if (classFind("android.app.ActivityThread")) break;
if (classFind("dalvik.system.DexPathList")) break;
if (classFind("android.app.LoadedApk")) break;
if (classFind("android.app.IActivityManager")) break;
if (classFind("android.content.pm.IPackageManager")) break;

En tentant d'accéder par réflexion à des classes marquées @hide dans le code source d'Android, on peut déterminer si les API cachées sont accessibles dans l'environnement actuel. Cette détection est difficile à contrer sans impact. Pour la masquer complètement, un framework de virtualisation devrait intercepter les appels, gérer une politique spécifique, pré-cacher les classes/méthodes/fields cibles, puis restaurer l'état, ce qui impacterait significativement la consommation mémoire et les performances d'initialisation.

7.2 Détection de l'Arborescence de Fichiers

En utilisant des appels système personnalisés, on peut inspecter le répertoire privé de l'application pour y détecter la présence de fichiers ou dossiers inconnus. Certains environnements virtualisés pré-déposent des fichiers de données dans ce répertoire.

7.3 Détection de la Pile d'Appels (Call Stack)

On peut inspecter la pile d'appels à des points critiques :

  1. Lors de l'initialisation via AppComponentFactory.
  2. Lors de l'initialisation de l'objet Application.
  3. Lors du callback callback du handler ActivityThread$H.

Méthodes de détection :

  1. Utilisation de Thread.dumpStack() au niveau Java. Un environnement virtualisé peut intercepter les fonctions natives associées pour la falsifier.
  2. Utilisation d'une bibliothèque bas niveau comme libunwind pour obtenir les noms de fonctions et les informations de bibliothèque. Un environnement virtualisé peut intercepter la fonction getcontext pour masquer les informations.

7.4 Détection des Threads

Au niveau Java :

public static void getAllThreadsInfo() {
    Map<Thread, StackTraceElement[]> allThreads = Thread.getAllStackTraces();
    for (Map.Entry<Thread, StackTraceElement[]> entry : allThreads.entrySet()) {
        Thread thread = entry.getKey();
        StackTraceElement[] stackTrace = entry.getValue();
        // Traitement des informations sur le thread
    }
}

Certains environnements interceptent les appels natifs pour falsifier ces informations. Il est donc nécessaire de parcourir le répertoire /proc/self/task via des appels système personnalisés :

void getAllThreadsInfo() {
    char threadName[128];
    DIR* taskDir = opendir("/proc/self/task");
    if (taskDir != nullptr) {
        struct dirent* entry;
        while ((entry = svc_readdir(taskDir)) != nullptr) {
            if (entry->d_type == DT_DIR && strcmp(entry->d_name, ".") != 0 && strcmp(entry->d_name, "..") != 0) {
                pid_t threadId = atoi(entry->d_name);
                if (pthread_getname_np(pthread_t(threadId), threadName, sizeof(threadName)) == 0) {
                             // Traitement des informations sur le thread
                }
            }
        }
        closedir(taskDir);
    }
}

7.5 Détection via Processus C

On peut collecter des informations en exécutant des commandes via un programme C :

  1. ls ${dir}.
  2. cat ${file}.
  3. Exécution d'un programme C personnalisé pour collecter des informations sur le processus principal.

Contre-mesures :

  1. Intercepter la fonction execve pour modifier les paramètres des commandes C exécutées.
  2. Intercepter execve pour, avant de créer un processus fils (fork), injecter une bibliothèque de préchargement dans les variables d'environnement (envp) du fils, permettant ainsi d'intercepter les appels de fonctions internes au programme C.

7.6 Détection via le Fichier maps

En utilisant des appels système, on analyse le contenu de /proc/self/maps :

  1. Vérifier la présence de bibliothèques tierces.
  2. Vérifier la légitimité du chemin du base.apk.
  3. Vérifier que les bibliothèques DEX n'ont pas été altérées.

Cette détection peut être contournée par un traceur qui intercepte la lecture et fournit un contenu maps falsifié pour le processus virtualisé.

7.7 Détection de Bibliothèques Injectées

On recherche dans les mappings mémoire du processus (/proc/self/maps) les traces de code chargé (ex. : DEX, bibliothèques natives) qui ne proviennent pas du répertoire d'installation standard.

int fd = svc_open("proc/self/maps", "r");
if (0 <= fd) {
  char buffer[1024];
  svc_read(fd, buffer, sizeof(buffer); // Lecture et analyse itérative pour détecter des bibliothèques hors répertoire d'installation
  svc_close(fd);
}

Un attaquant peut charger du code directement en mémoire, évitant ainsi de laisser des traces de chemin dans maps. Cela concerne à la fois les DEX et les bibliothèques natives.

Pour les DEX :

/**
 * Extrait de /libcore/dalvik/src/main/java/dalvik/system/DexPathList.java
 **/
public final class DexPathList {
       public static Element[] makeInMemoryDexElements(ByteBuffer[] dexFiles,
            List<IOException> suppressedExceptions) {
        // Logique de chargement en mémoire des DEX
        // ...
        return elements;
    }
}

Pour les bibliothèques natives :

FILE* tempFile = tmpfile();
// TODO Lire le contenu de la librairie dans tempFile
const char* tempFileName = fileno(tempFile);
void* libHandle = dlopen(tempFileName, RTLD_NOW);
if (libHandle != nullptr) {
    // ...
    dlclose(libHandle);
}
unlink(tempFileName); // Suppression du fichier temporaire

Dans ces cas, une analyse plus approfondie du contenu mémoire associé aux plages d'adresses dans maps est nécessaire.

7.8 Détection de Traceur (Tracer)

Une vérification simple consiste à contrôler le champ TracerPid: dans le fichier /proc/self/status via des appels système. Des mécanismes de détection mutuelle entre processus traceurs plus complexes peuvent être mis en place.

Étiquettes: Android détection d'environnement Virtualisation Sécurité appels système

Publié le 13 juin à 01h12