Automating Android System-Level Device Fingerprint Countermeasures

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.

Automating Android System-Level Device Fingerprint Countermeasures

Background

This article lays out an overall countermeasure strategy at the device-fingerprint layer. There are similar writeups out there, but none really applies to today’s environment, so I wanted to organize my thinking. In the past people did re-packaging — modify the client directly, hook the binder methods to intercept calls. That works but leaves a huge footprint, and it’s hard to do without touching the client. But once you’re on a rooted device, the game changes completely. Early on, Magisk could do some file replacement at the system layer. Now that APatch has arrived, a lot of the countermeasures live in the kernel — you jump straight from app-layer modification into the kernel. (Didn’t we agree not to go into the kernel?) Before, many people did this by compiling a custom ROM or a custom kernel, which now looks pretty pointless.

What follows is purely my own perspective. If you have a better approach, or if I got something wrong or unclear, feel free to reply.

This is the fourth in a series on device fingerprints. The conclusion from the third one was:

We only need to make the client’s security SDK detection “ineffective” to enable automated scraping.

“Ineffective” doesn’t mean stopping it from reporting — it means it can’t detect. But to get there on the client side you still need a ton of countermeasures: one-tap device reset, all sorts of complex environment checks that have to be bypassed. None of it is simple.

I once wrote a tool called Hunter, which detects risk items on the client. Through constant back-and-forth testing against fraud operators, I found their attack methods are fascinating: hardware breakpoints to escape in-memory CRC checks, kernel injection, directly modifying the server side to tamper with the client’s return values. The bypass methods are all over the map, but the core idea is the same:

The client injects and modifies not a single line of code, yet can do a one-tap device reset.

Below, let’s talk about how the client can actually pull that off, what the pitfalls are, and why it’s done this way — analyzing it from principles and details.

A simulated war

The opponent

Assume our opponent is the current ceiling of APK risk control, where every strategy is written by people who deeply understand client-side countermeasures.

The client is also a top-tier detection team: if they spot the slightest risk on the client, they can write a strategy to strike it down. For example, these common operations:

  • Behavior recognition & socket-tracking event reporting (checking whether a user is normal, whether the specified-scenario tracking events look right)
  • Separate detection from banning (when a client problem is found, don’t ban immediately — avoid letting the client test-and-find the ban point)
  • Click path matrix (recording the click coordinates on the current page)
  • Multi-layer strategy nesting (the server keeps historical strategies; if one point isn’t fully spoofed, the account gets banned or demoted)
    • For instance, getting device info B at location A, and also getting device info B at location C. If A and C return different results, the device is flagged abnormal — this kind of multi-level calibration.
  • Ceiling-level security SDK detection:
    • (The security SDK detection is also the domestic ceiling: Hook, sandbox, re-packaging, Hook frameworks are all detected at their strongest. All they leave exposed to us is an unlocked phone.)
  • AI user behavior & strategy learning:
    • (Continuously auto-deriving whether the current user is a normal device.)
  • Real-time monitoring & response:
    • (Monitoring client behavior in real time, detecting anomalies and responding.)
  • IP clustering detection, and so on
  • Scraping frequency strategies:
    • (For example, how much can be scraped per minute / hour / 24 hours.)

With all of the above in play at once, how do we bypass them and quantify our scraping?

The counter-offensive

Step one: 5 phones, 20 accounts. Assume an account’s threshold is 1000 data points per day; exceed it and the account gets banned. Get step one working first, then gradually add devices and accounts.

Architecture diagram

That’s a simple architecture diagram. The client’s device fingerprint and environment-info countermeasures are the core of this part. The whole article is 30% architecture design, 70% device countermeasures.

Phone requirements:

Android 11 or higher; 13+ is better. Flash APatch.

There’s a subtle point here: why Android 11+?

This is actually caused by a serious Android vulnerability: on Android 11 and below, even without permission you can directly get your NIC info via netlink.

Detailed code can be found in an earlier article of mine (open-sourced on GitHub).

(If you only have Android 10 or below phones, you could patch the kernel, recompile, and flash it — workable but tedious, not recommended.)

No authorization needed, and you can even read some net files by simply cat-ing them. Below 11 you can also use the old SD card read/write permission to write directly to the SD card.

Newer versions only allow writing SD card data under /sdcard/Android/data/<package>, so when the app is uninstalled it gets deleted together. Many major vendors’ fingerprints are quite nasty.

They write file seeds to the SD card; without isolation, it’s hard to spoof everything.

Later I’ll explain in detail why APatch is needed, and why not Magisk.

Client architecture

The client wants to accomplish two things: device-fingerprint countermeasures and environment-info countermeasures. Before modifying a fingerprint, I need to know how it’s produced. For example, a useful fingerprint like AndroidId is produced on the server side.

To keep the client’s device-fingerprint countermeasures as hidden as possible — because we can’t modify any of the target APK’s code — we have to modify at the place where the fingerprint is produced.

Let me organize the device fingerprints.

Device fingerprint classification

Fingerprint category Produced at Description
Android Id system_server
Various Ids, IMEI, etc. system_server
Apk package related system_server
Bluetooth related system_server
Wi-Fi info related system_server
Fingerprint category Produced at Description
SettingsProvider a security-center app e.g. oaid — obtained via a content provider exposed by some app; a major vendor’s security-center app provides it, so you can hook directly in that app’s process.
Fingerprint category (kernel-related) Produced at Description
/proc/sys/kernel/random/boot_id kernel Holds kernel data; modifying on 11+ can break RES resource files, needs mknod to modify.
/sys/block/mmcblk0/device/cid kernel
/sys/block/mmcblk0/device/serial kernel
/sys/devices/soc0/serial_number kernel CPU protocol; some CPUs don’t support it.

This process is special — init is the earliest process, the first process the kernel starts.

It turns out the DRM is produced in the init process. Underneath it communicates via native binder.

For example, to modify DRM you can modify it when the client fetches it — both Java and C need hooking. Common Java and C fetching:

Java:

import android.media.MediaDrm;
import java.util.UUID;
try {
UUID wideVineUuid = new UUID(-0x121074568629b532L, -0x5c37d8232ae2de13L);
MediaDrm wvDrm = new MediaDrm(wideVineUuid);
byte[] wideVineId =
wvDrm.getPropertyByteArray(MediaDrm.PROPERTY_DEVICE_UNIQUE_ID);
Log.i("DRM ID", bytesToHex(wideVineId));
wvDrm.release();
} catch (UnsupportedSchemeException e) {
e.printStackTrace();
}

C:

#include <media/NdkMediaDrm.h>
const uint8_t uuid[] =
{0xed,0xef,0x8b,0xa9,0x79,0xd6,0x4a,0xce,0xa3,0xc8,0x27,0xdc,0xd5,0x1d,0x21,0xed
};
AMediaDrm *mediaDrm = AMediaDrm_createByUUID(uuid);
AMediaDrmByteArray aMediaDrmByteArray;
AMediaDrm_getPropertyByteArray(mediaDrm, PROPERTY_DEVICE_UNIQUE_ID,
&aMediaDrmByteArray);
AMediaDrm_release(mediaDrm);

Both the C and Java flows end up calling the init process’s IPC.

The final process lives in android.hardware.drm:

C:\Users\ASUS>adb shell
cmi:/ $ su
cmi:/ # ps -ef | grep -service.widevine | grep android.hardware.drm
media         1069     1 0 19:31:15 ?     00:00:00 android.hardware.drm@1.3-service.widevine

“Environment” here means device fingerprints obtained via getprop — environment-related values.

Device fingerprint countermeasures

From the breakdown above, device fingerprints are produced in five major categories, and those five categories cover basically every way Android currently produces a device fingerprint.

Next we need to hook and modify these processes. Because we can’t modify within the client process, we have to modify at these five fingerprint-producing locations.

Preparation

We need a Magisk module to implement our own Xposed. Why build our own?

Because with a Magisk module you can inject lsplant into a specified process and do Java hooks — for example, hooking Java methods inside system_server.

It’s convenient, and if you build it yourself the logic is fully under your control, making it easy to hook processes other than the target APK.

This isn’t hard to implement — LSPosed is open source. Still, I’d recommend writing it from scratch to internalize it; if anything’s unclear, go read the LSPosed source.

Here’s a quick rundown of the implementation approach.

Magisk module basics

Magisk modules use Zygisk for injection. Zygisk provides five methods:

  • onLoad
  • preAppSpecialize
  • postAppSpecialize
  • preServerSpecialize
  • postServerSpecialize

These five are Zygisk’s corresponding interfaces — equivalent to Xposed’s loadPackage.

Notice there’s a pre and a post. The pre methods run before the process has been sandboxed — when our APK hatches out of zygote it’s just an ordinary process. The reason it can only read /data/data/<package> and nothing from other APKs is that it’s been “specialized”. In Magisk module terms this is called specialization. Below is the original text from the Magisk module header:

On Android, all app processes are forked from a special daemon called "Zygote".
For each new app process, zygote will fork a new process and perform "specialization".
This specialization operation enforces the Android security sandbox on the newly forked
process to make sure that 3rd party application code is only loaded after it is being
restricted within a sandbox.

On Android, there is also this special process called "system_server". This single
process hosts a significant portion of system services, which controls how the
Android operating system and apps interact with each other.

The Zygisk framework provides a way to allow developers to build modules and run custom
code before and after system_server and any app processes' specialization.
This enable developers to inject code and alter the behavior of system_server and app processes.

Please note that modules will only be loaded after zygote has forked the child process.
THIS MEANS ALL OF YOUR CODE RUNS IN THE APP/SYSTEM_SERVER PROCESS, NOT THE ZYGOTE DAEMON!

So we can do things before an APK gets its SELinux rules and sandbox applied — like chroot. Shamiko, for instance, seems to execute a chroot so that at that moment no permissions exist yet, isolating the target app into a brand-new root directory and thereby bypassing root detection.

post is the callback after specialization completes; pre is before specialization. Of the five methods, two are for system services (pre/post), and the rest are the pre/post when some app loads.

For easy reference, here’s the zygisk.hpp public API header:

/* Copyright 2022-2023 John "topjohnwu" Wu
 *
 * Permission to use, copy, modify, and/or distribute this software for any
 * purpose with or without fee is hereby granted.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
 * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
 * AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
 * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
 * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
 * OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
 * PERFORMANCE OF THIS SOFTWARE.
 */

// This is the public API for Zygisk modules.
// DO NOT MODIFY ANY CODE IN THIS HEADER.

#pragma once

#include <jni.h>
#include <ostream>
#include <sstream>
#include <string>

#define ZYGISK_API_VERSION 4

/*
***************
* Introduction
***************

On Android, all app processes are forked from a special daemon called "Zygote".
For each new app process, zygote will fork a new process and perform "specialization".
This specialization operation enforces the Android security sandbox on the newly forked
process to make sure that 3rd party application code is only loaded after it is being
restricted within a sandbox.

On Android, there is also this special process called "system_server". This single
process hosts a significant portion of system services, which controls how the
Android operating system and apps interact with each other.

The Zygisk framework provides a way to allow developers to build modules and run custom
code before and after system_server and any app processes' specialization.
This enable developers to inject code and alter the behavior of system_server and app processes.

Please note that modules will only be loaded after zygote has forked the child process.
THIS MEANS ALL OF YOUR CODE RUNS IN THE APP/SYSTEM_SERVER PROCESS, NOT THE ZYGOTE DAEMON!

*********************
* Development Guide
*********************

Define a class and inherit zygisk::ModuleBase to implement the functionality of your module.
Use the macro REGISTER_ZYGISK_MODULE(className) to register that class to Zygisk.

Example code:

static jint (*orig_logger_entry_max)(JNIEnv *env);
static jint my_logger_entry_max(JNIEnv *env) {
 return orig_logger_entry_max(env);
}

class ExampleModule : public zygisk::ModuleBase {
public:
    void onLoad(zygisk::Api *api, JNIEnv *env) override {
        this->api = api;
        this->env = env;
    }
    void preAppSpecialize(zygisk::AppSpecializeArgs *args) override {
        JNINativeMethod methods[] = {
            { "logger_entry_max_payload_native", "()I", (void*) my_logger_entry_max },
        };
        api->hookJniNativeMethods(env, "android/util/Log", methods, 1);
        *(void **) &orig_logger_entry_max = methods[0].fnPtr;
    }
private:
    zygisk::Api *api;
    JNIEnv *env;
};

REGISTER_ZYGISK_MODULE(ExampleModule)

-----------------------------------------------------------------------------------------

or runs in the sandbox of the target process in post[XXX]Specialize, the code in your class
never runs in a true superuser environment.

If your module require access to superuser permissions, you can create and register
a root companion handler function. This function runs in a separate root companion
daemon process, and an Unix domain socket is provided to allow you to perform IPC between
your target process and the root companion process.

Example code:

static void example_handler(int socket) { ... }

REGISTER_ZYGISK_COMPANION(example_handler)

*/

