Android malware - Part 1: Fake VPN dropper

TL;DR

Analysis of an Android dropper distributed as a fake VPN app

  • Multi stage malware
  • Use of obfuscation techniques
  • Full static analysis (because I <3 it)

Sample Identification

The sample was downloaded from MalwareBazaar, and analysis began the same day (August 6, 2026).

Decompilation

Using apktool to decompile the APK:

1
2
3
4
5
6
7
8
9
10
11
❯ file d5b948179c8cc33e4cbeebe0369c7dca452d968646a588d730341c3879d64880.apk
d5b948179c8cc33e4cbeebe0369c7dca452d968646a588d730341c3879d64880.apk: Zip archive data, at least v2.0 to extract, compression method=deflate
❯ apktool d d5b948179c8cc33e4cbeebe0369c7dca452d968646a588d730341c3879d64880.apk
Exception in thread "main" com.android.tools.smali.dexlib2.dexbacked.ZipDexContainer$NotAZipFileException
at com.android.tools.smali.dexlib2.dexbacked.ZipDexContainer.getZipFile(ZipDexContainer.java:178)
at com.android.tools.smali.dexlib2.dexbacked.ZipDexContainer.getEntries(ZipDexContainer.java:90)
at com.android.tools.smali.dexlib2.dexbacked.ZipDexContainer.getEntry(ZipDexContainer.java:125)
at brut.androlib.smali.SmaliDecoder.<init>(SmaliDecoder.java:46)
at brut.androlib.ApkDecoder.decode(ApkDecoder.java:74)
at brut.apktool.Main.cmdDecode(Main.java:523)
at brut.apktool.Main.main(Main.java:320)

First red flag: apktool crashes immediately with a NotAZipFileException, no legitimate app has any reason to break a standard ZIP parser this hard.

Same behavior using jadx-gui…

Investigating the ZIP file reveals that bit 0 of the general purpose flag — the “encrypted” flag — is set on the file entries:

1
2
3
4
5
6
>>> import zipfile
>>> z = zipfile.ZipFile("sample.apk")
>>> info = z.getinfo("AndroidManifest.xml")
>>> hex(info.flag_bits)
'0x801'
>>>

According to the ZIP specification, this flag indicates an “encrypted file”. However, this is not an encrypted ZIP file — the archive was intentionally modified to trick tools such as apktool.

Another weird thing: a lot of “fake files” are present, which sounds like BadPack obfuscation (see Unit42’s BadPack writeup).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
research/malwarze/android 
❯ zipinfo d5b948179c8cc33e4cbeebe0369c7dca452d968646a588d730341c3879d64880.apk|more
Archive: d5b948179c8cc33e4cbeebe0369c7dca452d968646a588d730341c3879d64880.apk
Zip file size: 8023361 bytes, number of entries: 372
-rw-r--r-- 2.0 fat 1719 B- defN 81-Jan-01 01:01 DebugProbesKt.bin
-rw-r--r-- 2.0 fat 54 B- defN 81-Jan-01 01:01 META-INF/services/r8
-rw-r--r-- 2.0 fat 52 B- defN 81-Jan-01 01:01 META-INF/services/yg
-rw-r--r-- 2.0 fat 1043 B- defN 81-Jan-01 01:01 assets/dexopt/baseline.prof
-rw-r--r-- 2.0 fat 205 B- defN 81-Jan-01 01:01 assets/dexopt/baseline.profm
-rw-r--r-- 2.0 fat 6585390 B- defN 81-Jan-01 01:01 assets/nvcgehin
-rw-r--r-- 2.0 fat 627 B- defN 81-Jan-01 01:01 kotlin-tooling-metadata.json
-rw-r--r-- 2.0 fat 381968 B- defN 81-Jan-01 01:01 lib/arm64-v8a/libhhcbcu.so
-rw-r--r-- 2.0 fat 236992 B- defN 81-Jan-01 01:01 lib/armeabi-v7a/libhhcbcu.so
-rw-r--r-- 2.0 fat 852 B- defN 81-Jan-01 01:01 AndroidManifest.xml/..xml
-rw-r--r-- 2.0 fat 508 B- defN 81-Jan-01 01:01 /AndroidManifest.xml///.xml
-rw-r--r-- 2.0 fat 1243 B- stor 81-Jan-01 01:01 resources.arsc////.9.png
-rw-r--r-- 2.0 fat 226 B- stor 81-Jan-01 01:01 /resources.arsc/////.9.png
-rw-r--r-- 2.0 fat 171 B- stor 81-Jan-01 01:01 classes.dex/\\\\.png
-rw-r--r-- 2.0 fat 167 B- stor 81-Jan-01 01:01 /classes.dex/\\\\\\.9.png
-rw-r--r-- 2.0 fat 258 B- stor 81-Jan-01 01:01 kotlin/\\/.9.png
-rw-r--r-- 2.0 fat 212 B- stor 81-Jan-01 01:01 META-INF/ .9.png
-rw-r--r-- 2.0 fat 208 B- stor 81-Jan-01 01:01 kotlin/annotation/ .9.png
-rw-r--r-- 2.0 fat 208 B- stor 81-Jan-01 01:01 kotlin/collections/classes.dex.9.png
-rw-r--r-- 2.0 fat 228 B- stor 81-Jan-01 01:01 kotlin/coroutines/AndroidManifest.xml.9.png
-rw-r--r-- 2.0 fat 229 B- stor 81-Jan-01 01:01 kotlin/internal/AndroidManifest.9.png
-rw-r--r-- 2.0 fat 738 B- stor 81-Jan-01 01:01 kotlin/ranges/resources.arsc.9.png
-rw-r--r-- 2.0 fat 1098 B- stor 81-Jan-01 01:01 kotlin/reflect/..9.png
-rw-r--r-- 2.0 fat 201 B- stor 81-Jan-01 01:01 res/values/arrays.xml///.png

