Crack de 4images1mot : De nouvelles énigmes - Balkany et Manuel Valls enfin à l'honneur

Ce tuto n'a qu'un but éducatif : Il s'agit d'un bon exemple de hack d'une application Android faisant appel à des techniques de décompilation, un peu de programmation, et beaucoup de logique.

Par ailleurs, je suppose que le préjudice reste faible pour une appli exploitée depuis des années et qui peut être recodée intégralement (sans pubs :p) en moins de 2 semaines par n'importe qui...

L'application est bien pensée, mais son modèle économique pose problème; Comme tout ce qui sort aujourd'hui dans le monde numérique à destination du grand public, il s'agit de monnayer le temps de cerveau disponible des gens.

Ici via :

  • Les publicités ultra-invasives qui agressent le joueur toutes les 30 secondes de jeu pendant 5 secondes (allant de l'image fixe à la vidéo ne pouvant être fermée).

  • Le principe de quêtes journalières augmenté d'un système de rappels, bien connu dans le monde des MMO pour astreindre le joueur à une activité quotidienne.

  • L'ajustement du temps passé par l'utilisateur à subir des animations multiples et inutiles, afin d'économiser le contenu coûteux à produire (les énigmes à résoudre dont le nombre est fini).

Avec près de 1,4 million d'utilisateurs, cette application dispose pourtant d'une masse critique dont elle ne fait rien. En effet, les joueurs pourraient contribuer à des projets de science participative, dont certains domaines de l'apprentissage automatique ont grand besoin pour résoudre des problèmes de classification nécessitant des annotations de qualité produites par des humains.

Sommaire

Rappels sur Android

Processus de construction des applications sur Android

De nombreux outils interviennent dans le processus de conversion du code source d'une application vers l'APK (Android Distribution Package) distribuable. Pour modifier le code d'une application, il est important d'avoir une vue d'ensemble de ce packaging.

D'après la documentation (Android : Build process), voici les étapes générales suivies:

flowchart_compilation_java_class_dex_apk

  • L'Android Asset Packaging Tool (aapt) produit un code java référençant les ressources utilisées par le reste du programme, ainsi que les ressources compilées qui seront intégrées à la dernière étape du packaging.

  • L'Android Interface Definition Language tool convertit à son tour les interfaces .aidl en interfaces Java.

  • Les compilateurs convertissent le code Java en bytecode (fichiers .class) pouvant être exécuté par une JVM (Machine Virtuelle Java) classique.

  • Un programme vient ensuite consolider le bytecode générique en bytecode optimisé destiné à s'exécuter dans des environnements à faible puissance de calcul sur lesquels tournent les systèmes Android; ce sont les fichiers de classes DEX (Dalvik Executable).

  • L'APK Packager combine les fichiers DEX aux ressources compilées pour donner un APK qui sera signé en vue de son installation sur l'appareil.

  • L'APK final est obtenu après une étape d'optimisation de l'archive en vue de réduire la consommation de mémoire du programme.

Machine Virtuelle Dalvik :

Le but d'une machine virtuelle comme Dalvik est de permettre d'exécuter le même programme sur une grande variété d'appareils, quelles que soient leurs caractéristiques techniques. Le code exécutable du programme (dit bytecode) est transformé à la volée en instructions spécifiques à l'appareil sur lequel le programme est exécuté — c'est la fonction de compilation just-in-time de la machine virtuelle.
Source : Wikipédia

Téléchargement des applications

Les fichiers .apk peuvent être récupérés sur des sites tiers comme Apkpure ou via ADB (Android Debug Bridge) pour une application déjà installée sur le système :

$ adb shell pm list packages -f-3 # only lists third party apk
$ adb pull /data/apps/\<nameofapk\>/base.apk

Faites attention aux sites qui proposent de télécharger des apks.

Google Play ne garantit déjà pas l'absence de malwares alors gardez en tête que les sites tiers sont encore plus dangereux ! Vérifiez et comparez les checksums des fichiers téléchargés (ex : md5sum file.apk) !

Décompilation

Les fichiers .dex contiennent les .class Java compilées pour Dalvik. À partir de là, sachez que :

  • Obtenir les fichiers .java à partir des .dex est possible MAIS, le procédé de décompilation peut être hasardeux à cause par exemple, d'une obfuscation volontaire du code par les développeurs, et la recompilation est lourde car nécessite de réimporter tout le projet dans un IDE comme Android Studio.

  • Pour pallier ces difficultés liées à l'obtention du code original et éviter de modifier le bytecode DEX à la main, une représentation humainement compréhensible a été développée: le format Smali.

Les fichiers smali sont ceux que vous modifierez, alors que les fichiers Java sont ceux que vous lirez pour comprendre la logique du programme. Le Smali étant un format destiné au debugging, lors de la conversion Java -> DEX, de nombreuses informations sont perdues; vous ne pouvez donc pas convertir du Smali en Java.

Exemple de code:

// Java
int x = 42


# Dex
# En assumant que x est la première variable de la méthode
13 00 2A 00


# Smali
const/16 v0, 42

Logiciels utilisés

outils et liensformat d'entrée(s)format de sortieremarques
dex2jar.apk.jarL'archive jar est un zip contenant les .class
jad.class.javaA priori plus maintenu mais fait encore le travail
jadx.dex, .apk, .jar, .class.java
apktool.apk.smali1 fichier .smali par .class
backsmali.smali/.dex.dex/.smali

Commandes

Nous voulons donc avoir un projet sous 2 formes: Smali et Java. Pour déconstruire un .apk et accéder entre autres aux classes au format Smali, apktool est l'outil de référence.

$ apktool d /chemin/complet/base.apk -o /chemin/complet

En cas de crash d'apktool :

Exception in thread "main" brut.androlib.AndrolibException: Could not decode arsc file
Mettez à jour l'application ! Celle des dépôts de votre distribution peut ne pas être assez récente.

Recompilation

Rebuild de l'apk :

$ apktool b/chemin/complet/ -o new.apk

Signature (keytool initialise le magasin de clés) :

$ keytool -genkey -alias apkKey -keystore ~/myawesomeapkkeystore
$ jarsigner new.apk apkKey -keystore ~/myawesomeapkkeystore

Optimisation de l'application pour diminuer la consommation de mémoire (Vous trouverez zipalign dans le sdk d'Android) :

