Interception Approfondie de Binder sur Android

Le mécanisme Binder constitue le cœur de la communication inter-processus (IPC) dans le système Android. De nombreux articles détaillent son fonctionnement interne :

  • Principes de la communication inter-processus Android et mécanisme Binder
  • Mécanismes clés d'Android : Binder [série]

Ces ressources, combinées au code source du système, permettent de comprendre les fondements de Binder et de préparer son interception. L'interception de Binder ouvre plusieurs possibilités :

  1. Virtualisation d'applications (ex: VirtualApp, DroidPlugin, espaces parallèles)
  2. Tests et validation de fonctionnalités au niveau Framework
  3. Surveillance des appels SDK tiers ou de modules système (notamment les API sensibles)
  4. Reverse engineering des interfaces de services d'applications
  5. Extension des services Framework sur les ROM personnalisées

Approches Actuelles

La méthode traditionnelle pour analyser et intercepter en temps réel les communications Binder entre processus repose sur la proxyfication des interfaces AIDL au niveau Java. Grâce aux conventions de conception des interfaces de services Binder dans Android, toutes les interfaces supérieures héritent de IBinder.

Voici un exemple de proxyfication de toutes les méthodes d'interface API d'un objet cible :


import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;

private static void recupererInterfaces(Class> classe, final HashSet<class>> interfaces) {
    Class>[] interfacesTrouvees;
    do {
        interfacesTrouvees = classe.getInterfaces();
        for (final Class> i : interfacesTrouvees) {
            if (interfaces.add(i)) {
                recupererInterfaces(i, interfaces);
            }
        }
        classe = classe.getSuperclass();
    } while (classe != null);
}

private static Class>[] getInterfaces(Class> classe) {
    final HashSet<class>> interfaces = new LinkedHashSet<class>>();
    recupererInterfaces(classe, interfaces);
    if (0 < interfaces.size()) {
        return interfaces.toArray(new Class>[interfaces.size()]);
    }
    return null;
}

public static Object creerProxy(Object original, InvocationHandler callback) {
    try {
        Class> classe = original.getClass();
        Class>[] classesInterfaces = getInterfaces(classe);
        return Proxy.newProxyInstance(classe.getClassLoader(), classesInterfaces, callback);
    } catch (Throwable e) {
        Logger.e(e);
    } finally {
        // TODO corriger le nom du proxy
    }
    return null;
}
</class></class></class>

1. Proxyfication des services Binder pré-créés

De nombreux services Binder sont déjà mis en cache avant que la logique de l'application puisse intervenir. Nous devons les identifier et les remplacer (AMS, PMS, WMS, etc.). Par exemple, pour AMS sur Android 8.0 et versions ultérieures :


// source code: http://aospxref.com/android-9.0.0_r61/xref/frameworks/base/core/java/android/app/ActivityManager.java
package android.app;

public class ActivityManager {

    public static IActivityManager getService() {
        return IActivityManagerSingleton.get();
    }

    private static final Singleton<iactivitymanager> IActivityManagerSingleton =
            new Singleton<iactivitymanager>() {
                @Override
                protected IActivityManager create() {
                    final IBinder b = ServiceManager.getService(Context.ACTIVITY_SERVICE);
                    final IActivityManager am = IActivityManager.Stub.asInterface(b);
                    return am;
                }
            };
}
</iactivitymanager></iactivitymanager>

Le remplacement s'effectue comme suit :


Object obj;
if (Build.VERSION.SDK_INT < 26) { // <= 7.0
    obj = ReflectUtils.getStaticFieldValue("android.app.ActivityManagerNative", "gDefault");
} else { // 8.0 <=
    obj = ReflectUtils.getStaticFieldValue("android.app.ActivityManager", "IActivityManagerSingleton");
}
Object instance = ReflectUtils.getFieldValue(obj, "mInstance");
ReflectUtils.setFieldValue(obj, "mInstance", creerProxy(instance));

2. Proxyfication de ServiceManager pour les services dynamiques

Pour les services obtenus durant l'exécution, nous devons proxyfier ServiceManager :


// source code: http://aospxref.com/android-9.0.0_r61/xref/frameworks/base/core/java/android/os/ServiceManager.java
package android.os;

public final class ServiceManager {
    private static final String TAG = "ServiceManager";

    private static IServiceManager sServiceManager;
}

Notre proxy ressemble à ceci :


