Android Device Fingerprinting: Collection and 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.

Android Device Fingerprinting: Collection and Countermeasures

Foreword

Device fingerprinting in major apps keeps evolving, but nailing both stability and uniqueness with high precision is genuinely hard. Some implementations derive a unique device ID from a weighted combination of device attributes — field A contributes 10%, field B contributes 20%, and the fingerprint only changes when the total exceeds 60%. The upside is that changing a single field doesn’t flip the fingerprint. Others instead pick one hidden-but-unique device attribute and cache it, reading the cache each time — common examples are obtaining the DRM ID natively, popen cat /sys/devices/soc0/serial_number, reading boot_id via svc and persisting it to a file, or grabbing the MAC through a netlink socket. All of these are common and stealthy device fingerprints.

This article walks through how the various fingerprints are collected, how to modify them, and — from a bird’s-eye view — the problems both attacker and defender run into.

After reading you’ll know:

  1. Which fingerprint checks are common.
  2. How to modify a device fingerprint, where the hard parts are, and what pitfalls to expect.
  3. What problems the current device-fingerprint landscape faces.

Prerequisites

What is the Android IPC proxy?

Android is a client/server architecture. The APIs we call through a Context go through a proxy; the real server is the Activity Manager Service (AMS), which has many proxies — PackageManager, ActivityManager, and so on. Those proxies are the agents; AMS is the principal. The proxy pattern is a design pattern where the agent exposes part or all of the principal’s functionality for encapsulation, access control, and code safety.

AMS and its proxies communicate over Binder. I won’t expand on what Binder is here — it’s Android-interview boilerplate; think of it as inter-process communication built on shared memory for faster data transfer. When we call a proxy’s API, we’re really sending data packets over Binder to AMS; AMS processes the message and returns the result to the proxy, which hands it back to the caller. Every Manager has a proxy inside it.

There used to be a dynamic-proxy technique that replaced the proxy. Since a proxy is an interface, you create a proxy via the Proxy class and reflectively set it back, letting you intercept and hook common APIs — similar to how VA-style sandboxes provide their own proxy implementations to multi-instance apps and control the return values to implement sandboxing. Another good way to bypass APK signature checks is to hook the “pipe” directly — i.e. hook the Binder communication method — and rewrite the result when a specific event arrives. That gives you a full Java-layer hook (once the Binder communication is hooked, both the caller and the proxy only see the rewritten result). I’ll go deeper when I cover signature verification later.

Bypassing the Android 9 hidden-API restriction

When collecting or analyzing, you need to bypass the Android 9 reflection restriction. The approach here uses the LSPosed author’s AndroidHiddenApiBypass to call hidden APIs.

If the phone has EdXposed or Magisk installed, the hidden-API restriction is usually already removed, because those frameworks themselves need hidden APIs.

Device fingerprints

Device fingerprints split into three parts: Java-layer fingerprints, native fingerprints, and fingerprints obtained by running shell commands via popen (including some core ones).

This article is organized around those three parts. Some things — kernel files, for instance — I’ll put under the native section.

Every fingerprint is split into two halves:

  • Get — how to obtain it, from the developer’s point of view.
  • Mock — how to modify it for testing, from the attacker’s point of view.

And every fingerprint falls into one of three tiers: normal, secondary, or important.

Java-layer device fingerprints

Settings (important)

Get:

The one most people run into under Settings is the Android ID:

Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID)

But Settings holds a lot more. The common ones are Settings.Secure and Settings.Global, and Settings.Global contains some fairly stealthy fingerprint fields:

Settings.Global.getString(context.getContentResolver(), "mi_health_id")
Settings.Global.getString(context.getContentResolver(), "mi_health_id")
Settings.Global.getString(context.getContentResolver(), "gcbooster_uuid")
Settings.Global.getString(context.getContentResolver(), "key_mqs_uuid")
Settings.Global.getString(context.getContentResolver(), "ad_aaid")

Mock:

  • Method hooking

Both Global and Secure implement the NameValueTable interface.

public static String getString(ContentResolver resolver, String name) {
      return getStringForUser(resolver, name, resolver.getUserId());
}

Underneath it calls getStringForUser(resolver, name, resolver.getUserId()) with three arguments, so if you hook, that’s the method to target:

Settings.Secure -> getStringForUser and Settings.Global -> getStringForUser.

  • In-memory reflection

Many developers fetch the value via in-memory reflection, so hooking the method alone isn’t enough. To mock properly you have to repair the in-memory variables inside Settings.Secure and Settings.Global. Settings.Global holds global variables; Settings.Secure holds security-related ones.

private static final HashSet<String> MOVED_TO_GLOBAL;
private static final NameValueCache sNameValueCache = new NameValueCache(
                CONTENT_URI,
                CALL_METHOD_GET_SYSTEM,
                CALL_METHOD_PUT_SYSTEM,
                sProviderHolder,
                System.class);


public static String getStringForUser(ContentResolver resolver, String name,
                int userHandle) {
            if (MOVED_TO_GLOBAL.contains(name)) {
                Log.w(TAG, "Setting " + name + " has moved from android.provider.Settings.Secure"
                        + " to android.provider.Settings.Global.");
                return Global.getStringForUser(resolver, name, userHandle);
            }
            ...
            return sNameValueCache.getStringForUser(resolver, name, userHandle);
     }


