美文网首页Android开发Android安卓面试宝典
一行代码帮你检测Android多开软件

一行代码帮你检测Android多开软件

作者: 普通的程序员 | 来源:发表于2018-06-29 01:21 被阅读721次
    声明:本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

    目录

    • 简介
    • 借鉴方案&测试结果
    • 端口法检测思路
    • 实现方案
    • 测试结果
    • Demo地址
    • 答疑见评论区

    简介

    最近有业务上的要求,要求app在本地进行诸如软件多开、hook框架、模拟器等安全检测,防止作弊行为。

    防作弊一直是老生常谈的问题,而软件多开检测往往是防作弊中的重要一环,在查找资料的过程中发现多开软件公司对防多开手段进行了针对性的升级,即使非常新的资料也无法做到通杀。

    所以站在前人的肩膀上,继续研究。

    借鉴方案

    借鉴方案来自以下两个帖子

    《Android多开/分身检测》https://blog.darkness463.top/2018/05/04/Android-Virtual-Check/

    《Android虚拟机多开检测》https://www.jianshu.com/p/216d65d9971e

    文中的方案简单总结起来是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在部分机器上用不了;
    http://410063005.iteye.com/blog/1923543

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


    一加5T没有busybox工具

    3.netstat的输出从源码上看,实际是纯打印;
    https://blog.csdn.net/earbao/article/details/32191607

    既然有这些坑,干脆直接手动处理,因为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地址: https://github.com/lamster2018/EasyProtector

    中文文档见:https://www.jianshu.com/p/c37b1bdb4757

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

    Todo

    1.检测到多开应该提供回调给开发者自行处理;--v1.0.4 support
    2.同样的思路,利用ContentProvider也应该可以完成

    *感谢同事大龙提供的思路

    相关文章

      网友评论

      • ed46de012787:请教一下大神,看了代码后不太明白为什么他自己发送的密文,自己收不到呢?(只有一个APP的情况,没有被多开)
        普通的程序员:@红尘梦客 这是代码执行顺序的原因。
        操作A 遍历已开启的端口并尝试密文配对
        操作B 开启端口并监听

        操作A只执行一次,操作B是始终监听,且A在B前执行。
        如果改成B在A前执行,那么就接收到了啊。
        或者你改成A操作不断遍历扫描,等B操作执行完,同样可以接收到。
      • _非_阳_:对linux不熟悉,所以开发中遇到这种问题一筹莫展,看到楼主的文章,顿时心生膜拜,32个👍送给你
        普通的程序员:@_非_阳_ 共同学习,一起进步:+1:
      • 3eff7aa64beb:这些方法最好使用C/C++来实现,不然容易被hook
      • 3eff7aa64beb:我有个想法,其实可以结合检测“多开环境”的方案来达到精准杀死克隆app的目的
        普通的程序员:@amlloc 怎么说?可以交流一下思路
      • 普通的程序员:针对公众号底下的疑问
        1.防多开应该杀死的是克隆体----这是防止狭义多开,这个方案不讨论这一方向;
        2.多开拿优惠从来不会同时开两个----这个方案确实局限在必须要同时存在两个“自己”的场景才有效,但是面对类似一机多开账号刷评论这种黑产场景,很有效,保证了一机在同一时间只能运行一个自己;
        3.双开助手是可以多开的吧?----该方案不直接影响多开软件的运行,但是能防止广义多开;
      • 没那么简单_7987:楼主有没有考虑 检测机器的光感,近距离感应器,以及他们感应值的变化来判断是真机还是模拟器,我这边试了夜神和逍遥发现都没有光感,近距离感应器,真机就有,还有就是检测螺旋仪的数值变化,真机的一直在变,虚拟机有可能没有螺旋仪,有的话也是不变的
        没那么简单_7987:@普通的程序员 嗯 我这边已经FORK 了
        普通的程序员:@没那么简单_7987 我觉得可以安排一下,你要不要考虑fork一下?
      • IT_Jason:逻辑严谨,带着问题寻找答案,看来这个程序员不普通。

      本文标题:一行代码帮你检测Android多开软件

      本文链接:https://www.haomeiwen.com/subject/tokgyftx.html