The next goal is to extract the real data. One idea is to manually parse the central directory, but some tools already exist:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Malfixer [ main][🐍 v3.14.6]
❯ python3 zipfixer.py ~/research/malwarze/android/analysis/sample.apk
2026-08-06 19:54:36,619 - INFO - Starting ZIP analysis
2026-08-06 19:54:37,738 - WARNING - Found 3 malformed directories
2026-08-06 19:54:37,738 - WARNING - - AndroidManifest.xml/
2026-08-06 19:54:37,738 - WARNING - - classes.dex/
2026-08-06 19:54:37,738 - WARNING - - resources.arsc/
2026-08-06 19:54:37,738 - WARNING - Found 367 files with password malformation type 1
2026-08-06 19:54:37,738 - WARNING - Found 5 files with password malformation type 2

==================================================
MALFORMED ZIP/APK DETECTED
==================================================
2026-08-06 19:54:37,738 - INFO - Starting ZIP analysis
2026-08-06 19:54:38,928 - WARNING - Found 3 malformed directories
2026-08-06 19:54:38,928 - WARNING - - AndroidManifest.xml/
2026-08-06 19:54:38,928 - WARNING - - classes.dex/
2026-08-06 19:54:38,928 - WARNING - - resources.arsc/
2026-08-06 19:54:38,928 - WARNING - Found 367 files with password malformation type 1
2026-08-06 19:54:38,928 - WARNING - Found 5 files with password malformation type 2
2026-08-06 19:54:38,960 - INFO - Fixed malformed directories
2026-08-06 19:54:40,043 - INFO - Fixed password malformation type 1
2026-08-06 19:54:40,073 - INFO - Fixed password malformation type 2
2026-08-06 19:54:40,076 - INFO - Recovery completed successfully: /home/ghozt/research/malwarze/android/analysis/sample-fixed.apk

Recovery completed successfully!
Recovered file: /home/ghozt/research/malwarze/android/analysis/sample-fixed.apk

The AndroidManifest.xml is not fully well-formed, likely due to manual tampering with the AXML structure, same story, another tool-breaking trick, but its content is fully recoverable.

The permissions declared are worth a closer look:

1
2
3
4
5
6
7
8
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="com.nagu.nasu.kamo.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" />
<permission android:name="com.nagu.nasu.kamo.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"
android:protectionLevel="signature" />

This reveals a new IOC: “com.nagu.nasu.kamo”.

Dropper behavior

