Persistent Native Hooking Without Repackaging: Swap the .so and Inline-Hook the Linker

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.


Persistent Native Hooking Without Repackaging: Swap the .so and Inline-Hook the Linker

The two functions that do the work

  • void* dlopen(const char *filename, int flag) — opens a dynamic library in the specified mode, returns a handle to the calling process (use dlclose() to unload it).
  • void* dlsym(void *handle, const char *symbol) — given a handle and a symbol, returns the address of that symbol.

The goal

By combining these two functions with inline hooking, we can achieve a persistent native hook under root without touching any smali and without repackaging the APK.

The trick (arm32)

System.loadLibrary() can load an .so from /data/app/<package>/lib/arm/ by name, and inline hooking itself works by loading an .so that patches the target’s original machine code at runtime. The normal way to get a custom .so loaded is to add a System.loadLibrary() call into the smali. We don’t do normal.

The idea: compile an inline-hook .so, name it after the app’s original .so, rename the original .so to something else, and have our .so load the original. That’s the whole principle.

Two problems to solve

From here on I’ll call the app’s original library the original so, and the one we compiled the new so.

  1. After the swap, we need to know the handle that the system’s dlopen returns for the new so.
  2. After the swap, every function that was statically or dynamically registered now lives in the original so, and the new so knows nothing about it.

Solving them

I first tried to prototype this in Frida:

  1. Hook android_dlopen_ext.
  2. If the first argument’s path contains the new so, record the handle.
  3. If the path contains the original so, intercept dlsym() and replace its first argument with the recorded handle.

It wouldn’t work — my guess is that the consecutive calls exposed a Frida performance issue (I’d be happy to hear a proper explanation). What worked was taking the pointer returned by Module.findExportByName() and replacing the dlsym return value directly. I haven’t read Frida’s source, but judging by the arguments and result, that API appears to just return whatever dlsym returns.

Then I moved to inline hooking — but there’s a bootstrapping problem: the system hasn’t even loaded the new so yet, so there’s nothing to hook. The obvious fix is to call dlopen once ourselves to load the new so first.

Before going further, a quick look at why the original call doesn’t land where we expect:

The dlopen/dlsym call detail

The important part is what we already said: dlopen returns a handle, and dlsym takes two arguments — the first is that handle, the second is the symbol we want. So we inline-hook both functions, manually load the original so, trigger its JNI_OnLoad(), and once the inline hook is live, replace the first argument of dlsym with the original so’s handle whenever it arrives carrying the new so’s handle.

A worked example

I’ll use a well-known Android app (package com.example.targetapp) as the target.

Before the swap

Steps

  • Rename the original libmarsxlog.so to libmarsxlogcp.so.
  • Compile our hook .so, name it libmarsxlog.so, and drop it into the library directory.
  • Launch the app and watch the logs.

Inline hooking replaces the first 8 bytes of the target function (two ARM instructions or four Thumb instructions) with a jump. Frida, by contrast, sees the original arguments at enter, which is why it looks like the wrong handle was passed yet the function doesn’t crash:

After the swap

This device didn’t have global debugging enabled, so I used Frida just to print the log calls:

function hookLog() {
    var isFirst = true
    Interceptor.attach(Module.findExportByName("liblog.so","__android_log_print"), {
        onEnter: function (args) {
            if(isFirst) {
                console.log("\n")
                isFirst = false
            }
            if(args[1].readCString().indexOf("ZZZ")!=-1)
                console.log(args[1].readCString()+"\t"+args[2].readCString()+"\t"+args[3]+"\t"+args[4]+"\t"+args[5])
        }
    });
}

__android_log_print is variadic, so to make every argument visible I just print a few extras and ignore the noise.

The replaced arguments

As for why dlopen takes two parameters in its header but three here — it’s because the second argument is the path; look at the disassembly (F5). Same story for dlsym:

dlopen’s real arguments

For packed apps, this same technique can be used to hook the critical points — OpenCommon / OpenMemory. A C implementation that swaps the original so lets you hook at startup and dump the dex. Unlike Xposed or Frida, an inline-hook approach like this has essentially nothing for anti-debugging to detect, which is why it’s so attractive.

Source code: inlinehook

#include <stdio.h>
#include <jni.h>

#include "../inlineHook/include/inlineHook.h"
#include <android/log.h>

#include <unistd.h>
#include <stdlib.h>
#include <dlfcn.h>

#define LOG_TAG "ZZZ"
#define LOGD(fmt, args...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG,fmt, ##args)
#define LOGE(fmt, args...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG,fmt, ##args)

// addresses of the functions to hook
unsigned int func_dlopen = NULL;
unsigned int func_dlsym = NULL;

// new-so handle
void *p0;
// original-so handle
void *p1;
int *env;