$ sdk/build-tools/19.1.0/zipalign -v 4 new.apk new_aligned.apk

Parler couramment le Smali

Les opcodes de Dalvik sont résumés ici. Cela sert de référence lorsqu'on veut recoder une fonction ou comprendre un code en Smali.

Des exemples annotés sont ici :

Modifications de 4 images 1 mot

Dans la suite du tuto, j'utilise la version 7.8.3fr de l'application disponible sur Apkpure.

Voici le fichier diff des modifications présentées dans cet article : diff.txt.

Activer le mode premium

Dans l'application vous verrez sur l'écran principal une publicité qui vous proposera l'achat d'un compte premium si vous cliquez dessus. Cherchez le mot "premium" dans les sources; vous tomberez rapidement sur une méthode nommée isPremium() :

./smali/de/lotum/whatsinthefoto/storage/preferences/SettingsPreferences.smali:34:.field private static final KEY_IS_PREMIUM:Ljava/lang/String; = "isPremium"
./smali/de/lotum/whatsinthefoto/storage/preferences/SettingsPreferences.smali:59:    const-string v1, "isPremium"
./smali/de/lotum/whatsinthefoto/storage/preferences/SettingsPreferences.smali:88:    const-string v1, "isPremium"
./smali/de/lotum/whatsinthefoto/storage/preferences/SettingsPreferences.smali:366:.method public isPremium()Z

Dans les sources Java nous voyons :

public SettingsPreferences(SharedPreferences sharedpreferences) {
    // Lecture de la clé "isPremium" du fichier de paramètres
    super(sharedpreferences);
    premiumSetting = new Preferences.BooleanSetting(this, "isPremium", Boolean.valueOf(false));
}

public SettingsPreferences(WhatsInTheFoto whatsinthefoto) {
    // Lecture de la clé "isPremium" du fichier de paramètres (surcharge de méthode)
    super(whatsinthefoto, "settings");
    premiumSetting = new Preferences.BooleanSetting(this, "isPremium", Boolean.valueOf(false));
}

public boolean isPremium() {
    // Retourne true ou false selon l'attribut premiumSetting
    return premium().get().booleanValue();
}

public Preferences.BooleanSetting premium() {
    // Getter/accesseur de l'attribut premiumSetting
    return premiumSetting;
}

Nous apprenons que:

  • Le paramètre est lu depuis un fichier de configuration; il s'agit d'un booléen nommé isPremium,
  • isPremium() renvoie true ou false en conséquence.

isPremium() doit donc toujours retourner true pour bénéficier à vie de ce statut.

Code Smali de isPremium() avant modification :

.method public isPremium()Z
    .locals 1
    .prologue
    .line 142
    invoke-virtual {p0}, Lde/lotum/whatsinthefoto/storage/preferences/SettingsPreferences;->premium()Lde/lotum/whatsinthefoto/storage/preferences/Preferences$BooleanSetting;
    move-result-object v0

    invoke-virtual {v0}, Lde/lotum/whatsinthefoto/storage/preferences/Preferences$BooleanSetting;->get()Ljava/lang/Boolean;
    move-result-object v0

    invoke-virtual {v0}, Ljava/lang/Boolean;->booleanValue()Z
    move-result v0

    return v0
.end method

Réécrivons la fonction pour qu'elle ne renvoie que true (autrement dit l'entier 1 ou 0x1 en hexadécimal) :

.method public isPremium()Z
    # Déclaration d'une seule variable (v0)
    .locals 1
    .prologue
    .line 142
    # Affectation de v0 avec la valeur 1
    const/4 v0, 0x1
    # Retourne v0
    return v0
.end method

Nous aurions également pu éditer comme suit le fichier de configuration de l'application /data/data/de.lotum.whatsinthefoto/shared_prefs/settings.xml:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <long name="lastSplashCreateTimestamp" value="1519404235490" />
    <boolean name="hasSeenDuelBriefing" value="true" />
    <string name="hasSeenEventBriefing">2018_02_SouthKorea</string>
    <int name="duelCompetitionRoundCompleted" value="68" />
    <boolean name="hasSeenSponsorPayInfo" value="false" />
    <boolean name="sound" value="true" />
    <boolean name="hasSeenMigrationBriefing" value="true" />

    <!-- ligne ajoutée pour activer l'option premium -->
    <boolean name="isPremium" value="true" />
</map>

L'édition de fichiers sur la partition système d'Android requiert les droits super-utilisateur (root)

"rooter" son téléphone est souvent possible et très souvent pratiqué comme on peut le constater sur les forums dédiés à cette plateforme. Toutefois, de la même façon qu'il est dangereux d'utiliser un ordinateur avec le compte administrateur (ce qui est pourtant systématique pour les utilisateurs de Windows), il est aussi dangereux d'utiliser un téléphone et des applications avec de tels droits par défaut ! En effet, la sécurité d'un système repose sur la limitation des droits des utilisateurs et des applications afin de bien séparer les tâches qui relèvent d'un fonctionnement normal de celles qui relèvent d'un contexte administratif (installation, mise à jour, configuration, etc.).
Certaines ROM comme CyanogenMod/LineageOS permettent d'octroyer ponctuellement les droits root à des applications. Je ne conseillerai jamais assez leur usage à la place des Android installés d'office sur le matériel vendu dans le commerce.

Afficher les solutions en jeu