@UnsupportedAppUsage
public static String getStringForUser(ContentResolver resolver, String name,
                int userHandle) {
            if (MOVED_TO_SECURE.contains(name)) {
                Log.w(TAG, "Setting " + name + " has moved from android.provider.Settings.System"
                        + " to android.provider.Settings.Secure, returning read-only value.");
                return Secure.getStringForUser(resolver, name, userHandle);
            }
            if (MOVED_TO_GLOBAL.contains(name) || MOVED_TO_SECURE_THEN_GLOBAL.contains(name)) {
                Log.w(TAG, "Setting " + name + " has moved from android.provider.Settings.System"
                        + " to android.provider.Settings.Global, returning read-only value.");
                return Global.getStringForUser(resolver, name, userHandle);
            }

}

As you can see, the cache lives in sNameValueCache and MOVED_TO_GLOBAL. You can reflect MOVED_TO_GLOBAL (a HashSet) or sNameValueCache to fetch the real value, so method-hooking alone isn’t enough.

In fact many big apps do exactly that: once the reflection restriction is bypassed on newer Android versions — or the device has no reflection restriction — they read the variable directly via reflection.

sNameValueCache is an object on newer versions but an ArrayMap on older ones — worth noting.

To modify sNameValueCache, call putStringForUser and force the value in. Then the next API call returns your mocked value. So when you modify, you must check whether the current value has already been mocked.

Bluetooth adapter MAC (normal)

The Bluetooth adapter isn’t the normal NIC; the real NIC is obtained via netlink, which I’ll cover later.

Get:

Mainly BluetoothAdapter -> getAddress:

public String getAddress() {
        try {
            return mManagerService.getAddress(mAttributionSource);
        } catch (RemoteException e) {
            Log.e(TAG, "", e);
        }
        return null;
    }

Mock:

Note this goes through the IPC proxy class. So prefer hooking the proxy IPC class — reflect android.bluetooth.IBluetooth$Stub$Proxy and hook its getAddress — rather than hooking BluetoothAdapter -> getAddress directly.

Many big apps check whether this method is hooked. The check is simple: get the ArtMethod struct, then see whether the method entry has been replaced. Common hook frameworks like SandHook use inline hooking on older versions but entry-replacement on newer ones — you can grab the method entry’s function address and check which .so it lives in. So hook the IPC method when possible. If you modify via Xposed, also watch for tampering, otherwise big apps will report your hooked methods through the XposedHelpers -> sHookedMethodCallbacks variable.

Tip: sHookedMethodCallbacks is a static variable, so you only need to obtain the XposedHelpers class. To get the class you first need its class loader. Normal Xposed uses the system class loader as the parent, but with EdXposed it’s a method-local member variable with no references to that class loader — so you need in-memory roaming: pull every class loader out of memory, then reflect XposedHelpers one by one.

sHookedMethodCallbacks holds all of Xposed’s hooked-method info, which is used to prove whether a method is hooked. The hooked-method dump looks like this:

XposedHelpers -> methodCache is not recommended — if an attacker used XposedBridge -> hookAllMethods, using it can miss hooked methods.

   private void getHookItemDemo() {
        ArrayList<Object> choose = ChooseUtils.choose(ClassLoader.class, true);
        for(Object obj :choose){
            ClassLoader clazzloader = (ClassLoader) obj;
            Class<?> clazzXh = null;
            Class<?> clazzBR = null;
            try {
                Log.e("Test", "-----------------------------------------------------------------");

                clazzBR =  Class.forName("de.robv.android.xposed.XposedBridge",false,clazzloader);
                Field callbacksField = clazzBR.getDeclaredField("sHookedMethodCallbacks");
                callbacksField.setAccessible(true);
                Map<Member, Object> callback = (Map<Member, Object>) callbacksField.get(null);

                for(Member key :callback.keySet()) {
                    Log.e("Test", "sHookedMethodCallbacks  " + key.toString());
                }
                Log.e("Test", "-----------------------------------------------------------------");

                return;
            } catch (Throwable e) {
                Log.e("Test","find errror "+e.getMessage());
            }
        }
    }

serial (normal)

On newer versions this is basically unobtainable — you get unknown — but you still have to support older Android.

Get:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        return Build.getSerial();
}

Mock:

If the return isn’t null and isn’t unknown/UNKNOWN, randomize a string of the original length. This field also goes through the IPC class, so you can hook the IPC object’s getSerialForPackage method directly:

public static String getSerial() {
    IDeviceIdentifiersPolicyService service = IDeviceIdentifiersPolicyService.Stub
            .asInterface(ServiceManager.getService(Context.DEVICE_IDENTIFIERS_SERVICE));
    try {
        Application application = ActivityThread.currentApplication();
        String callingPackage = application != null ? application.getPackageName() : null;
        return service.getSerialForPackage(callingPackage, null);
    } catch (RemoteException e) {
        e.rethrowFromSystemServer();
    }
    return UNKNOWN;
}

IMEI, IMSI, ICCID, Line1Number (normal)

These basic Java-layer fingerprint fields need no explanation — search and you’ll find how to fetch them. Just don’t hook them directly; prefer hooking the IPC.

Build (secondary)

Build holds plenty of useful stuff, like whether ADB is enabled or the USB interface state. I split Build into two parts. The fingerprint part is further split into single fields and composite fields:

  1. Configuration-related
  2. Fingerprint-related
    • Single field (one device attribute)
    • Composite field (several single fields combined)

Modifying these through the Java layer alone is never enough — underneath they go through system_property_get (covered in the native section). You also have to guard against popen getprop scanning all Build parameters (covered in the popen section; here I only cover the Java-side handling).

