Android Malware Analysis : Dissecting Hydra Dropper

Hydra is another android bankbot variant. It uses overlay to steal information like Anubis . Its name comes from command and control panel. Through July 2018 to March 2019 there was atleast 8-10 sample on Google Play Store. Distribution of malware is similar to Anubis cases. Dropper apps are uploaded to Play Store. But unlike Anubis, Dropper apps extract dex file from png file with kinda stenography and downloads malicious app from command and control server with dropped dex. You can find the sample that I will go through in this post here : Dropper

ToC:

  • Bypass checks that on the java side
  • GDB Debug
  • Ghidra shenanigans
  • Understanding creation of the dex file
  • Bonus

First of all, if the dropper app likes the environment it runs, it will load the dex file and connect to the command and control server. There are multiple checks on java and native side. We will debug the native side with gdb and use ghidra to help us to find checks and important functions.

Time Check

When we open the first app with jadx we can see time check in class com.taxationtex.giristexation.qes.Hdvhepuwy.

public static boolean j() {
        return new Date().getTime() >= 1553655180000L && new Date().getTime() <= 1554519180000L;
}

This function called in another class : com.taxationtex.giristexation.qes.Sctdsqres

class Sctdsqres {
    private static boolean L = false;
    private static native  void fyndmmn(Object obj);
    Sctdsqres() {
    }
    static void j() {
        if (Hdvhepuwy.j()) {
            H();
        }
    }
    static void H() {
        if (!L) {
            System.loadLibrary("hoter");
            L = true;
        }
        fyndmmn(Hdvhepuwy.j());
    }
}

First, it checks the time and if the condition holds, the app will load the native library and call fyndmmn(Hdvhepuwy.j()); which is native function. We need to bypass this check so app will always load the library.

I used apktool to disassemble apk to smali and changed j() to always return true.

  • apktool d com.taxationtex.giristexation.apk
  • cd com.taxationtex.giristexation/smali/com/taxationtext/giristexation/qes
  • edit j()Z in Hdvhepeuwy.smali
.method public static j()Z
    .locals 1
    const/4 v0, 0x1
    return v0
.end method

rebuild apk with apktool b com.taxationtex.giristexation -o hydra_time.apk and sign it.

Now time control will always return true and after loading native library and fyndmmn native function is called. Even with this still app doesn’t load dex file.

GDB Debug

Here is a great post explaining how to setup gdb to debug native libraries. Steps:

  • Download android sdk with ndk
  • adb push ~android-ndk-r20/prebuilt/android-TARGET-ARCH/gdbserver/gdbserver /data/local/tmp
  • adb shell “chmod 777 /data/local/tmp/gdbserver”
  • adb shell “ls -l /data/local/tmp/gdbserver”
  • get process id, ps -A | grep com.tax
  • /data/local/tmp/gdbserver :1337 –attach $pid
  • adb forward tcp:1337 tcp:1337
  • gdb
  • target remote :1337
  • b Java_com_tax\TAB

There is a small problem here. App will load the library and call the native function and exit. The app needs to wait for gdb connection. My first thought was putting sleep and then connect with gdb.

  • apktool d hydra_time.apk
  • vim hydra_time/com.taxationtex.giristexation/smali/com/taxationtex/giristexation/qes/Sctdsqres.smali

after following block:

.line 43
:cond_0

Add

const-wide/32 v0, 0xea60
invoke-static {v0, v1}, Landroid/os/SystemClock;->sleep(J)V

and since locals variable is 1 and we use an extra v1 variable, increment it to 2

.method static H()V
    .locals 2

Again sign and install the app. If all goes well the app will wait 60 seconds in a white screen. Now we can connect with gdb.

ps | grep com.tax
/data/local/tmp/gdbserver :1337 --attach $pid

I use pwndbg for better gdb experience, you can try peda or whatever you want.

  • adb forward tcp:1337 tcp:1337
  • gdb
  • target remote :1337
debug session

It takes some time to load all libraries. Put breakpoint to native function fymdmmn

set breakpoint

If you want to sync gdb and ghidra addresses, type vmmap at gdb and look for first entry of libhoter.so .
0xe73be000 0xe73fc000 r-xp 3e000 0 /data/app/com.taxationtex.giristexation-1/lib/x86/libhoter.so
So 0xe73be000 is my base address.
Go to Window-> Memory Map and press Home icon on the upper right. Put your base address and rebase the binary.

Look at the entry of native function in ghdira:

fyndmmn function

Why call the time function ? Again time check ? Rename return value of time function (curr_time) and press ctrl+shift+f from assembly view and go to location that context is READ

return (uint)(curr_time + 0xa3651a74U < 0xd2f00) 

So we were right, again time check. Rename the current function to check_time. Calculate the epoch time:

>>> 0xffffffff-0xa3651a74+0xd2f00
>>> 1554519179
>>> (1554519179+ 0xa3651a74) & 0xffffffff < 0xd2f00
>>> True

