~/posts/mobile/android-dex-dgc-runtime-bytecode-restoration.md

Android's two layers: DEX, DGC, and method restoration

Trace eight hidden DEX files into an external method pool. Separate RC4 key derivations, DGC indexes, and instruction boundaries, then examine ART's conditional repair point.

date[31:24]
read[23:16]
8 min
cat[15:8]
Mobile
Contents
  1. 0x00Bootstrap code does not explain the container size
  2. 0x01Recognize RC4 and separate the first key derivation
  3. 0x02debug_info_off points toward an external code pool
  4. 0x03The DGC key combines a data block and a deterministic sequence
  5. 0x04Check index byte order separately from code_item boundaries
  6. 0x05ART supplies a conditional repair point
  7. 0x06Restore opcodes at instruction boundaries
  8. 0x07Official references

DJI Pilot 2.5.1.17 combines two distinct protections: it hides DEX containers and moves method instructions into an external code pool. A file that opens in a decompiler has only passed the first hurdle. Intact method declarations followed by long runs of nop are a reason to follow the runtime repair path, not to declare unpacking complete.

The sample record dates to February 2024 and identifies the APK by SHA-256 642aa123437c259eea5895fe01dc4210c4a3a430842b79612074d88745f54714, with package name com.dji.industry.pilot. The complete APK, device build, and runtime dumps are not available with the record. The analysis therefore separates visible bytes, transformation relationships, and local offline checks; it does not present them as a new device experiment.

Drone illustration using two transparent body layers and a circuit board to evoke layered bytecode protection

The layered body is a conceptual illustration, not a representation of the device's actual internal design.

Bootstrap code does not explain the container size

The initial class tree exposes little beyond bootstrap content:

Initial package view
  • com
    • amap.api
    • autonavi
    • dji.industry.pilot
      • R
    • secneo.apkwrapper
2 directories, 4 files

The bootstrap selects DexHelper-x86 or DexHelper through H.isNeedLoadX86(), then calls System.loadLibrary. The analyzed path enters libDexHelper.so. Its on-disk ELF is protected too, so the native analysis uses an image obtained at runtime. The record does not fully describe that unpacking stage and does not establish a general solution for the native protector.

decrypt_jar_128K is a functional name assigned during analysis. Its output buffers begin with the following DEX 035 marker, with eight outputs recorded along the startup path:

DEX 035 magic
000000006465780A30333500
  1. magic0x00–0x03
  2. version0x04–0x06035
  3. terminator0x07

Despite the sparse class tree, classes.dex occupies roughly 63 MB. Its entropy profile shows eight regions approaching 8 bits per byte, associated in the record with the first 128 KiB of eight business-code DEX files. Neither the sampling window nor the underlying series is available, so the observation remains qualitative rather than becoming a newly plotted measurement.

High entropy alone does not establish encryption; compressed data can look similar. The useful proof connects file ranges to function inputs, output lengths, and DEX headers. Exporting also requires checks against file_size, readable mappings, and the actual buffer length. Magic bytes alone do not define a valid read boundary.

Recognize RC4 and separate the first key derivation

Flattened native control flow still reveals state initialization, key scheduling (KSA), and pseudorandom generation (PRGA). The PRGA relationship is:

RC4 PRGA — semantic formtext
i = (i + 1) & 0xff
j = (j + S[i]) & 0xff
swap(S[i], S[j])
output = input ^ S[(S[i] + S[j]) & 0xff]

An XOR loop is not enough. The 256-byte state, index updates, swaps, and output dependency together identify RC4. Following the output forward and the KSA input backward connects that algorithm to a particular container.

The DEX key combines a 16-byte constant with the first 16 bytes of the package name. That prefix is exactly com.dji.industry, without a trailing dot:

DEX key derivation — shape onlypython
package = b"com.dji.industry.pilot"
assert package[:16] == b"com.dji.industry"

def derive_dex_key(constant):
    if len(constant) != 16:
        raise ValueError("expected 16 bytes")
    return bytes(a ^ b for a, b in zip(constant, package[:16]))

This describes the derivation without supplying the sample constant. Processing the first 128 KiB of each DEX exposes more classes, but some methods still lack meaningful bodies. Container decryption and method restoration remain separate checkpoints.

debug_info_off points toward an external code pool

The recorded getRequiredPermissions method retains its declaration and exception structure but contains mostly nop instructions, together with const v0, 0x8854372. The same value appears in the method's debug_info_off field.

Fields of the same placeholder method6 rows
Field Value
registers_size 3
ins_size 1
outs_size 2
tries_size 1
debug_info_off 0x08854372
insns_size 0x23 16-bit code units

In ordinary DEX semantics, debug_info_off locates debug information relative to the start of the file; it is not a method identifier. The recorded parser attempts position 142951282, beyond its 9930016 buffer limit. That position equals 0x08854372. The failure suggests a repurposed field but does not, by itself, establish the lookup algorithm. AOSP DEX format

Correlating assets/classes.dgc with the native repair function closes the relationship: a placeholder method carries an identifier, the DGC index locates an external code_item, and runtime code restores its instructions. DGC also has a high-entropy prefix of approximately 128 KiB, but derives its key differently.

The DGC key combines a data block and a deterministic sequence

The first 16 bytes observed at the DGC handler's input match the file prefix:

classes.dgc — observed encrypted prefix
00000000EFBDDE508BBB81C7806335CA956E1D1D

A second native function exhibits the same RC4 state operations. Its key comes from a 4096-byte block near the mthfilekey marker in the native library, not from the package prefix. The block is visible in the recorded on-disk library.

