Contourner le shielding des applications mobiles : là où la détection s'arrête et où l'application des mesures échoue
La détection et l'application des mesures (enforcement) sont deux propriétés de sécurité distinctes. Sur cinq applications bancaires en production protégées par quatre produits de shielding commerciaux, la détection était sophistiquée et l'enforcement fragile.
Le shielding des applications mobiles est une protection compilée dans l'application elle-même. Il est vendu sous les noms de RASP, d'anti-tampering, d'anti-hooking, de détection du root, de détection du jailbreak ou de protection à l'exécution, et la promesse est toujours la même : si l'application se retrouve sur un téléphone rooté, dans un émulateur, sous Frida, attachée à un débogueur ou repackagée, elle doit s'en apercevoir et réagir.
Cette promesse comporte deux moitiés, et elles échouent indépendamment l'une de l'autre.
La détection consiste à décider que quelque chose ne va pas. L'enforcement consiste à agir en conséquence.
Un produit peut détecter le root à la perfection et ne rien protéger, si l'application ignore la réponse, fait passer toutes les réponses par une seule branche faible, ou fait confiance à une entrée que l'attaquant contrôle.
Nous avons évalué cinq applications bancaires en production, sur Android et iOS, protégées par quatre produits de shielding commerciaux différents. La détection était systématiquement bonne. C'est systématiquement l'enforcement qui cédait.
Premier symptôme : un crash conçu pour ne rien vous apprendre
Le scan a installé la première application sur un appareil de test et l'a lancée. Cinq secondes plus tard, le processus avait disparu. Pas de boîte de dialogue, pas d'erreur, rien dans les logs.
Le rapport de crash :
signal 11 (SIGSEGV), fault addr 0x0
x0=0xdef040f0 x1=0x738416ae x4=0x2319d258 x5=0xbd7d47fb
x6..x30=0 sp=0 pc=0
tid: Thread-7x
Le signal 11 est SIGSEGV, une erreur de segmentation : le processus a accédé à la mémoire d'une manière que le noyau a refusée. Jusque-là, rien d'inhabituel. Le dump des registres, en revanche, l'est.
Sur ARM64, pc est le compteur de programme, l'adresse de l'instruction en cours d'exécution. sp est le pointeur de pile, qui ancre la pile d'appels. x0 à x30 sont les registres généraux, qui contiennent les arguments, les valeurs de retour et les variables locales. Dans un vrai crash, ces valeurs sont conservées, et c'est précisément ce qui rend un rapport de crash utile : le compteur de programme désigne l'instruction fautive, et le pointeur de pile permet à un débogueur de remonter la chaîne des appelants.
Ici, ils sont tous à zéro. Il n'y a aucune instruction fautive à examiner et aucune pile à dérouler. L'unique frame du backtrace indique #00 pc 0x0 <unknown>.
Quelque chose a délibérément effacé le fichier de registres avant de laisser mourir le processus. Le thread désigné dans le rapport, Thread-7x, est un worker générique sans rapport avec la décision. Et 0xdef040f0 n'est pas un pointeur plausible sur cette plateforme ; il se comporte comme un marqueur laissé en place par le code de protection.
C'est un schéma courant dans les applications durcies. Au lieu d'annoncer son verdict, l'application détruit les preuves forensiques en sortant, de sorte qu'un analyste ne sait ni quelle vérification s'est déclenchée ni où elle se trouve.

