Mobile Development 23 min read

DexInjector: Direct APK (dex) Instrumentation Tool and Technical Design

This article introduces DexInjector, a tool for directly instrumenting Android APK dex files to insert logging, performance monitoring, and third‑party hooks without recompiling, explains the underlying technical research, design choices, implementation details, handling of method limits, string jumbo, obfuscation, class conflicts, and provides code examples and workflow diagrams.

ByteDance Terminal Technology
ByteDance Terminal Technology
ByteDance Terminal Technology
DexInjector: Direct APK (dex) Instrumentation Tool and Technical Design

Where to Find Technical Nuggets?

In offline scenarios we often need to insert detection code into an APK to record method execution time or add logging. Traditional approaches modify class bytecode at compile time (e.g., using ByteX), but they cannot instrument an already compiled APK, leading to extra Jenkins packaging steps and consuming nearly half of the automated test time.

To solve this pain point we developed DexInjector, a tool that directly instruments APK (dex) files for logging, performance data collection, and third‑party library injection, avoiding the need for a second packaging step and saving testing time. The solution has been used for log bypass, network data capture, third‑party library injection, user‑info injection, and daily debugging.

Current capabilities:

Method entry instrumentation

Method exit instrumentation

Initialization instrumentation

Technical Survey

smali

smali/baksmali can convert dex to readable smali syntax, but inserting new code is difficult because the tool parses bytecode syntactically and cannot perform structured register operations.

redex

Redex supports configuration‑based entry instrumentation via custom passes, but its functionality is limited, usage is complex, and it inserts Facebook‑specific code. Future versions will build on Redex’s powerful bytecode modification capabilities.

dexter

Dexter, developed by Google, provides a complete API for dex file structure and bytecode manipulation, offering a lightweight ASM‑like experience. Conclusion: dexter is chosen for dex operations.

Design

Requirements

For performance degradation prevention and traffic statistics, we need to insert calls to other methods at the beginning and end of a target method body. For example, in okhttp3.RealCall.getResponseWithInterceptorChain we insert a method at the start to obtain detailed request data.

Response getResponseWithInterceptorChain() throws IOException {
    com.netflow.inject.hookRealCall(this); // inserted method
    List
interceptors = new ArrayList<>();
    interceptors.addAll(client.interceptors());
    // ...
    return chain.proceed(originalRequest);
}

Basic Flow

1. Dex File Analysis

Parse the dex file format and serialize it into data structures (refer to official documentation).

2. Bytecode Parsing

Parse binary bytecode in the code section into manipulable data structures.

3. Bytecode Construction

Construct bytecode instructions according to the specification and insert them into the existing instruction sequence.

4. Bytecode Serialization

Re‑calculate indexes and serialize each data section back into the dex file format.

Functional Requirements

Instrumentation supports two capabilities: inserting a static method call before and after a method body.

1. Pre‑method instrumentation

If the target method is an instance method, the first parameter of the inserted method must be this ; static methods must match the target method’s parameter types and count. Example:

public class Tracer {
    private void MethodA(int a, int b) {}
    private static void MethodB(int a, int b) {}
}
public class Hooker {
    private static void TestHookA(Tracer this_, int a, int b) {}
    private static void TestHookB(int a, int b) {}
}
// After instrumentation
public class Tracer {
    private void MethodA(int a, int b) {
        Hooker.TestHookA(this, a, b);
        // ...
    }
    private static void MethodB(int a, int b) {
        Hooker.TestHookB(a, b);
        // ...
    }
}

2. Post‑method instrumentation

Return‑value handling is required; the inserted method’s return type must match the original method’s return type. Example:

public class Tracer {
    private void MethodA(int a, int b) {
        // ...
        Hooker.TestHookA(this, a, b);
    }
    private String MethodB(int a, int b) {
        // ...
        return Hooker.TestHookB(this, a, b, str);
    }
}

3. Initialization instrumentation

Used to insert code that must run early, parsing AndroidManifest.xml to locate the application class and inserting calls into its onCreate or attachBaseContext methods. If these methods do not exist, the tool generates them.

Common Issues

Method Count Limit

A dex file can hold at most 65 535 methods. Instrumentation adds new methods, which may exceed this limit. One solution is to merge dex files and split them, moving unused classes to an auxiliary dex to free space.

Solution 1: Compile‑time max‑idx

Use --set-max-idx-number to force the compiler not to fill the dex completely, though it may not work after tools like Redex.