Build deserves attention because its underlying implementation is tree-like — many branches carry the same content. There are seven scopes. Take the common fingerprint composite field, which exists in these seven scopes:

ro.build.fingerprint/ ro.build.build.fingerprint/ ro.bootimage.build.fingerprint/ ro.odm.build.fingerprint/ ro.product.build.fingerprint/ ro.system_ext.build.fingerprint/ ro.system.build.fingerprint/ ro.vendor.build.fingerprint/

The scopes are:

private static final String Region[] = {
      "build", "bootimage", "odm", "product", "system_ext", "system", "vendor"
};

The values are composed the same way, so you have to hook all of them — handling just one is pointless, since big apps rarely collect only one and will collect all seven scopes.

Common configuration follows. Whether you modify these may not matter, because many big apps raise the device’s risk score if developer options or debug mode are on — so it’s worth mocking them.

PUT_MOCK_AND_SAVE_ORG("sys.usb.config", "none", null, true);
PUT_MOCK_AND_SAVE_ORG("sys.usb.state", "none", null, true);
PUT_MOCK_AND_SAVE_ORG("persist.sys.usb.config", "none", null, true);
PUT_MOCK_AND_SAVE_ORG("persist.sys.usb.qmmi.func", "none", null, true);
// these two may be unavailable; skip mocking if so
PUT_MOCK_AND_SAVE_ORG("vendor.usb.mimode", "none", null, true);
PUT_MOCK_AND_SAVE_ORG("persist.vendor.usb.config", "none", null, true);
PUT_MOCK_AND_SAVE_ORG("ro.debuggable", "0", null, true);
PUT_MOCK_AND_SAVE_ORG("init.svc.adbd", "stopped", null, true);
PUT_MOCK_AND_SAVE_ORG("ro.secure", "1", null, true);
// phone unlock state
PUT_MOCK_AND_SAVE_ORG("ro.boot.flash.locked", "1", null, true);
PUT_MOCK_AND_SAVE_ORG("sys.oem_unlock_allowed", "1", null, true);
Single fields

Without changing the device model, the following are Build parameters big apps scan frequently and should be handled. When randomizing, just alter a few digits at the beginning or end of the original.

ro.build.id org-> [RKQ1.200826.002] mock -> [RKQ1.200826.945]
ro.build.build.id org-> [RKQ1.200826.002] mock -> [RKQ1.200826.945]
ro.bootimage.build.id org-> [RKQ1.200826.002] mock -> [RKQ1.200826.945]
ro.odm.build.id org-> [RKQ1.200826.002] mock -> [RKQ1.200826.945]
ro.product.build.id org-> [RKQ1.200826.002] mock -> [RKQ1.200826.945]
ro.system_ext.build.id org-> [RKQ1.200826.002] mock -> [RKQ1.200826.945]
ro.system.build.id org-> [RKQ1.200826.002] mock -> [RKQ1.200826.945]
ro.vendor.build.id org-> [RKQ1.200826.002] mock -> [RKQ1.200826.945]
ro.build.version.security_patch org-> [2021-11-01] mock -> [2021-11-19]
ro.boot.vbmeta.digest org-> [ebe54be57a4fb91d8c22c3d69f68651931878d8925eb8a517d8110084fe45513] mock -> [ebe54be57a4fb91d8c22c3d69f68651931878d8925eb8a517d8110084fa69209]
ro.netflix.bsp_rev org-> [Q8250-19134-1] mock -> [P3660-19134-1]
gsm.version.baseband org-> [MPSS.HI.2.0.c7-00266-1025_0156_49a7b03461,MPSS.HI.2.0.c7-00266-1025_0156_49a7b03461] mock -> [MPSS.HI.2.0.c7-00266-1025_0156_49a7b03846,MPSS.HI.2.0.c7-00266-1025_0156_49a7b03846]

ro.build.date.utc org-> [1639708288] mock -> [1663313901]
ro.build.build.date.utc org-> [1639708288] mock -> [1663313901]
ro.bootimage.build.date.utc org-> [1639708288] mock -> [1663313901]
ro.odm.build.date.utc org-> [1639708288] mock -> [1663313901]
ro.product.build.date.utc org-> [1639708288] mock -> [1663313901]
ro.system_ext.build.date.utc org-> [1639708288] mock -> [1663313901]
ro.system.build.date.utc org-> [1639708288] mock -> [1663313901]
ro.vendor.build.date.utc org-> [1639708288] mock -> [1663313901]

ro.build.display.id org-> [RKQ1.200826.002 test-keys] mock -> [RKQ1.200826.945]
// maybe debug-key
ro.build.tags org-> [release-keys] mock -> [release-keys]
ro.build.build.tags org-> [release-keys] mock -> [release-keys]
ro.bootimage.build.tags org-> [release-keys] mock -> [release-keys]
ro.odm.build.tags org-> [release-keys] mock -> [release-keys]
ro.product.build.tags org-> [release-keys] mock -> [release-keys]
ro.system_ext.build.tags org-> [release-keys] mock -> [release-keys]
ro.system.build.tags org-> [release-keys] mock -> [release-keys]
ro.vendor.build.tags org-> [release-keys] mock -> [release-keys]
ro.build.host org-> [m1-xm-ota-bd148.bj.idc.xiaomi.com] mock -> [m1-xm-ota-be811.bj.idc.xiaomi.com]
ro.build.user org-> [builder] mock -> [buizcdn]
ro.config.ringtone org-> [MiRemix.ogg] mock -> [MiRemix.acc]
ro.miui.ui.version.name org-> [V125] mock -> [V635]

