Eléments notables pour l’analyse d’échantillons Hydra / BianLian

Introduction

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 :

Pure rétroconception

Reconnaissance

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);
}

Surmonter l’obfuscation des chaînes

Toutes ces fonctions partagent plusieurs points communs :

  • aucun argument n’est fourni par l’appelant ;
  • beaucoup de code mort peut être retiré ;
  • touts les fonctions suivent la même organisation de code.

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
[...]

Surmonter l’obfuscation du code

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.

Déchiffrement du niveau suivant

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

Portions intéressantes

Pister des C2

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é.

Endpoints courants

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

Un soin particulier pour des gens particulier

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.

IOC

Adresse IP

79.110.62.198

Files

106ee77ee0766d14d608cbecaf224cb78df96798da80019c663658497155bd4d

576be33dbbd61ad2643304adcf4e2240e689a6b24641a1882d892bb71ad3d5c6