Class> classe = ReflectUtils.findClass("android.os.ServiceManager");
Object original = ReflectUtils.getStaticFieldValue(classe, "sServiceManager");
Object proxy = creerProxy(original);
if (null != proxy) {
    ReflectUtils.setStaticFieldValue(getGlobalClass(), "sServiceManager", proxy);
}

Cette approche permet d'intercepter le premier appel à chaque service via la méthode IServiceManager.getService(), de reconnaître le service demandé, de proxyfier l'objet retourné, puis de le renvoyer.

Limitations des approches actuelles

Cependant, ces méthodes ne permettent pas d'intercepter tous les services Binder dans un processus. Nous rencontrons plusieurs défis :

  1. Le code source d'Android est de plus en plus volumineux, et identifier tous les srevices pré-cachés est complexe
  2. Les fabricants ajoutent de plus en plus de services personnalisés, qui ne sont pas documentés
  3. Certains services n'ont qu'une implémentation native, rendant l'interception via l'interface Java impossible (ex: Sensor, Audio, Video, Camera)

// source code: http://aospxref.com/android-13.0.0_r3/xref/frameworks/av/camera/ICamera.cpp

class BpCamera: public BpInterface<icamera>
{
public:
    explicit BpCamera(const sp<ibinder>& impl)
        : BpInterface<icamera>(impl)
    {
    }
  
    // start recording mode, must call setPreviewTarget first
    status_t startRecording()
    {
        ALOGV("startRecording");
        Parcel data, reply;
        data.writeInterfaceToken(ICamera::getInterfaceDescriptor());
        remote()->transact(START_RECORDING, data, &reply);
        return reply.readInt32();
    }
}
</icamera></ibinder></icamera>

Nouvelle Approche : Interception au Niveau Bas

Principes

Que ce soit au niveau Java ou natif, tous les appels d'interface finissent par utiliser la fonction ioctl pour accéder à l'espace mémoire partagé, permettant l'échange de données entre processus. En interceptant cette fonction, nous pouvons capturer toutes les communications Binder.

Les avantages de l'interception bas-niveau sont :

  • Interception de toutes les communications Binder
  • Haute compatibilité et stabilité - seulement deux adaptations majeures en 10 ans d'évolution d'Android (support des processus 64 bits et adaptation au système HarmonyOS de Huawei)

Défis à Résoudre

Comment intercepter ?

L'interception de fonctions C/C++ ne bénéficie pas d'outils de proxyfication aussi stables qu'en Java. Nous pouvons utiliser des frameworks d'hooking open source :

Comment filtrer efficacement ?

La fonction ioctl est appelée très fréquemment par le système, tandis que les communications Binder n'en représentent qu'une petite partie. Nous devons donc rapidement identifier et ignorer les appels non-Binder.

Définition de la fonction :


#include <sys>

int ioctl(int descripteur, unsigned long requete, ...);
</sys>

Les paramètres de requete sont définis comme suit :


// source code: http://aospxref.com/android-14.0.0_r2/xref/bionic/libc/kernel/uapi/linux/android/binder.h
#define BINDER_WRITE_READ _IOWR('b', 1, struct binder_write_read)
#define BINDER_SET_IDLE_TIMEOUT _IOW('b', 3, __s64)
#define BINDER_SET_MAX_THREADS _IOW('b', 5, __u32)
#define BINDER_SET_IDLE_PRIORITY _IOW('b', 6, __s32)
#define BINDER_SET_CONTEXT_MGR _IOW('b', 7, __s32)
#define BINDER_THREAD_EXIT _IOW('b', 8, __s32)
#define BINDER_VERSION _IOWR('b', 9, struct binder_version)
#define BINDER_GET_NODE_DEBUG_INFO _IOWR('b', 11, struct binder_node_debug_info)
#define BINDER_GET_NODE_INFO_FOR_REF _IOWR('b', 12, struct binder_node_info_for_ref)
#define BINDER_SET_CONTEXT_MGR_EXT _IOW('b', 13, struct flat_binder_object)
#define BINDER_FREEZE _IOW('b', 14, struct binder_freeze_info)
#define BINDER_GET_FROZEN_INFO _IOWR('b', 15, struct binder_frozen_status_info)
#define BINDER_ENABLE_ONEWAY_SPAM_DETECTION _IOW('b', 16, __u32)
#define BINDER_GET_EXTENDED_ERROR _IOWR('b', 17, struct binder_extended_error)