convert epoch to time : Saturday, April 6, 2019 2:52:59 AM
Yep this was the time that app was on play store. Check how this boolean is used. Look for xrefs of check_time function.

Yep, as we think it will exit if time doesn’t hold.
First breakpoint/binary patch point is here. Or we can change emulator/phone’s time to April 5 2019.
b *(base + 0x8ba8)
But bypassing time check is not enough.

Ghidra Shenanigans

Now diving into binary file you will find multiple functions like this :

decryption blocks

If you look at while loop.

xor while loop

2 blocks of data are XORed. ( Length 0x18) We can put breakpoint after do while but it will not be efficient solution. Let’s think a programmatic way to find decrypted strings.
These xor blocks are next to each other. If we can get length of blocks we can easily get decrypted string. Then find the function that use these xor blocks and rename it. Afterwards we can jump 2*length and get next xor blocks. Repeat.
Starting xor block is at 0x34035.
Get xrefs of block:

xor block

go to function,

get cmp value

get size from CMP instruction, since we know the address of first xor block, add size to first address and get the address of second xor block. XOR the blocks and rename the calling function.

Ghidra : go to Window -> Script Manager -> Create New Script -> Python.
Set name for script and let’s write our ghidra script.

import ghidra.app.script.GhidraScript
import exceptions
from ghidra.program.model.address import AddressOutOfBoundsException
from ghidra.program.model.symbol import SourceType

def xor_block(addr,size):

	## get byte list
	first_block = getBytes(toAddr(addr),size).tolist()
	second_block = getBytes(toAddr(addr+size),size).tolist()

	a = ""
	## decrypt the block
	for i in range(len(first_block)):
		a += chr(first_block[i]^second_block[i])
        ## each string have trash value at the end, delete it
	trash = len("someval")
	return a[:-trash]
    
def block(addr):
   ## block that related to creation of dex file. pass itt
	if addr == 0x34755:
		return 0x0003494f
	## get xrefs
	xrefs = getReferencesTo(toAddr(addr))
	if len(xrefs) ==0:
		## no xrefs go to next byte
		return addr+1
		
	for xref in xrefs:
		ref_addr = xref.getFromAddress()
		try:
			inst = getInstructionAt(ref_addr.add(32))
		except AddressOutOfBoundsException as e:
			print("Found last xor block exiting..")
			exit()
            
    ## Get size of block with inst.getByte(2)
		block_size = inst.getByte(2)
    ## decrypt blocks
		dec_str = xor_block(addr,block_size)
    ## get function
		func = getFunctionBefore(ref_addr)
		new_name = "dec_"+dec_str[:-1]
    ## rename the function
		func.setName(new_name,SourceType.USER_DEFINED)
    ## log
		print("Block : {} , func : {}, dec string : {}".format(hex(addr),func.getEntryPoint(),dec_str))

	return addr+2*block_size

def extract_encrypted_str():

	## starting block
	curr_block_location = 0x34035
	for i in range(200):
		curr_block_location = block(curr_block_location)

def run():
	extract_encrypted_str()

run()

To run the script, select created script in Script Manager and press Run.
Now look at the output.

ghidra script output

As you can see there are functions : getSimCountryISO, getNetworkCountryIso, getCountry and one suspicious string : tr. Without running we can assume code will check if these function’s return values are equals to tr. I know this app targets Turkish people so this is reasonable to avoid sandbox and even manual analyze.
If you follow from these functions’ xrefs to function FUN_00018A90() (called after time check) you can see this block :

country check

So next patch/breakpoint is this check :
b *(base + 0x8c80)
After these checks code will drop dex and load it. If you run without patch/breakpoints only edevlet page is shown and nothing happens. Get your base address and try bypassing checks :

b *(base + 0x8ba8)
b *(base + 0x8c80)
copy eip : .... a8 -> set $eip = .... aa
c
copy eip : .... 80 -> set $eip = .... 82
c

After these breakpoints, app will create dex file and load it. You will see Accessibility page pop-pup if you do it correctly.

checks bypassed

Or we can patch je instructions to jne in native library and build apk again.

Understanding creation of the dex file

If you look for dropped file in filesystem, you won’t see anything. File is removed with remove. We can attach frida and catch dropped file easily. But forget about it for now and find how png file is used to create dex file.

Look at the last parts of the ghidra script’s output.

ghidra script output

Somehow prcnbzqn.png is processed with AndroidBitmap and dex file is created with the name xwchfc.dex. Then with ClassLoader API dex file is loaded and moonlight.loader.sdk.SdkBuilder class is called.

Check function : 0xeec0

get png file from asset folder

Iterates over assets and finds png file. Good. Rename this function asset_caller. Go to xrefs of this func and find 0xe2c0. I renamed some of functions. dex_header creates dex file on memory. dex_dropper drops dex file to system and loads.

