Explorez le bug iOS avec Hopper

salut! Je m'appelle Alexander Nikishin, je développe des applications iOS chez Badoo. Dans l'article, je parlerai de la façon dont nous avons enquêté sur un bogue dans UIKit, qu'Apple ne voulait pas corriger pendant six mois.



Tout a commencé en août 2019 avec les premières versions bêta d'iOS 13. Nous avons ensuite rencontré un problème. Dans les applications Badoo et Bumble, nous travaillons constamment à l'amélioration des interfaces et, par exemple, nous essayons d'optimiser au maximum le processus d'enregistrement fastidieux et mal aimé. Les info-bulles prédictives systématiques au-dessus du clavier sont un excellent moyen de réduire le nombre de clics de l'utilisateur lors de la saisie de données. Cependant, dans la nouvelle version d'iOS, nous avons été surpris de constater que les invites pour entrer le numéro de téléphone avaient disparu.



Lorsque la version GM est sortie, nous avons réalisé que le problème n'était pas résolu. Il nous a semblé qu'un bug aussi évident ne pouvait tout simplement pas être manqué par les tests de régression, qu'Apple avait probablement dans son arsenal, et nous avons commencé à attendre une correction dans le premier gros package de mise à jour. D' autres développeurs espéraient cela . Cependant, avec la sortie de la version 13.1, rien n'a changé et nous n'avons eu d'autre choix que d'ouvrir le radar, ce que nous avons fait début octobre. Le temps a passé, iOS 13.2 et 13.3 sont sortis et le bug n'est toujours pas corrigé.

En février, notre équipe d'inscription a eu du temps libre pour travailler sur l'arriéré, et j'ai décidé d'étudier ce problème un peu plus profondément.

Avant de commencer à creuser, vous deviez trouver le moyen de le faire. Étant donné que les astuces ont continué de fonctionner sur certains types de claviers, la première pensée a été d'étudier la hiérarchie de ses vues dans différentes versions d'iOS.


iOS 12 vs iOS 13

Il est immédiatement devenu clair qu'Apple dans iOS 13 a refactorisé l'implémentation du clavier et a mis en évidence les invites dans un contrôleur séparé ( UIPredictionViewController). De toute évidence, la tendance à la modularisation et à la décomposition l'a également atteinte (d'ailleurs, Artyom Loenko a récemment parlé de notre expérience de leur application ). Très probablement, à cause de cela, il y a eu une régression des fonctionnalités. Le cercle des recherches a commencé à se rétrécir.

Je pense que la plupart des développeurs iOS savent qu'il est facile de trouver des interfaces de classes système privées dans le domaine public: il suffit de saisir une requête dans le moteur de recherche. Dans l'étude d' une interface de classe, UIPredictionViewController l'œil attrape l'une de ses méthodes:



il semble qu'il y ait un indice, qui est assez facile à vérifier, en utilisant les bonnes vieilles fonctions d'implémentation d'usurpation d'outils (Swizzling). Nous allons l'utiliser pour qu'une fonction «suspecte» renvoie toujours la vraie valeur:

+(void)swizzleIsVisibleForInputDelegate {
    SEL targetSelector = sel_getUid("isVisibleForInputDelegate:inputViews:");
    Class targetClass = NSClassFromString(@”UIPredictionViewController”);
    if (targetClass == nil) {
        return;
    }
    if (![targetClass instancesRespondToSelector:targetSelector]) {
        return;
    }

    Method method = class_getInstanceMethod(targetClass, targetSelector);
    if (method == NULL) {
        return;
    }

    IMP originalImplementation = method_getImplementation(method);
    IMP newImp = imp_implementationWithBlock(^BOOL(id me, id delegate, id views) {
        //   ,        . 
        BOOL result = ((bool (*)(id,SEL,id,id))originalImplementation)(me, targetSelector, delegate, views);
        if ([delegate isKindOfClass:[UITextField class]] && [delegate keyboardType] == UIKeyboardTypePhonePad) {
            return YES;
        }
        return result;
    });

    method_setImplementation(method, newImp);
}

En redémarrant le projet de test, j'ai constaté que les invites du téléphone revenaient à iOS 13 et fonctionnaient «en mode normal». Cela pourrait mettre fin à l'enquête et peut-être même utiliser très soigneusement cette solution de guide Apple dangereuse et interdite dans la version avec la possibilité d'activer / désactiver à distance pour certains utilisateurs (pour l'option de gestion à distance des fonctionnalités, vous pouvez voir l' enregistrement du rapport de ma collègue Katerina Trofimenko). Néanmoins, je n'ai jamais cessé de me demander pourquoi cette fonction renvoie false lors de l'utilisation du clavier de type téléphone.

