Android安全防護/檢查root/檢查Xposed/反除錯/應用多開/模擬器檢測(持續更新)

lamster2018發表於2018-07-13
轉載請註明出處,轉載時請不要抹去原始連結。

程式碼已上傳git,歡迎star/fork/issue
https://github.com/lamster2018/EasyProtector
複製程式碼

文章目錄

  • 食用方法
  • root許可權檢查
  • Xposed框架檢查
  • 應用多開檢查
  • 反除錯方案
  • 模擬器檢測
  • TODO

使用方法

implementation 'com.lahm.library:easy-protector-release:latest.release'

https://github.com/lamster2018/EasyProtector

demo


root許可權檢查

開發者會使用諸如xposed,cydiasubstrate的框架進行hook操作,前提是擁有root許可權。

關於root的原理,請參考《Android Root原理分析及防Root新思路》 blog.csdn.net/hsluoyc/art…

簡單來說就是去拿『ro.secure』的值做判斷, ro.secure值為1,adb許可權降為shell,則認為沒有root許可權。 但是單純的判斷該值是沒法檢查userdebug版本的root許可權

結合《Android判斷裝置是User版本還是Eng版本》 https://www.jianshu.com/p/7407cf6c34bd 其實還有一個值ro.debuggable

ro.secure=0 ro.secure=1
ro.debuggable=0 / user
ro.debuggable=1 eng/userdebug* /

*暫無userdebug的機器,不知道ro.secure是否為1,埋坑 userdebug 的debuggable值未知,secure為0.

實際上通過『ro.debuggable』值判斷更準確 直接讀取ro.secure值足夠了

下一步再檢驗是否存在su檔案 方案來自《Android檢查手機是否被root》 www.jianshu.com/p/f9f39704e…

通過檢查su是否存在,su是否可執行,綜合判斷root許可權。

*EasyProtectorLib.checkIsRoot()*的內部實現

    public boolean isRoot() {
        int secureProp = getroSecureProp();
        if (secureProp == 0)//eng/userdebug版本,自帶root許可權
            return true;
        else return isSUExist();//user版本,繼續查su檔案
    }

    private int getroSecureProp() {
        int secureProp;
        String roSecureObj = CommandUtil.getSingleInstance().getProperty("ro.secure");
        if (roSecureObj == null) secureProp = 1;
        else {
            if ("0".equals(roSecureObj)) secureProp = 0;
            else secureProp = 1;
        }
        return secureProp;
    }

    private boolean isSUExist() {
        File file = null;
        String[] paths = {"/sbin/su",
                "/system/bin/su",
                "/system/xbin/su",
                "/data/local/xbin/su",
                "/data/local/bin/su",
                "/system/sd/xbin/su",
                "/system/bin/failsafe/su",
                "/data/local/su"};
        for (String path : paths) {
            file = new File(path);
            if (file.exists()) return true;//可以繼續做可執行判斷
        }
        return false;
    }
複製程式碼

Xposed框架檢查

原理請參考我的《反Xposed方案學習筆記》 www.jianshu.com/p/ee0062468…

所有的方案迴歸到一點:判斷xposed的包是否存在。 1.是通過主動丟擲異常查棧資訊; 2.是主動反射呼叫。

當檢測到xp框架存在時,我們先行呼叫xp方法,關閉xp框架達到反制的目的。

