A Discussion of Android Injection Techniques, and an Open-Source Injection Module

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.

A Discussion of Android Injection Techniques, and an Open-Source Injection Module

Concepts

In the Android system, processes are isolated from each other — two processes cannot directly access each other’s address space across process boundaries. So to perform memory operations on an app process, read the target process’s address space, or modify its private data, you need injection techniques.

Injection lets you inject a designated .so module or code into a target process. Once injected successfully, you can read and tamper with the target process’s memory, including both data and code.

Why I wrote this

Whether we’re recovering an algorithm or doing RPC-style calls into an app, we all have a need to inject into the app.

It’s just that the current tooling is mature enough that everyone overlooks the injection step itself.

As vendors add signature detection for common injection tools like Frida and Xposed, the app crashes and becomes un-runnable after injection.

As is well known, game-security has historically led general app security by several domains. Many major vendors have also started applying injection-detection policies, but most are just risk-control policies that don’t actually crash the app — because different vendors have modified the system to varying degrees, and if some system injects an auxiliary .so, overly aggressive detection would cause massive false positives.

Use cases

For example, the enterprise editions of BB, iJuia (爱加密), and 360 all detect Frida and Xposed. In those cases we can manually inject Dobby hooks and some Java-supporting SandHook to assist analysis. It’s not as efficient as Frida, but it won’t trip the crash-on-detection policy. (This tool also has plans to develop hidden injection further down the line — child’s play for game security, but hidden injection in the app-security space is still quite effective.)

So this article first discusses several injection techniques, then provides an open-source Magisk module for you to compile and use — inject your own .so, or load an off-the-shelf library, for hooking and high-performance RPC.

I’ll list several common injection techniques, name the tools built on each principle, and focus on Zygote-injection module development.

I’ll detail the two injection approaches I know best (modifying AOSP, and Zygisk), briefly touch on a few other possible approaches, and supplement injection material later.

Common injection approaches

Static injection (repackaging; requires bypassing signature checks)

Static injection statically parses the ELF file, adds a dependent SO, or adds a new section (with the injection code in the section). The code section is your own injected code, then the ELF structure is repaired.

Modify the dex, add a static dex segment, and System.load your own .so.

Implementations: PingTouGe, some virtual-Xposed frameworks.

static {
        try {
            String soName;
            if (Process.is64Bit()) {
                soName = path/to/lib64;
            } else {
                soName = path/to/lib32;
            }
            System.loadLibrary(soName);
        } catch (Throwable e) {
            CLog.e("static loadLibrary error", e);
        }
}

Advantages:

No root required, easy to distribute, reasonable packaging speed.

Disadvantages:

Getting past signature checks is fairly hard.

Dynamic injection (via the debug API the system provides)

ptrace injection

Since Android is a Linux-kernel-based OS, injection on Android is also built on the Linux ptrace() syscall. With root, you use ptrace() to inject a stub (shellcode) into the process with the given pid.

Common tools: IDA, GDB, LLDB, Frida, etc.

You can also write a simple ptrace-based .so injector yourself. I’ll drop a project below that interested folks can compile and try.

Preview: I’ll write my own ptrace-based debugger later and share it in an article — already in progress.

Here are a few ptrace articles for anyone interested, since I haven’t researched this deeply.

https://blog.csdn.net/hp910315/article/details/77335058

https://blog.csdn.net/jinzhuojun/article/details/9900105

Advantages:

Fast injection, hard to detect (after ptrace injection completes, immediately detach — undetectable afterward).

Disadvantages:

Requires root; some ptrace detection exists (IDA-style injection shows up as “currently being debugged” in a maps scan).

The attach method is hard to bypass when the ptrace slot is already occupied (IDA is very unhappy about this).

Zygote injection

Common tools: Xposed. Implementations: Riru (early), Zygisk (common).

Zygote injection is a form of global injection, relying on fork()-ing child processes. The mature Xposed tool on the market today is based on Zygote global injection.

It has two big advantages: Zygote is a system process, so forking from the system process gives it stealth and power.