Tous les registres généraux sont à zéro, et le backtrace se résume à une seule frame inconnue.
Ce que ces produits surveillent réellement
La première tâche est de dresser un inventaire : que vérifie ce produit ?
L'analyse statique a trouvé environ trente fonctions de détection natives, toutes enregistrées dans une seule classe Java au chargement.
Ce détail compte plus qu'il n'y paraît. Les applications Android accèdent au code natif C ou C++ via JNI, la Java Native Interface. Une bibliothèque native peut exposer une fonction à Java de deux manières. Elle peut exporter un symbole au nom décoré comme Java_com_example_Foo_bar, visible par quiconque exécute nm sur la bibliothèque. Ou elle peut appeler RegisterNatives pendant JNI_OnLoad et lier les méthodes dynamiquement à l'exécution.
Cette bibliothèque emprunte la seconde voie : aucun nom de détecteur n'apparaît donc parmi les symboles exportés. Il faut lire la table d'enregistrement ou observer le processus à l'exécution pour les trouver :
JNI_OnLoad @ 0x424264
checkHooks @ 0x424e50 scans the process memory map
checkForFridaAgent @ 0x424c38
isSuExists @ 0x424940
isFoundMagisk @ 0x425a90
isFoundDangerousProps @ 0x424810
isPermissiveSelinux @ 0x4248e8
getNativeSignature @ 0x42ca00 hashes the signing certificate
Quatre catégories sont représentées, et chacune répond à une question différente.
La détection du root cherche des indices que l'appareil accorde des privilèges élevés : binaires su sur le disque, artefacts de Magisk, chemins système accessibles en écriture, SELinux en mode permissif, paquets de gestionnaires de root. Le root est important parce qu'il permet à un attaquant de lire le stockage privé de l'application, de s'attacher au processus et de modifier son comportement à l'exécution.
La détection du hooking cherche les frameworks qui réécrivent le comportement des fonctions à l'exécution : Frida, Xposed, LSPosed, Zygisk, Substrate, SandHook, Taichi, VirtualXposed. Un hook peut changer la valeur de retour d'une fonction sans toucher à l'APK sur le disque, et c'est précisément ainsi que les contrôles de sécurité côté client sont mis en échec.
checkHooks lit /proc/self/maps. Sous Linux et Android, ce fichier liste toutes les régions mémoire mappées dans le processus courant, avec leurs permissions et le fichier qui les sous-tend. Si une bibliothèque étrangère ou une région exécutable anonyme y apparaît, l'application peut en déduire qu'elle est instrumentée.
La vérification de la signature est assurée par getNativeSignature, qui calcule le hachage du certificat de signature de l'application à l'exécution. Android exige que chaque APK soit signé. Un attaquant qui modifie et repackage une application doit la re-signer, généralement avec une autre clé ; comparer le certificat à l'exécution à un hachage attendu permet donc de détecter un repackaging naïf.
La détection des propriétés dangereuses lit les propriétés système d'Android, des paires clé-valeur que l'OS utilise pour publier la configuration du build et de l'appareil : ro.build.tags, ro.debuggable, ro.secure, ro.hardware, ro.product.model. Les images d'émulateur et les builds d'ingénierie y laissent des valeurs reconnaissables.
Sous ce produit payant se trouvent encore trois autres contrôles provenant d'éditeurs sans lien entre eux : une deuxième vérification du root issue d'une bibliothèque open source gratuite, une troisième dans le code Dart compilé de l'application elle-même, et la télémétrie d'un quatrième. Le manifeste déclare même des entrées <queries> afin que le gestionnaire de paquets réponde aux questions sur les paquets su, Magisk et KingRoot.
Quatre contrôles indépendants dans une même application, quatre avis indépendants sur le même appareil. Cela compte à la fin.
Ce n'est pas une implémentation superficielle. La faiblesse n'est pas l'absence de détection. C'est ce à quoi l'application fait confiance, et ce qu'elle fait de la réponse.
Pourquoi lire la vérification ne fonctionne pas
Trois couches se dressent entre un analyste et cette logique, et toutes trois remplissent leur rôle.
Le niveau Java est un leurre. L'application embarque 4 129 blobs chiffrés à la place des corps de méthodes, déchiffrés et exécutés uniquement au lancement. Les classes qui comptent, y compris l'activité principale, ne figurent pas sous forme lisible dans le fichier. Un décompilateur montre la structure et quelques sites d'appel, pas le comportement.
La bibliothèque native est packée. Chiffrée au repos, avec un seul symbole exporté, son contenu réel n'existe en mémoire qu'après le démarrage. Une bibliothèque porte un nom de fichier aléatoire et un nom d'export aléatoire :
libGHDSDFIUPOIFDLS8DSFN23LK.so
export: _3Wbwdz5QepMbJNn8CiW3HwFivKZsZoNvu
Le scan a donc extrait la bibliothèque déchiffrée du processus en cours d'exécution et a regardé à nouveau. Les vérifications n'y étaient toujours pas.
Les vérifications sont générées à l'exécution. Le code déchiffré émet 69 appels système. Sur ARM64, un appel système est une instruction svc, avec le numéro d'appel dans le registre x8 ; un désassembleur peut donc normalement lire quelle fonction du noyau est demandée. Dans 64 de ces sites, le numéro est une constante. Dans les cinq autres, l'application le calcule pendant l'exécution, ce qui rend ces cinq sites invisibles à l'analyse statique.
L'un des cinq se résout en mmap demandant une seule page avec PROT_READ | PROT_WRITE | PROT_EXEC. Il le fait environ toutes les 85 millisecondes. Il écrit du code dans la page, l'exécute, puis la rejette.
Une mémoire à la fois inscriptible et exécutable est inhabituelle par conception. Les systèmes modernes séparent ces deux permissions, car une mémoire dans laquelle on peut écrire puis exécuter est ce qui rend l'injection de code possible. Les packers et les protecteurs l'utilisent malgré tout, précisément pour cela : produire des vérifications qui n'existent jamais comme code stable à une adresse stable.
Les chaînes sont construites, pas stockées. Rechercher qemu, goldfish ou emulator dans la bibliothèque entièrement déchiffrée ne renvoie aucun résultat. L'application assemble ces chaînes caractère par caractère sur la pile, si bien qu'elles n'existent jamais sous forme de texte contigu que l'on pourrait chercher avec grep.
Les noms de méthodes sont des caractères Unicode d'apparence identique. Une seconde application, protégée par un autre produit, joue au même jeu en Java. Le décompilateur affiche m13674 ; le vrai nom à l'exécution est ˎ, une lettre modificatrice Unicode. Il affiche m13676 ; le vrai nom est Ι, un iota majuscule grec qui s'affiche de façon identique à un i majuscule latin.
Un hook écrit à partir du nom donné par le décompilateur ne se déclenche tout simplement jamais. Pas d'erreur, pas d'échec, et il est facile de prendre ce silence pour une protection plus forte qu'elle ne l'est. Résoudre les méthodes par leur signature de type plutôt que par leur nom évite le problème, et c'est pourquoi les hooks présentés plus loin dans cet article fonctionnent du premier coup.
Mesurer l'application de l'extérieur
Quand le code est conçu pour être illisible, mieux vaut arrêter de le lire et l'observer.
Le scan a attaché une trace au niveau du noyau, qui observe de l'extérieur du processus et ne laisse rien à l'intérieur qui puisse être détecté, puis a enregistré chaque fichier ouvert par l'application pendant le démarrage :
opens ~150 property files, three times each
reads /proc/self/maps three times
reads /proc/self/status, /proc/self/comm, /sys/fs/selinux/context
opens the virtual device files: 0 times
opens any su or root path: 0 times
Pour ceux qui n'ont pas passé de temps dans /proc : c'est un système de fichiers virtuel que le noyau génère à la demande, et /proc/self est la vue qu'a le processus appelant de lui-même. maps liste les régions mémoire mappées. status contient les métadonnées du processus, notamment TracerPid, l'identifiant du processus qui trace actuellement celui-ci. comm est le nom du processus. /sys/fs/selinux/context indique le contexte de sécurité SELinux sous lequel le processus s'exécute.
Le résultat a tout remis en perspective. Presque tout le monde suppose que cette catégorie de vérification cherche su sur le disque et les nœuds de périphériques d'émulateur. Celle-ci n'ouvre ni l'un ni l'autre, pas une seule fois. La décision au démarrage repose sur les valeurs des propriétés, sur la carte mémoire du processus lui-même et sur son état de traçage. Deux de ces trois éléments sont modifiables par un utilisateur privilégié.
L'analyse statique a produit une liste de vérifications susceptibles d'avoir de l'importance. La trace a montré lesquelles s'exécutent réellement.
Le contrôle des propriétés tombe avec une écriture de fichier
Android publie les propriétés système via une région de mémoire partagée exposée sous forme de fichiers dans /dev/__properties__/. Les bibliothèques les lisent via __system_property_get ; les valeurs sont conservées en mémoire, et sur un appareil rooté elles peuvent être modifiées sur place avant le lancement de l'application. Le contrôle n'effectue aucune vérification d'intégrité ni d'authenticité sur ce qu'il y lit.
Avant l'écriture :
signal 11 (SIGSEGV), fault addr 0x0
x0=0xdef040f0, pc=0, sp=0
crash report: GENERATED
Après :
no crash report
process alive
UI drawn
native libraries loaded