hierarchy of functions

How dex_header creates dex file ? Go to function definition.

dex creator function

bitmap_related creates bitmap from png file. Bitmap object is passed to dex_related function. Bitmap ?

If you read png file byte byte you don’t get color codes of pixels directly. You need to convert it to bitmap. So app first transfer png file to bitmap and read hex values of pixels. Fire up gimp/paint and look at the hex codes of first pixel of the image and compare with below picture 🙂

rgb values of pixels

Now comes fun part. How these values are used. At 0xfbf0you can find dex_related function.

Bitmap object is passed to this function. Now there are 2 important functions here:

two important function

byte_chooser will return one byte and dex_extractor will use that byte to get final dex bytes. 4_cmp variable is set to 0 at the beginning and will set to 0 at the end of else block. So flow will hit byte_chooser 2 times before entering dex_extractor. Here is byte_chooser

byte chooser function

param_3 is hex codes of pixels. param_2 is like seed. If its first call of byte_chooser it is set to 0. In second call of byte_chooser, param_2 will be return value of first call and left shifted by 4. Then its set to 0 at the end of else block.

After calculating the byte by calling byte_chooser twice, return value is passed to dex_extractor.

dex byte calculator function

param_2 is calculated byte param_1 is index.

Now we know how the dex file is created. Let’s do it with python

from PIL import Image
import struct

image_file = "prcnbzqn.png"
so_file = "libhoter.so"
offset = 0x34755
size = 0x1fa
output_file = "drop.dex"


im = Image.open(image_file)
rgb_im = im.convert('RGB')
im_y = im.size[1]
im_x = im.size[0]

dex_size = im_y*im_x/2-255

f = open(so_file)
d = f.read()
d = d[offset:offset+size]

def create_magic(p1,p2,p3):
	return (p1<<2 &4 | p2 & 2 | p2 & 1 | p1 << 2 & 8 | p3)

def dex_extractor(p1,p2):
	return (p1/size)*size&0xffffff00| ord(d[p1%size]) ^ p2

count = 0
dex_file = open(output_file,"wb")
second = False
magic_byte = 0
for y in range(0,im.size[1]):
	for x in range(0,im.size[0]):
		r, g, b = rgb_im.getpixel((x, y))
		magic_byte = create_magic(r,b,magic_byte)
		if second:
			magic_byte = magic_byte & 0xff
			dex_byte = dex_extractor(count,magic_byte)
			dex_byte = dex_byte &0xff
			if count > 7 and count-8 < dex_size:
				dex_file.write(struct.pack("B",dex_byte))
			magic_byte = 0
			second = False
			count+=1
		else:
			magic_byte = magic_byte << 4
			second = True

dex_file.close()

Let’s look at the output file with jadx

dropped dex file

Remember moonlight from output of ghidra script ? Yep this looks correct.

Frida <3

Well I cant write an article without mentioning frida. Bypass checks with frida.

  • There are time checks on java and native side.
  • Country check
  • File is removed at native side.
var unlinkPtr = Module.findExportByName(null, 'unlink');
// remove bypass
Interceptor.replace(unlinkPtr, new NativeCallback( function (a){
     console.log("[+] Unlink : " +  Memory.readUtf8String(ptr(a)))

}, 'int', ['pointer']));

var timePtr = Module.findExportByName(null, 'time');
// time bypass
Interceptor.replace(timePtr, new NativeCallback( function (){
    console.log("[+] native time bypass : ")
    return 1554519179
},'long', ['long']));

Java.perform(function() {
    var f = Java.use("android.telephony.TelephonyManager")
    var t = Java.use('java.util.Date')
    //country bypass
    f.getSimCountryIso.overload().implementation = function(){
        console.log("Changing country from " + this.getSimCountryIso() + " to tr ")
        return "tr"
    }
    t.getTime.implementation = function(){
    console.log("[+] Java date bypass ")
    return 1554519179000 
    }
 })
output of frida session

Pull the dex file with adb pull path/xwcnhfc.dex.

Homework

This part is homework for reader 🙂 Next version of this malware only use native arm binaries. So we can’t easily debug without having arm based device. But we can use our dex dropper python script. Malware sample. Load the arm binary to ghidra. Find the correct offset of the dex data block and the size of the block. dex_extractor function might look different but it does the same thing. So you need to only change the name of the files, offset and size variables at the python script. Hash of dropped dex file : 7ff02fb46009fc96c139c48c28fb61904cc3de60482663631272396c6c6c32ec

Conclusion

We attached gdb to debug native code and found certain checks. Wrote a ghidra script to automate decryption of strings and frida script to bypass checks. Also learned that png files needs to be converted with Bitmap to get pixel values. So next time you see png file and suspicious app, look for bitmap calls 😉

References

GDB Debug : https://packmad.github.io/gdb-android/
Featured image : https://www.deviantart.com/velinov/art/Hydra-monster-144496963