声明:本篇文章已授权微信公众号 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也应该可以完成
*感谢同事大龙提供的思路
网友评论
操作A 遍历已开启的端口并尝试密文配对
操作B 开启端口并监听
操作A只执行一次,操作B是始终监听,且A在B前执行。
如果改成B在A前执行,那么就接收到了啊。
或者你改成A操作不断遍历扫描,等B操作执行完,同样可以接收到。
1.防多开应该杀死的是克隆体----这是防止狭义多开,这个方案不讨论这一方向;
2.多开拿优惠从来不会同时开两个----这个方案确实局限在必须要同时存在两个“自己”的场景才有效,但是面对类似一机多开账号刷评论这种黑产场景,很有效,保证了一机在同一时间只能运行一个自己;
3.双开助手是可以多开的吧?----该方案不直接影响多开软件的运行,但是能防止广义多开;