HuskyDG

Detect Magisk and Xposed

Post by canyie

Translated to English

Not long ago, developers Rikka & vvb2060 launched an environmental detection application Momo , which smashed various anti-detection methods that people have always trusted. Below I will analyze this may be the strongest environmental detection application in history through some of the open source code.

Detect Magisk (Documentation by vvb2060)

MagiskHide

The core of Magisk Hide is the mount namespace. After magiskd waits for the mount namespace of the zygote child process to be separated from the parent process, it unmounts all Magisk mounts to the child process. Due to the nature of mount namespaces, the unmount operation will affect the child process of this child process, but not zygote. zygote is the parent process of all application processes, if zygote is handled by Magisk Hide, all applications will lose root. There is a parameter when zygote starts a new process , when it is Zygote.MOUNT_EXTERNAL_NONE, the new process does not mount the storage space, and there is no mount namespace separation step.

There are two cases, one is that the read storage space op of the application appops is ignored, and the other is that the process is an isolated process. The former application itself cannot be operated, and the latter has been supported since Android 4.1. In addition, an interesting feature of the isolated process is the random UID, which is different every time it runs. This interesting feature causes Magisk Hide to skip the process and not handle it before detecting the mount namespace.

Detect Magisk modules

Although the Magisk module can be hidden on the file system, the modified content has been loaded into the process memory, which can be found by checking the maps of the process. The data displayed by maps contains the device on which the loaded file is located. The Magisk module will cause the path of some files to be in the system partition or vendor partition, but the displayed device location is the data partition.

Detect MagiskSU

Under normal circumstances, applications cannot connect to sockets not established by themselves, but Magisk modified SELinux. All applications can connect to the socket of the magisk domain. Each Magisk su process will establish a socket and try to connect all sockets. The number of sockets that are not rejected by SELinux is the number of su processes. The reliability of this detection method depends entirely on the strictness of SELinux rules, and Android versions that are too low or too high will cause problems.

Test SELinux Policy

SU software must pay attention to the following principles when modifying the SELinux policy, otherwise there will be security loopholes and ways to be detected.

The source domain and the target domain, one of which is the custom domain of the su program, it is safe to add rules; Both parties are non-application domain and need to have a reasonable and necessary reason; One side is the application domain, and adding rules is very dangerous. If it is the origin domain, the addition should be rejected.

Detect init.rc modifications

Random only works if it cannot be traversed. If it can be traversed, statistical methods can be used to find exactly what is different each time.

Anti-Magisk Hide

First analyze the principle of Magisk Hide:

static  void  new_zygote ( int pid)  { struct stat st ; if (read_ns(pid, &st)) return ;
      
    
        

    auto it = zygote_map.find(pid); if (it != zygote_map.end()) { // Update namespace info         it->second = st; return ;     }
    
        

        


    LOGD( "proc_monitor: ptrace zygote PID=[%d]\n" , pid); 
    zygote_map[pid] = st;

    xptrace(PTRACE_ATTACH, pid);

    waitpid(pid, nullptr , __WALL | __WNOTHREAD); 
    xptrace(PTRACE_SETOPTIONS, pid, nullptr , 
            PTRACE_O_TRACEFORK | PTRACE_O_TRACEVFORK | PTRACE_O_TRACEEXIT); 
    xptrace(PTRACE_CONT, pid); 
}

void  proc_monitor ()  { // omit...
    

    // First try find existing zygotes
     check_zygote();

    for ( int status;;) { const int pid = waitpid( -1 , &status, __WALL | __WNOTHREAD); if (pid < 0 ) { // omitted...         }
         
        
            


        if (!WIFSTOPPED(status) /* Ignore if not ptrace-stop */ ) 
            DETACH_AND_CONT;

        int event = WEVENT(status); int signal = WSTOPSIG(status);
        

        if (signal == SIGTRAP && event) { unsigned long msg;             xptrace(PTRACE_GETEVENTMSG, pid, nullptr , &msg); if (zygote_map.count(pid)) { // Zygote event switch (event) { case PTRACE_EVENT_FORK: case PTRACE_EVENT_VFORK:                         PTRACE_LOG( "zygote forked: [%lu]\n" , msg);                         attaches[msg] = true ; break ; // ...                 }             } else { switch (event) { case PTRACE_EVENT_CLONE:
             

            
                
                
                    
                    


                        
                    


                
                    
                        PTRACE_LOG( "create new threads: [%lu]\n" , msg); if (attaches[pid] && check_pid(pid)) // here will actually hide magisk continue ;                 xptrace(PTRACE_CONT, pid);             } // ...         } // ...     } }
                        
                            
                        break;
                    // ...
                }
            }
            xptrace(PTRACE_CONT, pid);
        } else if (signal == SIGSTOP) {
            if (!attaches[pid]) {
                // Double check if this is actually a process
                attaches[pid] = is_process(pid);
            }
            if (attaches[pid]) {
                // This is a process, continue monitoring
                PTRACE_LOG("SIGSTOP from child\n");
                xptrace(PTRACE_SETOPTIONS, pid, nullptr,
                        PTRACE_O_TRACECLONE | PTRACE_O_TRACEEXEC | PTRACE_O_TRACEEXIT);

It can be seen that magisk hide traces all zygotes through the ptrace mechanism, and cat /proc/<pid>/statusour findings can also be confirmed by seeing the TracerPid. When the first thread of the child process is created, it is actually hidden.

static  bool  check_pid ( int pid )  { char path[ 128 ]; char cmdline[ 1024 ]; struct stat st ;
    
    
      

    sprintf (path, "/proc/%d/cmdline" , pid); if ( auto f = open_file(path, "re" )) {         fgets(cmdline, sizeof (cmdline), f.get());     } else { // Process died unexpectedly, ignore         detach_pid(pid); return true ;     } if (cmdline == "zygote" sv || cmdline == "zygote32" sv || cmdline == "zygote64" sv ||         cmdline == " usap32" sv || cmdline == "usap64"sv) return false ;
    


        

         

    
    

         

    // Judge whether hide is needed by uid and process name if (!is_hide_target(uid, cmdline)) goto not_target;
    
        

    // If the namespace is not detached, unmounting will affect zygote and thus all processes started later, skip
     read_ns(pid, &st); for ( auto &zit : zygote_map) { if (zit.second.st_ino == st .st_ino &&             zit.second.st_dev == st.st_dev) { // ns not separated, abort             LOGW( "proc_monitor: skip [%s] PID=[%d] UID=[%d]\n" , cmdline, pid, uid); goto not_target;         }     }
    
        

            

            



    // Detach but the process should still remain stopped // The hide daemon will resume the process after hiding it     LOGI( "proc_monitor: [%s] PID=[%d] UID=[%d]\n" , cmdline, pid , uid);     detach_pid(pid, SIGSTOP);     hide_daemon(pid); return true ;
    



     

not_target: 
    PTRACE_LOG( "[%s] is not our target\n" , cmdline); 
    detach_pid(pid); return true ; }
     

hide_daemon will fork a new process, setns to the namespace of the target process, and then uninstall everything that has been modified by magisk. Note that there is an if judgment in it. If the namespace is not separated, unmounting will affect zygote and thus all the processes that are started later, then skip it directly. That’s Magisk Hide’s first question.

In the introduction to the implementation details of MagiskDetector, it is stated that there are two situations that meet:

One is that the read storage space op of the application appops is ignored, and the other is that the process is an isolated process.

The isolated process here refers to android:isolatedProcess="true" the service. Moreover, there is also a (private) interesting (goods) thing on Android 10 called App Zygote. There is almost no description for this thing. The only document is ZygotePreload , which feels more like a backdoor opened by Google to Chrome. Ahem, off topic, this thing runs in a separate process and doesn’t separate namespaces.

There are currently two known solutions to this problem. The first is Magisk Lite , which directly unmount zygote process instead of applying it, but this method will destroy many existing modules; the other is to use code injection to forcibly separate namespaces , the typical solution is Riru-Unshare.

Ok, this question is over, the next one~~

In the above judgment code, the read process name part is /proc/<pid>/cmdline judged by reading; in fact, the length of the content of this file is limited! This means that when the configured process name is too long, the process name read by Magisk will not match, thus skipping the process! This is the rationale for Issue#3997 . Magisk has made a temporary fix for this: if the prefix matches, it is directly considered to be the target process to hide.

Is it finished? No. The next problem is when adding the process to the database:

static  int  add_list ( const  char *pkg, const  char *proc)  { if (proc[ 0 ] == '\0' )        proc = pkg;
    


    if (!validate(pkg) || !validate(proc)) return HIDE_INVALID_PKG; // ... }
        
    
    


static  bool  validate ( const  char *s)  { if ( strcmp (s, ISOLATED_MAGIC) == 0 ) return true ; bool dot = false ; for ( char c; (c = *s); ++s) { if (( c >= 'A' && c <= 'Z'
    
         
    
    
        ) || (c >= 'a' && c <= 'z') ||
            (c >= '0' && c <= '9') || c == '_' || c == ':') {
            continue;
        }
        if (c == '.') {
            dot = true;
            continue;
        }
        return false;
    }
    return dot;
}

The package name and process name will be checked here. If it contains illegal characters or no dots, it is considered to be an invalid process. Android has strict regulations on package names, and android:process also regulations on process names through configuration. It seems that it can’t be a demon? However the problem does occur: Issue#4176 .

After inspection, the application uses an isolated process to check Magisk, but the difference is that its service class name contains illegal characters (Java does not limit class names), and Android 10+, the system will append the class name to the name of the isolated process ( https://t.me/vvb2060Channel/441), causing the check to fail. The solution is also very simple, just modify this validate.

Detect the modification of init.rc: Random only works if it cannot be traversed. If it can be traversed, statistical methods can be used to find exactly what is different each time.

This sentence looks a bit confusing, just look at the Magisk source code. init/rootdir.cpp:

// Inject Magisk rc scripts 
char pfd_svc[ 16 ], ls_svc[ 16 ], bc_svc[ 16 ]; 
gen_rand_str(pfd_svc, sizeof (pfd_svc)); 
gen_rand_str(ls_svc, sizeof (ls_svc)); 
gen_rand_str(bc_svc, sizeof (bc_svc) ); 
LOGD( "Inject magisk services: [%s] [%s] [%s]\n" , pfd_svc, ls_svc, bc_svc); 
fprintf (rc, MAGISK_RC, tmp_dir, pfd_svc, ls_svc, bc_svc);

Magisk will inject three of its own services into init.rc at startup to receive events such as post-fs-data; the names of these three services are randomized, and init will actually go to the system properties. Add init.svc.<service name> property like this, with a value of running or stopped, to tell other processes the status of the service. MagiskDetector takes advantage of this mechanism, traverses the system properties to record all service names, and then knows whether any service names have changed after the user restarts.

Detect SELinux rules

magiskpolicy/rules.cpp

// Allow these processes to access MagiskSU 
const  char *clients[] { "init" , "shell" , "appdomain" , "zygote" }; 
for ( auto type : clients) { if (!exists(type)) continue ;     allow(type, SEPOL_PROC_DOMAIN, "unix_stream_socket" , "connectto" );     allow(type, SEPOL_PROC_DOMAIN, "unix_stream_socket" , "getopt" );
    
        



    // Allow termios ioctl const char *pts[] { "devpts" , "untrusted_app_devpts" }; for ( auto pts_type : pts) {         allow(type, pts_type, "chr_file" , "ioctl" ); if (db->policyvers >= POLICYDB_VERSION_XPERMS_IOCTL)             allowxperm(type, pts_type, "chr_file" , "0x5400-0x54FF" );     } }
     
    

        

Since Magisk allows some ioctls, it will be detected. As a workaround, update to Android 8+ & Magisk 21+ and Magisk will automatically use the new rules.

At the same time, it’s not just Magisk’s own pot, the wrong use of SELinux may also cause Magisk to be easily detected. Example:

type(SEPOL_PROC_DOMAIN, "domain" ); 
type(SEPOL_FILE_TYPE, "file_type" );

Two magisk own domains have been added, which seems to be no problem. However, if the user sets selinux to permissive mode, the app can proceed selinux_check_access() (the interface corresponding to the java layer is SELinux.checkSELinuxAccess()), and if it is allowed, it means that this domain exists. => Magisk installed.

Not only the permissive mode, if you add allow appdomain xxx relabelfrom such rules and don’t have them deny appdomain magisk_file relabelto, the app may chcon the context of a file magisk_file, and then by trying to manipulate the file to determine whether it is rejected, you can test whether there is this domain in the system. .

SELinux is an important part of Android’s security mechanism, and it is strongly discouraged to set it to permissive mode or ignore neverallow to add rules at will.

Off topic: Detecting magiskd

Although MagiskDetector does not use this method, it is a bit interesting, so I can talk about it. Before Android 7, /proc was no limit, and anyone could traverse to get the process list; in 7, it was added hidepid=2, but not all manufacturers kept up; for these devices, just scan to see if there is magiskda process called Make sure there is no magisk.

Xposed

Detect Xposed

The original Xposed framework added its own classes to the bootclasspath, which made it easy for anyone to find them. After that, everyone chose to isolate the classloader, making detection less easy; however, as long as it exists in memory, it can be found. The principle of XposedDetector is very simple. Through an internal interface of art (VisitRoots), find all ClassLoaders in the heap, and then try them one by one. At present, lsp, edxp, dreamland, etc. are only loaded in the target application to prevent accidental injury. Of course it’s okay to hook this function, but we don’t want to play this cat-and-mouse game, we can only ensure that the environment of the non-target application is not modified.

Anti-Xposed Hook

The method of XposedDetector is that through the above method, all classes in the current process can be found. According to this method, disableHooksyou sHookedMethodCallbackscan find XposedBridge and change the and .

In fact, there are many other methods: in addition to the original xposed and edxposed before 0.5, other frameworks basically ignore the isolation process directly, and can put important things in the isolation process.

A method through Xposed hook will eventually come to this method:

public  static XC_MethodHook.Unhook hookMethod (Member hookMethod , XC_MethodHook callback)  { // ... else if (hookMethod.getDeclaringClass().isInterface()) { throw new IllegalArgumentException( "Cannot hook interfaces: " + hookMethod.toString()) ; 	} }
    
     
		 

This check is problematic after Android 7, because Android 7 supports a Java 8 feature called interface default method. The interface can no longer only “talk empty words”, but can also have its own method body, and as long as the implementation class is not rewritten, the declaring class of the method is the interface, and Xposed will throw an exception when hooking.

The basic principle of Xposed and various implementations is to do something with entry_point_from_quick_compiled_code_this member, you can directly modify this member or you can inline hook; and there is a “universal entry” in art: interpretation execution entry, by setting the method entry to interpretation execution can make Xposed The hook is invalid, but not for Frida’s modified interpreter.

Blog content is under the Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0) license

The permalink to this article is: https://blog.canyie.top/2021/05/01/anti-magisk-xposed/