namespace zygisk {

    struct Api;
    struct AppSpecializeArgs;
    struct ServerSpecializeArgs;

    class ModuleBase {
    public:

        // This method is called as soon as the module is loaded into the target process. A Zygisk API handle will be passed as an argument.
        virtual void onLoad([[maybe_unused]] Api *api, [[maybe_unused]] JNIEnv *env) {}

        // This method is called before the app process is specialized.
        // At this point, the process just got forked from zygote, but no app specific specialization
        // is applied. This means that the process does not have any sandbox restrictions and
        // still runs with the same privilege of zygote.
        virtual void preAppSpecialize([[maybe_unused]] AppSpecializeArgs *args) {}

        // This method is called after the app process is specialized.
        // At this point, the process has all sandbox restrictions enabled for this application.
        // This means that this method runs with the same privilege of the app's own code.
        virtual void postAppSpecialize([[maybe_unused]] const AppSpecializeArgs *args) {}

        // This method is called before the system server process is specialized.
        // See preAppSpecialize(args) for more info.
        virtual void preServerSpecialize([[maybe_unused]] ServerSpecializeArgs *args) {}

        // This method is called after the system server process is specialized.
        // At this point, the process runs with the privilege of system_server.
        virtual void postServerSpecialize([[maybe_unused]] const ServerSpecializeArgs *args) {}
    };

    struct AppSpecializeArgs {
        // Required arguments.
        // These arguments are guaranteed to exist on all Android versions.
        jint &uid;
        jint &gid;
        jintArray &gids;
        jint &runtime_flags;
        jobjectArray &rlimits;
        jint &mount_external;
        jstring &se_info;
        jstring &nice_name;
        jstring &instruction_set;
        jstring &app_data_dir;

        // Optional arguments. Please check whether the pointer is null before de-referencing
        jintArray *const fds_to_ignore;
        jboolean *const is_child_zygote;
        jboolean *const is_top_app;
        jobjectArray *const pkg_data_info_list;
        jobjectArray *const whitelisted_data_info_list;
        jboolean *const mount_data_dirs;
        jboolean *const mount_storage_dirs;

        AppSpecializeArgs() = delete;

        std::string toString(JNIEnv *env) const {
            std::ostringstream stream;
            printJString(env, stream, "nice_name", nice_name);

            printJString(env, stream, "app_data_dir", app_data_dir);

            printJString(env, stream, "se_info", se_info);
            printJString(env, stream, "instruction_set", instruction_set);
            stream << "uid: " << uid << " gid: " << gid << " runtime_flags: " << runtime_flags
                   << " mount_external: " << mount_external;

            printJIntArray(env, stream, "gids", gids);

            printJBoolean(env, stream, "is_child_zygote", is_child_zygote);
            printJBoolean(env, stream, "is_top_app", is_top_app);
            printJBoolean(env, stream, "mount_data_dirs", mount_data_dirs);
            printJBoolean(env, stream, "mount_storage_dirs", mount_storage_dirs);

            printJObjectArrayPointer(env, stream, "pkg_data_info_list", pkg_data_info_list);
            printJObjectArrayPointer(env, stream, "whitelisted_data_info_list",
                                     whitelisted_data_info_list);

            return stream.str();
        }

    private:
        [[maybe_unused]]
        void printJString(JNIEnv *env, std::ostringstream &stream, const std::string &name,
                          jstring value) const {
            if (value != nullptr) {
                const char *chars = env->GetStringUTFChars(value, nullptr);
                stream << " " << name << ": " << chars << "\n";
                env->ReleaseStringUTFChars(value, chars);
            } else {
                stream << " " << name << ": null" << "\n";
            }
        }

        [[maybe_unused]]
        void printJIntArray(JNIEnv *env, std::ostringstream &stream, const std::string &name,
                            jintArray value) const {
            if (value != nullptr) {
                jsize length = env->GetArrayLength(value);
                jint *elements = env->GetIntArrayElements(value, nullptr);
                stream << " " << name << ": [";
                for (jsize i = 0; i < length; ++i) {
                    stream << elements[i];
                    if (i < length - 1) {
                        stream << ", ";
                    }
                }
                stream << "]" << "\n";
                env->ReleaseIntArrayElements(value, elements, JNI_ABORT);
            } else {
                stream << " " << name << ": null" << "\n";
            }
        }

        [[maybe_unused]]
        void printJBoolean(JNIEnv *env, std::ostringstream &stream,
                           const std::string &name, jboolean *value) const {
            if (value != nullptr) {
                stream << " " << name << ": " << ((*value) == JNI_TRUE ? "true" : "flase") << "\n";
            } else {
                stream << " " << name << ": null" << "\n";
            }
        }

        [[maybe_unused]]
        void
        printJObjectArrayPointer(JNIEnv *env, std::ostringstream &stream, const std::string &name,
                                 jobjectArray *arrayPtr) const {
            if (arrayPtr && *arrayPtr) {
                jobjectArray array = *arrayPtr;
                jsize length = env->GetArrayLength(array);
                stream << " " << name << ": [";
                for (jsize i = 0; i < length; ++i) {
                    jstring str = (jstring) env->GetObjectArrayElement(array, i);
                    const char *chars = env->GetStringUTFChars(str, nullptr);
                    stream << chars;
                    env->ReleaseStringUTFChars(str, chars);
                    if (i < length - 1) {
                        stream << ", ";
                    }
                }
                stream << "]" << "\n";
            } else {
                stream << " " << name << ": null" << "\n";
            }
        }

    };

    struct ServerSpecializeArgs {
        jint &uid;
        jint &gid;
        jintArray &gids;
        jint &runtime_flags;
        jlong &permitted_capabilities;
        jlong &effective_capabilities;

        ServerSpecializeArgs() = delete;
    };

    namespace internal {
        struct api_table;

        template<class T>
        void entry_impl(api_table *, JNIEnv *);
    }
    // These values are used in Api::setOption(Option)
    enum Option : int {
        // Force Magisk's denylist unmount routines to run on this process.
        //
        // Setting this option only makes sense in preAppSpecialize.
        // The actual unmounting happens during app process specialization.
        //
        // Set this option to force all Magisk and modules' files to be unmounted from the
        // mount namespace of the process, regardless of the denylist enforcement status.
        // This mainly unmounts the entire Magisk system files.
        FORCE_DENYLIST_UNMOUNT = 0,

        // When this option is set, your module's library will be dlclose-ed after post[XXX]Specialize.
        // Be aware that after dlclose-ing your module, all of your code will be unmapped from memory.
        // YOU MUST NOT ENABLE THIS OPTION AFTER HOOKING ANY FUNCTIONS IN THE PROCESS.
        // This unloads the so file.
        DLCLOSE_MODULE_LIBRARY = 1,
    };

    // Bit masks of the return value of Api::getFlags()
    enum StateFlag : uint32_t {
        // The user has granted root access to the current process
        PROCESS_GRANTED_ROOT = (1u << 0),

        // The current process was added on the denylist
        PROCESS_ON_DENYLIST = (1u << 1),
    };

    // All API methods will stop working after post[XXX]Specialize as Zygisk will be unloaded
    // from the specialized process afterwards.
    struct Api {

        // Connect to a root companion process and get a Unix domain socket for IPC.
        // Get a handle to communicate with the root daemon; you can do IPC through it directly.
        //
        // This API only works in the pre[XXX]Specialize methods due to SELinux restrictions.
        //
        // The pre[XXX]Specialize methods run with the same privilege of zygote.
        // If you would like to do some operations with superuser permissions, register a handler
        // function that would be called in the root process with REGISTER_ZYGISK_COMPANION(func).

        // Another good use case for a companion process is that if you want to share some resources
        // across multiple processes, hold the resources in the companion process and pass it over.
        //
        // The root companion process is ABI aware; that is, when calling this method from a 32-bit
        // process, you will be connected to a 32-bit companion process, and vice versa for 64-bit.
        //
        // Returns a file descriptor to a socket that is connected to the socket passed to your
        // module's companion request handler. Returns -1 if the connection attempt failed.
        int connectCompanion();

        // Get the file descriptor of the root folder of the current module.
        //
        // This API only works in the pre[XXX]Specialize methods.
        // Accessing the directory returned is only possible in the pre[XXX]Specialize methods
        // or in the root companion process (assuming that you sent the fd over the socket).
        // Both restrictions are due to SELinux and UID.
        //
        // Returns -1 if errors occurred.
        int getModuleDir();

        // Set various options for your module.
        // Please note that this method accepts one single option at a time.
        // Check zygisk::Option for the full list of options available.
        void setOption(Option opt);

        // Get information about the current process.
        // Returns bitwise-or'd zygisk::StateFlag values.
        uint32_t getFlags();

        // Exempt the provided file descriptor from being automatically closed.
        //
        // This API only make sense in preAppSpecialize; calling this method in any other situation
        // is either a no-op (returns true) or an error (returns false).
        //
        // When false is returned, the provided file descriptor will eventually be closed by zygote.
        bool exemptFd(int fd);

        // Hook JNI native methods for a class
        //
        // Lookup all registered JNI native methods and replace it with your own methods.
        // The original function pointer will be saved in each JNINativeMethod's fnPtr.
        // If no matching class, method name, or signature is found, that specific JNINativeMethod.fnPtr
        // will be set to nullptr.
        void hookJniNativeMethods(JNIEnv *env, const char *className, JNINativeMethod *methods,
                                  int numMethods);

        // Hook functions in the PLT (Procedure Linkage Table) of ELFs loaded in memory.
        //
        // Parsing /proc/[PID]/maps will give you the memory map of a process. As an example:
        //
        //       <address>       <perms>  <offset>   <dev>  <inode>           <pathname>
        // 56b4346000-56b4347000  r-xp    00002000   fe:00    235       /system/bin/app_process64
        // (More details: https://man7.org/linux/man-pages/man5/proc.5.html)
        //
        // The `dev` and `inode` pair uniquely identifies a file being mapped into memory.
        // For matching ELFs loaded in memory, replace function `symbol` with `newFunc`.
        // If `oldFunc` is not nullptr, the original function pointer will be saved to `oldFunc`.
        void
        pltHookRegister(dev_t dev, ino_t inode, const char *symbol, void *newFunc, void **oldFunc);

        // Commit all the hooks that was previously registered.
        // Returns false if an error occurred.
        bool pltHookCommit();

    private:
        internal::api_table *tbl;

        template<class T>
        friend void internal::entry_impl(internal::api_table *, JNIEnv *);
    };

// Register a class as a Zygisk module

#define REGISTER_ZYGISK_MODULE(clazz) \
void zygisk_module_entry(zygisk::internal::api_table *table, JNIEnv *env) { \
    zygisk::internal::entry_impl<clazz>(table, env);                        \
}

// Register a root companion request handler function for your module

// The function runs in a superuser daemon process and handles a root companion request from
// your module running in a target process.
// The function has to accept an integer value,
// which is a Unix domain socket that is connected to the target process.

// See Api::connectCompanion() for more info.
//
// NOTE:
// the function can run concurrently on multiple threads.
// Be aware of race conditions if you have globally shared resources.

#define REGISTER_ZYGISK_COMPANION(func) \
void zygisk_companion_entry(int client) { func(client); }

/*********************************************************
 * The following is internal ABI implementation detail.
 * You do not have to understand what it is doing.
 *********************************************************/

