# I Found the Live C2 Server Controlling Thousands of Infected Devices

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:

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/09cad221-bd74-4c16-b92a-ffbca876a388.png align="center")

`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`](https://github.com/MegatronKing/StringFog-Sample1), 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:

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

```json
{
  "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)

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/8e196a05-6e1d-44ec-9ddc-8b8c8b1cc606.png align="center")

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."

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/37824cd3-e168-41b3-a621-4e6db4632f47.png align="center")

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.

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/3e07ef0a-bead-4bd3-8540-147d2f6a917b.png align="center")

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.

`SMSRetriever`is 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:

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

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/643a0e18-1cc7-456f-b76d-3a4e2fc65ed1.png align="center")

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.

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

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

```json
{
  "dateTime": true,
  "id": true,
  "message": true,
  "sender": true,
  "type": true
}
```

```json
{
  "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

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

```plaintext
# 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](https://cybercrime.gov.in).
