Big-Tech Device Fingerprinting, Part 2: Hunter's Environment Detection Explained
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.
Big-Tech Device Fingerprinting, Part 2: Hunter’s Environment Detection Explained
Preface
This is a follow-up to an earlier post on the fundamentals of device-fingerprint countermeasures. That post covered the basics; this one goes deeper into concrete detection techniques, and the mainstream evasion approaches that go with them.
Among the headaches this creates for big-tech vendors is a single hard problem: keeping a device fingerprint stable across a factory reset. I’ll walk through that in detail here.
Vendors collect device fingerprints through many different channels, so the interesting question is where and how to push back. I’ll lay out my own view of how to solve it at the “optimal” interception point. If you’ve got a better approach, message me.
Device fingerprinting
Android ID
The classic fingerprint field is the Android ID. As far as I know there are no fewer than five ways to obtain it. Let’s go through them.
Method 1
The most basic way — just hook it.
// original android id retrieval
String androidId = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
CLog.i(String.format("android_id -> 2222 %s", androidId));
Method 2
After the first retrieval, the system caches the Android ID in a HashMap to avoid repeated IPC initialization. So to verify method 1, you can fetch the cache a second time and compare it with the value above. On Android 9+ you need to bypass the Android ID reflection restriction. The retrieval looks like this:
// query the android id cache via reflection
ArrayMap mValues = null;
try {
Field sNameValueCache = Settings.Secure.class.getDeclaredField("sNameValueCache");
sNameValueCache.setAccessible(true);
Object sLockSettings = sNameValueCache.get(null);
Field fieldmValues = sLockSettings.getClass().getDeclaredField("mValues");
fieldmValues.setAccessible(true);
mValues = (ArrayMap<String,String>) fieldmValues.get(sLockSettings);
String android_id = (String)mValues.get("android_id");
CLog.i(String.format("android_id -> 3333 %s", android_id));
} catch (Throwable e) {
e.printStackTrace();
}
Method 3
Method 3 is also a basic API, obtained indirectly through ContentResolver. Many big vendors use this one.
try {
Bundle callResult = context.getContentResolver().call(
Uri.parse("content://settings/secure"), "GET_secure", "android_id", new Bundle()
);
String androidIdValue = callResult.getString("value");
CLog.i(String.format("android_id -> 1111 %s", androidIdValue));
} catch (Exception e) {
CLog.e(e.toString(), e);
}
Method 4
Query it through the content command. Under the hood this still goes through ContentResolver.
// query android id via the content command
String android_id = NativeEngine.popen(
"content query --uri content://settings/secure --where \"name=\\'android_id\\'\"",
"");
CLog.i(String.format("android_id -> 4444 %s", android_id));
Method 5
The reflection implementation of method 4. It kept misbehaving on higher Android versions in my testing — not stable enough — so I won’t post it here.
Total disk size
If you want the fingerprint to survive a factory reset, this field can carry heavy weight in the server-side similarity algorithm, and it lets you bucket devices by model. In my own testing, the core fields that stay stable across a factory reset are few: disk size, IPv6, and the MAC address. Let’s start with disk size.
There are three ways to get it, but all three bottom out in the same syscall. To evade, you only need to handle it at the SVC layer. I don’t recommend handling the three separately — it can leak. Especially if a new process is spawned via execve and the data comes back through a pipe, it’s easy to miss a hook.
jclass pJclass = env->FindClass("android/os/StatFs");
jmethodID id = env->GetMethodID(pJclass, "<init>", "(Ljava/lang/String;)V");
jobject pJobject =
env->NewObject(pJclass, id, env->NewStringUTF("/storage/emulated/0"));
jlong i = env->CallLongMethod(pJobject, env->GetMethodID(pJclass, "getTotalBytes", "()J"));
LOG(ERROR) << "Java getTotalBytes "<<i;
char buffer[1024];
FILE *fp = popen("stat -f /storage/emulated/0", "r");
if (fp != nullptr) {
while (fgets(buffer, sizeof(buffer), fp) != nullptr) {
//LOGI("ps -ef %s",buffer)
LOG(INFO) << "stat -f /storage/emulated/0" << buffer;
}
pclose(fp);
}
struct statfs64 buf={};
if (statfs64("/storage/emulated/0", &buf) == -1) {
LOG(ERROR) << "statfs64 system info failed";
return;
}
LOG(INFO) << "f_type (filesystem type): " << buf.f_type;
LOG(INFO) << "f_bsize (block size): " << buf.f_bsize;
LOG(INFO) << "f_blocks (total data blocks): " << buf.f_blocks;
LOG(INFO) << "f_bfree (free blocks): " << buf.f_bfree;
LOG(INFO) << "f_bavail (free blocks for non-privileged users): " << buf.f_bavail;
LOG(INFO) << "f_files (total inodes): " << buf.f_files;
LOG(INFO) << "f_ffree (free inodes): " << buf.f_ffree;
LOG(INFO) << "f_fsid (filesystem ID): " << buf.f_fsid.__val[0] << ", " << buf.f_fsid.__val[1];
LOG(INFO) << "f_namelen (max filename length): " << buf.f_namelen;
I previously referenced a reference hook framework and hooked the Java method, but found that the Native layer couldn’t be fully intercepted that way, so I dropped it. The framework’s code looked like this:
private void relocateRomMemInfo(VirtualBaseInfo virtualBaseInfo) {
final String androidSystemOsClassName = "android.system.Os";
Class<?> androidSystemOsClass =
RposedHelpers.findClassIfExists(androidSystemOsClassName, ClassLoader.getSystemClassLoader());
if (androidSystemOsClass == null) {
CLog.e(">>>>>>>>>>>>> vfs android.system.Os not found");
return;
}
RposedHelpers.findAndHookMethod(androidSystemOsClass, "statvfs", String.class, new RC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
if(param.getResult() == null) {
return;
}
StructStatVfs structStatVfs = (StructStatVfs) param.getResult();
if (structStatVfs.f_blocks == 0) {
return;
}
if(param.args[0] instanceof String) {
long mockRomOut = getMockRomOut(structStatVfs.f_blocks, virtualBaseInfo, structStatVfs.f_blocks);
CLog.i(TAG, "vfs statvfs path:" + param.args[0] + " f_blocks:" + structStatVfs.f_blocks + " mock value:" + mockRomOut);
RposedHelpers.setObjectField(
structStatVfs,
"f_blocks",
mockRomOut
);
}
}
});
}
That approach only works for Java-side retrieval. The three native methods all bottom out in statfs64 or statfs. To evade, simply replace and rewrite argument 2 in the after hook of statfs64 / statfs. The interception at the SVC layer can follow the proot approach I wrote about in an earlier post.
The upside of this approach: no matter how many processes are spawned, it all gets handled uniformly at the bottom layer.
//int statfs(const char *path, struct statfs *buf);
//int statfs64(const char *path, struct statfs64 *buf);
case SC_statfs:
case SC_statfs64: {
if (isMockFingerptint()) {
if ((int) syscall_result < 0) {
break;
}
// f_type: filesystem type.
// f_bsize: filesystem block size.
// f_blocks: total number of blocks in the filesystem.
// f_bfree: number of free blocks in the filesystem.
// f_bavail: number of blocks available to non-superusers.
// f_files: total number of inodes in the filesystem.
// f_ffree: number of free inodes in the filesystem.
// f_fsid: filesystem identifier.
// f_namelen: maximum filename length.
char pathBuff[PATH_MAX];
word_t pPath = peek_reg(tracee, ORIGINAL, SYSARG_1);
int ret = read_string(tracee, pathBuff, pPath, PATH_MAX);
if(ret < 0){
break;
}
if(get_sysnum(tracee, ORIGINAL) == SC_statfs64){
struct statfs64 fs = {};
word_t arg2 = peek_reg(tracee, ORIGINAL, SYSARG_2);
read_data(tracee,&fs,arg2,sizeof (struct statfs64));
NativeFingerHandler::StatfsHandler64(pathBuff,&fs);
write_data(tracee,arg2,&fs,sizeof (struct statfs64));
} else{
struct statfs fs = {};
word_t arg2 = peek_reg(tracee, ORIGINAL, SYSARG_2);
read_data(tracee,&fs,arg2,sizeof (struct statfs));
NativeFingerHandler::StatfsHandler32(pathBuff,&fs);
write_data(tracee,arg2,&fs,sizeof (struct statfs));
}
}
break;
}
MAC address
Not much to say here — a basic field. Four ways to get it: Java, netlink, command line, and reading a file. As above, just replace the packet in the after hook of recvmsg, recv, and recvfrom at the SVC layer. The netlink MAC retrieval can follow my earlier code.
If the message is a netlink message and is fetching the interface type, just parse and replace the packet:
case SC_recvmsg: {
//LOGI("start handle SC_recvmsg systexit after")
if (isMockFingerptint()) {
NetlinkMacHandler::netlinkHandler_recmsg(tracee);
}
break;
}
case SC_recv:
case SC_recvfrom: {
//LOGE("start handle SC_recvfrom systexit after")
//recv bottoms out in recvfrom, so recvfrom doesn't need separate handling
if (isMockFingerptint()) {
NetlinkMacHandler::netlinkHandler_recv(tracee);
}
break;
}
For the read-file path, since the interface info is already in memory, just redirect the I/O.
Common files for interface info, taking wlan0 as an example — you can cat them or read them directly:
/sys/class/net/wlan0/address
/sys/devices/virtual/net/wlan0/address
...
Nearby network interfaces
This field mainly monitors fleet/device-farm operations — its purpose is to get the MAC addresses of nearby Wi-Fi peers.
For example, vendors usually detect device farms by scanning nearby interfaces; if there’s clustering, it’s flagged as a farm. Retrieval is the same as above — five methods.
The bottom layer is netlink, same as MAC retrieval. For example you can just run popen:
popen("ip neigh show", "r");
Or read the file directly:
/proc/net/arp
Or fetch it via netlink directly — after receiving the message, check whether the message type is hdr->nlmsg_type == RTM_NEWNEIGH and replace it.
After recv returns, replace the buffer in the data. Core code below, including the MAC-address replacement above:
static void _getifaddrs_callback(void *context, nlmsghdr *hdr) {
auto **out = reinterpret_cast<ifaddrs **>(context);
// first check whether the message type is RTM_NEWLINK
if (hdr->nlmsg_type == RTM_NEWLINK) {
auto *ifi = reinterpret_cast<ifinfomsg *>(NLMSG_DATA(hdr));
ifaddrs_storage new_addr(out);
new_addr.interface_index = ifi->ifi_index;
new_addr.ifa.ifa_flags = ifi->ifi_flags;
// Get the interface name
char ifname[IFNAMSIZ];
if_indextoname(ifi->ifi_index, ifname);
// Go through the various bits of information and find the name.
rtattr *rta = IFLA_RTA(ifi);
// get the length of this message
size_t rta_len = IFLA_PAYLOAD(hdr);
// check whether this message is valid
while (RTA_OK(rta, rta_len)) {
if (rta->rta_type == IFLA_ADDRESS){
if (RTA_PAYLOAD(rta) < sizeof(new_addr.addr)) {
void *data = RTA_DATA(rta);
// modify the mac address
setMacInData(data, ifname, ZHENXI_RUNTIME_NETLINK_MAC, false);
new_addr.SetAddress(AF_PACKET, data, RTA_PAYLOAD(rta));
new_addr.SetPacketAttributes(ifi->ifi_index, ifi->ifi_type,
RTA_PAYLOAD(rta));
}
}
else if (rta->rta_type == IFLA_BROADCAST) {
if (RTA_PAYLOAD(rta) < sizeof(new_addr.ifa_ifu)) {
void *data = RTA_DATA(rta);
size_t byteCount = RTA_PAYLOAD(rta);
new_addr.SetBroadcastAddress(AF_PACKET, data, byteCount);
new_addr.SetPacketAttributes(ifi->ifi_index, ifi->ifi_type,
RTA_PAYLOAD(rta));
}
}
else if (rta->rta_type == IFLA_IFNAME) {
if (RTA_PAYLOAD(rta) < sizeof(new_addr.name)) {
memcpy(new_addr.name, RTA_DATA(rta), RTA_PAYLOAD(rta));
new_addr.ifa.ifa_name = new_addr.name;
}
}
rta = RTA_NEXT(rta, rta_len);
}
}
else if (hdr->nlmsg_type == RTM_NEWADDR) {
// this type wasn't observed when fetching interfaces
auto *msg = reinterpret_cast<ifaddrmsg *>(NLMSG_DATA(hdr));
// We should already know about this from an RTM_NEWLINK message.
const auto *addr = reinterpret_cast<const ifaddrs_storage *>(*out);
while (addr != nullptr && addr->interface_index != static_cast<int>(msg->ifa_index)) {
//LOGE("Current interface index: %d", addr->interface_index); // log current interface index
addr = reinterpret_cast<const ifaddrs_storage *>(addr->ifa.ifa_next);
}
// If this is an unknown interface,
// ignore whatever we're being told about it.
if (addr == nullptr) {
//LOGE ("_getifaddrs_callback RTM_NEWADDR return")
return;
}
ifaddrs_storage new_addr(out);
strcpy(new_addr.name, addr->name);
new_addr.ifa.ifa_name = new_addr.name;
new_addr.ifa.ifa_flags = addr->ifa.ifa_flags;
new_addr.interface_index = addr->interface_index;
// Go through the various bits of information and find the address
// and any broadcast/destination address.
rtattr *rta = IFA_RTA(msg);
size_t rta_len = IFA_PAYLOAD(hdr);
while (RTA_OK(rta, rta_len)) {
LOGE("RTA type: %d", rta->rta_type);
if (rta->rta_type == IFA_ADDRESS) {
//LOGE ("_getifaddrs_callback RTM_NEWADDR IFA_ADDRESS %d ",msg->ifa_family)
if (msg->ifa_family == AF_INET || msg->ifa_family == AF_INET6) {
void *data = RTA_DATA(rta);
// make sure RTA_DATA(rta) has the correct size
if (msg->ifa_family == AF_INET6 && RTA_PAYLOAD(rta) < sizeof(struct in6_addr)) {
LOGE("RTA_PAYLOAD size is less than sizeof(struct in6_addr)");
return;
}
struct in6_addr addr_v6_address{};
memcpy(&addr_v6_address, RTA_DATA(rta), sizeof(struct in6_addr));
char str[INET6_ADDRSTRLEN];
inet_ntop(AF_INET6, &addr_v6_address, str, sizeof(str));
LOGE("RTM_NEWADDR&IFA_ADDRESS&AF_INET6 111 %s", str)
size_t byteCount = RTA_PAYLOAD(rta);
LOGE ("RTM_NEWADDR&IFA_ADDRESS %zu %s ",
byteCount, getpData(data, byteCount).c_str())
new_addr.SetAddress(msg->ifa_family, data, byteCount);
new_addr.SetNetmask(msg->ifa_family, msg->ifa_prefixlen);
}
}
else if (rta->rta_type == IFA_BROADCAST) {
if (msg->ifa_family == AF_INET||msg->ifa_family == AF_INET6) {
void *data = RTA_DATA(rta);
size_t byteCount = RTA_PAYLOAD(rta);
LOGE ("RTM_NEWADDR&IFA_BROADCAST %zu %s ",
byteCount, getpData(data, byteCount).c_str())
new_addr.SetBroadcastAddress(msg->ifa_family, data,
byteCount);
}
}
else if (rta->rta_type == IFA_LOCAL) {
//LOGE ("_getifaddrs_callback RTM_NEWADDR IFA_LOCAL %d ",msg->ifa_family)
if (msg->ifa_family == AF_INET || msg->ifa_family == AF_INET6) {
void *data = RTA_DATA(rta);
struct in6_addr addr_v6_local{};
memcpy(&addr_v6_local, RTA_DATA(rta), sizeof(struct in6_addr));
char str[INET6_ADDRSTRLEN];
inet_ntop(AF_INET6, &addr_v6_local, str, sizeof(str));
LOGE("RTM_NEWADDR&IFA_ADDRESS&AF_INET6 222 %s", str)
size_t byteCount = RTA_PAYLOAD(rta);
LOGE ("RTM_NEWADDR&IFA_LOCAL %zu %s ",
byteCount, getpData(data, byteCount).c_str())
new_addr.SetLocalAddress(msg->ifa_family, data, byteCount);
}
}
rta = RTA_NEXT(rta, rta_len);
}
}
else if (hdr->nlmsg_type == RTM_NEWNEIGH) {
// RTM_NEWNEIGH is a neighbor entry (arp table), needs randomization
auto *ifinfo = reinterpret_cast<ndmsg *>(NLMSG_DATA(hdr));
rtattr *rta = NDA_RTA(ifinfo);
size_t rta_len = NDA_PAYLOAD(hdr);
int if_index = ifinfo->ndm_ifindex;
char if_name[IFNAMSIZ];
if_indextoname(if_index, if_name);
// iterate over the specific message types
while (RTA_OK(rta, rta_len)) {
//a neighbor cache n/w layer destination address
//neighbor cache nw-layer destination address, ip is 32 or 64 bits
//ip address, can be v4 or v6
if (rta->rta_type == NDA_DST) {
if (ifinfo->ndm_family == AF_INET) {
//32
struct in_addr addr{};
memcpy(&addr, RTA_DATA(rta), sizeof(struct in_addr));
char *ntoa = inet_ntoa(addr);
//LOGE("NDA_DST&AF_INET %s", inet_ntoa(addr))
} else if (ifinfo->ndm_family == AF_INET6) {
//64
struct in6_addr addr{};
memcpy(&addr, RTA_DATA(rta), sizeof(struct in6_addr));
char str[INET6_ADDRSTRLEN];
inet_ntop(AF_INET6, &addr, str, sizeof(str));
//LOGE("NDA_DST&AF_INET6 %s", str);
}
} else if (rta->rta_type == NDA_LLADDR) {
//interface address
auto *data = RTA_DATA(rta);
setMacInData(data, if_name, ZHENXI_RUNTIME_NETLINK_NEIGH, true);
}
rta = RTA_NEXT(rta, rta_len);
}
}
else if (hdr->nlmsg_type == RTM_GETADDR) {
//LOGE("RTM_GETADDR ")
auto *ifa = reinterpret_cast<ifaddrmsg *>(NLMSG_DATA(hdr));
// Get the interface name
char ifname[IFNAMSIZ];
if_indextoname(ifa->ifa_index, ifname);
// Process the attributes
rtattr *rta = IFA_RTA(ifa);
size_t rta_len = IFA_PAYLOAD(hdr);
while (RTA_OK(rta, rta_len)) {
if (rta->rta_type == IFA_ADDRESS) {
if (ifa->ifa_family == AF_INET6) {
// Ensure RTA_DATA(rta) size is correct
if (RTA_PAYLOAD(rta) < sizeof(struct in6_addr)) {
LOGE("RTM_GETADDR RTA_PAYLOAD size is less than sizeof(struct in6_addr)");
return;
}
struct in6_addr addr_v6_address{};
memcpy(&addr_v6_address, RTA_DATA(rta), sizeof(struct in6_addr));
char str[INET6_ADDRSTRLEN];
inet_ntop(AF_INET6, &addr_v6_address, str, sizeof(str));
LOGE("RTM_GETADDR RTM_GETADDR&IFA_ADDRESS&AF_INET6 %s", str);
}
}
rta = RTA_NEXT(rta, rta_len);
}
}
}
IPv6
Another core fingerprint field. Its bottom layer is also netlink, but it’s genuinely hard to handle, so I haven’t dealt with it yet.
Common retrieval paths are Java and the command line. To replace it, you only need to handle the command line and the Java hook.
On the command-line side, you can swap the command for a cat of a pre-mocked file before the target executes it — same effect.
Another idea: this field can actually be fetched server-side and reported back by the client for matching.
try {
NetworkInterface networkInterface;
InetAddress inetAddress;
for (Enumeration<NetworkInterface> en = NetworkInterface.getNetworkInterfaces(); en.hasMoreElements(); ) {
networkInterface = en.nextElement();
for (Enumeration<InetAddress> enumIpAddr = networkInterface.getInetAddresses(); enumIpAddr.hasMoreElements(); ) {
inetAddress = enumIpAddr.nextElement();
if (inetAddress instanceof Inet6Address) {
CLog.e("Java get ipv6 " + inetAddress.getHostAddress());
}
}
}
} catch (Throwable ex) {
CLog.e("printf ipv6 info error " + ex);
}
Command-line retrieval with the ip command:
ip -6 addr show
Output looks like:
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 state UNKNOWN qlen 1000
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
3: dummy0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 state UNKNOWN qlen 1000
inet6 fe80::b86c:79ff:fe96:4945/64 scope link
valid_lft forever preferred_lft forever
10: rmnet_data0@rmnet_ipa0: <UP,LOWER_UP> mtu 1500 state UNKNOWN qlen 1000
inet6 fe80::2ad1:b5a0:792b:9ec4/64 scope link
valid_lft forever preferred_lft forever
30: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP qlen 3000
inet6 fe80::8670:a04c:b8cf:467c/64 scope link stable-privacy
valid_lft forever preferred_lft forever
System kernel info
This bottoms out in the uname function — just handle the uname syscall. You can call uname via SVC directly, or via the command line.
To modify it, replace the data in the after hook of uname:
uname -a
Package random path
This is an extremely core field: /data/app/<random Base64 path>/base.apk.
That random path is the device fingerprint. Some vendors play games here — they read a well-known app’s random path, get the app’s package info, then extract the random path.
For example, the random paths of a messaging app, a short-video app, and major e-commerce apps serve as a core unique device fingerprint. As long as you don’t uninstall those apps, your device fingerprint never changes — no matter how much you tamper with the info inside the app itself, it has zero effect on this.
System account
Generally, on a major Android OEM’s device, logging into a specific account yields an account id that also needs handling. The best approach is simply not to log into the account.
Environment detection
Most environment detection is a re-implementation of Hunter’s open-source detection techniques. A lot of it comes straight from Hunter’s source, and many of these tricks aren’t public industry-wide — a lot of the detection in the wild is already stale.
APK signature
No discussion of environment detection is complete without APK repackaging detection. The techniques are all over the place, so I’ll list them and walk through the risk points, detection, and bypass principles for each. If you have better ideas, leave a comment.
The cheapest, most effective way to bypass signature checks: patch, then install unsigned, combined with core cracking.
Core cracking is an LSPosed module — grab it from the LSPosed store. It hooks the system’s APK signature parsing and bypasses the check entirely, so you can install unsigned.
First, the detection points used by big vendors and enterprise shells. I’ll skip the basic Java-layer signature retrieval.
Native-layer signature retrieval
Detection
Let’s start with Hunter’s source. There are three core parts:
- SVC
openatreads the APK and parses the signature. - Check the opened fd and reverse-lookup its path. One detail:
buff[len] = '\0';— if the attacker didn’t modifyreadlinkat’s return value, this catches them. - After checking the path, reverse-check the file’s permissions. A normal APK lives under the system, so its GID and UID should be 1000. If the attacker forgot to fix the permissions, this catches them too.
const char *path = getApkPath(env, context);
//check svc apk sign
const string &string = checkSign(env, path).substr(0, 10);
LOG(INFO) << "apk sign " << string;
if (string == Base64Utils::VTDecode("TFtCRU58UERAUQ==")) {
//check sign success,but maybe svc io hook
//check apk path
int fd = my_openat(AT_FDCWD, reinterpret_cast<const char *>(path),
O_RDONLY | O_CLOEXEC,
0640);
//check apk path
char buff[PATH_MAX] = {0};
std::string fdPath("/proc/");
fdPath.append(to_string(getpid())).append("/fd/").append(to_string(fd));
long len = raw_syscall(__NR_readlinkat, AT_FDCWD, fdPath.c_str(), buff, PATH_MAX);
if (len < 0) {
return getItemData(env, "APK signature verification failed",
"readlinkat error", true,
RISK_LEAVE_DEADLY, TAG_REPACKAGE);
}
//truncate; if the attacker hooked readlinkat and only changed the argument
//but not the return value, this still catches them.
buff[len] = '\0';
LOG(INFO) << "check apk sign path " << buff;
if (my_strcmp(path, buff) == 0) {
LOG(INFO) << "check apk sign path success ";
//start check memory&location inode
struct stat statBuff = {0};
long stat = raw_syscall(__NR_fstat, fd, &statBuff);
if (stat < 0) {
LOG(ERROR) << "check apk sign path fail __NR_fstat<0";
return getItemData(env, "APK signature verification failed",
"fstat error", true, RISK_LEAVE_DEADLY, TAG_REPACKAGE);
}
//check uid&gid (1000 = system group)
if (statBuff.st_uid != 1000 && statBuff.st_gid != 1000) {
LOG(ERROR) << "check apk sign gid&uid fail ";
return getItemData(env, "APK signature verification failed",
nullptr, true, RISK_LEAVE_DEADLY, TAG_REPACKAGE);
}
size_t inode = getFileInMapsInode(path);
if (statBuff.st_ino != inode) {
LOG(ERROR) << "check apk sign inode fail "<<statBuff.st_ino<<" maps ->"<<inode;
return getItemData(env, "APK signature verification failed",
nullptr, true, RISK_LEAVE_DEADLY, TAG_REPACKAGE);
}
LOG(ERROR) << ">>>>>>>>>> check apk sign success! uid-> " << statBuff.st_uid
<< " gid-> "
<< statBuff.st_gid;
} else {
LOG(ERROR) << "check apk sign path fail ";
return getItemData(env, "APK signature verification failed",
nullptr, true, RISK_LEAVE_DEADLY, TAG_REPACKAGE);
}
LOG(INFO) << "check apk sign success";
return nullptr;
}
Countermeasures
Bypassing the above is simple: once you intercept the SVC openat, just handle readlinkat and stat — easy to get past. Note that most packers check more than just the signature — ROOT detection, LSPosed call-stack detection, etc. For example, if seccomp is enabled they crash, if root is detected they crash.
Java-layer signature retrieval
Detection
Detecting whether CREATOR was replaced
First, Hunter’s Java-layer signature check — it effectively reflects the CREATOR variable, which holds some IPC-related data.
Many attackers pack with LSPatch and replace this variable. Here we check whether this variable’s ClassLoader is the system ClassLoader, to prevent replacement.
try {
Field creatorField = PackageInfo.class.getField("CREATOR");
creatorField.setAccessible(true);
Object creator = creatorField.get(null);
if (creator != null) {
ClassLoader creatorClassloader = creator.getClass().getClassLoader();
ClassLoader sysClassloader = ClassLoader.getSystemClassLoader();
if (creatorClassloader == null || sysClassloader == null) {
return null;
}
//the system one is bootclassloader
//user-created ones are pathclassloader
//if they're equal, the system one was replaced
if (sysClassloader.getClass().getName().
equals(creatorClassloader.getClass().getName())) {
return new ListItemBean("APK signature verification failed!",
ListItemBean.RiskLeave.Deadly,
"APK signature method replaced!\n"
+ creatorClassloader.getClass().getName() + "\n"
+ sysClassloader.getClass().getName());
}
return null;
}
} catch (Throwable e) {
CLog.e("checkApkPackageInfoCreator error " + e);
}
Countermeasures
There are three main approaches:
Big vendors generally talk to PMS directly over IPC, but that path is also easy to get past — I referenced LSPatch here too.
Two core ideas: intercept the Binder IPC communication, and hook the server-side signature parsing. Code below.
Hooking the server-side signature parsing
Replace the signature while the system parses it.
private static void hookPackageParser(Signature[] fakeSignature) {
try {
RposedBridge.hookAllMethods(
RposedHelpers.findClass("android.content.pm.PackageParser", ClassLoader.getSystemClassLoader()),
"generatePackageInfo", new RC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
PackageInfo packageInfo = (PackageInfo) param.getResult();
if (packageInfo == null) return;
if (packageInfo.packageName.equals(RuntimeToolKit.packageName)) {
if (packageInfo.signatures != null && packageInfo.signatures.length > 0) {
CLog.i("PackageParser signature info (method 1)");
packageInfo.signatures[0] = fakeSignature[0];
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
if (packageInfo.signingInfo != null) {
CLog.i("PackageParser signature info (method 2)");
Signature[] signaturesArray = packageInfo.signingInfo.getApkContentsSigners();
if (signaturesArray != null && signaturesArray.length > 0) {
signaturesArray[0] = fakeSignature[0];
}
}
}
}
}
});
} catch (Throwable e) {
CLog.e("hook apkSign PackageParser -> generatePackageInfo " + e.getMessage());
}
}
Replacing CREATOR
This approach is also borrowed from LSPatch, and it can be detected by the CREATOR-variable-ClassLoader check described above.
public static void byPassSignatureForCREATOR(Signature[] fakeSignature) {
if (fakeSignature == null) {
return;
}
Parcelable.Creator<PackageInfo> originalCreator = PackageInfo.CREATOR;
Parcelable.Creator<PackageInfo> proxiedCreator = new Parcelable.Creator<PackageInfo>() {
@Override
public PackageInfo createFromParcel(Parcel source) {
PackageInfo packageInfo = originalCreator.createFromParcel(source);
if (packageInfo.packageName.equals(RuntimeToolKit.packageName)) {
if (packageInfo.signatures != null && packageInfo.signatures.length > 0) {
//CLog.i("CREATOR Replace signature info (method 1)");
packageInfo.signatures[0] = fakeSignature[0];
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
if (packageInfo.signingInfo != null) {
//CLog.i("CREATOR Replace signature info (method 2)");
Signature[] signaturesArray = packageInfo.signingInfo.getApkContentsSigners();
if (signaturesArray != null && signaturesArray.length > 0) {
signaturesArray[0] = fakeSignature[0];
}
}
}
}
return packageInfo;
}
@Override
public PackageInfo[] newArray(int size) {
return originalCreator.newArray(size);
}
};
RposedHelpers.setStaticObjectField(PackageInfo.class, "CREATOR", proxiedCreator);
try {
Map<?, ?> mCreators = (Map<?, ?>) RposedHelpers.getStaticObjectField(Parcel.class, "mCreators");
mCreators.clear();
} catch (NoSuchFieldError ignore) {
} catch (Throwable e) {
CLog.e("fail to clear Parcel.mCreators", e);
}
try {
Map<?, ?> sPairedCreators = (Map<?, ?>)
RposedHelpers.getStaticObjectField(Parcel.class,
"sPairedCreators");
sPairedCreators.clear();
} catch (NoSuchFieldError ignore) {
} catch (Throwable e) {
CLog.e("fail to clear Parcel.sPairedCreators", e);
}
}
The Binder transact method
Many vendors forge their own IPC to talk to the server side, using this to modify and replace signatures. The idea is to hook the Binder communication-parsing method. If the transmitted data type is a signature fetch, replace it. The core idea: hook the “pipe” that carries the data and replace what’s inside.
For some reason the newer LSPatch versions removed this code, keeping only CREATOR replacement and the server-side signature-parse hook.
RposedHelpers.findAndHookMethod("android.os.BinderProxy",
context.getClassLoader(),
"transact",
int.class,
Parcel.class,
Parcel.class,
int.class,
new RC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
try {
Object object = param.thisObject;
int id = (int) param.args[0];
Parcel write = (Parcel) param.args[1];
Parcel out = (Parcel) param.args[2];
// forward check
if (write == null || out == null) {
return;
}
// prevent recursive call
if (id == IBinder.INTERFACE_TRANSACTION) {
//IBinder protocol transaction code: asks the recipient for its canonical interface descriptor.
return;
}
String desc = (String) RposedHelpers.callMethod(object, "getInterfaceDescriptor");
if (desc == null || !desc.equals("android.content.pm.IPackageManager")) {
return;
}
if (id == TRANSACTION_getPackageInfo_ID) {
out.readException();
if (0 != out.readInt()) {
PackageInfo packageInfo = PackageInfo.CREATOR.createFromParcel(out);
if (packageInfo.packageName.equals(context.getApplicationInfo().packageName)) {
if (fakeSignature[0] == null) {
CLog.e(">>>>>>>>>> byPassSignature fakeSignature == null");
System.exit(0);
return;
}
//CLog.i( "org data size -> "+out.dataSize());
if (packageInfo.signatures != null && packageInfo.signatures.length > 0) {
packageInfo.signatures[0] = fakeSignature[0];
//CLog.i(" byPassSignatureByLSPosed 1 !!");
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
if (packageInfo.signingInfo != null) {
Signature[] signaturesArray = packageInfo.signingInfo.getApkContentsSigners();
if (signaturesArray != null && signaturesArray.length > 0) {
signaturesArray[0] = fakeSignature[0];
//CLog.i( " byPassSignatureByLSPosed 2 !!");
}
}
}
out.setDataPosition(0);
out.setDataSize(0);
out.writeNoException();
out.writeInt(1);
packageInfo.writeToParcel(out, PARCELABLE_WRITE_RETURN_VALUE);
}
}
// reset pos
out.setDataPosition(0);
}
}
catch (Throwable err) {
CLog.e(">>>>>>>>>> byPassSignatureByLSPosed error " + err.getMessage());
}
}
});
There are plenty of other clever approaches too — for example, an ISO thread that reads the APK signature to prevent SVC I/O redirection.
There are certainly more signature-detection methods than these; if you know one I missed, leave a comment.
Emulator detection
I’ll skip the basic Java-layer API/architecture retrieval here.
Seccomp architecture detection
The main idea comes from a fellow researcher — thanks to them. First, install the seccomp skeleton.
void install_check_arch_seccomp() {
struct sock_filter filter[15] = {
BPF_STMT(BPF_LD + BPF_W + BPF_ABS, (uint32_t) offsetof(struct seccomp_data, nr)),
BPF_JUMP(BPF_JMP + BPF_JEQ, __NR_getpid, 0, 12),
BPF_STMT(BPF_LD + BPF_W + BPF_ABS, (uint32_t) offsetof(struct seccomp_data, args[0])),
BPF_JUMP(BPF_JMP + BPF_JEQ, DetectX86Flag, 0, 10),
BPF_STMT(BPF_LD + BPF_W + BPF_ABS, (uint32_t) offsetof(struct seccomp_data, arch)),
BPF_JUMP(BPF_JMP + BPF_JEQ, AUDIT_ARCH_X86_64, 0, 1),
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ERRNO | (864 & SECCOMP_RET_DATA)),
BPF_JUMP(BPF_JMP + BPF_JEQ, AUDIT_ARCH_I386, 0, 1),
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ERRNO | (386 & SECCOMP_RET_DATA)),
BPF_JUMP(BPF_JMP + BPF_JEQ, AUDIT_ARCH_ARM, 0, 1),
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ERRNO | (0xA32 & SECCOMP_RET_DATA)),
BPF_JUMP(BPF_JMP + BPF_JEQ, AUDIT_ARCH_AARCH64, 0, 1),
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ERRNO | (0xA64 & SECCOMP_RET_DATA)),
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ERRNO | (6 & SECCOMP_RET_DATA)),
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW)
};
struct sock_fprog program = {
.len = (unsigned short) (sizeof(filter) / sizeof(filter[0])),
.filter = filter
};
errno = 0;
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) {
LOG(ERROR) << "prctl(PR_SET_NO_NEW_PRIVS) " << strerror(errno);
}
errno = 0;
if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program)) {
LOG(ERROR) << "prctl(PR_SET_SECCOMP) " << strerror(errno);
}
}
Paired with the code above: at startup, call getpid. The skeleton intercepts getpid, then checks the architecture.
string check_arch_by_seccomp() {
if (get_sdk_level() < __ANDROID_API_N_MR1__){
return "";
}
errno = 0;
syscall(__NR_getpid, DetectX86Flag);
if (errno == 386) {
return "I386 device";
} else if (errno == 864) {
return "X86_64 device";
} else if (errno == 0xA32 || errno == 0xA64) {
return "";
}else if (errno == 0) {
//seccomp may not be enabled
return "";
}
return ("likely X86 emulator device" + to_string(errno));
}
Thermal mount-file detection
int thermal_check() {
DIR *dir_ptr;
int count = 0;
struct dirent *entry;
if ((dir_ptr = opendir("/sys/class/thermal/")) != nullptr) {
while ((entry = readdir(dir_ptr))) {
if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) {
continue;
}
char *tmp = entry->d_name;
if (strstr(tmp, "thermal_zone") != nullptr) {
count++;
}
}
closedir(dir_ptr);
} else {
count = -1;
}
return count;
}
Emulator signature files
string simulator_files_check() {
if (file_exist("/system/bin/androVM-prop")) {//detect androidVM
return "/system/bin/androVM-prop";
} else if (file_exist("/system/bin/microvirt-prop")) {//detect Microvirt emulator — newer versions may not match
return "/system/bin/microvirt-prop";
} else if (file_exist("/system/lib/libdroid4x.so")) {//detect Droid4X emulator
return "/system/lib/libdroid4x.so";
} else if (file_exist("/system/bin/windroyed")) {//detect Windroye emulator
return "/system/bin/windroyed";
} else if (file_exist("/system/bin/nox-prop")) {//detect Nox emulator — some versions may not match
return "/system/bin/nox-prop";
} else if (file_exist("system/lib/libnoxspeedup.so")) {//detect Nox emulator
return "system/lib/libnoxspeedup.so";
} else if (file_exist("/system/bin/ttVM-prop")) {//detect Tiantian emulator
return "/system/bin/ttVM-prop";
} else if (file_exist("/data/.bluestacks.prop")) {//detect BlueStacks emulator (and 51 emulator)
return "/data/.bluestacks.prop";
} else if (file_exist("/system/bin/duosconfig")) {//detect AMIDuOS emulator
return "/system/bin/duosconfig";
} else if (file_exist("/system/etc/xxzs_prop.sh")) {//detect Xingxing emulator
return "/system/etc/xxzs_prop.sh";
} else if (file_exist("/system/etc/mumu-configs/device-prop-configs/mumu.config")) {//NetEase MuMu emulator
return "/system/etc/mumu-configs/device-prop-configs/mumu.config";
} else if (file_exist("/system/priv-app/ldAppStore")) {//LDPlayer emulator
return "/system/priv-app/ldAppStore";
} else if (file_exist("system/bin/ldinit") && file_exist("system/bin/ldmountsf")) {//LDPlayer emulator
return "system/bin/ldinit";
} else if (file_exist("/system/app/AntStore") && file_exist("/system/app/AntLauncher")) {//Xiaoyi emulator
return "/system/app/AntStore";
} else if (file_exist("vmos.prop")) {//VMOS virtual machine
return "vmos.prop";
} else if (file_exist("fstab.titan") && file_exist("init.titan.rc")) {//LightSpeed VM
return "fstab.titan";
} else if (file_exist("x8.prop")) {//x8 sandbox and 51 VM
return "x8.prop";
} else if (file_exist("/system/lib/libc_malloc_debug_qemu.so")) {//AVD QEMU
return "/system/lib/libc_malloc_debug_qemu.so";
}
LOGD("simulator file check info not find ");
return "";
}
Emulator base features
The idea here mostly comes from a reference implementation — thanks again.
public static ListItemBean checkEmulator(Context context) {
ArrayList<String> choose = new ArrayList<>();
// try {
// String[] strArr = {
// "/boot/bstmods/vboxguest.ko",
// "/boot/bstmods/vboxsf.ko",
// "/dev/mtp_usb",
// "/dev/qemu_pipe",
// "/dev/socket/baseband_genyd",
// "/dev/socket/genyd",
// "/dev/socket/qemud",
// "/dev/socket/windroyed-audio",
// "/dev/socket/windroyed-camera",
// "/dev/socket/windroyed-gps",
// "/dev/socket/windroyed-sensors",
// "/dev/vboxguest",
// "/dev/vboxpci",
// "/dev/vboxuser",
// "/fstab.goldfish",
// "/fstab.nox",
// "/fstab.ranchu-encrypt",
// "/fstab.ranchu-noencrypt",
// "/fstab.ttVM_x86",
// "/fstab.vbox86",
// "/init.goldfish.rc",
// "/init.magisk.rc",
// "/init.nox.rc",
// "/init.ranchu-encrypt.rc",
// "/init.ranchu-noencrypt.rc",
// "/init.ranchu.rc",
// "/init.ttVM_x86.rc",
// "/init.vbox86.rc",
// "/init.vbox86p.rc",
// "/init.windroye.rc",
// "/init.windroye.sh",
// "/init.x86.rc",
// "/proc/irq/20/vboxguest",
// "/sdcard/Android/data/com.redfinger.gamemanage",
// "/stab.andy",
// "/sys/bus/pci/drivers/vboxguest",
// "/sys/bus/pci/drivers/vboxpci",
// "/sys/bus/platform/drivers/qemu_pipe",
// "/sys/bus/platform/drivers/qemu_pipe/qemu_pipe",
// "/sys/bus/platform/drivers/qemu_trace",
// "/sys/bus/virtio/drivers/itolsvmlgtp",
// "/sys/bus/virtio/drivers/itoolsvmhft",
// "/sys/class/bdi/vboxsf-1",
// "/sys/class/bdi/vboxsf-2",
// "/sys/class/bdi/vboxsf-3",
// "/sys/class/misc/qemu_pipe",
// "/sys/class/misc/vboxguest",
// "/sys/class/misc/vboxuser",
// "/sys/devices/platform/qemu_pipe",
// "/sys/devices/virtual/bdi/vboxsf-1",
// "/sys/devices/virtual/bdi/vboxsf-2",
// "/sys/devices/virtual/bdi/vboxsf-3",
// "/sys/devices/virtual/misc/qemu_pipe",
// "/sys/devices/virtual/misc/vboxguest",
// "/sys/devices/virtual/misc/vboxpci",
// "/sys/devices/virtual/misc/vboxuser",
// "/sys/fs/selinux/booleans/in_qemu",
// "/sys/kernel/debug/bdi/vboxsf-1",
// "/sys/kernel/debug/bdi/vboxsf-2",
// "/sys/kernel/debug/x86",
// "/sys/module/qemu_trace_sysfs",
// "/sys/module/vboxguest",
// "/sys/module/vboxguest/drivers/pci:vboxguest",
// "/sys/module/vboxpcism",
// "/sys/module/vboxsf",
// "/sys/module/vboxvideo",
// "/sys/module/virtio_pt/drivers/virtio:itoolsvmhft",
// "/sys/module/virtio_pt_ie/drivers/virtio:itoolsvmlgtp",
// "/sys/qemu_trace",
// "/system/app/GenymotionLayout",
// "/system/bin/OpenglService",
// "/system/bin/androVM-vbox-sf",
// "/system/bin/droid4x",
// "/system/bin/droid4x-prop",
// "/system/bin/droid4x-vbox-sf",
// "/system/bin/droid4x_setprop",
// "/system/bin/enable_nox",
// "/system/bin/genymotion-vbox-sf",
// "/system/bin/microvirt-prop",
// "/system/bin/microvirt-vbox-sf",
// "/system/bin/microvirt_setprop",
// "/system/bin/microvirtd",
// "/system/bin/mount.vboxsf",
// "/system/bin/nox",
// "/system/bin/nox-prop",
// "/system/bin/nox-vbox-sf",
// "/system/bin/nox_setprop",
// "/system/bin/noxd",
// "/system/bin/noxscreen",
// "/system/bin/noxspeedup",
// "/system/bin/qemu-props",
// "/system/bin/qemud",
// "/system/bin/shellnox",
// "/system/bin/ttVM-prop",
// "/system/bin/windroyed",
// "/system/droid4x",
// "/system/etc/init.droid4x.sh",
// "/system/etc/init.tiantian.sh",
// "/system/lib/egl/libEGL_emulation.so",
// "/system/lib/egl/libEGL_tiantianVM.so",
// "/system/lib/egl/libEGL_windroye.so",
// "/system/lib/egl/libGLESv1_CM_emulation.so",
// "/system/lib/egl/libGLESv1_CM_tiantianVM.so",
// "/system/lib/egl/libGLESv1_CM_windroye.so",
// "/system/lib/egl/libGLESv2_emulation.so",
// "/system/lib/egl/libGLESv2_tiantianVM.so",
// "/system/lib/egl/libGLESv2_windroye.so",
// "/system/lib/hw/audio.primary.vbox86.so",
// "/system/lib/hw/audio.primary.windroye.so",
// "/system/lib/hw/audio.primary.x86.so",
// "/system/lib/hw/autio.primary.nox.so",
// "/system/lib/hw/camera.vbox86.so",
// "/system/lib/hw/camera.windroye.jpeg.so",
// "/system/lib/hw/camera.windroye.so",
// "/system/lib/hw/camera.x86.so",
// "/system/lib/hw/gps.nox.so",
// "/system/lib/hw/gps.vbox86.so",
// "/system/lib/hw/gps.windroye.so",
// "/system/lib/hw/gralloc.nox.so",
// "/system/lib/hw/gralloc.vbox86.so",
// "/system/lib/hw/gralloc.windroye.so",
// "/system/lib/hw/sensors.nox.so",
// "/system/lib/hw/sensors.vbox86.so",
// "/system/lib/hw/sensors.windroye.so",
// "/system/lib/init.nox.sh",
// "/system/lib/libGM_OpenglSystemCommon.so",
// "/system/lib/libc_malloc_debug_qemu.so",
// "/system/lib/libclcore_x86.bc",
// "/system/lib/libdroid4x.so",
// "/system/lib/libnoxd.so",
// "/system/lib/libnoxspeedup.so",
// "/system/lib/modules/3.10.30-android-x86.hd+",
// "/system/lib/vboxguest.ko",
// "/system/lib/vboxpcism.ko",
// "/system/lib/vboxsf.ko",
// "/system/lib/vboxvideo.ko",
// "/system/lib64/egl/libEGL_emulation.so",
// "/system/lib64/egl/libGLESv1_CM_emulation.so",
// "/system/lib64/egl/libGLESv2_emulation.so",
// "/vendor/lib64/egl/libEGL_emulation.so",
// "/vendor/lib64/egl/libGLESv1_CM_emulation.so",
// "/vendor/lib64/egl/libGLESv2_emulation.so",
// "/vendor/lib64/libandroidemu.so",
// "/system/lib64/hw/gralloc.ranchu.so",
// "/system/lib64/libc_malloc_debug_qemu.so",
// "/system/usr/Keylayout/droid4x_Virtual_Input.kl",
// "/system/usr/idc/Genymotion_Virtual_Input.idc",
// "/system/usr/idc/droid4x_Virtual_Input.idc",
// "/system/usr/idc/nox_Virtual_Input.idc",
// "/system/usr/idc/windroye.idc",
// "/system/usr/keychars/nox_gpio.kcm",
// "/system/usr/keychars/windroye.kcm",
// "/system/usr/keylayout/Genymotion_Virtual_Input.kl",
// "/system/usr/keylayout/nox_Virtual_Input.kl",
// "/system/usr/keylayout/nox_gpio.kl",
// "/system/usr/keylayout/windroye.kl",
// "system/etc/init/ndk_translation_arm64.rc",
// "/system/xbin/noxsu",
// "/ueventd.android_x86.rc",
// "/ueventd.andy.rc",
// "/ueventd.goldfish.rc",
// "/ueventd.nox.rc",
// "/ueventd.ranchu.rc",
// "/ueventd.ttVM_x86.rc",
// "/ueventd.vbox86.rc",
// "/vendor/lib64/libgoldfish-ril.so",
// "/vendor/lib64/libgoldfish_codecs_common.so",
// "/vendor/lib64/libstagefright_goldfish_avcdec.so",
// "/vendor/lib64/libstagefright_goldfish_vpxdec.so",
// "/x86.prop"
// };
// for (int i = 0; i < 7; i++) {
// String f = strArr[i];
// if (new File(f).exists())
// choose.add(f);
// }
// } catch (Exception e) {
// e.printStackTrace();
// }
try {
String[] myArr = {
"generic",
"vbox"
};
for (String str : myArr) {
if (Build.FINGERPRINT.contains(str))
choose.add(Build.FINGERPRINT);
}
} catch (Exception e) {
e.printStackTrace();
}
try {
String[] myArr = {
"google_sdk",
"emulator",
"android sdk built for",
"droid4x"
};
for (String str : myArr) {
if (Build.MODEL.contains(str))
choose.add(Build.MODEL);
}
} catch (Exception e) {
e.printStackTrace();
}
try {
String[] myArr = {
"Genymotion"
};
for (String str : myArr) {
if (Build.MANUFACTURER.contains(str))
choose.add(Build.MANUFACTURER);
}
} catch (Exception e) {
e.printStackTrace();
}
try {
String[] myArr = {
"google_sdk", "sdk_phone", "sdk_x86", "vbox86p", "nox"
};
for (String str : myArr) {
if (Build.PRODUCT.toLowerCase(Locale.ROOT).contains(str))
choose.add(Build.PRODUCT);
}
} catch (Exception e) {
e.printStackTrace();
}
try {
String[] myArr = {
"nox"
};
for (String str : myArr) {
if (Build.BOARD.toLowerCase(Locale.ROOT).contains(str))
choose.add(Build.BOARD);
}
} catch (Exception e) {
e.printStackTrace();
}
try {
String[] myArr = {
"nox"
};
for (String str : myArr) {
if (Build.BOOTLOADER.toLowerCase(Locale.ROOT).contains(str))
choose.add(Build.BOOTLOADER);
}
} catch (Exception e) {
e.printStackTrace();
}
try {
String[] myArr = {
"ranchu", "vbox86", "goldfish"
};
for (String str : myArr) {
if (Build.HARDWARE.equalsIgnoreCase(str))
choose.add(Build.HARDWARE);
}
} catch (Exception e) {
e.printStackTrace();
}
try {
Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
while (networkInterfaces.hasMoreElements()) {
NetworkInterface ele = networkInterfaces.nextElement();
if (ele != null) {
Enumeration<InetAddress> inetAddresses = ele.getInetAddresses();
while (inetAddresses.hasMoreElements()) {
InetAddress nextElement = inetAddresses.nextElement();
if (!nextElement.isLoopbackAddress() &&
(nextElement instanceof Inet4Address)) {
String ip = nextElement.getHostAddress();
if (ip == null) continue;
if (ip.equalsIgnoreCase("10.0.2.15") ||
ip.equalsIgnoreCase("10.0.2.16")) {
choose.add(ip);
}
}
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
// try {
// String[] qemuProps = {
// "ro.kernel.qemu.avd_name",
// "ro.kernel.qemu.gles",
// "ro.kernel.qemu.gltransport",
// "ro.kernel.qemu.opengles.version",
// "ro.kernel.qemu.uirenderer",
// "ro.kernel.qemu.vsync",
// "ro.qemu.initrc",
// "init.svc.qemu-props",
// "qemu.adb.secure",
// "qemu.cmdline",
// "qemu.hw.mainkeys",
// "qemu.logcat",
// "ro.adb.qemud",
// "qemu.sf.fake_camera",
// "qemu.sf.lcd_density",
// "qemu.timezone",
// "init.svc.goldfish-logcat",
// "ro.boottime.goldfish-logcat",
// "ro.hardware.audio.primary",
// "init.svc.ranchu-net",
// "init.svc.ranchu-setup",
// "ro.boottime.ranchu-net",
// "ro.boottime.ranchu-setup",
// "init.svc.droid4x",
// "init.svc.noxd",
// "init.svc.qemud",
// "init.svc.goldfish-setup",
// "init.svc.goldfish-logcat",
// "init.svc.ttVM_x86-setup",
// "vmos.browser.home",
// "vmos.camera.enable",
// "ro.trd_yehuo_searchbox",
// "init.svc.microvirtd",
// "init.svc.vbox86-setup",
// "ro.ndk_translation.version",
// "redroid.width",
// "redroid.height",
// "redroid.fps",
// "ro.rf.vmname"
// };
//
// for (String str : qemuProps) {
// String val = SystemPropertiesUtils.getProperty(str, null);
// if (val != null) {
// choose.add(str);
// }
// }
// } catch (Throwable e) {
// e.printStackTrace();
// }
//check whether specified hardware exists
PackageManager pm = null;
try {
pm = context.getPackageManager();
String[] features = {
//PackageManager.FEATURE_RAM_NORMAL,//this one is problematic; self-assembled devices may not have this trace
PackageManager.FEATURE_BLUETOOTH,
PackageManager.FEATURE_CAMERA_FLASH,
PackageManager.FEATURE_TELEPHONY
};
for (String feature : features) {
if (!pm.hasSystemFeature(feature)) {
choose.add(feature);
}
}
} catch (Throwable ignored) {
}
try {
String[] emuPkgs = {
"com.google.android.launcher.layouts.genymotion",
"com.bluestacks",
"com.bignox.app"
};
for (String pkg : emuPkgs) {
try {
if (pm != null) {
pm.getPackageInfo(pkg, 0);
}
choose.add(pkg);
} catch (Throwable e) {
//e.printStackTrace();
}
}
} catch (Throwable ignored) {
}
try {
SensorManager sensor = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
int sensorSize = sensor.getSensorList(Sensor.TYPE_ALL).size();
for (int i = 0; i < sensorSize; i++) {
Sensor s = sensor.getDefaultSensor(i);
if (s != null && s.getName().contains("Goldfish")) {
choose.add(s.getName());
}
}
} catch (Throwable ignored) {
}
try {
if (checkSelfPermission(context, "android.permission.READ_SMS") == 0 ||
checkSelfPermission(context, "android.permission.READ_PHONE_NUMBERS") == 0 ||
checkSelfPermission(context, "android.permission.READ_PHONE_STATE") == 0) {
TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
String phoneNumber = telephonyManager.getLine1Number();
String[] phoneNumbers = {
"15555215554",
"15555215556",
"15555215558",
"15555215560",
"15555215562",
"15555215564",
"15555215566",
"15555215568",
"15555215570",
"15555215572",
"15555215574",
"15555215576",
"15555215578",
"15555215580",
"15555215582",
"15555215584"
};
if(phoneNumber!=null) {
for (String phone : phoneNumbers) {
if (phoneNumber.equalsIgnoreCase(phone)) {
choose.add(phone);
break;
}
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
if (choose.size() > 0) {
ListItemBean item = new ListItemBean("detected APK running in a VM & emulator",
ListItemBean.RiskLeave.Deadly,
choose.toString()
);
for (String str : choose) {
item.putData(str);
}
return item;
}
return null;
}
Cloud phone detection
There are many approaches here; different cloud-phone vendors have different tells. Most cloud phones are actually well-made and can pass Hunter’s checks.
Detecting current & voltage
private final BroadcastReceiver batteryInfoReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
// battery status
int plugged = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1);
// voltage (millivolts)
int voltage = intent.getIntExtra(BatteryManager.EXTRA_VOLTAGE, -1);
// get battery current (milliamps)
int currentNow = -1;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
BatteryManager batteryManager = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE);
currentNow = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW);
}
// check whether charging
if (plugged == BatteryManager.BATTERY_PLUGGED_AC || plugged == BatteryManager.BATTERY_PLUGGED_USB || plugged == BatteryManager.BATTERY_PLUGGED_WIRELESS) {
// charging
if (voltage != -1 && currentNow != -1) {
float voltageInVolts = voltage / 1000f; // convert voltage to volts
float currentInAmperes = currentNow / 1000000f; // convert current to amperes
float chargingPower = voltageInVolts * currentInAmperes; // compute charging power (watts)
CLog.i(String.format("charging power: %.2fW", chargingPower));
if (Math.abs(chargingPower) > 300) {
CLog.e("charging power too high");
handlerItemData(new ListItemBean(
"battery abnormal: charging power too high (possibly a cloud phone)",
ListItemBean.RiskLeave.Deadly,
"detected excessive charging power -> " + String.format("%.2fW", Math.abs(chargingPower))
));
}
}
}
}
};
Detecting camera & sensors
Check the camera count.
try {
CameraManager manager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
String[] cameraIds = manager.getCameraIdList();
//camera count
CLog.i("cameraIds -> "+ Arrays.toString(cameraIds));
if(cameraIds.length < CAMERA_MINIMUM_QUANTITY_LIMIT){
items.add(
new ListItemBean(
"current device may be an emulator & cloud phone",
ListItemBean.RiskLeave.Warn,
"camera size -> "+cameraIds.length
));
}
} catch (Throwable ignored) {
}
Detecting sensor count
The idea is to just grab the count; fewer than 10 is a straight flag for fraud operations. I haven’t seen a real phone with fewer than 10 sensors. If possible, try actually invoking a sensor to confirm it works, to guard against cloud phones faking it.
try {
//3, detect sensor types — all supported sensor types
SensorManager sm = (SensorManager) context.getSystemService(SENSOR_SERVICE);
List<Sensor> sensorlist = sm.getSensorList(Sensor.TYPE_ALL);
ArrayList<Integer> sensorTypeS = new ArrayList<>();
for (Sensor sensor : sensorlist) {
//get sensor type
int type = sensor.getType();
if (!sensorTypeS.contains(type)) {
//add a type when a new one is found
sensorTypeS.add(type);
}
}
//a flagship OEM device: 51 sensor types
//a standard Pixel: 27
//another OEM device: 18 sensors
CLog.e("sensor types size -> " + sensorlist.size());
//we treat fewer than 20 sensors as a risky device
if (sensorlist.size() < SENSOR_MINIMUM_QUANTITY_LIMIT) {
items.add(new ListItemBean(
"current device may be an emulator & cloud phone",
ListItemBean.RiskLeave.Warn,
"sensor size -> ("+ sensorlist.size()+") \n" +
"sensor type size -> ("+sensorTypeS.size()+") \n"
//+ "sensor info -> \n"+ Sensorlist //print all sensor info
));
}
Detecting sensor names
This checks sensor names. A mainstream OEM device won’t have a sensor named “AOSP”.
“AOSP” sensors basically only appear on self-built ROMs, so this is a good signal. You can report some sensor name info — it’s an important environment-detection lever.
The average novice certainly won’t think to change sensor names.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
ArrayList<Sensor> aospSensor = new ArrayList<>();
for(Sensor sensor:sensorlist){
if(sensor.getVendor().contains("AOSP")){
aospSensor.add(sensor);
}
}
if (aospSensor.size()>3) {
CLog.e("sensor parameters abnormal (vendor is AOSP)");
items.add(new ListItemBean(
"current device may be an emulator & cloud phone",
ListItemBean.RiskLeave.Warn,
aospSensor.size()
+"/"+sensorlist.size()+" sensor parameters abnormal -> "+ aospSensor
));
}
}
Detecting mount files
Iterate over the mount files below and check whether they contain the “docker” keyword, to catch cloud phones using docker for virtualization mounts.
Also a good signal.
String[] marks = {"docker"};
//check whether proc/mounts contains the docker keyword
String mark = NativeEngine.getZhenxiInfoK("/proc/mounts",marks );
if(mark == null){
mark = NativeEngine.getZhenxiInfoK("/proc/self/mountstats", marks);
if(mark == null){
mark = NativeEngine.getZhenxiInfoK("/proc/self/mountinfo", marks);
}
}
if(mark!=null){
items.add(new ListItemBean(
"current device may be an emulator & cloud phone",
ListItemBean.RiskLeave.Warn,
"(mounts abnormal)\n"+mark
));
}
Detecting whether the ROM matches
Detecting environment info
There are many approaches here, mainly to catch custom ROMs that try to evade custom-ROM detection by faking the device model.
You can run getprop to grab all environment info. On an OEM device, the environment info will contain the OEM’s ROM string.
For example, on a given OEM’s device I check for these key environment entries:
private static final String KEY_MIUI_VERSION_NAME = "ro.miui.ui.version.name";
private static final String KEY_MIUI_VERSION_CODE = "ro.miui.ui.version.code";
private static final String KEY_MIUI_INTERNAL_STORAGE = "ro.miui.internal.storage";
This can be collected and judged server-side, to prevent custom-ROM model spoofing.
Detecting the service list
Run service list — a mainstream OEM device will have that OEM’s system services, which are hard to fake. If someone fakes them, just try calling them.
I still recommend uploading to the server and letting the server-side similarity algorithm judge, rather than deciding locally — Hunter is an offline APK, so it only sketches this on the client.
Detecting whether the environment is hooked
Detection here comes in all shapes. The most basic is scanning maps for Frida, or checking the call stack for LSPosed fingerprints. I’ll skip the basic schemes — I don’t think they’re great. Rename something and you’re past them.
For example, the Frida fingerprint trio:
static const char *FRIDA_THREAD_GUM_JS_LOOP = "gum-js-loop";
static const char *FRIDA_THREAD_GMAIN = "gmain";
static const char *FRIDA_NAMEDPIPE_LINJECTOR = "linjector";
For hook detection, we really only need to check that memory hasn’t been modified. Here’s the principle and pseudocode.
Normally, when an .so loads into memory it’s fundamentally mmap’d into place. If function A’s instructions are BBB, they should still be BBB in memory.
Keep that in mind: we can convert the in-memory instructions to an int and sum them. If memory hasn’t been modified, the on-disk checksum and the in-memory checksum should match.
Because hooks these days target the text segment and the PLT — inline hooks and GOT tables respectively. Frida may also start late, so spawn a detection thread that polls in a loop.
There’s a design problem many developers miss: what if an attacker anti’s my detection thread?
There are countless ways to kill a detection thread. For example, watch all thread file I/O and see which thread only reads files — that alone is usually enough to spot it. Or hook the thread-spawn function and kill the spawned thread. But is there no good counter or detection for that?
Actually, it’s simple — just assign a variable in your detection code and flip a flag.
After the first check, mark a variable in the main process as true. You can use
__NR_process_vm_writev, and since it’s asynchronous, the main thread can fetch the flag after a 2-second delay and check whether it’s true —to make sure the detection thread actually started.
The concrete flow below uses libc as an example; swap in whatever path you need.
First, get the on-disk checksum of the local .so, returned as an execSection struct.
//struct recording the executable sections, one PLT and one text
//so the count is 2
typedef struct stExecSection {
int execSectionCount;
unsigned long offset[2];
unsigned long memsize[2];
unsigned long checksum[2];
unsigned long startAddrinMem;
bool isSuccess = false;
} execSection;
/**
* Get the checksum of the local file
* Slow read; initialize once and cache locally.
*/
execSection fetch_checksum_of_library(const char *filePath) {
execSection section = {0};
Elf_Ehdr ehdr;
Elf_Shdr sectHdr;
int fd;
int execSectionCount = 0;
fd = my_openat(AT_FDCWD, filePath, O_RDONLY, 0);
if (fd < 0) {
return section;
}
my_read(fd, &ehdr, sizeof(Elf_Ehdr));
my_lseek(fd, (off_t) ehdr.e_shoff, SEEK_SET);
unsigned long memSize[2] = {0};
unsigned long offset[2] = {0};
//find the start and length of the PLT and text sections
for (int i = 0; i < ehdr.e_shnum; i++) {
my_memset(§Hdr, 0, sizeof(Elf_Shdr));
my_read(fd, §Hdr, sizeof(Elf_Shdr));
//PLT and Text are usually the executable sections
if (sectHdr.sh_flags & SHF_EXECINSTR) {
offset[execSectionCount] = sectHdr.sh_offset;
memSize[execSectionCount] = sectHdr.sh_size;
execSectionCount++;
if (execSectionCount == 2) {
break;
}
}
}
if (execSectionCount == 0) {
LOG(INFO) << "get elf section error " << filePath;
my_close(fd);
return section;
}
//record the count
section.execSectionCount = execSectionCount;
section.startAddrinMem = 0;
for (int i = 0; i < execSectionCount; i++) {
my_lseek(fd, (off_t) offset[i], SEEK_SET);
//void * buffer = alloca(memSize[i] * sizeof(uint8_t));
//holds the entire text or plt contents, roughly 5-10M,
//so to support low-memory devices it lives on the heap,
//not the stack, to avoid stack-pointer overflow on low-memory phones.
auto buffer = (void *) calloc(1, memSize[i] * sizeof(uint8_t));
if (buffer == nullptr) {
free(buffer);
return section;
}
my_read(fd, buffer, memSize[i]);
section.offset[i] = offset[i];
section.memsize[i] = memSize[i];
section.checksum[i] = checksum(buffer, memSize[i]);
// LOGE("fetch_checksum_of_library %s ExecSection:[%d][%ld][%ld][%ld]",
// filePath, i, offset[i], memSize[i], section->checksum[i])
free(buffer);
}
section.isSuccess = true;
my_close(fd);
return section;
}
Then compute against the local instructions. To compute the local instructions, iterate over maps, only over the text and PLT segments, compute the checksum, and compare with local.
/**
* Detect the checksum
* 0 = unmodified
* 1 = modified
* -1 = detection failed
*/
int detect_elf_checksum(const char *soPath, execSection *pSection) {
if (pSection == nullptr) {
LOGI("detect_elf_checksum execSection == null ");
return -1;
}
char map[MAX_LINE];
const char *maps_path = string("proc/").append(to_string(getpid())).append("/maps").c_str();
int fd = my_openat(AT_FDCWD, maps_path, O_RDONLY, 0);
if (fd <= 0) {
LOGE("detect_elf_checksum open %s fail ", PROC_MAPS);
return -1;
}
int checkSum = 0;
while ((read_one_line(fd, map, MAX_LINE)) > 0) {
if (my_strstr(map, soPath) != nullptr) {
checkSum = scan_executable_segments(map, pSection, soPath);
if (checkSum == 1) {
break;
}
}
}
my_close(fd);
return checkSum;
}
/**
* Detect the checksum
* 0 = unmodified
* 1 = modified
* -1 = detection failed
*/
int scan_executable_segments(
char *mapItem,
execSection *pElfSectArr,
const char *libraryName) {
unsigned long start, end;
char buf[MAX_LINE] = "";
char path[MAX_LENGTH] = "";
char tmp[100] = "";
sscanf(mapItem, "%lx-%lx %s %s %s %s %s", &start, &end, buf, tmp, tmp, tmp, path);
if (buf[2] == 'x') {
if (buf[0] == 'r') {
uint8_t *buffer;
buffer = (uint8_t *) start;
for (int i = 0; i < pElfSectArr->execSectionCount; i++) {
if (start + pElfSectArr->offset[i] + pElfSectArr->memsize[i] > end) {
if (pElfSectArr->startAddrinMem != 0) {
buffer = (uint8_t *) pElfSectArr->startAddrinMem;
pElfSectArr->startAddrinMem = 0;
break;
}
}
}
for (int i = 0; i < pElfSectArr->execSectionCount; i++) {
auto begin = (void *) (buffer + pElfSectArr->offset[i]);
unsigned long size = pElfSectArr->memsize[i];
LOGI("%s [%p] size ->[%lu]", libraryName, begin, size);
//MPROTECT((size_t)begin, size, MEMORY_R);
unsigned long output = checksum(begin, size);
LOGI("%s checksum:[%ld][%ld]", libraryName, output, pElfSectArr->checksum[i])
if (output != pElfSectArr->checksum[i]) {
//doesn't match the local .so checksum
return 1;
}
}
}
return 0;
} else {
if (buf[0] == 'r') {
pElfSectArr->startAddrinMem = start;
}
}
return 0;
}
Is this necessarily a bulletproof detection scheme? Not really — there are plenty of countermeasures. For example, hide the maps entry so it becomes anonymous memory, so the maps scan can’t find it (e.g. the libc item above).
This can follow map_hide from Riru — code path:
https://github.com/RikkaApps/Riru/blob/master/riru/src/main/cpp/hide_utils.cpp
But is that approach necessarily safe?
Actually, no. In that code I found a gap that can serve as a detection hook: some hardening vendors don’t just iterate maps entries — they also iterate anonymous memory and check its magic. The
.somagic iself; if the magic matches, they parse it as maps and iterate.So you can improve on the original: after turning the memory anonymous, wipe the first four bytes — the magic — and then restore the original permissions, to dodge the memory check.
Sandbox detection
The core detection logic lives in an ISO thread. You can configure a service and use the following attributes inside it:
<service
android:name=".ZhenxiServer"
android:isolatedProcess="true"
android:useAppZygote="true"
/>
This service is genuinely nasty — most sandboxes skip over this thread.
What’s an ISO thread? Think of it as an independent, secure process that can only communicate with the outside via IPC. useAppZygote makes it run in the Zygote — which happens extremely early. So early that even libart.so isn’t loaded yet. That means the detection process can only call raw libc functions, not any ART-related functions.
To expand on the two attributes: android:isolatedProcess="true" runs the service in a special isolated process with no permissions — a private process that doesn’t share the Linux UID or permissions with the rest of the app. Since it has no permissions, it can’t access most device resources except via IPC to other processes. This raises security: even if the isolated process is compromised, the attacker can’t reach the app’s other resources. android:useAppZygote="true" runs the service in a dedicated AppZygote process — a lightweight process preloaded with the app’s shared resources and some classes, so services start faster, at the cost of higher memory use.
On to Hunter’s detection approach.
Detecting extra-thread PIDs
The idea is to check whether there’s any PID under /proc besides the main process — a normal launch should only have one main process.
But sandboxes spawn other processes before startup, so this can be bypassed. I’ll cover the countermeasure and bypass later.
First the detection approach and principle. replaceSecInsns is a helper I wrote myself — worried that opendir is hooked, I swap in the on-disk instructions every time instead of executing the in-memory ones.
auto orig_opendir = reinterpret_cast<DIR *(*)
(const char *)>(replaceSecInsns(getlibcPath(), "opendir"));
DIR *pdr = orig_opendir("/proc");
if (pdr == nullptr) {
return getItemData(env, "program error, abandon after retry",
nullptr, true, RISK_LEAVE_DEADLY, TAG_SANDBOX);
}
auto orig_ator = reinterpret_cast<struct dirent *(*)(DIR *)>
(replaceSecInsns(getlibcPath(), "readdir"));
dirent *read_ptr;
//before app startup, check all the app's processes for any process different from main
while ((read_ptr = orig_ator(pdr)) != nullptr) {
long procPid = strtol(read_ptr->d_name, nullptr, 10);
//LOG(INFO) << "find /proc/ child dir " << procPid;
//opened successfully && found a pid different from the main process id
if (procPid && procPid != getpid()) {
auto title = string("detected APK inside a sandbox");
char buff[200];
getNameByPid((pid_t) procPid, buff);
LOG(ERROR) << ">>>>> FIND OTHER THREAD SANDBOX " << procPid << " " << buff;
auto &data =
string("current thread main process PID(").append(to_string(getpid())).append(")").append("\n").
append("abnormal pid -> ").append(to_string(procPid)).
append("(").append("pid name: ").append(buff).append(")");
//there may be multiple abnormal pids, not just one
getItemData(env, title, data.c_str(), false, RISK_LEAVE_WARN, TAG_SANDBOX);
}
}
closedir(pdr);
Countermeasures
Bypassing this is simple. opendir bottoms out in getdents64 / getdents; if you want to hide a file, just bypass it and point to the next entry.
Code from proot:
case SC_getdents64:
case SC_getdents: {
/* get the result of the syscall, which is the number of bytes read by getdents */
unsigned int res = peek_reg(tracee, CURRENT, SYSARG_RESULT);
if (res <= 0) {
break;
}
/* get the system call arguments */
word_t orig_start = peek_reg(tracee, CURRENT, SYSARG_2);
unsigned int count = peek_reg(tracee, CURRENT, SYSARG_3);
char orig[count];
char path[PATH_MAX];
int status = readlink_proc_pid_fd(tracee->pid,
(int) peek_reg(tracee, ORIGINAL, SYSARG_1), path);
if (status < 0) {
break;
}
//not a bound path, don't handle; a check should be added here.
//if this path has no handling path, return.
//currently only proc needs handling, to hide the debug thread.
// if(!belongs_to_guestfs(tracee, path))
// return 0;
if (!StringUtils::containsInsensitive(path,"proc")){
break;
}
/* retrieve the data from getdents */
status = read_data(tracee, orig, orig_start, res);
if (status < 0) {
break;
}
/* allocate a space for the copy of the data we want */
char copy[count];
/* curr will hold the current struct we're examining */
struct linux_dirent64 *curr64;
struct linux_dirent *curr32;
/* pos keeps track of where in memory the copy is */
char *pos = copy;
/* ptr keeps track of where in memory the original is */
char *ptr = orig;
/* nleft keeps track of how many bytes we've saved */
unsigned int nleft = 0;
/* while we're still within the memory allowed */
if (get_sysnum(tracee, ORIGINAL) == SC_getdents64) {
while (ptr < orig + res) {
/* get the current struct */
curr64 = (struct linux_dirent64 *) ptr;
/* if the name does not match a given prefix
*
* if the directory entry's name does not start with HIDDEN_PREFIX,
* i.e. hasprefix(HIDDEN_PREFIX, curr64->d_name) returns false,
* the entry is kept in the result; otherwise it is ignored, i.e. hidden.
* */
//if (!hasprefix(HIDDEN_PREFIX, curr64->d_name)) {
if (!isRuntimeHideDir(tracee->pid,curr64->d_name, tracer_pc(tracee), tracer_lr(tracee))) {
/* copy the information */
mybcopy(ptr, pos, curr64->d_reclen);
/* move the pos and nleft */
pos += curr64->d_reclen;
nleft += curr64->d_reclen;
}
/* move to the next linux_dirent
* hide this directory, point to the next file
* */
ptr += curr64->d_reclen;
}
} else {
while (ptr < orig + res) {
/* get the current struct */
curr32 = (struct linux_dirent *) ptr;
/* if the name does not match a given prefix */
// if (!hasprefix(HIDDEN_PREFIX, curr64->d_name)) {
if (!isRuntimeHideDir(tracee->pid,curr32->d_name, tracer_pc(tracee), tracer_lr(tracee))) {
/* copy the information */
mybcopy(ptr, pos, curr32->d_reclen);
/* move the pos and nleft */
pos += curr32->d_reclen;
nleft += curr32->d_reclen;
}
/* move to the next linux_dirent */
ptr += curr32->d_reclen;
}
}
/* If there is nothing left
* this folder itself has nothing; handle later
* */
if (!nleft) {
// /* call getdents again */
// if (get_sysnum(tracee, ORIGINAL) == PR_getdents64)
// register_chained_syscall(tracee, PR_getdents64, peek_reg(tracee, ORIGINAL, SYSARG_1), orig_start, count, 0, 0, 0);
// else
// register_chained_syscall(tracee, PR_getdents, peek_reg(tracee, ORIGINAL, SYSARG_1), orig_start, count, 0, 0, 0);
} else {
/* copy the data back into the register */
status = write_data(tracee, orig_start, copy, nleft);
if (status < 0) {
break;
}
/* update the return value to match the data */
poke_reg(tracee, SYSARG_RESULT, nleft);
}
break;
}
Running the ps command
This checks for other processes by running ps -ef.
if (get_sdk_level() > ANDROID_Q) {
//check process
auto orig_popen = reinterpret_cast<FILE *(*)(const char *,
const char *)>(replaceSecInsns(getlibcPath(),
"popen"));
FILE *file = orig_popen("ps -ef", "r");
if (file == nullptr) {
return getItemData(env, "program error, abandon after retry",
"ps error", true, 3, TAG_SANDBOX);
}
char buf[0x1000];
string buffStr;
uint size = 0;
// get process count size
while (fgets(buf, sizeof(buf), file)) {
buffStr.append(buf).append("\n");
//exclude ++
if(!StringUtils::contains(buf,"hunter")){
size++;
LOG(INFO) << "ps -ef match -> %s " << buf;
}
}
if (size > 2) {
//UID PID PPID C STIME TTY TIME CMD
//u0_a531 6187 885 72 10:27:53 ? 00:00:00 com.zhenxi.hunter
//u0_a531 6236 6187 0 10:27:53 ? 00:00:00 ps -ef
pclose(file);
return getItemData(env, "detected APK inside a sandbox (abnormal thread)",
buffStr.c_str(), false, RISK_LEAVE_DEADLY, TAG_SANDBOX);
}
LOG(ERROR) << ">>>>> NOT FIND SANDBOX ";
pclose(file);
}
Countermeasures
Fake a normal file, and when ps -ef runs, swap the command for a cat of your own file.
Memory Choose
This detects the number of Activity or Application instances in memory. A normal launch only has our own Application; a sandbox, because of pre-launch, has others. Also a good signal.
public static ListItemBean checkSandbox() {
ArrayList<Object> choose;
try {
choose = ChooseUtils.choose(Activity.class, true);
} catch (Throwable e) {
return null;
}
if (choose != null) {
ArrayList<Object> list = new ArrayList<>();
//remove our Activity, add the other activity instances to the list
for(Object activty:choose){
String name = activty.getClass().getName();
if(!name.equals(MainActivity.class.getName())&&
!name.equals(ShareActivity.class.getName())&&
!name.equals(FeedbackActivity.class.getName())
){
list.add(activty);
}
}
//check whether the list count is greater than 1
if (list.size() >= 1) {
ListItemBean item = new ListItemBean(
"detected APK inside a sandbox",
ListItemBean.RiskLeave.Deadly);
for (Object obj : list) {
item.putData(obj.getClass().getName());
}
return item;
}
}
return null;
}
Detecting Google & LineageOS
On devices where Google (AOSP) and LineageOS are uncommon, they’re usually a sign of a custom-flashed ROM — typically flagged as fraud-operation hardware. Such devices normally shouldn’t appear in OEM-ROM-dominated markets; when they do, they get tagged as fraud. You can just get the vendor via Build.MODEL.
One more detail: LineageOS has a characteristic file, /system/addon.d. Like this:
private static void isLineageOs(ArrayList<ListItemBean> items) {
//lineage detection
String display = NativeEngine.getZhenxiInfoH("ro.build.display.id");
if (display.toLowerCase(Locale.ROOT).contains("lineage")) {
items.add(
new ListItemBean("current device is a Lineage system",
ListItemBean.RiskLeave.Warn,
"may be a custom ROM, device not trustworthy!"
));
return;
}
//lineage characteristic file
File file = new File("/system/addon.d");
if (file.exists()) {
items.add(
new ListItemBean("current device is a Lineage system",
ListItemBean.RiskLeave.Warn,
"may be a custom ROM, device not trustworthy!"
));
return;
}
}
One more detail here:
Directly reading ro.build.display.id can be hooked, so my suggestion is to parse the config file directly.
The core parsing method for the /dev/__properties__/u:object_r: file:
public PropArea(String area) throws IOException {
area = "/dev/__properties__/u:object_r:" + area + ":s0";
File file = new File(area);
if (!file.isFile()) throw new FileNotFoundException("Not a file: " + area);
long size = file.length();
if (size <= 0 || size >= 0x7fffffffL) throw new IllegalArgumentException("invalid file size " + size);
try (FileChannel channel = new FileInputStream(area).getChannel()) {
data = channel.map(FileChannel.MapMode.READ_ONLY, 0, size).order(ByteOrder.nativeOrder());
}
byteUsed = data.getInt();
data.getInt(); // serial
int magic = data.getInt();
if (magic != PROP_AREA_MAGIC) throw new IllegalArgumentException("Bad file magic: " + magic);
int version = data.getInt();
if (version != PROP_AREA_VERSION) throw new IllegalArgumentException("Bad area versin: " + version);
data.position(data.position() + 28); // reserved
}
public List<String> findPossibleValues(String name) {
List<String> values ;
try {
// atomic_uint_least32_t serial;
// union {
// char value[PROP_VALUE_MAX];
// struct {
// char error_message[kLongLegacyErrorBufferSize];
// uint32_t offset;
// } long_property;
// };
final int LONG_PROP_FLAG = 1 << 16;
final int PROP_VALUE_MAX = 92;
final int VALUE_OFFSET = 4;
final int NAME_OFFSET = VALUE_OFFSET + 92;
values = new ArrayList<>(2);
findFromBuffer(data.slice(), name.getBytes(StandardCharsets.UTF_8), (buffer, offset) -> {
if (offset < NAME_OFFSET) return;
int base = offset - NAME_OFFSET;
int serial = buffer.getInt(base);
if ((serial & LONG_PROP_FLAG) != 0) return; // Long properties are not supported
values.add(toString(buffer, base + VALUE_OFFSET, PROP_VALUE_MAX));
});
CLog.i("Found " + name + "=" + values);
return values;
} catch (Throwable e) {
CLog.e("findPossibleValues get error "+ name+" "+e);
return null;
}
}
Detecting IDA and anti-debug
There are too many techniques here — I know at least ten. For example, add a timestamp between core method A and method B; if A-to-B takes more than 5 seconds, treat it as being debugged.
Checking the debug state is also a good option; you can use an ISO thread to check the debug state, to avoid being ptrace’d.
service list
You can also run service list to get the service list and check whether it contains the frida keyword.
public static CheckServerRet checkServer(Context context, String[] fit) {
CheckServerRet ret = new CheckServerRet();
ArrayList<String> list = ret.list;
BufferedReader reader = null;
Process process;
try {
// run the service list command
process = Runtime.getRuntime().exec("service list");
// read the command output
reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
if (line.length() > 1) {
for (String fitItem : fit) {
//case-insensitive comparison
if (line.toLowerCase().contains(fitItem.toLowerCase())) {
list.add(line);
//CLog.e("checkServer find item is match " + Arrays.toString(fit) + " " + line);
break;
} else {
//reaching here means we got something; the return isn't empty
ret.isSuccess = true;
}
}
}
}
// close the stream
reader.close();
// wait for the command to finish
int exitValue = process.waitFor();
CLog.e("checkServer ret mark " + Arrays.toString(fit) + " " + ret);
return ret;
} catch (IOException | InterruptedException e) {
e.printStackTrace();
if (reader != null) {
try {
reader.close();
} catch (IOException ignored) {
}
}
}
return ret;
}
Afterword
If you use code from this post, please credit the source. Thanks for understanding.
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