    namespace internal {

        struct module_abi {
            long api_version;
            ModuleBase *impl;

            void (*preAppSpecialize)(ModuleBase *, AppSpecializeArgs *);

            void (*postAppSpecialize)(ModuleBase *, const AppSpecializeArgs *);

            void (*preServerSpecialize)(ModuleBase *, ServerSpecializeArgs *);

            void (*postServerSpecialize)(ModuleBase *, const ServerSpecializeArgs *);

            module_abi(ModuleBase *

            module) : api_version(ZYGISK_API_VERSION), impl(module) {
                preAppSpecialize = [](auto m, auto args) { m->preAppSpecialize(args); };
                postAppSpecialize = [](auto m, auto args) { m->postAppSpecialize(args); };
                preServerSpecialize = [](auto m, auto args) { m->preServerSpecialize(args); };
                postServerSpecialize = [](auto m, auto args) { m->postServerSpecialize(args); };
            }
        };

        struct api_table {
            // Base
            void *impl;

            bool (*registerModule)(api_table *, module_abi *);

            void (*hookJniNativeMethods)(JNIEnv *, const char *, JNINativeMethod *, int);

            void (*pltHookRegister)(dev_t, ino_t, const char *, void *, void **);

            bool (*exemptFd)(int);

            bool (*pltHookCommit)();

            int (*connectCompanion)(void * /* impl */);

            void (*setOption)(void * /* impl */, Option);

            int (*getModuleDir)(void * /* impl */);

            uint32_t (*getFlags)(void * /* impl */);
        };

        template<class T>
        void entry_impl(api_table *table, JNIEnv *env) {
            static Api api;
            api.tbl = table;
            static T module;
            ModuleBase *m = &module;
            static module_abi abi(m);
            if (!table->registerModule(table, &abi)) return;
            m->onLoad(&api, env);
        }

    } // namespace internal

    inline int Api::connectCompanion() {
        return tbl->connectCompanion ? tbl->connectCompanion(tbl->impl) : -1;
    }

    inline int Api::getModuleDir() {
        return tbl->getModuleDir ? tbl->getModuleDir(tbl->impl) : -1;
    }

    inline void Api::setOption(Option opt) {
        if (tbl->setOption) tbl->setOption(tbl->impl, opt);
    }

    inline uint32_t Api::getFlags() {
        return tbl->getFlags ? tbl->getFlags(tbl->impl) : 0;
    }

    inline bool Api::exemptFd(int fd) {
        return tbl->exemptFd != nullptr && tbl->exemptFd(fd);
    }

    inline void
    Api::hookJniNativeMethods(JNIEnv *env, const char *className, JNINativeMethod *methods,
                              int numMethods) {
        if (tbl->hookJniNativeMethods)
            tbl->hookJniNativeMethods(env, className, methods, numMethods);
    }

    inline void Api::pltHookRegister(dev_t dev, ino_t inode, const char *symbol, void *newFunc,
                                     void **oldFunc) {
        if (tbl->pltHookRegister) tbl->pltHookRegister(dev, inode, symbol, newFunc, oldFunc);
    }

    inline bool Api::pltHookCommit() {
        return tbl->pltHookCommit != nullptr && tbl->pltHookCommit();
    }

} // namespace zygisk

extern "C" {

[[gnu::visibility("default"), maybe_unused]]
void zygisk_module_entry(zygisk::internal::api_table *, JNIEnv *);

[[gnu::visibility("default"), maybe_unused]]
void zygisk_companion_entry(int);

} // extern "C"

There are more API operations — system file replacement, modifying SELinux permissions, etc. — I won’t go into them here. See the official docs for details:

https://topjohnwu.github.io/Magisk/guides.html

How a Magisk module implements an Xposed

Once you’ve grasped the Magisk module basics, you can try implementing your own LSPosed. The open-source LSPosed doesn’t support Android 15 yet, but as long as LSPlant supports 15, you can use it there too. Thanks again to the LSPlant developer.

We need to first get our compiled Java code running inside the Magisk module, have it execute Java code inside the system service (system_server), then proceed with the follow-up operations.

Here’s the concrete flow:

1. Magisk module loads Java code

Drop your compiled Java code into the Magisk module’s system/framework folder, so the system service loads our jar at startup.

In the system-service callback, use a PathClassLoader to load the jar, then reflectively invoke its static methods. Step one is going from the Magisk module to a Java call.

Because the jar sits under system/framework, only system_server has permission to read it — no other APK can read it. So you don’t have to worry about other APKs reading your signature.

2. System service initialization

With the above setup we can invoke Java methods. Next, inside those Java methods we replace and mock the system services, and mount our own service inside system_server — you can swap in your own when services are being added.

How do you hook the add-service function? Dynamic proxy does the job. When the system starts calling addService, and it’s adding a specified service, you can replace the corresponding service with yours — your service contains both the original service and your own service.

That way, when someone makes a request, you can use your own service to do whatever you need.

  Object origServiceManager = ServiceManager.getIServiceManager.callStatic();
        // Proxy dynamically before SystemServer adds system services
        ServiceManager.sServiceManager.setStaticValue(Reflection.on("android.os.IServiceManager")
                .proxy((proxy, method, args) -> {
                    if ("addService".equals(method.getName())) {
                        String serviceName = (String) args[0];
                        IBinder binder = (IBinder) args[1];
                        //CLog.w("[" + serviceName + "] -> [" + binder + "]");
                        if (ServerConfig.TARGET_BINDER_SERVICE_NAME.equals(serviceName)) {
                            // Replace the clipboard service so apps can acquire binder
                            args[1] = new BinderServiceProxy((Binder) args[1],
                                    ServerConfig.TARGET_BINDER_SERVICE_DESCRIPTOR, rms);
                            clipboardServiceReplaced = true;
                        }  else if (Context.ACTIVITY_SERVICE.equals(serviceName)) {
                            RuntimeManagerService.initContext();
                        }
                        if (clipboardServiceReplaced)
                            //set orig ServiceManager
                            ServiceManager.sServiceManager.setStaticValue(origServiceManager);
                        }

                    try {
                        return method.invoke(origServiceManager, args);
                    } catch (InvocationTargetException e) {
                        throw e.getTargetException();
                    }
                }));

Just dynamic-proxy ServiceManager. When the system service adds all sorts of managers, if it finds the specified system service, replace it with your own. Your system service needs to implement Binder. After it’s added, expose an interface on the server side for shared memory.

3. Implementing shared memory

First, why shared memory? In the old EDXposed approach, you inject a dex into the process you want to hook — but then the dex is already injected, and the footprint is huge.

It’s also easy to detect: use memory roaming to count the DexFile/File objects in memory, and you can easily detect how many dex files have been loaded.

We can expose an interface on the server side (system_service), wait for a client to request it, then hand out the dex to the designated client via shared memory.

For example, if I want to hook Hunter, I can have Hunter call this shared-memory-allocating interface on the server side at startup. The server returns an fd handle; Hunter mmaps it into memory, then uses InMemoryDexClassLoader to load the mapped value. With the classloader, do findClass and call the methods.

We want one global dex rather than each process loading its own copy — that would be inefficient. Here’s the code the server runs when it receives a client request and starts allocating the dex:

  else if (code == ServerConfig.DEX_TRANSACTION_CODE) {
            //CLog.i("RuntimeManagerService receive DEX_TRANSACTION_CODE "+.getCallAppName());
            try {
                //unzip jar
                unzip(RuntimeManagerService.RUNTIME_JAR_PATH,
                        RuntimeManagerService.RUNTIME_TEMP_DEX_PATH);
                //get dex
                File[] files = new File(RuntimeManagerService.RUNTIME_TEMP_DEX_PATH).listFiles();
                if (files == null || files.length == 0) {
                    CLog.i("DEX_TRANSACTION_CODE files size == 0 ");
                    return false;
                }
                @SuppressWarnings("all")
                List<File> dexFiles = Stream.of(files).
                        filter(file -> file.getName().endsWith(".dex")).collect(Collectors.toList());

                if (dexFiles.isEmpty()) {
                    CLog.i("DEX_TRANSACTION_CODE dexFiles.size()==0 ");
                    return false;
                }
                for(File file:dexFiles){
                    if(!file.exists()){
                        CLog.d("DEX_TRANSACTION_CODE dex file not find  "+file);
                        return false;
                    }
                }
                //CLog.i("DEX_TRANSACTION_CODE dex info " + dexFiles);
                ArrayList<SharedMemory> shm = RuntimeManagerService.getPreloadDex(dexFiles);
                if (shm == null || shm.isEmpty()) return false;
                reply.writeNoException();
                //dex size
                reply.writeInt(shm.size());
                for (int i = 0; i < shm.size(); i++) {
                    // assume that write only a fd
                    SharedMemory sharedMemory = shm.get(i);
                    sharedMemory.writeToParcel(reply, 0);
                    reply.writeLong(sharedMemory.getSize());
                }
                //clean temp dir
                try {
                    FileUtils.deleteDirectory(
                            new File(RuntimeManagerService.RUNTIME_TEMP_DEX_PATH)
                    );
                }catch (Throwable e) {
                    MagiskEngine.popen("rm -rf "+RuntimeManagerService.RUNTIME_TEMP_DEX_PATH);
                }
            } catch (Throwable e) {
                CLog.e("DEX_TRANSACTION_CODE handler error  " + e, e);
            }
            //CLog.i("RuntimeManagerService receive DEX_TRANSACTION_CODE return");
            return true;
        }

The server directly opens /system/framework/myjar.jar, which contains the dex.

After opening the jar, it loads the jar into memory via SharedMemory; once loaded, it writes the corresponding SharedMemory handle to the client. This supports writing multiple dex files.

The write format:

SharedMemory sharedMemory = shm.get(i);
sharedMemory.writeToParcel(reply, 0);

My format first writes the count of fd handles to load, then the corresponding file handle fd and its size.

The client reads what the server wrote from the parcel, in order: first the count of fd handles, then a for-loop reads each fd and fd size, and loads the fd. After mmap-ing into memory, use InMemoryDexClassLoader to load it.

jobject DexLoader::FromDexFd(JNIEnv *env,
                             const std::vector<std::tuple<int, size_t>> fd_list,const std::string& process_name ) {
    if (fd_list.size() == 0) {
        LOGE("DexLoader::FromDexFd fd size<=0")
        return nullptr;
    }
    LOGI("DexLoader::FromDexFd start load dex %s ", process_name.c_str())

    jobjectArray byteBuffArray =
            env->NewObjectArray(fd_list.size(),env->FindClass("java/nio/ByteBuffer"), NULL);

    for(int i=0;i<fd_list.size();i++){
        std::tuple<int, size_t> fd_info =  fd_list.at(i);
        auto fd = std::get<0>(fd_info);
        auto size = std::get<1>(fd_info);
        auto *addr = mmap(nullptr, size, PROT_READ, MAP_SHARED, fd, 0);
        LOGI("DexLoader::FromDexFd mmap start -> %p  [%s] ",addr, printMappedRegion(addr).c_str())
        //auto *addr = mmap(nullptr, size, PROT_READ, MAP_ANONYMOUS | MAP_SHARED, fd, 0);
        if (addr == MAP_FAILED) {
            LOGE("DexLoader::FromDexFd mmap error %s ", strerror(errno))
            return nullptr;
        }
        //get byte buff
        ScopedLocalRef<jobject> buffer(env,env->NewDirectByteBuffer(addr, (long) size));
        env->SetObjectArrayElement(byteBuffArray,i,buffer.get());
        //LOGI("DexLoader::FromDexFd mmap success %lu %p - %p package name -> [%s] ", size,addr,(void*)((char*)addr+size),process_name.c_str())
    }
    if(InMemoryDexClassLoader == nullptr){
        InMemoryDexClassLoader = WellKnownClasses::CacheClass(env,
              "dalvik/system/InMemoryDexClassLoader");
    }
    if(InMemoryDexClassLoader_init == nullptr){
        //more dex
        InMemoryDexClassLoader_init = WellKnownClasses::CacheMethod(env,
              InMemoryDexClassLoader,"<init>","([Ljava/nio/ByteBuffer;Ljava/lang/ClassLoader;)V",false);
    }
    WellKnownClasses::Init(env);
    ScopedLocalRef<jobject> system_class_loader(env, env->CallStaticObjectMethod(
            WellKnownClasses::java_lang_ClassLoader,
            WellKnownClasses::java_lang_ClassLoader_getSystemClassLoader));
    jobject loader = env->NewObject(InMemoryDexClassLoader, InMemoryDexClassLoader_init,
                                    byteBuffArray, system_class_loader.get());
    if (JNIHelper::ExceptionCheck(env)) {
        return nullptr;
    }
    return loader;
}

