轉載請註明出處,轉載時請不要抹去原始連結。
程式碼已上傳git,歡迎star/fork/issue
https://github.com/lamster2018/EasyProtector
複製程式碼
文章目錄
- 食用方法
- root許可權檢查
- Xposed框架檢查
- 應用多開檢查
- 反除錯方案
- 模擬器檢測
- TODO
使用方法
implementation 'com.lahm.library:easy-protector-release:latest.release'
https://github.com/lamster2018/EasyProtector
root許可權檢查
開發者會使用諸如xposed,cydiasubstrate的框架進行hook操作,前提是擁有root許可權。
關於root的原理,請參考《Android Root原理分析及防Root新思路》 blog.csdn.net/hsluoyc/art…
簡單來說就是去拿『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》 www.jianshu.com/p/f9f39704e…
通過檢查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方案學習筆記》 www.jianshu.com/p/ee0062468…
所有的方案迴歸到一點:判斷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;
}
複製程式碼
多開軟體檢測
多開軟體的檢測方案這裡提供5種,首先4種來自 《Android多開/分身檢測》 blog.darkness463.top/2018/05/04/…
《Android虛擬機器多開檢測》
這裡提供程式碼整理,一鍵呼叫,
VirtualApkCheckUtil.getSingleInstance().checkByPrivateFilePath(this);
VirtualApkCheckUtil.getSingleInstance().checkByOriginApkPackageName(this);
VirtualApkCheckUtil.getSingleInstance().checkByHasSameUid();
VirtualApkCheckUtil.getSingleInstance().checkByMultiApkPackageName();
VirtualApkCheckUtil.getSingleInstance().checkByPortListening(getPackageName(),callback);
複製程式碼
第5種來自我同事的啟發,目前最好用的就是這種,我起名叫埠檢測法,具體思路已經單獨成文見 《一行程式碼幫你檢測Android多開軟體》 www.jianshu.com/p/65c841749…
測試情況
測試機器/多開軟體* | 多開分身6.9 | 平行空間4.0.8389 | 雙開助手3.8.4 | 分身大師2.5.1 | VirtualXP0.11.2 | Virtual App * |
---|---|---|---|---|---|---|
紅米3S/Android6.0/原生eng | XXXOO | OXOOO | OXOOO | XOOOO | XXXOO | XXXOO |
華為P9/Android7.0/EUI 5.0 root | XXXXO | OXOXO | OXOXO | XOOXO | XXXXO | XXXOO |
小米MIX2/Android8.0/MIUI穩定版9.5 | XXXXO | OXOXO | OXOXO | XOOXO | XXXXO | XXXOO |
一加5T/Android8.1/氫OS 5.1 穩定版 | XXXXO | OXOXO | OXOXO | XOOXO | XXXXO | XXXOO |
*測試方案順序如下12345,測試結果X代表未能檢測O成功檢測多開
*virtual app測試版本是git開源版,商用版已經修復uid的問題
1.檔案路徑檢測
public boolean checkByPrivateFilePath(Context context) {
String path = context.getFilesDir().getPath();
for (String virtualPkg : virtualPkgs) {
if (path.contains(virtualPkg)) return true;
}
return false;
}
複製程式碼
2.應用列表檢測
簡單來說,多開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檢測
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檢測
簡單來說,檢測自身程式,如果該程式下的包名有不同多個私有檔案目錄,則認為被多開 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;
}
複製程式碼
5.埠檢測
前4種方案,有一種直接對抗的意思,不希望我們的app執行在多開軟體中,第5種方案,我們不直接對抗,只要不是在同一機器上同時執行同一app,我們都認為該app沒有被多開。 假如同時執行著兩個app(無論先開始執行),兩個app進行一個通訊,如果通訊成功,我們則認為其中有一個是克隆體。
//遍歷查詢已開啟的埠
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);
portList.add(port);
}
複製程式碼
對每個埠開啟執行緒嘗試連線,並且傳送一段自定義的訊息,作為鑰匙,這裡一般傳送包名就行(剛好多開軟體會把包名處理)
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();
複製程式碼
之後自己再開啟埠監聽作為伺服器,等待連線,如果被連線上之後且訊息匹配,則認為有一個克隆體在同時執行。
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));
while (true) {
Socket socket = serverSocket.accept();
ReadThread readThread = new ReadThread(secret, socket);
readThread.start();
// serverSocket.close();
}
} catch (BindException e) {
startServer(secret);//may be loop forever
} catch (IOException e) {
e.printStackTrace();
}
}
複製程式碼
*因為埠通訊需要Internet許可權,本庫不會通過網路上傳任何隱私
反除錯方案
我們不希望自己的app被反編譯/動態除錯,那首先應該瞭解如何反編譯/動態除錯,此處可以參考我的《動態除錯筆記--除錯smali》 www.jianshu.com/p/90f495191…
然後從除錯的步驟來分析學習檢測。
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/反除錯學習筆記》 www.jianshu.com/p/082456acf…
檢查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模擬器》 www.jianshu.com/p/434b3075b…
現在的模擬器基本可以做到模擬手機號碼,手機品牌,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 (TextUtils.isEmpty(baseBandVersion))
++suspectCount;
//讀渠道資訊,針對一些基於vbox的模擬器
String buildFlavor = CommandUtil.getSingleInstance().getProperty("ro.build.flavor");
if (TextUtils.isEmpty(buildFlavor) | (buildFlavor != null && buildFlavor.contains("vbox")))
++suspectCount;
//讀處理器資訊,這裡經常會被處理
String productBoard = CommandUtil.getSingleInstance().getProperty("ro.product.board");
if (TextUtils.isEmpty(productBoard) | (productBoard != null && productBoard.contains("android")))
++suspectCount;
//讀處理器平臺,這裡不常會處理
String boardPlatform = CommandUtil.getSingleInstance().getProperty("ro.board.platform");
if (TextUtils.isEmpty(boardPlatform) | (boardPlatform != null && boardPlatform.contains("android")))
++suspectCount;
//高通的cpu兩者資訊一般是一致的
if (!TextUtils.isEmpty(productBoard)
&& !TextUtils.isEmpty(boardPlatform)
&& !productBoard.equals(boardPlatform))
++suspectCount;
//一些模擬器讀取不到程式租資訊
String filter = CommandUtil.getSingleInstance().exec("cat /proc/self/cgroup");
if (filter == null || filter.length() == 0) ++suspectCount;
return suspectCount > 2;
}
複製程式碼
以下是測試情況*
機器/測試方案 | 基帶資訊 | 渠道資訊 | 處理器資訊 | 程式組 | 檢測結果 |
---|---|---|---|---|---|
AS自帶模擬器 | O | O | O | X | 模擬器 |
Genymotion2.12.1 | O | O | O | X | 模擬器 |
逍遙模擬器5.3.2 | O | X | X | O | 模擬器 |
Appetize | O | X | O | X | 模擬器 |
夜神模擬器6.1.1 | O | O | O | O | 模擬器 |
騰訊手遊助手2.0.5 | O | O | O | X | 模擬器 |
雷電模擬器3.27 | O | X | X | X | 模擬器 |
一加5T | X | X | X | X | 真機 |
華為P9 | X | X | O | X | 真機 |
*O代表該方案檢測為模擬器,X代表檢測正常;
*Xamarin/Manymo因為網路原因暫未進行測試;
*因安卓機型太廣,真機覆蓋測試不完全,有空大家去git提issue
TODO
1.Accessibility檢查(反自動搶紅包/接單);
2.模擬器的光感,陀螺儀檢測;
3.檢測到模擬器/多開應該給回撥給開發者自行處理,而不是直接FC;--v1.0.4 support
4.埠法檢測多開應該可以利用ContentProvider做到;