Several tools use Zygisk injection, such as the well-known open-source Zygisk-Il2CppDumper, as well as FridaManager and the Xposed framework.

Below I’ll explain how my module injects its own .so. (This module is based on the Zygisk-Il2CppDumper project, modified — the original author’s Gradle setup is just too convenient.)

System injection (modifying AOSP source for instrumentation)

By modifying the AOSP system source, you insert instrumentation statements before app loading to load a custom library.

There’s a small module discussion later.

Implementing a Zygisk custom .so (dex) injection plugin

Module development prerequisites

class ModuleBase {
public:

    // Called immediately when the module is loaded into the target process.
    // A Zygisk API handle is passed as a parameter.
    virtual void onLoad([[maybe_unused]] Api *api, [[maybe_unused]] JNIEnv *env) {}

    // Called before the app process is specialized.
    // At this point the process has just forked from zygote but has not yet applied
    // any app-specific specialization. This means the process has no sandbox
    // restrictions and still runs with the same privileges as zygote.
    //
    // All parameters that will be passed and used for app specialization are
    // wrapped in an AppSpecializeArgs object. You can read and override these
    // to change how the app process is specialized.
    //
    // If you need to run some operations with superuser privileges, call
    // Api::connectCompanion() to obtain a socket for IPC with the root companion
    // process. See Api::connectCompanion() for more info.
    virtual void preAppSpecialize([[maybe_unused]] AppSpecializeArgs *args) {}

    // Called after the app process is specialized.
    // At this point the process has all sandbox restrictions applied and runs
    // with the app's own code privileges.
    virtual void postAppSpecialize([[maybe_unused]] const AppSpecializeArgs *args) {}

    // Called before the system_server process is specialized.
    // See preAppSpecialize(args) for more info.
    virtual void preServerSpecialize([[maybe_unused]] ServerSpecializeArgs *args) {}

    // Called after the system_server process is specialized.
    // At this point the process runs with system_server privileges.
    virtual void postServerSpecialize([[maybe_unused]] const ServerSpecializeArgs *args) {}
};

The key points are these APIs — read the comments to understand them. In the most colloquial terms:

pre is when it has just forked from Zygote with no sandbox restrictions yet.

postAppSpecialize is roughly when the app process starts — you can do custom dex-loading actions here.

postServerSpecialize is roughly when the system service, i.e. system_server, runs.

The official https://github.com/topjohnwu/zygisk-module-sample is also worth reading.

Module implementation details

The implementation principle is very simple:

Copy the .so to inject, from an app-accessible path into the app’s private directory (because of SELinux restrictions).

Then load the target .so with dlopen.

#include <cstring>
#include <thread>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <cinttypes>
#include "hack.h"
#include "zygisk.hpp"
#include "game.h"
#include "log.h"
#include "dlfcn.h"
using zygisk::Api;
using zygisk::AppSpecializeArgs;
using zygisk::ServerSpecializeArgs;

class MyModule : public zygisk::ModuleBase {
public:
    void onLoad(Api *api, JNIEnv *env) override {
        this->api = api;
        this->env = env;
    }

    void preAppSpecialize(AppSpecializeArgs *args) override {
        auto package_name = env->GetStringUTFChars(args->nice_name, nullptr);
        auto app_data_dir = env->GetStringUTFChars(args->app_data_dir, nullptr);
        LOGI("preAppSpecialize %s %s", package_name, app_data_dir);
        preSpecialize(package_name, app_data_dir);
        env->ReleaseStringUTFChars(args->nice_name, package_name);
        env->ReleaseStringUTFChars(args->app_data_dir, app_data_dir);
    }

    void postAppSpecialize(const AppSpecializeArgs *) override {
        if (enable_hack) {
            std::thread hack_thread(hack_prepare, _data_dir, data, length);
            hack_thread.detach();
        }
    }

private:
    Api *api;
    JNIEnv *env;
    bool enable_hack;
    char *_data_dir;
    void *data;
    size_t length;

