Implementing Android APK Encryption with First-Generation Packers
A first-generation packer encrypts the entire source APK and appends it to a stub (shell) DEX file. The stub DEX contains decryption and dynamic loading code, which extracts and loads the original APK at runtime. As it involves encrypting the whole APK, this method is resource-intensive for large applications and was largely abandoned early on. Nevertheless, its core concepts remain foundational.
Encryption techniques can be subdivided into several areas:
- String encryption within DEX files.
- Static encryption and decryption of the complete DEX.
- Resource encryption (XML and ARSC files, sometimes in hexadecimal).
- Anti-decompilation tactics targeting tools like
apktool, exploiting their inherent limitations. - Anti-debugging via Ptrace and TracePid value verification.
- Custom
DexClassLoaderimplementations for handling DEX protection and packing. - Disk-loading (where the decrypted DEX becomes visible in the APK's directory).
Application Launch Flow Analysis
Understanding the application startup sequence is crucial, as packers must intervene before the main execution begins. The key flow relevant to first-generation packers is:
ActivityThread.main(): The application entry point.ActivityThread.handleBindApplication(): Instantiates the application.LoadedApk.makeApplication(): Creates theApplicationobject.Instrumentation.newApplication(): Instantiates theApplicationclass.Application.attachBaseContext(): The first callback available for theApplicationobject.Application.onCreate(): Called afterattachBaseContext.
After the Application startup flow concludes, the MainActivity's attachBaseContext() and onCreate() methods are invoked. Therefore, a packer must perform dynamic loading and classloader adjustments during the Application's initialization, typically within attachBaseContext() or onCreate(). In essence, the original application is concealed, and an outer "shell" program is wrapped around it. This shell decrypts and loads the original application via reflection.
Implementation Process
The implementation involves three main components:
- Source Application: A standard Android project compiled into an APK that will be hidden.
- Packing Tool: A separate program (e.g., in Python) that merges the source APK with the shell stub.
- Stub (Shell) Program: An Android project that decrypts, loads, and executes the original application.
The final packaged APK must have the stub program as its entry point.
Constructing the Packed APK
The tool concatenates the source APK data to the end of the stub's classes.dex file and appends the size of the APK data. It then updates the DEX file's header fields (file_size, checksum, signature) accordingly.
Example Python Packing Script (Core Logic):
import hashlib
import struct
import zlib
def pack_apk(stub_dex_path, source_apk_path, output_dex_path):
with open(stub_dex_path, 'rb') as f:
stub_data = f.read()
with open(source_apk_path, 'rb') as f:
apk_data = f.read()
# Optional: Process (encrypt/compress) the APK data here
processed_apk = process_data(apk_data)
apk_size = len(processed_apk)
# Build the new DEX: Stub + APK + APK size (8 bytes, little-endian)
new_dex = stub_data + processed_apk + struct.pack('<Q', apk_size)
# Update DEX header fields (offsets are simplified for illustration)
# Update file size (offset 0x20)
total_len = len(new_dex)
new_dex = new_dex[:0x20] + struct.pack('<I', total_len) + new_dex[0x24:]
# Update SHA-1 signature (offset 0x0C)
sha1_digest = hashlib.sha1(new_dex[0x20:]).digest()
new_dex = new_dex[:0x0C] + sha1_digest + new_dex[0x20:]
# Update Adler-32 checksum (offset 0x08)
checksum = zlib.adler32(new_dex[0x0C:])
new_dex = new_dex[:0x08] + struct.pack('<I', checksum) + new_dex[0x0C:]
with open(output_dex_path, 'wb') as f:
f.write(new_dex)
The Stub (Shell) Program
The shell's Application class (e.g., ProxyApplication) overrides attachBaseContext() to extract and load the original APK.
1. Reading and Extracting the Original APK
public class ProxyApplication extends Application {
private String extractedApkPath;
@Override
protected void attachBaseContext(Context ctx) {
super.attachBaseContext(ctx);
try {
// 1. Read the shell's own APK and locate the DEX
ZipFile apkZip = new ZipFile(ctx.getApplicationInfo().sourceDir);
ZipEntry dexEntry = apkZip.getEntry("classes.dex");
InputStream is = apkZip.getInputStream(dexEntry);
byte[] combinedData = readStreamFully(is);
is.close();
apkZip.close();
// 2. Extract the original APK from the end of the DEX
long apkSize = ByteBuffer.wrap(combinedData, combinedData.length - 8, 8)
.order(ByteOrder.LITTLE_ENDIAN).getLong();
int startIndex = combinedData.length - 8 - (int) apkSize;
byte[] originalApkData = Arrays.copyOfRange(combinedData, startIndex, combinedData.length - 8);
// Optional: Decrypt/decompress originalApkData here
// 3. Write the original APK to a private directory
File outDir = ctx.getDir("extracted", Context.MODE_PRIVATE);
File apkFile = new File(outDir, "original.apk");
FileOutputStream fos = new FileOutputStream(apkFile);
fos.write(originalApkData);
fos.close();
apkFile.setReadOnly(); // Required for Android 14+
extractedApkPath = apkFile.getAbsolutePath();
} catch (Exception e) {
throw new RuntimeException("Failed to extract APK", e);
}
}
private byte[] readStreamFully(InputStream is) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] data = new byte[4096];
int nRead;
while ((nRead = is.read(data, 0, data.length)) != -1) {
buffer.write(data, 0, nRead);
}
return buffer.toByteArray();
}
}
2. Loading the Original APK and Hooking the ClassLoader
After extraction, the stub must load the DEX from the original APK and ensure the system uses the correct ClassLoader to find the original app's classes (like its MainActivity). The key is to replace the mClassLoader field within the LoadedApk object associated with the current process.
private void loadOriginalApp(Context ctx) {
try {
// Create directories for optimized DEX and native libraries
File optDir = ctx.getDir("odex", Context.MODE_PRIVATE);
File libDir = ctx.getDir("libs", Context.MODE_PRIVATE);
ClassLoader parentLoader = getClassLoader(); // The shell's PathClassLoader
// Load the original APK's DEX
DexClassLoader originalLoader = new DexClassLoader(
extractedApkPath,
optDir.getAbsolutePath(),
libDir.getAbsolutePath(),
parentLoader
);
// Use reflection to replace the LoadedApk's class loader
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThread = activityThreadClass.getDeclaredMethod("currentActivityThread");
Object activityThread = currentActivityThread.invoke(null);
Field mPackagesField = activityThreadClass.getDeclaredField("mPackages");
mPackagesField.setAccessible(true);
ArrayMap<String, ?> mPackages = (ArrayMap<String, ?>) mPackagesField.get(activityThread);
String packageName = ctx.getPackageName();
WeakReference<?> wr = (WeakReference<?>) mPackages.get(packageName);
Object loadedApk = wr.get();
Class<?> loadedApkClass = Class.forName("android.app.LoadedApk");
Field mClassLoaderField = loadedApkClass.getDeclaredField("mClassLoader");
mClassLoaderField.setAccessible(true);
mClassLoaderField.set(loadedApk, originalLoader);
// Preload the main activity class to ensure it's found
originalLoader.loadClass("com.example.originalapp.MainActivity");
} catch (Exception e) {
Log.e("ProxyApplication", "Failed to load original app", e);
}
}
3. Replacing the Application Instance (Optional)
If the original APK has its own custom Application class, the stub needs to replace the current Application instance with the original one to ensure its lifecycle methods (onCreate(), etc.) are called. This involves more complex reflection to manipulate ActivityThread and LoadedApk fields.
private void replaceApplicationInstance() {
try {
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentAt = activityThreadClass.getDeclaredMethod("currentActivityThread");
Object at = currentAt.invoke(null);
// Get the LoadedApk and clear its mApplication field
Field mBoundAppField = activityThreadClass.getDeclaredField("mBoundApplication");
mBoundAppField.setAccessible(true);
Object boundAppData = mBoundAppField.get(at);
Class<?> appBindDataClass = Class.forName("android.app.ActivityThread$AppBindData");
Field infoField = appBindDataClass.getDeclaredField("info");
infoField.setAccessible(true);
Object loadedApk = infoField.get(boundAppData);
Class<?> loadedApkClass = Class.forName("android.app.LoadedApk");
Field appField = loadedApkClass.getDeclaredField("mApplication");
appField.setAccessible(true);
appField.set(loadedApk, null);
// Update the application class name in AppInfo
Field appInfoField = appBindDataClass.getDeclaredField("appInfo");
ApplicationInfo appInfo = (ApplicationInfo) appInfoField.get(boundAppData);
appInfo.className = "com.example.originalapp.OriginalApplication"; // Original app's Application class
// Create the new Application instance
Method makeApp = loadedApkClass.getDeclaredMethod("makeApplication", boolean.class, Instrumentation.class);
makeApp.setAccessible(true);
Application originalApp = (Application) makeApp.invoke(loadedApk, false, null);
// Replace the ActivityThread's mInitialApplication
Field initAppField = activityThreadClass.getDeclaredField("mInitialApplication");
initAppField.setAccessible(true);
initAppField.set(at, originalApp);
// Call the original Application's onCreate()
originalApp.onCreate();
} catch (Exception e) {
Log.e("ProxyApplication", "Failed to replace Application", e);
}
}
Key Implementation Notes
- Android 14+ Restriction: DEX files loaded via
DexClassLoadermust be read-only. Ensure the extracted APK file has its permissions set accordingly (setReadOnly()). - ClassLoader Hierarchy: The standard approach replaces the
mClassLoaderinLoadedApk. An alternative method involves inserting a customClassLoaderinto the parent delegation chain betweenPathClassLoaderandBootClassLoader. - Resource Loading: This basic implementation does not handle loading resources from the original APK. A complete solution would require creating a new
Resourcesobject and attaching the original APK'sAssetManager. - Manifest Modification: The shell's
AndroidManifest.xmlmust declareProxyApplicationas theandroid:namein the<application>tag. TheMainActivityentry point should be changed to the original app's activity.
Common Unpacking Techniques
Common methods for analyzing or unpacking such packed applications include:
- Memory Dumping: Extracting decrypted DEX files from process memory.
- Cache Analysis: Retrieving optimized DEX/ODEX files from the application's cache directories.
- File Monitoring: Observing file system operations during app startup.
- Hooking: Intercepting key functions in the loading process (e.g.,
DexFileAPIs). - Custom ROMs: Using modified Android systems with built-in dumping capabilities.
- Dynamic Debugging: Debugging the stub's extraction and loading routines.