ro.build.version.incremental org-> [V12.5.19.0.RKHCNXM] mock -> [V12.5.19.0.RKHWCRG]
ro.build.build.version.incremental org-> [V12.5.19.0.RKHCNXM] mock -> [V12.5.19.0.RKHWCRG]
ro.bootimage.build.version.incremental org-> [V12.5.19.0.RKHCNXM] mock -> [V12.5.19.0.RKHWCRG]
ro.odm.build.version.incremental org-> [V12.5.19.0.RKHCNXM] mock -> [V12.5.19.0.RKHWCRG]
ro.product.build.version.incremental org-> [V12.5.19.0.RKHCNXM] mock -> [V12.5.19.0.RKHWCRG]
ro.system_ext.build.version.incremental org-> [V12.5.19.0.RKHCNXM] mock -> [V12.5.19.0.RKHWCRG]
ro.system.build.version.incremental org-> [V12.5.19.0.RKHCNXM] mock -> [V12.5.19.0.RKHWCRG]
ro.vendor.build.version.incremental org-> [V12.5.19.0.RKHCNXM] mock -> [V12.5.19.0.RKHWCRG]
Composite fields

Composite fields are assembled from single fields — commonly ro.build.description and the seven fingerprint fields above. Their mocked value must match the single fields you mocked: if a single field was mocked to A, the composite must contain A too.

ro.build.description org-> [alioth-user 11 RKQ1.200826.002 V12.5.19.0.RKHCNXM release-keys] mock -> [alioth-user 11 RKQ1.200826.945 V12.5.19.0.RKHWCRG release-keys]

ro.build.fingerprint org-> [Redmi/alioth/alioth:11/RKQ1.200826.002/V12.5.19.0.RKHCNXM:user/release-keys] mock -> [Redmi/alioth/alioth:11/RKQ1.200826.945/V12.5.19.0.RKHWCRG:user/release-keys]
ro.build.build.fingerprint org-> [Redmi/alioth/alioth:11/RKQ1.200826.002/V12.5.19.0.RKHCNXM:user/release-keys] mock -> [Redmi/alioth/alioth:11/RKQ1.200826.945/V12.5.19.0.RKHWCRG:user/release-keys]
ro.bootimage.build.fingerprint org-> [Redmi/alioth/alioth:11/RKQ1.200826.002/V12.5.19.0.RKHCNXM:user/release-keys] mock -> [Redmi/alioth/alioth:11/RKQ1.200826.945/V12.5.19.0.RKHWCRG:user/release-keys]
ro.odm.build.fingerprint org-> [Redmi/alioth/alioth:11/RKQ1.200826.002/V12.5.19.0.RKHCNXM:user/release-keys] mock -> [Redmi/alioth/alioth:11/RKQ1.200826.945/V12.5.19.0.RKHWCRG:user/release-keys]
ro.product.build.fingerprint org-> [Redmi/alioth/alioth:11/RKQ1.200826.002/V12.5.19.0.RKHCNXM:user/release-keys] mock -> [Redmi/alioth/alioth:11/RKQ1.200826.945/V12.5.19.0.RKHWCRG:user/release-keys]
ro.system_ext.build.fingerprint org-> [Redmi/alioth/alioth:11/RKQ1.200826.002/V12.5.19.0.RKHCNXM:user/release-keys] mock -> [Redmi/alioth/alioth:11/RKQ1.200826.945/V12.5.19.0.RKHWCRG:user/release-keys]
ro.system.build.fingerprint org-> [Redmi/alioth/alioth:11/RKQ1.200826.002/V12.5.19.0.RKHCNXM:user/release-keys] mock -> [Redmi/alioth/alioth:11/RKQ1.200826.945/V12.5.19.0.RKHWCRG:user/release-keys]
ro.vendor.build.fingerprint org-> [Redmi/alioth/alioth:11/RKQ1.200826.002/V12.5.19.0.RKHCNXM:user/release-keys] mock -> [Redmi/alioth/alioth:11/RKQ1.200826.945/V12.5.19.0.RKHWCRG:user/release-keys]

Get:

android.os.SystemProperties->get(key)

Mock:

android.os.SystemProperties -> get calls native_get underneath — a native method — so prefer hooking native_get:

android.os.SystemProperties->native_get

After the Java hook, you also need to reflectively set the member variables inside Build to prevent collection via reflection.

System default accounts (normal)

Many big apps use this field as part of the fingerprint, so it needs handling too.

Get:

AccountManager->getAccounts

Mock: prefer hooking the IPC.

Get:

AudioManager->getStreamVolume

Mock: prefer hooking the IPC.

This doesn’t need much handling — every phone type is roughly the same. Just shuffle the returned ordering each time.

Get:

SensorManager->getFullSensorList

Mock: prefer hooking the IPC.

Java-layer DRM (important)

This DRM is watermark-related; its core is a deviceUniqueId — a random 32-byte array used to watermark different phones. Many big apps use it as the core fingerprint, collecting it not only in Java but natively too (more in the native section).

Get:

MediaDrm->getPropertyByteArray
MediaDrm->getPropertyString

Hooking is easy — this method has no IPC and its own implementation, so just hook the getter. But a Java-layer hook is far from enough; you still need the native layer.

Randomize a 32-byte array each time.

Java-layer NIC info (normal)