Filtrage rapide :


static int interception_ioctl(int descripteur, int commande, void* argument) {
    if (commande != BINDER_WRITE_READ || !argument || desactivation_globale_ioctl) {
        return fonction_origine_ioctl(descripteur, commande, argument);
    }
}

Comment analyser les données ?

Le code source pertinent se trouve ici : http://aospxref.com/android-14.0.0_r2/xref/frameworks/native/libs/binder

L'analyse se concentre sur les types de données envoyés (BC_TRANSACTION et BC_REPLY) et reçus (BR_TRANSACTION et BR_REPLY).

Comment modifier les données ?

La modification des données peut prendre plusieurs formes :

  1. Correction des paramètres d'appel
  2. Correction des données de retour
  3. Interception directe de l'appel

Pour assurer la stabilité, il est crucial de ne pas interrompre le flux d'exécution de Binder. Une approche consiste à modifier le code de la fonction pour qu'elle utilise une méthode de traitement générique du parent, puis à corriger la valeur de retour.

Mise en Œuvre de la Solution

Analyse des Données

La structure des données d'appel Binder est la suivante :

Analyse de bwr

bwr correspond à binder_write_read. La structure de données pour l'argument de type BINDER_WRITE_READ dans ioctl est :


struct binder_write_read {
    // Données d'entrée
    binder_size_t write_size; // données d'appel
    binder_size_t write_consumed; // données d'appel
    binder_uintptr_t write_buffer; // données d'appel
    
    // Données de sortie
    binder_size_t read_size; // données reçues
    binder_size_t read_consumed; // données reçues
    binder_uintptr_t read_buffer; // données reçues
};

L'analyse des commandes se fait en boucle :


void binder_recherche_bc(struct binder_write_read& bwr) {
    binder_uintptr_t commandes = bwr.write_buffer;
    binder_uintptr_t fin = commandes + (binder_uintptr_t)bwr.write_size;

    binder_txn_st* transaction = NULL;
    while (0 < commandes && commandes < fin && !transaction) {
        // Étant donné la limitation de taille des données de communication Binder,
        // chaque appel ne contient qu'une commande de paramètre valide
        commandes = binder_analyse_commandes_bc(commandes, transaction);
    }
}

Analyse de txn

txn correpsond à binder_transaction_data :


struct binder_transaction_data {
    union {
        __u32 handle;
        binder_uintptr_t pointeur;
    } cible; // handle du service cible (utilisé par le serveur)
    
    binder_uintptr_t cookie; // pour l'accès à un Binder mis en cache
    __u32 code; // numéro de méthode
    
    __u32 flags; // indicateurs, comme oneway
    __s32 pid_emetteur;
    __u32 uid_emetteur;
    binder_size_t taille_donnees; // longueur des données
    binder_size_t taille_decalages; // taille des objets de données
    
    union {
        struct {
            binder_uintptr_t tampon; // adresse des valeurs de paramètres de méthode
            binder_uintptr_t decalages; // adresse des objets de paramètres
        } ptr;
        __u8 tampon[8];
    } donnees;
};

Extraction du nom du service

L'en-tête des données de communication Binder permet d'extraire le nom du service cible :


void extraire_nom_service(const binder_txn_st* transaction) {
    const int32_t* ptr = reinterpret_cast<const int32_t="">(transaction->donnees.ptr.tampon);
    ++ ptr; // ignorer le modèle strict
    if (29 <= versionSDK()) ++ ptr; // Android 10.0+, ignorer les indicateurs
    
    int32_t longueurNom = *ptr;
    const uint16_t* nom16 = (const uint16_t*)(ptr+1);
}
</const>
Extraction du nom de méthode

Le paramètre qui identifie la méthode du service est transaction->code. Les interfaces AIDL génèrent des constantes statiques pour chaque méthode après compilation.

Par exemple, pour une interface définie comme :


interface IDemo {
  void test();
  void test2();
}

La classe générée ressemblera à :


class IDemo$Stub {
   void test();
   void test2();
   
  static final int TRANSACTION_test = 1;
  static final int TRANSACTION_test2 = 2;
}

Nous pouvons donc utiliser la réflexion pour trouver les variables statiques correspondant au code de méthode.

Analyse des données