When loading in a designated client, you only need to initialize lsplant once to achieve Java hooks on any process.

4. How the client evades class detection

If we inject a process, but the target process walks all classloaders via memory roaming, then loadClass on each, directly loading a signature class would expose that class and leak our code. So this part uses an in-memory obfuscation — mainly LSPosed’s obfuscation.cpp.

Its principle is to obfuscate the in-memory dex: a normal path like com/zhenxi/hack gets obfuscated into random strings like A/B/C. The hook point is when the system service opens the dex — once it has the SharedMemory, obfuscate its contents.

    private static SharedMemory readDex(InputStream in) throws IOException, ErrnoException {
        var memory = SharedMemory.create(null, in.available());
        var byteBuffer = memory.mapReadWrite();
        Channels.newChannel(in).read(byteBuffer);
        SharedMemory.unmap(byteBuffer);
        if (BuildConfig.isObfuscationDex) {
            //obfuscate dex
            SharedMemory newMemory = ObfuscationManager.obfuscateDex(memory);
            if (BuildConfig.isSavefuscationDex) {
                saveObfuscationDex(newMemory);
            }
            if (memory != newMemory) {
                memory.close();
                memory = newMemory;
            }
        }
        memory.setProtect(OsConstants.PROT_READ);
        return memory;
    }

    public synchronized static ArrayList<SharedMemory> getPreloadDex(List<File> dexFiles) {
        if (preloadDex == null) {
            preloadDex = new ArrayList<>();
            for (File dex_file : dexFiles) {
                if (!dex_file.exists()) {
                    CLog.e("RuntimeManagerService getPreloadDex file not find " + dex_file);
                    continue;
                }
                try (var is = new FileInputStream(dex_file)) {
                    preloadDex.add(readDex(is));
                } catch (Throwable e) {
                    CLog.e("RuntimeManagerService preload dex", e);
                    return null;
                }
            }
        }
        return preloadDex;
    }

Since you obfuscated, you also need to expose a query interface on the server side — e.g., the client knows that the original path of A/B/C is com/zhenxi/hack, to avoid reflection failures.

You can configure the paths you want to obfuscate in obfuscation.cpp. See the LSPosed source for details.

Device fingerprint countermeasures by source

System-service countermeasures (system_service)

System services are simple — initialize lsplant inside system_service, then hook with the Xposed API directly.

For example, to modify the entire system’s android id, just hook the call method inside SettingsProvider and you can modify every android id.

        Class<?> SettingsProviderClazz = RuntimeManagerService.findclass(
                    "com.android.providers.settings.SettingsProvider");
            if (SettingsProviderClazz == null) {
                CLog.e("ServiceBaseInfoProcessor mockAndroidId ContentProviderProxyClazz == null");
                return;
            }
            //public Bundle call(String method, String name, Bundle args) {
            hook_set.add(RposedHelpers.findAndHookMethod(SettingsProviderClazz,
                    "call", String.class, String.class, Bundle.class, new RC_MethodHook() {
                        @Override
                        protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                            super.beforeHookedMethod(param);
                            //GET_secure/android_id/Bundle[mParcelledData.dataSize=48]
                            var thisOjb = (ContentProvider) param.thisObject;
                            if (thisOjb == null) return;
                            //if(!FPServiceProcessManager.isMatchCallPackageInfo(thisOjb.getCallingPackage())) return;

                            String key = (String) param.args[1];
                            if (StringUtils.isEmpty(key)) {
                                return;
                            }
                            //args bundle
                            //Bundle bundle = (Bundle) param.args[2];
                            //result
                            Bundle result = (Bundle) param.getResult();
                            if (result != null) {
                                String orig_value = result.getString("value");
                                String mock_value = getMockValueDistribution(orig_value, key,thisOjb.getCallingPackage());

                                if (mock_value != null && !mock_value.equals(orig_value)) {
                                    Bundle temp_result = new Bundle();
                                    temp_result.putString("value", mock_value);
                                    param.setResult(temp_result);
                                    CLog.i("[" + key + "] system_service orig value [" + orig_value + "] mock value -> ["
                                            + mock_value + "] call package -> " + thisOjb.getCallingPackage());
                                }
//                                CLog.i("[" + key + "] system_service orig value [" + orig_value + "] mock value -> ["
//                                        + mock_value + "] call package -> " + thisOjb.getCallingPackage());
                            }
                        }
                    }));

        } catch (Throwable e) {
            CLog.e("ServiceBaseInfoProcessor mockAndroidId error " + e, e);
        }

Because system services are global, anything any APK reads is what you’ve mocked. If you want to hide a package name, or make a given APK unable to read a specific package’s existence, hook packageManager inside the system service. After hooking, no APK can read the package you hooked — you can do centralized dispatch in isHandler.

Get the current caller’s uid, then its package name, and you know exactly who is querying and what they’re querying for.

private void HookgetPackageInfo(IPackageManager server_package) {

//        Method[] declaredMethods = server_package.getClass().getDeclaredMethods();
//        for(var method: declaredMethods){
//            CLog.e("IPackageManager method info "+method);
//        }

        RC_MethodHook handler_list = new RC_MethodHook() {
            @Override
            protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                super.afterHookedMethod(param);
                try {
                    Object result = param.getResult();
                    if (result == null) return;
                    if (result instanceof List<?> list) {
                        list.removeIf(item -> isHandler(param,item));
                    } else if (result instanceof ParceledListSlice<?> list) {
                        list.getList().removeIf(item -> isHandler(param,item));
                    }
                } catch (Throwable e) {
                    CLog.e("ServicePackageProcessor handler_list error ",e);
                }
            }
        };

        RC_MethodHook handler_single = new RC_MethodHook() {
            @Override
            protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                super.afterHookedMethod(param);
                try {
                    Object result = param.getResult();
                    if (result == null) return;
                    if (isHandler(param,result)) {
                        param.setResult(null);
                    }
                } catch (Exception e) {
                    CLog.e("ServicePackageProcessor handler_single error ",e);
                }
            }
        };

        //java.util.List com.android.server.pm.PackageManagerService.getAllPackages()
        try {
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "getAllPackages", handler_list));
        } catch (Exception ignored) {

        }
        try {
            //android.content.pm.ParceledListSlice com.android.server.pm.PackageManagerService.getInstalledPackages(int,int)
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "getInstalledPackages", handler_list));
        } catch (Exception ignored) {

        }
        try {
            //android.content.pm.ParceledListSlice
            //com.android.server.pm.PackageManagerService.getInstalledApplications(int,int)
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "getInstalledApplications", handler_list
            ));
        } catch (Exception ignored) {

        }
        try {
            //public final ParceledListSlice<PackageInfo> getPackagesHoldingPermissions()
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "getPackagesHoldingPermissions", handler_list
            ));
        } catch (Exception ignored) {

        }
        try {
            //List<InstrumentationInfo> queryInstrumentationInternal(String targetPackage,int)
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "queryInstrumentationInternal", handler_list
            ));
        } catch (Exception ignored) {

        }
        try {
            //public abstract List<ResolveInfo> queryIntentActivities(@NonNull Intent intent, int flags);
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "queryIntentActivities", handler_list
            ));
        } catch (Exception ignored) {

        }
        try {
            //public abstract List<ResolveInfo> queryIntentActivityOptions(@Nullable ComponentName caller,
            //              @Nullable Intent[] specifics, @NonNull Intent intent, int flags);
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "queryIntentActivityOptions", handler_list
            ));
        } catch (Exception ignored) {

        }
        try {
            //public List<ResolveInfo> queryIntentServices(Intent intent, ResolveInfoFlags flags)
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "queryIntentServices", handler_list
            ));
        } catch (Exception ignored) {

        }
        try {
            //public List<ResolveInfo> queryIntentContentProviders(Intent intent, ResolveInfoFlags flags)
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "queryIntentContentProviders", handler_list
            ));
        } catch (Exception ignored) {

        }
        try {
            // ParceledListSlice<ResolveInfo> parceledList = mPM.queryIntentReceivers(
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "queryIntentReceivers", handler_list
            ));
        } catch (Exception ignored) {

        }
        try {
            //PackageInfo com.android.server.pm.PackageManagerService.getPackageInfo(java.lang.String,int,int)
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "getPackageInfo", handler_single));
        } catch (Exception ignored) {

        }
        try {
            //int[] com.android.server.pm.PackageManagerService.getPackageGids(java.lang.String,int,int)
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "getPackageGids", handler_single));
        } catch (Exception ignored) {

        }
        try {
            //ApplicationInfo com.android.server.pm.PackageManagerService.getApplicationInfo(java.lang.String,int,int)
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "getApplicationInfo", handler_single));
        } catch (Exception ignored) {

        }
        try {
            //InstallSourceInfo
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "getInstallSourceInfo", handler_single));
        } catch (Exception ignored) {

        }
        try {
            //public abstract @Nullable Intent getLaunchIntentForPackage(@NonNull String packageName);
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "getLaunchIntentForPackage", handler_single));
        } catch (Exception ignored) {

        }
        try {
            //public abstract @Nullable Intent getLeanbackLaunchIntentForPackage(@NonNull String packageName);
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "getLeanbackLaunchIntentForPackage", handler_single));
        } catch (Exception ignored) {

        }
        try {
            //public ActivityInfo getActivityInfo(ComponentName className, int flags)
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "getActivityInfo", handler_single));
        } catch (Exception ignored) {

        }
        try {
            //public ResolveInfo resolveActivity(Intent intent, ResolveInfoFlags flags)
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "resolveActivity", handler_single));
        } catch (Exception ignored) {

        }
        try {
            //public ResolveInfo resolveActivityAsUser(Intent intent, ResolveInfoFlags flags, int userId)
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "resolveActivityAsUser", handler_single));
        } catch (Exception ignored) {

        }
        try {
            //public int getPackageUid(String packageName, int flags)
            hook_set.addAll(RposedBridge.hookAllMethods(
                    server_package.getClass(), "getPackageUid", new RC_MethodHook() {
                        @Override
                        protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                            super.afterHookedMethod(param);
                            Object result = param.getResult();
                            if (result == null) return;
                            if (result instanceof Integer) {
                                if (param.args[0] instanceof String package_name) {
                                    if (isBlackApp(param,package_name)) {
                                        param.setResult(-1);
                                    }
                                }
                            }
                        }
                    }));
        } catch (Exception ignored) {

        }
    }

System-service hooking is simple — basic Xposed hooks suffice. Other things like IMEI, ICCID, NIC, Bluetooth can all be handled in the system service.

Just hook the corresponding APIs. You can extract framework.jar under system/framework into classes and import them into Android Studio for convenient class manipulation.

Otherwise you’d have to findClass every time you hook, which is annoying. Set Gradle to compile-only.

Hooking other APKs

Currently I’ve only found that the three basics — oaid, vaid, aaid — are produced in a major phone vendor’s security-center app. It exposes a content provider, and handling it is also simple.

When the Magisk module detects that the vendor’s security-center app is loading, inject lsplant, request the server, get the Java code handle, and implement logic similar to the above.

Just hook the corresponding code, and adjust the hook logic to your needs.

