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:
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 liens | format d'entrée(s) | format de sortie | remarques |
---|---|---|---|
dex2jar | .apk | .jar | L'archive jar est un zip contenant les .class |
jad | .class | .java | A priori plus maintenu mais fait encore le travail |
jadx | .dex, .apk, .jar, .class | .java | |
apktool | .apk | .smali | 1 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 :
- http://androidcracking.blogspot.fr/2010/09/examplesmali.html
- http://androidcracking.blogspot.fr/2011/01/example-structuressmali.html
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()
renvoietrue
oufalse
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 ?
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 joker | Coût en pièces | Valeur hexadécimale |
---|---|---|
REMOVE | 80 | 0x50 |
REVEAL | 60 | 0x3c |
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
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 :
(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.
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