一行程式碼幫你檢測Android多開軟體

lamster2018發表於2018-07-13

宣告:本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家釋出

目錄

  • 簡介
  • 借鑑方案&測試結果
  • 埠法檢測思路
  • 實現方案
  • 測試結果
  • Demo地址

簡介

最近有業務上的要求,要求app在本地進行諸如軟體多開、hook框架、模擬器等安全檢測,防止作弊行為。

防作弊一直是老生常談的問題,而軟體多開檢測往往是防作弊中的重要一環,在查詢資料的過程中發現多開軟體公司對防多開手段進行了針對性的升級,即使非常新的資料也無法做到通殺。

所以站在前人的肩膀上,繼續研究。

借鑑方案

借鑑方案來自以下兩個帖子

《Android多開/分身檢測》blog.darkness463.top/2018/05/04/…/

《Android虛擬機器多開檢測》www.jianshu.com/p/216d65d99…

文中的方案簡單總結起來是4點 1.私有檔案路徑檢測; 2.應用列表檢測; 3.maps檢測; 4.ps檢測;

程式碼此處不貼了,這四種方案測試結果如下

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

*測試方案順序1234,測試結果X代表未能檢測O成功檢測多開; *virtual app測試版本是git開源版,商用版已經修復uid的問題;

可以看到的是,檢測效果不是很理想,沒有哪一種方法可以做到通殺市面排名靠前的這些多開軟體,甚至在高版本機器上,多開軟體完美避開了檢測。

埠監聽法思路

為了避免歧義,我們接下來所說的app都是指的同一款軟體,並定義普通執行的app叫做本體,執行在多開軟體上的app叫克隆體。並提出以下兩個概念

狹義多開:只要app是通過多開軟體開啟的,則認為多開,即使同一時間內只執行了一個app

廣義多開:無論app是否執行在多開軟體上,只要app在執行期間,有其餘的『自己』在執行,則認為多開 (有點《第六日》的意思,克隆人以為自己是真人,發現跟自己一模一樣的人,都認為對方是克隆人)

我們前面所借鑑的四種方案,都是去針對狹義多開進行檢測,通過判斷執行在多開軟體時的特徵進行反制,多開軟體也會針對這些檢測方案進行研究,提出相應措施。

那麼我們退一步,順著檢測廣義多開的方向進行思考,我們允許app執行在多開軟體上,但是在一臺機器上同一時間有且只能執行一個app(無論本體or克隆體),只要app能發現有一個同樣的自己,然後幹掉對方或自殺,就達到防止廣義多開的目的。

那麼我們怎樣讓這兩個app見面呢?

微信同一賬號不能同時登入在不同的手機上,靠的是網路請求,限定登入裝置。

常見的通訊方式

那在本地如何處理這種情況呢?是不是也可以靠網路通訊的方式完成見面? 答案當然是肯定的啊,不然我寫這篇幹嘛,利用socket,自己既當客戶端又當服務端就能完成我們的需求。

自己做服務端又做客戶端

1.app執行後,先做傳送端,在合適的時候去連線本地埠併傳送一段密文訊息,如果有埠連線且密文匹配,則認為之前已經有app在執行了(廣義多開),接收端進行處理; 2.app再成為接收端,接收可能到來連線; 3.後續若有app啟動(無論本體or克隆體),則重複1&2步驟,達到『同一時間只有一個app在執行』的目的,解決廣義多開的問題。

實現方案

思路有了,接下來就是實現,完整程式碼地址見文章底部。

第1步:掃描本地埠

想當然利用netstat指令來掃描已經開啟的本地埠

netstat指令

但是這個方法有3個坑 1.netstat在部分機器上用不了; 410063005.iteye.com/blog/192354…

2.busybox 在部分機器用不了;

一加5T沒有busybox工具

3.netstat的輸出從原始碼上看,實際是純列印; blog.csdn.net/earbao/arti…