flowchart TD
  A["4096-byte data block"] --> B["MD5: 16-byte digest"]
  A --> C["Access block through deterministic sequence"]
  D["F(0)=0, F(1)=1"] --> C
  C --> E["Select 16 bytes"]
  B --> X["Bytewise XOR"]
  E --> X
  X --> K["DGC RC4 key"]

The sequence function shows F(0)=0, F(1)=1, and F(i)=F(i-2)+F(i-1). The initial values are supported by the evidence, rather than being unknown. Integer width, overflow behavior, the mapping from sequence values to block positions, and any modulus still require confirmation in the target function. "Fibonacci plus MD5" is not a complete decryption specification.

Round logic and constants support the MD5 identification. A compression function that consumes 512-bit message blocks should not be described as computing the entire digest over only 64 bytes. Block processing, padding, and digest output form a separate chain to verify.

Check index byte order separately from code_item boundaries

The DGC index view labels a code-section base of 0x00167e38 and several relative offsets. Adding 0x38 produces 0x00167e70, matching the location of a separate code-item view. The index contains byte sequences such as 00 16 7E 38; DEX's usual little-endian representation is not a reason to parse every custom DGC field as little-endian.

This checks an address relationship, not the complete index schema or every identifier pairing. The following code_item belongs to a different method: its debug_info_off = 0x046d63dd is distinct from the earlier 0x08854372.

DGC code_item — 16-byte header
00167E700300020002000100DD636D0412000000
  1. registers_size0x167E70–0x167E713
  2. ins_size0x167E72–0x167E732
  3. outs_size0x167E74–0x167E752
  4. tries_size0x167E76–0x167E771
  5. debug_info_off0x167E78–0x167E7B0x046d63dd
  6. insns_size0x167E7C–0x167E7F0x12

insns_size = 0x12 means 18 code units, or 36 bytes, not 18 bytes. After the 16-byte header, the instruction array ends at relative offset 0x34. Its even code-unit count requires no two-byte alignment pad before tries.

With tries_size = 1, an exception table and handler data remain to be checked. A valid header-plus-instruction range is not proof that the whole code item is valid. Building a replacement DEX also requires consistent relocated offsets, indexes, debug information, and file integrity fields, rather than a blind block overwrite.

ART supplies a conditional repair point

The sample redirects the entry of Instrumentation::InitializeMethodsCode into custom code. The functional name PatchMethodCode describes a sequence that obtains the identifier, looks up DGC data, allocates a restoration buffer, calls DecryptMethodCode, updates the method's code association, and reaches the original function through a trampoline.

flowchart TD
  A["Post-verification class update"] --> B{"Runtime and current entry qualify?"}
  B -->|Yes| C["InitializeMethodsCode"]
  B -->|No| N["Skip this initialization branch"]
  C --> P["PatchMethodCode"]
  P --> D["DGC lookup and opcode restoration"]
  D --> U["Update method code association"]
  U --> T["Trampoline / original initialization"]
  T --> R["Return to caller"]

In public ART commit 82e525a4f5f08a72ea1b6907c0a10dacb77a8a87, the loop in UpdateClassAfterVerification calls InitializeMethodsCode only when CanRuntimeUseNterp() holds and the method currently uses the quick-to-interpreter bridge. This pinned source explains the condition; it has not been matched to the sample device build.

The diagram therefore does not imply that every method on every ART version unconditionally traverses this entry. Entry initialization, class verification, and actual method execution are separate events. Restored bytes should be compared with the relevant method's code association at that point, not judged solely by whether an exported DEX opens.

Restore opcodes at instruction boundaries

The core operation in DecryptMethodCode replaces an opcode byte:

Single-opcode transformpython
def decode_opcode(encoded_opcode, debug_info_off, substitution):
    if not 0 <= encoded_opcode <= 255:
        raise ValueError("opcode must fit in one byte")
    if len(substitution) != 256:
        raise ValueError("expected a 256-entry table")
    return substitution[encoded_opcode ^ (debug_info_off & 0xff)]

The substitution table resides in the native library. It is not RC4's continually permuted state array. An analyst might call both objects S, but their lifetimes and purposes are different.

Dalvik instructions use 16-bit code units, with the opcode normally in the low byte of the first unit. A walker needs the restored instruction format to advance while preserving registers, immediates, and branch offsets. Switch and array-data payloads require separate handling of identifiers, sizes, and alignment. Applying the function to every byte would corrupt operands too. AOSP Dalvik bytecode

A restored example shows coherent checkAccountManager calls and preference-writing logic. That establishes improved readability, not equivalence between every restored method and runtime execution. Check identifier uniqueness, complete instruction traversal, valid referenced indexes, and consistent control-flow and exception ranges.

Local offline checks use public RFC 6229 RC4 test material rather than a sample key. A deliberately synthetic substitution table checks the single-byte mapping, alongside parsing the header above:

Offline checkslog
PASS: 2 RC4 vectors; 65536 synthetic opcode round trips; 4 boundary rejections
DEX key prefix: com.dji.industry
DGC code_item: 0x167e70 insns bytes: 36 tries relative offset: 52

These results validate the RC4 implementation, length arithmetic, and mapping shape. They do not validate missing DGC derivation parameters, a complete unpacked application, or device execution. A trustworthy restoration ultimately connects three things: where container bytes originate, where method identifiers lead, and why restored instructions agree with the runtime.

Official references

NORMAL~/posts/mobile/android-dex-dgc-runtime-bytecode-restoration.md§--
0%en