Detecting Zygisk Injection via the dlclose soinfo Gap

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.

Detecting Zygisk Injection via the dlclose soinfo Gap

Preface

The zygiskNext I’d been using for a long time stopped being open source, so I wrote a similar project from scratch. Originally I was modifying zygiskNext, but I later hit an anti-detection problem I couldn’t get around, so I ended up refactoring it entirely.

The injection-detection problem

Old versions of zygiskNext themselves carry many fingerprints; I’ll skip those. I’ll only describe one technique that can reliably detect the injection method used by old zygiskNext and Magisk’s zygisk (new versions of zygiskNext have fixed this, and I can’t guarantee this technique works 100%).

The detection principle in brief

Traverse the soinfo list. If there’s a gap, it means a .so loaded too early in the zygote process called dlclose — which almost certainly means the zygote has been compromised.

zygisk’s .so self-unload

To understand this one-shot detection, you need to know exactly what zygisk does.

zygisk has a very clever .so self-unload technique — it blew my mind when I was first studying it.

We all know zygisk injects a .so into the zygote process, but once the app process starts, that .so disappears from the app process. At first I thought it opened a monitoring process or used ptrace; later I found it was neither. Digging into the code, I found this hook:

// We cannot directly call `dlclose` to unload ourselves, otherwise when `dlclose` returns,
// it will return to our code which has been unmapped, causing segmentation fault.
// Instead, we hook `pthread_attr_destroy` which will be called when VM daemon threads start.
DCL_HOOK_FUNC(int, pthread_attr_destroy, void *target) {
    int res = old_pthread_attr_destroy((pthread_attr_t *)target);

    // Only perform unloading on the main thread
    if (gettid() != getpid())
        return res;

    LOGV("pthread_attr_destroy\n");
    if (should_unmap_zygisk) {
        unhook_functions();
        if (should_unmap_zygisk) {
            // Because both `pthread_attr_destroy` and `dlclose` have the same function signature,
            // we can use `musttail` to let the compiler reuse our stack frame and thus
            // `dlclose` will directly return to the caller of `pthread_attr_destroy`.
            [[clang::musttail]] return dlclose(self_handle);
        }
    }

    return res;
}

It’s a PLT hook — nothing magical-looking — but this is exactly where the .so unloads itself.

The principle: it hooks a function with the same signature (and roughly the same return semantics) as dlclose. Look at how this function compiles:

compiled musttail codeFig. 1

Normally calling a system function requires a bl instruction, but here it uses b. After our code runs, it carries the previous hooked function’s return address (its stack) into b dlclose; dlclose then returns exactly as if it had been called via bl, returning through the address on the previous stack frame.

clang::musttail is likely what turns the bl into a b.

dlclose-based .so loading detection

soinfo allocation

You can see the function above calls dlclose. Intuitively, after a dlopen, once the function finishes and closes, that’s the end of it — but not quite.

Let’s look at how soinfo is created:

soinfo* soinfo_alloc(android_namespace_t* ns, const char* name,const struct stat* file_stat, off64_t file_offset,uint32_t rtld_flags) {

	if (strlen(name) >= PATH_MAX) {
		async_safe_fatal("library name \"%s\" too long", name);
	}
	TRACE("name %s: allocating soinfo for ns=%p", name, ns);
	soinfo* si = new (g_soinfo_allocator.alloc()) soinfo(ns, name, file_stat,
	file_offset, rtld_flags);
	solist_add_soinfo(si);
	si->generate_handle();
	ns->add_soinfo(si);
	TRACE("name %s: allocated soinfo @ %p", name, si);
	return si;
}

The new creates the soinfo in memory returned by g_soinfo_allocator.alloc(), which means all soinfo addresses end up tightly adjacent to one another. If we close a soinfo in the middle, that middle slot is left empty. And our detection is exactly this: detect whether there’s an empty soinfo slot in the middle.

Code for inspecting soinfo spacing

#include "jni.h"
#include "vector"
#include "elf_symbol_resolver.h"

#include "android/log.h"

#define LOGV(...)  ((void)__android_log_print(ANDROID_LOG_INFO, "soinfo", __VA_ARGS__))