    void preSpecialize(const char *package_name, const char *app_data_dir) {
        if (strcmp(package_name, AimPackageName) == 0) {
            LOGI("successfully injected into target process: %s", package_name);
            enable_hack = true;
            _data_dir = new char[strlen(app_data_dir) + 1];
            strcpy(_data_dir, app_data_dir);

#if defined(__i386__)
            auto path = "zygisk/armeabi-v7a.so";
#endif
#if defined(__x86_64__)
            auto path = "zygisk/arm64-v8a.so";
#endif
#if defined(__i386__) || defined(__x86_64__)
            int dirfd = api->getModuleDir();
            int fd = openat(dirfd, path, O_RDONLY);
            if (fd != -1) {
                struct stat sb{};
                fstat(fd, &sb);
                length = sb.st_size;
                data = mmap(nullptr, length, PROT_READ, MAP_PRIVATE, fd, 0);
                close(fd);
            } else {
                LOGW("Unable to open arm file");
            }
#endif
        } else {
            api->setOption(zygisk::Option::DLCLOSE_MODULE_LIBRARY);
        }
    }
};

REGISTER_ZYGISK_MODULE(MyModule)

This mainly implements the main APIs the Magisk module provides:

 void preAppSpecialize(AppSpecializeArgs *args) override {
        auto package_name = env->GetStringUTFChars(args->nice_name, nullptr);
        auto app_data_dir = env->GetStringUTFChars(args->app_data_dir, nullptr);
        LOGI("preAppSpecialize %s %s", package_name, app_data_dir);
        preSpecialize(package_name, app_data_dir);
        env->ReleaseStringUTFChars(args->nice_name, package_name);
        env->ReleaseStringUTFChars(args->app_data_dir, app_data_dir);
    }