Il serait intéressant d'afficher la solution du puzzle quelque-part dans l'interface du jeu non ?

solution_in_copyright

Voici tout d'abord quelques informations concernant le stockage des données de l'application. Lors du premier démarrage, les fichiers du jeu sont initialisés et une base de données (BDD) SQLite est constituée avec les principales tables et leurs attributs suivants :

BonusPuzzle (
    id INTEGER,
    poolId INTEGER,
    isSolved INTEGER,
    copyright1 TEXT,
    copyright2 TEXT,
    copyright3 TEXT,
    copyright4 TEXT,
    solution TEXT,
    removeJokerIndices TEXT DEFAULT '',
    showJokerIndices TEXT DEFAULT '',
    keyPermutation TEXT DEFAULT '',
    date TEXT,description1 TEXT,
    description2 TEXT,
    description3 TEXT,
    description4 TEXT,
    notificationType INTEGER DEFAULT 0,
    notificationTemplate TEXT,
    PRIMARY KEY(id))

Challenge (
    id INTEGER,
    type INTEGER,
    goal INTEGER,
    description TEXT,
    progress TEXT,
    PRIMARY KEY(id))

DailyEvent (
    eventId TEXT PRIMARY KEY,
    eventName TEXT,
    dateStart TEXT,
    dateEnd TEXT,
    goal1 INTEGER,
    goal2 INTEGER,
    goal3 INTEGER,
    goal4 INTEGER,
    reward1 INTEGER,
    reward2 INTEGER,
    reward3 INTEGER,
    reward4 INTEGER,
    briefingTitle TEXT,
    briefingSubtitle TEXT,
    briefingText1 TEXT,
    briefingText2 TEXT,
    briefingText3 TEXT,
    startNotification1 TEXT,
    startNotification2 TEXT,
    startNotification3 TEXT,
    colorHalo TEXT,
    colorStar TEXT,
    colorFont TEXT,
    teaserText TEXT,
    solvedPuzzle INTEGER)

GameState (
    id INTEGER,
    coins INTEGER,
    puzzleId INTEGER,
    PRIMARY KEY(id))

Puzzle (
    id INTEGER,
    poolId INTEGER,
    challengeId INTEGER DEFAULT 0,
    isSolved INTEGER,
    copyright1 TEXT,
    copyright2 TEXT,
    copyright3 TEXT,
    copyright4 TEXT,
    solution TEXT,
    removeJokerIndices TEXT DEFAULT '',
    showJokerIndices TEXT DEFAULT '',
    keyPermutation TEXT DEFAULT '',
    countHints INTEGER DEFAULT 0,
    PRIMARY KEY(id))

La classe contenant les méthodes de gestion de la BDD est smali/de/lotum/whatsinthefoto/storage/database/DatabaseAdapter. À chaque fois qu'une nouvelle énigme (puzzle) est proposée, la méthode consumePuzzle est appelée :

private static Puzzle consumePuzzle(Cursor cursor) {
    // Reçoit un curseur pointant sur une ligne de la base de données
    cursor = new CursorHelper(cursor);
    // Construit un objet Puzzle avec les différents attributs de la bdd
    return new Puzzle(
        cursor.getInt("id"),
        Arrays.asList(new Integer[] {
            Integer.valueOf(0), Integer.valueOf(1), Integer.valueOf(2), Integer.valueOf(3)
        }),
        cursor.getInt("poolId"),
        cursor.getInt("challengeId"),
        cursor.getString("copyright1", new Object[0]),
        cursor.getString("copyright2", new Object[0]),
        cursor.getString("copyright3", new Object[0]),
        cursor.getString("copyright4", new Object[0]),
        cursor.getString("solution", new Object[0]),
        cursor.getBoolean("isSolved"),
        cursor.getString("keyPermutation", new Object[0]),
        cursor.getInt("countHints")
    );
}

Voici une fraction du code smali correspondant :