public static void mockOaid(IRuntimeService service, Context context) {
        if(isInited) return;
        // class in the vendor's security-center app
        Class<?> IdProviderClazz = RposedHelpers.findClass(
                "com.miui.idprovider.IdProvider", context.getClassLoader());
        if (IdProviderClazz == null) {
            CLog.e("ServiceProcessOthersApks mockOaid  IdProvider clazz == null");
            return;
        }
        //CLog.e("ServiceProcessOthersApks mockOaid  get class success !");
        //public Cursor query(Uri uri, String[] strArr, String str, String[] strArr2, String str2) {
        RposedHelpers.findAndHookMethod(IdProviderClazz, "query",
                Uri.class,
                String[].class, String.class,
                String[].class, String.class, new RC_MethodHook() {
                    @Override
                    protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                        super.afterHookedMethod(param);
                        Object arg = param.args[0];
                        if (arg == null) {
                            return;
                        }
                        Object result = param.getResult();
                        if (result == null) return;
                        Cursor ret = (Cursor) result;
                        if (param.thisObject instanceof ContentProvider object) {
                            Uri uri = (Uri) arg;
                            if (object.getCallingPackage() == null) {
                                return;
                            }
                            String callingPackage = object.getCallingPackage();
                            //CLog.i("ServiceProcessOthersApks callingPackage "+callingPackage);
                            // only handle the target APK, to reduce server-side logic
                            if (callingPackage.equals(BuildConfig.tagPackageName)) {
                                if (uri.toString().equals(AUTHORITY + PATH_OAID)) {
                                    handlerId(param, service, ret, PATH_OAID);
                                } else if (uri.toString().equals(AUTHORITY + PATH_AAID)) {
                                    handlerId(param, service, ret, PATH_AAID);
                                } else if (uri.toString().equals(AUTHORITY + PATH_VAID)) {
                                    handlerId(param, service, ret, PATH_VAID);
                                } else {
                                    CLog.i("not match uri " + uri + " " + AUTHORITY + PATH_OAID);
                                }
                            }
                        }
                    }
                });
        isInited = true;
    }

Kernel ID countermeasures

Common kernel IDs
/sys/block/mmcblk0/device/cid
/sys/block/mmcblk0/device/serial
/sys/devices/soc0/serial_number
/proc/sys/kernel/random/boot_id
netlink NIC
kernel config-file related

This part has always been a sore point for me, because these values are produced in the kernel, not belonging to any APK, yet they can be used as device fingerprints.

Take the most basic boot_id — it changes after reboot, but the ways to read it are many:

  1. svc to open the file and read its contents.
  2. popen cat the file contents.
  3. On higher versions, you can also go into the current process’s fd handle and ls -l to see the corresponding boot_id.
houji:/ # ls -l /proc/19178/fd
total 0
lrwx------ 1 root root 64 2024-05-24 22:46 0 -> /dev/null
lrwx------ 1 root root 64 2024-05-24 22:46 1 -> /dev/null

lrwx------ 1 root root 64 2024-05-24 22:46 79 -> /dev/ashmem5bc1633e-ea26-444e-b8a6-d7f607b9dd37

lr-x------ 1 root root 64 2024-05-24 22:46 8 -> /apex/com.android.art/javalib/core-oj.jar
lrwx------ 1 root root 64 2024-05-24 22:46 80 -> socket:[605258]

For example the third line — the string after /dev/ashmem is the corresponding boot_id: 5bc1633e-ea26-444e-b8a6-d7f607b9dd37.

Since we might have 10 accounts on one phone at the same time, we want each account to have its own boot_id — e.g., when account A starts, switch to account A’s boot_id.

My previous approach was to compile a major vendor’s kernel directly, but it’s full of pitfalls — that vendor’s kernel has many closed-source drivers, like the under-screen fingerprint unlock.

Low Android versions compile fine, but high versions have all sorts of pitfalls, and the environment is incomplete. In the end I found that separately compiling the kernel and modifying it inside the kernel is a dead end.

Mark this down: back then I researched compiling a high-version kernel for over a month and still couldn’t do it.

Another pitfall: why not use a custom ROM?

Because the domestic environment detects stock ROMs strongly, the benefit is marginal, the change cost is too high, and it doesn’t suit a plug-in approach that can be turned on/off at will.

Just as I was stuck, I found that APatch supports kernel hooking. As long as the phone has APatch flashed, you can use KPM modules to develop your own kernel hook tool.

APatch can run not just Magisk modules, but also kernel-module hooks.

For example, to modify boot_id, first see how the kernel reads it — it exposes a function via the sysctl mechanism. The source is Kernel\drivers\char\random.c, initialized like this:

    {
        .procname = "boot_id",
        .data = &sysctl_bootid,
        .maxlen = 16,
        .mode = 0444,
        .proc_handler = proc_do_uuid,
    },

You can see data points to the real data in sysctl_bootid.

It’s just a 16-byte array static char sysctl_bootid[16]; at the top of the C file.

Use the KPM module to hook it directly — save the original value’s address, then define a custom syscall to write a mocked boot_id into the kernel.

char *sysctl_bootid = 0;
void initHackBootId(){
    sysctl_bootid = (char *)kallsyms_lookup_name("sysctl_bootid");
    //save orig boot_id
    memset(ori_sysctl_bootid, 0, 0x10);
    memcpy(ori_sysctl_bootid, sysctl_bootid, 0x10);
    snprintf(ori_ashmem_path, ASHMEM_FULL_NAME_LEN, "%s%pU", ASHMEM_NAME_PREFIX, ori_sysctl_bootid);
    if(sysctl_bootid == NULL){
        pr_info("get sysctl_bootid sym sysctl_bootid == NULL \n");
    }
    pr_info("runtime_kpm: kernel sysctl_bootid addr:\n %llx %s",sysctl_bootid, sysctl_bootid);
}

Just call this custom syscall to write directly into the original boot_id — we already got the original byte-array pointer via kallsyms_lookup_name.

There’s another pitfall after modifying boot_id: no APK will open anymore. The reason is that the server already loaded and saved the original boot_id.

We then send a specific device-modification instruction to the server, and the server writes the mock boot_id into the kernel via syscall — because we need the mock fingerprint work done before the client starts.

The client has just started; when it reads server resources via shared memory, the client gets an fd handle. When the client tries to open this handle it can’t find the boot_id, so no APK opens. The fix is simple: use mknod to back it up.

Other fields are easy to modify too — nothing much going on in the kernel; just hook the corresponding function and modify it.

Make a mock value, make a custom syscall to set the mock value. If the mock value is non-null, the hooked kernel function reads the mock value first.

Back when I first implemented kernel countermeasures, I used a custom kernel and compiled kernel modules — but on higher versions it wouldn’t flash; the phone bricked after flashing.

I used Android 10 for a long time. Taking boot_id as an example, it’s really just a sysctl function, which is convenient for kernel↔app interaction.

Modifying the kernel is also simple: add a sysctl function in the kernel, expose a command to the app layer, and write directly.

static int hack_bootid(struct ctl_table *table, int write,
                  void __user *buffer, size_t *lenp, loff_t *ppos) {
size_t len;
long ret;
if (!write) {
   pr_err("hack_bootid: Attempt to read-only");
   return -EFAULT;
}
if (write) {
   len = min_t(size_t, *lenp, sizeof(sysctl_bootid));
   pr_info("hack_bootid: Attempt to write. Requested len=%zu", len);
   if (len) {
       memset(sysctl_bootid, 0, sizeof(sysctl_bootid));
       ret = copy_from_user(sysctl_bootid, buffer, len); // copy data directly from user space
       if (ret) {
           pr_err("hack_bootid: Failed to copy data from user space");
           return -EFAULT;
       }
       pr_info("hack_bootid: Successfully updated sysctl_bootid ret = %d ",ret);
   }
}
pr_info("hack_bootid: Current bootid -> [%.*s]", (int)sizeof(sysctl_bootid), sysctl_bootid);
return 0;
}

Register the corresponding sysctl table in the kernel; the app layer can directly

{
   .procname = "hack_boot_id",
   .data = &sysctl_bootid,
   .maxlen = 16,
   .mode = 0666,
   .proc_handler = hack_bootid,
},

To modify, one command line does it:

sysctl -w kernel.random.hack_boot_id="1234567890123456"

But now that APatch exists, this is abandoned.

Init-process device fingerprints

This is the most special device fingerprint. As mentioned at the start, DRM IDs are produced in the init process. Init is the ancestor of all processes, and it contains too much.

Precisely because it starts so early, hooking this process is expensive — you need to inject into it. I previously talked with the zygisk_next author (one of the LSPosed authors) about adding an init-process callback in zygisk_next, but I don’t know the current development progress. That could take forever. For an init process, injection has only one route: use ptrace to get in. But there’s another nasty bit: after injecting with ptrace, how do you pass the mock value to the target process?

The fingerprints above are all saved through system services — the idea is to call binder via env to get the class and communicate with the server. But the init process doesn’t allow this, because it’s created too early; it doesn’t even have a JavaVM yet, so you can’t get env.

Also, the init process belongs to the vendor partition, not the system partition.

On Android 8+, there’s partition isolation: the system partition can’t read vendor content, and vendor can’t read system. Various app layers modify at the HAL layer. So you can’t inject into the target process and then use /system/lib64/libandroid_runtime.so to call _ZN7android14AndroidRuntime7mJavaVME for VM creation.

After thinking for a long time, writing a string into the target process via ptrace is the best approach.

Let’s talk about the DRM process modification. Its core production point is in /vendor/lib64/libwvhidl.so, under this symbol:

_ZN5wvdrm8hardware3drm4V1_28widevine11WVDrmPlugin20CdmIdentifierBuilder17getDeviceUniqueIdEPNSt3__112basic_stringIcNS6_11char_traitsIcEENS6_9allocatorIcEEEE

We have the target. Now implement it with ptrace. First we need to modify system_server to allow root execution, then use ptrace to write an injector program.

const char* _libinject_log_tag = "Zhenxi";
int _libinject_log = 1;

void libinject_log(const char* log_tag) {
    _libinject_log_tag = log_tag;
    _libinject_log = log_tag == NULL ? 0 : 1;
}

pid_t _pid;
void *_dlopen;
void *_dlerror;
void *_calloc;
void *_free;

typedef void (*remote_stop_t)();
remote_stop_t remote_stop_ptr = NULL;

// ptrace wrapper with some error checking.
static long trace(const char* debug, int request, void *addr = NULL, size_t data = 0) {
    errno = 0;
    long ret = 0;
    for (int i = 0; i < 10; i++) {
        ret = ptrace(request, _pid, (caddr_t) addr, (void *) data);
        if (ret == -1 && (errno == EBUSY || errno == EFAULT || errno == ESRCH)) {
            char eb[16];
            char rb[16];

            const char* e = NULL;
            const char* r = NULL;

            switch (errno) {
            case ESRCH: e = "ESRCH"; break;
            default: snprintf(eb, sizeof(eb), "%d", errno); e = eb;
            }

            switch (request) {
            case PTRACE_PEEKTEXT: r = "PTRACE_PEEKTEXT"; break;
            case PTRACE_PEEKDATA: r = "PTRACE_PEEKDATA"; break;
            case PTRACE_POKETEXT: r = "PTRACE_POKETEXT"; break;
            case PTRACE_POKEDATA: r = "PTRACE_POKEDATA"; break;
            case PTRACE_CONT: r = "PTRACE_CONT"; break;
            case PTRACE_KILL: r = "PTRACE_KILL"; break;
            case PTRACE_SINGLESTEP: r = "PTRACE_SINGLESTEP"; break;
#if defined(PTRACE_GETREGS)
            case PTRACE_GETREGS: r = "PTRACE_GETREGS"; break;
#endif
#if defined(PTRACE_SETREGS)
            case PTRACE_SETREGS: r = "PTRACE_SETREGS"; break;
#endif
#if defined(PTRACE_GETFPREGS)
            case PTRACE_GETFPREGS: r = "PTRACE_GETFPREGS"; break;
#endif
#if defined(PTRACE_SETFPREGS)
            case PTRACE_SETFPREGS: r = "PTRACE_SETFPREGS"; break;
#endif
            case PTRACE_ATTACH: r = "PTRACE_ATTACH"; break;
            case PTRACE_DETACH: r = "PTRACE_DETACH"; break;
            case PTRACE_SYSCALL: r = "PTRACE_SYSCALL"; break;
            case PTRACE_SETOPTIONS: r = "PTRACE_SETOPTIONS"; break;
            case PTRACE_GETEVENTMSG: r = "PTRACE_GETEVENTMSG"; break;
            case PTRACE_GETSIGINFO: r = "PTRACE_GETSIGINFO"; break;
            case PTRACE_SETSIGINFO: r = "PTRACE_SETSIGINFO"; break;
#if defined(PTRACE_GETREGSET)
            case PTRACE_GETREGSET: r = "PTRACE_GETREGSET"; break;
#endif
#if defined(PTRACE_SETREGSET)
            case PTRACE_SETREGSET: r = "PTRACE_SETREGSET"; break;
#endif
            default: snprintf(rb, sizeof(rb), "%d", request); r = rb;
            }

            INJECTLOG("ptrace [%s] error [%s] on request [%s]", debug, e, r);
        }
        if (ret == -1 && (errno == ESRCH)) {
            INJECTLOG("ptrace remote_stop/retry");
            if (remote_stop_ptr != NULL) {
                remote_stop_ptr();
            }
        } else {
            break;
        }
    }
    return ret;
}