Le kill cesse de se produire. Aucun patch de l'APK, aucun hook, aucune modification de l'application.
Cela ne rend pas les vérifications de propriétés inutiles. Cela les rend non faisant autorité. Si un attaquant disposant du root peut réécrire la valeur, l'application a besoin de recouper des signaux indépendants, ou d'un enforcement qui ne s'effondre pas lorsqu'un seul signal bascule.
Pourquoi le fichier d'index est important
C'est plus délicat qu'il n'y paraît, et le mode de défaillance mérite d'être connu.
Ce répertoire contient deux structures différentes : les enregistrements prop_info qui stockent chaque propriété, et un index sérialisé distinct servant à résoudre le nom d'une propriété vers son enregistrement. Toute recherche par nom passe par l'index. Un rechercher-remplacer aveugle sur les fichiers le corrompt.
Les dégâts se dissimulent alors eux-mêmes. L'énumération des propriétés parcourt directement les enregistrements et continue de fonctionner, en renvoyant environ 460 entrées, alors que demander une propriété isolée par son nom ne renvoie rien. Toutes les bibliothèques qui lisent une propriété au démarrage cessent de fonctionner, de façons qui semblent sans rapport avec ce que vous venez de faire.
Le scan identifie donc les vrais enregistrements d'après leur forme avant d'écrire, puis vérifie son propre travail au lieu de lui faire confiance :
properties : 465 (was 465)
sdk : '31'
giveaways : 0
Le nombre de propriétés est inchangé, une recherche connue se résout toujours, les indices révélateurs ont disparu. Ce n'est qu'ensuite qu'il lance l'application. Un contournement qui fonctionne en saccageant l'environnement n'est pas un contournement.
La détection de Frida est en réalité une détection de timing
Frida est la boîte à outils d'instrumentation dynamique de référence pour ce travail. Elle injecte un agent dans un processus en cours d'exécution et permet de hooker des méthodes Java, des fonctions natives et des appels système.
Face à ces applications, elle meurt immédiatement. Un débogueur, sans doute plus intrusif, tourne aussi longtemps qu'on le souhaite.
debugger attaches 1.9s after launch -> ran 120 seconds, no complaint
Frida attaches 1.0s after launch -> killed
Frida launches together with the app -> killed
L'application lit /proc/self/status une seule fois, au début. L'une des lignes de ce fichier est TracerPid, qui vaut 0 quand rien ne trace le processus et contient sinon l'identifiant du processus traceur. Les débogueurs utilisent ptrace, ils le renseignent donc. L'injection de Frida utilise aussi ptrace brièvement, pour détourner un thread et lui faire charger l'agent.
La vérification ne s'exécute qu'une seule fois, environ une seconde après le démarrage. Nous l'avons confirmé de la manière la plus simple : attacher un débogueur, reprendre l'exécution, puis ne rien faire du tout pendant 100 secondes. Aucun verdict, l'application reste active. Être observé n'est pas le déclencheur.
Le problème de Frida est son entrée. L'injection laisse deux traces dans cette fenêtre de démarrage : TracerPid renseigné pendant quelques centaines de millisecondes, et un mapping exécutable étranger dans /proc/self/maps là où l'agent a été chargé depuis un fichier anonyme. Le verdict tombe avant que le script de l'agent n'affiche sa première ligne :
{"c":7,"v":1,"p":{"id":107,"name":"Hooking Detected"}}
Certains essaient de renommer les threads et les ports de Frida pour contourner cela. Cela ne change rien, car ce n'est jamais le nom qui a été détecté.
La question que se pose l'application n'est donc pas « est-ce Frida ? ». Elle est plus proche de « quelque chose m'a-t-il tracée ou modifiée pendant cette fenêtre ? ». C'est un autre contrôle, aux conséquences différentes : dès qu'une vérification ne s'exécute qu'une fois à un moment connu, le timing devient une surface d'attaque. On peut arriver avant elle, arriver après elle, ou lancer le processus suspendu et être en place avant que l'application n'exécute sa première instruction. Le scan choisit la dernière option, ce qui lui permet d'atteindre des vérifications qu'un attachement normal ne pourrait jamais voir.
Une seconde application arrive à la même conclusion par un autre chemin. Tracée dès sa toute première instruction, toute la décision tient dans les quatre dernières choses qu'elle fait :
openat(".../split_config.arm64_v8a.apk")
readlinkat("/proc/self/fd/60", ".../split_config.arm64_v8a.apk")
openat("/proc/self/status")
--- SIGBUS {si_code=BUS_ADRALN, si_addr=0x8f19c0bf} ---
+++ killed by SIGBUS +++
SIGBUS avec BUS_ADRALN est une faute d'alignement : une lecture ou une écriture à une adresse que le CPU n'accepte pas pour cette largeur d'accès. Ce n'est pas un crash accidentel courant sur ARM64, et l'adresse fautive est identique à chaque exécution. L'application lit son état de traçage, puis exécute délibérément un accès mal aligné pour se tuer.
Un seul entier décide à huit endroits
La deuxième application Android illustre le point central mieux que n'importe quelle autre de l'ensemble.
Sa protection est sérieuse. Chaînes de détection chiffrées dans la bibliothèque native, noms de classes chiffrés dans le niveau Java, noms de méthodes en caractères Unicode d'apparence identique, et appels système émis sous forme d'instructions svc en ligne plutôt que via libc, de sorte que hooker le wrapper standard syscall() n'attrape strictement rien.
Tout cela converge vers une seule méthode Java. On lui passe un entier aléatoire. Sur un appareil sain, elle renvoie ce même entier.
Chaque site d'enforcement de l'application vérifie cette unique valeur :
j/ma.java:480 if (rd.m13674(ctx, nextInt) != nextInt)
j/ma.java:551 if (rd.m13674(ctx, nextInt2) != nextInt2)
bk/a.java:844 m9379 = rd.m13674(ctx, nextInt) == nextInt ? -91 : 808;
bk/a.java:911 if (rd.m13674(ctx, nextInt2) == nextInt2)
ca/ma.java:2298 if (rd.m13674(ctx, r0) == r0)
ca/b.java:1331 if (rd.m13674(ctx, r0) == r0)
ca/a.java:816 if (rd.m13674(ctx, r0) == r0)
y/b.java:192,228 rd.m13674(ctx, SecureRandom.nextInt())