#define soinfo uintptr_t


soinfo* (*solist_get_head)() = (soinfo* (*)()) linkerResolveElfInternalSymbol(
        get_android_linker_path(), "__dl__Z15solist_get_headv");

soinfo* (*solist_get_somain)() = (soinfo* (*)()) linkerResolveElfInternalSymbol(
        get_android_linker_path(), "__dl__Z17solist_get_somainv");

char* (*soinfo_get_soname)(soinfo*) = (char* (*)(soinfo*)) linkerResolveElfInternalSymbol(
        get_android_linker_path(), "__dl__ZNK6soinfo10get_sonameEv");





// Dynamically compute the struct member positions, loop to find the `next` pointer
// inside soinfo, then traverse via `next`.
void find_all_library_name(){
    std::vector<void *> linker_solist;

    static uintptr_t *solist_head = NULL;
    if (!solist_head)
        solist_head = (uintptr_t *)solist_get_head();


    static uintptr_t somain = 0;

    if (!somain)
        somain = (uintptr_t)solist_get_somain();

    // Generate the name for an offset.
#define PARAM_OFFSET(type_, member_) __##type_##__##member_##__offset_
#define STRUCT_OFFSET PARAM_OFFSET
    int STRUCT_OFFSET(solist, next) = 0;
    for (size_t i = 0; i < 1024 / sizeof(void *); i++) {
        if (*(uintptr_t *)((uintptr_t)solist_head + i * sizeof(void *)) == somain) {
            STRUCT_OFFSET(solist, next) = i * sizeof(void *);
            break;
        }
    }

    linker_solist.push_back(solist_head);

    uintptr_t sonext = 0;
    sonext = *(uintptr_t *)((uintptr_t)solist_head + STRUCT_OFFSET(solist, next));
    while (sonext) {
        linker_solist.push_back((void *)sonext);
        sonext = *(uintptr_t *)((uintptr_t)sonext + STRUCT_OFFSET(solist, next));
        if(sonext == 0){
            continue;
        }
        char* ret_name = soinfo_get_soname(reinterpret_cast<soinfo *>(sonext));
        LOGV("soinfo addr:%p %s",sonext,ret_name);
    }

    return ;
}

Comparing different soinfo layouts

A normal soinfo list:

soinfo_addr:0x72a9159268 size: 0x0,    linux-vdso.so.1
soinfo_addr:0x72a91594c0 size: 0x258,  libandroid_runtime.so
soinfo_addr:0x72a9159718 size: 0x258,  libbinder.so
soinfo_addr:0x72a9159970 size: 0x258,  libcutils.so
soinfo_addr:0x72a9159bc8 size: 0x258,  libhidlbase.so
soinfo_addr:0x72a9159e20 size: 0x258,  liblog.so
soinfo_addr:0x72a915a078 size: 0x258,  libnativeloader.so
soinfo_addr:0x72a915a2d0 size: 0x258,  libsigchain.so
soinfo_addr:0x72a915a528 size: 0x258,  libutils.so
soinfo_addr:0x72a915a780 size: 0x258,  libwilhelm.so
soinfo_addr:0x72a915a9d8 size: 0x258,  libc++.so
soinfo_addr:0x72a915ac30 size: 0x258,  libc.so
soinfo_addr:0x72a915ae88 size: 0x258,  libm.so
soinfo_addr:0x72a915b0e0 size: 0x258,  libdl.so
soinfo_addr:0x72a915b338 size: 0x258,  libbase.so
soinfo_addr:0x72a915b590 size: 0x258,  libharfbuzz_ng.so
soinfo_addr:0x72a915b7e8 size: 0x258,  libminikin.so
soinfo_addr:0x72a915ba40 size: 0x258,  libz.so
soinfo_addr:0x72a915bc98 size: 0x258,  android.media.audio.common.types-V1-cpp.so
soinfo_addr:0x72a915bef0 size: 0x258,  audioclient-types-aidl-cpp.so
soinfo_addr:0x72a915c148 size: 0x258,  audioflinger-aidl-cpp.so
soinfo_addr:0x72a915c3a0 size: 0x258,  audiopolicy-types-aidl-cpp.so
soinfo_addr:0x72a915c5f8 size: 0x258,  spatializer-aidl-cpp.so
soinfo_addr:0x72a915c850 size: 0x258,  av-types-aidl-cpp.so
soinfo_addr:0x72a915caa8 size: 0x258,  android.hardware.camera.device@3.2.so
soinfo_addr:0x72a915cd00 size: 0x258,  libandroid_net.so
soinfo_addr:0x72a915cf58 size: 0x258,  libandroidicu.so
soinfo_addr:0x72a915d1b0 size: 0x258,  libbattery.so
soinfo_addr:0x72a915d408 size: 0x258,  libnetdutils.so
soinfo_addr:0x72a915d660 size: 0x258,  libmemtrack.so
soinfo_addr:0x72a915d8b8 size: 0x258,  libandroidfw.so
soinfo_addr:0x72a915db10 size: 0x258,  libappfuse.so
soinfo_addr:0x72a915dd68 size: 0x258,  libcrypto.so
soinfo_addr:0x72a915dfc0 size: 0x258,  libdebuggerd_client.so
soinfo_addr:0x72a915e218 size: 0x258,  libbinder_ndk.so
soinfo_addr:0x72a915e470 size: 0x258,  libui.so
soinfo_addr:0x72a915e6c8 size: 0x258,  libgraphicsenv.so
soinfo_addr:0x72a915e920 size: 0x258,  libgui.so
soinfo_addr:0x72a915eb78 size: 0x258,  libhwui.so
soinfo_addr:0x72a915edd0 size: 0x258,  libmediandk.so
soinfo_addr:0x72a915f028 size: 0x258,  libpermission.so
soinfo_addr:0x72a915f280 size: 0x258,  libsensor.so
soinfo_addr:0x72a915f4d8 size: 0x258,  libinput.so
soinfo_addr:0x72a915f730 size: 0x258,  libcamera_client.so
soinfo_addr:0x72a915f988 size: 0x258,  libcamera_metadata.so
soinfo_addr:0x72a915fbe0 size: 0x258,  libprocinfo.so
soinfo_addr:0x72a915fe38 size: 0x258,  libsqlite.so
soinfo_addr:0x72a9160090 size: 0x258,  libEGL.so
soinfo_addr:0x72a91602e8 size: 0x258,  libGLESv1_CM.so
soinfo_addr:0x72a9160540 size: 0x258,  libGLESv2.so
soinfo_addr:0x72a9160798 size: 0x258,  libGLESv3.so
soinfo_addr:0x72a91609f0 size: 0x258,  libincfs.so
soinfo_addr:0x72a9160c48 size: 0x258,  libdataloader.so
soinfo_addr:0x72a9160ea0 size: 0x258,  libvulkan.so
soinfo_addr:0x72a91610f8 size: 0x258,  libETC1.so
soinfo_addr:0x72a9161350 size: 0x258,  libjpeg.so
soinfo_addr:0x72a91615a8 size: 0x258,  libhardware.so
soinfo_addr:0x72a9161800 size: 0x258,  libhardware_legacy.so
soinfo_addr:0x72a9161a58 size: 0x258,  libselinux.so
soinfo_addr:0x72a9161cb0 size: 0x258,  libmedia.so
soinfo_addr:0x72a9161f08 size: 0x258,  libmedia_helper.so
soinfo_addr:0x72a9162160 size: 0x258,  libmediametrics.so
soinfo_addr:0x72a91623b8 size: 0x258,  libmeminfo.so
soinfo_addr:0x72a9162610 size: 0x258,  libaudioclient.so
soinfo_addr:0x72a9162868 size: 0x258,  libaudioclient_aidl_conversion.so
soinfo_addr:0x72a9162ac0 size: 0x258,  libaudiofoundation.so
soinfo_addr:0x72a9162d18 size: 0x258,  libaudiopolicy.so
soinfo_addr:0x72a9162f70 size: 0x258,  libusbhost.so
soinfo_addr:0x72a91631c8 size: 0x258,  libpdfium.so
soinfo_addr:0x72a9163420 size: 0x258,  libimg_utils.so
soinfo_addr:0x72a9163678 size: 0x258,  libnetd_client.so
soinfo_addr:0x72a91638d0 size: 0x258,  libprocessgroup.so
soinfo_addr:0x72a9163b28 size: 0x258,  libnativebridge_lazy.so
soinfo_addr:0x72a9163d80 size: 0x258,  libnativeloader_lazy.so
soinfo_addr:0x72a9163fd8 size: 0x258,  libmemunreachable.so
soinfo_addr:0x72a9164230 size: 0x258,  libvintf.so
soinfo_addr:0x72a9164488 size: 0x258,  libnativedisplay.so
soinfo_addr:0x72a91646e0 size: 0x258,  libnativewindow.so
soinfo_addr:0x72a9164938 size: 0x258,  libdl_android.so
soinfo_addr:0x72a9164b90 size: 0x258,  libtimeinstate.so
soinfo_addr:0x72a9164de8 size: 0x258,  server_configurable_flags.so
soinfo_addr:0x72a9165040 size: 0x258,  libvndksupport.so
soinfo_addr:0x72a9165298 size: 0x258,  libnativebridge.so
soinfo_addr:0x72a91654f0 size: 0x258,  libbase.so
soinfo_addr:0x72a9165748 size: 0x258,  libc++.so
soinfo_addr:0x72a91659a0 size: 0x258,  framework-permission-aidl-cpp.so
soinfo_addr:0x72a9165bf8 size: 0x258,  libmedia_codeclist.so
soinfo_addr:0x72a9165e50 size: 0x258,  libaudiomanager.so
soinfo_addr:0x72a91660a8 size: 0x258,  libdatasource.so
soinfo_addr:0x72a9166300 size: 0x258,  libstagefright.so
soinfo_addr:0x72a9166558 size: 0x258,  libstagefright_foundation.so
soinfo_addr:0x72a91667b0 size: 0x258,  libstagefright_http_support.so
soinfo_addr:0x72a9166a08 size: 0x258,  libicu.so
soinfo_addr:0x72a9166c60 size: 0x258,  effect-aidl-cpp.so
soinfo_addr:0x72a9166eb8 size: 0x258,  shared-file-region-aidl-cpp.so
soinfo_addr:0x72a9167110 size: 0x258,  android.hardware.camera.common@1.0.so
soinfo_addr:0x72a9167368 size: 0x258,  android.hardware.graphics.common@1.0.so
soinfo_addr:0x72a91675c0 size: 0x258,  libbase.so
soinfo_addr:0x72a9167818 size: 0x258,  libicuuc.so
soinfo_addr:0x72a9167a70 size: 0x258,  libicui18n.so
soinfo_addr:0x72a9167cc8 size: 0x258,  libc++.so
soinfo_addr:0x72a9167f20 size: 0x258,  android.hardware.memtrack@1.0.so
soinfo_addr:0x72a9168178 size: 0x258,  android.hardware.memtrack-V1-ndk.so
soinfo_addr:0x72a91683d0 size: 0x258,  libandroid_runtime_lazy.so
soinfo_addr:0x72a9168628 size: 0x258,  android.hardware.graphics.allocator@2.0.so
soinfo_addr:0x72a9168880 size: 0x258,  android.hardware.graphics.allocator@3.0.so
soinfo_addr:0x72a9168ad8 size: 0x258,  android.hardware.graphics.allocator@4.0.so
soinfo_addr:0x72a9168d30 size: 0x258,  android.hardware.graphics.allocator-V1-ndk.so
soinfo_addr:0x72a9168f88 size: 0x258,  android.hardware.graphics.common-V3-ndk.so
soinfo_addr:0x72a91691e0 size: 0x258,  android.hardware.graphics.common@1.2.so
soinfo_addr:0x72a9169438 size: 0x258,  android.hardware.graphics.mapper@2.0.so
soinfo_addr:0x72a9169690 size: 0x258,  android.hardware.graphics.mapper@2.1.so
soinfo_addr:0x72a91698e8 size: 0x258,  android.hardware.graphics.mapper@3.0.so
soinfo_addr:0x72a9169b40 size: 0x258,  android.hardware.graphics.mapper@4.0.so
soinfo_addr:0x72a9169d98 size: 0x258,  libgralloctypes.so
soinfo_addr:0x72a9169ff0 size: 0x258,  libsync.so
soinfo_addr:0x72a916a248 size: 0x258,  android.hardware.graphics.bufferqueue@1.0.so
soinfo_addr:0x72a916a4a0 size: 0x258,  android.hardware.graphics.bufferqueue@2.0.so
soinfo_addr:0x72a916a6f8 size: 0x258,  android.hardware.graphics.common@1.1.so
soinfo_addr:0x72a916a950 size: 0x258,  android.hidl.token@1.0-utils.so
soinfo_addr:0x72a916aba8 size: 0x258,  libbufferhub.so
soinfo_addr:0x72a916ae00 size: 0x258,  libbufferhubqueue.so
soinfo_addr:0x72a916b058 size: 0x258,  libpdx_default_transport.so
soinfo_addr:0x72a916b2b0 size: 0x258,  libpng.so
soinfo_addr:0x72a916b508 size: 0x258,  libdng_sdk.so
soinfo_addr:0x72a916b760 size: 0x258,  libpiex.so
soinfo_addr:0x72a916b9b8 size: 0x258,  libexpat.so
soinfo_addr:0x72a916bc10 size: 0x258,  libheif.so
soinfo_addr:0x72a916be68 size: 0x258,  android.hardware.graphics.composer3-V1-ndk.so
soinfo_addr:0x72a916c0c0 size: 0x258,  libprotobuf-cpp-lite.so
soinfo_addr:0x72a916c318 size: 0x258,  libft2.so
soinfo_addr:0x72a916c570 size: 0x258,  libjpeg-alpha.so
soinfo_addr:0x72a916c7c8 size: 0x258,  vendor.mediatek.hardware.mms@1.0.so
soinfo_addr:0x72a916ca20 size: 0x258,  vendor.mediatek.hardware.mms@1.1.so
soinfo_addr:0x72a916cc78 size: 0x258,  vendor.mediatek.hardware.mms@1.2.so
soinfo_addr:0x72a916ced0 size: 0x258,  vendor.mediatek.hardware.mms@1.3.so
soinfo_addr:0x72a916d128 size: 0x258,  libion.so
soinfo_addr:0x72a916d380 size: 0x258,  libdmabufheap.so
soinfo_addr:0x72a916d5d8 size: 0x258,  libJpegOal.so
soinfo_addr:0x72a916d830 size: 0x258,  libmediadrm.so
soinfo_addr:0x72a916da88 size: 0x258,  libmedia_omx.so
soinfo_addr:0x72a916dce0 size: 0x258,  libmedia_jni_utils.so
soinfo_addr:0x72a916df38 size: 0x258,  libmediandk_utils.so
soinfo_addr:0x72a916e190 size: 0x258,  android.hardware.drm-V1-ndk.so
soinfo_addr:0x72a916e3e8 size: 0x258,  libbacktrace.so
soinfo_addr:0x72a916e640 size: 0x258,  android.hardware.configstore@1.0.so
soinfo_addr:0x72a916e898 size: 0x258,  android.hardware.configstore-utils.so
soinfo_addr:0x72a916eaf0 size: 0x258,  libSurfaceFlingerProp.so
soinfo_addr:0x72a916ed48 size: 0x258,  libgralloc_extra_sys.so
soinfo_addr:0x72a916efa0 size: 0x258,  libgpud_sys.so
soinfo_addr:0x72a916f1f8 size: 0x258,  libziparchive.so
soinfo_addr:0x72a916f450 size: 0x258,  android.system.suspend-V1-ndk.so
soinfo_addr:0x72a916f6a8 size: 0x258,  libpcre2.so
soinfo_addr:0x72a916f900 size: 0x258,  libpackagelistparser.so
soinfo_addr:0x72a916fb58 size: 0x258,  mediametricsservice-aidl-cpp.so
soinfo_addr:0x72a916fdb0 size: 0x258,  libaudioutilmtk.so
soinfo_addr:0x72a9170008 size: 0x258,  audiopolicy-aidl-cpp.so
soinfo_addr:0x72a9170260 size: 0x258,  capture_state_listener-aidl-cpp.so
soinfo_addr:0x72a91704b8 size: 0x258,  libaudioutils.so
soinfo_addr:0x72a9170710 size: 0x258,  libmediautils.so
soinfo_addr:0x72a9170968 size: 0x258,  libnblog.so
soinfo_addr:0x72a9170bc0 size: 0x258,  libshmemcompat.so
soinfo_addr:0x72a9170e18 size: 0x258,  packagemanager_aidl-cpp.so
soinfo_addr:0x72a9171070 size: 0x258,  libcgrouprc.so
soinfo_addr:0x72a91712c8 size: 0x258,  libhidl-gen-utils.so
soinfo_addr:0x72a9171520 size: 0x258,  libtinyxml2.so
soinfo_addr:0x72a9171778 size: 0x258,  libbpf_bcc.so
soinfo_addr:0x72a91719d0 size: 0x258,  libbpf_minimal.so
soinfo_addr:0x72a9171c28 size: 0x258,  android.hardware.media.omx@1.0.so
soinfo_addr:0x72a9171e80 size: 0x258,  libstagefright_framecapture_utils.so
soinfo_addr:0x72a91720d8 size: 0x258,  libcodec2.so
soinfo_addr:0x72a9172330 size: 0x258,  libcodec2_vndk.so
soinfo_addr:0x72a9172588 size: 0x258,  libmedia_omx_client.so
soinfo_addr:0x72a91727e0 size: 0x258,  libsfplugin_ccodec.so
soinfo_addr:0x72a9172a38 size: 0x258,  libsfplugin_ccodec_utils.so
soinfo_addr:0x72a9172c90 size: 0x258,  libstagefright_codecbase.so
soinfo_addr:0x72a9172ee8 size: 0x258,  libstagefright_omx_utils.so
soinfo_addr:0x72a9173140 size: 0x258,  libRScpp.so
soinfo_addr:0x72a9173398 size: 0x258,  libhidlallocatorutils.so
soinfo_addr:0x72a91735f0 size: 0x258,  libhidlmemory.so
soinfo_addr:0x72a9173848 size: 0x258,  android.hidl.allocator@1.0.so
soinfo_addr:0x72a9173aa0 size: 0x258,  android.hardware.cas.native@1.0.so
soinfo_addr:0x72a9173cf8 size: 0x258,  android.hardware.drm@1.0.so
soinfo_addr:0x72a9173f50 size: 0x258,  android.hardware.common-V2-ndk.so
soinfo_addr:0x72a91741a8 size: 0x258,  android.hardware.media@1.0.so
soinfo_addr:0x72a9174400 size: 0x258,  android.hidl.token@1.0.so
soinfo_addr:0x72a9174658 size: 0x258,  libjpeg-alpha-oal.so
soinfo_addr:0x72a91748b0 size: 0x258,  libmediadrmmetrics_lite.so
soinfo_addr:0x72a9174b08 size: 0x258,  android.hardware.drm@1.1.so
soinfo_addr:0x72a9174d60 size: 0x258,  android.hardware.drm@1.2.so
soinfo_addr:0x72a9174fb8 size: 0x258,  android.hardware.drm@1.3.so
soinfo_addr:0x72a9175210 size: 0x258,  android.hardware.drm@1.4.so
soinfo_addr:0x72a9175468 size: 0x258,  libunwindstack.so
soinfo_addr:0x72a91756c0 size: 0x258,  android.hardware.configstore@1.1.so
soinfo_addr:0x72a9175918 size: 0x258,  libsf_cpupolicy.so
soinfo_addr:0x72a9175b70 size: 0x258,  libged_sys.so
soinfo_addr:0x72a9175dc8 size: 0x258,  libutilscallstack.so
soinfo_addr:0x72a9176020 size: 0x258,  libspeexresampler.so
soinfo_addr:0x72a9176278 size: 0x258,  libshmemutil.so
soinfo_addr:0x72a91764d0 size: 0x258,  android.hardware.media.bufferpool@2.0.so
soinfo_addr:0x72a9176728 size: 0x258,  libfmq.so
soinfo_addr:0x72a9176980 size: 0x258,  libstagefright_bufferpool@2.0.1.so
soinfo_addr:0x72a9176bd8 size: 0x258,  android.hardware.media.c2@1.0.so
soinfo_addr:0x72a9176e30 size: 0x258,  libcodec2_client.so
soinfo_addr:0x72a9177088 size: 0x258,  libstagefright_bufferqueue_helper.so
soinfo_addr:0x72a91772e0 size: 0x258,  libstagefright_omx.so
soinfo_addr:0x72a9177538 size: 0x258,  libstagefright_surface_utils.so
soinfo_addr:0x72a9177790 size: 0x258,  libstagefright_xmlparser.so
soinfo_addr:0x72a91779e8 size: 0x258,  android.hidl.memory@1.0.so
soinfo_addr:0x72a9177c40 size: 0x258,  android.hidl.memory.token@1.0.so
soinfo_addr:0x72a9177e98 size: 0x258,  android.hardware.cas@1.0.so
soinfo_addr:0x72a91780f0 size: 0x258,  liblzma.so
soinfo_addr:0x72a9178348 size: 0x258,  android.hidl.safe_union@1.0.so
soinfo_addr:0x72a91785a0 size: 0x258,  android.hardware.media.c2@1.1.so
soinfo_addr:0x72a91787f8 size: 0x258,  android.hardware.media.c2@1.2.so
soinfo_addr:0x72a9178a50 size: 0x258,  libcodec2_hidl_client@1.0.so
soinfo_addr:0x72a9178ca8 size: 0x258,  libcodec2_hidl_client@1.1.so
soinfo_addr:0x72a9178f00 size: 0x258,  libcodec2_hidl_client@1.2.so
soinfo_addr:0x72a9179158 size: 0x258,  libged_kpi.so
soinfo_addr:0x72a91793b0 size: 0x258,  libnativehelper.so
soinfo_addr:0x72a9179608 size: 0x258,  libart.so