Pour arriver à la vérité, nous avons besoin du code source de la fonction. Évidemment, Apple ne distribue pas le code du composant iOS à droite et à gauche, il n'est donc pas possible de le rechercher sur Google. Il n'y a qu'une seule façon de faire - l'ingénierie inverse pour décompiler le code binaire. Plus tôt, j'avais entendu parler d'un produit tel que Hopper à plusieurs reprises et lu plusieurs articles sur son utilisation afin de creuser plus profondément sous le capot des bibliothèques système, mais je ne l'ai jamais utilisé moi-même. Et immédiatement, j'ai été agréablement surpris: il s'est avéré que pour jouer et apprendre les outils, il n'était même pas nécessaire d'acheter la version complète. La version de démonstration comprend des sessions de travail sans fin de 30 minutes sans possibilité d'enregistrer l'index et d'apporter des modifications aux fichiers binaires, ce qui signifie qu'il s'agit d'une excellente plateforme d'expérimentation.

Tout de mêmeinterface privée publiée , vous pouvez découvrir ce qui UIPredictionViewControllerfait partie du cadre UIKitCore. Il ne reste plus qu'à le trouver et à le télécharger sur Hopper. Les fichiers de framework binaires sont situés dans les profondeurs de Xcode. Voici, par exemple, le chemin complet vers l'UIKitCore dont nous avons besoin:

/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore

Nous créons un fichier glisser-déposer dans Hopper, confirmons l'action dans la boîte de dialogue système et attendons la fin de l'indexation (dans le cas d'un framework aussi grand que UIKit, cela peut prendre de quatre à six minutes).

L'interface du programme est assez simple, je ne noterai que les éléments clés qui étaient nécessaires à ma recherche. Dans le panneau gauche de l'interface, vous pouvez trouver la ligne de recherche à travers laquelle le code est parcouru. En recherchant le nom de la classe qui nous intéresse, on obtient rapidement la fonction à l'étude, son code assembleur s'ouvre dans la fenêtre principale.



Dans la barre des tâches supérieure, il y a des boutons pour changer de mode d'affichage du code. De gauche à droite:



  • Mode ASM - code assembleur;
  • Mode CFG - code assembleur sous la forme d'un diagramme (arbre), où les morceaux de code sont combinés en blocs et les transitions sont affichées sous forme de branches;
  • Mode pseudo-code - le pseudo-code généré (nous en parlerons plus loin ci-dessous);
  • Le mode hexadécimal est une représentation hexadécimale d'un fichier binaire, ou abracadabra, qui ne nous aide pas beaucoup dans nos recherches.

Vous devez maintenant découvrir ce qui se passe à l'intérieur de la fonction. Son corps est assez long, donc seuls les gourous ASM, que je ne peux pas appeler moi-même, peuvent comprendre la logique en regardant le code assembleur. Pour ce faire, le mode pseudo-code vous aidera, dans lequel Hopper simplifie autant que possible les opérations d'assemblage, en remplaçant les noms de fonction réels lorsque cela est possible et en utilisant des noms de registre comme des variables. Cela ressemble à ceci:



il ne reste plus qu'à parcourir la logique de la fonction et à comprendre dans laquelle de ses branches nous tombons. Les points d'arrêt symboliques m'ont aidé avec cela., qui peut être installé sur les appels système, imprimant simultanément toutes les variables nécessaires et les résultats des appels de fonction à la console Xcode. En utilisant cette méthode simple, j'ai trouvé que l'exécution de la fonction est interrompue par une sortie anticipée du fait que l'une des conditions ne fonctionne pas dans l'exemple de bloc de code. Voyons ce qui se passe ici.

Un peu de contexte: dans le registre rbx un peu plus haut dans le code (que j'ai omis pour des raisons de simplicité) se trouve un lien vers l'objet implémentant le protocole UITextInputTraits_Private(il s'agit d'une version étendue du protocole public UITextInputTraits).

Ainsi, la première des conditions est de vérifier que les invites ne sont pas masquées par la configuration du champ de saisie. Dans le débogage, vous pouvez voir qu'il s'exécute: propriétéhidePredictionrenvoie faux. La deuxième condition est de vérifier que le clavier n'est pas en mode «split» (sur votre iPad, tirez le bouton inférieur droit vers le haut , seuls 2-3% des utilisateurs connaissent cette chose). Avec cela aussi, tout est en ordre.

Passez. À l'étape suivante, les manipulations commencent keyboardType, ce qui nous laisse entendre que la vérité est quelque part à proximité. Il est d'abord vérifié que le courant est keyboardTypeinférieur ou égal à 0xb (ou 11 au format décimal). Si nous ouvrons la déclaration dans Xcode UIKeyboardType, nous verrons qu'il existe 13 types de claviers, et l'un d'eux (UIKeyboardTypeAlphabet) est obsolète et déclaré comme référence à un autre type. Autrement dit, il y a 12 types en enum: si vous commencez à partir de 0, ce dernier aura une valeur égale à 11. En d'autres termes, le code valide la valeur sous la forme d'une vérification de dépassement de capacité, et elle passe à nouveau avec succès.

De plus, nous voyons une condition très étrange if (!COND), et pendant longtemps je n'ai pas pu comprendre ce qu'elle vérifiait, étant donné que la variable COND n'était déclarée nulle part ailleurs dans le code. De plus, mes points d'arrêt symboliques ont montré que c'est précisément le non-respect de cette condition qui conduit à une sortie anticipée de la fonction. Et ici, nous n'avons pas d'autre choix que de revenir en mode ASM et d'étudier cette partie du code sous forme d'assembleur.

Pour trouver cette condition dans la liste ASM, vous pouvez utiliser l'option «Pas de duplication de code», qui affichera le pseudocode non pas sous la forme de code source avec des conditions if-else, mais sous la forme de blocs de code uniques et de transitions sous la forme de goto. Dans ce cas, nous verrons la position du début de ces blocs dans le fichier binaire et utiliserons ce pointeur pour rechercher en mode ASM. J'ai donc découvert que le bloc qui nous intéresse se trouve à fe79f4:



En revenant en mode ASM, nous pouvons facilement trouver ce bloc:



Nous arrivons à la partie la plus difficile, où nous analyserons les trois lignes de code assembleur.

J'ai reconnu la première ligne de ma mémoire (grâce aux cours d'assembleur dans mon MIET natal, enfin le moment est venu dans ma vie où l'enseignement supérieur est devenu pratique!). Tout est assez simple ici: la constante 0x930 (100100110000 au format binaire) est placée dans le registre ecx.

Dans la deuxième ligne, nous voyons l'instruction bt exécutée sur les registres excet eax. La valeur de l'un que nous connaissons déjà, la valeur de la seconde peut être vue dans la capture d'écran précédente: rax = [rbx keyboardType]- elle contient le type de clavier actuel. rax- c'est l'intégralité du registre 64 bits, eax- c'est sa partie 32 bits.

Une fois les données décidées, il reste à comprendre la logique de l'équipe. Google nous amène à cette description :
Selects the bit in a bit string (specified with the first operand, called the bit base) at the bit-position designated by the bit offset operand (second operand) and stores the value of the bit in the CF flag.

L'instruction extrait un bit du premier opérande (constante 0x930) à la position spécifiée par le second opérande (type clavier) et le place dans CF ( indicateur de portage ). En d'autres termes, le CF contiendra 0 ou 1, selon le type de clavier.

Nous passons à la dernière opération jb, elle a la description suivante :

Jump short if below (CF=1)

Il est facile de deviner qu'il y a une transition vers la fonction (sortie anticipée) si CF est 1.

A ce stade, le puzzle commence à s'additionner. Nous avons un masque de bits 100100110000 (il contient 12 bits en fonction du nombre de types de clavier disponibles), et il détermine les conditions de sortie anticipée. Maintenant, si nous vérifions la disponibilité des indices pour tous les types de claviers dans l'ordre croissant rawValue, tout sera en place.



Dans iOS 12, nous ne trouverons pas une telle logique - les astuces y fonctionnent pour tout type de clavier. Je soupçonne que dans iOS 13, Apple a décidé de désactiver les indices pour les claviers numériques, ce qui est compréhensible en principe: je ne peux pas proposer un scénario dans lequel le système doit demander des chiffres. Apparemment, par erreur, une «main chaude» a également frappé UIKeyboardTypePhonePad, ce qui est très similaire à un pavé numérique ordinaire. Dans le même temps UIKeyboardTypeNamePhonePad, combinant un clavier QWERTY pour rechercher des contacts téléphoniques et le même clavier numérique désactivé, il a continué à afficher des invites.

À ma grande surprise, travailler avec Hopper s'est avéré très agréable et divertissant, pendant longtemps je n'ai pas été tellement fan. J'ai partagé les résultats avec les ingénieurs Apple dans mon rapport de bogue, et son état a changé au fil du temps en «Correction potentielle identifiée - Pour une future mise à jour du système d'exploitation». J'espère que le correctif atteindra les utilisateurs dans les futures mises à jour. Dans le même temps, Hopper peut être utilisé non seulement pour trouver les causes de vos bogues ou d'Apple, mais également pour détecter les correctifs Apple pour les programmes tiers .

All Articles