With students of mine, we built a static unpacker (short of public name, I named it “JsonPacker” in APKiD). Unfortunately, the static unpacker doesn’t work for the sample and the students are in for a quick patch before their defense in a few days. Lol. I’m sure they hate me 😁 The sha256 of the sample is 2877b27f1b6c7db466351618dda4f05d6a15e9a26028f3fc064fa144ec3a1850
, and it dates back to February 2022.
Quickly spot the encrypted json filename in the code
There are many classes, and obfuscated names, so at first it could be a bit disorientating to find the right spot. But I’ve unpacked such samples dozens of time: just search for the class with attachBaseContext
(which is a method found in classes which derive from Application
and which is called at “the very beginning”).
In there, head to the object fields which get their initial value in the constructor. Spot the json file hq.json
.
Spot the place where the file is dynamically loaded
For such samples, just look where DexClassLoader
is used. I like to use the detailed report of DroidLysis for that.
Go to that class, and search for DexClassLoader
, you find method rigidmiddle
.
Now, work your way up to who calls this method, using cross-references:
tailcreek
guessextra
aerobicneutral
attachBaseContext
: the call toaeronicneutral
is at the end of the image below.
Spot where payload decryption occurs
loadover
constructs the absolute path of the filename.ceilingnice
decrypts the fileaerobicneutral
loads the decrypted file.
In ceilingnice
, let’s follow the calls to the decryption algorithm:
allrather
orcharddecide
Method orcharddecide
loads the asset:
Then it reads the asset, decrypts it (this happens inside futureinherit
), unzips the result and writes it to the output stream.
futureinherit
calls ratherbanana
. It takes an encrypted byte array as input and returns a decrypted byte array.
Understanding the preparation of the key
ratherbanana
reads a fixed string (“Ianj” for this sample), and I assume it is the key. It converts the string to a byte array, then converts it to an integer array in nomineesign
.
The code of nomineesign
is not very long but requires close attention to remove junk code (but not too much: the loop initializing the convertedkey
table is not junk!), and de-obfuscate code.
For example, the lign with hypot
is interesting:
int cv = (int)StrictMath.hypot(this.timeone('d', 0x1E681L, convertedkey, index), 0.0);
timeone
actually returns the index-th byte of convertedkey
, i.e convertedkey[index]
.
hypot
computes square root of ( convertedkey[index]² + 0² ). As 0² = 0, the variable cv
will simply receive the value of convertedkey[index]
.
In the end, the algorithm boils down to this:
private void swap(int a, int b, int[] array) {
int tmp = array[a];
array[a]=array[b];
array[b]=tmp;
}
private int[] convert_key(byte[] key) {
int[] convertedkey = new int[0x100];
int i;
for(i = 0; i < 256; ++i) {
convertedkey[i] = i; // init
}
int j = 0;
int k = 0;
while(j < 0x100) {
int cv = convertedkey[j];
k = (k+cv+key[j%key.length]+0x100) % 0x100;
swap(j, k, convertedkey); // swap values
++j;
}
return convertedkey;
}
Decryption algorithm
The next step is to understand the decryption algorithm in itself. Actually, there is lots of junk code that can be removed. To start, I focus on where the encrypted input byte array is used.
decrypted[i] = this.motionavoid(Math.round(v0_6) ^ encrypted[i]);
The method motionavoid is there just for obfuscation: it merely returns its argument. Also, obviously, we only have integers, so Math.round is useless. So, we have decrypted[i] = v0_6 ^ encrypted[i];
. A few lines above, we have v0_6
: int v0_6 = ckey[(v15 + v0_5) % 0x100];
. A few lines above, we have v15
and v0_5
:
int v15 = this.timeone('b', 5222L, ckey, HMoEsEkXySsLhTyCkZlChSoBfFlPk.counter); // ckey[counter]
this.GfnxRHLRQuDY_713808 = this.KfYicpzIQMgk_598597 * 0x12FC3 + this.RMSmhfBNuxnA_506561 - 50009; // junk
int v0_5 = this.timeone('z', 0x179161L, ckey, v14); // ckey[v14]
The method timeone
only uses the last two arguments: a table and an index, and returns table[index]
value. Quite strangely, v15 uses a static integer that I have renamed counter
. I search where this counter is used:
I work out that int v15 = ckey[counter];
.
As for v0_5
, we have v0_5 = ckey[v14]
and v14
is yet another static counter: int v14 = HMoEsEkXySsLhTyCkZlChSoBfFlPk.other_counter;
. Same, I search where this other counter is used, and it’s basically the same: an increment modulus 0x100, a swap and ckey[other_counter]
. That’s it! We have all elements to decrypt! The algorithm boils down to this:
Note: the code above uses static variables counter and other_counter, but actually it works fine with local variables, and probably would be easier to read with local ones.
Decrypt the payload
To the key + decryption algorithm, we just need to add something to read hq.json
and write to another file. Then, we can unpack!
The decrypted file is a Zip file (this was expected: remember that orcharddecide
unzips the result): inside, there is a classes.dex
(sha256: dae52bbee7f709fae9d91e06229c35b46d4559677f26152d4327fc1601d181be
). It is the payload of the Xenomorph malware.
Which class/method does the malware load dynamically?
Before we decompile this payload, we need to know which method is called. The manifest shows the main activity is com.sniff.sibling.MainActivity
. This class is not present in the wrapping apk… so it must be in the payload! This will be automatically called by Android as soon as it’s time to launch the main activity.
We’ve had enough for a single blog post, but the payload, similarly to many Android botnets, uses the Accessibility Services API to overlay windows of given applications.
— Cryptax