EasyProtectorLib.checkIsXposedExist()_內部實現

    private static final String XPOSED_HELPERS = "de.robv.android.xposed.XposedHelpers";
    private static final String XPOSED_BRIDGE = "de.robv.android.xposed.XposedBridge";

    //手動丟擲異常,檢查堆疊資訊是否有xp框架包
    public boolean isEposedExistByThrow() {
        try {
            throw new Exception("gg");
        } catch (Exception e) {
            for (StackTraceElement stackTraceElement : e.getStackTrace()) {
                if (stackTraceElement.getClassName().contains(XPOSED_BRIDGE)) return true;
            }
            return false;
        }
    }

    //檢查xposed包是否存在
    public boolean isXposedExists() {
        try {
            Object xpHelperObj = ClassLoader
                    .getSystemClassLoader()
                    .loadClass(XPOSED_HELPERS)
                    .newInstance();
        } catch (InstantiationException e) {
            e.printStackTrace();
            return true;
        } catch (IllegalAccessException e) {
            //實測debug跑到這裡報異常
            e.printStackTrace();
            return true;
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
            return false;
        }

        try {
            Object xpBridgeObj = ClassLoader
                    .getSystemClassLoader()
                    .loadClass(XPOSED_BRIDGE)
                    .newInstance();
        } catch (InstantiationException e) {
            e.printStackTrace();
            return true;
        } catch (IllegalAccessException e) {
            //實測debug跑到這裡報異常
            e.printStackTrace();
            return true;
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

    //嘗試關閉xp的全域性開關,親測可用
    public boolean tryShutdownXposed() {
        if (isEposedExistByThrow()) {
            Field xpdisabledHooks = null;
            try {
                xpdisabledHooks = ClassLoader.getSystemClassLoader()
                        .loadClass(XPOSED_BRIDGE)
                        .getDeclaredField("disableHooks");
                xpdisabledHooks.setAccessible(true);
                xpdisabledHooks.set(null, Boolean.TRUE);
                return true;
            } catch (NoSuchFieldException e) {
                e.printStackTrace();
                return false;
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
                return false;
            } catch (IllegalAccessException e) {
                e.printStackTrace();
                return false;
            }
        } else return true;
    }
複製程式碼

多開軟體檢測

多開軟體的檢測方案這裡提供5種,首先4種來自 《Android多開/分身檢測》 blog.darkness463.top/2018/05/04/…

《Android虛擬機器多開檢測》

www.jianshu.com/p/216d65d99…

這裡提供程式碼整理,一鍵呼叫,

        VirtualApkCheckUtil.getSingleInstance().checkByPrivateFilePath(this);
        VirtualApkCheckUtil.getSingleInstance().checkByOriginApkPackageName(this);
        VirtualApkCheckUtil.getSingleInstance().checkByHasSameUid();
        VirtualApkCheckUtil.getSingleInstance().checkByMultiApkPackageName();
        VirtualApkCheckUtil.getSingleInstance().checkByPortListening(getPackageName(),callback);
複製程式碼

第5種來自我同事的啟發,目前最好用的就是這種,我起名叫埠檢測法,具體思路已經單獨成文見 《一行程式碼幫你檢測Android多開軟體》 www.jianshu.com/p/65c841749…

測試情況

測試機器/多開軟體* 多開分身6.9 平行空間4.0.8389 雙開助手3.8.4 分身大師2.5.1 VirtualXP0.11.2 Virtual App *
紅米3S/Android6.0/原生eng XXXOO OXOOO OXOOO XOOOO XXXOO XXXOO
華為P9/Android7.0/EUI 5.0 root XXXXO OXOXO OXOXO XOOXO XXXXO XXXOO
小米MIX2/Android8.0/MIUI穩定版9.5 XXXXO OXOXO OXOXO XOOXO XXXXO XXXOO
一加5T/Android8.1/氫OS 5.1 穩定版 XXXXO OXOXO OXOXO XOOXO XXXXO XXXOO

*測試方案順序如下12345,測試結果X代表未能檢測O成功檢測多開

*virtual app測試版本是git開源版,商用版已經修復uid的問題

1.檔案路徑檢測

Android安全防護/檢查root/檢查Xposed/反除錯/應用多開/模擬器檢測(持續更新)

public boolean checkByPrivateFilePath(Context context) {
        String path = context.getFilesDir().getPath();
        for (String virtualPkg : virtualPkgs) {
            if (path.contains(virtualPkg)) return true;
        }
        return false;
    }
複製程式碼

2.應用列表檢測

Android安全防護/檢查root/檢查Xposed/反除錯/應用多開/模擬器檢測(持續更新)
簡單來說,多開app把原始app克隆了,並讓自己的包名跟原始app一樣,當使用克隆app時,會檢測到原始app的包名會和多開app包名一樣(就是有兩個一樣的包名)

    public boolean checkByOriginApkPackageName(Context context) {
        try {
            if (context == null)  return false;
            int count = 0;
            String packageName = context.getPackageName();
            PackageManager pm = context.getPackageManager();
            List<PackageInfo> pkgs = pm.getInstalledPackages(0);
            for (PackageInfo info : pkgs) {
                if (packageName.equals(info.packageName)) {
                    count++;
                }
            }
            return count > 1;
        } catch (Exception ignore) {
        }
        return false;
    }
複製程式碼

3.maps檢測

需要維護多款分身包名

    public boolean checkByMultiApkPackageName() {
        BufferedReader bufr = null;
        try {
            bufr = new BufferedReader(new FileReader("/proc/self/maps"));
            String line;
            while ((line = bufr.readLine()) != null) {
                for (String pkg : virtualPkgs) {
                    if (line.contains(pkg)) {
                        return true;
                    }
                }
            }
        } catch (Exception ignore) {

        } finally {
            if (bufr != null) {
                try {
                    bufr.close();
                } catch (IOException e) {

                }
            }
        }
        return false;
    }
複製程式碼

4.ps檢測

Android安全防護/檢查root/檢查Xposed/反除錯/應用多開/模擬器檢測(持續更新)
簡單來說,檢測自身程式,如果該程式下的包名有不同多個私有檔案目錄,則認為被多開

    public boolean checkByHasSameUid() {
        String filter = getUidStrFormat();//拿uid
        String result = CommandUtil.getSingleInstance().exec("ps");
        if (result == null || result.isEmpty()) return false;

        String[] lines = result.split("\n");
        if (lines == null || lines.length <= 0) return false;

        int exitDirCount = 0;
        for (int i = 0; i < lines.length; i++) {
            if (lines[i].contains(filter)) {
                int pkgStartIndex = lines[i].lastIndexOf(" ");
                String processName = lines[i].substring(pkgStartIndex <= 0
                        ? 0 : pkgStartIndex + 1, lines[i].length());
                File dataFile = new File(String.format("/data/data/%s", processName, Locale.CHINA));
                if (dataFile.exists()) {
                    exitDirCount++;
                }
            }
        }

        return exitDirCount > 1;
    }
複製程式碼

5.埠檢測

前4種方案,有一種直接對抗的意思,不希望我們的app執行在多開軟體中,第5種方案,我們不直接對抗,只要不是在同一機器上同時執行同一app,我們都認為該app沒有被多開。 假如同時執行著兩個app(無論先開始執行),兩個app進行一個通訊,如果通訊成功,我們則認為其中有一個是克隆體。

        //遍歷查詢已開啟的埠
        String tcp6 = CommandUtil.getSingleInstance().exec("cat /proc/net/tcp6");
        if (TextUtils.isEmpty(tcp6)) return;
        String[] lines = tcp6.split("\n");
        ArrayList<Integer> portList = new ArrayList<>();
        for (int i = 0, len = lines.length; i < len; i++) {
            int localHost = lines[i].indexOf("0100007F:");//127.0.0.1:
            if (localHost < 0) continue;
            String singlePort = lines[i].substring(localHost + 9, localHost + 13);
            Integer port = Integer.parseInt(singlePort, 16);
            portList.add(port);
        }
複製程式碼

對每個埠開啟執行緒嘗試連線,並且傳送一段自定義的訊息,作為鑰匙,這裡一般傳送包名就行(剛好多開軟體會把包名處理)

            Socket socket = new Socket("127.0.0.1", port);
            socket.setSoTimeout(2000);
            OutputStream outputStream = socket.getOutputStream();
            outputStream.write((secret + "\n").getBytes("utf-8"));
            outputStream.flush();
            socket.shutdownOutput();
複製程式碼

之後自己再開啟埠監聽作為伺服器,等待連線,如果被連線上之後且訊息匹配,則認為有一個克隆體在同時執行。

private void startServer(String secret) {
        Random random = new Random();
        ServerSocket serverSocket = null;
        try {
            serverSocket = new ServerSocket();
            serverSocket.bind(new InetSocketAddress("127.0.0.1",
                    random.nextInt(55534) + 10000));
            while (true) {
                Socket socket = serverSocket.accept();
                ReadThread readThread = new ReadThread(secret, socket);
                readThread.start();
//                serverSocket.close();
            }
        } catch (BindException e) {
            startServer(secret);//may be loop forever
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
複製程式碼

*因為埠通訊需要Internet許可權,本庫不會通過網路上傳任何隱私


反除錯方案

我們不希望自己的app被反編譯/動態除錯,那首先應該瞭解如何反編譯/動態除錯,此處可以參考我的《動態除錯筆記--除錯smali》 www.jianshu.com/p/90f495191…

然後從除錯的步驟來分析學習檢測。

1.修改清單更改apk版本為debug版,我們發出去的包為release包,進行除錯的話,要求為debug版(如果是已root的機器則沒有這個要求),所以首先可檢查當前版本是否為debug,或者簽名資訊有沒有被更改。

public boolean checkIsDebugVersion(Context context) {
        return (context.getApplicationInfo().flags
                & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
    }
複製程式碼

該方法提供了C++實現,見 https://github.com/lamster2018/learnNDK/blob/master/app/src/main/jni/ctest.cpp的checkDebug方法

2.等待偵錯程式附加,直接用api檢查debugger是否被附加

public boolean checkIsDebuggerConnected() {
        return android.os.Debug.isDebuggerConnected();
    }
複製程式碼

實測效果,可以結合電量變化的廣播監聽來做usb插拔監聽,如果是usb充電,此時來檢查debugger是否被插入,但是debugger attach到app需要一定時間,所以並不是實時的,還有我們常用的waiting for attach,建議監聽到usb插上,開啟一個子執行緒輪訓檢查,30s後關閉這個子執行緒。

    //檢查usb充電狀態
    public boolean checkIsUsbCharging(Context context) {
        IntentFilter filter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
        Intent batteryStatus = context.registerReceiver(null, filter);
        if (batteryStatus == null) return false;
        int chargePlug = batteryStatus.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1);
        return chargePlug == BatteryManager.BATTERY_PLUGGED_USB;
    }
複製程式碼

3.檢查埠占用

public boolean isPortUsing(String host, int port) throws UnknownHostException {
        boolean flag = false;
        InetAddress theAddress = InetAddress.getByName(host);
        try {
            Socket socket = new Socket(theAddress, port);
            flag = true;
        } catch (IOException e) {
        }
        return flag;
    }
複製程式碼

4.當app被除錯的時候,程式中會有traceid被記錄,該原理可參考 《jni動態註冊/輪詢traceid/反除錯學習筆記》 www.jianshu.com/p/082456acf…

檢查traceid提供java和c++實現 原理都是輪詢讀取/proc/Pid/status的TracerPid值 當debugger attach到app時,tracerId不為0,如ida附加除錯時,tracerId為23946. *測試機華為P9,會自己給自己附加一個tracer,該值小於1000

鑑於篇幅,此處不貼c++程式碼。 _EasyProtectorLib.checkIsBeingTracedByC()_使用c++方案

public boolean readProcStatus() {
        try {
            BufferedReader localBufferedReader =
                    new BufferedReader(new FileReader("/proc/" + Process.myPid() + "/status"));
            String tracerPid = "";
            for (; ; ) {
                String str = localBufferedReader.readLine();
                if (str.contains("TracerPid")) {
                    tracerPid = str.substring(str.indexOf(":") + 1, str.length()).trim();
                    break;
                }
                if (str == null) {
                    break;
                }
            }
            localBufferedReader.close();
            if ("0".equals(tracerPid)) return false;
            else return true;
        } catch (Exception fuck) {
            return false;
        }
    }
複製程式碼

模擬器檢測

具體研究單獨成文見《一行程式碼幫你檢測Android模擬器》 www.jianshu.com/p/434b3075b…

現在的模擬器基本可以做到模擬手機號碼,手機品牌,cpu資訊等,常規的java方案也可能被hook掉,比如逍遙模擬器讀取ro.product.board進行了處理,能得到設定的cpu資訊。

在研究各個模擬器的過程中,尤其是在研究build.prop檔案時,發現各個模擬器的處理方式不一樣,比如以下但不限於 1.基帶資訊幾乎沒有; 2.處理器資訊ro.product.board和ro.board.platform異常; 3.部分模擬器在讀控制組資訊時讀取不到; 4.連上wifi但會出現 Link encap:UNSPEC未指定網路卡型別的情況

結合以上資訊,綜合判斷是否執行在模擬器中。

_EasyProtectorLib.checkIsRunningInEmulator()_的程式碼實現如下

    public boolean readSysProperty() {
        int suspectCount = 0;
        //讀基帶資訊
        String basebandVersion = CommandUtil.getSingleInstance().getProperty("gsm.version.baseband");
        if (TextUtils.isEmpty(baseBandVersion))
           ++suspectCount;
        //讀渠道資訊,針對一些基於vbox的模擬器
        String buildFlavor = CommandUtil.getSingleInstance().getProperty("ro.build.flavor");
        if (TextUtils.isEmpty(buildFlavor) | (buildFlavor != null && buildFlavor.contains("vbox")))
            ++suspectCount;
        //讀處理器資訊,這裡經常會被處理
        String productBoard = CommandUtil.getSingleInstance().getProperty("ro.product.board");
        if (TextUtils.isEmpty(productBoard) | (productBoard != null && productBoard.contains("android")))
            ++suspectCount;
        //讀處理器平臺,這裡不常會處理
        String boardPlatform = CommandUtil.getSingleInstance().getProperty("ro.board.platform");
        if (TextUtils.isEmpty(boardPlatform) | (boardPlatform != null && boardPlatform.contains("android"))) 
            ++suspectCount;
        //高通的cpu兩者資訊一般是一致的
       if (!TextUtils.isEmpty(productBoard)
                && !TextUtils.isEmpty(boardPlatform)
                && !productBoard.equals(boardPlatform))
            ++suspectCount;
        //一些模擬器讀取不到程式租資訊
        String filter = CommandUtil.getSingleInstance().exec("cat /proc/self/cgroup");
        if (filter == null || filter.length() == 0) ++suspectCount;

        return suspectCount > 2;
    }
複製程式碼

以下是測試情況*

機器/測試方案 基帶資訊 渠道資訊 處理器資訊 程式組 檢測結果
AS自帶模擬器 O O O X 模擬器
Genymotion2.12.1 O O O X 模擬器
逍遙模擬器5.3.2 O X X O 模擬器
Appetize O X O X 模擬器
夜神模擬器6.1.1 O O O O 模擬器
騰訊手遊助手2.0.5 O O O X 模擬器
雷電模擬器3.27 O X X X 模擬器
一加5T X X X X 真機
華為P9 X X O X 真機

*O代表該方案檢測為模擬器,X代表檢測正常;

*Xamarin/Manymo因為網路原因暫未進行測試;

*因安卓機型太廣,真機覆蓋測試不完全,有空大家去git提issue

TODO

1.Accessibility檢查(反自動搶紅包/接單);

2.模擬器的光感,陀螺儀檢測;

3.檢測到模擬器/多開應該給回撥給開發者自行處理,而不是直接FC;--v1.0.4 support

4.埠法檢測多開應該可以利用ContentProvider做到;

相關文章