La classe Parcel facilite l'analyse de données simples. Pour des données complexes comme Intent contenant plusieurs couches d'objets Parcelable, l'approche native présente des problèmes de compatibilité. Une solution consiste à transférer l'analyse vers le niveau Java, puis de reformater les données pour le niveau natif.

Voici un exemple d'implémentation natif pour l'échange de données avec Java :


// Création
jobject obtenir(JNIEnv* env) {
    jclass classeJava = env->FindClass("android/os/Parcel");
    jmethodID methode = env->GetStaticMethodID(classeJava, "obtenir", "()Landroid/os/Parcel;");
    if (!methode) return NULL;

    jobject parcelObj = env->CallStaticObjectMethod(classeJava, methode);
    if (!parcelObj) return NULL;

    if (0 < uparcel->tailleDonnees()) {
        methode = env->GetMethodID(classeParcel, "setDataPosition", "(I)V");
        if (methode) {
            demarshallage(env, uparcel->donnees(), uparcel->tailleDonnees());
            env->CallVoidMethod(parcelObj, methode, uparcel->positionDonnees());
        }
    }

    return parcelObj;
}

// Libération
void recycler(JNIEnv* env) {
    jclass classeJava = env->FindClass("android/os/Parcel");
    jmethodID methode = env->GetMethodID(classeJava, "recycler", "()V");
    if (methode) {
        env->CallVoidMethod(parcelObj, methode);
    }
    if (parcelObj) {
        env->DeleteLocalRef(parcelObj);
    }
    parcelObj = NULL;
}

Exemple d'implémentation Java :


public static void nettoyerLienHttp(Parcel p/*ENTREE*/, Parcel q/*SORTIE*/) {
    try {
        Intent intention = Intent.CREATOR.createFromParcel(pp);
        // TODO traitement des données...
        
        // écriture des nouvelles données
        q.appendFrom(p, p.positionDonnees(), p.donneesDisponibles());
    } catch (Throwable e) {
        e.printStackTrace();
    }
}

Interception des Données

L'analyse et l'affichage des données Binder ne modifient pas le contenu original, ce qui simplifie le processus. En revanche, la modification des données nécessite des étapes supplémentaires.

  1. Remplacement des données : Pointer vers une nouvelle zone mémoire
  2. Correction des pointeurs d'objets : Recalculer les décalages si nécessaire
  3. Gestion de la mémoire : Assurer le bon cycle de vie des allocations

Exemple de remplacement des données dans une transaction :


int binder_remplacer_transaction_br(binder_txn_st *transaction, ParcelEx* reponse, binder_size_t _pos) {
    size_t taille = reponse->tailleDonneesIPC();
    uint8_t* nouvellesDonnees = (uint8_t*)malloc(taille + transaction->taille_decalages);
    memcpy(nouvellesDonnees, reponse->donnees(), taille);
    if (0 < transaction->taille_decalages) {
        binder_remplacer_objets(transaction, nouvellesDonnees, _pos, ((int)taille) - ((int)(transaction->taille_donnees)));
    }

    transaction->donnees.ptr.tampon = reinterpret_cast<uintptr_t>(nouvellesDonnees);
    transaction->taille_donnees = taille;
    return 0;
}
</uintptr_t>

Exemple de correction des pointeurs d'objets :


void remplacerObjets(binder_txn_st *transaction, uint8_t* donneesObjet, binder_size_t _pos, int _decalage) {
    binder_size_t* decalages = reinterpret_cast<binder_size_t>(transaction->donnees.ptr.decalages);
    unsigned compteur = transaction->taille_decalages / sizeof(binder_size_t);

    while (0 < compteur--) {
        if (0 != memcmp(donneesObjet + (int)(*decalages), (uint8_t*)transaction->donnees.ptr.tampon + (int)(*decalages), sizeof(binder_size_t))) {
            *decalages += _decalage;
        }
        ++ decalages;
    }
}
</binder_size_t>

Exemple de gestion de la mémoire avec un pool personnalisé :


case BC_FREE_BUFFER:
{
    uintptr_t* pointeurTampon = (uintptr_t *)commande;
    uintptr_t pointeurOriginal = PoolMemoire::detacher(*pointeurTampon);
    if (__UNLIKELY(0 != pointeurOriginal)) {
        *pointeurTampon = pointeurOriginal; // restaurer le tampon original
    }
    commande += sizeof(uintptr_t); // passer à la commande suivante
}   break;

Étiquettes: Android Binder Interception IPC Hooking

Publié le 7 juin à 00h06