Big apps shouldn’t trust the Java-layer MAC; underneath they get the NIC via netlink directly, or run ip a through popen for a full NIC dump (see the popen section). I handle this at the netlink socket level, so I skip the Java layer entirely. Any NIC-getting method ultimately goes through netlink.

Getting the NIC directly via netlink seems to have stopped working on Android 10, but on a rooted phone there’s no restriction (verified working on an Android 13 dev board). It can also be used to detect root.

When ip a runs — or a Java API is called — the bottom layer still goes through netlink. Just ptrace at the bottom, and after the call returns, mock and set the registers. I covered the detailed retrieval method in a previous writeup.

File creation time (secondary)

Many big apps collect creation times under /sdcard/ or the gallery as a fingerprint. Many files default to the 1970 timestamp, but a few folder creation times are important device identifiers. The Java File object exposes a file’s creation time.

Native device fingerprints

That was a lot of Java, but the Java-layer fingerprints aren’t the critical ones — the core fingerprints live in native. The native section covers kernel files and some trickier collection methods.

Build (system_property_get & system_property_read) (important)

As mentioned, Java’s native_get bottoms out in system_property_get. Let’s look at its source first — the implementation differs between Android 9 and earlier.

Android 9:

__BIONIC_WEAK_FOR_NATIVE_BRIDGEint __system_property_get(const char* name, char* value) {
 return system_properties.Get(name, value);
}

Android <9:

int __system_property_get(const char* name, char* value) {
  const prop_info* pi = __system_property_find(name);

  if (pi != 0) {
    return __system_property_read(pi, nullptr, value);
  } else {
    value[0] = 0;
    return 0;
  }
}

Pre-9 implements the method directly, so here’s a detail: on Android 9+ you can’t just hook __system_property_get’s entry — you also need to hook system_properties.Get.

Many big apps on Android 9+ call system_properties.Get directly: they parse the .so to get the non-exported system_properties.Get function pointer, cast it, and call it — rather than calling system_property_get. Hooking only system_property_get can leak the fingerprint. So on Android 9+ handle system_properties.Get(name, value) too.

Hooking __system_property_get directly can also run into the short-instruction problem — the method is a single BL instruction, so ordinary inline hooking can fail. You need exception-based hooking, or just check the Android version and hook system_properties.Get directly on 9+. system_properties.Get is non-exported, so parse the .so to find its address (see SandHook’s ELFUtils.cpp).

The read method is the same — on Android 9+ it needs special handling too.

When hooking, the mocked value’s length must not exceed the original. After system_property_get returns, memcpy the mocked value in. The handler looks like this:

   int new_system_property_get(const char *name, char *value) {
        int len = origin_system_property_get(name, value);
        string sname = string(name);
        if (gLobalfakeProperties.find(sname) == gLobalfakeProperties.end()) {
            //ALOGE("hook native system_property_get_1 , not replace -> %s value-> %s " ,name,value);
            return len;
        }
        // get org value
        string fake_value = gLobalfakeProperties[sname];
        if (strlen(value) > 0) {
            memcpy(value, (char *) fake_value.c_str(), strlen(value));
        }
        return len;
    }

One detail worth calling out:

Why not handle __system_property_find directly?

Both get and read bottom out in find, so why not handle it there? Because find returns a prop_info* pointing at a variable in system memory. Writing to it directly raises SIGSEGV; using mprotect to force-write the in-memory variable can destabilize the system. I’ve stepped on that one before — so I only handle get and find.

Get:

Simple — include the header:

#include <sys/system_properties.h>
char sdk[PROP_VALUE_MAX] = {0};
__system_property_get("ro.build.version.sdk", sdk);

Native DRM ID (important)

This is another core fingerprint used as a unique ID. Handle it carefully — it’s one of the most important device-fingerprint IDs.

Get:

Simple — include the header; under ten lines. The implementation lives in mediandk.so, so when linking with CMake’s target_link_libraries, don’t forget to add mediandk.

Note: different apps read different values here.

#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);

// get deviceUniqueId
AMediaDrmByteArray aMediaDrmByteArray;
AMediaDrm_getPropertyByteArray(mediaDrm,PROPERTY_DEVICE_UNIQUE_ID, &aMediaDrmByteArray);
string resut = Base64Utils::Encode((uint8_t *)aMediaDrmByteArray.ptr,aMediaDrmByteArray.length);

Mock:

Just hook the function address. It’s also a short instruction, so exception-based hooking is needed. We only care about description; leave the rest alone. Direct writes sometimes fail, so call mprotect first — but you can’t use mprotect blindly; you have to compute the page size and memory alignment.

bool MPROTECT(size_t addr,size_t size,int __prot){
    // compute page alignment
    auto alignment = (unsigned) ((unsigned long long) addr % sysconf(_SC_PAGESIZE));
    // subtract the page offset from the start address
    int i = mprotect((void *) (addr - alignment), (size_t) (alignment + size),__prot);
    if (i == 0) {
        return true;
    }
    LOGE("Helper mprotect error   %s ", strerror(errno))
    return false;
}