static unsigned long find_module_by_name(char *soName) {
    char filename[32];
    char cmdline[256];
    sprintf(filename, "/proc/%d/maps", getpid());
    LOGD("filename = %s", filename);
    FILE *fp = fopen(filename, "r");
    unsigned long revalue = 0;
    if (fp) {
        while (fgets(cmdline, 256, fp)) // read line by line
        {
            if (strstr(cmdline, soName) && strstr(cmdline, "r-xp"))// filter
            {
                LOGD("cmdline = %s", cmdline);
                char *str = strstr(cmdline, "-");
                if (str) {
                    *str = '\0';
                    char num[32];
                    sprintf(num, "0x%s", cmdline);
                    revalue = strtoul(num, NULL, 0);
                    LOGD("revalue = %lu", revalue);
                    return revalue;
                }
            }
            memset(cmdline, 0, 256); // clear
        }
        fclose(fp);
    }
    return 0L;
}

// original function pointers
void* (*old_func_dlopen)(const char* filename, int flags, const void* caller_addr,void* s) = NULL;
void* (*old_fun_dlsym)(void* /*handle*/, const char* /*symbol*/) = NULL;

void* new_func_dlopen(const char *filename, int flags, const void *caller_addr, void *s) {
    void* p = old_func_dlopen(filename,flags,caller_addr,s);
    LOGD("%p = __loader_dlopen('%s','%d','%p')",p,filename,flags,caller_addr);
    return p;
}

void* new_func_dlsym(void *handle, const char *symbol){
    if(handle == p0 && strstr(symbol,"JNI_OnLoad") == NULL){
        handle = p1;
        LOGD("change handle from %p to %p",p0,p1);
    }
    void* ret = old_fun_dlsym(handle,symbol);
    LOGD("%p = __loader_dlsym('%p','%s')",ret,handle,symbol);
    return ret;
}

jint JNICALL
JNI_OnLoad(JavaVM *vm, void *reserved) {

    LOGE("------------------- JNI_OnLoad -------------------");

    if (vm->GetEnv( (void**)&env, JNI_VERSION_1_6) == JNI_OK) {
        LOGD("GetEnv OK");
    }

    char *lib_name = const_cast<char *>("linker");
    unsigned int base = find_module_by_name(lib_name);
    LOGD("Find %s at 0x%x ", lib_name,base);

    // compute the function addresses to hook (baseAddress + offset + thumb ? 1 : 0)
    // these offsets come from the linker's exported symbols and differ across OS versions
    // this build targets a Nexus 6P on Android 9
    func_dlopen = base + 0x75A4+1;
    func_dlsym = base + 0x76F0+1;

    LOGE("------------------- InlineHook -------------------");

    // register the hooks
    registerInlineHook((uint32_t) func_dlopen, (uint32_t) new_func_dlopen, (uint32_t **) &old_func_dlopen)==ELE7EN_OK ?
    LOGD("Success Hook func_dlopen at 0x%x",func_dlopen):LOGE("Fail Hook func_dlopen at 0x%x",func_dlopen);

    registerInlineHook((uint32_t) func_dlsym, (uint32_t) new_func_dlsym, (uint32_t **) &old_fun_dlsym)==ELE7EN_OK ?
    LOGD("Success Hook func_dlsym at 0x%x",func_dlsym):LOGE("Fail Hook func_dlsym at 0x%x",func_dlsym);

    inlineHookAll();

    // new so
    p0 = dlopen("/data/data/com.example.targetapp/lib/libmarsxlog.so", 0);
    LOGD("dlopen libmarsxlog.so handle = 0x%p",p0);
    // original so
    p1 = dlopen("/data/data/com.example.targetapp/lib/libmarsxlogcp.so", 0);
    LOGD("dlopen libmarsxlogcp.so handle =  0x%p",p1);

    // get the original so's JNI_OnLoad() address
    void *p2 = dlsym(p1, "JNI_OnLoad");
    LOGD("called dlsym JNI_OnLoad 0x%p",p2);
    // manually call the original so's JNI_OnLoad()
    p2(vm,reserved);
    LOGE("-------------------  Function  -------------------");

    return JNI_VERSION_1_6;
}

Takeaways

  • If we can redirect dlsym to hand back a different function, that’s a hook primitive all by itself — control dlsym and you control every exported function call.
  • By extension, PLT-based hooking looks like it can do something similar too.

A hook idea

  • For resolving the addresses of dlopen and dlsym, I used base-address-plus-offset here. A more portable alternative is to write a one-shot function whose only job is to call dlopen, then hook that function to obtain a proxy address for dlopen.

Proxying the two functions

  • The most generic approach: typedef a dlopen of our own, declare a function pointer, point it at dlopen, and cast it:
typedef int (*dlopen_tp)(const char* __filename, int __flag);
dlopen_tp my_dlopen = (dlopen_tp)dlopen;

func_dlopen = base + 0x2F00+1;
LOGD("DLOPEN = %p   -----   %p ",my_dlopen,func_dlopen);

With that, my_dlopen resolves to the real func_dlopen.


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