既然有這些坑,乾脆直接手動處理,因為netstat的本質上還是去讀取/proc/net/tcp等檔案再格式化處理,tcp檔案格式也是很標準化的,通過研究原始碼,找出埠之間的關係。 0100007F:8CA7 其實就是 127.0.0.1:36007

/proc/net/tcp6檔案

最終掃描tcp檔案並格式化埠的關鍵程式碼

 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);
       //16進位制轉成10進位制
       portList.add(port);
     }
複製程式碼
第2步:發起連線請求

接下來向每個埠都發起一個執行緒進行連線,併傳送自定義訊息,該段訊息用app的包名就行了(多開軟體很大程度會hook getPackageName方法,乾脆就順著多開軟體做)

try {
       //發起連線,併傳送訊息
       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();
       //獲取輸入流,這裡沒做處理,純列印
       InputStream inputStream = socket.getInputStream();
       BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
       String info = null;
       while ((info = bufferedReader.readLine()) != null) {
         Log.i(TAG, "ClientThread: " + info);
       }
​
       bufferedReader.close();
       inputStream.close();
       socket.close();
     } catch (ConnectException e) {
       Log.i(TAG, port + "port refused");
 }
複製程式碼

主動連線的過程完成,先於自己啟動的app(可能是本體or克隆體)接收到訊息並進行處理。

第3步:成為接收端,等待連線

接下來就是成為接收端,監聽某埠,等待可能到來的app連線(可能是本體or克隆體)。

  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));
      //開一個10000~65535之間的埠
       while (true) {
         Socket socket = serverSocket.accept();
         ReadThread readThread = new ReadThread(secret, socket);
        //假如這個方案很多app都在用,還是每個連線都開執行緒處理一些
         readThread.start();
//                serverSocket.close();
         }
     } catch (BindException e) {
       startServer(secret);//may be loop forever
     } catch (IOException e) {
       e.printStackTrace();
     }
 }
複製程式碼

開啟埠時為了避免開一個已經開啟的埠,主動捕獲BindExecption,並迭代呼叫,可能會因此無限迴圈,如果怕死迴圈的話,可以加一個類似ConcurrentHashMap最壞嘗試次數的計數值。不過實際測試沒那麼衰,隨機埠範圍10000~65535,最多嘗試兩次就好了。

每一個處理執行緒,做的事情就是匹配密文,對應上了就是某個克隆體or本體傳送的密文,這裡是接收端主動執行一個空指標異常,殺死自己。處理方式有點像《三體》的黑暗森林法則,誰先暴露誰先死。

private class ReadThread extends Thread {
       private ReadThread(String secret, Socket socket) {
       InputStream inputStream = null;
       try {
         inputStream = socket.getInputStream();
         byte buffer[] = new byte[1024 * 4];
         int temp = 0;
         while ((temp = inputStream.read(buffer)) != -1) {
         String result = new String(buffer, 0, temp);
         if (result.contains(secret)) {
                   checkCallback.findSuspect();//提供回撥,開發者自行處理
                   checkCallback = null;
               }
           }
       inputStream.close();
       socket.close();
       } catch (IOException e) {
       e.printStackTrace();
      }
     }
 }
複製程式碼

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

測試結果

以之前提到的那些機型和多開軟體做測試樣本,目前測試效果基本做到通殺。 因安卓機型太廣,真機覆蓋測試不完全,有空大家去git提issue;

在application的mainProcess裡呼叫一次即可。 模擬器因為會搶localhost,demo裡做了模擬器判斷。

Demo地址

本文方案已經整合到EasyProtectorLib

github地址: github.com/lamster2018…

中文文件見:www.jianshu.com/p/c37b1bdb4…

使用方法 VirtualApkCheckUtil.getSingleInstance().checkByPortListening(String secret);

Todo

1.檢測到多開應該提供回撥給開發者自行處理;--v1.0.4 support

2.同樣的思路,利用ContentProvider也應該可以完成

*感謝同事大龍提供的思路

相關文章