Interception des rappels de service Binder dans Android

Dans des scénarios tels que la virtualisation, l'exécution sans installation, la collecte de métriques ou la détection d'environnement, l'interception des interfaces des services système Android est une technique courante. Cela fonctionne généralement bien pour les appels directs aux services. Cependant, intercepter les rappels passifs émis par le service vers l'application s'avère plus complexe.

Contexte technique

Dans le cadre d'un produit de conteneurisation d'applications, il est nécesssaire d'exécuter les applications de manière transparente. Cela requiert d'intercepter, filtrer et potentiellement modifier les communications entre l'application et plus d'une centaine de services système. L'objectif est que ni le système ni l'application ne perçoivent cette médiation, permettant une exécution sans installation préalable.

L'effort principal s'est traditionnellement porté sur l'interception des appels initiés par l'application. L'interception des rappels des services a été moins explorée, mais elle devient cruciale pour des fonctionnalités avancées. Cette appproche générique pour intercepter les mécanismes de rappel « Oneway » de Binder est présentée ici.

Fonctionnement

Le mécanisme de rappel Oneway Binder permet à un processus application d'enregistrer une instance de rappel (souvent via un couple inscription/désinscription) auprès d'un service système. Lorsqu'un événement pertinent survient, le service peut invoquer directement cette instance Binder.

Prenons l'exemple de l'interface du service ActivityManager (AMS) :

// Source: /frameworks/base/core/java/android/app/IActivityManager.aidl
interface IActivityManager {
    // ...
    Intent registerReceiver(IApplicationThread caller,
                            String callerPackage,
                            IIntentReceiver receiver, // Instance Binder à intercepter
                            IntentFilter filter,
                            String requiredPermission,
                            int userId,
                            int flags);
    void unregisterReceiver(in IIntentReceiver receiver);
    // ...
}

Les objectifs de l'interception sont :

  1. Lors de l'appel à registerReceiver, remplacer le paramètre receiver par un proxy qui étend l'interface originale.
  2. Le proxy doit conserver le nom de classe original (ex: android.content.IIntentReceiver) pour satisfaire les contrôles de type.
  3. Côté service, le proxy est reconstitué en tant qu'instance de l'interface correspondante.
  4. Lors d'un rappel du service, le proxy intercepte l'appel, applique la logique métier souhaitée, puis transmet l'invocation à l'objet original.
  5. Lors de la désinscription, le proxy doit également être signalé au service pour libération.

Approche 1 : Importation des sources AIDL

Une première idée consiste à importer les fichiers AIDL des interfaces système (comme IActivityManager.aidl, IPackageManager.aidl) dans le projet. Après configuration du build pour compiler ces fichiers AIDL, il est possible d'hériter des classes générées.

// Configuration Gradle (exemple)
android {
    sourceSets {
        main {
            aidl.srcDirs = ['src/main/aidl']
        }
    }
}

// Implémentation étendant le Stub généré
public class ProxyIntentReceiver extends IIntentReceiver.Stub {
    private final Object mOriginalReceiver;

    public ProxyIntentReceiver(Object original) {
        this.mOriginalReceiver = original;
    }

    private static Method sPerformReceiveMethod;

    @Override
    public void performReceive(Intent intent, int resultCode, String data,
                               Bundle extras, boolean ordered, boolean sticky,
                               int sendingUser) throws RemoteException {
        // Logique d'interception ici...
        if (sPerformReceiveMethod == null) {
            sPerformReceiveMethod = ReflectUtils.getDeclaredMethod(
                mOriginalReceiver, "performReceive",
                Intent.class, int.class, String.class, Bundle.class,
                boolean.class, boolean.class, int.class);
        }
        sPerformReceiveMethod.invoke(mOriginalReceiver, intent, resultCode,
                                     data, extras, ordered, sticky, sendingUser);
    }
}

Cette méthode fonctionne pour des interfaces simples et stables comme IIntentReceiver. Cependant, de nombreuses interfaces système comportent plusieurs méthodes, changent entre les versions d'Android, et dépendent de classes masquées (Parcelable, etc.), rendant l'approche peu scalable et sujette aux erreurs de compatibilité.

Approche 2 : Multi-flavors pour la compatibilité

Pour gérer les différences entre versions, on pourrait créer des variantes (flavors) du projet ciblant différentes API d'Android. Chaque flavor importerait et compilerait les AIDL spécifiques à sa version.

Cette approche résout les problèmes de signature de méthode mais engendre une surcharge de maintenance considérable : multiples bases de code, complexité de compilation et de chargmeent, et risque de divergence. De plus, certaines implémentations spécifiques des fabricants resteraient incompatibles.

Approche 3 : Surcharge de onTransact via template d'interface