A suspicious soinfo list:

soinfo_addr:0x72b1530a50 size: 0x258,  libcodec2_hidl_client@1.0.so
soinfo_addr:0x72b1530ca8 size: 0x258,  libcodec2_hidl_client@1.1.so
soinfo_addr:0x72b1530f00 size: 0x258,  libcodec2_hidl_client@1.2.so
soinfo_addr:0x72b1531158 size: 0x258,  libged_kpi.so
soinfo_addr:0x72b1531608 size: 0x4b0,  libnativehelper.so
soinfo_addr:0x72b1531860 size: 0x258,  libart.so

In the suspicious list, one spacing is 0x4b0, which means a soinfo was closed exactly in the middle.

How I found and analyzed this

A detection tool flagged this problem. At the time my zygiskNext modification was basically complete and I’d changed all its fingerprints, yet it still reported injection. That made me suspicious of zygiskNext’s injection method itself, so I verified it step by step from scratch and finally found that using the .so self-unload technique is what gets detected — if you don’t use it, you’re not detected. Then I immediately locked onto the soinfo-gap detection caused by dlclose.

Why zygisk has this problem

(Again: new zygiskNext has fixed this, and I can’t guarantee this technique works 100%.)

Old zygiskNext and the current Magisk zygisk (v28.1) load libzygisk.so too early, so its soinfo sits too far forward — in particular, libart.so and the rest haven’t even loaded yet. When the user process starts, it closes some soinfos and opens others; if a closed soinfo slot can’t be reused, a gap appears. The most obvious case is a single soinfo gap right before a certain soinfo, like libart.so.

But this is still my guess — I haven’t verified the mechanism by which a closed soinfo slot is reused on re-allocation. I’ve also tested on some phones and found that as long as it’s after libart.so, there seems to be no problem. Is it a coincidence that the gap always lands right after libart.so’s soinfo?

The solution

Just delay the loading of libzygisk.so. I started by loading it before zygote starts the systemservice process; later, while testing my tooling, I found that loading it after libart.so also works fine.

Final thoughts

A bypass for this technique exists now, and I’ve implemented it myself. It has some value, though not huge, but it’s definitely worth learning — hence this writeup.


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