Android安全防护/检查root/检查Xposed/反调试/
转载请注明出处,转载时请不要抹去原始链接。
代码已上传git
https://github.com/lamster2018/EasyProtector
文章目录
- 食用方法
- root权限检查
- Xposed框架检查
- 应用多开检查
- 反调试方案
- 模拟器检测
- TODO
使用方法
implementation 'com.lahm.library:easy-protector-release:latest.release'
https://github.com/lamster2018/EasyProtector
![](https://img.haomeiwen.com/i2554175/7ee67add271a2035.png)
root权限检查
开发者会使用诸如xposed,cydiasubstrate的框架进行hook操作,前提是拥有root权限。
关于root的原理,请参考《Android Root原理分析及防Root新思路》
https://blog.csdn.net/hsluoyc/article/details/50560782
简单来说就是去拿『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》
https://www.jianshu.com/p/f9f39704e30c
通过检查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方案学习笔记》
https://www.jianshu.com/p/ee0062468251
所有的方案回归到一点:判断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;
}
多开软件检测
多开软件的检验原理和方案来自,这里我只做了整合和测试。
《Android多开/分身检测》
https://blog.darkness463.top/2018/05/04/Android-Virtual-Check/
《Android虚拟机多开检测》
https://www.jianshu.com/p/216d65d9971e
测试机器/多开软件* | 多开分身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的问题
1.文件路径检测
![](https://img.haomeiwen.com/i2554175/d54c9e5f5fba518f.png)
public boolean checkByPrivateFilePath(Context context) {
String path = context.getFilesDir().getPath();
for (String virtualPkg : virtualPkgs) {
if (path.contains(virtualPkg)) return true;
}
return false;
}
2.应用列表检测
![](https://img.haomeiwen.com/i2554175/f53ac565a296c42d.png)
简单来说,多开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检测
![](https://img.haomeiwen.com/i2554175/cf0005968d448331.png)
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检测
![](https://img.haomeiwen.com/i2554175/86078e4a840b4ddb.png)
简单来说,检测自身进程,如果该进程下的包名有不同多个私有文件目录,则认为被多开
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;
}
反调试方案
我们不希望自己的app被反编译/动态调试,那首先应该了解如何反编译/动态调试,此处可以参考我的《动态调试笔记--调试smali》
https://www.jianshu.com/p/90f495191a6a
然后从调试的步骤来分析学习检测。
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/反调试学习笔记》
https://www.jianshu.com/p/082456acf89c
检查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模拟器》
https://www.jianshu.com/p/434b3075b5dd
现在的模拟器基本可以做到模拟手机号码,手机品牌,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 (basebandVersion == null | "".equals(basebandVersion)) ++suspectCount;
//读渠道信息,针对一些基于vbox的模拟器
String buildFlavor = CommandUtil.getSingleInstance().getProperty("ro.build.flavor");
if (buildFlavor == null | "".equals(buildFlavor) | (buildFlavor != null && buildFlavor.contains("vbox")))
++suspectCount;
//读处理器信息,这里经常会被处理
String productBoard = CommandUtil.getSingleInstance().getProperty("ro.product.board");
if (productBoard == null | "".equals(productBoard)) ++suspectCount;
//读处理器平台,这里不常会处理
String boardPlatform = CommandUtil.getSingleInstance().getProperty("ro.board.platform");
if (boardPlatform == null | "".equals(boardPlatform)) ++suspectCount;
//高通的cpu两者信息一般是一致的
if (productBoard != null && boardPlatform != null && !productBoard.equals(boardPlatform))
++suspectCount;
//一些模拟器读取不到进程租信息
String filter = CommandUtil.getSingleInstance().exec("cat /proc/self/cgroup");
if (filter == null || filter.length() == 0) ++suspectCount;
return suspectCount > 2;
}
以下是测试情况*
测试方案/模拟器 | AS自带模拟器 | Genymotion2.12.1 | 逍遥模拟器5.3.2 | Appetize | 夜神模拟器6.1.1 |
---|---|---|---|---|---|
基带信息 | O | O | O | O | O |
渠道信息 | O | O | X | X | O |
处理器信息 | O | O | X | O | O |
进程组 | X | X | O | X | O |
检测结果 | 模拟器 | 模拟器 | 模拟器 | 模拟器 | 模拟器 |
*O代表该方案检测为模拟器,X代表检测不到
*Xamarin/Manymo因为网络原因暂未进行测试
TODO
Accessibility检查(反自动抢红包/接单)