Solution 2: Dex splitting

If the remaining method slots are insufficient, split the existing dex by moving unused classes to a new dex file. The process involves checking method count, recording parameter and field types, analyzing bytecode references, and extracting untouched classes.

Dex Merging

After splitting, merge the auxiliary dexes back into a single dex (e.g., classes11.dex ) and also merge the instrumentation dex into the final dex.

String Jumbo Handling

When inserting new strings, indices may exceed 0xFFFF, causing const-string instructions to fail. The tool scans for such instructions and upgrades them to const-string/jumbo .

Obfuscation Handling

Target methods are often obfuscated; the tool uses the mapping file to locate obfuscated names before instrumentation. It also renames classes in the injected dex to match the original APK’s obfuscation, and deals with removed or inlined methods by adjusting call sites or avoiding such calls.

Class Duplication

When the injected dex contains classes with the same name as those in the host APK (e.g., Kotlin runtime), two solutions are applied: (1) remove duplicate classes from the injected dex, or (2) rename the conflicting third‑party packages (e.g., kotlin → kotlin_copy ).

Bytecode Instrumentation Details

Method‑entry instrumentation

Insert an invoke-static/range instruction that forwards the original parameters to the hook method.

.method public static monitorEvent(Ljava/lang/String;Lorg/json/JSONObject;Lorg/json/JSONObject;Lorg/json/JSONObject;)V
    .registers 9
    // instrumentation code
    invoke-static/range {p0 .. p3}, Lcom/bytedance/apm_bypass_tool/monitor/BypassMonitor;->monitorEvent(Ljava/lang/String;Lorg/json/JSONObject;Lorg/json/JSONObject;Lorg/json/JSONObject;)V
    const/4 v0, 0x4
    ...

Method‑exit instrumentation

Locate all return instructions, insert a call to the hook before the return, and handle the return value by moving it to a temporary register if necessary.

invoke-direct {p2, p0, p1}, Lcom/ss/android/lark/ico$1;->
(Lcom/ss/android/lark/ico;Ljava/lang/reflect/Type;)V
move-result-object v4
invoke-static {p0, p1, p2, v4}, Lcom/netflow/inject/NetFlowHookReceiver;->hookCallServerInterceptor_executeCall_end(...)
move-result-object v5
return-object v5

Register reuse issues

When the compiler reuses parameter registers for return values, the instrumentation may lose access to original parameters. The solution is to expand the register set (e.g., add extra v registers) and adjust instructions accordingly, using move-object/from16 or similar variants.

invoke-interface {p1, p2}, Lcom/ss/android/lark/idf;->a(Lcom/ss/android/lark/idh;)Lcom/ss/android/lark/idj;
move-result-object v4   // original p1 reused
invoke-static {p0, p1, p2, v4}, Lcom/netflow/inject/NetFlowHookReceiver;->hookCall(...)
move-result-object v5
return-object v5

Creating the Instrumentation Dex

The instrumentation code is placed in a separate Gradle module, compiled to a JAR, then converted to dex using the d8 tool.

mkdir resources
./gradlew inject-dex:clean
./gradlew inject-dex:assembleRelease
d8 inject-dex/build/intermediates/aar_main_jar/release/classes.jar --output resources/
mv resources/classes.dex resources/netflow_caller.dex
mv resources/netflow_caller.dex netflow_caller.dex

Two strategies are provided:

Extract only the needed classes from the instrumentation dex by package name, then apply the host APK’s mapping for obfuscation.

Rename conflicting third‑party libraries (e.g., kotlin → kotlin_copy ) to avoid clashes.

References

Redex: https://github.com/facebook/redex/blob/master/opt/instrument/Instrument.cpp

Dexter: https://android.googlesource.com/platform/tools/dexter/+/refs/heads/master

Dalvik Executable File Format: https://source.android.google.cn/devices/tech/dalvik/dex-format?hl=zh-cn

--set-max-idx-number discussion: https://stackoverflow.com/questions/27631500/is-there-a-way-to-limit-method-amount-in-main-dex-file-while-using-multidex-feat/27766126

Dalvik Instruction Formats: https://source.android.com/devices/tech/dalvik/instruction-formats

performanceInstrumentationAndroidBytecodeAPKDextool
ByteDance Terminal Technology
Written by

ByteDance Terminal Technology

Official account of ByteDance Terminal Technology, sharing technical insights and team updates.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.