HOOK_DEF_DRM(media_status_t, AMediaDrm_getPropertyByteArray,
             AMediaDrm * drm, const char *propertyName, AMediaDrmByteArray *propertyValue){
    //LOG(INFO)<< "AMediaDrm_getPropertyByteArray  "<<propertyName ;
    media_status_t array = orig_AMediaDrm_getPropertyByteArray(drm, propertyName, propertyValue);
    if(propertyName!= nullptr&&strstr(propertyName,PROPERTY_DEVICE_UNIQUE_ID)!=nullptr&&
            propertyValue!= nullptr&&propertyValue->ptr!= nullptr&&propertyValue->length!=0){
        //LOG(INFO)<< "orig_AMediaDrm_getPropertyByteArray native hook drm start replcae !";
        // only handle description
        if(gMockdrmid== nullptr){
            LOG(INFO)<< "orig_AMediaDrm_getPropertyByteArray org value == null !";
            return array;
        }
        MPROTECT((size_t)propertyValue->ptr,propertyValue->length,MEMORY_RWX);
        memcpy((void*)propertyValue->ptr,gMockdrmid, strlen(gMockdrmid));
        LOG(INFO)<< "orig_AMediaDrm_getPropertyByteArray native hook drm  replcae sucess !";
    }

    return array;
}

As said, on Linux every NIC-getting path bottoms out in netlink. Below Android 10 you can bypass system permissions to get the NIC; on newer versions it’s dead. Underneath, svc calls recvfrom or recvmsg to receive the socket message, so without handling svc you can’t fully rewrite the data.

I use ptrace: after recvfrom returns, read the argument registers, rewrite the data, then write the registers back. The handler:

Detail: the socket’s main message-receiving functions are recvfrom, recvmsg, and recv. Netlink communication goes through these three; recv bottoms out in recvfrom, so we only need to handle recvfrom and recvmsg. After recvfrom returns, its argument is a buffer array — just overwrite the buffer. But recvmsg can’t be handled that way: its argument is an iovec pointer. Think of an iovec as a box holding the content, its length, and its start position. To modify it you have to read that start-position pointer first.

Handling svc -> recvmsg:
void NetlinkMacHandler::netlinkHandler_recmsg(Tracer *tracee) {
    ssize_t bytes_read = TEMP_FAILURE_RETRY(peek_reg(tracee, CURRENT, SYSARG_RESULT));
    if (bytes_read > 0) {
        // get org value
        word_t msg = peek_reg(tracee, CURRENT, SYSARG_2);
        if(msg == 0){
            return;
        }
        auto *my_Msg = (msghdr *) alloca(sizeof(msghdr));
        int msg_ret = read_data(tracee, (void *) my_Msg, (word_t) msg, sizeof(msghdr));
        if (msg_ret == 0) {
            //LOGE("ptrace SC_recvmsg msg_ret read sucess ! ")
            auto *iov = (iovec *) alloca(sizeof(iovec));
            int iov_ret = read_data(tracee, (void *) iov, (word_t) my_Msg->msg_iov, sizeof(iovec));
            if (iov_ret == 0) {
                //LOGE("ptrace SC_recvmsg iov read sucess ! buff size ->%lu  ",iov->iov_len)
                // save a temporary buff
                auto *temp_hdr = (nlmsghdr *) alloca(iov->iov_len);
                // temp_hdr is our own; but in handler_mac_callback_svc it may persist

                int hdr_ret = read_data(tracee, (void *) temp_hdr, (word_t) iov->iov_base,
                                        iov->iov_len);
                if (hdr_ret == 0) {
                    //LOGE("ptrace SC_recvmsg hdr read sucess ! ")
                    NetlinkMacHandler::handler_mac_callback_svc(tracee,temp_hdr, bytes_read);
                    //LOGE("handler_mac_callback_svc sucess ! ")
                    // overwrite and rewrite
                    write_data(tracee, (word_t) iov->iov_base, temp_hdr, iov->iov_len);
                    //LOGE("ptrace SC_recvmsg write_data sucess ! ")
                } else {
                    //LOGE("ptrace SC_recvmsg hdr read  fail ! ")
                }
            } else {
                //LOGE("ptrace SC_recvmsg iov read  fail ! ")
            }
        } else {
            //LOGE("ptrace SC_recvmsg msg_ret read fail ! ")
        }
    }
}
Handling svc -> recvfrom:
void NetlinkMacHandler::netlinkHandler_recv(Tracer *tracee) {
    ssize_t bytes_read = TEMP_FAILURE_RETRY(peek_reg(tracee, CURRENT, SYSARG_RESULT));
    if (bytes_read > 0) {
        //get org value
        word_t buff = peek_reg(tracee, CURRENT, SYSARG_2);
        if(buff == 0){
            return;
        }
        // buffer length
        auto size = (size_t) peek_reg(tracee, CURRENT, SYSARG_3);
        if(size == 0){
            return;
        }
        char tempBuff[size];
        int readStr_ret = read_data(tracee, tempBuff, buff, size);
        if (readStr_ret != 0) {
            LOGE("svc netlink handler read_string error  %s", strerror(errno))
            return;
        }
        auto *hdr = reinterpret_cast<nlmsghdr *>(tempBuff);
        // netlink packet struct
        NetlinkMacHandler::handler_mac_callback_svc(tracee,hdr, bytes_read);
        // write the data back over the original
        write_data(tracee, buff, tempBuff, size);
    }
}

The netlink MAC retrieval method is detailed in a previous writeup; I won’t repeat it here. And I covered using ptrace to modify svc in another writeup.

Once the above is done, even running ip a yields the modified value.

Kernel files (important)

“Kernel files” means the system files many big apps read via popen cat or a direct read-only fopen. Only a few core ones matter.

They’re usually read via svc openat, which needs svc I/O redirection underneath. Without handling that, you basically can’t mock or modify them.

build.prop

/system/build.prop /odm/etc/build.prop /product/build.prop /vendor/build.prop