/*
 * This method will open /proc/<pid>/maps and search for the specified
 * library base address.
 */
static uintptr_t findLibrary(const char *library, pid_t pid) {
    char filename[0xFF] = { 0 }, buffer[1024] = { 0 };
    FILE *fp = NULL;
    uintptr_t address = 0;
    bool isFind = false;
    sprintf(filename, "/proc/%d/maps", pid == -1 ? _pid : pid);

    fp = fopen(filename, "rt");
    if (fp == NULL) {
        INJECTLOGE("findLibrary fopen error %s %s ",library,filename);
        goto done;
    }

    while (fgets(buffer, sizeof(buffer), fp)) {
        if (strstr(buffer, library)) {
            address = (uintptr_t) strtoul(buffer, NULL, 16);
            isFind = true;
            goto done;
        } else{
            //INJECTLOG("printf map item %s ",buffer);
        }
    }

    done:
//    if(!isFind){
//        INJECTLOG("findLibrary not find [%s] in %s ",library,filename);
//    }
    if (fp) {
        fclose(fp);
    }
//    if(address == 0){
//        INJECTLOG("findLibrary [%s] return 0 ,not find addr ",library);
//    }
    return address;
}

/*
 * Compute the delta of the local and the remote modules and apply it to the local address of the symbol ...
 * BOOM, remote symbol address!
 */
static void* remote_findFunction(const char* library, void* local_addr) {
    if(local_addr == nullptr){
        INJECTLOGE("remote_findFunction %s location base %p",library,(void*)local_addr);
        return nullptr;
    }
    uintptr_t remote_handle = findLibrary( library, -1 );
    if(remote_handle == 0){
        INJECTLOGE("remote_findFunction %s remote base == 0 ",library);
        return nullptr;
    }
    uintptr_t local_handle = findLibrary( library, getpid() );
    if(local_handle == 0){
        //INJECTLOG("remote_findFunction local_handle == 0 %s loc  %p  %d %s ",library,local_addr,getpid(),getprogname());
        Dl_info info;
        if(dladdr(local_addr,&info)){
            local_handle = (uintptr_t)info.dli_fbase;
        } else{
            INJECTLOGE("remote_findFunction local_handle backup == 0  ");
            return nullptr;
        }
    }
    uintptr_t offset = (uintptr_t) local_addr - local_handle;
    uintptr_t remote_addr = remote_handle + offset;
    return (void*)remote_addr;
}

static uint64_t ms() {
    struct timespec spec;
    clock_gettime(CLOCK_MONOTONIC, &spec);
    return (spec.tv_sec * 1000) + (spec.tv_nsec / 1.0e6);
}

/*
 * Make sure the remote process is stopped, or we get ESRCH errors
 */
static void remote_stop() {
//    INJECTLOG( "remote_stop" );
    kill( _pid, SIGSTOP );

    int status;
    int ret;
    uint64_t start = ms();
    while ( (ret = waitpid( _pid, &status, WUNTRACED || WNOHANG )) != -1 ) {
        if (ret == _pid) {
            if (WIFSIGNALED(status)) {
                trace ( "remote_stop", PTRACE_CONT, NULL, WTERMSIG(status));
            } else if (WIFSTOPPED(status)) {
                break;
            } else if (WIFEXITED(status)) {
                break;
            }
        } else if (ms() - start > 128) {
            // assume stopped before remote_stop() was called, 128ms is long
            break;
        }
        usleep(1);
    }
//    INJECTLOG( "/remote_stop" );
}

/*
* Read 'blen' bytes from the remote process at 'addr' address.
*/
static bool remote_read(const char* debug, size_t addr, unsigned char *buf, size_t blen){
    remote_stop();

    size_t i = 0;
    long ret = 0;

    for( i = 0; i < blen; i += sizeof(size_t) ){
       ret = trace( debug, PTRACE_PEEKTEXT, (void *)(addr + i) );
       if( ret == -1 ) {
           return false;
       }

       memcpy( &buf[i], &ret, sizeof(ret) );
    }

    return true;
}

/*
 * Write 'blen' bytes to the remote process at 'addr' address.
 */
static bool remote_write(const char* debug, size_t addr, unsigned char *buf, size_t blen) {
    remote_stop();

    size_t i = 0;
    long ret;

    // make sure the buffer is word aligned
    char *ptr = (char *) malloc(blen + blen % sizeof(size_t));
    memcpy(ptr, buf, blen);

    for (i = 0; i < blen; i += sizeof(size_t)) {
        ret = trace( debug, PTRACE_POKETEXT, (void *) (addr + i), *(size_t *) &ptr[i] );
        if (ret == -1) {
            free(ptr);
            return false;
        }
    }

    free(ptr);

    return true;
}

// Get remote registers
static void trace_getregs(const char* debug, struct pt_regs * regs) {
#if defined (__aarch64__) || defined(__x86_64__)
    uintptr_t regset = NT_PRSTATUS;
    struct iovec ioVec;

    ioVec.iov_base = regs;
    ioVec.iov_len = sizeof(*regs);
    trace( debug, PTRACE_GETREGSET, (void*)regset, (size_t)&ioVec );
#else
    trace( debug, PTRACE_GETREGS, 0, (size_t)regs );
#endif
}

// Set remote registers
static void trace_setregs(const char* debug, struct pt_regs * regs) {
#if defined (__aarch64__) || defined(__x86_64__)
    uintptr_t regset = NT_PRSTATUS;
    struct iovec ioVec;

    ioVec.iov_base = regs;
    ioVec.iov_len = sizeof(*regs);
    trace( debug, PTRACE_SETREGSET, (void*)regset, (size_t)&ioVec );
#else
    trace( debug, PTRACE_SETREGS, 0, (size_t)regs );
#endif
}

/*
 * Remotely call the remote function given its address, the number of
 * arguments and the arguments themselves.
 */
static uintptr_t remote_call(void *function, int nargs, ...) {
#if defined(__arm__) || defined(__aarch64__) || defined(__i386__) || defined(__x86_64__)
    // pause the target process
    remote_stop();

    struct pt_regs regs, rbackup;

    // get registers and backup them
    trace_getregs( "backup", &regs );
    // backup original registers
    memcpy( &rbackup, &regs, sizeof(struct pt_regs) );

    // start copying parameters
    va_list vl;
    va_start(vl,nargs);

    // push parameters into registers and stacks, setup registers to perform the call

#if defined(__arm__) || defined(__aarch64__)
    // fill R0-Rx with the first 4 (32-bit) or 8 (64-bit) parameters
    // on ARM registers start from R0
    for ( int i = 0; ( i < nargs ) && ( i < PARAMS_IN_REGS ); ++i ) {
        regs.uregs[i] = va_arg( vl, uintptr_t );
    }

    // push remaining parameters onto stack
    if (nargs > PARAMS_IN_REGS) {
        regs.ARM_sp -= sizeof(uintptr_t) * (nargs - PARAMS_IN_REGS);
        uintptr_t stack = regs.ARM_sp;
        for ( int i = PARAMS_IN_REGS; i < nargs; ++i ) {
            uintptr_t arg = va_arg( vl, uintptr_t );
            remote_write( "params", (size_t)stack, (uint8_t *)&arg, sizeof(uintptr_t) );
            stack += sizeof(uintptr_t);
        }
    }

    // return address to catch
    regs.ARM_lr = 0;

    // function address to call
    regs.ARM_pc = (uintptr_t)function;

    // set up the current processor status register
    if ( regs.ARM_pc & 1 ) {
        // thumb
        regs.ARM_pc &= (~1u);
        regs.ARM_cpsr |= CPSR_T_MASK;
    } else {
        // arm
        regs.ARM_cpsr &= ~CPSR_T_MASK;
    }
#elif defined(__i386__)
    // push all params onto stack
    regs.esp -= sizeof(uintptr_t) * nargs;
    uintptr_t stack = regs.esp;
    for( int i = 0; i < nargs; ++i ) {
        uintptr_t arg = va_arg( vl, uintptr_t );
        remote_write( "params", (size_t)stack, (uint8_t *)&arg, sizeof(uintptr_t) );
        stack += sizeof(uintptr_t);
    }

    // return address to catch
    uintptr_t tmp_addr = 0;
    regs.esp -= sizeof(uintptr_t);
    remote_write( "return", (size_t)regs.esp, (uint8_t *)&tmp_addr, sizeof(uintptr_t) );

    // function address to call
    regs.eip = (uintptr_t)function;
#elif defined(__x86_64__)
    // align, rsp - 8 must be a multiple of 16 at function entry point
    {
        uintptr_t space = sizeof(uintptr_t);
        if (nargs > 6) space += sizeof(uintptr_t) * (nargs - 6);
        while (((regs.rsp - space - 8) & 0xF) != 0) regs.rsp--;
    }

    // fill [RDI, RSI, RDX, RCX, R8, R9] with the first 6 parameters
    for ( int i = 0; ( i < nargs ) && ( i < 6 ); ++i ) {
        uintptr_t arg = va_arg( vl, uintptr_t );
        switch (i) {
        case 0: regs.rdi = arg; break;
        case 1: regs.rsi = arg; break;
        case 2: regs.rdx = arg; break;
        case 3: regs.rcx = arg; break;
        case 4: regs.r8 = arg; break;
        case 5: regs.r9 = arg; break;
        }
    }

    // push remaining parameters onto stack
    if (nargs > 6) {
        regs.rsp -= sizeof(uintptr_t) * (nargs - 6);
        uintptr_t stack = regs.rsp;
        for( int i = 6; i < nargs; ++i ) {
            uintptr_t arg = va_arg( vl, uintptr_t );
            remote_write( "params", (size_t)stack, (uint8_t *)&arg, sizeof(uintptr_t) );
            stack += sizeof(uintptr_t);
        }
    }

    // return address to catch
    uintptr_t tmp_addr = 0;
    regs.rsp -= sizeof(uintptr_t);
    remote_write( "return", (size_t)regs.rsp, (uint8_t *)&tmp_addr, sizeof(uintptr_t) );

    // function address to call
    regs.rip = (uintptr_t)function;

    // may be needed
    regs.rax = 0;
    regs.orig_rax = 0;
#endif

    // end of parameters
    va_end(vl);

    // do the call
    trace_setregs( "call", &regs );
    trace( "call", PTRACE_CONT );

    // catch the SIGSEGV caused by the 0 return address
    int status;
    while ( waitpid( _pid, &status, WUNTRACED ) == _pid ) {
        if ( WIFSTOPPED(status) && (WSTOPSIG(status) == SIGSEGV) ) {
            break;
        }
        trace( "waitpid", PTRACE_CONT );
    }

    // get registers again for return value
    trace_getregs( "return", &regs );

    // restore original registers state
    trace_setregs( "restore", &rbackup );

    // continue execution
    trace( "continue", PTRACE_CONT );

#if defined(__arm__) || defined(__aarch64__)
    return regs.ARM_r0;
#elif defined(__i386__)
    return regs.eax;
#elif defined(__x86_64__)
    return regs.rax;
#endif
    return 0;
#else
#error ARCHITECTURE NOT SUPPORTED
#endif
}

