2- ThePackage: Unpacking a Runtime-Loaded DEX
Overview
ThePackage introduced a classic Android evasion technique: DEX-in-DEX packing. The APK delivered to the user contains nothing interesting in its primary classes.dex. The actual application logic — including the flag — lives in a secondary DEX that is encrypted inside the assets directory and only loaded at runtime.
The goal was to find the decryption key, extract the hidden DEX, and recover the flag from the decrypted classes.
The Stub APK
Opening ThePackage.apk with JADX immediately felt wrong. The package structure was sparse, class names were generic, and there were no obvious flag-related strings. The main activity was minimal. This is the hallmark of a packing stub: the visible DEX exists only to bootstrap the real application.
The decompiled stub class revealed a DexClassLoader invocation loading a DEX from a path inside the app’s private storage — a path that the app would populate by decrypting an asset at first run.
The asset bundle inside the APK contained a single encrypted file. The stub read this file, decrypted it in memory, wrote the plaintext DEX to the device’s internal storage, and then loaded it dynamically.
Extracting the Hidden DEX
Rather than running the app on a device and extracting the DEX from the filesystem, the faster path was to identify the decryption routine in the stub and replicate it statically.
The stub’s decrypt method was straightforward — a simple XOR scheme keyed on the loop index. Reproducing it in Python produced a valid DEX header (0x64 0x65 0x78 0x0A — the dex\n magic).
With the DEX recovered, JADX decompiled it cleanly into two classes: FlagProvider and SecretVault.
The Hidden Classes
FlagProvider contained the flag encoded as a hardcoded byte array:
1private static final int _b = 71;
2private static final byte[] _d = {
3 4, 18, 3, 63, 41, 49, 113, 46, 16, 62,
4 121, 47, 32, 121, 45, 23, 115, 54, 46, 55,
5 28, 118, 51, 115, 16, 32, 125, 56, 20, 57,
6 125, 46, 116, 59
7};
8
9private static byte _k(int i) {
10 return (byte) ((i & 15) ^ _b);
11}
12
13public static String getFlag() {
14 byte[] r = new byte[_d.length];
15 for (int i = 0; i < _d.length; i++)
16 r[i] = (byte) (_d[i] ^ _k(i));
17 return new String(r, StandardCharsets.US_ASCII);
18}The key function _k(i) takes the lower nibble of the index and XORs it with the constant _b = 71. Applying this to each byte of _d produces the flag.
SecretVault contained a second encoded value using a simpler fixed-key XOR:
1private static final byte[] _v = {
2 25, 14, 28, 33, 41, 105, 57, 40, 105, 46,
3 5, 44, 110, 47, 54, 46, 5, 107, 41, 5,
4 110, 5, 46, 40, 110, 42, 39
5};
6private static final int _xk = 90;
7
8public static String getVaultContent() {
9 byte[] r = new byte[_v.length];
10 for (int i = 0; i < _v.length; i++)
11 r[i] = (byte) (_v[i] ^ 90);
12 return new String(r, StandardCharsets.US_ASCII);
13}Decoding both classes confirmed the flag:
1CTF{js0n_p4ck3d_4pks_4r3_n0t_s4f3}What Made This Interesting
The packing technique here is simple by production-malware standards, but it demonstrates the core principle cleanly: the visible attack surface of an APK is not necessarily the actual attack surface. A static scan of the installer DEX alone would find nothing.
The class names themselves were a deliberate hint — FalgProvider and SecrectVault (both with typos, as they appear in the extracted DEX file names) — an indicator that these were programmatically generated as part of the obfuscation layer. The verify() method in SecretVault, which uses an FNV-1a hash to check a 4-byte input against a known value, was a red herring; nothing in the visible code calls it.
The getChallengeInfo() string — "AppShield Runtime v4.2.1 — integrity verification module" — was another decoy, mimicking a commercial packer name to imply the challenge was about defeating an enterprise protection product.
Key Techniques
| Technique | Where |
|---|---|
| DEX-in-DEX packing | Real classes encrypted in APK assets, loaded via DexClassLoader |
| Index-keyed XOR | FlagProvider._k(i) = (i & 0xF) ^ 71 — a new key byte per position |
| Fixed-key XOR | SecretVault uses constant key 90 across all bytes |
| Misleading class names | Typos suggest auto-generated names; verify() is never called |
| Fake module identity | getChallengeInfo() mimics a commercial packer string |