L'objectif devient d'avoir une seule implémentation compatible avec toutes les versions, ne traitant que les méthodes d'intérêt. L'analyse du code compilé d'une classe Stub Binder révèle que le routage des appels passe par la méthode onTransact(int code, Parcel data, Parcel reply, int flags).

// Extrait simplifié de IIntentReceiver.Stub généré
public boolean onTransact(int code, Parcel data, Parcel reply, int flags)
        throws RemoteException {
    switch (code) {
        case TRANSACTION_performReceive: {
            data.enforceInterface(DESCRIPTOR);
            // Lecture des paramètres depuis le Parcel 'data'
            Intent arg0 = (data.readInt() != 0) ? Intent.CREATOR.createFromParcel(data) : null;
            int arg1 = data.readInt();
            String arg2 = data.readString();
            Bundle arg3 = (data.readInt() != 0) ? Bundle.CREATOR.createFromParcel(data) : null;
            boolean arg4 = (data.readInt() != 0);
            boolean arg5 = (data.readInt() != 0);
            int arg6 = data.readInt();
            // Appel de la méthode métier réelle
            this.performReceive(arg0, arg1, arg2, arg3, arg4, arg5, arg6);
            reply.writeNoException();
            return true;
        }
        // ...
    }
    return super.onTransact(code, data, reply, flags);
}

La stratégie est la suivante :

  1. Définir une interface AIDL minimale portant le même nom complet que l'interface cible. Elle peut être vide de méthodes, servant uniquement à générer une classe de base lors de la compilation.
  2. Créer une classe proxy qui étend le Stub généré à partir de cette interface.
  3. Surcharger onTransact pour intercepter uniquement les codes de transaction (code) des méthodes qui nous intéressent. Pour ces codes, les paramètres sont lus manuellement depuis le Parcel. Pour les autres codes, l'appel est délégué à l'objet original via une invocation réflexive de sa propre méthode onTransact.

Implémentation concrète pour le proxy de IIntentReceiver :

import android.content.IIntentReceiver;
import android.content.Intent;
import android.os.Bundle;
import android.os.Parcel;
import java.lang.reflect.Method;

public class BroadcastReceiverProxy extends IIntentReceiver.Stub {
    private final Object mTarget; // L'objet original
    private static int CALLBACK_CODE_RECEIVE = -1; // Code TRANSACTION_performReceive

    public BroadcastReceiverProxy(Object target) {
        this.mTarget = target;
        // Récupération dynamique du code de transaction
        if (CALLBACK_CODE_RECEIVE < 0) {
            CALLBACK_CODE_RECEIVE = ReflectUtils.getTransactionCode(mTarget, "TRANSACTION_performReceive");
        }
    }

    @Override
    public boolean onTransact(int transactionCode, Parcel request, Parcel reply, int flags)
            throws RemoteException {
        if (transactionCode == CALLBACK_CODE_RECEIVE) {
            request.enforceInterface(getInterfaceDescriptor());
            Intent intent = (request.readInt() != 0) ? Intent.CREATOR.createFromParcel(request) : null;
            int resultCode = request.readInt();
            String data = request.readString();
            Bundle extras = (request.readInt() != 0) ? Bundle.CREATOR.createFromParcel(request) : null;
            boolean ordered = request.readInt() != 0;
            boolean sticky = request.readInt() != 0;
            int sendingUser = request.readInt();

            // Logique d'interception/modification avant le rappel original
            // ...

            // Invocation de la méthode 'performReceive' sur l'objet original via réflexion
            Method performReceive = ReflectUtils.getDeclaredMethod(
                mTarget, "performReceive",
                Intent.class, int.class, String.class, Bundle.class,
                boolean.class, boolean.class, int.class);
            performReceive.invoke(mTarget, intent, resultCode, data, extras, ordered, sticky, sendingUser);

            reply.writeNoException();
            return true;
        }
        // Délégation des autres transactions à l'objet original
        return delegateToOriginal(transactionCode, request, reply, flags);
    }

    private boolean delegateToOriginal(int code, Parcel data, Parcel reply, int flags) {
        Method originalOnTransact = ReflectUtils.getDeclaredMethod(
            mTarget, "onTransact",
            int.class, Parcel.class, Parcel.class, int.class);
        try {
            return (Boolean) originalOnTransact.invoke(mTarget, code, data, reply, flags);
        } catch (Exception e) {
            Logger.error("Delegation failed", e);
        }
        return false;
    }
}

Cette approche offre une solution portable et maintenable pour intercepter les rappels Binder sans dépendre des AIDL compilés pour chaque version d'Android. Pour les services implémentés en natif (C/C++), une stratégie d'interception au niveau des appels système est requise.

Étiquettes: Android Binder AIDL IPC réflexion

Publié le 19 juin à 03h54