Mainly these four files. On older versions they can be read directly, so after mocking you should generate a new copy as a backup. The new value must match what you mocked to avoid inconsistencies. When the app reads via svc, bypass it with svc I/O redirection.

/proc/sys/kernel/random/boot_id

This ID changes after a reboot or reflash. Many big apps read it; it’s UUID-like. They read it via svc and save it to a private directory.

Compared to the DRM ID, its advantage is that different apps read the same value — a heavily weighted fingerprint.

/proc/sys/kernel/random/uuid

Same as above.

/sys/block/mmcblk0/device/cid

Same as above.

/sys/devices/soc0/serial_number

Same as above.

/proc/misc

Same as above.

/proc/version

This is a Linux kernel file recording the current Linux version info. Its value looks like:

eg. Linux version 3.18.31-perf-g9b0888a(builder@c3-miui-ota-bd96.bj)

On Android 11+ this file is basically unreadable, but on Android 9 it works. Is there a replacement for Android 11? Yes — call uname via svc. uname is also a command, and you can get it via popen uname -a (covered in the popen section). This function is also handy on iOS.

 struct utsname buff;
    int i = uname(&buff);
    LOGE("uname sysname %s ", buff.sysname)
    LOGE("uname nodename %s ", buff.nodename)
    LOGE("uname release %s ", buff.release)
    LOGE("uname version %s ", buff.version)
    LOGE("uname machine %s ", buff.machine)
    LOGE("uname domainname %s ", buff.domainname)

From these you can reconstruct everything in /proc/version.

Many big apps use popen uname -a / the svc uname function / svc openat on /proc/version to cross-check that the value is accurate — if any one of them disagrees, the device is flagged as modified.

To modify, generally only release and version need handling:

void handlerLinuxEnvInfo(Tracer* tracee){
        word_t reg = peek_reg(tracee, ORIGINAL, SYSARG_1);
        if(reg == 0){
            LOGI("svc after org peek_reg reg 1 == 0 ");
            return;
        }
        auto *buff = (struct utsname *) alloca(sizeof(struct utsname));
        int msg_ret = read_data(tracee,  buff, reg, sizeof(struct utsname));
        if (msg_ret == 0) {

            string mockLinuxRelease ;
            getZhenxiRuntimeMMKV()->getString(LINUX_VERSION_RELEASE,mockLinuxRelease);
            memcpy(buff->release,mockLinuxRelease.c_str() ,SYS_NMLN);

            string mockLinuxVersion ;
            getZhenxiRuntimeMMKV()->getString(LINUX_VERSION_VERSION,mockLinuxVersion);
            memcpy(buff->version,mockLinuxVersion.c_str() ,SYS_NMLN);

            // write back to memory
            int ret = write_data(tracee, reg, buff, sizeof(struct utsname));
            if(ret!=0){
                LOGE("linux info write data error %s ", strerror(errno));
            }
        } else{
            LOGE("read_data read data error %s ", strerror(errno));
        }
    }

popen

Before diving in, a quick intro to popen: it’s an exported libc function whose job is to spawn a thread that runs a shell command, saving the output into a temporary FILE handle you can then read. I never quite understood this Android design choice — running shell directly from the Java layer throws, but popen works fine.

Note 1: popen bottoms out in the execve command — a shell entry point. In a 64-bit .so, redirecting svc openat I/O works even when execve runs, because 64-bit execve reads files through openat underneath.

Example: when I run popen cat /sys/devices/soc0/serial_number, if /sys/devices/soc0/serial_number is redirected via svc openat to /sdcard/a, then cat /sys/devices/soc0/serial_number reads /sdcard/a, not the original path.

Note 2: popen spawns a thread to run the shell, since execve itself spawns a thread. It returns a FILE handle. If I hook popen and swap the return result with my own handle, that’s not allowed — by Linux’s default behavior, thread A’s file can’t be read by thread B; the fd isn’t visible across threads (easy fix: make the file group-readable).

So is there a way to modify the returned content without changing file permissions?

Actually yes — just change the argument to cat <the mocked result's path>, and it reads the modified content. Or set argument 1 to "", and it reads null.

uname -a

Covered earlier; mainly to solve /proc/version being unreadable. Fetch Linux version info like this:

char buffer[100];
FILE *fp3 = popen("uname -a", "r");
if (fp3 != nullptr) {
    while (fgets(buffer, sizeof(buffer), fp3) != nullptr) {
            LOG(INFO) << "uname -a " << buffer;
    }
    pclose(fp3);
}

Mock:

uname bottoms out in svc’s uname, so just modify the uname return value at the svc layer. See the native /proc/version modification.

getprop

Running this returns the same as adb shell getprop — the phone’s full Build configuration.

pfile = popen("getprop", "r");
pfile = popen("getprop | grep dalvik", "r");
pfile = popen("getprop ro.odm.build.id", "r");

while (fgets(buf, sizeof(buf), pfile)) {
    LOGE("getprop -> %s", buf);
}

I won’t paste the output — run getprop over adb shell on your own phone.

Mock:

Simple — hook popen, prepare a pre-mocked version (which must match the Java-layer Build mock), then swap the command to cat your own file. Note getprop has three modes:

getprop ro.odm.build.id getprop | grep dalvik getprop

You have to handle the filtered mode too.

cat on kernel files

The kernel files from the native section can all be cat’d via popen. To modify, redirect svc openat I/O.

FILE *fp = popen("cat /sys/devices/soc0/serial_number", "r");
...

ps -ef & ps

