Dynamically Injecting Java and Native Code on a Rooted Android Device
About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.
This research is published for educational and defensive purposes. The target sample is anonymized throughout.
Dynamically Injecting Java and Native Code on a Rooted Android Device
Background
In Android reverse engineering, we usually use Frida to dynamically inject JavaScript into a target app from the command line. Writing JavaScript can be awkward for Android newcomers — being able to hook Java-layer methods with Java, and native functions with C/C++, would feel far more natural.
On the forward-engineering side, we often need to run performance diagnostics or analyze complex issues on a release build — never easy. Release builds are awkward to debug, large apps take forever to compile, and modifying framework or third-party SDK code is painful. So sometimes we lean on reverse-engineering tools like Xposed or Frida to inject code and debug that way.
So how do you dynamically inject an Xposed plugin into a target app from the command line? This article walks through one approach on a rooted Android device.
Design
The implementation has these parts:
- the injection process itself;
- wrapping an ART Hook framework;
- loading the plugin APK, including its native libraries;
- the native-to-Java entry point.
The plan:
- Model the injection on Frida — use ptrace.
- Use SandHook (a bit dated but still working) for ART hooking, and bundle it with the plugin loader as dex + so.
- In the ptrace-injected native library, load the dex and invoke its entry method.
Implementation
Here’s the overall flow:

Three layers:
- Injection layer: a CLI executable uses ptrace to inject a specified
.sointo the target process. - Glue layer: when the target process starts, merge the dex and so into the app’s main ClassLoader, then use that merged ClassLoader to call the dex’s entry method.
- Plugin-loading layer: initialize the ART Hook library and load the external Xposed APK plugin.
Injection layer
The goal is to inject a binary into the target process and run a function inside it.
ptrace-based process injection on Linux is a well-trodden path. I’d researched this exact area before and open-sourced a relatively stable ptrace injection library for Android: XInjector.
Build that repo’s dynamic executable and you can easily inject a shared library into the target app.
Glue layer
The glue layer merges the dex and so into the app’s main ClassLoader, then calls the dex’s entry method via the merged ClassLoader.
The hardest part: prying open Java’s door from the native world.
The core flow:
- Get the current thread’s JNIEnv pointer.
- Find the right moment to initialize the plugin-loading layer.
- Execute the plugin-loading layer’s entry method.
Let me walk through these three steps.
Timing the initialization
Choosing when to initialize the plugin-loading layer is one of the main challenges, because:
- It should run as early as possible so the plugin APK can hook more methods.
- But ptrace injection timing is nondeterministic. Inject too early and the app’s package info may not be parsed yet — the LoadedApk object isn’t created, so you can’t build a Context and can’t enter the Java world.
- Running the plugin-loading initialization directly inside the post-ptrace flow can crash in unknown ways. For instance, if ptrace lands in a JNI method annotated
@CriticalNative, using the JNIEnv pointer inside that flow can crash unexpectedly.
Frida hits a similar problem during development. Its solution is to spawn a child thread to run the hook code, sidestepping the third issue — but since the code runs in a child thread, the timing is nondeterministic and injection can be slightly delayed, so some methods may execute before they get hooked.
Based on that analysis, the plugin-loading layer’s initialization runs at one of three moments:
- If ptrace injected into a JNI method where the JNIEnv pointer can’t be used, use ALooper to post the initialization to the main thread’s Handler. Downside: the hook runs late — the Application’s
attachBaseContextandonCreatehave already run — but it works as a fallback. - If ptrace injected very early and ActivityThread’s
mLoadedApkmember isn’t created yet, we can’t enter the Java world and must delay. Here we plt-hookatrace_update_tagsin libcutils and run the initialization inside it — this function runs exactly once at startup, by which pointmLoadedApkexists. - Otherwise, run the initialization directly in the post-ptrace flow: merge dex and so into the app’s main ClassLoader and call the dex entry method.
Pseudocode:
static void OnAtraceFuncCalled() {
void* current_thread_ptr = runtime::CurrentThreadFunc();
JNIEnv* env = runtime::GetJNIEnvFromThread(current_thread_ptr);
if (!env) {
LOGE("Failed to get JNIEnv, Inject Xposed Module failed.");
return;
}
InjectXposedLibraryInternal(env);
}
int DoInjection(JNIEnv* env) {
runtime::InitRuntime();
void* current_thread_ptr = nullptr;
if (!env) {
current_thread_ptr = runtime::CurrentThreadFunc();
env = runtime::GetJNIEnvFromThread(current_thread_ptr);
}
if (!env) {
LOGE("Failed to get JNIEnv !!");
return -1;
}
// When ptrace lands in the JNI method Java_java_lang_Object_wait, waiting on a lock makes env->FindClass hang;
// when it lands in Java_com_android_internal_os_ClassLoaderFactory_createClassloaderNamespace,
// the classLoader is being constructed and FindClass hangs too. Bypass both by posting the task to the main-thread Handler.
void* art_method = runtime::GetCurrentMethod(current_thread_ptr, false, false);
if (art_method != nullptr) {
std::string name = runtime::JniShortName(art_method);
if (strcmp(name.c_str(), "Java_java_lang_Object_wait") == 0
|| strcmp(name.c_str(), "Java_com_android_internal_os_ClassLoaderFactory_createClassloaderNamespace") == 0) {
// load xposed modules after in the main message handler, this is later than application's attachBaseContext and onCreate method.
InjectXposedLibraryByHandler(env);
return 0;
}
}
// If the inject time is very early, then, the loadedapk info and the app classloader is not ready, so we try to hook atrace_set_debuggable function to make sure
// the injection is early enough and the classloader has also been created.
jobject loaded_apk_obj = jni::GetLoadedApkObj(env);
LOGD("Try to get the app loaded apk info, loadedapk jobject: %p", loaded_apk_obj);
if (loaded_apk_obj == nullptr) {
// load xposed modules after atrace_set_debuggable or atrace_update_tags is called.
HookAtraceFunctions(OnAtraceFuncCalled);
}
else {
// loadedapk and classloader is ready, so load the xposed modules directly.
InjectXposedLibraryInternal(env);
}
return 0;
}
Running the injection on the main thread guarantees it happens early, but in practice the injected app crashed or hung at a very high rate — likely because of VM constraints: some JNI functions internally can’t run Java code.
So in the end we default to Frida’s approach: initialize and run the plugin’s entry method in a child thread. More stable and reliable.
The code:
static void InjectXposedLibraryAsync(JNIEnv* jni_env) {
JavaVM* javaVm;
jni_env->GetJavaVM(&javaVm);
std::thread worker([javaVm]() {
JNIEnv* env;
javaVm->AttachCurrentThread(&env, nullptr);
int count = 0;
while (count < 10000) {
jobject app_loaded_apk_obj = jni::GetLoadedApkObj(env);
// wait here until loaded apk object is available
if (app_loaded_apk_obj == nullptr) {
usleep(100);
} else {
break;
}
count++;
}
InjectXposedLibraryInternal(env);
if (env) {
javaVm->DetachCurrentThread();
}
});
worker.detach();
}
Merging the ClassLoader
Once ptrace succeeds we can run our native code, but loading an external plugin requires Java. To enter the Java world, we construct the relevant ClassLoader in native and call the Java entry method.
Two strategies for loading Java code with a ClassLoader:
- Build a dedicated ClassLoader for the dex file from its path, and load the entry class with it.
- Merge the target dex into the app’s main ClassLoader, and load the entry class with the app’s ClassLoader.
Given the difficulty, I picked option 2 — the merge strategy.
The idea is to modify the app’s PathClassLoader’s pathList member: build a new DexElement from the target dex path and append it to pathList’s dexElements array, and add the target so path to pathList’s nativeLibraryDirectories array.
The challenges:
- It must be pure native code.
- PathClassLoader’s members and data structures differ a lot across Android versions, so each version needs compatibility handling.
The solution: first implement it fully in Java with version compatibility handled, then “translate” it to C++.
Calling the entry method
After merging, reflectively invoking the dex’s entry class/method with the current thread’s JNIEnv throws a class-not-found exception, for reasons I haven’t pinned down.
To dodge it, I used a brute-force approach: call loadClass on the app’s ClassLoader to load the target class, getDeclaredMethod to fetch the method, then Method.invoke() to run it.
Pseudocode:
void CallStaticMethodByJavaMethodInvoke(JNIEnv* env, const char* class_name, const char* method_name) {
ScopedLocalRef<jobject> app_class_loader(env, jni::GetAppClassLoader(env));
const char* classloader_class_name = "java/lang/ClassLoader";
const char* class_class_name = "java/lang/Class";
const char* method_class_name = "java/lang/reflect/Method";
// Class<?> clazz = appClassLoader.loadClass("class_name");
ScopedLocalRef<jclass> classloader_jclass(env, env->FindClass(classloader_class_name));
auto loadClass_mid = env->GetMethodID(classloader_jclass.get(), "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;");
ScopedLocalRef<jstring> class_name_jstr(env, env->NewStringUTF(class_name));
ScopedLocalRef<jobject> clazz_obj(env, env->CallObjectMethod(app_class_loader.get(), loadClass_mid, class_name_jstr.get()));
// get Java Method mid: Class.getDeclaredMethod()
ScopedLocalRef<jclass> class_jclass(env, env->FindClass(class_class_name));
auto getDeclaredMethod_mid = env->GetMethodID(class_jclass.get(), "getDeclaredMethod",
"(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;");
// Get the Method object
ScopedLocalRef<jstring> method_name_jstr(env, env->NewStringUTF(method_name));
jvalue args[] = { {.l = method_name_jstr.get()},{.l = nullptr} };
ScopedLocalRef<jobject> method_obj(env, env->CallObjectMethodA(clazz_obj.get(), getDeclaredMethod_mid, args));
ScopedLocalRef<jclass> method_jclass(env, env->FindClass(method_class_name));
// get Method.invoke jmethodId
auto invoke_mid = env->GetMethodID(method_jclass.get(), "invoke",
"(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;");
//Call Method.invoke()
jvalue args2[] = { {.l = nullptr},{.l = nullptr} };
env->CallObjectMethodA(method_obj.get(), invoke_mid, args2);
}
Plugin-loading layer
The plugin-loading layer’s output is a dex + so compiled from an Android app project. That project bundles the SandHook-based Xposed API library and an entry method that initializes SandHook and loads the Xposed plugin APK.
The core logic:
- Initialize ART Hook: construct an app Context object and call SandHook’s init with it.
- Pre-process the plugin APK: extract its so files into the app’s private directory.
- Load the plugin APK: from the APK path and so path, construct the plugin ClassLoader, load the entry class, and call the entry method.
Here are the three steps.
Initializing ART Hook
SandHook’s init needs a Context, but since the plugin layer is injected very early the Application and app Context likely don’t exist yet — so we create one ourselves.
ContextImpl.createAppContext takes two args: the current process’s ActivityThread object and a LoadedApk object. The former is a singleton, easy to get via reflection; the latter lives in ActivityThread’s mBoundApplication member, also reflectively obtainable.
The full context construction:
public static Context createAppContext() {
// ContextImpl appContext = ContextImpl.createAppContext(mActivityThread, mLoadedApk);
try {
Class activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
currentActivityThreadMethod.setAccessible(true);
Object activityThreadObj = currentActivityThreadMethod.invoke(null);
Field boundApplicationField = activityThreadClass.getDeclaredField("mBoundApplication");
boundApplicationField.setAccessible(true);
Object mBoundApplication = boundApplicationField.get(activityThreadObj); // AppBindData
Field infoField = mBoundApplication.getClass().getDeclaredField("info"); // info
infoField.setAccessible(true);
Object loadedApkObj = infoField.get(mBoundApplication); // LoadedApk
Class contextImplClass = Class.forName("android.app.ContextImpl");
Method createAppContextMethod = contextImplClass.getDeclaredMethod("createAppContext", activityThreadClass, loadedApkObj.getClass());
createAppContextMethod.setAccessible(true);
Object context = createAppContextMethod.invoke(null, activityThreadObj, loadedApkObj);
if (context instanceof Context) {
return (Context) context;
}
} catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InvocationTargetException | NoSuchFieldException e) {
e.printStackTrace();
}
return null;
}
Pre-processing the plugin APK
To auto-load native code from the Xposed plugin, we handle the plugin’s shared libraries specially: extract the so files from the plugin APK to a target dir, then pass that dir when constructing the plugin’s DexClassLoader so System.loadLibrary() can find them.
The key step is extracting the so files. The common approach: unzip the APK and copy the so files out.
If you know the Android source well, you’ll find it already has this — the NativeLibraryHelper.java utility class. During APK install, if android:extractNativeLibs = true, the system extracts the so files to a target dir, then at startup passes that dir into the app’s ClassLoader for automatic native-library loading.
The extraction source — com/android/internal/content/NativeLibraryHelper.java:
/**
* Copies native binaries to a shared library directory.
*
* @param handle APK file to scan for native libraries
* @param sharedLibraryDir directory for libraries to be copied to
* @return {@link PackageManager#INSTALL_SUCCEEDED} if successful or another
* error code from that class if not
*/
public static int copyNativeBinaries(Handle handle, File sharedLibraryDir, String abi) {
for (long apkHandle : handle.apkHandles) {
int res = nativeCopyNativeBinaries(apkHandle, sharedLibraryDir.getPath(), abi,
handle.extractNativeLibs, handle.debuggable);
if (res != INSTALL_SUCCEEDED) {
return res;
}
}
return INSTALL_SUCCEEDED;
}
So we just reflectively construct a Handle object, then reflectively call copyNativeBinaries to extract the so files.
Loading the plugin APK
Loading the plugin APK is simpler — follow Xposed’s native implementation. Two steps:
- Build a
DexClassLoaderand read the entry-class full name configured underassets/xposed_init. - Build another
DexClassLoader, load the entry class by full name, construct an instance, and call the entry method.
Pseudocode:
// use system classloader to load asset to avoid the app has file assets/xposed_init
ClassLoader assetLoader = new DexClassLoader(moduleApkPath, moduleOdexDir, moduleLibPath, ClassLoader.getSystemClassLoader());
InputStream is = assetLoader.getResourceAsStream("assets/xposed_init");
if (is == null) {
Log.i(TAG, "assets/xposed_init not found in the APK");
return false;
}
ClassLoader mcl = new DexClassLoader(moduleApkPath, moduleOdexDir, moduleLibPath, appClassLoader);
BufferedReader moduleClassesReader = new BufferedReader(new InputStreamReader(is));
String moduleClassName;
while ((moduleClassName = moduleClassesReader.readLine()) != null) {
moduleClassName = moduleClassName.trim();
if (moduleClassName.isEmpty() || moduleClassName.startsWith("#"))
continue;
Class<?> moduleClass = mcl.loadClass(moduleClassName);
final Object moduleInstance = moduleClass.newInstance();
if (moduleInstance instanceof IXposedHookLoadPackage) {
IXposedHookLoadPackage.Wrapper wrapper = new IXposedHookLoadPackage.Wrapper((IXposedHookLoadPackage) moduleInstance);
XposedBridge.CopyOnWriteSortedSet<XC_LoadPackage> xc_loadPackageCopyOnWriteSortedSet = new XposedBridge.CopyOnWriteSortedSet<>();
xc_loadPackageCopyOnWriteSortedSet.add(wrapper);
XC_LoadPackage.LoadPackageParam lpparam = new XC_LoadPackage.LoadPackageParam(xc_loadPackageCopyOnWriteSortedSet);
lpparam.packageName = currentApplicationInfo.packageName;
lpparam.processName = getCurrentProcessName(currentApplicationInfo);;
lpparam.classLoader = appClassLoader;
lpparam.appInfo = currentApplicationInfo;
lpparam.isFirstApplication = true;
XC_LoadPackage.callAll(lpparam);
}
}
That completes the plugin APK load.
Output layout
The final artifacts and what each file does:
├── README.md // documentation
├── glue // the glue layer's so injected into the target process, used to inject/load the plugin layer's dex and so
│ ├── arm64-v8a
│ │ └── libinjector-glue.so
│ └── armeabi-v7a
│ └── libinjector-glue.so
├── injector // the injection layer's executable
│ ├── arm64-v8a
│ │ └── xinjector
│ └── armeabi-v7a
│ └── xinjector
├── plugin_loader // the plugin layer's compiled dex and so, implementing ART Hook and Xposed plugin APK loading
│ ├── classes.dex
│ ├── libsandhook-64.so
│ └── libsandhook.so
└── start.sh // startup script (macOS only for now) that copies all files to the rooted device's data/local/tmp and runs xinjector
Usage
Basic usage
- Download and unzip.
- Open a terminal and
cdinto the extracted directory. - Run the following to restart the target app and inject the Xposed plugin into it:
$ ./start.sh -p com.android.settings -f xposed_module_sample.apk
The app started is com.android.settings; the injected Xposed plugin APK path is xposed_module_sample.apk.
Other options
Performance mode: use -q.
By default it copies the glue and plugin_loader dex/so files into the target app’s data/data/ directory. -q skips that copy — use it on a second injection of the same app to speed things up.
$ ./start.sh -p com.android.settings -f xposed_module_sample.apk -q
Similarly, when re-injecting the same app with the same Xposed plugin, you can omit -f xposed_module_sample.apk — it reuses the previously injected plugin.
-h prints help:
$ ./start.sh -h
Console output:
[ Java And C/C++ Code Injection for Android Root Device]
Usage: ./start.sh -p package_name -f xxx.apk -h -q
Options:
-p [package name] the package name to be injected
-f [apk path] the xposed module apk path
-h display this help message
-q use quick mode, do not inject the xposed dex and so file, this can only used when it is not the first time to inject target app.
Known issues
The tool was thrown together quickly, so it still has some short-term unfixable issues:
- ptrace injection occasionally fails, crashing or hanging the app on startup. I’m working on a fix and may switch to a non-ptrace scheme (e.g. “Code injection on Android without ptrace”), which would also dodge anti-ptrace apps.
- ART Hook currently uses SandHook, which can be unstable on some devices; may switch to the actively maintained Lsposed later.
- For now it only injects into the app’s main process — child-process injection isn’t uniformly supported because their startup timing is nondeterministic.
- On Android 13+, due to a SandHook bug, hooking a static method of an unresolved class can fail. A workaround is to call
resolveStaticMethod()below to resolve the class first, then hook:
public static void resolveStaticMethod(Member method) {
try {
if (method instanceof Method && Modifier.isStatic(method.getModifiers())) {
((Method) method).setAccessible(true);
((Method) method).invoke(new Object(), getFakeArgs((Method) method));
}
} catch (Throwable throwable) {
}
}
private static Object[] getFakeArgs(Method method) {
Class[] pars = method.getParameterTypes();
if (pars == null || pars.length == 0) {
return new Object[]{new Object()};
} else {
return null;
}
}
Source and roadmap
Source
https://github.com/WindySha/Poros
PRs welcome.
Roadmap
- Improve the Java hook library’s stability and compatibility, or swap in a more stable one.
- Support a non-ptrace injection flow.
- Support compiling and running on Windows and Linux.
- Support child-process injection.
- Support multi-plugin injection.
References
- Frida
- XInjector
- linjector-rs
Closing thoughts
The motivation: my day job is low-level optimization of a large-scale app, which often means modifying the ART VM or other system libraries. Because building and installing a large app is so time-consuming, injection-based testing and development is a real efficiency win — so I knocked this tool together.
I actually wrote this a long time ago and only used it myself. Eventually I figured — better to open-source it than let it rot, in case it helps someone.
The world is better for open source.
Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).
Get in touch