With the manifest and permissions in hand, the next step is looking at what the decompiled Java code actually does with them. Two classes stand out immediately: KtBs9YhCfDjN (the fake install screen) and
GrPu2MwZeAkX (the VPN service).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class KtBs9YhCfDjN extends j2 {
// Real PackageInstaller machinery
public volatile File preparedApkFile;
public volatile PackageInstaller.Session preparedSession;
public volatile int preparedSessionId;
public volatile Intent savedConfirmIntent;
public volatile boolean installStarted;
public boolean isLaunched;

// Fake progress UI
public float fakeInstallPct;
public final Runnable fakePrgRunnable;
private ProgressBar installProgressBar;
private TextView installStatusText;
private TextView failureMessage;
private View installerScreen;
private View loadingScreen;
private TextView installerAppName;
private ImageView installerAppLogo;
}

public class GrPu2MwZeAkX extends VpnService {
public static volatile GrPu2MwZeAkX instance;
public ParcelFileDescriptor d;

public static native void stopVpn();
public native void onCreate();
public native void onDestroy();
public native int onStartCommand(Intent intent, int i, int i2);
}

This seems to mirror the behavior described in this detailed CYFIRMA research on KYCShadow, though the disguise itself differs: KYCShadow poses as a KYC identity-verification flow, while this sample poses as a VPN app.

Footprint of the native library

Digging into the decompiled Java code shows the use of a JNI library (keyword native):

1
2
3
android/analysis/sample-fixed 
❯ file lib/armeabi-v7a/libhhcbcu.so
lib/armeabi-v7a/libhhcbcu.so: ELF 32-bit LSB shared object, ARM, EABI5 version 1 (SYSV), dynamically linked, for Android 21, built by NDK r27 (12077973), BuildID[sha1]=ca8cdcae7249e876a5a2b33efaae0ce672389263, stripped

Using the best (imho) reversing tool, some methods matching the Java native declarations appear:

A hardcoded list of 30 package names is embedded in the native library:

1
2
3
4
5
6
7
8
9
10
com.android.phone
com.android.mms
com.android.server.telecom
com.whatsapp
org.telegram.messenger
org.thoughtcrime.securesms (Signal)
com.viber.voip
jp.naver.line.android (LINE)
com.tencent.mm (WeChat)
com.truecaller

This kind of targeting — messaging apps rather than banking apps — recalls Sturnus, a recently documented Android banking trojan that specifically targets WhatsApp, Telegram, and Signal users.

Deep dive into the native library

With the Java code obfuscated and sabotaged, an intuitive reversing game begins.

An eye-catching symbol quickly appears: Java_com_qmvxhkjp_rnbtzwlc_Sec_nativeGetStr. After some renaming and structure creation, the function looks like this:

It performs a direct lookup into a 75-entry table of (pointer, length) pairs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
00048314  struct { char* ptr; uint32_t len; } data_48314[0x4b] = 
00048314 {
00048314 [0x00] =
00048314 {
00048314 char* ptr = 0x22c48
00048318 uint32_t len = 0x1b
0004831c }
0004831c [0x01] =
0004831c {
0004831c char* ptr = 0x22c63
00048320 uint32_t len = 0x20
00048324 }
00048324 [0x02] =
00048324 {
00048324 char* ptr = 0x22c83
00048328 uint32_t len = 0x20
0004832c }
....

In parallel, a simple Binary Ninja snippet is used to check for AES usage, via S-box detection.

Tracing cross-references to the AES S-box leads to the Sec_nativeGetStr function. The conclusion: this function decrypts strings using AES-CTR, with the id embedded as part of the counter block. The key is also easily identifiable:

1
2
00024f30  data_24f30:
00024f30 a2 e8 56 bf 49 52 52 a8 35 4b 1e ea 07 c0 c5 ef ..V.IRR.5K......

Running the snippet against the table yields the full set of decrypted strings:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[ScriptingProvider] 0 com.fsnnd.hfvfhsdhsn.dhbvdh
[ScriptingProvider] 1 C4B8F2197D5E03A6B1C9F47E2A8D60B3
[ScriptingProvider] 2 7E3A91B4C8D5026F19A4E7C2D8F0B6A1
[ScriptingProvider] 3 nvcgehin
[ScriptingProvider] 4 base_temp.apk
[ScriptingProvider] 5 com.qmvxhkjp.rnbtzwlc.INSTALL_COMPLETE
[ScriptingProvider] 6 com.qmvxhkjp.rnbtzwlc.PACKAGE_INSTALLED
[ScriptingProvider] 7 quick_mode
[ScriptingProvider] 8 package_name
[ScriptingProvider] 9 android.content.pm.extra.STATUS
[ScriptingProvider] 10 android.content.pm.extra.STATUS_MESSAGE
[ScriptingProvider] 11 file:///android_asset/main_ui.html
[ScriptingProvider] 12 package:
[ScriptingProvider] 13 https://play.google.com/store
...

Among the decrypted strings, one is particularly relevant: nvcgehin. Indeed, it was already spotted in the zipinfo output of the original APK:

1
2
3
research/malwarze/android 
❯ zipinfo d5b948179c8cc33e4cbeebe0369c7dca452d968646a588d730341c3879d64880.apk|grep nvcgehin
-rw-r--r-- 2.0 fat 6585390 B- defN 81-Jan-01 01:01 assets/nvcgehin

This blob was extracted from the ZIP repaired by Malfixer:

1
2
3
android/analysis/sample-fixed 
❯ file assets/nvcgehin
assets/nvcgehin: data

Unsurprisingly, this blob is also encrypted:

Entropy sits close to 8 bits/byte across the entire file, the signature of properly encrypted (or at least well-compressed) data, with no recognizable header or magic bytes anywhere.

Now, the idea is to check whether any code in the .so handles assets directly. This search does not show any relevant clues in the compiled library.

Given the blob’s high entropy, and of course knowing AES is already used elsewhere in this library, it’s worth checking whether the same routines are involved here too. Tracing cross-references to the AES S-box a second time reveals another function:

The nonce and the key can be spotted:

1
2
3
4
5
6

00025140 payload_key:
00025140 f5 09 0d 29 cc 4f 11 df 7b ca 1e 81 3d 68 60 0f ...).O..{...=h`.

000250a2 int32_t var_f4_1 = 0x6b71def9;
000250ac int32_t var_f8 = 0xb8938f83;

With the key and nonce in hand, nvcgehin can finally be decrypted:

1
2
3
4
5
6
7
8
9
android/analysis/scripts [🐍 v3.14.6]
❯ python3 decrypt_payload.py ../sample-fixed/assets/nvcgehin ../second-stage
taille: 6585390 octets, 411587 blocs AES
écrit: ../second-stage (6585390 octets)
premiers octets: 504b0304140008080800557d055d0000 (attendu: 504b0304... = PK..)

malwarze/android/analysis [C v16.1.1-gcc]
❯ file second-stage
second-stage: Android package (APK), with AndroidManifest.xml, with APK Signing Block

IOC

Searching for the sample’s hash on URLhaus reveals the original distribution vector:

Date added (UTC) Malware URL Status Tags Reporter
2026-08-04 16:29:13 https://actualiza-red5g.app/ClaroRed5G.apk Offline apk, encrypted, Obfuscapk arcvault

The domain and filename impersonate Claro, a major Latin American telecom carrier, the naming pattern strongly suggests a fake “5G network update” lure, matching the fake “Update Available” Play Store screen we found decrypted inside the .so library earlier. This seems to point to a campaign targeting Spanish-speaking users in Latin America.

Type Value
Dropper APK (SHA256) d5b948179c8cc33e4cbeebe0369c7dca452d968646a588d730341c3879d64880
Second stage APK (SHA256) 51768db3ebe06f51ab81805cc9ca3afb0ea9b7350d5ceb4bacacd1e769d0988f
Package name com.nagu.nasu.kamo
Native library libhhcbcu.so
AES key (config strings) a2e856bf495252a8354b1eea07c0c5ef
AES key (second-stage payload) f5090d29cc4f11df7bca1e813d68600f
target_package value dispatcher.schedulerbot.proxy
Custom broadcast actions com.qmvxhkjp.rnbtzwlc.INSTALL_COMPLETE, com.qmvxhkjp.rnbtzwlc.PACKAGE_INSTALLED

Conclusion

What looked like a simple fake VPN app turned out to be a fully layered dropper: ZIP-level anti-analysis, a sabotaged manifest, a sabotaged DEX, and a native library hiding two separate AES keys, all retrieved without ever running a single line of the malware. The second stage is another
APK \o/ Its analysis will be covered in a follow-up article :)