L'entier aléatoire n'est pas décoratif. C'est un nonce, et il sert à empêcher l'attaque la plus paresseuse. Si la méthode renvoyait simplement true ou 0 sur un appareil sain, un attaquant la hookerait pour qu'elle renvoie cette constante en permanence. Passer une nouvelle valeur aléatoire à chaque appel et exiger qu'elle soit renvoyée fait échouer un retour constant, car la réponse attendue change à chaque fois.
Cela n'aide pas ici. Un attaquant capable de hooker la méthode peut renvoyer l'argument qu'elle a reçu, et la comparaison réussit à chaque site.
Trois hooks, résolus par signature de type plutôt que par nom, car les noms sont en Unicode :
{"type":"hooked","method":"m13674","sig":"int(android.content.Context,int)"}
{"type":"hooked","method":"m13676","sig":"boolean(java.lang.Exception)"}
{"type":"hooked","method":"m13677","sig":"int(android.content.Context,int,int)"}
{"type":"alive","pid":6285,"ping":1} ... {"type":"alive","pid":6285,"ping":18}

Deux minutes d'exécution, un processus stable, aucun crash, et le chemin anti-tampering ne se déclenche jamais.
La défaillance est architecturale plutôt que cryptographique. Le niveau natif a calculé un verdict correct. L'application a ensuite fait confiance à ce verdict sous la forme d'un unique entier modifiable, placé dans la partie la moins protégée du programme.
Comment prouver réellement un contournement du pinning
Le certificate pinning signifie que l'application ne s'appuie pas uniquement sur le magasin de confiance du système d'exploitation. Elle vérifie aussi que le certificat du serveur, ou la clé publique qu'il contient, correspond à une valeur embarquée dans l'application. Bien fait, il résiste à un attaquant qui installe sa propre autorité de certification racine sur l'appareil.
Mais le pinning est du code applicatif : hooker ou patcher la comparaison le met donc en échec.
La plupart des rapports le prouvent mal : le hook s'est chargé, le proxy a montré du trafic, l'application n'a pas crashé. Ces éléments sont compatibles avec un contournement, mais aussi avec plusieurs autres explications.
Le scan a plutôt mené une expérience contrôlée sur le vérificateur de certificats de l'application elle-même, construit via le propre constructeur de l'application et chargé avec sa propre liste de pins. Même certificat dans les deux cas, même objet, mêmes pins. La seule variable était l'activation ou non des hooks.
| Même certificat, même vérificateur, mêmes pins | Sans le contournement | Avec le contournement |
|---|---|---|
| Comparaison du pin, premier pin | false |
true |
| Comparaison du pin, second pin | false |
true |
| Validation du certificat | rejeté, exception levée | accepté, aucune erreur |
Une exécution rejette, l'autre accepte, tout le reste étant maintenu constant. C'est le niveau d'exigence à viser : non pas « l'outil a dit que ça a fonctionné », mais « la décision protégée a changé dans des conditions contrôlées ». Cela n'a en outre nécessité ni proxy ni trafic capturé, car le test pilote directement le code de pinning au lieu d'attendre que l'application établisse une connexion.
Sur iOS, personne ne vérifie le reçu
L'application iOS embarque un contrôle de jailbreak, deux implémentations de pinning indépendantes dans deux langages différents, un framework de protection de 35 Mo et plusieurs SDK tiers.
Elle ne vérifie jamais si son propre code a été modifié.
L'auto-vérification sur iOS consiste à interroger le système sur la signature de son propre code, via des API comme SecStaticCodeCheckValidity ou l'appel système csops, ou à hacher ses propres pages __TEXT et à comparer le résultat à une valeur attendue. Le scan a recherché toutes les formes standard de cette vérification dans les sept binaires liés à la protection :
main binary 36.8 MB self-check: NONE
Flutter framework 19.6 MB self-check: NONE
protection framework 35.0 MB self-check: NONE
attribution SDK 0.6 MB self-check: NONE
jailbreak detection 0.07 MB self-check: NONE
fingerprinting SDK 0.9 MB self-check: NONE
monitoring agent 5.9 MB self-check: NONE
Un symbole manquant prouve peu de choses dans un binaire obfusqué, puisque les symboles peuvent être supprimés et les appels faits par numéro d'appel système brut. Le scan ne s'est donc pas arrêté là. Il a désassemblé le framework de protection et passé en revue les 1 761 sites où ce framework émet directement un appel système. Aucun n'interroge la signature du code.
Ce framework sait repérer un débogueur actif et une injection active. Il ne sait pas remarquer que ses propres octets ont changé.
Les octets ont donc été modifiés. Trois patches, de quelques instructions chacun :
Jailbreak check
sub sp, sp, #0x40 -> mov w0, #0 ; ret
Flutter TLS pinning
ldrb w0, [sp, #8] -> mov w0, #1
bic w8, w0, w0, asr#31 -> mov w8, #1
Payment SDK pinning, four places
mov w1, #2 -> mov w1, #1
Dans la convention d'appel ARM64, w0 correspond aux 32 bits de poids faible de x0, le registre dans lequel une fonction renvoie sa valeur. mov w0, #0 suivi de ret est une fonction complète qui renvoie zéro, soit NO en Objective-C. Les huit octets qui remplacent le prologue de la vérification du jailbreak transforment donc toute la fonction en « pas de jailbreak », et rien de ce qui suit ne s'exécute jamais.
Les patches de pinning fonctionnent de la même manière, un niveau plus bas, en forçant le callback qui rapporte une décision de confiance à rapporter un succès.
Le troisième patch est le plus intéressant. Au lieu de forcer l'application à accepter un mauvais certificat, il a modifié les quatre sites où l'application en rejette un. Ces sites passaient 2 au gestionnaire de challenge, ce qui signifie annuler la connexion. Ils passent désormais 1, ce qui signifie revenir à l'évaluation normale du système. Le seul site qui accepte réellement un bon certificat a été laissé intact.
L'application cesse de refuser sans qu'on lui ait jamais dit d'accepter, ce qui garde son comportement plausible plutôt qu'évidemment cassé.
Les trois patches ont survécu au repackaging et à la re-signature sans aucune réaction de la part du moindre élément du bundle. C'est exactement ce que l'on prédirait d'une application qui ne vérifie jamais son propre reçu.
Le shielding n'est pas de l'autorisation
Une application de l'ensemble n'avait aucune faille de shielding digne d'être signalée, et pourtant une vulnérabilité critique.
Une Activity est un point d'entrée dans une application Android, à peu près un écran. Par défaut, elle est privée à l'application. La marquer exported="true" permet à d'autres applications de la lancer. Ajouter la catégorie BROWSABLE à son filtre d'intents permet à un navigateur web de la lancer, en suivant un lien avec le schéma personnalisé de l'application.
Cette activité était exportée, accessible depuis un navigateur, et n'effectuait aucune vérification d'authentification, de session ou d'appelant avant d'écrire dans le dépôt des transactions.
Toutes les données de l'application ayant été effacées, de sorte que personne n'était connecté, une seule ligne de JavaScript a suffi :
<script>
window.location.href = "app://quickpay?payee=BrowserAttacker&amount=200.00";
</script>
Le navigateur a lancé l'écran. La liste des transactions contenait ensuite six entrées, contre cinq dans la référence, avec le bénéficiaire et le montant de l'attaquant.
Aucun produit de shielding ne l'aurait détecté, car rien dans l'appareil n'était suspect. Le shielding augmente le coût d'une attaque sur un téléphone compromis. Il ne fait rien contre l'absence d'autorisation sur un téléphone sain, et il ne remplace pas les contrôles côté serveur sur la transaction elle-même.
Le schéma, dans chaque application
La technologie de détection n'était nulle part triviale. Code natif packé, code généré dans une mémoire fraîche plusieurs fois par seconde, appels système effectués en dessous de libc, chaînes qui n'existent jamais sous forme de texte, enregistrement JNI dynamique, analyse de la carte mémoire, hachage de signature, noms de méthodes en lettres grecques se faisant passer pour des lettres latines, et crashs conçus pour ne rien vous apprendre.
Les défaillances se situaient toutes une couche plus loin :
- confiance accordée à des valeurs de propriétés qu'un attaquant privilégié peut réécrire
- une seule fenêtre de démarrage qui décide de tout
- huit sites d'enforcement lisant un seul entier modifiable
- du code iOS patché sans aucune auto-vérification pour s'en apercevoir
- une application qui a correctement détecté le root et n'a rien appliqué
Et la découverte la plus frappante n'était pas du tout un contournement. L'une de ces applications a été installée sur un téléphone rooté ordinaire, sans patch, sans hook et sans modification des propriétés. Elle est allée directement à son écran de connexion et y est restée. Le shielding a remarqué le root et l'a correctement signalé. L'application a poursuivi son exécution, parce que personne n'écoutait.
Un détecteur répond à : que voyons-nous ? Un contrôle répond à : que faisons-nous, et l'attaquant peut-il changer cette décision ? De nombreux déploiements sont solides sur le premier point et faibles sur le second.
Trois tests à exécuter sur votre propre application
Vérifiez la réponse, pas la détection. Ne demandez pas si l'application peut détecter le root. Installez-la sur un téléphone rooté et observez. Se termine-t-elle, restreint-elle les flux sensibles, bloque-t-elle l'authentification, ou se contente-t-elle de consigner et de continuer ? Si elle continue, vous avez acheté de la télémétrie, pas un contrôle.
Comptez les points d'enforcement. Tracez comment le verdict atteint le comportement. Existe-t-il une seule méthode qui décide de tout ? Un seul booléen ou entier qui porte toute la réponse ? Un hook peut-il le modifier ? L'enforcement a-t-il lieu à côté de l'action sensible, ou une seule fois au démarrage ?
Vérifiez l'intégrité de votre propre code, en particulier sur iOS. L'application interroge-t-elle sa propre signature de code ou hache-t-elle ses propres pages de texte ? Détecte-t-elle le repackaging et la re-signature ? Vérifie-t-elle le framework de protection lui-même ? Échoue-t-elle en mode fermé (fail closed) ? Sans cela, toutes les autres protections ne tiennent qu'à une instruction à inverser, et rien dans le bundle ne le remarquera.
Comment l'évaluation a été menée
Le shielding mobile est facile à mal juger. Un outil qui se charge n'est pas une preuve. Un crash qui disparaît n'en est pas toujours une. Un proxy qui montre du trafic n'est pas la preuve d'un contournement du pinning. La méthode compte autant que le résultat.
Une référence avant le contournement. Chaque application a d'abord été lancée sur un appareil sain, non rooté, sans rien d'attaché. Fonctionne-t-elle ? Meurt-elle ? Si elle meurt, qu'est-ce qui se déclenche en premier ? Cette réponse désigne le bouclier le plus externe et sert de référence pour toute affirmation ultérieure. Sans elle, impossible de distinguer une protection d'un bug.
Cartographier les deux couches. L'inspection des paquets, la décompilation et le désassemblage ont produit l'inventaire des détecteurs et les offsets ci-dessus, les huit sites d'enforcement et la méthode qui les alimente, les points de patch iOS et la logique de validation des certificats.
Adapter l'état de l'appareil à la question. Appareils sains pour les références, appareils rootés pour le root et les propriétés, appareils d'interception du trafic pour le pinning, lancement suspendu pour les vérifications qui se déclenchent avant qu'un attachement normal puisse les voir, et builds patchés et re-signés pour les tests d'altération sur iOS. Un seul état d'appareil ne peut pas répondre à toutes les questions.
Ne changer qu'une variable à la fois. La même application avant et après l'écriture des propriétés. Le même certificat avant et après les hooks de pinning. Le même processus avec un attachement précoce ou tardif. Le même binaire iOS avant et après le patch.
Exiger un effet de sécurité observable. Un résultat n'était retenu que si un effet pertinent pour la sécurité avait changé : l'application restait active là où elle s'était terminée, l'interface atteignait un état protégé, la décision sur le certificat basculait, le dépôt acceptait des entrées non authentifiées, ou le build patché s'exécutait sans être détecté. Le temps écoulé n'est jamais un critère de réussite, car « elle a tenu trente secondes » est un espoir, pas une observation.

Chaque critère que l'évaluation s'était fixé, et ce qu'il a réellement donné.
FAQ
Qu'est-ce que le shielding des applications mobiles ?
Une protection intégrée à l'application pour rendre plus difficiles la rétro-ingénierie, l'altération, le débogage, le hooking et l'exécution dans des environnements hostiles. Elle regroupe généralement la détection du root et du jailbreak, la détection des émulateurs et des débogueurs, la détection des hooks, l'obfuscation, le packing, le certificate pinning et la logique anti-tampering.
Qu'est-ce que le RASP ?
Runtime Application Self-Protection (autoprotection des applications à l'exécution). Sur mobile, cela signifie que l'application surveille son propre environnement et réagit quand quelque chose semble anormal. Le mot important est réagit. Une détection sans réponse n'est que de la télémétrie.
Quelle est la différence entre détection et enforcement ?
La détection décide que l'environnement est hostile. L'enforcement décide quoi faire en conséquence. Une application peut détecter parfaitement et ne rien protéger, soit en ignorant la réponse, soit en l'exposant sous la forme d'une valeur unique que l'attaquant peut modifier.
Pourquoi Frida est-il détecté alors qu'un débogueur ne l'est pas ?
À cause de la manière dont il arrive, et non de ce qu'il fait. L'injection renseigne brièvement TracerPid et laisse un mapping exécutable étranger dans la carte mémoire du processus. Une application qui échantillonne ces deux éléments pendant le démarrage détecte l'injection avant que votre script ne s'exécute. Un débogueur qui s'attache après cette vérification ne laisse ni l'une ni l'autre de ces traces.
Pourquoi un seul hook a-t-il mis en échec huit protections à la fois ?
Parce que les huit sites d'enforcement lisent la valeur de retour d'une seule méthode. Centraliser le verdict est pratique à intégrer et simple à auditer, et cela signifie qu'un seul hook désactive tout.
La détection du root suffit-elle ?
Non. La détection du root est une entrée, pas un contrôle. L'application doit encore décider quoi en faire, et tenir compte des attaquants capables de masquer les indicateurs de root, de réécrire les propriétés, de hooker le détecteur ou de patcher l'application.
Le certificate pinning suffit-il ?
Non. Le pinning est implémenté dans le code de l'application : hooker ou patcher ce code le met donc en échec. Il vaut la peine d'être en place, mais ne doit pas être la seule protection d'une transaction sensible.
Le shielding peut-il empêcher les failles de logique métier ?
Non. Il peut détecter un environnement d'exécution hostile. Il ne remplace ni l'authentification, ni l'autorisation, ni la validation côté serveur, ni l'intégrité des transactions. Si un deep link non authentifié peut créer une transaction, le shielding n'est pas la défense pertinente.
Que doit vérifier en premier une équipe mobile ?
Installer l'application sur un téléphone rooté et voir si elle s'arrête. Ensuite, compter combien d'endroits lisent le verdict. Enfin, sur iOS, confirmer que le binaire vérifie sa propre signature. Ces trois réponses déterminent si le reste du budget sert à quelque chose.