一行代码帮你检测Android多开软件(更新至1.1.0)

声明:本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

目录

  • 简介
  • 借鉴方案&测试结果
  • 端口法检测思路
  • 实现方案(1)
  • 实现方案(2)
  • 测试结果
  • 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)

思路有了,接下来就是实现,完整代码地址见文章底部。

第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权限,本库不会通过网络上传任何隐私
*感谢同事https://github.com/yulong提供的帮助

实现方案(2)

基本思路与前面一致,但是会更简单
借助LocalServerSocket的构造方法完成端口占用

/**
     * Creates a new server socket listening at specified name.
     * On the Android platform, the name is created in the Linux
     * abstract namespace (instead of on the filesystem).
     * 
     * @param name address for socket
     * @throws IOException
     */
    public LocalServerSocket(String name) throws IOException {
        impl = new LocalSocketImpl();
        impl.create(LocalSocket.SOCKET_STREAM);
        localAddress = new LocalSocketAddress(name);
        impl.bind(localAddress);
        impl.listen(LISTEN_BACKLOG);
    }

    /**
     * Create a LocalServerSocket from a file descriptor that's already
     * been created and bound. listen() will be called immediately on it.
     * Used for cases where file descriptors are passed in via environment
     * variables
     *
     * @param fd bound file descriptor
     * @throws IOException
     */
    public LocalServerSocket(FileDescriptor fd) throws IOException {
        impl = new LocalSocketImpl(fd);
        impl.listen(LISTEN_BACKLOG);
        localAddress = impl.getSockAddress();
    }

*方案来自https://github.com/lamster2018/EasyProtector/issues/25
https://github.com/wangkunlin『AMS 会用 LocalSocket 和 zygote 通信来 fork 新的子进程』

附app启动的部分代码调用栈(源码来自API 28)
···
Process::start
ZygoteProcess::start
ZygoteProcess::startViaZygote
ZygoteProcess::openZygoteSocketIfNeeded
--ZygoteState::connect(ZygoteState是ZygoteProcess的静态内部类)
--connect方法通过LocalSocket完成通信,Zygote孵化一个新的进程
ZygoteProcess::zygoteSendArgsAndGetResult(处理新进程)

通信的接收方见zygoteSendArgsAndGetResult方法内注释 
SystemZygoteInit.readArgumentList()--可是我没找到这个方法,有谁找到了告知一下,我找的是API 26的ZygoteInit类

ZygoteInit::main
--zygoteServer::registerServerSocketFromEnv
--内部创建了一个LocalServerSokcet
···

测试结果

以之前提到的那些机型和多开软件做测试样本,目前测试效果基本做到通杀。
因安卓机型太广,真机覆盖测试不完全,有空大家去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也应该可以完成

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,100评论 18 139
  • 先说一下这篇文章里面的内容:TCP 客户端, 自定义对话框, 自定义按钮, ProgressBar竖直显示, 重力...
    杨奉武阅读 3,158评论 0 3
  • 冬日十时的阳光透过窗吻在身上,暖意渐起。合上书,走在阳光里,这种时候适合去挑泉水,天赐自然甘甜的泉水配上佳茗,韵味...
    春城一粟阅读 457评论 5 12
  • 前言:一个傻叉的YY,不喜就喷,爱喷不喷,我不关心! “放学后,宝宝进入卧室,突然感叹道:漂亮死我了。 妈妈问:什...
    小高加油阅读 878评论 15 12