// Allocate memory in remote process
static uintptr_t remote_calloc(size_t nmemb, size_t size) {
    return remote_call(_calloc, 2, nmemb, size);
}

// Free remotely allocated memory.
static void remote_free(uintptr_t p) {
    remote_call(_free, 1, p);
}

// Copy a given string into the remote process memory.
static uintptr_t remote_string(const char *s) {
    uintptr_t mem = remote_calloc(strlen(s) + 1, 1);

    remote_write( "string", mem, (unsigned char *) s, strlen(s) + 1);

    return mem;
}

// Remotely force the target process to dlopen a library.
static uintptr_t remote_dlopen(const char *libname) {
    uintptr_t pmem = remote_string(libname);
    uintptr_t plib = remote_call(_dlopen, 2, pmem, 2);
    remote_free(pmem);
    return plib;
}

// Get remote dlerror
static void remote_dlerror(char* error, int size) {
    uintptr_t e = remote_call(_dlerror, 0);
    remote_read("dlerror", e, (unsigned char*)error, size - 1);
}

// Find pid for process
pid_t find_pid_of(const char* process) {
    int id;
    pid_t pid = -1;
    DIR* dir;
    FILE *fp;
    char filename[32];
    char cmdline[256];

    struct dirent * entry;

    if (process == NULL)
        return -1;

    dir = opendir("/proc");
    if (dir == NULL)
        return -1;

    while ((entry = readdir(dir)) != NULL) {
        id = atoi(entry->d_name);
        if (id != 0) {
            sprintf(filename, "/proc/%d/cmdline", id);
            fp = fopen(filename, "r");
            if (fp) {
                fgets(cmdline, sizeof(cmdline), fp);
                fclose(fp);

                if (strcmp(process, cmdline) == 0) {
                    /* process found */
                    //if(id > pid){
                    // if same process name, use the process with the largest ID (most recently started), to avoid finding the host process in VMOS
                    //  pid = id;
                    //}
                    pid = id;
                    break;
                }
            }
        }
    }

    closedir(dir);
    return pid;
}


// its purpose is to unblock the zygote process
void * connect_to_zygote(void *arg) {
    int s, len;
    struct sockaddr_un remote;

    INJECTLOG("[+] wait 2s...");
    sleep(2);

    /***
    * after the zygote process starts it enters an infinite loop, used to receive AMS connection requests.
    * when no app is starting, the zygote process stays blocked.
    * to prevent wait from never returning, proactively initiate a zygote connection to unblock it.
    */
    if ((s = socket(AF_UNIX, SOCK_STREAM, 0)) != -1) {
        remote.sun_family = AF_UNIX;
#if defined(__arm__)
        strcpy(remote.sun_path, "/dev/socket/zygote");
#elif defined(__aarch64__)
        strcpy(remote.sun_path, "/dev/socket/zygote64");
#endif
        len = strlen(remote.sun_path) + sizeof(remote.sun_family);
        INJECTLOG("[+] start to connect zygote socket");
        connect(s, (struct sockaddr *) &remote, len);
        INJECTLOG("[+] close socket");
        close(s);
    }

    return NULL;
}

// load a library into process pid, resolve JavaVM and pass it and param to the loaded library; returns 0 on success
int injectvm(pid_t pid, char* library, const char* type,const char* param) {
    remote_stop_ptr = remote_stop;

    int ret = 1;
    _pid = pid;

    // attach to target process
    if ( trace( "attach", PTRACE_ATTACH ) != -1) {
        // stop entire process, including non-main threads
        kill( _pid, SIGSTOP);

        // wait until we're stopped
        remote_stop();

        /* First thing first, we need to search these functions into the target
         * process address space.
         */

        /* We can resolve the references to LIBC easily, but dl* is tricky. On older Android
         * versions, libdl.so is commonly not loaded by the linker, and our dl* functions
         * come directly from the linker.
         *
         * On newer Android versions, libdl.so is directly loaded and dl* come from there.
         *
         * On even newer Android versions, the linker/libc/libdl have moved from /system to /bionic
         */
        // detect VMOS environment: isVMOS!=0 means VMOS environment
        int isVMOS = findLibrary(PATH_LIBC_VMOS, _pid);
        int isVMOSPRO = findLibrary(PATH_LIBC_VMOS_PRO, _pid);
        int isVMOSPRO2 = findLibrary(PATH_LIBC_VMOS_PRO_2, _pid);
        int isTWOYI = findLibrary(PATH_LIBC_TWOYI, _pid);
        int isVPHONE = findLibrary(PATH_LIBC_VPHONE, _pid);

        const char* libc = isVMOS!=0 ? PATH_LIBC_VMOS : (isVMOSPRO!=0 ? PATH_LIBC_VMOS_PRO :(isVMOSPRO2!=0 ? PATH_LIBC_VMOS_PRO_2 : ( isTWOYI!=0 ? PATH_LIBC_TWOYI : ( isVPHONE!=0 ? PATH_LIBC_VPHONE : (access( PATH_LIBC_BIONIC, R_OK ) == 0 ? PATH_LIBC_BIONIC : PATH_LIBC)))));
        const char* libdl = isVMOS!=0 ? PATH_LIBDL_VMOS : (isVMOSPRO!=0 ? PATH_LIBDL_VMOS_PRO :(isVMOSPRO2!=0 ? PATH_LIBDL_VMOS_PRO_2 : ( isTWOYI!=0 ? PATH_LIBDL_TWOYI :( isVPHONE!=0 ? PATH_LIBDL_VPHONE :(access( PATH_LIBDL_BIONIC, R_OK ) == 0 ? PATH_LIBDL_BIONIC : PATH_LIBDL)))));
        const char* linker = isVMOS!=0 ? PATH_LINKER_VMOS :(isVMOSPRO!=0 ? PATH_LINKER_VMOS_PRO :(isVMOSPRO2!=0 ? PATH_LINKER_VMOS_PRO_2 : ( isTWOYI!=0 ? PATH_LINKER_TWOYI :( isVPHONE!=0 ? PATH_LINKER_VPHONE :(access( PATH_LINKER_BIONIC, R_OK ) == 0 ? PATH_LINKER_BIONIC : PATH_LINKER)))));
        //INJECTLOG( "libc:%s", libc );
        //INJECTLOG( "libdl:%s", libdl );
        //INJECTLOG( "linker:%s", linker );
        // get the remote dlopen address
        _calloc = remote_findFunction( libc, (void *) calloc );
        _free = remote_findFunction( libc, (void *) free );
        if ((findLibrary( libdl, -1 ) != 0) && (findLibrary( libdl, _pid ) != 0)) {
           //ret = 100+isVPHONE;
            void* handle = dlopen( libdl, RTLD_LAZY );
            _dlopen = remote_findFunction( libdl, dlsym( handle, "dlopen" ) );
            _dlerror = remote_findFunction( libdl, dlsym( handle, "dlerror" ) );
            dlclose( handle );
        } else {
            //ret = 200+isVPHONE;
            _dlopen = remote_findFunction( linker, (void *) dlopen );
            _dlerror = remote_findFunction( linker, (void *) dlerror );
        }

        // Resolve android::AndroidRuntime::mJavaVM,
        // this is tricky from the payload because
        // of linker namespaces (you can't load the lib, dlsym doesn't work right, and the location
        // of the variable in memory is different between Android versions), but no such issue
        // exists from this injector.

        // get java vm; if under vendor, getting runtime may fail
        // because runtime is under the system partition
        void* runtime = dlopen( PATH_LIBANDROID_RUNTIME, RTLD_NOW  );
        void* javavm = dlsym( runtime, "_ZN7android14AndroidRuntime7mJavaVME" );
        void* _javavm = remote_findFunction( PATH_LIBANDROID_RUNTIME, javavm );
        dlclose(runtime);

        INJECTLOG( "calloc:%p free:%p dlopen:%p dlerror:%p javavm:%p", _calloc, _free, _dlopen, _dlerror, _javavm );
        INJECTLOG( "inject elf path %s ", library );

        // once we have the addresses, we can proceed to inject
        if ( remote_dlopen(library) != 0 ) {
            INJECTLOG( "remote dlopen [%s] success ! ",library);
            void* payload = dlopen( library, RTLD_LAZY );
            if(payload == nullptr){
                INJECTLOG( "remote dlopen error %s ", library);
            }
            void* LocDoRunSym = dlsym(payload, "doRun" );
            if(LocDoRunSym == nullptr){
                INJECTLOG( "not find doRun sym " );
            }
            Dl_info info;
            dladdr(LocDoRunSym,&info);
            INJECTLOG("loc doRun info [%s] fbase -> %p  offset %p ",
                      info.dli_fname,info.dli_fbase,(void*)((size_t)LocDoRunSym-(size_t)info.dli_fbase)
            );
            void* remoteDoRunSym = remote_findFunction(library, LocDoRunSym);
            if (LocDoRunSym != NULL && remoteDoRunSym != NULL) {
                //INJECTLOG("start call doRun -> %p -> [%s]", remoteDoRunSym, param);
                uintptr_t pdata = remote_string(param);
                uintptr_t ptype = remote_string(type);
                //remote_call(remoteDoRunSym, 2, _javavm, pmem);
                //return 0 is success ~!
                auto call_dorun_ret = remote_call(remoteDoRunSym, 2,ptype, pdata);
                //auto call_dorun_ret = remote_call(remoteDoRunSym, 0);
                remote_free(pdata);
                remote_free(ptype);
                //INJECTLOG("doRun call finish %lu %p",call_dorun_ret,(void*)call_dorun_ret);
                ret = call_dorun_ret;
            }
        } else {
            char error[1024] = { 0 };
            remote_dlerror(error, 1024);
            INJECTLOG( "remote_dlopen failed: %s", error );
        }

        // detach from target process
        remote_stop();
        trace( "detach", PTRACE_DETACH );
        // let all threads in the target process continue
        kill( _pid, SIGCONT );
        INJECTLOG( "<<<<<<<< ptrace finish >>>>>>>> ");
    } else {
        INJECTLOG( "Failed to attach to process %d", _pid);
    }

    return ret;
}

Compile this code into an injector; the input is the pid and the .so file you want to inject.

The overall idea: ptrace-attach to the target process, then make the target process call dlopen to open the designated .so file, initializing our own logic.


Below, “remote function” refers to the function address in the injected process.

1. Getting the target process’s dlopen/malloc addresses

First compute dlopen’s offset relative to libc. Open libc in our own executable and compute the offset of dlopen.

Then after injecting into the process, open libc in the target process; target libc base + the obtained offset = the target process’s dlopen address.

Get dlopen, dlerror, malloc, free, etc. the same way.

/*
 * Compute the delta of the local and the remote modules and apply it to the local address of the symbol ...
 * BOOM, remote symbol address!
 */
static void* remote_findFunction(const char* library, void* local_addr) {
    if(local_addr == nullptr){
        INJECTLOGE("remote_findFunction %s location base %p",library,(void*)local_addr);
        return nullptr;
    }
    uintptr_t remote_handle = findLibrary( library, -1 );
    if(remote_handle == 0){
        INJECTLOGE("remote_findFunction %s remote base == 0 ",library);
        return nullptr;
    }
    uintptr_t local_handle = findLibrary( library, getpid() );
    if(local_handle == 0){
        //INJECTLOG("remote_findFunction local_handle == 0 %s loc  %p  %d %s ",library,local_addr,getpid(),getprogname());
        Dl_info info;
        if(dladdr(local_addr,&info)){
            local_handle = (uintptr_t)info.dli_fbase;
        } else{
            INJECTLOGE("remote_findFunction local_handle backup == 0  ");
            return nullptr;
        }
    }
    uintptr_t offset = (uintptr_t) local_addr - local_handle;
    uintptr_t remote_addr = remote_handle + offset;
    return (void*)remote_addr;
}