const-string v8, "copyright4"
new-array v9, v12, [Ljava/lang/Object;
.line 706
invoke-virtual {v13, v8, v9}, Lde/lotum/whatsinthefoto/storage/database/CursorHelper;->getString(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;
move-result-object v8

const-string/jumbo v9, "solution"
new-array v10, v12, [Ljava/lang/Object;
.line 707
invoke-virtual {v13, v9, v10}, Lde/lotum/whatsinthefoto/storage/database/CursorHelper;->getString(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;
move-result-object v9

Nous voyons que le copyright de chaque image est requêté afin de l'afficher en jeu lors du zoom. À partir de là il est facile de remplacer le champ d'un des 4 copyrights par la solution de l'énigme :

# remplacer "copyright4" par "solution"
# const-string v8, "copyright4"
const-string v8, "solution"

Les puzzles bonus des quêtes journalières ont des attributs un peu différents et sont donc gérés par une autre fonction semblable.
Toujours dans smali/de/lotum/whatsinthefoto/storage/database/DatabaseAdapter:

private static BonusPuzzle consumeSingleBonusPuzzle(Cursor cursor) {
    return DatabaseXKt.consumeSingleBonusPuzzle(cursor);
}

Poursuivons donc dans smali/de/lotum/whatsinthefoto/storage/database/DatabaseXKt:

public final class DatabaseXKt {

    public static final BonusPuzzle consumeSingleBonusPuzzle(Cursor cursor) {
        Intrinsics.checkParameterIsNotNull(cursor, "c");
        // Retourne null si aucun Puzzle n'est disponible en bdd
        if(cursor.getCount() == 0)
            return null;
        // Ne récupère que la première ligne retournée par la requête SQL
        cursor.moveToFirst();

        String s3 = ((CursorHelper) (obj)).getString("copyright4", new Object[0]);
        if(s3 == null)
            Intrinsics.throwNpe();
        String s4 = ((CursorHelper) (obj)).getString("solution", new Object[0]);
        if(s4 == null)
            Intrinsics.throwNpe();
        ...

Fraction de consumeSingleBonusPuzzle() en Smali dans laquelle il faut aussi remplacer "copyright4" par "solution":

.line 37
:cond_3
# To be modified:
const-string v10, "copyright4"
const/4 v11, 0x0
new-array v11, v11, [Ljava/lang/Object;
move-object/from16 v0, v21
invoke-virtual {v0, v10, v11}, Lde/lotum/whatsinthefoto/storage/database/CursorHelper;->getString(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;
move-result-object v10

if-nez v10, :cond_4
invoke-static {}, Lkotlin/jvm/internal/Intrinsics;->throwNpe()V

...

Coût des jokers

En cherchant le mot "coin" on tombe sur des fonctions intéressantes de gestion des quantités de pièces en base de données (addCoins(), getCoins(), setCoins()). Mais le plus intéressant se situe au niveau du retrait des pièces lors de la consommation d'un joker; cette fonctionnalité utilise addCoins() :

./smali/de/lotum/whatsinthefoto/ui/controller/JokerController.smali:580:    invoke-virtual {v8, v9}, Lde/lotum/whatsinthefoto/storage/database/DatabaseAdapter;->addCoins(I)V
./smali/de/lotum/whatsinthefoto/ui/controller/JokerController.smali:674:    invoke-virtual {v2, v3}, Lde/lotum/whatsinthefoto/storage/database/DatabaseAdapter;->addCoins(I)V

Nous trouvons une méthode pour chaque joker dans smali/de/lotum/whatsinthefoto/ui/controller/JokerController :

private void doJokerRemove(IPuzzleManager ipuzzlemanager)
    ...
    database.addCoins(-Joker.REMOVE.cost);

private void doJokerReveal(IPuzzleManager ipuzzlemanager)
    ...
    database.addCoins(-Joker.REVEAL.cost);

Cherchons donc "REVEAL" :

./smali_classes2/de/lotum/whatsinthefoto/ui/controller/JokerController$Joker.smali:31:.field public static final enum REVEAL:Lde/lotum/whatsinthefoto/ui/controller/JokerController$Joker;
./smali_classes2/de/lotum/whatsinthefoto/ui/controller/JokerController$Joker.smali:68:    const-string v1, "REVEAL"

./smali_classes2/de/lotum/whatsinthefoto/ui/controller/JokerController$Joker.smali:35:.field public final cost:I
./smali_classes2/de/lotum/whatsinthefoto/ui/controller/JokerController$Joker.smali:102:    .param p3, "cost"    # I

Le prix est stocké dans une énumération déclarée dans JokerController$Joker.smali; or nous connaissons les prix de ces jokers :

Type de jokerCoût en piècesValeur hexadécimale
REMOVE800x50
REVEAL600x3c


Voici le code d'initialisation des prix:

const-string v1, "REMOVE"
const/16 v2, 0x50

const-string v1, "REVEAL"
const/16 v2, 0x3c

Remplaçons ces valeurs par 0x0.

Rendre les quêtes journalières hebdomadaires

Une énigme par jour est trop peu et contraignant; voici une façon de laisser 10 jours à l'utilisateur pour résoudre les énigmes de cet intervalle. C'est la modification qui m'a pris le plus de temps, notamment à cause du nombre de fonctions à réécrire et d'une duplication de code assez gênante dans le projet.

Résoudre tous les puzzles journaliers d'une traite

Un certain nombre de fonctions dans le fichier DatabaseAdapter sont impliquées :

public BonusPuzzleManager dailyPuzzleManager() {
    String s = BonusDateIdentifier.createForToday();
    if(getCountSolvedBonusPuzzles(s) > 0)
        return PuzzleManagerFactoryKt.newBonusPuzzleManager(findSolvedBonusPuzzleByDate(s));
    else
        return PuzzleManagerFactoryKt.newBonusPuzzleManager(getBonusPuzzle(s));
}
private BonusPuzzle getBonusPuzzle(String s) {
    BonusPuzzle bonuspuzzle = findBonusPuzzleByDate(s);
    if(bonuspuzzle == null)
        return null;
    JokerAdapter jokeradapter = getJokerAdapter(PuzzleManagerFactoryKt.newBonusPuzzleManager(bonuspuzzle));
    Object obj = bonuspuzzle;
    if(!KeyPermutationGenerator.hasValidKeyPermutation(bonuspuzzle, jokeradapter.getRemoveIndices()))
    {
        jokeradapter.clear();
        obj = new ContentValues();
        ((ContentValues) (obj)).put("keyPermutation", keyPermutationGenerator.generate(bonuspuzzle));
        database.update("BonusPuzzle", ((ContentValues) (obj)), "id = ?", new String[] {
            String.valueOf(bonuspuzzle.getId())
        });
        obj = findBonusPuzzleByDate(s);
    }
    return ((BonusPuzzle) (obj));
}
public BonusPuzzle findSolvedBonusPuzzleByDate(String s) {
    return consumeSingleBonusPuzzle(database.query("SELECT * FROM BonusPuzzle WHERE isSolved = 1 AND date = ?", new String[] {
        s
    }));
}
private int getCountSolvedBonusPuzzles(String s) {
    return querySingleInt("SELECT COUNT(*) FROM BonusPuzzle WHERE isSolved = 1 AND date = ?", new String[] {
        s
    });
}
public BonusPuzzle findBonusPuzzleByDate(String s) {
    return consumeSingleBonusPuzzle(database.query("SELECT * FROM BonusPuzzle WHERE date = ?", new String[] {
        s
    }));
}
private static BonusPuzzle consumeSingleBonusPuzzle(Cursor cursor) {
    return DatabaseXKt.consumeSingleBonusPuzzle(cursor);
}

public boolean existsBonusPuzzle(String s) {
    return querySingleInt("SELECT COUNT (*) FROM BonusPuzzle WHERE date = ?;", new String[] {
        s
    }) > 0;
}

Intéressons-nous à dailyPuzzleManager(); si getCountSolvedBonusPuzzles() retourne 1 ou plus (1 puzzle au moins a été résolu pour la journée courante), alors findSolvedBonusPuzzleByDate() est appelée et retourne le premier puzzle résolu en BDD grâce à consumeSingleBonusPuzzle(). Sinon, getBonusPuzzle() est appelée pour retourner un puzzle non résolu grâce à findBonusPuzzleByDate(). Notons que getBonusPuzzle() vérifie si le puzzle a une combinaison de lettres keyPermutation prédéfinie, si non, elle est calculée puis insérée en bdd. On remarque aussi que findBonusPuzzleByDate() ne filtre pas les puzzles sur l'attribut isSolved car si on est là c'est que getCountSolvedBonusPuzzles a retourné 0 et qu'on est sûr de n'avoir aucun puzzle résolu pour la journée courante (tout le monde suit ?).


getCountSolvedBonusPuzzles()

Nous voulons retourner tous les puzzles non résolus jusqu'à leur épuisement. getCountSolvedBonusPuzzles() ne doit donc plus se baser sur la date courante, mais sur ce qui reste en bdd :

-- Donne-moi le nb de puzzles résolus pour la date donnée
-- SELECT COUNT(*) FROM BonusPuzzle WHERE isSolved = 1 AND date = ?
-- devient: Donne-moi le nombre de puzzles non résolus dans toute la bdd
SELECT COUNT(*) FROM BonusPuzzle WHERE isSolved = 0

Dorénavant si le nombre calculé par la fonction d'agrégat COUNT() est égal à 0, cela veut dire que tous les puzzles sont résolus : la fonction doit alors retourner 1; si le nombre est supérieur à 0, alors il reste des puzzles à résoudre : la fonction doit retourner 0 pour qu'un puzzle non résolu soit retourné par dailyPuzzleManager().

Ces modifications correspondent à ceci en Java :

return querySingleInt("SELECT COUNT(*) FROM BonusPuzzle WHERE isSolved = 0", new String[0]) == 0 ? 1 : 0;

En Smali :

.method private getCountSolvedBonusPuzzles(Ljava/lang/String;)I
    .locals 3
    .param p1, "date"    # Ljava/lang/String;
    .prologue
    .line 179
    const-string v0, "SELECT COUNT(*) FROM BonusPuzzle WHERE isSolved = 1 AND date = ?"

    # Déclaration d'un tableau de chars de taille 1
    const/4 v1, 0x1
    new-array v1, v1, [Ljava/lang/String;
    const/4 v2, 0x0
    # Place la référence du paramètre p1 (la date) dans v1 à l'emplacement v2 (0)
    aput-object p1, v1, v2
    # Exécute la requête avec 2 arguments: La requête SQL préparée et son paramètre.
    invoke-virtual {p0, v0, v1}, Lde/lotum/whatsinthefoto/storage/database/DatabaseAdapter;->querySingleInt(Ljava/lang/String;[Ljava/lang/String;)I
    # Place le résultat de la commande précédente dans v0
    move-result v0

    return v0
.end method

Devient :

.method private getCountSolvedBonusPuzzles(Ljava/lang/String;)I
    # Plus besoin de v2
    .locals 2
    .param p1, "date"    # Ljava/lang/String;
    .prologue
    .line 179
    const-string v0, "SELECT COUNT(*) FROM BonusPuzzle WHERE isSolved = 0"

    # Déclaration d'un tableau de chars de taille 0 (on supprime le paramètre 'date' de la requête
    const/4 v1, 0x0
    new-array v1, v1, [Ljava/lang/String;
    invoke-virtual {p0, v0, v1}, Lde/lotum/whatsinthefoto/storage/database/DatabaseAdapter;->querySingleInt(Ljava/lang/String;[Ljava/lang/String;)I
    move-result v0

    # Si v0 == 0, on saute à cond_0
    if-eqz v0, :cond_0
    # Affectation de v1 avec la valeur 0
    const/4 v1, 0x0

    :goto_0
    # Retourne 1 ou 0
    return v1

    :cond_0
    # Affectation de v1 avec la valeur 1
    const/4 v1, 0x1
    # Saut inconditionnel vers goto_0
    goto :goto_0
.end method


findSolvedBonusPuzzleByDate()

La requête SQL de findSolvedBonusPuzzleByDate() doit être modifiée :

-- Donne-moi les puzzles résolus pour la date donnée
-- SELECT * FROM BonusPuzzle WHERE isSolved = 1 AND date = ?
-- devient: donne-moi les puzzles résolus triés par ordre descendant (du plus récent au plus ancien),
-- sauf le puzzle du tuto avec la date 'ZZZZ-ZZ-ZZ'
SELECT * FROM BonusPuzzle WHERE isSolved = 1 AND date != \'ZZZZ-ZZ-ZZ\' ORDER BY DATE DESC

Soit :

const-string v2, "SELECT * FROM BonusPuzzle WHERE isSolved = 1 AND date = ?"
const/4 v3, 0x1
new-array v3, v3, [Ljava/lang/String;
const/4 v4, 0x0
aput-object p1, v3, v4
invoke-virtual {v1, v2, v3}, Lcom/squareup/sqlbrite2/BriteDatabase;->query(Ljava/lang/String;[Ljava/lang/String;)Landroid/database/Cursor;

Devenant :

# Nous n'avons plus besoin de 5 variables, .locals est donc réduit à 4 variables: v0, v1, v2, v3
.locals 4
...
const-string v2, "SELECT * FROM BonusPuzzle WHERE isSolved = 1 AND date != \'ZZZZ-ZZ-ZZ\' ORDER BY DATE DESC"
const/4 v3, 0x0
new-array v3, v3, [Ljava/lang/String;
invoke-virtual {v1, v2, v3}, Lcom/squareup/sqlbrite2/BriteDatabase;->query(Ljava/lang/String;[Ljava/lang/String;)Landroid/database/Cursor;


findBonusPuzzleByDate()

Nous voulons récupérer tous les puzzles non résolus quelque-soit leur date. Cette fonction doit maintenant retourner le puzzle non résolu le plus vieux en bdd au lieu du puzzle de la date courante.

-- SELECT * FROM BonusPuzzle WHERE date = ?
-- devient: donne-moi les puzzles non résolus triés par ordre ascendant (du plus ancien au plus récent),
SELECT * FROM BonusPuzzle WHERE isSolved = 0 ORDER BY DATE ASC

Le principe est le même que pour findSolvedBonusPuzzleByDate() : on supprime l'argument. Mais nous sommes alors confrontés à un nouveau cas de figure à prendre en compte; lors du tutoriel concernant les quêtes journalières, le mot "COEUR" doit être deviné. Ce puzzle au statut particulier à une date ZZZZ-ZZ-ZZ; il est donc retourné en dernier à cause du tri de la nouvelle requête.

Soyons plus propres :

public BonusPuzzle findBonusPuzzleByDate(String s)
{
    BriteDatabase britedatabase = database;
    String as[];
    if(s.equals("ZZZZ-ZZ-ZZ"))
    {
        String s1 = "SELECT * FROM BonusPuzzle WHERE date = ?";
        as = new String[1];
        as[0] = s;
        s = s1;
    } else
    {
        s = "SELECT * FROM BonusPuzzle WHERE isSolved = 0 ORDER BY DATE ASC";
        as = new String[0];
    }
    return consumeSingleBonusPuzzle(britedatabase.query(s, as));
}

Soit :

.method public findBonusPuzzleByDate(Ljava/lang/String;)Lde/lotum/whatsinthefoto/entity/BonusPuzzle;
    .locals 4
    .param p1, "date"    # Ljava/lang/String;
    .annotation build Landroid/support/annotation/Nullable;
    .end annotation
    .prologue
    .line 736
    # Récupération de l'attribut privé de la classe courante dans v1
    iget-object v1, p0, Lde/lotum/whatsinthefoto/storage/database/DatabaseAdapter;->database:Lcom/squareup/sqlbrite2/BriteDatabase;

    # Déclaration et affectation de la chaine de caractères destinée à détecter la date du puzzle du tutorial
    const-string v0, "ZZZZ-ZZ-ZZ"
    # Appel de la méthode Java equals() pour comparer v0 au paramètre p1 (date donnée) de la fonction
    invoke-virtual {p1, v0}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z
    move-result v0

    # Si le résultat est égal à 0 (false), on saute à cond_0
    if-eqz v0, :cond_0
    # Retourne le puzzle du tutorial
    const-string v2, "SELECT * FROM BonusPuzzle WHERE date = \'ZZZZ-ZZ-ZZ\'"

    :goto_0
    # Exécute la requête
    const/4 v3, 0x0
    new-array v3, v3, [Ljava/lang/String;
    invoke-virtual {v1, v2, v3}, Lcom/squareup/sqlbrite2/BriteDatabase;->query(Ljava/lang/String;[Ljava/lang/String;)Landroid/database/Cursor;
    move-result-object v0

    .line 737
    .local v0, "c":Landroid/database/Cursor;
    invoke-static {v0}, Lde/lotum/whatsinthefoto/storage/database/DatabaseAdapter;->consumeSingleBonusPuzzle(Landroid/database/Cursor;)Lde/lotum/whatsinthefoto/entity/BonusPuzzle;
    move-result-object v1
    return-object v1

    :cond_0
    # Requête SQL modifiée
    const-string v2, "SELECT * FROM BonusPuzzle WHERE isSolved = 0 ORDER BY DATE ASC"
    # Saut inconditionnel
    goto :goto_0
.end method

Corriger la mise à jour des puzzles

DatabaseAdapter et AppStartDb contiennent les fonctions responsables du déclenchement des mises à jour des puzzles bonus. Les fonctions de AppStartDb sont manifestement déclenchées lorsque l'application est lancée après avoir été arrêtée ou après redémarrage du téléphone.

/* DatabaseAdapter */
public boolean isBonusPuzzleImportNeeded() {
    // Retourne true si le puzzle retourné par dailyPuzzleManager est null ou si il y a moins de 5 puzzles jouables en bdd
    return !dailyPuzzleManager().hasPuzzle() || playableBonusPuzzleCount() < 5;
}
public int playableBonusPuzzleCount() {
    // Retourne le nombre de puzzle jouables à partir de la date courante
    return querySingleInt("SELECT COUNT(*) FROM BonusPuzzle WHERE date >= ?", new String[] {
        BonusDateIdentifier.createForToday()
    });
}

/* AppStartDb */
public boolean isBonusImportNeeded() {
    return isBonusPuzzleImportNeeded() || !existsCurrentEvent();
}

public final boolean isBonusPuzzleImportNeeded() {
    if(playableBonusPuzzleCount() < 5)
        return true;
    Object obj = BonusDateIdentifier.createForToday();
    Intrinsics.checkExpressionValueIsNotNull(obj, "BonusDateIdentifier.createForToday()");
    obj = new BonusPuzzleManager(findBonusPuzzleByDate(((String) (obj))), (IFileAccess)AppFileAccess.INSTANCE, (ICrashLog)CrashlyticsLog.INSTANCE);
    boolean flag;
    if(!((BonusPuzzleManager) (obj)).hasPuzzle() || !((BonusPuzzleManager) (obj)).hasLocalPictures())
        flag = true;
    else
        flag = false;
    return flag;
}

Résultat, nous devons modifier findBonusPuzzleByDate() de AppStartDb d'une manière semblable à celle vue précédemment pour DatabaseAdapter et modifier playableBonusPuzzleCount() dans les 2 fichiers pour la rendre indépendante de la date courante :

La fonction playableBonusPuzzleCount() de DatabaseAdapter :

.local v0, "today":Ljava/lang/String;
const-string v1, "SELECT COUNT(*) FROM BonusPuzzle WHERE date >= ?"
const/4 v2, 0x1
new-array v2, v2, [Ljava/lang/String;
const/4 v3, 0x0
aput-object v0, v2, v3
invoke-virtual {p0, v1, v2}, Lde/lotum/whatsinthefoto/storage/database/DatabaseAdapter;->querySingleInt(Ljava/lang/String;[Ljava/lang/String;)I

Devient :

const-string v1, "SELECT COUNT(*) FROM BonusPuzzle WHERE isSolved = 0"
const/4 v2, 0x0
new-array v2, v2, [Ljava/lang/String;
invoke-virtual {p0, v1, v2}, Lde/lotum/whatsinthefoto/storage/database/DatabaseAdapter;->querySingleInt(Ljava/lang/String;[Ljava/lang/String;)I

Mettre à jour les puzzles journaliers

N'avoir qu'une avance de 5 puzzles sur la date courante est assez contraignant. Par ailleurs l'application est conçue pour écraser les puzzles déjà en bdd lors d'une mise à jour (même s'ils sont déjà résolus).

Nous pouvons modifier le nombre de puzzles ajoutés à la bdd lors d'une mise à jour, puis préserver le statut de résolution des puzzles et si besoin n'effacer que les puzzles résolus en laissant ceux qui ne le sont pas.

Allons dans smali/de/lotum/whatsinthefoto/storage/database/DailyPuzzleImporter où l'on trouve une assez longue fonction nommée importAsync() :

public final Deferred importAsync(Context context, int i) {

    ...

        public final Object doResume(Object obj, Throwable throwable) {
                ...
                BonusPuzzle bonuspuzzle;
                for(Iterator iterator = SequencesKt.sortedWith(SequencesKt.filter(onlinePuzzles(), (Function1)new Function1(this) {

            public volatile Object invoke(Object obj) = {
                return Boolean.valueOf(invoke((BonusPuzzle)obj));
            }

            public final boolean invoke(BonusPuzzle bonuspuzzle) {
                ...
                return isWithinNextDays(bonuspuzzle, dayCount) && needsUpdate(bonuspuzzle);
            }
            ...

        }), (Comparator)new Comparator() {

            public final int compare(Object obj, Object obj1) {
                return ComparisonsKt.compareValues((Comparable)((BonusPuzzle)obj).getDate(), (Comparable)((BonusPuzzle)obj1).getDate());
            }

        }).iterator(); iterator.hasNext(); throwable.add(Integer.valueOf(bonuspuzzle.getId())))
                {
                    bonuspuzzle = (BonusPuzzle)iterator.next();
                    DailyPuzzleImporter dailypuzzleimporter = DailyPuzzleImporter.this;
                    ...
                    dailypuzzleimporter.downloadImages(bonuspuzzle, ((Context) (obj)));
                    addToDatabase(bonuspuzzle);
                }

                deleteOldPuzzles();
                return Unit.INSTANCE;
        ...

On voit que les puzzles sont téléchargés par la fonction onlinePuzzles() (vue dans le chapitre suivant), filtrés par needsUpdate() et isWithinNextDays(), vérifiant respectivement l'équivalence entre les puzzles en bdd et ceux qui viennent d'être téléchargés, et si les dates sont comprises dans les dayCount jours suivants. Après cela les puzzles sont ajoutés par addToDatabase(), et la bdd est nettoyée par deleteOldPuzzles().


isWithinNextDays()
private final boolean isWithinNextDays(BonusPuzzle bonuspuzzle, int i) {
    // Retourne true si la date du puzzle est comprise entre la date du jour et i jours
    String s1 = BonusDateIdentifier.createForToday();
    String s = BonusDateIdentifier.createForTodayPlus(i);
    String s2 = bonuspuzzle.getDate();
    Intrinsics.checkExpressionValueIsNotNull(s1, "start");
    if(s2.compareTo(s1) >= 0)
    {
        bonuspuzzle = bonuspuzzle.getDate();
        Intrinsics.checkExpressionValueIsNotNull(s, "end");
        if(bonuspuzzle.compareTo(s) < 0)
            return true;
    }
    return false;
}

Essayons un intervalle compris entre -i et i jours :

String s1 = BonusDateIdentifier.createForTodayPlus(-1 * i);

Code original :

.param p2, "days"    # I
.prologue
.line 213
invoke-static {}, Lde/lotum/whatsinthefoto/util/time/BonusDateIdentifier;->createForToday()Ljava/lang/String;
move-result-object v1

.line 214
.local v1, "start":Ljava/lang/String;
invoke-static {p2}, Lde/lotum/whatsinthefoto/util/time/BonusDateIdentifier;->createForTodayPlus(I)Ljava/lang/String;
move-result-object v0

Devient :

.line 213
# Multiplions le nombre v0 par -1 pour avoir -v0
const v0, -0x1
mul-int v0,v0,p2
# Remplaçons createForToday() par createForTodayPlus() appelée avec le nouvel argument négatif
invoke-static {v0}, Lde/lotum/whatsinthefoto/util/time/BonusDateIdentifier;->createForTodayPlus(I)Ljava/lang/String;
...


addToDatabase()

La fonction needsUpdate() est censée filtrer les puzzles ne nécessitant pas de mise à jour (tous les attributs téléchargés devant être identiques aux attributs en bdd) mais je n'ai pas réussi à la modifier :

private final boolean needsUpdate(BonusPuzzle bonuspuzzle) {
    // Avant:
    // Object obj = database.findBonusPuzzleByDate(bonuspuzzle.getDate());

    // findBonusPuzzleByDate() a été modifiée et n'est plus capable de retourner un puzzle selon
    // une date spécifique. L'idée serait d'avoir ceci:
    Object obj = database.findBonusPuzzleById(bonuspuzzle.getId());

    if(obj != null)
    ...

La fonction addToDatabase() vérifie si le puzzle est en base de données mais écrase tous les champs d'un puzzle. Or nous voulons conserver le champ isSolved :

private final long addToDatabase(BonusPuzzle bonuspuzzle) {
    ContentValues contentvalues = DatabaseAdapter.createBonusPuzzleContentValues(bonuspuzzle);
    if(database.existsBonusPuzzle(bonuspuzzle.getDate()))
    {
        // Modification ici : suppression de l'écrasement de l'attribut isSolved:
        contentvalues.remove("isSolved");
        ...
        database.update("BonusPuzzle", contentvalues, "date = ?", new String[] {
            bonuspuzzle.getDate()
        });
    } else
    {
        ...
        database.insert("BonusPuzzle", contentvalues, 4);
    }
    return (long)bonuspuzzle.getId();
}

Le code suivant :

invoke-virtual {v1, v2}, Lde/lotum/whatsinthefoto/storage/database/DatabaseAdapter;->existsBonusPuzzle(Ljava/lang/String;)Z
move-result v1

if-eqz v1, :cond_0

.line 134
const-string v1, "DailyPuzzleImporter"

Devient :

invoke-virtual {v1, v2}, Lde/lotum/whatsinthefoto/storage/database/DatabaseAdapter;->existsBonusPuzzle(Ljava/lang/String;)Z
move-result v1

if-eqz v1, :cond_0
const-string v1, "isSolved"
invoke-virtual {v0, v1}, Landroid/content/ContentValues;->remove(Ljava/lang/String;)V

.line 134
const-string v1, "DailyPuzzleImporter"


deleteOldPuzzles()

Cette fonction requête les puzzles devant être effacés via oldPuzzles() et les supprime de la bdd ainsi que leurs images. Remplaçons juste la requête SQL.

-- SELECT * FROM BonusPuzzle WHERE date < \'
-- devient:
SELECT * FROM BonusPuzzle WHERE isSolved = 1 AND date < \'

Un faux serveur de mise à jour

Hommage au niveau de corruption frisant la science-fiction de certains politiques français


Dans smali/de/lotum/whatsinthefoto/storage/database/DailyPuzzleImporter, vous trouverez ces deux fonctions chargées du téléchargement des nouveaux challenges et images :

private final Sequence onlinePuzzles() {
    ...
    Object aobj[] = new Object[1];
    aobj[0] = language;
    obj = String.format(((Locale) (obj)), "http://4p1w.lotum.de/bonuschallenges/bonusChallenges-%s.json", Arrays.copyOf(aobj, aobj.length));
    Intrinsics.checkExpressionValueIsNotNull(obj, "java.lang.String.format(locale, this, *args)");
    return puzzlesFromJsonStream(byteStream(((String) (obj))));
    ...
}

private final void downloadImages(BonusPuzzle bonuspuzzle, Context context) {
    ...
    bject aobj[] = new Object[3];
    aobj[0] = language;
    aobj[1] = Integer.valueOf(bonuspuzzle.getId());
    aobj[2] = Integer.valueOf(i + 1);
    context = String.format(context, "http://4p1w-images.lotum.de/%s/_%d_%d.jpg", Arrays.copyOf(aobj, aobj.length));
    ...

Nous apprenons donc que :

  • L'url de téléchargement des puzzles est du type http://4p1w.lotum.de/bonuschallenges/bonusChallenges-<langue>.json,
  • L'url de téléchargement d'une image d'un puzzle ayant l'id id_puzzle est du type http://4p1w-images.lotum.de/<langue>/_<id_puzzle>_<numéro_image>.jpg


Imaginons un DNS poisoning redirigeant les domaines 4p1w-images.lotum.de et 4p1w.lotum.de vers un serveur web hébergeant ceci :

bonuschallenges/
└── bonusChallenges-fr.json
fr
├── _891234567_1.jpg
├── _891234567_2.jpg
├── _891234567_3.jpg
└── _891234567_4.jpg

Avec bonusChallenges-fr.json contenant le code JSON minimal suivant:

[{
    "id":"891234567",
    "solution":"RELIGION",
    "date":"2018-02-31",
    "copyright1":"",
    "copyright2":"",
    "copyright3":"",
    "copyright4":"",
    "description1":"",
    "description2":"",
    "description3":"",
    "description4":""
}]

Nous obtenons alors en jeu le puzzle suivant :

Isaac Newton, la NASA, Galilée, Copernic vus par Donald Trump
(ou tout complotiste qui se respecte)

Divers

La suppression des publicités en activant le mode premium n'empêche pas l'application de dialoguer avec les régies publicitaires comme en témoigne une courte analyse des paquets sur le réseau. Certaines requêtes sont non sécurisées et envoient beaucoup d'informations sur votre matériel.

Fuites d'informations : Identifiant de l'appareil, IMEI, nom de l'appareil, version du système, etc.

Les requêtes DNS trahissent le fonctionnement des librairies publicitaires en mode premium.

Le HTTPS devrait pourtant être une pratique généralisée car cette norme limite un peu les fuites d'informations. La moindre information qui filtre sur le réseau peut être utilisée contre le possesseur du matériel.


Si vous crackez l'application, vous perdrez vos sauvegardes de l'application officielle (la signature faite avec jarsigner est différente). Vous trouverez les fichiers de sauvegarde dans le dossier de l'application /data/data/de.lotum.whatsinthefoto.fr :

  • Pièces, progression : ./databases/4Pics1Word.db
  • Id unique, et niveau pour le multijoueur : ./shared_prefs/user.xml, ./shared_prefs/userToken.xml et ./shared_prefs/duel.xml

Sources