This scans the current processes and can be used for anti-debugging — e.g. at startup, grab the process list.

That reveals whether Frida is present, or whether the process is being ptrace’d, since ptrace debugging requires an extra debugger thread.

pfile = popen("ps -ef", "r");
while (fgets(buf, sizeof(buf), pfile)) {
    LOGE("ps -ef  -> %s", buf);
}

Output looks like:

2022-07-21 15:33:13.411 2812-2812/<package> I/Zhenxi: instrumentation UID             PID   PPID C STIME TTY          TIME CMD
2022-07-21 15:33:13.413 2812-2812/<package> I/Zhenxi: instrumentation u0_a484        2812    783 19 15:33:12 ?    00:00:00 <package>
2022-07-21 15:33:13.413 2812-2812/<package> I/Zhenxi: instrumentation u0_a484        2863   2812 0 15:33:12 ?     00:00:00 ps -ef

ip a (important)

pfile = popen("ip a", "r");
while (fgets(buf, sizeof(buf), pfile)) {
    LOGE("ps -ef  -> %s", buf);
}

This is another core fingerprint: it returns the phone’s NIC info — wlan0, wlan1, p2p0, and so on. Underneath it also goes through netlink.

So modifying and intercepting at the netlink layer works even for command-line collection. It returns a lot — print it yourself. Many big apps scan your NIC MAC this way.

ls -al /sdcard/Android/data

Scans the private directory and returns its info. Used to detect whether the app exists under another app’s directory — mainly for sandbox detection.

Another good sandbox check is examining process info: if the app starts normally, there’s only one thread. Inside a VA sandbox, the sandbox itself starts one thread and the app another, so the thread count doesn’t match — treated as cheating. To bypass, hook readdir and return null when it reads the debugger thread.

void Test::checkSandbox() {
    DIR *pdr = opendir("/proc");
    if (pdr == nullptr) {
        return;
    }
    dirent *read_ptr;
    // before the app starts, scan all its processes to find one different from main
    while ((read_ptr = readdir(pdr)) != nullptr) {
        int procpid = atoi(read_ptr->d_name);
        LOG(INFO) << "find /proc/ child dir  " << procpid;
        // opened successfully && found a pid different from the main process id
        if (procpid && procpid != getpid()) {
            // this file belongs to the main thread
            LOG(ERROR) << ">>>>>  FIND OTHER THREAD SANDBOX " << procpid;
            closedir(pdr);
        }
    }
    closedir(pdr);
    LOG(ERROR) << ">>>>> NOT FIND SANDBOX ";
}

popen scanning for Magisk

These commands scan for Magisk entries and check whether keywords like magisk exist in the current process:

popen("df | grep /sbin/.magisk", "r");
popen("mount  | grep /sbin/.magisk", "r");
popen("ps | grep magisk", "r");

To modify, simply generate a file without the magisk keyword for ps/df (plus other trace keywords like xposed, edxp, riru — common detection keywords). For mount, bypass via svc I/O redirection.

popen logcat

Many big apps, upon detecting abnormal device info, run popen logcat to scan the phone’s log system.

They report the abnormal logs to confirm the user is cheating, so this needs handling too:

pfile = popen("/system/bin/logcat -b main -d -v threadtime -t 200 --pid <current pid>", "r");

while (fgets(buf, sizeof(buf), pfile)) {
    LOGE("logcat  -> %s", buf);
}

APK signature

Someone recently asked me how to bypass APK signature checks. It’s actually very simple these days. There are two common approaches, used by most big apps and packers. Here’s the principle.

Java layer: talk to AMS over Binder for the real signature

The advantage needs little explanation — talk to AMS directly and get the most authentic signature, so hooking whatever methods in pms is useless.

I won’t publish the code, since many big apps and packers use it. It bypasses most signature hooks — dynamic pms proxies and hooked signature getters can all be bypassed this way. So how do you bypass this check?

Bypass principle

Communicating with AMS requires Binder — think of Binder as a “pipe”. Even though it talks to AMS directly, it still passes through our pipe, so we just handle the pipe: intercept during communication and replace the signature in BinderProxy -> transact.

Native layer: svc reads /data/app/<package>/base.apk, parses the zip, and extracts the signature file

The Java layer gets the signature via AMS; here we use svc openat to read the APK file directly and manually parse its signature — not trusting the system API’s parse result. This is another common signature check; the result is considered trustworthy.

Bypass principle

svc openat I/O redirection: when it reads the original /data/app/<package>/base.apk, we rewrite the path to the original APK. Then it reads the original APK path rather than the modified one, yielding the original signature. At the bottom, also handle svc readlink/readlinkat to prevent the replaced path from being detected.

These two approaches kill 99% of the signature checks on the market today.

Afterword

I personally think current fingerprints aren’t very secure — they call system APIs, and I don’t think the returned content can really be trusted.

Next up: how to build a device-fingerprint solution that talks to AMS directly over Binder, manually parses the Binder packets, and uses XML to force an isolated process at startup.

What is an isolated process?

android:isolatedProcess="true"

Configure this when starting a service. The service can then only communicate with the Binder it’s bound to, shielded from other threads — preventing detection during initialization. Or initialize in Zygote with android:useAppZygote="true". Check Google’s docs for the details.

And prevent the process from being interfered with. Previously everything talked to AMS through the system-provided IPC classes; you can construct your own replacement and manually talk to AMS, obtaining the system fingerprint directly. I haven’t seen a writeup on this approach in English yet.


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