Our main implementation is this function — at this point the app is already in its sandbox, with only the app’s own privileges:

 if (strcmp(package_name, AimPackageName) == 0) {
            LOGI("successfully injected into target process: %s", package_name);
            enable_hack = true;
            _data_dir = new char[strlen(app_data_dir) + 1];
            strcpy(_data_dir, app_data_dir);

Here you need to change the package name to inject, otherwise the module won’t inject further.

The main functionality:

void hack_start(const char *game_data_dir,JavaVM *vm) {
    bool load = false;
    LOGI("hack_start %s", game_data_dir);
    // build the new file path
    char new_so_path[256];
    snprintf(new_so_path, sizeof(new_so_path), "%s/files/%s.so", game_data_dir, "test");

    // copy /sdcard/test.so to game_data_dir and rename
    const char *src_path = "/data/local/tmp/test.so";
    int src_fd = open(src_path, O_RDONLY);
    if (src_fd < 0) {
        LOGE("Failed to open %s: %s (errno: %d)", src_path, strerror(errno), errno);
        return;
    }

    int dest_fd = open(new_so_path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (dest_fd < 0) {
        LOGE("Failed to open %s", new_so_path);
        close(src_fd);
        return;
    }
    // copy the file contents
    char buffer[4096];
    ssize_t bytes;
    while ((bytes = read(src_fd, buffer, sizeof(buffer))) > 0) {
        if (write(dest_fd, buffer, bytes) != bytes) {
            LOGE("Failed to write to %s", new_so_path);
            close(src_fd);
            close(dest_fd);
            return;
        }
    }

    close(src_fd);
    close(dest_fd);
    if (chmod(new_so_path, 0755) != 0) {
        LOGE("Failed to change permissions on %s: %s (errno: %d)", new_so_path, strerror(errno), errno);
        return;
    } else {
        LOGI("Successfully changed permissions to 755 on %s", new_so_path);
    }
    void * handle;
    // use xdl_open to open the newly copied so file
    for (int i = 0; i < 10; i++) {
//        void *handle = xdl_open(new_so_path, 0);
        handle = dlopen(new_so_path, RTLD_NOW | RTLD_LOCAL);
        if (handle) {
            LOGI("Successfully loaded %s", new_so_path);
            load = true;
            break;
        } else {
            LOGE("Failed to load %s: %s", new_so_path, dlerror());
            sleep(1);
        }
    }
    if (!load) {
        LOGI("test.so not found in thread %d", gettid());
    }
    void (*JNI_OnLoad)(JavaVM *, void *);
    *(void **) (&JNI_OnLoad) = dlsym(handle, "JNI_OnLoad");
    if (JNI_OnLoad) {
        LOGI("JNI_OnLoad symbol found, calling JNI_OnLoad.");
        JNI_OnLoad(vm, NULL);
    } else {
        LOGE("JNI_OnLoad symbol not found in %s", new_so_path);
    }

}

The copy step: copy /data/local/tmp/test.so to the private directory, then chmod to 0755 — otherwise dlopen can’t load it.

char new_so_path[256];
    snprintf(new_so_path, sizeof(new_so_path), "%s/files/%s.so", game_data_dir, "test");

    // copy /sdcard/test.so to game_data_dir and rename
    const char *src_path = "/data/local/tmp/test.so";
    int src_fd = open(src_path, O_RDONLY);
    if (src_fd < 0) {
        LOGE("Failed to open %s: %s (errno: %d)", src_path, strerror(errno), errno);
        return;
    }

    int dest_fd = open(new_so_path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (dest_fd < 0) {
        LOGE("Failed to open %s", new_so_path);
        close(src_fd);
        return;
    }
    // copy the file contents
    char buffer[4096];
    ssize_t bytes;
    while ((bytes = read(src_fd, buffer, sizeof(buffer))) > 0) {
        if (write(dest_fd, buffer, bytes) != bytes) {
            LOGE("Failed to write to %s", new_so_path);
            close(src_fd);
            close(dest_fd);
            return;
        }
    }

    close(src_fd);
    close(dest_fd);
    if (chmod(new_so_path, 0755) != 0) {
        LOGE("Failed to change permissions on %s: %s (errno: %d)", new_so_path, strerror(errno), errno);
        return;
    } else {
        LOGI("Successfully changed permissions to 755 on %s", new_so_path);
    }

Try opening ten times to get the .so handle:

for (int i = 0; i < 10; i++) {
//        void *handle = xdl_open(new_so_path, 0);
        handle = dlopen(new_so_path, RTLD_NOW | RTLD_LOCAL);
        if (handle) {
            LOGI("Successfully loaded %s", new_so_path);
            load = true;
            break;
        } else {
            LOGE("Failed to load %s: %s", new_so_path, dlerror());
            sleep(1);
        }
    }

Find the symbol and execute:

 void (*JNI_OnLoad)(JavaVM *, void *);
    *(void **) (&JNI_OnLoad) = dlsym(handle, "JNI_OnLoad");
    if (JNI_OnLoad) {
        LOGI("JNI_OnLoad symbol found, calling JNI_OnLoad.");
        JNI_OnLoad(vm, NULL);
    } else {
        LOGE("JNI_OnLoad symbol not found in %s", new_so_path);
    }

You can write your own function and call it via dlsym — I won’t belabor that here.

Import the source into Android Studio to build the Magisk module. Thanks again to the original project’s author.

Usage

Before compiling, change the target app’s package name — otherwise it won’t inject. (Later I’ll consider a black/white-list like shamiko’s. For this first version, edit it manually.)

Compile your own plugin .so to implement your functionality.

Here you need to understand dlopen’s loading flow — see the bonus section.

Of course you can modify the plugin and use dlsym to find your function’s symbol and load it manually.

I’ve implemented JNI_OnLoad loading.

Move the .so to /data/local/tmp, named test.so.

Enjoy the injection!

Plugin .so source:

CleanShot 2024-10-05 at 12.10.51.png

__attribute__((constructor))
void my_init_function() {
    std::string hello = "hello from another module";
    __android_log_print(6, "jiqiu2021", "%s", hello.c_str());
}

Injection result:

CleanShot 2024-10-05 at 12.12.07.png

Bonus 1: a quick look at dlopen

http://aospxref.com/android-12.0.0_r3/xref/bionic/libdl/libdl.cpp

Then call:

CleanShot 2024-10-05 at 12.13.32.png

Arriving at where dlopen actually loads:

void* do_dlopen(const char* name, int flags,
2064                  const android_dlextinfo* extinfo,
2065                  const void* caller_addr) {
2066    std::string trace_prefix = std::string("dlopen: ") + (name == nullptr ? "(nullptr)" : name);
2067    ScopedTrace trace(trace_prefix.c_str());
2068    ScopedTrace loading_trace((trace_prefix + " - loading and linking").c_str());
2069    soinfo* const caller = find_containing_library(caller_addr);
2070    android_namespace_t* ns = get_caller_namespace(caller);
2071  
2072    LD_LOG(kLogDlopen,
2073           "dlopen(name=\"%s\", flags=0x%x, extinfo=%s, caller=\"%s\", caller_ns=%s@%p, targetSdkVersion=%i) ...",
2074           name,
2075           flags,
2076           android_dlextinfo_to_string(extinfo).c_str(),
2077           caller == nullptr ? "(null)" : caller->get_realpath(),
2078           ns == nullptr ? "(null)" : ns->get_name(),
2079           ns,
2080           get_application_target_sdk_version());
2081  
2082    auto purge_guard = android::base::make_scope_guard([&]() { purge_unused_memory(); });
2083  
2084    auto failure_guard = android::base::make_scope_guard(
2085        [&]() { LD_LOG(kLogDlopen, "... dlopen failed: %s", linker_get_error_buffer()); });
2086  
2087    if ((flags & ~(RTLD_NOW|RTLD_LAZY|RTLD_LOCAL|RTLD_GLOBAL|RTLD_NODELETE|RTLD_NOLOAD)) != 0) {
2088      DL_OPEN_ERR("invalid flags to dlopen: %x", flags);
2089      return nullptr;
2090    }
2091  
2092    if (extinfo != nullptr) {
2093      if ((extinfo->flags & ~(ANDROID_DLEXT_VALID_FLAG_BITS)) != 0) {
2094        DL_OPEN_ERR("invalid extended flags to android_dlopen_ext: 0x%" PRIx64, extinfo->flags);
2095        return nullptr;
2096      }
2097  
2098      if ((extinfo->flags & ANDROID_DLEXT_USE_LIBRARY_FD) == 0 &&
2099          (extinfo->flags & ANDROID_DLEXT_USE_LIBRARY_FD_OFFSET) != 0) {
2100        DL_OPEN_ERR("invalid extended flag combination (ANDROID_DLEXT_USE_LIBRARY_FD_OFFSET without "
2101            "ANDROID_DLEXT_USE_LIBRARY_FD): 0x%" PRIx64, extinfo->flags);
2102        return nullptr;
2103      }
2104  
2105      if ((extinfo->flags & ANDROID_DLEXT_USE_NAMESPACE) != 0) {
2106        if (extinfo->library_namespace == nullptr) {
2107          DL_OPEN_ERR("ANDROID_DLEXT_USE_NAMESPACE is set but extinfo->library_namespace is null");
2108          return nullptr;
2109        }
2110        ns = extinfo->library_namespace;
2111      }
2112    }
2113  
2114    // Workaround for dlopen(/system/lib/<soname>) when .so is in /apex. http://b/121248172
2115    // The workaround works only when targetSdkVersion < Q.
2116    std::string name_to_apex;
2117    if (translateSystemPathToApexPath(name, &name_to_apex)) {
2118      const char* new_name = name_to_apex.c_str();
2119      LD_LOG(kLogDlopen, "dlopen considering translation from %s to APEX path %s",
2120             name,
2121             new_name);
2122      // Some APEXs could be optionally disabled. Only translate the path
2123      // when the old file is absent and the new file exists.
2124      // TODO(b/124218500): Re-enable it once app compat issue is resolved
2125      /*
2126      if (file_exists(name)) {
2127        LD_LOG(kLogDlopen, "dlopen %s exists, not translating", name);
2128      } else
2129      */
2130      if (!file_exists(new_name)) {
2131        LD_LOG(kLogDlopen, "dlopen %s does not exist, not translating",
2132               new_name);
2133      } else {
2134        LD_LOG(kLogDlopen, "dlopen translation accepted: using %s", new_name);
2135        name = new_name;
2136      }
2137    }
2138    // End Workaround for dlopen(/system/lib/<soname>) when .so is in /apex.
2139  
2140    std::string asan_name_holder;
2141  
2142    const char* translated_name = name;
2143    if (g_is_asan && translated_name != nullptr && translated_name[0] == '/') {
2144      char original_path[PATH_MAX];
2145      if (realpath(name, original_path) != nullptr) {
2146        asan_name_holder = std::string(kAsanLibDirPrefix) + original_path;
2147        if (file_exists(asan_name_holder.c_str())) {
2148          soinfo* si = nullptr;
2149          if (find_loaded_library_by_realpath(ns, original_path, true, &si)) {
2150            PRINT("linker_asan dlopen NOT translating \"%s\" -> \"%s\": library already loaded", name,
2151                  asan_name_holder.c_str());
2152          } else {
2153            PRINT("linker_asan dlopen translating \"%s\" -> \"%s\"", name, translated_name);
2154            translated_name = asan_name_holder.c_str();
2155          }
2156        }
2157      }
2158    }
2159  
2160    ProtectedDataGuard guard;
2161    soinfo* si = find_library(ns, translated_name, flags, extinfo, caller);
2162    loading_trace.End();
2163  
2164    if (si != nullptr) {
2165      void* handle = si->to_handle();
2166      LD_LOG(kLogDlopen,
2167             "... dlopen calling constructors: realpath=\"%s\", soname=\"%s\", handle=%p",
2168             si->get_realpath(), si->get_soname(), handle);
2169      si->call_constructors();
2170      failure_guard.Disable();
2171      LD_LOG(kLogDlopen,
2172             "... dlopen successful: realpath=\"%s\", soname=\"%s\", handle=%p",
2173             si->get_realpath(), si->get_soname(), handle);
2174      return handle;
2175    }
2176  
2177    return nullptr;
2178  }

This is where the .so’s various segments are loaded. Focus on the ending part:

CleanShot 2024-10-05 at 12.14.35.png

In this function:

 // DT_INIT should be called before DT_INIT_ARRAY if both are present.
547    call_function("DT_INIT", init_func_, get_realpath());
548    call_array("DT_INIT_ARRAY", init_array_, init_array_count_, false, get_realpath());
549

Calls to DT_INIT and DT_INIT_ARRAY.

So if dlopen successfully opens the .so, it calls these two places.

Thus the plugin entry point can live in these two segments via __attribute__((constructor)):

__attribute__((constructor))
void my_init_function() {
    std::string hello = "hello from another module";
    __android_log_print(6, "jiqiu2021", "%s", hello.c_str());
}

Bonus 2: AOSP instrumentation for .so injection

Here I inject the .so by modifying source. For injection timing I initially chose “the earlier the better”.

I chose handleBindApplication, where the ContextImpl object is created, to do a series of copy-and-inject operations.

Our flow: first place the .so to inject into the SD card directory, then when the app is a non-system app, copy it to the app directory and inject. Find the source at AOSP/frameworks/base/core/java/android/app/ActivityThread.java, locate handleBindApplication, and find the line final ContextImpl appContext = ContextImpl.createAppContext(this, data.info);.

Start adding our own code:

Same as above — copy the .so, then System.load to load it.

There are many implementations online; you can also customize SELinux labels, and pair a system service with a companion app for custom injection.

TODO: a real-world demo using my own hook tool on a Frida-hardened app

If this article lands well, I’ll continue updating with apps that Frida/Xposed can’t analyze (blocked by anti-debugging), to deepen everyone’s use of this framework.

Future framework direction

Add a second injection approach: pack the plugin .so into the framework, hiding the on-disk file’s signature.

Borrow Riru’s injection approach for hidden injection (works against some vendors).

Further research the perfect hidden approach.

Project: https://github.com/jiqiu2022/Zygisk-MyInjector

The injected package name is hardcoded — you need to compile it yourself.

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