The code above does exactly that. Param 1 is the .so path; param 2 is the function you need, e.g. the dlopen address (this is our local process’s dlopen address).

The return value is the remote dlopen address. The implementation:

  • First findLibrary to traverse our own process’s e.g. libc.so start address, say 0xABC.
  • Then get the target process’s libc start address, via dladdr’s dli_fbase, say 0x456.
  • Subtract our own libc.so start 0xABC from our local dlopen absolute address to get dlopen’s relative offset.
  • Add that offset to 0x456 to get the target process’s dlopen absolute address.

2. The remote process opens our own .so

Call the dlopen function pointer directly in the remote process to open the .so we want to inject; after getting the .so handle, use dlsym to get our exported function.

Then call the remote process’s malloc to allocate a string, and pass it as a parameter to our exported function to do initialization.

See the injectvm function for the detailed injection flow.

3. Hack DRM

Now that we’re injected into the target process, we can use inline hook directly. Because the injected partition is mainly under vendor, we can’t directly use the system/lib .so.

extern "C" {
__attribute__ ((visibility ("default")))
int
doRun(char *type, char *data) {
    LOG(ERROR) << "start doRun  !!! data ->" << data;
    if (strcpy(type, "hack_drm")) {
#define SYM_THE_PATH "/vendor/lib64/libwvhidl.so"
        LOG(ERROR) << "rep mock drm  [" << fake_wvdrm_hash << "] orig [" << data << "]";
        fake_wvdrm_hash = data;
        if (!DrmIsHooked) {
            //1.2&1.3&1.4
            auto sym = getSymCompat(SYM_THE_PATH,
                                    "_ZN5wvdrm8hardware3drm4V1_28widevine11WVDrmPlugin20CdmIdentifierBuilder17getDeviceUniqueIdEPNSt3__112basic_stringIcNS6_11char_traitsIcEENS6_9allocatorIcEEEE");
            if (sym == nullptr) {
                sym = getSymCompat(SYM_THE_PATH,
                                   "_ZN5wvdrm8hardware3drm4V1_38widevine11WVDrmPlugin20CdmIdentifierBuilder17getDeviceUniqueIdEPNSt3__112basic_stringIcNS6_11char_traitsIcEENS6_9allocatorIcEEEE");
            }
            if (sym == nullptr) {
                sym = getSymCompat(SYM_THE_PATH,
                                   "_ZN5wvdrm8hardware3drm4V1_48widevine11WVDrmPlugin20CdmIdentifierBuilder17getDeviceUniqueIdEPNSt3__112basic_stringIcNS6_11char_traitsIcEENS6_9allocatorIcEEEE");
            }
            if (sym == nullptr) {
                LOG(ERROR) << "!!!!!!!!!! mock drm error ,not find sym  !!!!!!!! ";
                return 1;
            }
            auto ret = HookUtils::Hooker(sym, (void *) new_getDeviceUniqueId,
                                         (void **) &orig_getDeviceUniqueId);

            if (ret) {
                DrmIsHooked = true;
                LOG(ERROR) << "hack drm is success ! " << ret;
            } else {
                LOG(ERROR) << "hack drm is error ! ";
            }
        } else {
            LOG(INFO) << "realy is hack drm " << fake_wvdrm_hash;
        }
    }
    return 0;
}
}

Modifying getprop fingerprints

Before getting into this, a quick primer on what actually happens when SELinux is turned off — plus a fun anecdote about how a client can poison its own data back at the server.

zeus:/ $ id
uid=2000(shell) gid=2000(shell) groups=2000(shell),1004(input),1007(log),1011(adb),1015(sdcard_rw),1028(sdcard_r),1078(ext_data_rw),1079(ext_obb_rw),3001(net_bt_admin),3002(net_bt),3003(inet),3006(net_bw_stats),3009(readproc),3011(uhid),3012(readtracefs) context=u:r:shell:s0
zeus:/ $ getprop | grep oem
[ro.fota.oem]: [Xiaomi]
[ro.ril.oem.imei]: [redacted]
[ro.ril.oem.imei1]: [redacted]
[ro.ril.oem.imei2]: [redacted]
[ro.ril.oem.meid]: [redacted]
[ro.ril.oem.psno]: [redacted]
[ro.ril.oem.sno]: [redacted]
[sys.oem_unlock_allowed]: [0]

getprop stores a bunch of variables whose SELinux permissions are very high — you need a system-level group, or root with the full SELinux group set, to read them.

But if you turn SELinux off, all of these become freely readable. And it’s not just the oem partition values in there — there are also historical boot times. These are all fairly hidden device fingerprints.

[persist.sys.boot.reason.history]: [reboot,userrequested,1716561955]

An app can simply call getprop and read your entire device state out at once. That’s the opposite of what we want, so SELinux should be on no matter what — otherwise your phone is running naked.

Modifying these environment variables has even more options. Just use Magisk’s built-in magisk resetprop command. For example, flipping the debug flag:

magisk resetprop ro.debuggable 1

One command line, global change. Very convenient.

Now for that anecdote I mentioned. A lot of major vendors simply cannot guarantee the data they collect is accurate — especially the oem-partition fields. Their assumption is “I can read this value, therefore it’s valid to report to the server.” So let’s poison the well in reverse.

We take a perfectly normal phone, pretend it has SELinux disabled, and deliberately expose a modified ro.ril.oem.imei (and friends) for the target app to read.

I nearly burst out laughing — it actually brought a previously-banned phone back to life. This basically confirms that many major-vendor clients never verify the data they collect, especially these hidden fields.

They try to read it, and we hand them a fake. Fields like IMEI carry enormous weight in a major vendor’s risk-control device fingerprint, yet they get folded into the fingerprint with zero verification of whether they’re real.

The reality is that many strategy teams inside major vendors don’t understand the technology at all. When something breaks, they just ask the security-SDK client team to help dig into the case — and when that team can’t find anything wrong, the headache is all theirs.

Device fingerprint collection — reflections

A while back, while writing the hunter code, a question suddenly occurred to me:

When we build the data-collection side of a security SDK client, how do we make sure the fields we read are real and secure?

1. Compile libc into our own .so instead of using the system libc

Why avoid the system libc? Because there’s a generic, one-size-fits-all string attack here: hook strlen.

If the upper call stack is our own, return normally; if it’s the target .so, replace and intercept right there. That basically wipes out any major vendor on the market.

When I did countermeasures against one competing SDK earlier, I found it did not use the standard libc at all — not a single libc function was called.

2. Avoid C++ std::string as much as possible

There are plenty of open-source string libraries out there. If you use C++ string, it’s hard to escape the strlen hook, because string construction inevitably uses strlen anyway.

And if the other side hooks your string’s append function, there’s no escape either.

3. Fetch a single field through multiple routes

Take boot_id from above as an example. Try to read it several different ways — routes A, B, and C should all return the same value — before the client reports it.

4. Basic environment detection

Device fingerprints split into two parts: fingerprint-field collection, and risk-detection fields. Personally I think a 1:9 ratio is best. Given that a field can be tampered with, you should think through every point where it could be modified, rather than only collecting the “valid” fields. Pay more attention to environment info — for example, in key scenarios, collect the user’s logcat logs and report them.

That makes attack analysis easier. A lot of fraud operators or attackers ignore this piece — but detection really is a game of details. Report the logcat straight up, spot the tell-tale log, and ban the account.

This has a downside, though: logcat is big data, and how you report it is a question. Syncing it up front is definitely slow, so this probably needs more design — report it on demand, in specific scenarios, via socket-based push.

One device, many accounts

All that said, there’s still one piece missing: how do you implement I/O redirection? An app can only write to two places:

  1. /sdcard
  2. /data/data/<package>

We only need to I/O-redirect these persistent locations.

On the SD card, /sdcard/Android/data/<package> also lives under /sdcard, but the two have completely different mount points and different SELinux permissions.

/sdcard is FUSE (filesystem in userspace), while /sdcard/Android/data/ is F2FS (flash-friendly filesystem).

There are two good ways to do this:

  • A kernel module implementing system-level I/O redirection.
  • Just use the mount command.

You already have root. If developing a syscall hook is too much work, option 1 is recommended — do global I/O redirection directly in the kernel via an APatch module. But it needs testing. I use the mount command here — lighter weight.

The key command is:

 mount --bind <source-dir> <target-dir>

For example, to I/O-redirect /data/data/<package>: mount --bind /abc/AAA-user/data-dir /data/data/<package>.

With that, whenever the target app writes to /data/data/<package>, it’s also written through to /abc/AAA-user/data-dir. When you umount, the files in /abc/AAA-user/data-dir still exist. That gives you global I/O redirection. The concrete setup is below, with the package name desensitized. Remember to fix file permissions — and watch out for SELinux restrictions. Though even if it restricts you, it doesn’t matter: Magisk modules support custom SELinux rule overrides.

Per-account data directories

Each user/account directory holds three things:

  • The current account’s device-fingerprint data
  • SD-card data (redirected directory)
  • /data/data/<package> data (redirected directory)

Three directories is all you need. To switch to a given account, just read that account’s fingerprint data, store the mocked values in memory, and have the mocked fingerprint read back from memory.

Countermeasures Q&A

Through the operations above, we can fully rewrite the device fingerprint without injecting a single line of code into the client. We’re fighting the target process, but we never modified a single line of its code.

Everything we changed was the system’s own values — but what the client reads back is always the modified version.

Q1: You used the mount command — I could just detect your mounted files. What if someone traverses your mounts?

That’s simple. Just don’t let them read it, or write a kernel module to erase that file’s signature. Once you can freely modify the kernel, you’re the author of the phone’s entire rulebook — there’s no file you can’t modify.

Q2: Doesn’t a Magisk module inject into the target process too? Is there really zero trace?

Injection is injection and modification is modification. We injected but modified nothing, and memory leaves no trace.

After the Magisk module finishes loading, just call:

api_->setOption(zygisk::Option::DLCLOSE_MODULE_LIBRARY);

to unload the Magisk module .so. By the time it’s removed, the target’s Application may not even have started running yet.

We modified no code in the target process, so there’s no function-jump address, and I also didn’t inline-hook any .so in the current process.

No executable segment remains in memory, and no .so’s CRC changes.

Q3: Your phone is rooted — can’t I detect the unlocked/rooted state?

Here’s the nasty part: shamiko + magisk package-name modification gets past 99.999% of root detection in the domestic app ecosystem.

shamiko gives you a fresh chroot environment with zero root traces. Root detection is genuinely hard, and an unlocked phone can’t just be banned outright.

(I found some exploitable holes in older shamiko versions, but in the new version I looked for a long time and found no good opening.)

Because unlocked phones make up a large share of the market now — and being unlocked doesn’t mean cheating.

Q4: If you inject no code into the client, how do you read the APK’s data?

Plenty of ways. You can use auto-click software combined with OCR text recognition directly. It depends on the data — if it’s large text, just decrypt it directly via packet capture or a routing proxy.

Worst case, inject LSPlant into the client and hook the relevant function to grab the data.

LSPlant’s hook doesn’t necessarily change the CRC either — I once commented out its inline-hook part and found the hook still worked. That inline-hook mainly exists for functions that got OAT-compiled into short instructions.

There’s also a newer technique: use hardware breakpoints in the kernel to implement inline hooks — another traceless approach, though the number of hooks is limited. Thanks to a friend for the hardware-breakpoint idea.

Q5: With so many phones clustered together, aren’t you afraid of getting banned?

Put the phones all over the country, connect them over USB for auto-clicking, and route traffic through IoT SIM cards.

That solves the clustering strategy. This needs to grow gradually — scale the phone count up only after the existing ones are stable.

Closing

Thanks for reading this far — if nothing else, you now have a direction. If anything here is wrong, or you have a better approach, drop a reply below; I read every comment when I have time.

Thanks again to the LSPlant and APatch open-source projects.

References

[1] https://github.com/LSPosed/LSPlant

[2] https://github.com/bmax121/APatch

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